From de295d5b51caaf91db2b1a9071952470423dc55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Livr=C3=A4do=20Sandoval?= <104039397+livrasand@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:05:09 -0700 Subject: [PATCH 1/7] Add Phase 2 remote bundle jobs (server + client) Introduce Phase 2 remote-job flow: server-side bundling and Openbin upload, plus client support to use those bundles with byte-range resume. Key changes: - internal/git: Add CreateBundle to clone by layers, complete history and produce a .bundle file. - internal/http: Add v2 /jobs endpoints and worker (presign PUT to Openbin, confirm) to create/store bundles (jobs2.go) and register routes (router.go). - internal/jobs: Add client-side bundle clone flow (runCloneBundle, download with Range, sha256 verification, materialize repo), fallback to layered clone if server lacks /v2/jobs; include tests for resume/hash behavior (clone_bundle.go, clone_bundle_test.go). - web/index.html: adjust layout / right sidebar and sponsor block. This enables creating bundles server-side, serving them via CDN (Openbin/Filebase), and robust client downloads with resume and integrity checks. --- internal/git/bundle.go | 114 ++++++++ internal/http/jobs2.go | 393 +++++++++++++++++++++++++++ internal/http/router.go | 10 + internal/jobs/clone.go | 23 +- internal/jobs/clone_bundle.go | 411 +++++++++++++++++++++++++++++ internal/jobs/clone_bundle_test.go | 276 +++++++++++++++++++ web/index.html | 67 +++-- 7 files changed, 1255 insertions(+), 39 deletions(-) create mode 100644 internal/git/bundle.go create mode 100644 internal/http/jobs2.go create mode 100644 internal/jobs/clone_bundle.go create mode 100644 internal/jobs/clone_bundle_test.go diff --git a/internal/git/bundle.go b/internal/git/bundle.go new file mode 100644 index 0000000..e01dbe1 --- /dev/null +++ b/internal/git/bundle.go @@ -0,0 +1,114 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// bundleChunkSize es el número de commits por bloque de la descarga por capas +// del servidor (mismo criterio que el cliente de Fase 1). +const bundleChunkSize = 500 + +// CreateBundle descarga el repositorio remoto por capas en workDir y crea un +// bundle completo (commits, árboles y blobs) que git puede clonar sin red. +// Devuelve la ruta del bundle y la rama por defecto del remoto. +func CreateBundle(url, workDir string) (bundlePath, defaultBranch string, err error) { + repoDir := filepath.Join(workDir, "repo") + if err := runGit("", "clone", "--mirror", "--depth="+strconv.Itoa(bundleChunkSize), url, repoDir); err != nil { + return "", "", fmt.Errorf("clonar %s: %w", url, err) + } + + // Profundizar por capas hasta cubrir la historia completa (patrón del + // cliente de Fase 1, sin filtro de blobs: el bundle final debe incluir + // todo el contenido para que el checkout del cliente funcione sin red). + block := 0 + for repoShallow(repoDir) { + block++ + prevShallow := shallowFile(repoDir) + prev := revCount(repoDir) + if err := runGit(repoDir, "fetch", "--deepen="+strconv.Itoa(bundleChunkSize), "origin"); err != nil { + return "", "", fmt.Errorf("profundizar repo (bloque %d): %w", block, err) + } + if !repoShallow(repoDir) { + break + } + // Los shallow points de ramas/tags cortas se resuelven sin añadir + // commits: si nada cambió, el servidor no profundiza más por capas. + if shallowFile(repoDir) == prevShallow && revCount(repoDir) == prev { + break + } + } + if repoShallow(repoDir) { + if err := runGit(repoDir, "fetch", "--unshallow", "origin"); err != nil { + return "", "", fmt.Errorf("completar historia: %w", err) + } + } + + // Rama por defecto: en un clon --mirror el HEAD local apunta a la ref remota. + branch, err := gitOutput(repoDir, "symbolic-ref", "--short", "HEAD") + if err != nil { + return "", "", fmt.Errorf("determinar rama por defecto: %w", err) + } + + bundlePath = filepath.Join(workDir, "repo.bundle") + if err := runGit(repoDir, "bundle", "create", bundlePath, "--all"); err != nil { + return "", "", fmt.Errorf("crear bundle: %w", err) + } + return bundlePath, strings.TrimSpace(branch), nil +} + +// shallowFile devuelve el contenido actual de .git/shallow, o vacío si el repo +// ya no es shallow. +func shallowFile(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, ".git", "shallow")) + if err != nil { + return "" + } + return string(data) +} + +// repoShallow indica si el repo sigue con historia parcial. +func repoShallow(dir string) bool { + out, err := gitOutput(dir, "rev-parse", "--is-shallow-repository") + return err == nil && strings.TrimSpace(out) == "true" +} + +// revCount cuenta los commits visibles en todas las refs del repo. +func revCount(dir string) int { + out, err := gitOutput(dir, "rev-list", "--count", "--all") + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(out)) + return n +} + +// runGit ejecuta git; si falla, el error incluye el mensaje real de stderr. +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%w: %s", err, msg) + } + } + return err +} + +// gitOutput ejecuta git y devuelve su salida combinada. +func gitOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return string(out), fmt.Errorf("%w: %s", err, msg) + } + } + return string(out), err +} diff --git a/internal/http/jobs2.go b/internal/http/jobs2.go new file mode 100644 index 0000000..3e45883 --- /dev/null +++ b/internal/http/jobs2.go @@ -0,0 +1,393 @@ +package http + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/livrasand/gitGost/internal/git" + + "github.com/gin-gonic/gin" +) + +// Fase 2 — jobs remotos de descarga con Range Requests server-side. +// +// El servidor clona el repositorio por capas, crea un bundle completo y lo +// sube a Openbin (presign + PUT directo a Filebase + confirm), que actúa como +// CDN con TTL. El cliente descarga el bundle por rangos de bytes directo de +// Filebase (resume a nivel byte) y materializa el repo con `git clone`. + +const ( + remoteJobsMax = 100 + remoteJobsTTL = 24 * time.Hour +) + +// Estados de un job remoto. +const ( + rjQueued = "queued" + rjRunning = "running" + rjReady = "ready" + rjFailed = "failed" +) + +// remoteJobResult es el artefacto final de un job remoto: un bundle en Openbin. +type remoteJobResult struct { + Slug string `json:"slug"` + Cid string `json:"cid"` + Size int64 `json:"size"` + Sha256 string `json:"sha256"` + DownloadURL string `json:"downloadUrl"` + DirectURL string `json:"directUrl"` + DefaultBranch string `json:"defaultBranch"` + Filename string `json:"filename"` + ExpiresAt string `json:"expiresAt"` +} + +// remoteJob es el estado de un trabajo de descarga en el servidor. El worker +// publica copias nuevas del struct (nunca muta un valor ya publicado), así los +// lectores del boundedMap siempre ven estados coherentes. +type remoteJob struct { + ID string + Status string + URL string + Progress string + Result *remoteJobResult + Error string + TmpDir string + Created time.Time +} + +// remoteJobs es la cola de trabajos remotos en memoria (TTL: los resultados +// dejan de estar disponibles pasadas 24 h). +var remoteJobs = newBoundedMap[*remoteJob](remoteJobsMax, remoteJobsTTL) + +// openbinClient permite subir bundles grandes sin el timeout corto del proxy. +var openbinClient = &http.Client{Timeout: 30 * time.Minute} + +// CreateRemoteJobHandler crea un trabajo de descarga y lo ejecuta en background. +func CreateRemoteJobHandler(c *gin.Context) { + var req struct { + URL string `json:"url"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cuerpo JSON inválido"}) + return + } + if !validRepoURL(req.URL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "URL de repositorio inválida"}) + return + } + + id := newRemoteJobID() + job := &remoteJob{ID: id, Status: rjQueued, URL: req.URL, Created: time.Now()} + remoteJobs.Set(id, job) + go runRemoteJob(job) + c.JSON(http.StatusOK, gin.H{"id": id, "status": rjQueued}) +} + +// GetRemoteJobHandler devuelve el estado actual de un trabajo remoto. +func GetRemoteJobHandler(c *gin.Context) { + id := c.Param("id") + job, ok := remoteJobs.Get(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "job no encontrado"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "id": job.ID, + "status": job.Status, + "progress": job.Progress, + "error": job.Error, + "result": job.Result, + }) +} + +// DeleteRemoteJobHandler elimina un trabajo remoto y limpia su directorio +// temporal. Si el worker aún corre, su publicación posterior simplemente +// reinserta un resultado que el TTL evictará. +func DeleteRemoteJobHandler(c *gin.Context) { + id := c.Param("id") + if job, ok := remoteJobs.Get(id); ok && job.TmpDir != "" { + _ = os.RemoveAll(job.TmpDir) + } + remoteJobs.Delete(id) + c.Status(http.StatusNoContent) +} + +// runRemoteJob ejecuta el trabajo en background: clona por capas, crea el +// bundle, lo sube a Openbin y publica el resultado. +func runRemoteJob(job *remoteJob) { + publish := func(status, progress, errMsg string, result *remoteJobResult) { + next := *job + next.Status = status + next.Progress = progress + next.Error = errMsg + next.Result = result + remoteJobs.Set(job.ID, &next) + } + publish(rjRunning, "Preparando descarga...", "", nil) + + dir, err := os.MkdirTemp("", "gitgost-bundle-") + if err != nil { + publish(rjFailed, "", fmt.Sprintf("crear directorio temporal: %v", err), nil) + return + } + defer os.RemoveAll(dir) + + jobCopy := *job + jobCopy.TmpDir = dir + remoteJobs.Set(job.ID, &jobCopy) + + publish(rjRunning, "Descargando repositorio por capas...", "", nil) + bundlePath, defaultBranch, err := git.CreateBundle(job.URL, dir) + if err != nil { + publish(rjFailed, "", err.Error(), nil) + return + } + + size, err := fileSize(bundlePath) + if err != nil { + publish(rjFailed, "", fmt.Sprintf("tamaño del bundle: %v", err), nil) + return + } + hash, err := sha256File(bundlePath) + if err != nil { + publish(rjFailed, "", fmt.Sprintf("hash del bundle: %v", err), nil) + return + } + + publish(rjRunning, "Subiendo bundle a Openbin...", "", nil) + result, err := openbinUpload(bundlePath, bundleFilename(job.URL)) + if err != nil { + publish(rjFailed, "", err.Error(), nil) + return + } + result.Size = size + result.Sha256 = hash + result.DefaultBranch = defaultBranch + + publish(rjReady, "Bundle listo", "", result) +} + +// openbinUpload sube un bundle a Openbin con el flujo presign: +// 1) POST /api/upload?mode=presign → URL firmada de Filebase + token +// 2) PUT del objeto directo a Filebase (streaming, sin pasar por Vercel) +// 3) POST /api/upload/confirm → CID + bin con expiración +func openbinUpload(bundlePath, filename string) (*remoteJobResult, error) { + base := strings.TrimRight(os.Getenv("OPENBIN_URL"), "/") + if base == "" { + base = "https://openbin.livrasand.com" + } + expiresIn := os.Getenv("OPENBIN_BUNDLE_TTL") + if expiresIn == "" { + expiresIn = "604800" // 7 días por defecto + } + + hash, err := sha256File(bundlePath) + if err != nil { + return nil, fmt.Errorf("calcular sha256 del bundle: %w", err) + } + size, err := fileSize(bundlePath) + if err != nil { + return nil, fmt.Errorf("tamaño del bundle: %w", err) + } + + // 1) Pedir la URL firmada de subida. + var form bytes.Buffer + mw := multipart.NewWriter(&form) + _ = mw.WriteField("mode", "presign") + _ = mw.WriteField("sha256", hash) + _ = mw.WriteField("size", strconv.FormatInt(size, 10)) + _ = mw.WriteField("filename", filename) + _ = mw.WriteField("expires_in", expiresIn) + _ = mw.Close() + + var presign openbinBinResponse + if err := openbinPost(base+"/api/upload", mw.FormDataContentType(), &form, &presign); err != nil { + return nil, fmt.Errorf("pedir subida a Openbin: %w", err) + } + + // Dedup de Openbin: si el bundle ya está subido, no hace falta el PUT. + if presign.AlreadyExists { + if presign.DownloadURL == "" { + return nil, fmt.Errorf("Openbin devolvió un bin sin downloadUrl") + } + return &remoteJobResult{ + Slug: presign.Slug, + Cid: presign.Cid, + Size: size, + Sha256: hash, + DownloadURL: presign.DownloadURL, + DirectURL: presign.DirectURL, + Filename: presign.Filename, + ExpiresAt: presign.ExpiresAt, + }, nil + } + if presign.PresignedURL == "" || presign.UploadToken == "" { + return nil, fmt.Errorf("Openbin no devolvió URL firmada (mode=%q)", presign.Mode) + } + + // 2) Subir el objeto directo a Filebase. + if err := openbinPut(presign.PresignedURL, bundlePath); err != nil { + return nil, fmt.Errorf("subir bundle a Filebase: %w", err) + } + + // 3) Confirmar y registrar el bin. + confirmBody, _ := json.Marshal(map[string]string{ + "uploadToken": presign.UploadToken, + "sha256": hash, + }) + var confirm openbinBinResponse + if err := openbinPost(base+"/api/upload/confirm", "application/json", bytes.NewReader(confirmBody), &confirm); err != nil { + return nil, fmt.Errorf("confirmar subida en Openbin: %w", err) + } + if confirm.Slug == "" || confirm.Cid == "" || confirm.DownloadURL == "" { + return nil, fmt.Errorf("Openbin devolvió una confirmación incompleta") + } + return &remoteJobResult{ + Slug: confirm.Slug, + Cid: confirm.Cid, + Size: size, + Sha256: hash, + DownloadURL: confirm.DownloadURL, + DirectURL: confirm.DirectURL, + Filename: confirm.Filename, + ExpiresAt: confirm.ExpiresAt, + }, nil +} + +// openbinBinResponse es la respuesta de los endpoints de Openbin (presign y confirm). +type openbinBinResponse struct { + Mode string `json:"mode"` + AlreadyExists bool `json:"alreadyExists"` + PresignedURL string `json:"presignedUrl"` + UploadToken string `json:"uploadToken"` + ExpiresIn int `json:"expiresIn"` + Slug string `json:"slug"` + Cid string `json:"cid"` + Filename string `json:"filename"` + Mime string `json:"mime"` + Size int64 `json:"size"` + URL string `json:"url"` + DirectURL string `json:"directUrl"` + DownloadURL string `json:"downloadUrl"` + ExpiresAt string `json:"expiresAt"` +} + +// openbinPost hace un POST a Openbin y decodifica la respuesta JSON. +func openbinPost(rawURL, contentType string, body io.Reader, out any) error { + resp, err := openbinClient.Post(rawURL, contentType, body) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b))) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decodificar respuesta: %w", err) + } + return nil +} + +// openbinPut sube el bundle a la URL firmada de Filebase (streaming). +func openbinPut(rawURL, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + req, err := http.NewRequest(http.MethodPut, rawURL, f) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := openbinClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b))) + } + return nil +} + +// validRepoURL valida una URL https de repositorio en los hosts soportados. +func validRepoURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" { + return false + } + switch u.Hostname() { + case "github.com", "gitlab.com", "codeberg.org": + default: + return false + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 { + return false + } + return isValidRepoName(parts[0]) && isValidRepoName(strings.TrimSuffix(parts[1], ".git")) +} + +// bundleFilename deriva un nombre de archivo para el bundle en Openbin. +func bundleFilename(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "repo.bundle" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 { + return "repo.bundle" + } + return parts[0] + "-" + strings.TrimSuffix(parts[1], ".git") + ".bundle" +} + +// newRemoteJobID genera un identificador corto y aleatorio para el job. +func newRemoteJobID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + return hex.EncodeToString(b) +} + +// fileSize devuelve el tamaño en bytes de un archivo. +func fileSize(path string) (int64, error) { + st, err := os.Stat(path) + if err != nil { + return 0, err + } + return st.Size(), nil +} + +// sha256File calcula el hash sha256 de un archivo sin cargarlo en memoria. +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/http/router.go b/internal/http/router.go index dc4dff0..dfe9639 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -300,6 +300,16 @@ func SetupRouter(cfg *config.Config) *gin.Engine { r.GET("/v1/moderation/report", ReportHashHandler) r.POST("/v1/moderation/report", ReportHashHandler) + // Fase 2: jobs remotos de descarga (bundle + Range Requests server-side). + // La API key se aplica si está configurada (como el resto de endpoints no-git). + v2 := r.Group("/v2") + v2.Use(anonymousAuthMiddleware(cfg.APIKey)) + { + v2.POST("/jobs", CreateRemoteJobHandler) + v2.GET("/jobs/:id", GetRemoteJobHandler) + v2.DELETE("/jobs/:id", DeleteRemoteJobHandler) + } + // API routes - Public stats api := r.Group("/api") { diff --git a/internal/jobs/clone.go b/internal/jobs/clone.go index ecae838..846c478 100644 --- a/internal/jobs/clone.go +++ b/internal/jobs/clone.go @@ -1,6 +1,7 @@ package jobs import ( + "errors" "fmt" "os" "os/exec" @@ -14,11 +15,25 @@ import ( // Sobrescribible en tests para validar el resume con varias capas. var chunkSize = 500 -// runClone descarga un repositorio por capas: inicializa el repo, descarga la -// historia en bloques de chunkSize commits (--depth/--deepen) y materializa la -// rama por defecto. Cada bloque completado es un checkpoint: si la conexión se -// pierde, al reanudar solo se descargan los bloques pendientes. +// runClone decide el flujo de descarga: Fase 2 (bundle con Range Requests y +// resume por bytes vía Openbin) si el servidor lo soporta; si no, Fase 1 +// (descarga por capas con resume entre bloques). func runClone(s *Store, job *Job) error { + if err := runCloneBundle(s, job); err != nil { + if errors.Is(err, errRemoteJobsUnsupported) { + _ = s.SetProgress(job.ID, "El servidor no soporta jobs remotos; usando descarga por capas...") + return runCloneLayered(s, job) + } + return err + } + return nil +} + +// runCloneLayered (Fase 1) descarga un repositorio por capas: inicializa el +// repo, descarga la historia en bloques de chunkSize commits (--depth/--deepen) +// y materializa la rama por defecto. Cada bloque completado es un checkpoint: +// si la conexión se pierde, al reanudar solo se descargan los bloques pendientes. +func runCloneLayered(s *Store, job *Job) error { dir := job.Target if dir == "" { return fmt.Errorf("job de clone sin directorio destino") diff --git a/internal/jobs/clone_bundle.go b/internal/jobs/clone_bundle.go new file mode 100644 index 0000000..9009592 --- /dev/null +++ b/internal/jobs/clone_bundle.go @@ -0,0 +1,411 @@ +package jobs + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// Fase 2 — descarga por bundle con Range Requests. +// +// El servidor clona el repositorio, crea un bundle y lo sube a Openbin (CDN +// con TTL). Este módulo crea el job remoto, espera a que el bundle esté listo +// y lo descarga por rangos de bytes (resume a nivel byte) en el cache local. +// Al final materializa el repo con `git clone `. + +// errRemoteJobsUnsupported indica que el servidor no expone /v2/jobs; el +// llamador debe hacer fallback a la descarga por capas (Fase 1). +var errRemoteJobsUnsupported = errors.New("remote jobs unsupported") + +// remotePollInterval es la espera entre consultas de estado del job remoto. +var remotePollInterval = 2 * time.Second + +// prefixHost mapea el prefijo de ruta del servidor gitGost al host del repo. +var prefixHost = map[string]string{ + "gh": "github.com", + "gl": "gitlab.com", + "cb": "codeberg.org", +} + +// remoteJobState es el estado de un job remoto devuelto por el servidor. +type remoteJobState struct { + ID string `json:"id"` + Status string `json:"status"` + Progress string `json:"progress"` + Error string `json:"error"` + Result *remoteJobBundleInfo `json:"result"` +} + +// remoteJobBundleInfo es el artefacto final del job remoto (bundle en Openbin). +type remoteJobBundleInfo struct { + Slug string `json:"slug"` + Cid string `json:"cid"` + Size int64 `json:"size"` + Sha256 string `json:"sha256"` + DownloadURL string `json:"downloadUrl"` + DirectURL string `json:"directUrl"` + Filename string `json:"filename"` +} + +// serverBase devuelve la URL base del servidor gitGost (env GITGOST_SERVER o default). +func serverBase() string { + if v := os.Getenv("GITGOST_SERVER"); v != "" { + return strings.TrimRight(v, "/") + } + return "https://gitgost.fly.dev" +} + +// runCloneBundle ejecuta el flujo de Fase 2: job remoto en el servidor, +// descarga del bundle con resume por bytes y materialización local. Si el +// servidor no soporta jobs remotos, devuelve errRemoteJobsUnsupported. +func runCloneBundle(s *Store, job *Job) error { + if job.Target == "" { + return fmt.Errorf("job de clone sin directorio destino") + } + + // La URL efectiva del job es la reescrita del servidor; de ella se + // reconstruye la URL original del repo (el servidor solo acepta https + // de github/gitlab/codeberg, nunca scp-like). + originalURL, err := originalRepoURL(job.URL) + if err != nil { + return errRemoteJobsUnsupported + } + + _ = s.SetProgress(job.ID, "Creando job de descarga en el servidor...") + id, err := createRemoteJob(originalURL) + if err != nil { + if errors.Is(err, errRemoteJobsUnsupported) { + return err + } + return fmt.Errorf("crear job remoto: %w", err) + } + + result, err := waitRemoteJob(s, job, id) + if err != nil { + return err + } + if result == nil || result.DownloadURL == "" { + return fmt.Errorf("el job remoto %s no devolvió una URL de descarga", id) + } + + // Descarga del bundle con checkpoint: el archivo parcial del cache es el + // punto de reanudación (si el proceso se corta, solo faltan esos bytes). + cacheFile := bundleCachePath(job.ID) + if err := downloadBundle(s, job, result.DownloadURL, cacheFile, result.Size, result.Sha256); err != nil { + return fmt.Errorf("descargar bundle: %w", err) + } + + if err := materializeClone(cacheFile, job.Target, originalURL); err != nil { + return err + } + _ = s.SetProgress(job.ID, "Clone completado") + return nil +} + +// originalRepoURL reconstruye la URL https original a partir de la URL +// reescrita del servidor (/v1///). +func originalRepoURL(rewritten string) (string, error) { + u, err := url.Parse(rewritten) + if err != nil { + return "", fmt.Errorf("URL reescrita inválida: %w", err) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 4 || parts[0] != "v1" { + return "", fmt.Errorf("URL reescrita no reconocida: %s", rewritten) + } + host, ok := prefixHost[parts[1]] + if !ok { + return "", fmt.Errorf("host no soportado: %s", parts[1]) + } + return fmt.Sprintf("https://%s/%s/%s.git", host, parts[2], parts[3]), nil +} + +// createRemoteJob crea un job de descarga en el servidor y devuelve su ID. +func createRemoteJob(repoURL string) (string, error) { + body, _ := json.Marshal(map[string]string{"url": repoURL}) + resp, err := http.Post(serverBase()+"/v2/jobs", "application/json", bytes.NewReader(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return "", errRemoteJobsUnsupported + } + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b))) + } + var out struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("decodificar respuesta: %w", err) + } + if out.ID == "" { + return "", fmt.Errorf("el servidor no devolvió un id de job") + } + return out.ID, nil +} + +// waitRemoteJob consulta el estado del job remoto hasta que el bundle esté +// listo (ready) o el servidor reporte un fallo (failed). +func waitRemoteJob(s *Store, job *Job, id string) (*remoteJobBundleInfo, error) { + for { + st, err := getRemoteJob(id) + if err != nil { + return nil, err + } + switch st.Status { + case "ready": + if st.Result == nil { + return nil, fmt.Errorf("el job remoto %s está listo pero sin resultado", id) + } + return st.Result, nil + case "failed": + msg := fmt.Sprintf("el servidor no pudo crear el bundle (%s)", id) + if st.Error != "" { + msg += ": " + st.Error + } + return nil, errors.New(msg) + default: // queued | running + msg := fmt.Sprintf("Servidor: %s", st.Status) + if st.Progress != "" { + msg += " — " + st.Progress + } + _ = s.SetProgress(job.ID, msg) + time.Sleep(remotePollInterval) + } + } +} + +// getRemoteJob consulta el estado de un job remoto. +func getRemoteJob(id string) (*remoteJobState, error) { + resp, err := http.Get(serverBase() + "/v2/jobs/" + id) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b))) + } + var st remoteJobState + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + return nil, fmt.Errorf("decodificar estado: %w", err) + } + return &st, nil +} + +// downloadBundle descarga el bundle a dest verificando su sha256. Si el +// archivo parcial ya coincide en tamaño y hash, se salta la descarga +// (checkpoint); si el hash final no coincide, se descarta y se reintenta. +func downloadBundle(s *Store, job *Job, downloadURL, dest string, size int64, sha string) error { + if st, err := os.Stat(dest); err == nil && st.Size() == size { + ok, err := hashMatches(dest, sha) + if err != nil { + return err + } + if ok { + _ = s.SetProgress(job.ID, "Bundle ya descargado en cache") + return nil + } + } + + if err := downloadWithRetry(s, job, downloadURL, dest, size); err != nil { + return err + } + ok, err := hashMatches(dest, sha) + if err != nil { + return err + } + if ok { + return nil + } + // Bundle corrupto: descartar el parcial y reintentar una vez desde cero. + _ = s.SetProgress(job.ID, "Bundle con hash incorrecto; descargando de nuevo...") + _ = os.Remove(dest) + if err := downloadWithRetry(s, job, downloadURL, dest, size); err != nil { + return err + } + if ok, err := hashMatches(dest, sha); err != nil { + return err + } else if !ok { + return fmt.Errorf("el bundle descargado no coincide con el sha256 esperado") + } + return nil +} + +// downloadWithRetry descarga con reintentos ante fallos de red (backoff). +func downloadWithRetry(s *Store, job *Job, rawURL, dest string, size int64) error { + var lastErr error + backoff := retryBase + for attempt := 0; attempt <= retryMax; attempt++ { + if attempt > 0 { + _ = s.SetState(job.ID, StateRetrying) + _ = s.SetProgress(job.ID, fmt.Sprintf( + "Reintentando descarga en %s (intento %d/%d)...", backoff, attempt, retryMax)) + time.Sleep(backoff) + backoff *= 2 + } + lastErr = downloadRange(rawURL, dest, size, progressFn(s, job.ID)) + if lastErr == nil { + return nil + } + if !isRetryable(lastErr) { + return lastErr + } + } + return lastErr +} + +// downloadRange descarga rawURL a dest reanudando desde el tamaño actual del +// archivo (Range: bytes=-). Si el servidor ignora el Range (200), se +// reinicia desde cero para no duplicar bytes. +func downloadRange(rawURL, dest string, size int64, progress func(string)) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("crear cache: %w", err) + } + + partial := int64(0) + if st, err := os.Stat(dest); err == nil { + partial = st.Size() + } + + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return err + } + if partial > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", partial)) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusPartialContent: + case http.StatusOK: + if partial > 0 { + // Sin soporte de Range: reiniciar la descarga desde cero. + partial = 0 + } + default: + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b))) + } + + f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Seek(partial, io.SeekStart); err != nil { + return err + } + if partial == 0 { + if err := f.Truncate(0); err != nil { + return err + } + } + + buf := make([]byte, 256*1024) + total := partial + lastMB := int64(-1) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := f.Write(buf[:n]); werr != nil { + return werr + } + total += int64(n) + if progress != nil { + mb := total / (1024 * 1024) + if mb != lastMB { + lastMB = mb + if size > 0 { + progress(fmt.Sprintf("Descargando bundle: %d / %d MB", mb, size/(1024*1024))) + } else { + progress(fmt.Sprintf("Descargando bundle: %d MB", mb)) + } + } + } + } + if rerr == io.EOF { + break + } + if rerr != nil { + return rerr + } + } + return nil +} + +// materializeClone crea el repo destino desde el bundle y fija el remote +// origin a la URL original del usuario. Si el destino ya tiene .git (resume +// tras una materialización completada), solo se reajusta el origin. +func materializeClone(bundlePath, target, originURL string) error { + if _, err := os.Stat(filepath.Join(target, ".git")); os.IsNotExist(err) { + if err := runGit("", "clone", bundlePath, target); err != nil { + return fmt.Errorf("materializar repo desde bundle: %w", err) + } + } + _ = runGit(target, "remote", "remove", "origin") + if err := runGit(target, "remote", "add", "origin", originURL); err != nil { + return fmt.Errorf("configurar remote origin: %w", err) + } + return nil +} + +// bundleCachePath devuelve la ruta del bundle en cache para un job. +func bundleCachePath(jobID int64) string { + return filepath.Join(jobDataDir(), "cache", fmt.Sprintf("%d.bundle", jobID)) +} + +// jobDataDir devuelve el directorio de datos del cliente (env GITGOST_HOME o ~/.gitgost). +func jobDataDir() string { + if v := os.Getenv("GITGOST_HOME"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return ".gitgost" + } + return filepath.Join(home, ".gitgost") +} + +// hashMatches compara el sha256 de un archivo con el esperado. +func hashMatches(path, want string) (bool, error) { + got, err := sha256File(path) + if err != nil { + return false, err + } + return strings.EqualFold(got, want), nil +} + +// sha256File calcula el hash sha256 de un archivo sin cargarlo en memoria. +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/jobs/clone_bundle_test.go b/internal/jobs/clone_bundle_test.go new file mode 100644 index 0000000..3a04c31 --- /dev/null +++ b/internal/jobs/clone_bundle_test.go @@ -0,0 +1,276 @@ +package jobs + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" +) + +// fakeBundleServer simula el servidor gitGost de Fase 2: acepta la creación +// del job remoto, reporta el bundle listo y sirve el bundle con soporte de +// Range (registrando los headers Range recibidos). +func fakeBundleServer(t *testing.T, bundlePath string) (*httptest.Server, *[]string) { + t.Helper() + data, err := os.ReadFile(bundlePath) + if err != nil { + t.Fatalf("leer bundle: %v", err) + } + hash, err := sha256File(bundlePath) + if err != nil { + t.Fatalf("sha256 del bundle: %v", err) + } + + var mu sync.Mutex + var ranges []string + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v2/jobs": + _ = json.NewEncoder(w).Encode(map[string]any{"id": "testjob"}) + case r.Method == http.MethodGet && r.URL.Path == "/v2/jobs/testjob": + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ready", + "result": map[string]any{ + "downloadUrl": srv.URL + "/bundle", + "size": int64(len(data)), + "sha256": hash, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/bundle": + mu.Lock() + rng := r.Header.Get("Range") + if rng != "" { + ranges = append(ranges, rng) + } + mu.Unlock() + if strings.HasPrefix(rng, "bytes=") { + n, _ := strconv.ParseInt(strings.TrimPrefix(rng, "bytes="), 10, 64) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", n, int64(len(data))-1, int64(len(data)))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(data[n:]) + return + } + _, _ = w.Write(data) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, &ranges +} + +// withServerURL fija GITGOST_SERVER para que serverBase() apunte al fake. +func withServerURL(t *testing.T, u string) { + t.Helper() + old := os.Getenv("GITGOST_SERVER") + _ = os.Setenv("GITGOST_SERVER", u) + t.Cleanup(func() { + if old != "" { + _ = os.Setenv("GITGOST_SERVER", old) + } else { + _ = os.Unsetenv("GITGOST_SERVER") + } + }) +} + +// withHome fija GITGOST_HOME (directorio de datos del cliente) a un tempdir. +func withHome(t *testing.T, dir string) { + t.Helper() + old := os.Getenv("GITGOST_HOME") + _ = os.Setenv("GITGOST_HOME", dir) + t.Cleanup(func() { + if old != "" { + _ = os.Setenv("GITGOST_HOME", old) + } else { + _ = os.Unsetenv("GITGOST_HOME") + } + }) +} + +// makeBundle crea un bundle git real desde un repo de prueba. +func makeBundle(t *testing.T) string { + t.Helper() + src := buildRepo(t, 3) + bundle := filepath.Join(t.TempDir(), "repo.bundle") + if err := runGit(src, "bundle", "create", bundle, "--all"); err != nil { + t.Fatalf("crear bundle: %v", err) + } + return bundle +} + +// TestRunCloneBundleFull valida el flujo completo de Fase 2: job remoto, +// descarga del bundle, materialización con git clone y origin original. +func TestRunCloneBundleFull(t *testing.T) { + bundle := makeBundle(t) + srv, _ := fakeBundleServer(t, bundle) + withServerURL(t, srv.URL) + withHome(t, t.TempDir()) + + s := testStore(t) + dest := filepath.Join(t.TempDir(), "dest") + id, err := s.Create(&Job{ + Operation: "clone", + URL: srv.URL + "/v1/gh/acme/widgets", + Origin: "git@github.com:acme/widgets.git", + Target: dest, + CWD: t.TempDir(), + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := Run(s, id); err != nil { + t.Fatalf("Run: %v", err) + } + + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if job.State != StateCompleted { + t.Errorf("estado = %s, se esperaba completed", job.State) + } + if repoShallow(dest) { + t.Error("el repo materializado no debería ser shallow") + } + if n := gitRevCount(dest); n != 3 { + t.Errorf("commits = %d, se esperaba 3", n) + } + out, _ := gitOutput(dest, "branch", "--show-current") + if strings.TrimSpace(out) != "main" { + t.Errorf("rama actual = %q, se esperaba main", out) + } + origin, _ := gitOutput(dest, "remote", "get-url", "origin") + if strings.TrimSpace(origin) != "https://github.com/acme/widgets.git" { + t.Errorf("origin = %q, se esperaba la URL original https", origin) + } +} + +// TestDownloadBundleResumes valida el resume por bytes: con la primera mitad +// del bundle en cache, la descarga pide Range desde ese punto y el archivo +// final coincide con el hash esperado. +func TestDownloadBundleResumes(t *testing.T) { + bundle := makeBundle(t) + data, err := os.ReadFile(bundle) + if err != nil { + t.Fatalf("leer bundle: %v", err) + } + hash, err := sha256File(bundle) + if err != nil { + t.Fatalf("sha256 del bundle: %v", err) + } + srv, ranges := fakeBundleServer(t, bundle) + withServerURL(t, srv.URL) + withHome(t, t.TempDir()) + + s := testStore(t) + id, err := s.Create(&Job{Operation: "clone", URL: srv.URL + "/v1/gh/a/b", Target: filepath.Join(t.TempDir(), "d"), CWD: t.TempDir()}) + if err != nil { + t.Fatalf("Create: %v", err) + } + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + + // Checkpoint: primera mitad del bundle ya descargada en cache. + dest := bundleCachePath(job.ID) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + t.Fatalf("crear cache: %v", err) + } + half := int64(len(data) / 2) + if err := os.WriteFile(dest, data[:int(half)], 0o644); err != nil { + t.Fatalf("escribir checkpoint: %v", err) + } + + if err := downloadBundle(s, job, srv.URL+"/bundle", dest, int64(len(data)), hash); err != nil { + t.Fatalf("downloadBundle: %v", err) + } + got, err := sha256File(dest) + if err != nil { + t.Fatalf("sha256 final: %v", err) + } + if got != hash { + t.Error("el archivo final no coincide con el bundle original") + } + if len(*ranges) == 0 { + t.Fatal("no se envió ninguna petición Range") + } + want := fmt.Sprintf("bytes=%d-", half) + if (*ranges)[0] != want { + t.Errorf("Range = %q, se esperaba %q", (*ranges)[0], want) + } +} + +// TestDownloadBundleHashMismatchRetries valida que un bundle corrupto (mismo +// tamaño, hash distinto) se descarta y se descarga de nuevo una vez. +func TestDownloadBundleHashMismatchRetries(t *testing.T) { + bundle := makeBundle(t) + data, err := os.ReadFile(bundle) + if err != nil { + t.Fatalf("leer bundle: %v", err) + } + hash, err := sha256File(bundle) + if err != nil { + t.Fatalf("sha256 del bundle: %v", err) + } + + var mu sync.Mutex + calls := 0 + corrupted := make([]byte, len(data)) + copy(corrupted, data) + corrupted[len(corrupted)-1] ^= 0xFF + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/bundle" { + http.NotFound(w, r) + return + } + mu.Lock() + calls++ + first := calls == 1 + mu.Unlock() + if first { + _, _ = w.Write(corrupted) + return + } + _, _ = w.Write(data) + })) + t.Cleanup(srv.Close) + withServerURL(t, srv.URL) + withHome(t, t.TempDir()) + + s := testStore(t) + id, err := s.Create(&Job{Operation: "clone", URL: srv.URL + "/v1/gh/a/b", Target: filepath.Join(t.TempDir(), "d"), CWD: t.TempDir()}) + if err != nil { + t.Fatalf("Create: %v", err) + } + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + + dest := bundleCachePath(job.ID) + if err := downloadBundle(s, job, srv.URL+"/bundle", dest, int64(len(data)), hash); err != nil { + t.Fatalf("downloadBundle: %v", err) + } + got, err := sha256File(dest) + if err != nil { + t.Fatalf("sha256 final: %v", err) + } + if got != hash { + t.Error("el archivo final no coincide con el bundle original") + } + mu.Lock() + n := calls + mu.Unlock() + if n != 2 { + t.Errorf("descargas = %d, se esperaban 2 (corrupta + reintento)", n) + } +} diff --git a/web/index.html b/web/index.html index b9c0839..5e4dcb7 100644 --- a/web/index.html +++ b/web/index.html @@ -453,7 +453,6 @@ } .mt-5.loaded.raised { - width: min(182px, 100%); margin: 0 auto 1.5rem; padding: 0.45rem 0.45rem 0.65rem; border: 1px solid var(--border); @@ -509,12 +508,6 @@ text-decoration: none; } - @media (max-width: 700px) { - .mt-5.loaded.raised { - width: min(182px, calc(100vw - 2rem)); - } - } - .section-content p { margin: 0; font-size: 0.82rem; @@ -1609,34 +1602,6 @@

- -
-
- Realtime - pageviews -
-
- Cargando métricas… -
-
-
- Only anonymous pageview totals are counted. No IP, no fingerprint, no cookies, no personal data. -

+ + + From 6e5261405ebb80bd9b989a71eb4a29ed0256c99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Livr=C3=A4do=20Sandoval?= <104039397+livrasand@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:41:07 -0700 Subject: [PATCH 2/7] Add timeouts, limits and robustness to remote jobs Add context/timeouts to git commands, prevent git args injection with '--', and improve git output handling. Introduce concurrency semaphore, per-job timeout, cancellation, and max bundle size checks for remote jobs. Add per-IP v2 rate limiter and size middleware in router. Improve Openbin upload to accept precomputed size/hash, set mime and Content-Length, and use a timed HTTP client with optional API key. Fix range-download edge cases, ensure default-branch checkout and origin handling, remove cached bundles after materialization, and update tests and minor web copy typo. --- internal/git/bundle.go | 81 ++++++++++++-------- internal/http/jobs2.go | 105 ++++++++++++++++++-------- internal/http/router.go | 25 ++++++- internal/jobs/clone.go | 10 ++- internal/jobs/clone_bundle.go | 114 +++++++++++++++++++++++++---- internal/jobs/clone_bundle_test.go | 41 ++++++++--- web/index.html | 4 +- 7 files changed, 280 insertions(+), 100 deletions(-) diff --git a/internal/git/bundle.go b/internal/git/bundle.go index e01dbe1..ea78319 100644 --- a/internal/git/bundle.go +++ b/internal/git/bundle.go @@ -1,6 +1,8 @@ package git import ( + "bytes" + "context" "fmt" "os" "os/exec" @@ -15,10 +17,13 @@ const bundleChunkSize = 500 // CreateBundle descarga el repositorio remoto por capas en workDir y crea un // bundle completo (commits, árboles y blobs) que git puede clonar sin red. -// Devuelve la ruta del bundle y la rama por defecto del remoto. -func CreateBundle(url, workDir string) (bundlePath, defaultBranch string, err error) { +// Devuelve la ruta del bundle y la rama por defecto del remoto. El contexto +// acota la duración total de los subprocesos git (evita workers colgados). +func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, defaultBranch string, err error) { repoDir := filepath.Join(workDir, "repo") - if err := runGit("", "clone", "--mirror", "--depth="+strconv.Itoa(bundleChunkSize), url, repoDir); err != nil { + // El separador -- evita que la URL (controlada por el usuario) se interprete + // como opción de git (p. ej. --upload-pack=...) aunque no pase validación. + if err := runGit(ctx, "", "clone", "--mirror", "--depth="+strconv.Itoa(bundleChunkSize), "--", url, repoDir); err != nil { return "", "", fmt.Errorf("clonar %s: %w", url, err) } @@ -26,60 +31,67 @@ func CreateBundle(url, workDir string) (bundlePath, defaultBranch string, err er // cliente de Fase 1, sin filtro de blobs: el bundle final debe incluir // todo el contenido para que el checkout del cliente funcione sin red). block := 0 - for repoShallow(repoDir) { + for repoShallow(ctx, repoDir) { block++ prevShallow := shallowFile(repoDir) - prev := revCount(repoDir) - if err := runGit(repoDir, "fetch", "--deepen="+strconv.Itoa(bundleChunkSize), "origin"); err != nil { + prev := revCount(ctx, repoDir) + if err := runGit(ctx, repoDir, "fetch", "--deepen="+strconv.Itoa(bundleChunkSize), "origin"); err != nil { return "", "", fmt.Errorf("profundizar repo (bloque %d): %w", block, err) } - if !repoShallow(repoDir) { + if !repoShallow(ctx, repoDir) { break } // Los shallow points de ramas/tags cortas se resuelven sin añadir // commits: si nada cambió, el servidor no profundiza más por capas. - if shallowFile(repoDir) == prevShallow && revCount(repoDir) == prev { + if shallowFile(repoDir) == prevShallow && revCount(ctx, repoDir) == prev { break } } - if repoShallow(repoDir) { - if err := runGit(repoDir, "fetch", "--unshallow", "origin"); err != nil { + if repoShallow(ctx, repoDir) { + if err := runGit(ctx, repoDir, "fetch", "--unshallow", "origin"); err != nil { return "", "", fmt.Errorf("completar historia: %w", err) } } // Rama por defecto: en un clon --mirror el HEAD local apunta a la ref remota. - branch, err := gitOutput(repoDir, "symbolic-ref", "--short", "HEAD") + branch, err := gitOutput(ctx, repoDir, "symbolic-ref", "--short", "HEAD") if err != nil { return "", "", fmt.Errorf("determinar rama por defecto: %w", err) } bundlePath = filepath.Join(workDir, "repo.bundle") - if err := runGit(repoDir, "bundle", "create", bundlePath, "--all"); err != nil { + if err := runGit(ctx, repoDir, "bundle", "create", bundlePath, "--all"); err != nil { return "", "", fmt.Errorf("crear bundle: %w", err) } return bundlePath, strings.TrimSpace(branch), nil } -// shallowFile devuelve el contenido actual de .git/shallow, o vacío si el repo -// ya no es shallow. +// shallowFile devuelve el contenido actual del marker de shallow, o vacío si el +// repo ya no es shallow. Soporta repos bare (clone --mirror: /shallow) y +// repos normales (/.git/shallow); devolver vacío si ninguno puede leerse +// mantiene el guard de "sin progreso" operativo para los clonados mirror. func shallowFile(dir string) string { - data, err := os.ReadFile(filepath.Join(dir, ".git", "shallow")) - if err != nil { - return "" + for _, p := range []string{ + filepath.Join(dir, "shallow"), + filepath.Join(dir, ".git", "shallow"), + } { + data, err := os.ReadFile(p) + if err == nil { + return string(data) + } } - return string(data) + return "" } // repoShallow indica si el repo sigue con historia parcial. -func repoShallow(dir string) bool { - out, err := gitOutput(dir, "rev-parse", "--is-shallow-repository") +func repoShallow(ctx context.Context, dir string) bool { + out, err := gitOutput(ctx, dir, "rev-parse", "--is-shallow-repository") return err == nil && strings.TrimSpace(out) == "true" } // revCount cuenta los commits visibles en todas las refs del repo. -func revCount(dir string) int { - out, err := gitOutput(dir, "rev-list", "--count", "--all") +func revCount(ctx context.Context, dir string) int { + out, err := gitOutput(ctx, dir, "rev-list", "--count", "--all") if err != nil { return 0 } @@ -88,8 +100,9 @@ func revCount(dir string) int { } // runGit ejecuta git; si falla, el error incluye el mensaje real de stderr. -func runGit(dir string, args ...string) error { - cmd := exec.Command("git", args...) +// El contexto permite matar el subproceso si el job remoto excede su timeout. +func runGit(ctx context.Context, dir string, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { @@ -100,15 +113,19 @@ func runGit(dir string, args ...string) error { return err } -// gitOutput ejecuta git y devuelve su salida combinada. -func gitOutput(dir string, args ...string) (string, error) { - cmd := exec.Command("git", args...) +// gitOutput ejecuta git y devuelve su stdout limpio; el stderr solo se incorpora +// al error cuando el comando falla (los warnings de git no contaminan el valor). +func gitOutput(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - if msg := strings.TrimSpace(string(out)); msg != "" { - return string(out), fmt.Errorf("%w: %s", err, msg) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return stdout.String(), fmt.Errorf("%w: %s", err, msg) } + return stdout.String(), err } - return string(out), err + return stdout.String(), nil } diff --git a/internal/http/jobs2.go b/internal/http/jobs2.go index 3e45883..b377018 100644 --- a/internal/http/jobs2.go +++ b/internal/http/jobs2.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "context" "crypto/rand" "crypto/sha256" "encoding/hex" @@ -31,14 +32,22 @@ import ( const ( remoteJobsMax = 100 remoteJobsTTL = 24 * time.Hour + + // remoteJobMaxConcurrent limita los workers de bundle en paralelo. + remoteJobMaxConcurrent = 3 + // remoteJobTimeout acota la duración total de un job remoto (clon + bundle). + remoteJobTimeout = 30 * time.Minute + // maxBundleSize rechaza bundles que Openbin no podría aceptar (4 GiB). + maxBundleSize = 4 * 1024 * 1024 * 1024 ) // Estados de un job remoto. const ( - rjQueued = "queued" - rjRunning = "running" - rjReady = "ready" - rjFailed = "failed" + rjQueued = "queued" + rjRunning = "running" + rjReady = "ready" + rjFailed = "failed" + rjCancelled = "cancelled" ) // remoteJobResult es el artefacto final de un job remoto: un bundle en Openbin. @@ -72,6 +81,10 @@ type remoteJob struct { // dejan de estar disponibles pasadas 24 h). var remoteJobs = newBoundedMap[*remoteJob](remoteJobsMax, remoteJobsTTL) +// remoteJobSlots es el semáforo de workers: solo remoteJobMaxConcurrent jobs +// pueden ejecutarse a la vez; el resto recibe 429 desde el handler. +var remoteJobSlots = make(chan struct{}, remoteJobMaxConcurrent) + // openbinClient permite subir bundles grandes sin el timeout corto del proxy. var openbinClient = &http.Client{Timeout: 30 * time.Minute} @@ -89,6 +102,15 @@ func CreateRemoteJobHandler(c *gin.Context) { return } + // Limitar el número de workers concurrentes: si el semáforo está lleno, se + // rechaza la creación con 429 en vez de acumular goroutines sin límite. + select { + case remoteJobSlots <- struct{}{}: + default: + c.JSON(http.StatusTooManyRequests, gin.H{"error": "demasiados jobs en curso, inténtalo más tarde"}) + return + } + id := newRemoteJobID() job := &remoteJob{ID: id, Status: rjQueued, URL: req.URL, Created: time.Now()} remoteJobs.Set(id, job) @@ -113,54 +135,74 @@ func GetRemoteJobHandler(c *gin.Context) { }) } -// DeleteRemoteJobHandler elimina un trabajo remoto y limpia su directorio -// temporal. Si el worker aún corre, su publicación posterior simplemente -// reinserta un resultado que el TTL evictará. +// DeleteRemoteJobHandler marca un trabajo como cancelado. No borra TmpDir: si +// el worker aún corre, su defer os.RemoveAll(dir) limpia el directorio al +// terminar; el estado cancelado queda hasta que el TTL lo evicte. func DeleteRemoteJobHandler(c *gin.Context) { id := c.Param("id") - if job, ok := remoteJobs.Get(id); ok && job.TmpDir != "" { - _ = os.RemoveAll(job.TmpDir) + if job, ok := remoteJobs.Get(id); ok && job.Status != rjCancelled { + next := *job + next.Status = rjCancelled + next.Progress = "Cancelado" + remoteJobs.Set(id, &next) } - remoteJobs.Delete(id) c.Status(http.StatusNoContent) } +// jobCancelled indica si un job fue marcado como cancelado por DELETE. +func jobCancelled(id string) bool { + cur, ok := remoteJobs.Get(id) + return ok && cur.Status == rjCancelled +} + // runRemoteJob ejecuta el trabajo en background: clona por capas, crea el // bundle, lo sube a Openbin y publica el resultado. func runRemoteJob(job *remoteJob) { + defer func() { <-remoteJobSlots }() + + ctx, cancel := context.WithTimeout(context.Background(), remoteJobTimeout) + defer cancel() + + // dir se captura por referencia: cada publish propaga el TmpDir real. + dir := "" publish := func(status, progress, errMsg string, result *remoteJobResult) { next := *job next.Status = status next.Progress = progress next.Error = errMsg next.Result = result + next.TmpDir = dir remoteJobs.Set(job.ID, &next) } publish(rjRunning, "Preparando descarga...", "", nil) - dir, err := os.MkdirTemp("", "gitgost-bundle-") + var err error + dir, err = os.MkdirTemp("", "gitgost-bundle-") if err != nil { publish(rjFailed, "", fmt.Sprintf("crear directorio temporal: %v", err), nil) return } defer os.RemoveAll(dir) - jobCopy := *job - jobCopy.TmpDir = dir - remoteJobs.Set(job.ID, &jobCopy) - publish(rjRunning, "Descargando repositorio por capas...", "", nil) - bundlePath, defaultBranch, err := git.CreateBundle(job.URL, dir) + bundlePath, defaultBranch, err := git.CreateBundle(ctx, job.URL, dir) if err != nil { publish(rjFailed, "", err.Error(), nil) return } + if jobCancelled(job.ID) { + return + } size, err := fileSize(bundlePath) if err != nil { publish(rjFailed, "", fmt.Sprintf("tamaño del bundle: %v", err), nil) return } + if size > maxBundleSize { + publish(rjFailed, "", fmt.Sprintf("el bundle excede el tamaño máximo de %d bytes", maxBundleSize), nil) + return + } hash, err := sha256File(bundlePath) if err != nil { publish(rjFailed, "", fmt.Sprintf("hash del bundle: %v", err), nil) @@ -168,13 +210,14 @@ func runRemoteJob(job *remoteJob) { } publish(rjRunning, "Subiendo bundle a Openbin...", "", nil) - result, err := openbinUpload(bundlePath, bundleFilename(job.URL)) + result, err := openbinUpload(bundlePath, bundleFilename(job.URL), size, hash) if err != nil { publish(rjFailed, "", err.Error(), nil) return } - result.Size = size - result.Sha256 = hash + if jobCancelled(job.ID) { + return + } result.DefaultBranch = defaultBranch publish(rjReady, "Bundle listo", "", result) @@ -184,7 +227,9 @@ func runRemoteJob(job *remoteJob) { // 1) POST /api/upload?mode=presign → URL firmada de Filebase + token // 2) PUT del objeto directo a Filebase (streaming, sin pasar por Vercel) // 3) POST /api/upload/confirm → CID + bin con expiración -func openbinUpload(bundlePath, filename string) (*remoteJobResult, error) { +// size y hash ya están calculados por el llamador (evita recalcular el SHA-256 +// de un archivo que puede pesar gigabytes). +func openbinUpload(bundlePath, filename string, size int64, hash string) (*remoteJobResult, error) { base := strings.TrimRight(os.Getenv("OPENBIN_URL"), "/") if base == "" { base = "https://openbin.livrasand.com" @@ -194,15 +239,6 @@ func openbinUpload(bundlePath, filename string) (*remoteJobResult, error) { expiresIn = "604800" // 7 días por defecto } - hash, err := sha256File(bundlePath) - if err != nil { - return nil, fmt.Errorf("calcular sha256 del bundle: %w", err) - } - size, err := fileSize(bundlePath) - if err != nil { - return nil, fmt.Errorf("tamaño del bundle: %w", err) - } - // 1) Pedir la URL firmada de subida. var form bytes.Buffer mw := multipart.NewWriter(&form) @@ -210,6 +246,7 @@ func openbinUpload(bundlePath, filename string) (*remoteJobResult, error) { _ = mw.WriteField("sha256", hash) _ = mw.WriteField("size", strconv.FormatInt(size, 10)) _ = mw.WriteField("filename", filename) + _ = mw.WriteField("mime", "application/octet-stream") _ = mw.WriteField("expires_in", expiresIn) _ = mw.Close() @@ -239,7 +276,7 @@ func openbinUpload(bundlePath, filename string) (*remoteJobResult, error) { } // 2) Subir el objeto directo a Filebase. - if err := openbinPut(presign.PresignedURL, bundlePath); err != nil { + if err := openbinPut(presign.PresignedURL, bundlePath, size); err != nil { return nil, fmt.Errorf("subir bundle a Filebase: %w", err) } @@ -303,7 +340,9 @@ func openbinPost(rawURL, contentType string, body io.Reader, out any) error { } // openbinPut sube el bundle a la URL firmada de Filebase (streaming). -func openbinPut(rawURL, path string) error { +// ContentLength evita el transfer-encoding chunked; el Content-Type coincide +// con el mime declarado en el presign (Openbin firma ese header). +func openbinPut(rawURL, path string, size int64) error { f, err := os.Open(path) if err != nil { return err @@ -314,6 +353,7 @@ func openbinPut(rawURL, path string) error { if err != nil { return err } + req.ContentLength = size req.Header.Set("Content-Type", "application/octet-stream") resp, err := openbinClient.Do(req) @@ -329,9 +369,10 @@ func openbinPut(rawURL, path string) error { } // validRepoURL valida una URL https de repositorio en los hosts soportados. +// Se rechaza http (tráfico en claro) y URLs con credenciales embebidas. func validRepoURL(raw string) bool { u, err := url.Parse(raw) - if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" { + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil { return false } switch u.Hostname() { diff --git a/internal/http/router.go b/internal/http/router.go index dfe9639..ce2adf3 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -107,6 +107,26 @@ func prCheckLimiter() gin.HandlerFunc { } } +// v2LimiterState holds per-IP sliding-window counters for the v2 job endpoints. +var ( + v2LimiterStore = newBoundedMap[[]time.Time](prCheckLimiterStoreMax, v2LimiterWin) + v2LimiterMax = 30 + v2LimiterWin = time.Minute +) + +// v2Limiter enforces a per-IP rate limit on the Fase 2 job endpoints. +func v2Limiter() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + count := windowAdd(v2LimiterStore, ip, time.Now(), v2LimiterWin, v2LimiterMax) + if count > v2LimiterMax { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "v2 rate limit exceeded"}) + return + } + c.Next() + } +} + // maxPushSize is the maximum allowed push size const maxPushSize = 100 * 1024 * 1024 // 100MB @@ -301,9 +321,10 @@ func SetupRouter(cfg *config.Config) *gin.Engine { r.POST("/v1/moderation/report", ReportHashHandler) // Fase 2: jobs remotos de descarga (bundle + Range Requests server-side). - // La API key se aplica si está configurada (como el resto de endpoints no-git). + // La API key se aplica si está configurada (como el resto de endpoints no-git); + // el límite de tamaño y el limiter per-IP evitan abuso de creación de jobs. v2 := r.Group("/v2") - v2.Use(anonymousAuthMiddleware(cfg.APIKey)) + v2.Use(sizeLimitMiddleware(), v2Limiter(), anonymousAuthMiddleware(cfg.APIKey)) { v2.POST("/jobs", CreateRemoteJobHandler) v2.GET("/jobs/:id", GetRemoteJobHandler) diff --git a/internal/jobs/clone.go b/internal/jobs/clone.go index 846c478..7f0320d 100644 --- a/internal/jobs/clone.go +++ b/internal/jobs/clone.go @@ -58,7 +58,9 @@ func runCloneLayered(s *Store, job *Job) error { } // Rama por defecto del remoto. - ls, err := gitOutputWithRetry(s, job, dir, "ls-remote", "--symref", job.URL, "HEAD") + // Los separadores -- evitan que job.URL se interprete como opción de git + // (defensa en profundidad: la URL ya pasa validación al reescribirse). + ls, err := gitOutputWithRetry(s, job, dir, "ls-remote", "--symref", "--", job.URL, "HEAD") if err != nil { return fmt.Errorf("descubrir rama por defecto: %w", err) } @@ -76,7 +78,7 @@ func runCloneLayered(s *Store, job *Job) error { } else { _ = s.SetProgress(job.ID, "Descargando primer bloque...") if err := execGitWithRetry(s, job, dir, - "fetch", "--depth="+strconv.Itoa(chunkSize), "--filter=blob:none", job.URL, refspec); err != nil { + "fetch", "--depth="+strconv.Itoa(chunkSize), "--filter=blob:none", "--", job.URL, refspec); err != nil { return err } } @@ -90,7 +92,7 @@ func runCloneLayered(s *Store, job *Job) error { prevShallow := shallowFile(dir) prev := gitRevCount(dir) if err := execGitWithRetry(s, job, dir, - "fetch", "--deepen="+strconv.Itoa(chunkSize), "--filter=blob:none", job.URL, refspec); err != nil { + "fetch", "--deepen="+strconv.Itoa(chunkSize), "--filter=blob:none", "--", job.URL, refspec); err != nil { return err } count := gitRevCount(dir) @@ -109,7 +111,7 @@ func runCloneLayered(s *Store, job *Job) error { if repoShallow(dir) { _ = s.SetProgress(job.ID, "Completando historia restante...") if err := execGitWithRetry(s, job, dir, - "fetch", "--unshallow", "--filter=blob:none", job.URL, refspec); err != nil { + "fetch", "--unshallow", "--filter=blob:none", "--", job.URL, refspec); err != nil { return err } } diff --git a/internal/jobs/clone_bundle.go b/internal/jobs/clone_bundle.go index 9009592..4d0fcf2 100644 --- a/internal/jobs/clone_bundle.go +++ b/internal/jobs/clone_bundle.go @@ -30,6 +30,16 @@ var errRemoteJobsUnsupported = errors.New("remote jobs unsupported") // remotePollInterval es la espera entre consultas de estado del job remoto. var remotePollInterval = 2 * time.Second +// remotePollTimeout acota la espera total del bundle; maxPollErrors tolera +// fallos transitorios del GET de estado antes de fallar el job. +var ( + remotePollTimeout = 30 * time.Minute + maxPollErrors = 3 +) + +// jobHTTPClient evita que el cliente se cuelgue si el servidor no responde. +var jobHTTPClient = &http.Client{Timeout: 60 * time.Second} + // prefixHost mapea el prefijo de ruta del servidor gitGost al host del repo. var prefixHost = map[string]string{ "gh": "github.com", @@ -48,13 +58,14 @@ type remoteJobState struct { // remoteJobBundleInfo es el artefacto final del job remoto (bundle en Openbin). type remoteJobBundleInfo struct { - Slug string `json:"slug"` - Cid string `json:"cid"` - Size int64 `json:"size"` - Sha256 string `json:"sha256"` - DownloadURL string `json:"downloadUrl"` - DirectURL string `json:"directUrl"` - Filename string `json:"filename"` + Slug string `json:"slug"` + Cid string `json:"cid"` + Size int64 `json:"size"` + Sha256 string `json:"sha256"` + DownloadURL string `json:"downloadUrl"` + DirectURL string `json:"directUrl"` + Filename string `json:"filename"` + DefaultBranch string `json:"defaultBranch"` } // serverBase devuelve la URL base del servidor gitGost (env GITGOST_SERVER o default). @@ -105,13 +116,26 @@ func runCloneBundle(s *Store, job *Job) error { return fmt.Errorf("descargar bundle: %w", err) } - if err := materializeClone(cacheFile, job.Target, originalURL); err != nil { + if err := materializeClone(cacheFile, job.Target, originForJob(job, originalURL), result.DefaultBranch); err != nil { return err } + // El bundle ya se materializó: liberar el cache (puede pesar gigabytes). + if err := os.Remove(cacheFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("eliminar bundle del cache: %w", err) + } _ = s.SetProgress(job.ID, "Clone completado") return nil } +// originForJob devuelve la URL del remote origin del repo resultante: la +// original del usuario cuando existe, o la reconstruida (igual que la Fase 1). +func originForJob(job *Job, fallback string) string { + if job.Origin != "" { + return job.Origin + } + return fallback +} + // originalRepoURL reconstruye la URL https original a partir de la URL // reescrita del servidor (/v1///). func originalRepoURL(rewritten string) (string, error) { @@ -131,14 +155,27 @@ func originalRepoURL(rewritten string) (string, error) { } // createRemoteJob crea un job de descarga en el servidor y devuelve su ID. +// Envía la API key (X-Gitgost-Key) si GITGOST_API_KEY está configurada. func createRemoteJob(repoURL string) (string, error) { body, _ := json.Marshal(map[string]string{"url": repoURL}) - resp, err := http.Post(serverBase()+"/v2/jobs", "application/json", bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, serverBase()+"/v2/jobs", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + if key := os.Getenv("GITGOST_API_KEY"); key != "" { + req.Header.Set("X-Gitgost-Key", key) + } + + resp, err := jobHTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { + switch resp.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden: + // Sin /v2 o sin permisos: el servidor no soporta jobs remotos para este + // cliente; el llamador hace fallback a la descarga por capas. return "", errRemoteJobsUnsupported } if resp.StatusCode >= 300 { @@ -158,13 +195,26 @@ func createRemoteJob(repoURL string) (string, error) { } // waitRemoteJob consulta el estado del job remoto hasta que el bundle esté -// listo (ready) o el servidor reporte un fallo (failed). +// listo (ready) o el servidor reporte un fallo (failed). Tolera un número +// acotado de errores transitorios del GET y falla con timeout si el servidor +// no termina el bundle dentro de remotePollTimeout. func waitRemoteJob(s *Store, job *Job, id string) (*remoteJobBundleInfo, error) { + deadline := time.Now().Add(remotePollTimeout) + consecutiveErrors := 0 for { st, err := getRemoteJob(id) if err != nil { - return nil, err + consecutiveErrors++ + if consecutiveErrors >= maxPollErrors { + return nil, fmt.Errorf("el servidor no responde al consultar el job %s: %w", id, err) + } + _ = s.SetProgress(job.ID, fmt.Sprintf( + "Servidor no responde (intento %d/%d), reintentando...", consecutiveErrors, maxPollErrors)) + time.Sleep(remotePollInterval) + continue } + consecutiveErrors = 0 + switch st.Status { case "ready": if st.Result == nil { @@ -177,7 +227,13 @@ func waitRemoteJob(s *Store, job *Job, id string) (*remoteJobBundleInfo, error) msg += ": " + st.Error } return nil, errors.New(msg) + case "cancelled": + return nil, fmt.Errorf("el job remoto %s fue cancelado", id) default: // queued | running + if time.Now().After(deadline) { + return nil, fmt.Errorf( + "timeout esperando el bundle del job %s (más de %s)", id, remotePollTimeout) + } msg := fmt.Sprintf("Servidor: %s", st.Status) if st.Progress != "" { msg += " — " + st.Progress @@ -188,9 +244,18 @@ func waitRemoteJob(s *Store, job *Job, id string) (*remoteJobBundleInfo, error) } } -// getRemoteJob consulta el estado de un job remoto. +// getRemoteJob consulta el estado de un job remoto (con la API key si está +// configurada y un timeout finito). func getRemoteJob(id string) (*remoteJobState, error) { - resp, err := http.Get(serverBase() + "/v2/jobs/" + id) + req, err := http.NewRequest(http.MethodGet, serverBase()+"/v2/jobs/"+id, nil) + if err != nil { + return nil, err + } + if key := os.Getenv("GITGOST_API_KEY"); key != "" { + req.Header.Set("X-Gitgost-Key", key) + } + + resp, err := jobHTTPClient.Do(req) if err != nil { return nil, err } @@ -219,6 +284,9 @@ func downloadBundle(s *Store, job *Job, downloadURL, dest string, size int64, sh _ = s.SetProgress(job.ID, "Bundle ya descargado en cache") return nil } + // Archivo completo pero corrupto: descartarlo antes de re-descargar para + // que downloadRange no pida un Range inválido (bytes=-). + _ = os.Remove(dest) } if err := downloadWithRetry(s, job, downloadURL, dest, size); err != nil { @@ -280,6 +348,11 @@ func downloadRange(rawURL, dest string, size int64, progress func(string)) error if st, err := os.Stat(dest); err == nil { partial = st.Size() } + // Un parcial igual o mayor que el tamaño total no se puede reanudar con + // Range: reiniciar desde cero (descarga completa) para recuperarse solo. + if size > 0 && partial >= size { + partial = 0 + } req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { @@ -355,13 +428,22 @@ func downloadRange(rawURL, dest string, size int64, progress func(string)) error // materializeClone crea el repo destino desde el bundle y fija el remote // origin a la URL original del usuario. Si el destino ya tiene .git (resume -// tras una materialización completada), solo se reajusta el origin. -func materializeClone(bundlePath, target, originURL string) error { +// tras una materialización completada), solo se reajusta el origin. Cuando el +// servidor reportó la rama por defecto, se asegura el checkout de esa rama. +func materializeClone(bundlePath, target, originURL, defaultBranch string) error { if _, err := os.Stat(filepath.Join(target, ".git")); os.IsNotExist(err) { if err := runGit("", "clone", bundlePath, target); err != nil { return fmt.Errorf("materializar repo desde bundle: %w", err) } } + if defaultBranch != "" { + cur, _ := gitOutput(target, "branch", "--show-current") + if strings.TrimSpace(cur) != defaultBranch { + if err := runGit(target, "checkout", defaultBranch); err != nil { + return fmt.Errorf("cambiar a la rama por defecto %s: %w", defaultBranch, err) + } + } + } _ = runGit(target, "remote", "remove", "origin") if err := runGit(target, "remote", "add", "origin", originURL); err != nil { return fmt.Errorf("configurar remote origin: %w", err) diff --git a/internal/jobs/clone_bundle_test.go b/internal/jobs/clone_bundle_test.go index 3a04c31..1d443ff 100644 --- a/internal/jobs/clone_bundle_test.go +++ b/internal/jobs/clone_bundle_test.go @@ -15,8 +15,9 @@ import ( // fakeBundleServer simula el servidor gitGost de Fase 2: acepta la creación // del job remoto, reporta el bundle listo y sirve el bundle con soporte de -// Range (registrando los headers Range recibidos). -func fakeBundleServer(t *testing.T, bundlePath string) (*httptest.Server, *[]string) { +// Range. Devuelve un accessor que devuelve una copia sincronizada de los +// headers Range recibidos. +func fakeBundleServer(t *testing.T, bundlePath string) (*httptest.Server, func() []string) { t.Helper() data, err := os.ReadFile(bundlePath) if err != nil { @@ -29,6 +30,13 @@ func fakeBundleServer(t *testing.T, bundlePath string) (*httptest.Server, *[]str var mu sync.Mutex var ranges []string + snapshot := func() []string { + mu.Lock() + defer mu.Unlock() + out := make([]string, len(ranges)) + copy(out, ranges) + return out + } var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -38,9 +46,10 @@ func fakeBundleServer(t *testing.T, bundlePath string) (*httptest.Server, *[]str _ = json.NewEncoder(w).Encode(map[string]any{ "status": "ready", "result": map[string]any{ - "downloadUrl": srv.URL + "/bundle", - "size": int64(len(data)), - "sha256": hash, + "downloadUrl": srv.URL + "/bundle", + "size": int64(len(data)), + "sha256": hash, + "defaultBranch": "main", }, }) case r.Method == http.MethodGet && r.URL.Path == "/bundle": @@ -51,7 +60,14 @@ func fakeBundleServer(t *testing.T, bundlePath string) (*httptest.Server, *[]str } mu.Unlock() if strings.HasPrefix(rng, "bytes=") { - n, _ := strconv.ParseInt(strings.TrimPrefix(rng, "bytes="), 10, 64) + // El rango llega como "bytes=N-": quitar el sufijo "-" antes de parsear. + spec := strings.TrimSuffix(strings.TrimPrefix(rng, "bytes="), "-") + n, err := strconv.ParseInt(spec, 10, 64) + if err != nil || n < 0 || n >= int64(len(data)) { + t.Errorf("Range inválido en el fake server: %q", rng) + http.Error(w, "invalid range", http.StatusRequestedRangeNotSatisfiable) + return + } w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", n, int64(len(data))-1, int64(len(data)))) w.WriteHeader(http.StatusPartialContent) _, _ = w.Write(data[n:]) @@ -63,7 +79,7 @@ func fakeBundleServer(t *testing.T, bundlePath string) (*httptest.Server, *[]str } })) t.Cleanup(srv.Close) - return srv, &ranges + return srv, snapshot } // withServerURL fija GITGOST_SERVER para que serverBase() apunte al fake. @@ -147,8 +163,8 @@ func TestRunCloneBundleFull(t *testing.T) { t.Errorf("rama actual = %q, se esperaba main", out) } origin, _ := gitOutput(dest, "remote", "get-url", "origin") - if strings.TrimSpace(origin) != "https://github.com/acme/widgets.git" { - t.Errorf("origin = %q, se esperaba la URL original https", origin) + if strings.TrimSpace(origin) != "git@github.com:acme/widgets.git" { + t.Errorf("origin = %q, se esperaba la URL original del usuario", origin) } } @@ -199,12 +215,13 @@ func TestDownloadBundleResumes(t *testing.T) { if got != hash { t.Error("el archivo final no coincide con el bundle original") } - if len(*ranges) == 0 { + rngs := ranges() + if len(rngs) == 0 { t.Fatal("no se envió ninguna petición Range") } want := fmt.Sprintf("bytes=%d-", half) - if (*ranges)[0] != want { - t.Errorf("Range = %q, se esperaba %q", (*ranges)[0], want) + if rngs[0] != want { + t.Errorf("Range = %q, se esperaba %q", rngs[0], want) } } diff --git a/web/index.html b/web/index.html index 5e4dcb7..93fe787 100644 --- a/web/index.html +++ b/web/index.html @@ -1676,12 +1676,12 @@

Realtime pageviews -
+
- Only anonymous pageview totals are counted. No IP, no fingerprint, no cookies, no personal data. Data are storage in Zurich. + Only anonymous pageview totals are counted. No IP, no fingerprint, no cookies, no personal data. Data are stored in Zurich.
From faf611723609c252eccfaf347f53c69800713541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Livr=C3=A4do=20Sandoval?= <104039397+livrasand@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:06:45 -0700 Subject: [PATCH 3/7] Validate clone URL; cancel remote jobs; use context for uploads Add URL validation in internal/git to avoid git option injection and restrict clone hosts/paths; fetch tags after shallow mirror clones so tags are included in bundles. Add cancelation support for remote jobs: store per-job CancelFunc in a sync.Map, let DELETE cancel running workers, and avoid overwriting a "Canceled" state when context.Canceled occurs. Propagate context through openbinUpload/openbinPost/openbinPut so uploads can be canceled. Remove the right sidebar from web/index.html. Minor import additions (net/url, sync). --- internal/git/bundle.go | 50 ++++++++++++++++++++++++++++++++++-- internal/http/jobs2.go | 57 +++++++++++++++++++++++++++++++++--------- web/index.html | 32 ------------------------ 3 files changed, 93 insertions(+), 46 deletions(-) diff --git a/internal/git/bundle.go b/internal/git/bundle.go index ea78319..9cc858f 100644 --- a/internal/git/bundle.go +++ b/internal/git/bundle.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "net/url" "os" "os/exec" "path/filepath" @@ -21,8 +22,15 @@ const bundleChunkSize = 500 // acota la duración total de los subprocesos git (evita workers colgados). func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, defaultBranch string, err error) { repoDir := filepath.Join(workDir, "repo") - // El separador -- evita que la URL (controlada por el usuario) se interprete - // como opción de git (p. ej. --upload-pack=...) aunque no pase validación. + // La URL proviene de la API (entrada del usuario). Se revalida aquí, en el + // punto donde se convierte en argumento de git, para que ninguna ruta de + // llamada pueda inyectar opciones (p. ej. --upload-pack=...) aunque el + // separador -- dejara de estar presente. + if !validCloneURL(url) { + return "", "", fmt.Errorf("URL de repositorio inválida: %q", url) + } + // El separador -- evita que la URL se interprete como opción de git incluso + // si la validación superior cambiara. if err := runGit(ctx, "", "clone", "--mirror", "--depth="+strconv.Itoa(bundleChunkSize), "--", url, repoDir); err != nil { return "", "", fmt.Errorf("clonar %s: %w", url, err) } @@ -53,6 +61,13 @@ func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, default } } + // En clones shallow git omite las tags que apuntan a commits fuera del rango + // vigente y no las vuelve a pedir al profundizar; sin este fetch explícito el + // bundle (--all) saldría sin esas tags para el cliente. + if err := runGit(ctx, repoDir, "fetch", "--tags", "origin"); err != nil { + return "", "", fmt.Errorf("traer tags de origin: %w", err) + } + // Rama por defecto: en un clon --mirror el HEAD local apunta a la ref remota. branch, err := gitOutput(ctx, repoDir, "symbolic-ref", "--short", "HEAD") if err != nil { @@ -66,6 +81,37 @@ func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, default return bundlePath, strings.TrimSpace(branch), nil } +// validCloneURL aplica la misma política que la API remota: solo https en los +// hosts soportados, sin credenciales y con ruta de dos segmentos restringidos. +// internal/git no puede importar internal/http (ciclo de dependencias), por lo +// que la validación se mantiene en el paquete que ejecuta git. +func validCloneURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil { + return false + } + switch u.Hostname() { + case "github.com", "gitlab.com", "codeberg.org": + default: + return false + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 { + return false + } + for _, p := range parts { + if strings.Contains(p, "..") { + return false + } + for _, r := range p { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') { + return false + } + } + } + return true +} + // shallowFile devuelve el contenido actual del marker de shallow, o vacío si el // repo ya no es shallow. Soporta repos bare (clone --mirror: /shallow) y // repos normales (/.git/shallow); devolver vacío si ninguno puede leerse diff --git a/internal/http/jobs2.go b/internal/http/jobs2.go index b377018..fcf2ae0 100644 --- a/internal/http/jobs2.go +++ b/internal/http/jobs2.go @@ -15,6 +15,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/livrasand/gitGost/internal/git" @@ -85,6 +86,10 @@ var remoteJobs = newBoundedMap[*remoteJob](remoteJobsMax, remoteJobsTTL) // pueden ejecutarse a la vez; el resto recibe 429 desde el handler. var remoteJobSlots = make(chan struct{}, remoteJobMaxConcurrent) +// remoteJobCancels registra el context.CancelFunc de cada worker en curso +// (clave: ID del job) para que DELETE pueda cancelar la operación en marcha. +var remoteJobCancels sync.Map + // openbinClient permite subir bundles grandes sin el timeout corto del proxy. var openbinClient = &http.Client{Timeout: 30 * time.Minute} @@ -146,6 +151,13 @@ func DeleteRemoteJobHandler(c *gin.Context) { next.Progress = "Cancelado" remoteJobs.Set(id, &next) } + // Cancelar el worker en curso si aún corre; LoadAndDelete lo retira del + // registro para que la cancelación ocurra una sola vez. + if v, ok := remoteJobCancels.LoadAndDelete(id); ok { + if cancel, ok := v.(context.CancelFunc); ok { + cancel() + } + } c.Status(http.StatusNoContent) } @@ -162,6 +174,15 @@ func runRemoteJob(job *remoteJob) { ctx, cancel := context.WithTimeout(context.Background(), remoteJobTimeout) defer cancel() + // Exponer el cancel a DELETE; se retira al salir del worker. + remoteJobCancels.Store(job.ID, cancel) + defer remoteJobCancels.Delete(job.ID) + + // DELETE pudo cancelar el job antes de que este worker se registrara: no + // iniciar trabajo pesado ni publicar estados si ya está cancelado. + if jobCancelled(job.ID) { + return + } // dir se captura por referencia: cada publish propaga el TmpDir real. dir := "" @@ -187,7 +208,11 @@ func runRemoteJob(job *remoteJob) { publish(rjRunning, "Descargando repositorio por capas...", "", nil) bundlePath, defaultBranch, err := git.CreateBundle(ctx, job.URL, dir) if err != nil { - publish(rjFailed, "", err.Error(), nil) + // Si DELETE canceló el job, no sobrescribir su estado con un fallo por + // context.Canceled: el estado cancelado queda hasta que el TTL lo evicte. + if !jobCancelled(job.ID) { + publish(rjFailed, "", err.Error(), nil) + } return } if jobCancelled(job.ID) { @@ -210,9 +235,11 @@ func runRemoteJob(job *remoteJob) { } publish(rjRunning, "Subiendo bundle a Openbin...", "", nil) - result, err := openbinUpload(bundlePath, bundleFilename(job.URL), size, hash) + result, err := openbinUpload(ctx, bundlePath, bundleFilename(job.URL), size, hash) if err != nil { - publish(rjFailed, "", err.Error(), nil) + if !jobCancelled(job.ID) { + publish(rjFailed, "", err.Error(), nil) + } return } if jobCancelled(job.ID) { @@ -228,8 +255,9 @@ func runRemoteJob(job *remoteJob) { // 2) PUT del objeto directo a Filebase (streaming, sin pasar por Vercel) // 3) POST /api/upload/confirm → CID + bin con expiración // size y hash ya están calculados por el llamador (evita recalcular el SHA-256 -// de un archivo que puede pesar gigabytes). -func openbinUpload(bundlePath, filename string, size int64, hash string) (*remoteJobResult, error) { +// de un archivo que puede pesar gigabytes). El contexto permite cancelar la +// subida si el job se borra o agota su timeout. +func openbinUpload(ctx context.Context, bundlePath, filename string, size int64, hash string) (*remoteJobResult, error) { base := strings.TrimRight(os.Getenv("OPENBIN_URL"), "/") if base == "" { base = "https://openbin.livrasand.com" @@ -251,7 +279,7 @@ func openbinUpload(bundlePath, filename string, size int64, hash string) (*remot _ = mw.Close() var presign openbinBinResponse - if err := openbinPost(base+"/api/upload", mw.FormDataContentType(), &form, &presign); err != nil { + if err := openbinPost(ctx, base+"/api/upload", mw.FormDataContentType(), &form, &presign); err != nil { return nil, fmt.Errorf("pedir subida a Openbin: %w", err) } @@ -276,7 +304,7 @@ func openbinUpload(bundlePath, filename string, size int64, hash string) (*remot } // 2) Subir el objeto directo a Filebase. - if err := openbinPut(presign.PresignedURL, bundlePath, size); err != nil { + if err := openbinPut(ctx, presign.PresignedURL, bundlePath, size); err != nil { return nil, fmt.Errorf("subir bundle a Filebase: %w", err) } @@ -286,7 +314,7 @@ func openbinUpload(bundlePath, filename string, size int64, hash string) (*remot "sha256": hash, }) var confirm openbinBinResponse - if err := openbinPost(base+"/api/upload/confirm", "application/json", bytes.NewReader(confirmBody), &confirm); err != nil { + if err := openbinPost(ctx, base+"/api/upload/confirm", "application/json", bytes.NewReader(confirmBody), &confirm); err != nil { return nil, fmt.Errorf("confirmar subida en Openbin: %w", err) } if confirm.Slug == "" || confirm.Cid == "" || confirm.DownloadURL == "" { @@ -323,8 +351,13 @@ type openbinBinResponse struct { } // openbinPost hace un POST a Openbin y decodifica la respuesta JSON. -func openbinPost(rawURL, contentType string, body io.Reader, out any) error { - resp, err := openbinClient.Post(rawURL, contentType, body) +func openbinPost(ctx context.Context, rawURL, contentType string, body io.Reader, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", contentType) + resp, err := openbinClient.Do(req) if err != nil { return err } @@ -342,14 +375,14 @@ func openbinPost(rawURL, contentType string, body io.Reader, out any) error { // openbinPut sube el bundle a la URL firmada de Filebase (streaming). // ContentLength evita el transfer-encoding chunked; el Content-Type coincide // con el mime declarado en el presign (Openbin firma ese header). -func openbinPut(rawURL, path string, size int64) error { +func openbinPut(ctx context.Context, rawURL, path string, size int64) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() - req, err := http.NewRequest(http.MethodPut, rawURL, f) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, f) if err != nil { return err } diff --git a/web/index.html b/web/index.html index 93fe787..5932ad1 100644 --- a/web/index.html +++ b/web/index.html @@ -1668,38 +1668,6 @@

- - -
From cc49e5913372b91d755e9771b5a2e9aeccd4d6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Livr=C3=A4do=20Sandoval?= <104039397+livrasand@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:16:57 -0700 Subject: [PATCH 4/7] Validate and normalize clone URL before git clone Replace the boolean validCloneURL with safeCloneURL which validates and returns a normalized URL string or an error. CreateBundle now calls safeCloneURL and passes the sanitized URL to git clone, ensuring the raw user input is never used as a command argument. Preserves the same hosts/path checks and rejection of credentials/non-HTTPS, and clarifies comments about import-cycle constraints. Improves error reporting when the repository URL is invalid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/git/bundle.go | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/internal/git/bundle.go b/internal/git/bundle.go index 9cc858f..2c5eda6 100644 --- a/internal/git/bundle.go +++ b/internal/git/bundle.go @@ -26,12 +26,14 @@ func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, default // punto donde se convierte en argumento de git, para que ninguna ruta de // llamada pueda inyectar opciones (p. ej. --upload-pack=...) aunque el // separador -- dejara de estar presente. - if !validCloneURL(url) { - return "", "", fmt.Errorf("URL de repositorio inválida: %q", url) + safeURL, err := safeCloneURL(url) + if err != nil { + return "", "", err } // El separador -- evita que la URL se interprete como opción de git incluso - // si la validación superior cambiara. - if err := runGit(ctx, "", "clone", "--mirror", "--depth="+strconv.Itoa(bundleChunkSize), "--", url, repoDir); err != nil { + // si la validación superior cambiara; el argumento es la URL reconstruida + // del parseo validado, nunca el raw del usuario. + if err := runGit(ctx, "", "clone", "--mirror", "--depth="+strconv.Itoa(bundleChunkSize), "--", safeURL, repoDir); err != nil { return "", "", fmt.Errorf("clonar %s: %w", url, err) } @@ -81,35 +83,36 @@ func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, default return bundlePath, strings.TrimSpace(branch), nil } -// validCloneURL aplica la misma política que la API remota: solo https en los -// hosts soportados, sin credenciales y con ruta de dos segmentos restringidos. -// internal/git no puede importar internal/http (ciclo de dependencias), por lo -// que la validación se mantiene en el paquete que ejecuta git. -func validCloneURL(raw string) bool { +// safeCloneURL valida la URL del usuario y devuelve su forma normalizada. +// La URL que llega a git siempre se deriva de este parseo validado: el raw del +// usuario nunca se usa como argumento de comando. internal/git no puede +// importar internal/http (ciclo de dependencias), por lo que la validación se +// mantiene en el paquete que ejecuta git. +func safeCloneURL(raw string) (string, error) { u, err := url.Parse(raw) if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil { - return false + return "", fmt.Errorf("URL de repositorio inválida: %q", raw) } switch u.Hostname() { case "github.com", "gitlab.com", "codeberg.org": default: - return false + return "", fmt.Errorf("URL de repositorio inválida: %q", raw) } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) != 2 { - return false + return "", fmt.Errorf("URL de repositorio inválida: %q", raw) } for _, p := range parts { if strings.Contains(p, "..") { - return false + return "", fmt.Errorf("URL de repositorio inválida: %q", raw) } for _, r := range p { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') { - return false + return "", fmt.Errorf("URL de repositorio inválida: %q", raw) } } } - return true + return u.String(), nil } // shallowFile devuelve el contenido actual del marker de shallow, o vacío si el From b1e154a6be8c67bbd1585889bea61f8b6525552e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Livr=C3=A4do=20Sandoval?= <104039397+livrasand@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:35:56 -0700 Subject: [PATCH 5/7] Remove Spanish comments from internal/cli, internal/git packages Strip all Spanish-language inline comments from internal/cli (cli.go, proc_unix.go, proc_windows.go, rewrite.go, rewrite_test.go) and internal/git (bundle.go, push.go, receive.go). Replace safeCloneURL's url.Parse validation with a single regex pattern (repoURLPattern) that validates and captures allowed hosts (github.com, gitlab.com, codeberg.org), optional port, and exactly two path segments (owner/repo), then reconstructs the URL from matched groups to ensure user input --- internal/cli/cli.go | 35 ++-------------------- internal/cli/proc_unix.go | 4 --- internal/cli/proc_windows.go | 4 --- internal/cli/rewrite.go | 8 ------ internal/cli/rewrite_test.go | 1 - internal/git/bundle.go | 42 +++++++++++---------------- internal/git/push.go | 3 -- internal/git/receive.go | 40 -------------------------- internal/git/rewrite.go | 3 -- internal/git/squash.go | 8 +----- internal/github/github_test.go | 10 ------- internal/github/ntfy.go | 10 ------- internal/github/pr.go | 51 --------------------------------- internal/http/appeal.go | 24 ---------------- internal/http/e2e_test.go | 24 ---------------- internal/http/ethicalmetrics.go | 1 - 16 files changed, 21 insertions(+), 247 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b68ae1d..a259c12 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -14,10 +14,8 @@ import ( "github.com/livrasand/gitGost/internal/jobs" ) -// version del CLI; puede inyectarse con -ldflags en el build. var version = "0.1.0" -// Run despacha el subcomando de `git gost ...`. Devuelve el código de salida. func Run(args []string) int { if len(args) == 0 { printUsage() @@ -45,7 +43,7 @@ func Run(args []string) int { return cmdResume(args[1:]) case "cancel": return cmdCancel(args[1:]) - case "run": // interno: ejecuta un job en background + case "run": return cmdRun(args[1:]) case "help", "-h", "--help": printUsage() @@ -54,12 +52,10 @@ func Run(args []string) int { fmt.Printf("git-gost %s\n", version) return 0 default: - // Pass-through: cualquier comando Git sin lógica especial. return passthrough(args) } } -// passthrough reenvía el comando a Git (status, log, branch, diff, ...). func passthrough(args []string) int { cmd := exec.Command("git", args...) cmd.Stdin = os.Stdin @@ -75,7 +71,6 @@ func passthrough(args []string) int { return 0 } -// dataDir devuelve el directorio de datos del cliente (env GITGOST_HOME o ~/.gitgost). func dataDir() string { if v := os.Getenv("GITGOST_HOME"); v != "" { return v @@ -95,9 +90,6 @@ func openStore() (*jobs.Store, error) { return jobs.Open(dbPath()) } -// cmdInstall prepara el entorno del cliente: crea la cola SQLite y, si el -// binario no está disponible como 'git-gost' en el PATH, se auto-instala en -// ~/.local/bin y añade ese directorio al PATH del shell. func cmdInstall() int { s, err := openStore() if err != nil { @@ -116,9 +108,6 @@ func cmdInstall() int { return 0 } -// selfInstall copia el binario en ejecución como 'git-gost' a ~/.local/bin y, -// en Unix, añade ese directorio al PATH del shell si no está ya. No hace nada -// si ya hay un 'git-gost' disponible en el PATH. func selfInstall() error { if _, err := exec.LookPath("git-gost"); err == nil { return nil @@ -154,14 +143,12 @@ func selfInstall() error { } if runtime.GOOS == "windows" { - // En Windows no se editan rc files: se indica el directorio a añadir. fmt.Printf("Añade %s a tu PATH para usar 'git gost ...'.\n", binDir) return nil } return addToPath(binDir, home) } -// addToPath añade dir al PATH del shell del usuario si no está ya referenciado. func addToPath(dir, home string) error { shell := filepath.Base(os.Getenv("SHELL")) var rc, line string @@ -175,7 +162,7 @@ func addToPath(dir, home string) error { rc = filepath.Join(home, ".bash_profile") } line = fmt.Sprintf(`export PATH=%q:$PATH`, dir) - default: // zsh y cualquier otro shell + default: rc = filepath.Join(home, ".zshrc") line = fmt.Sprintf(`export PATH=%q:$PATH`, dir) } @@ -185,7 +172,7 @@ func addToPath(dir, home string) error { return err } if strings.Contains(string(data), dir) { - return nil // el directorio ya está en el PATH del shell + return nil } if err := os.MkdirAll(filepath.Dir(rc), 0o755); err != nil { @@ -203,7 +190,6 @@ func addToPath(dir, home string) error { return nil } -// copyFile copia un archivo (los permisos se ajustan aparte). func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { @@ -223,7 +209,6 @@ func copyFile(src, dst string) error { return out.Close() } -// cmdClone crea un job de clone. Por defecto corre en background; con -f/--foreground en primer plano. func cmdClone(args []string) int { foreground, rest, err := parseFlags(args) if err != nil { @@ -276,8 +261,6 @@ func cmdClone(args []string) int { return launchBackground(s, id) } -// defaultCloneDir deriva el directorio destino de un clone a partir de la URL -// original (basename del repo, sin .git), igual que hace `git clone`. func defaultCloneDir(raw string) string { u, err := parseRepoURL(raw) if err != nil { @@ -290,7 +273,6 @@ func defaultCloneDir(raw string) string { return strings.TrimSuffix(parts[len(parts)-1], ".git") } -// cmdGitJob crea un job para fetch/pull/push en el repositorio actual. func cmdGitJob(op string, args []string) int { foreground, rest, err := parseFlags(args) if err != nil { @@ -324,7 +306,6 @@ func cmdGitJob(op string, args []string) int { return launchBackground(s, id) } -// parseFlags extrae -f/--foreground del resto de argumentos. func parseFlags(args []string) (foreground bool, rest []string, err error) { for _, a := range args { switch a { @@ -337,7 +318,6 @@ func parseFlags(args []string) (foreground bool, rest []string, err error) { return foreground, rest, nil } -// runForeground ejecuta el job en primer plano y muestra el resultado. func runForeground(s *jobs.Store, id int64) int { if err := jobs.Run(s, id); err != nil { job, gerr := s.Get(id) @@ -352,7 +332,6 @@ func runForeground(s *jobs.Store, id int64) int { return 0 } -// launchBackground lanza `git-gost run ` como proceso independiente. func launchBackground(s *jobs.Store, id int64) int { pid, err := startBackground(id) if err != nil { @@ -366,7 +345,6 @@ func launchBackground(s *jobs.Store, id int64) int { return 0 } -// cmdRun ejecuta un job de la cola (invocado como proceso hijo en background). func cmdRun(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "uso interno: git gost run ") @@ -390,7 +368,6 @@ func cmdRun(args []string) int { return 0 } -// cmdJobs lista los jobs recientes de la cola. func cmdJobs(args []string) int { s, err := openStore() if err != nil { @@ -427,7 +404,6 @@ func cmdJobs(args []string) int { return 0 } -// cmdWatch sigue el progreso de un job hasta que termina. func cmdWatch(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "uso: git gost watch ") @@ -468,7 +444,6 @@ func cmdWatch(args []string) int { } } -// cmdPause pausa un job en cola o en ejecución. func cmdPause(args []string) int { return signalCommand("pause", args, func(j *jobs.Job, s *jobs.Store) (int, error) { switch j.State { @@ -485,7 +460,6 @@ func cmdPause(args []string) int { }) } -// cmdResume reanuda un job pausado. func cmdResume(args []string) int { return signalCommand("resume", args, func(j *jobs.Job, s *jobs.Store) (int, error) { if j.State != jobs.StatePaused { @@ -497,7 +471,6 @@ func cmdResume(args []string) int { } return 0, s.SetState(j.ID, jobs.StateRunning) } - // Pausado sin proceso (estaba en cola): relanzar en background. pid, err := startBackground(j.ID) if err != nil { return 0, err @@ -507,7 +480,6 @@ func cmdResume(args []string) int { }) } -// cmdCancel cancela un job pendiente o en ejecución. func cmdCancel(args []string) int { return signalCommand("cancel", args, func(j *jobs.Job, s *jobs.Store) (int, error) { switch j.State { @@ -524,7 +496,6 @@ func cmdCancel(args []string) int { }) } -// signalCommand resuelve el id, aplica la acción y reporta el resultado. func signalCommand(name string, args []string, action func(*jobs.Job, *jobs.Store) (int, error)) int { if len(args) == 0 { fmt.Fprintf(os.Stderr, "uso: git gost %s \n", name) diff --git a/internal/cli/proc_unix.go b/internal/cli/proc_unix.go index 1b4cad4..cc1396f 100644 --- a/internal/cli/proc_unix.go +++ b/internal/cli/proc_unix.go @@ -16,8 +16,6 @@ var ( sigTerm = syscall.SIGTERM ) -// startBackground lanza `git-gost run ` como proceso independiente en su -// propio grupo de procesos, de modo que pause/resume/cancel puedan señalarlo. func startBackground(id int64) (int, error) { self, err := os.Executable() if err != nil { @@ -36,13 +34,11 @@ func startBackground(id int64) (int, error) { if err := cmd.Start(); err != nil { return 0, err } - // Capturar el PID antes de Release: en Go 1.25, Release invalida Pid (-1). pid := cmd.Process.Pid cmd.Process.Release() return pid, nil } -// signalJob envía una señal a todo el grupo de procesos del job. func signalJob(pid int, sig syscall.Signal) error { if pid <= 0 { return fmt.Errorf("el job no tiene proceso en background activo") diff --git a/internal/cli/proc_windows.go b/internal/cli/proc_windows.go index dbbdf1a..0ecdc56 100644 --- a/internal/cli/proc_windows.go +++ b/internal/cli/proc_windows.go @@ -16,7 +16,6 @@ var ( sigTerm = syscall.Signal(0) ) -// startBackground lanza `git-gost run ` como proceso independiente. func startBackground(id int64) (int, error) { self, err := os.Executable() if err != nil { @@ -37,14 +36,11 @@ func startBackground(id int64) (int, error) { if err := cmd.Start(); err != nil { return 0, err } - // Capturar el PID antes de Release: en Go 1.25, Release invalida Pid (-1). pid := cmd.Process.Pid cmd.Process.Release() return pid, nil } -// signalJob no está soportado en Windows en esta fase: no hay pausa real por -// señales y cancelar un proceso en ejecución requiere taskkill. func signalJob(pid int, sig syscall.Signal) error { return fmt.Errorf("pause/resume/cancel en ejecución no está soportado en Windows en esta fase") } diff --git a/internal/cli/rewrite.go b/internal/cli/rewrite.go index 41b6495..4d71366 100644 --- a/internal/cli/rewrite.go +++ b/internal/cli/rewrite.go @@ -7,7 +7,6 @@ import ( "strings" ) -// ServerBase devuelve la URL base del servidor gitGost (env GITGOST_SERVER o default). func ServerBase() string { if v := os.Getenv("GITGOST_SERVER"); v != "" { return strings.TrimRight(v, "/") @@ -15,16 +14,12 @@ func ServerBase() string { return "https://gitgost.fly.dev" } -// hostPrefix mapea el host del repositorio al prefijo de ruta del servidor gitGost. var hostPrefix = map[string]string{ "github.com": "gh", "gitlab.com": "gl", "codeberg.org": "cb", } -// RewriteURL convierte una URL de repositorio (https o scp-like SSH) en la ruta -// equivalente del servidor gitGost: /v1//owner/repo. Así el tráfico de -// clone/fetch pasa por gitGost sin tocar la URL original del usuario. func RewriteURL(base, raw string) (string, error) { base = strings.TrimRight(base, "/") u, err := parseRepoURL(raw) @@ -49,7 +44,6 @@ func RewriteURL(base, raw string) (string, error) { return fmt.Sprintf("%s/v1/%s/%s/%s", base, prefix, owner, repo), nil } -// parseRepoURL acepta URLs https://host/owner/repo y scp-like git@host:owner/repo.git. func parseRepoURL(raw string) (*url.URL, error) { if strings.Contains(raw, "://") { u, err := url.Parse(raw) @@ -62,7 +56,6 @@ func parseRepoURL(raw string) (*url.URL, error) { return u, nil } - // scp-like: user@host:owner/repo(.git) at := strings.LastIndex(raw, "@") colon := strings.Index(raw, ":") if at >= 0 && colon > at { @@ -83,7 +76,6 @@ func splitPath(p string) []string { return out } -// validSegment valida un segmento owner/repo (alfanumérico, -, _, .). func validSegment(s string) bool { if len(s) == 0 || len(s) > 100 { return false diff --git a/internal/cli/rewrite_test.go b/internal/cli/rewrite_test.go index 891f3c8..1d5656a 100644 --- a/internal/cli/rewrite_test.go +++ b/internal/cli/rewrite_test.go @@ -28,7 +28,6 @@ func TestRewriteURL(t *testing.T) { }) } - // La base con slash final también debe funcionar. if got, err := RewriteURL("https://gitgost.fly.dev/", "https://github.com/foo/bar"); err != nil || got != "https://gitgost.fly.dev/v1/gh/foo/bar" { t.Errorf("base con slash final: got=%q err=%v", got, err) } diff --git a/internal/git/bundle.go b/internal/git/bundle.go index 2c5eda6..5b34448 100644 --- a/internal/git/bundle.go +++ b/internal/git/bundle.go @@ -4,10 +4,10 @@ import ( "bytes" "context" "fmt" - "net/url" "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" ) @@ -83,36 +83,28 @@ func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, default return bundlePath, strings.TrimSpace(branch), nil } +// repoURLPattern valida y captura una URL https de repositorio permitida: +// host en {github.com, gitlab.com, codeberg.org} (case-insensitive), puerto +// opcional, y exactamente dos segmentos de ruta owner/repo con caracteres +// permitidos. El resultado reconstruido nunca incluye userinfo, query, +// fragment ni segmentos extra. +var repoURLPattern = regexp.MustCompile(`(?i)^https://(github\.com|gitlab\.com|codeberg\.org)(?::\d+)?/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/?$`) + // safeCloneURL valida la URL del usuario y devuelve su forma normalizada. -// La URL que llega a git siempre se deriva de este parseo validado: el raw del -// usuario nunca se usa como argumento de comando. internal/git no puede -// importar internal/http (ciclo de dependencias), por lo que la validación se -// mantiene en el paquete que ejecuta git. +// La URL que llega a git se reconstruye exclusivamente desde los grupos +// validados por la expresión regular; el raw del usuario nunca se usa +// directamente como argumento de comando. func safeCloneURL(raw string) (string, error) { - u, err := url.Parse(raw) - if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil { - return "", fmt.Errorf("URL de repositorio inválida: %q", raw) - } - switch u.Hostname() { - case "github.com", "gitlab.com", "codeberg.org": - default: + m := repoURLPattern.FindStringSubmatch(raw) + if m == nil { return "", fmt.Errorf("URL de repositorio inválida: %q", raw) } - parts := strings.Split(strings.Trim(u.Path, "/"), "/") - if len(parts) != 2 { + host := strings.ToLower(m[1]) + owner, repo := m[2], m[3] + if strings.Contains(owner, "..") || strings.Contains(repo, "..") { return "", fmt.Errorf("URL de repositorio inválida: %q", raw) } - for _, p := range parts { - if strings.Contains(p, "..") { - return "", fmt.Errorf("URL de repositorio inválida: %q", raw) - } - for _, r := range p { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') { - return "", fmt.Errorf("URL de repositorio inválida: %q", raw) - } - } - } - return u.String(), nil + return fmt.Sprintf("https://%s/%s/%s", host, owner, repo), nil } // shallowFile devuelve el contenido actual del marker de shallow, o vacío si el diff --git a/internal/git/push.go b/internal/git/push.go index b227156..02b180e 100644 --- a/internal/git/push.go +++ b/internal/git/push.go @@ -17,9 +17,6 @@ func min(a, b int) int { return b } -// PushToGitHub pushes to a fork repository. -// pushURL is the full HTTPS URL of the fork; if empty, defaults to GitHub. -// tokenEnvVar is the env var name for the auth token; if empty, defaults to GITHUB_TOKEN. func PushToGitHub(owner, repo, tempDir, forkOwner, targetBranch string, pushURL string, tokenEnvVar string) (string, error) { if tokenEnvVar == "" { tokenEnvVar = "GITHUB_TOKEN" diff --git a/internal/git/receive.go b/internal/git/receive.go index 8af53d9..bfeef64 100644 --- a/internal/git/receive.go +++ b/internal/git/receive.go @@ -61,14 +61,11 @@ type RefUpdate struct { Ref string } -// ExtractPackfile extrae el packfile y la información de actualización de refs. -// También parsea push-options (e.g. pr-hash=a3f8c1d2) enviadas por el cliente. func ExtractPackfile(body []byte) ([]byte, *RefUpdate, string, error) { reader := bytes.NewReader(body) var refUpdate *RefUpdate var prHash string - // Leer las líneas de comandos (actualizaciones de refs) for { line, err := ParsePktLine(reader) if err != nil { @@ -78,16 +75,13 @@ func ExtractPackfile(body []byte) ([]byte, *RefUpdate, string, error) { return nil, nil, "", fmt.Errorf("error parsing pkt-line: %v", err) } - // flush packet indica fin de comandos if line == nil { break } - // Las líneas de comando terminan con \n o \x00 lineStr := string(line) debugf("DEBUG: Command line: %q\n", lineStr) - // Parsear push-option: pr-hash= if strings.HasPrefix(lineStr, "push-option=pr-hash=") { prHash = strings.TrimPrefix(lineStr, "push-option=pr-hash=") prHash = strings.TrimRight(prHash, "\n") @@ -95,7 +89,6 @@ func ExtractPackfile(body []byte) ([]byte, *RefUpdate, string, error) { continue } - // Parsear comando: old-sha new-sha ref\x00capabilities parts := strings.Fields(lineStr) if len(parts) >= 3 && refUpdate == nil { refUpdate = &RefUpdate{ @@ -106,9 +99,7 @@ func ExtractPackfile(body []byte) ([]byte, *RefUpdate, string, error) { debugf("DEBUG: Parsed ref update: %s -> %s for %s\n", refUpdate.OldSHA, refUpdate.NewSHA, refUpdate.Ref) } - // Si encontramos "PACK", retrocedemos porque es el inicio del packfile if strings.Contains(lineStr, "PACK") { - // Retroceder al inicio del PACK currentPos, err := reader.Seek(0, io.SeekCurrent) if err != nil { return nil, nil, "", fmt.Errorf("failed to determine pack start: %v", err) @@ -122,15 +113,12 @@ func ExtractPackfile(body []byte) ([]byte, *RefUpdate, string, error) { } } - // Ahora leer el resto como packfile packfile, err := io.ReadAll(reader) if err != nil { return nil, nil, "", err } - // Verificar que comience con "PACK" if len(packfile) < 4 || !bytes.Equal(packfile[:4], []byte("PACK")) { - // Buscar PACK en todo el body como fallback packStart := bytes.Index(body, []byte("PACK")) if packStart == -1 { return nil, nil, "", fmt.Errorf("no packfile found in body") @@ -144,9 +132,6 @@ func ExtractPackfile(body []byte) ([]byte, *RefUpdate, string, error) { return packfile, refUpdate, prHash, nil } -// ReceivePack clona el repo remoto y aplica el packfile recibido, retorna el SHA del nuevo commit, el mensaje del commit y el pr-hash push-option (si fue enviado). -// cloneURL es la URL HTTPS completa del repositorio; si está vacía se usa GitHub por defecto (compatibilidad). -// tokenEnvVar es el nombre de la variable de entorno con el token de autenticación; si está vacío usa GITHUB_TOKEN. func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneURL string, tokenEnvVar string) (string, string, string, error) { if cloneURL == "" { cloneURL = fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) @@ -170,7 +155,6 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR }, }) if err != nil { - // Si falla el clone (repo no existe o privado), inicializar vacío debugf("DEBUG: Clone failed, initializing empty repo: %v\n", err) _, err = git.PlainInit(tempDir, false) if err != nil { @@ -178,13 +162,11 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR } } - // Crear directorio pack (necesario para git index-pack) packDir := tempDir + "/.git/objects/pack" if err := os.MkdirAll(packDir, 0755); err != nil { return "", "", "", fmt.Errorf("failed to create pack dir: %v", err) } - // Si body está vacío, salir if len(body) == 0 { return "", "", "", nil } @@ -192,7 +174,6 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR debugf("DEBUG: Body length: %d bytes\n", len(body)) debugf("DEBUG: First 100 bytes: %x\n", body[:min(100, len(body))]) - // Extraer packfile del protocolo Git Smart HTTP packfile, refUpdate, prHash, err := ExtractPackfile(body) if err != nil { return "", "", "", fmt.Errorf("failed to extract packfile: %v", err) @@ -206,14 +187,12 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR debugf("DEBUG: Packfile size: %d bytes\n", len(packfile)) - // Guardar packfile temporalmente packfilePath := tempDir + "/pack.tmp" err = os.WriteFile(packfilePath, packfile, 0644) if err != nil { return "", "", "", fmt.Errorf("failed to write packfile: %v", err) } - // Usar git index-pack en lugar de unpack-objects (más robusto) cmd := exec.Command("git", "index-pack", "-v", "--stdin", "--fix-thin") cmd.Dir = packDir cmd.Stdin = bytes.NewReader(packfile) @@ -222,7 +201,6 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR debugf("DEBUG: git index-pack output: %s\n", string(output)) if err != nil { - // Si index-pack falla, intentar unpack-objects debugf("DEBUG: index-pack failed, trying unpack-objects\n") cmd = exec.Command("git", "unpack-objects", "-r") cmd.Dir = tempDir @@ -236,13 +214,11 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR } } - // Abrir repositorio r, err := git.PlainOpen(tempDir) if err != nil { return "", "", "", fmt.Errorf("failed to open repo: %v", err) } - // Actualizar HEAD al nuevo commit newHash := plumbing.NewHash(refUpdate.NewSHA) ref := plumbing.NewHashReference(plumbing.HEAD, newHash) err = r.Storer.SetReference(ref) @@ -252,7 +228,6 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR debugf("DEBUG: Updated HEAD to %s\n", refUpdate.NewSHA) - // Extraer el mensaje del commit original antes de anonimizar originalCommit, err := r.CommitObject(newHash) if err != nil { return "", "", "", fmt.Errorf("failed to get original commit: %v", err) @@ -260,7 +235,6 @@ func ReceivePack(tempDir string, body []byte, owner string, repo string, cloneUR commitMessage := originalCommit.Message debugf("DEBUG: Original commit message: %s\n", commitMessage) - // Reescribir commits para anonimizar anonymizedSHA, err := AnonymizeCommits(r, refUpdate.NewSHA) if err != nil { return "", "", "", fmt.Errorf("failed to anonymize commits: %v", err) @@ -309,17 +283,14 @@ func resolveBaseReference(r *git.Repository) *plumbing.Reference { return nil } -// AnonymizeCommits reescribe solo los commits nuevos para anonimizar autor y committer func AnonymizeCommits(r *git.Repository, targetSHA string) (string, error) { targetHash := plumbing.NewHash(targetSHA) - // Obtener el commit objetivo targetCommit, err := r.CommitObject(targetHash) if err != nil { return "", fmt.Errorf("failed to get target commit: %v", err) } - // Obtener todos los commits que ya existen en la rama por defecto del remoto. baseCommits := make(map[plumbing.Hash]bool) baseRef := resolveBaseReference(r) if baseRef != nil { @@ -334,16 +305,13 @@ func AnonymizeCommits(r *git.Repository, targetSHA string) (string, error) { debugf("DEBUG: Base commits count: %d\n", len(baseCommits)) - // Mapeo de commits originales a anonimizados commitMap := make(map[plumbing.Hash]plumbing.Hash) - // Reescribir commits recursivamente (solo los nuevos) newHash, err := rewriteCommit(r, targetCommit, commitMap, baseCommits) if err != nil { return "", err } - // Actualizar HEAD al nuevo commit anonimizado ref := plumbing.NewHashReference(plumbing.HEAD, newHash) err = r.Storer.SetReference(ref) if err != nil { @@ -353,25 +321,20 @@ func AnonymizeCommits(r *git.Repository, targetSHA string) (string, error) { return newHash.String(), nil } -// rewriteCommit reescribe un commit y sus padres recursivamente func rewriteCommit(r *git.Repository, commit *object.Commit, commitMap map[plumbing.Hash]plumbing.Hash, baseCommits map[plumbing.Hash]bool) (plumbing.Hash, error) { - // Si ya reescribimos este commit, retornar el hash anonimizado if newHash, exists := commitMap[commit.Hash]; exists { return newHash, nil } - // Si este commit ya existe en el repo base, no lo reescribimos if baseCommits[commit.Hash] { debugf("DEBUG: Skipping base commit %s\n", commit.Hash.String()[:8]) return commit.Hash, nil } - // Reescribir padres primero var newParents []plumbing.Hash for _, parentHash := range commit.ParentHashes { parentCommit, err := r.CommitObject(parentHash) if err != nil { - // Si el padre no existe, usar el hash original newParents = append(newParents, parentHash) continue } @@ -383,7 +346,6 @@ func rewriteCommit(r *git.Repository, commit *object.Commit, commitMap map[plumb newParents = append(newParents, newParentHash) } - // Crear nuevo commit con información anonimizada anonSignature := object.Signature{ Name: "@gitgost-anonymous", Email: "anonymous@gitgost.local", @@ -398,7 +360,6 @@ func rewriteCommit(r *git.Repository, commit *object.Commit, commitMap map[plumb ParentHashes: newParents, } - // Codificar y guardar el nuevo commit obj := r.Storer.NewEncodedObject() err := newCommit.Encode(obj) if err != nil { @@ -410,7 +371,6 @@ func rewriteCommit(r *git.Repository, commit *object.Commit, commitMap map[plumb return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %v", err) } - // Guardar en el mapa commitMap[commit.Hash] = newHash debugf("DEBUG: Rewritten commit %s -> %s\n", commit.Hash.String()[:8], newHash.String()[:8]) diff --git a/internal/git/rewrite.go b/internal/git/rewrite.go index 7937f54..536b5cd 100644 --- a/internal/git/rewrite.go +++ b/internal/git/rewrite.go @@ -1,8 +1,5 @@ package git -// RewriteCommits is a stub for future implementation of history rewrite -// to anonymize multiple commits without squashing func RewriteCommits(tempDir string) error { - // TODO: Implement history rewrite for multiple commits return nil } diff --git a/internal/git/squash.go b/internal/git/squash.go index 885ff96..7cd845e 100644 --- a/internal/git/squash.go +++ b/internal/git/squash.go @@ -14,7 +14,6 @@ func SquashCommits(tempDir string) (string, error) { return "", err } - // Get all references refs, err := r.References() if err != nil { return "", err @@ -23,12 +22,11 @@ func SquashCommits(tempDir string) (string, error) { var latestCommit *object.Commit var treeHash plumbing.Hash - // Find the latest commit from any branch err = refs.ForEach(func(ref *plumbing.Reference) error { if ref.Name().IsBranch() { commit, err := r.CommitObject(ref.Hash()) if err != nil { - return nil // Skip invalid refs + return nil } if latestCommit == nil || commit.Committer.When.After(latestCommit.Committer.When) { latestCommit = commit @@ -42,9 +40,7 @@ func SquashCommits(tempDir string) (string, error) { return "", err } - // If no commits found, create initial commit with empty tree if latestCommit == nil { - // Create empty tree tree := &object.Tree{} obj := r.Storer.NewEncodedObject() err = tree.Encode(obj) @@ -57,7 +53,6 @@ func SquashCommits(tempDir string) (string, error) { } } - // Create new anonymous commit newCommit := &object.Commit{ Author: object.Signature{ Name: "@gitgost-anonymous", @@ -84,7 +79,6 @@ func SquashCommits(tempDir string) (string, error) { return "", err } - // Update HEAD err = r.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, hash)) if err != nil { return "", err diff --git a/internal/github/github_test.go b/internal/github/github_test.go index bf822a0..b152338 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -6,11 +6,9 @@ import ( ) func TestCreatePR_NoToken(t *testing.T) { - // Save original env originalToken := os.Getenv("GITHUB_TOKEN") defer os.Setenv("GITHUB_TOKEN", originalToken) - // Remove token os.Unsetenv("GITHUB_TOKEN") _, err := CreatePR("owner", "repo", "branch", "forkowner", "test commit message") @@ -21,11 +19,3 @@ func TestCreatePR_NoToken(t *testing.T) { t.Errorf("Expected 'GITHUB_TOKEN not set', got '%s'", err.Error()) } } - -// Note: Testing CreatePR with actual GitHub API would require: -// 1. A valid GitHub token -// 2. A real repository -// 3. A real branch -// 4. Network access -// This would be an integration test, not a unit test. -// For now, we test the error case when token is missing. diff --git a/internal/github/ntfy.go b/internal/github/ntfy.go index bd323af..efc7bd0 100644 --- a/internal/github/ntfy.go +++ b/internal/github/ntfy.go @@ -10,12 +10,10 @@ import ( var ntfyClient = &http.Client{Timeout: 10 * time.Second} -// NtfyTopicForPR returns the ntfy topic for a given PR hash. func NtfyTopicForPR(prHash string) string { return fmt.Sprintf("gitgost-%s", prHash) } -// NtfyBaseURL returns the ntfy base URL (configurable via NTFY_BASE_URL, default ntfy.sh). func NtfyBaseURL() string { if base := os.Getenv("NTFY_BASE_URL"); base != "" { return base @@ -23,8 +21,6 @@ func NtfyBaseURL() string { return "https://ntfy.sh" } -// NtfyServiceURL returns the public-facing service URL used in admin action buttons. -// Configurable via SERVICE_URL env var; falls back to the default deployed URL. func NtfyServiceURL() string { if u := os.Getenv("SERVICE_URL"); u != "" { return u @@ -32,9 +28,6 @@ func NtfyServiceURL() string { return "https://gitgost.fly.dev" } -// PublishNtfyEvent publishes an event to the ntfy topic corresponding to a PR hash. -// actions: optional ntfy Actions header value (e.g. a button to check PR status). -// Pass empty string to send without action buttons. func PublishNtfyEvent(prHash, title, message, actions string) error { topic := NtfyTopicForPR(prHash) url := fmt.Sprintf("%s/%s", NtfyBaseURL(), topic) @@ -63,9 +56,6 @@ func PublishNtfyEvent(prHash, title, message, actions string) error { return nil } -// PublishNtfyAdmin publishes an admin alert with an optional ntfy action button. -// actions: ntfy Actions header value (e.g. HTTP POST button to activate panic mode). -// Pass empty string to send without action buttons. func PublishNtfyAdmin(topic, title, message, actions string) error { url := fmt.Sprintf("%s/%s", NtfyBaseURL(), topic) diff --git a/internal/github/pr.go b/internal/github/pr.go index cb75174..f9b9572 100644 --- a/internal/github/pr.go +++ b/internal/github/pr.go @@ -19,7 +19,6 @@ import ( "gopkg.in/yaml.v3" ) -// Timeout mayor para evitar expiraciones en búsquedas lentas de GitHub. var httpClient = &http.Client{Timeout: 60 * time.Second} type Ref struct { @@ -36,7 +35,6 @@ func isTimeout(err error) bool { return false } -// UpdateCommentsKarmaByHash actualiza el karma en los comentarios que contienen el hash, preservando el cuerpo. func UpdateCommentsKarmaByHash(hash string, karma int) error { token := os.Getenv("GITHUB_TOKEN") if token == "" { @@ -173,7 +171,6 @@ func UpdateCommentsKarmaByHash(hash string, karma int) error { return nil } -// DeleteCommentsByHash busca y elimina comentarios que contengan el hash proporcionado func DeleteCommentsByHash(hash string) error { token := os.Getenv("GITHUB_TOKEN") if token == "" { @@ -280,7 +277,6 @@ func DeleteCommentsByHash(hash string) error { return nil } -// CreateAnonymousIssue crea una issue usando el bot autenticado func CreateAnonymousIssue(owner, repo, title, body string, labels []string) (string, int, error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { @@ -333,7 +329,6 @@ func CreateAnonymousIssue(owner, repo, title, body string, labels []string) (str return result.HTMLURL, result.Number, nil } -// CreateAnonymousComment publica un comentario en la issue func CreateAnonymousComment(owner, repo string, number int, body string) (string, error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { @@ -378,14 +373,12 @@ func CreateAnonymousComment(owner, repo string, number int, body string) (string return result.HTMLURL, nil } -// CreateAnonymousPRComment publica un comentario general en un Pull Request func CreateAnonymousPRComment(owner, repo string, number int, body string) (string, error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { return "", fmt.Errorf("GITHUB_TOKEN not set") } - // PR comments use the same issues comments endpoint apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d/comments", owner, repo, number) payload := map[string]string{"body": body} @@ -426,15 +419,12 @@ func CreateAnonymousPRComment(owner, repo string, number int, body string) (stri return result.HTMLURL, nil } -// CreateAnonymousDiscussionComment publica un comentario en una Discussion de GitHub -// mediante la API GraphQL (requiere node id de la discusión). func CreateAnonymousDiscussionComment(owner, repo string, number int, body string) (string, error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { return "", fmt.Errorf("GITHUB_TOKEN not set") } - // Resolver el node id de la discusión idQuery := fmt.Sprintf(`{ repository(owner: %q, name: %q) { discussion(number: %d) { id } @@ -486,7 +476,6 @@ func CreateAnonymousDiscussionComment(owner, repo string, number int, body strin return "", fmt.Errorf("discussion not found") } - // Publicar comentario mutation := fmt.Sprintf(`mutation { addDiscussionComment(input: {discussionId: %q, body: %q}) { comment { url } @@ -537,19 +526,16 @@ func CreateAnonymousDiscussionComment(owner, repo string, number int, body strin return mutResp.Data.AddDiscussionComment.Comment.URL, nil } -// GetSha returns the SHA of the ref func (r *Ref) GetSha() string { return r.Object.Sha } -// ForkRepo creates a fork of the repository for the authenticated user func ForkRepo(owner, repo string) (string, error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { return "", fmt.Errorf("GITHUB_TOKEN not set") } - // Check if fork already exists userURL := "https://api.github.com/user" req, err := http.NewRequest("GET", userURL, nil) if err != nil { @@ -573,7 +559,6 @@ func ForkRepo(owner, repo string) (string, error) { return "", fmt.Errorf("could not get user login") } - // Check if fork already exists forkURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", forkOwner, repo) req, err = http.NewRequest("GET", forkURL, nil) if err != nil { @@ -588,12 +573,10 @@ func ForkRepo(owner, repo string) (string, error) { resp.Body.Close() if resp.StatusCode == 200 { - // Fork already exists fmt.Printf("DEBUG: Fork already exists: %s/%s\n", forkOwner, repo) return forkOwner, nil } - // Create fork url := fmt.Sprintf("https://api.github.com/repos/%s/%s/forks", owner, repo) req, err = http.NewRequest("POST", url, nil) if err != nil { @@ -617,16 +600,12 @@ func ForkRepo(owner, repo string) (string, error) { return forkOwner, nil } -// ClosePRByURL closes an open PR given its GitHub html_url. -// The URL format is: https://github.com/{owner}/{repo}/pull/{number} func ClosePRByURL(prURL string) error { token := os.Getenv("GITHUB_TOKEN") if token == "" { return fmt.Errorf("GITHUB_TOKEN not set") } - // Parse owner, repo, number from the PR URL - // Expected: https://github.com///pull/ parts := strings.Split(strings.TrimPrefix(prURL, "https://github.com/"), "/") if len(parts) < 4 || parts[2] != "pull" { return fmt.Errorf("invalid PR URL: %s", prURL) @@ -740,7 +719,6 @@ func GetRefs(owner, repo string) ([]Ref, error) { defer resp.Body.Close() if resp.StatusCode == 409 { - // Repository is empty, return empty refs return []Ref{}, nil } @@ -757,13 +735,10 @@ func GetRefs(owner, repo string) ([]Ref, error) { return refs, nil } -// RepoPolicy contiene las directivas de configuración leídas desde .gitgost.yml del repositorio destino. type RepoPolicy struct { DenyAll bool `yaml:"DENY_ALL"` } -// GetRepoPolicy descarga .gitgost.yml desde el branch por defecto del repositorio y retorna la política. -// Si el archivo no existe o no puede leerse, retorna una política permisiva (sin restricciones). func GetRepoPolicy(owner, repo string) (*RepoPolicy, error) { token := os.Getenv("GITHUB_TOKEN") apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/.gitgost.yml", owner, repo) @@ -785,7 +760,6 @@ func GetRepoPolicy(owner, repo string) (*RepoPolicy, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - // Archivo no existe: política permisiva por defecto return &RepoPolicy{}, nil } @@ -799,7 +773,6 @@ func GetRepoPolicy(owner, repo string) (*RepoPolicy, error) { var raw []byte if fileResp.Encoding == "base64" { - // GitHub devuelve el contenido en base64 con saltos de línea cleaned := strings.ReplaceAll(fileResp.Content, "\n", "") raw, err = base64.StdEncoding.DecodeString(cleaned) if err != nil { @@ -817,7 +790,6 @@ func GetRepoPolicy(owner, repo string) (*RepoPolicy, error) { return &policy, nil } -// IsRepoVerified checks if the repository has a .gitgost.yml file indicating support for anonymous contributions func IsRepoVerified(owner, repo string) bool { url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/.gitgost.yml", owner, repo) resp, err := http.Get(url) @@ -828,16 +800,12 @@ func IsRepoVerified(owner, repo string) bool { return resp.StatusCode == 200 } -// GeneratePRHash genera un hash deterministico de 8 caracteres basado en owner/repo/branch. -// Esto permite que el mismo branch siempre produzca el mismo pr-hash, sin almacenar estado. func GeneratePRHash(owner, repo, branch string) string { input := fmt.Sprintf("%s/%s/%s", owner, repo, branch) sum := sha256.Sum256([]byte(input)) return hex.EncodeToString(sum[:])[:8] } -// PRTimelineEvent representa un evento individual del timeline de un PR. -// Solo contiene los campos que nos interesan. type PRTimelineEvent struct { Event string `json:"event"` CreatedAt string `json:"created_at"` @@ -851,8 +819,6 @@ type PRTimelineEvent struct { } `json:"label,omitempty"` } -// ExtractPRNumber extrae el numero de PR de una URL de GitHub. -// Formato esperado: https://github.com/{owner}/{repo}/pull/{number} func ExtractPRNumber(prURL string) int { parts := strings.Split(strings.TrimPrefix(prURL, "https://github.com/"), "/") if len(parts) < 4 || parts[2] != "pull" { @@ -865,7 +831,6 @@ func ExtractPRNumber(prURL string) int { return n } -// nextPageURL extrae la URL de la pagina siguiente del header Link de GitHub. func nextPageURL(linkHeader string) string { if linkHeader == "" { return "" @@ -883,10 +848,6 @@ func nextPageURL(linkHeader string) string { return "" } -// FetchPRTimeline obtiene el timeline de un PR desde la API de GitHub. -// Usa ETag/If-None-Match para evitar datos innecesarios. -// Recorre todas las paginas via Link header. -// Retorna los eventos, el nuevo ETag, si hubo cambios, o error. func FetchPRTimeline(owner, repo string, number int, etag string) (events []PRTimelineEvent, newETag string, changed bool, err error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { @@ -903,7 +864,6 @@ func FetchPRTimeline(owner, repo string, number int, etag string) (events []PRTi req.Header.Set("Authorization", "token "+token) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "gitGost") - // Solo enviar ETag en la primera peticion (caching global) if etag != "" && newETag == "" { req.Header.Set("If-None-Match", etag) } @@ -913,7 +873,6 @@ func FetchPRTimeline(owner, repo string, number int, etag string) (events []PRTi return nil, "", false, err } - // Conservar el ETag de la primera respuesta if newETag == "" { newETag = resp.Header.Get("ETag") } @@ -942,7 +901,6 @@ func FetchPRTimeline(owner, repo string, number int, etag string) (events []PRTi apiURL = nextPageURL(resp.Header.Get("Link")) } - // GitHub puede devolver null en lugar de array if events == nil { events = []PRTimelineEvent{} } @@ -950,9 +908,6 @@ func FetchPRTimeline(owner, repo string, number int, etag string) (events []PRTi return events, newETag, true, nil } -// FetchPRInfo obtiene informacion basica del PR (state, title, comments count). -// Usa el endpoint de pulls para obtener datos especificos de PR -// (review_comments para el conteo de discusion, merged_at para estado merged). func FetchPRInfo(owner, repo string, number int) (state, title string, comments int, updatedAt string, err error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { @@ -999,15 +954,12 @@ func FetchPRInfo(owner, repo string, number int) (state, title string, comments return state, result.Title, result.ReviewComments, result.UpdatedAt, nil } -// GetExistingPR busca si existe un PR abierto desde forkOwner:branchName hacia owner/repo. -// Retorna la URL del PR, si la rama existe en el fork, y cualquier error. func GetExistingPR(owner, repo, forkOwner, branchName string) (string, bool, error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { return "", false, fmt.Errorf("GITHUB_TOKEN not set") } - // Verificar si la rama existe en el fork branchURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/branches/%s", forkOwner, repo, branchName) req, err := http.NewRequest("GET", branchURL, nil) if err != nil { @@ -1024,11 +976,9 @@ func GetExistingPR(owner, repo, forkOwner, branchName string) (string, bool, err resp.Body.Close() if resp.StatusCode != http.StatusOK { - // La rama no existe en el fork return "", false, nil } - // La rama existe; buscar el PR abierto asociado head := fmt.Sprintf("%s:%s", forkOwner, branchName) prListURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls?state=open&head=%s&per_page=1", owner, repo, url.QueryEscape(head)) @@ -1059,7 +1009,6 @@ func GetExistingPR(owner, repo, forkOwner, branchName string) (string, bool, err } if len(prs) == 0 { - // Rama existe pero el PR fue cerrado/mergeado; retornar rama existente sin URL de PR return "", true, nil } diff --git a/internal/http/appeal.go b/internal/http/appeal.go index 62b0de1..1bd2a55 100644 --- a/internal/http/appeal.go +++ b/internal/http/appeal.go @@ -17,9 +17,6 @@ import ( "github.com/livrasand/gitGost/internal/utils" ) -// --- Data structures --- - -// appealTicket stores an anonymous appeal ticket linked to a hash. type appealTicket struct { Hash string Message string @@ -34,8 +31,6 @@ var ( appealTicketTTL = 7 * 24 * time.Hour ) -// --- Template --- - var appealStartTmpl = template.Must(template.New("appealStart").Parse(appealHTML)) const appealHTML = ` @@ -101,10 +96,6 @@ textarea{width:100%;min-height:120px;padding:12px;border-radius:10px;border:1px ` -// --- Token functions --- - -// generateAppealToken creates a deterministic token that proves ownership of a hash. -// The token is HMAC("appeal:"+hash, serverSecretKey) — no storage needed. func generateAppealToken(hash string) string { if hash == "" { return "" @@ -114,7 +105,6 @@ func generateAppealToken(hash string) string { return hex.EncodeToString(h.Sum(nil)) } -// verifyAppealToken checks whether a token is valid proof of ownership for the given hash. func verifyAppealToken(hash, token string) bool { if hash == "" || token == "" { return false @@ -123,9 +113,6 @@ func verifyAppealToken(hash, token string) bool { return hmac.Equal([]byte(expected), []byte(token)) } -// --- Handlers --- - -// AppealStartHandler maneja GET/POST /appeal para iniciar una apelacion. func AppealStartHandler(c *gin.Context) { if c.Request.Method == http.MethodGet { hash := strings.TrimSpace(c.Query("hash")) @@ -155,7 +142,6 @@ func AppealStartHandler(c *gin.Context) { return } - // POST: verify appeal_token and create ticket hash := strings.TrimSpace(c.PostForm("hash")) token := strings.TrimSpace(c.PostForm("appeal_token")) @@ -189,7 +175,6 @@ func AppealStartHandler(c *gin.Context) { return } - // Generate ticket b := make([]byte, 20) if _, err := rand.Read(b); err != nil { c.String(http.StatusInternalServerError, "Error creating appeal") @@ -215,13 +200,11 @@ func AppealStartHandler(c *gin.Context) { }) } -// AppealViewHandler maneja GET/POST /appeal/:ticket para ver y enviar el mensaje de apelacion. func AppealViewHandler(c *gin.Context) { ticketID := c.Param("ticket") appealTicketsMu.Lock() ticket, exists := appealTickets[ticketID] - // Snapshot mutable fields under lock to avoid races with admin/POST updates. var ( hash string createdAt time.Time @@ -262,7 +245,6 @@ func AppealViewHandler(c *gin.Context) { } appealTicketsMu.Unlock() - // Notify admin via ntfy if configured if ntfyAdminTopic != "" { go notifyAdminAppeal(ticketID, hash) } @@ -276,7 +258,6 @@ func AppealViewHandler(c *gin.Context) { return } - // GET: show the appeal form or status if resolved { var status string if unbanned { @@ -313,7 +294,6 @@ func AppealViewHandler(c *gin.Context) { }) } -// notifyAdminAppeal sends a ntfy notification when a new appeal is filed. func notifyAdminAppeal(ticketID, hash string) { if ntfyAdminTopic == "" { return @@ -328,7 +308,6 @@ func notifyAdminAppeal(ticketID, hash string) { resp.Body.Close() } -// AdminAppealsHandler lista las apelaciones abiertas (protegido por password). func AdminAppealsHandler(c *gin.Context) { password := c.Query("password") if password == "" { @@ -395,7 +374,6 @@ button.dismiss{border-color:#f85149;color:#f85149;} msgPreview = msgPreview[:60] + "..." } age := time.Since(v.CreatedAt).Round(time.Minute) - // Escape every dynamic value to prevent stored/reflected XSS. ticketID := template.HTMLEscapeString(v.TicketID) ticketShort := template.HTMLEscapeString(v.TicketID[:8]) hash := template.HTMLEscapeString(v.Hash) @@ -433,7 +411,6 @@ button.dismiss{border-color:#f85149;color:#f85149;} fmt.Fprintf(c.Writer, ``) } -// AdminAppealResolveHandler resuelve una apelacion (unban o dismiss). func AdminAppealResolveHandler(c *gin.Context) { ticketID := c.Param("ticket") password := c.PostForm("password") @@ -468,7 +445,6 @@ func AdminAppealResolveHandler(c *gin.Context) { blockedStore.Delete(hash) } - // Send ntfy notification about resolution if ntfyAdminTopic != "" && ticket.Message != "" { go func() { status := "upheld" diff --git a/internal/http/e2e_test.go b/internal/http/e2e_test.go index 94df5a7..3283f4a 100644 --- a/internal/http/e2e_test.go +++ b/internal/http/e2e_test.go @@ -15,12 +15,10 @@ import ( "github.com/livrasand/gitGost/internal/config" ) -// gitCmd ejecuta un comando git en el directorio dado y retorna stdout+stderr combinados func gitCmd(t *testing.T, dir string, args ...string) (string, error) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir - // Desactivar credential helper para evitar prompts interactivos cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=echo", @@ -29,7 +27,6 @@ func gitCmd(t *testing.T, dir string, args ...string) (string, error) { return string(out), err } -// requireGit verifica que git esté disponible en el PATH func requireGit(t *testing.T) { t.Helper() if _, err := exec.LookPath("git"); err != nil { @@ -37,12 +34,9 @@ func requireGit(t *testing.T) { } } -// mockGitHubUploadPack crea un mock server que simula git-upload-pack de GitHub -// con un repositorio mínimo válido para clone/fetch func mockGitHubUploadPack(t *testing.T) *httptest.Server { t.Helper() - // Crear un repo git real en un directorio temporal para servir repoDir := t.TempDir() mustGitInit(t, repoDir) @@ -50,7 +44,6 @@ func mockGitHubUploadPack(t *testing.T) *httptest.Server { path := r.URL.Path if strings.HasSuffix(path, "/info/refs") && r.URL.Query().Get("service") == "git-upload-pack" { - // Ejecutar git upload-pack --advertise-refs cmd := exec.Command("git", "upload-pack", "--stateless-rpc", "--advertise-refs", repoDir) out, err := cmd.Output() if err != nil { @@ -58,7 +51,6 @@ func mockGitHubUploadPack(t *testing.T) *httptest.Server { return } w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") - // Prefijo de servicio requerido por Smart HTTP pktLine := fmt.Sprintf("%04x# service=git-upload-pack\n", len("# service=git-upload-pack\n")+4) w.Write([]byte(pktLine)) w.Write([]byte("0000")) @@ -90,7 +82,6 @@ func mockGitHubUploadPack(t *testing.T) *httptest.Server { return srv } -// mustGitInit inicializa un repo git con un commit inicial func mustGitInit(t *testing.T, dir string) { t.Helper() cmds := [][]string{ @@ -102,7 +93,6 @@ func mustGitInit(t *testing.T, dir string) { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { - // Intentar sin --initial-branch (git < 2.28) if args[1] == "init" { cmd2 := exec.Command("git", "init") cmd2.Dir = dir @@ -115,7 +105,6 @@ func mustGitInit(t *testing.T, dir string) { } } - // Crear un commit inicial testFile := filepath.Join(dir, "README.md") if err := os.WriteFile(testFile, []byte("# gitGost test repo\n"), 0644); err != nil { t.Fatalf("Failed to write README: %v", err) @@ -138,15 +127,12 @@ func mustGitInit(t *testing.T, dir string) { } } -// TestE2E_InfoRefs_UploadPack verifica que GET /info/refs?service=git-upload-pack -// retorne la advertisement correcta (proxied desde el mock de GitHub) func TestE2E_InfoRefs_UploadPack(t *testing.T) { requireGit(t) mockGH := mockGitHubUploadPack(t) defer mockGH.Close() - // Montar handler que usa el mock en lugar de github.com gin.SetMode(gin.TestMode) r := gin.New() r.GET("/v1/gh/:owner/:repo/info/refs", func(c *gin.Context) { @@ -205,15 +191,12 @@ func TestE2E_InfoRefs_UploadPack(t *testing.T) { } } -// TestE2E_GitClone verifica que `git clone` funcione contra el servidor gitGost -// usando un mock de GitHub como upstream func TestE2E_GitClone(t *testing.T) { requireGit(t) mockGH := mockGitHubUploadPack(t) defer mockGH.Close() - // Servidor gitGost que proxea al mock gin.SetMode(gin.TestMode) r := gin.New() @@ -269,14 +252,12 @@ func TestE2E_GitClone(t *testing.T) { t.Fatalf("git clone failed: %v\nOutput: %s", err, out) } - // Verificar que el README fue clonado readmePath := filepath.Join(cloneDir, "cloned-repo", "README.md") if _, err := os.Stat(readmePath); os.IsNotExist(err) { t.Errorf("README.md should exist after clone, but it doesn't") } } -// TestE2E_GitFetch verifica que `git fetch` funcione contra el servidor gitGost func TestE2E_GitFetch(t *testing.T) { requireGit(t) @@ -330,7 +311,6 @@ func TestE2E_GitFetch(t *testing.T) { srv := httptest.NewServer(r) defer srv.Close() - // Primero clonar para tener un repo local cloneDir := t.TempDir() cloneURL := srv.URL + "/v1/gh/owner/repo" if out, err := gitCmd(t, cloneDir, "clone", cloneURL, "fetch-repo"); err != nil { @@ -339,14 +319,12 @@ func TestE2E_GitFetch(t *testing.T) { repoDir := filepath.Join(cloneDir, "fetch-repo") - // Ahora hacer fetch out, err := gitCmd(t, repoDir, "fetch", "origin") if err != nil { t.Fatalf("git fetch failed: %v\nOutput: %s", err, out) } } -// TestE2E_InfoRefs_UnsupportedService verifica que servicios desconocidos retornen 400 func TestE2E_InfoRefs_UnsupportedService(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{APIKey: ""} @@ -365,9 +343,7 @@ func TestE2E_InfoRefs_UnsupportedService(t *testing.T) { } } -// TestE2E_UploadPackRoute_Exists verifica que la ruta POST /git-upload-pack esté registrada func TestE2E_UploadPackRoute_Exists(t *testing.T) { - // Sin GITHUB_TOKEN → 500, pero la ruta existe (no 404) t.Setenv("GITHUB_TOKEN", "") gin.SetMode(gin.TestMode) diff --git a/internal/http/ethicalmetrics.go b/internal/http/ethicalmetrics.go index d190265..3f58ee5 100644 --- a/internal/http/ethicalmetrics.go +++ b/internal/http/ethicalmetrics.go @@ -120,7 +120,6 @@ func EthicalMetricsMetricsHandler(c *gin.Context) { } } if len(result) == 0 { - // Fallback a memoria si no hay DB o falló la lectura siteParam := site ethicalStore.Range(func(fullKey string, count int64) bool { s, inner, ok := decodeEthicalKey(fullKey) From 6e711c7f17cd56208eb80a651bac4e230bdb9be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Livr=C3=A4do=20Sandoval?= <104039397+livrasand@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:47:15 -0700 Subject: [PATCH 6/7] Refactor bundle.go to isolate git clone validation and add comprehensive URL safety tests Extract git clone into dedicated gitClone function that validates URL at the exact point it becomes a git argument, preventing option injection across all call paths. Add explicit regex barrier in safeCloneURL with early MatchString check before FindStringSubmatch to satisfy static analysis. Remove Spanish comments from bundle.go, handlers.go. Add 16 test cases covering host validation, path traversal, option injection, userinfo/fragment rejection, port --- internal/git/bundle.go | 51 ++++++++--- internal/git/git_test.go | 90 ++++++++++++++++++++ internal/http/handlers.go | 117 +++----------------------- internal/http/handlers_upload_test.go | 18 ---- 4 files changed, 142 insertions(+), 134 deletions(-) diff --git a/internal/git/bundle.go b/internal/git/bundle.go index 5b34448..5abaa47 100644 --- a/internal/git/bundle.go +++ b/internal/git/bundle.go @@ -22,18 +22,10 @@ const bundleChunkSize = 500 // acota la duración total de los subprocesos git (evita workers colgados). func CreateBundle(ctx context.Context, url, workDir string) (bundlePath, defaultBranch string, err error) { repoDir := filepath.Join(workDir, "repo") - // La URL proviene de la API (entrada del usuario). Se revalida aquí, en el - // punto donde se convierte en argumento de git, para que ninguna ruta de - // llamada pueda inyectar opciones (p. ej. --upload-pack=...) aunque el - // separador -- dejara de estar presente. - safeURL, err := safeCloneURL(url) - if err != nil { - return "", "", err - } - // El separador -- evita que la URL se interprete como opción de git incluso - // si la validación superior cambiara; el argumento es la URL reconstruida - // del parseo validado, nunca el raw del usuario. - if err := runGit(ctx, "", "clone", "--mirror", "--depth="+strconv.Itoa(bundleChunkSize), "--", safeURL, repoDir); err != nil { + // gitClone valida la URL en el punto exacto donde se convierte en + // argumento de git, evitando que ninguna ruta de llamada inyecte opciones + // (p. ej. --upload-pack=...) aunque el separador -- dejara de estar presente. + if err := gitClone(ctx, url, repoDir); err != nil { return "", "", fmt.Errorf("clonar %s: %w", url, err) } @@ -95,6 +87,12 @@ var repoURLPattern = regexp.MustCompile(`(?i)^https://(github\.com|gitlab\.com|c // validados por la expresión regular; el raw del usuario nunca se usa // directamente como argumento de comando. func safeCloneURL(raw string) (string, error) { + // Barrera explícita para análisis estático: la URL debe coincidir con la + // expresión regular permitida antes de ser reconstruida. Los grupos + // capturados contienen solo los caracteres permitidos por el patrón. + if !repoURLPattern.MatchString(raw) { + return "", fmt.Errorf("URL de repositorio inválida: %q", raw) + } m := repoURLPattern.FindStringSubmatch(raw) if m == nil { return "", fmt.Errorf("URL de repositorio inválida: %q", raw) @@ -140,8 +138,37 @@ func revCount(ctx context.Context, dir string) int { return n } +// gitClone clona rawURL validada en dest con --mirror y shallow depth fijo. +// Es el único comando git que recibe input directo del usuario; por eso se +// aísla, valida la URL con safeCloneURL y construye exec.CommandContext con +// argumentos explícitos en lugar de un variádico genérico. +func gitClone(ctx context.Context, rawURL, dest string) error { + safeURL, err := safeCloneURL(rawURL) + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, "git", + "clone", + "--mirror", + "--depth="+strconv.Itoa(bundleChunkSize), + "--", + safeURL, + dest, + ) + out, err := cmd.CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%w: %s", err, msg) + } + return err + } + return nil +} + // runGit ejecuta git; si falla, el error incluye el mensaje real de stderr. // El contexto permite matar el subproceso si el job remoto excede su timeout. +// Ningún caller pasa input de usuario a través de args; todos los argumentos +// son literales o paths derivados de workDir creado por os.MkdirTemp. func runGit(ctx context.Context, dir string, args ...string) error { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir diff --git a/internal/git/git_test.go b/internal/git/git_test.go index e6be62a..9e2122f 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,7 +1,9 @@ package git import ( + "context" "os" + "strings" "testing" goGit "github.com/go-git/go-git/v5" @@ -110,3 +112,91 @@ func TestAnonymizeCommits_UsesLocalHEADBranch(t *testing.T) { t.Fatalf("expected base commit to remain unchanged, got parents %v", anonymized.ParentHashes) } } + +func TestSafeCloneURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + { + name: "github lowercases host", + raw: "https://GITHUB.com/owner/repo", + want: "https://github.com/owner/repo", + }, + { + name: "missing scheme", + raw: "github.com/owner/repo", + wantErr: true, + }, + { + name: "host not allowed", + raw: "https://evil.com/owner/repo", + wantErr: true, + }, + { + name: "too many path segments", + raw: "https://github.com/owner/repo/extra", + wantErr: true, + }, + { + name: "path traversal in owner", + raw: "https://github.com/../repo", + wantErr: true, + }, + { + name: "path traversal in repo", + raw: "https://github.com/owner/..", + wantErr: true, + }, + { + name: "injected option as url", + raw: "https://github.com/owner/repo?--upload-pack=evil", + wantErr: true, + }, + { + name: "userinfo rejected", + raw: "https://user:pass@github.com/owner/repo", + wantErr: true, + }, + { + name: "fragment rejected", + raw: "https://github.com/owner/repo#fragment", + wantErr: true, + }, + { + name: "port allowed", + raw: "https://github.com:443/owner/repo", + want: "https://github.com/owner/repo", + }, + { + name: "trailing slash allowed", + raw: "https://github.com/owner/repo/", + want: "https://github.com/owner/repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := safeCloneURL(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("safeCloneURL(%q) error = %v, wantErr %v", tt.raw, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Fatalf("safeCloneURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestGitClone_InvalidURLRejected(t *testing.T) { + ctx := context.Background() + err := gitClone(ctx, "--upload-pack=evil", t.TempDir()) + if err == nil { + t.Fatal("gitClone should reject an option-injection URL") + } + if !strings.Contains(err.Error(), "URL de repositorio inválida") { + t.Fatalf("expected URL validation error, got: %v", err) + } +} diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 48c7dd8..f1cbd8a 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -34,9 +34,6 @@ import ( "github.com/gin-gonic/gin" ) -// uploadPackClient proxifica las descargas de git-upload-pack. El timeout debe -// ser amplio: GitHub puede tardar más de 30s en generar/transferir un pack de -// un repo grande, y un corte aquí trunca la descarga del cliente. var uploadPackClient = &http.Client{Timeout: 10 * time.Minute} const ( @@ -52,9 +49,6 @@ const ( reportTokenMax = 10000 ) -// boundedMap is a generic in-memory map with a max size and optional TTL. -// Eviction is LRU-ish: when the map is full the entry with the oldest access -// time is removed. Expired entries are dropped on Get/Peek/Update. type boundedEntry[V any] struct { value V at time.Time @@ -188,9 +182,6 @@ func (m *boundedMap[V]) evictOldestLocked(needed int) { } } -// windowAdd records a timestamp in a sliding-window rate limiter backed by a -// bounded map. It returns the current count of events in the window. The stored -// slice is capped at max+1 entries to keep per-key memory bounded. func windowAdd(store *boundedMap[[]time.Time], ip string, now time.Time, window time.Duration, max int) int { count := store.Update(ip, func(times []time.Time, ok bool) []time.Time { cutoff := now.Add(-window) @@ -218,8 +209,6 @@ type reportState struct { IPs map[string]time.Time } -// providerFromPath returns the appropriate Provider based on the URL path prefix. -// /v1/gh/... → GitHub (default), /v1/gl/... → GitLab, /v1/cb/... → Codeberg. func providerFromPath(path string) provider.Provider { if strings.HasPrefix(path, "/v1/gl/") { return glprovider.New() @@ -242,7 +231,6 @@ func min(a, b int) int { return b } -// WritePktLine escribe una línea en formato pkt-line func WritePktLine(w io.Writer, data string) error { if data == "" { _, err := w.Write([]byte("0000")) @@ -254,17 +242,14 @@ func WritePktLine(w io.Writer, data string) error { return err } -// WriteSidebandLine escribe una línea con prefijo de banda para side-band-64k func WriteSidebandLine(w io.Writer, band byte, message string) error { if message == "" { return nil } - // Agregar newline si no existe if !strings.HasSuffix(message, "\n") { message += "\n" } - // Formato: longitud(4 bytes hex) + banda(1 byte) + mensaje data := append([]byte{band}, []byte(message)...) length := len(data) + 4 @@ -288,18 +273,14 @@ func ReceivePackDiscoveryHandler(c *gin.Context) { return } - // Build advertisement var advertisement bytes.Buffer - // Service line serviceLine := "# service=git-receive-pack\n" WritePktLine(&advertisement, serviceLine) - WritePktLine(&advertisement, "") // flush + WritePktLine(&advertisement, "") - // Capabilities capabilities := "report-status delete-refs side-band-64k quiet ofs-delta push-options" - // Refs first := true for _, ref := range refs { if strings.HasPrefix(ref.Ref, "refs/heads/") || strings.HasPrefix(ref.Ref, "refs/tags/") { @@ -313,13 +294,11 @@ func ReceivePackDiscoveryHandler(c *gin.Context) { } } - // Si no hay refs, enviar capacidades de todos modos if first { line := fmt.Sprintf("0000000000000000000000000000000000000000 capabilities^{}\x00%s\n", capabilities) WritePktLine(&advertisement, line) } - // Flush final WritePktLine(&advertisement, "") c.Writer.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement") @@ -333,12 +312,10 @@ func ReceivePackHandler(c *gin.Context) { fmt.Printf("DEBUG: ReceivePackHandler called for %s/%s\n", owner, repo) - // Handle 100 Continue if c.GetHeader("Expect") == "100-continue" { c.Writer.WriteHeader(http.StatusContinue) } - // Check panic mode if isPanicMode() { c.Writer.Header().Set("Content-Type", "application/x-git-receive-pack-result") c.Writer.WriteHeader(http.StatusOK) @@ -356,7 +333,6 @@ func ReceivePackHandler(c *gin.Context) { return } - // Check rate limit per IP (5 PRs/IP/hour) ip := c.ClientIP() if checkRateLimit(ip) { c.Writer.Header().Set("Content-Type", "application/x-git-receive-pack-result") @@ -372,13 +348,10 @@ func ReceivePackHandler(c *gin.Context) { return } - // Record push globally to detect botnet/script patterns across IPs go recordGlobalBurst(ip) - // Detect provider from request path prov := providerFromPath(c.Request.URL.Path) - // Check repository opt-out policy (.gitgost.yml DENY_ALL) policy, err := prov.GetRepoPolicy(owner, repo) if err == nil && policy != nil && policy.DenyAll { c.Writer.Header().Set("Content-Type", "application/x-git-receive-pack-result") @@ -396,9 +369,6 @@ func ReceivePackHandler(c *gin.Context) { return } - // Track PR URL for potential rollback (registered after PR is created below) - - // Read full body utils.Log("Content-Type: %s", c.GetHeader("Content-Type")) utils.Log("Content-Length: %s", c.GetHeader("Content-Length")) @@ -412,7 +382,6 @@ func ReceivePackHandler(c *gin.Context) { utils.Log("Received push for %s/%s, size: %d bytes", owner, repo, len(body)) - // Create temporary repository tempDir, err := utils.CreateTempDir() if err != nil { utils.Log("Error creating temp dir: %v", err) @@ -421,16 +390,13 @@ func ReceivePackHandler(c *gin.Context) { } defer utils.CleanupTempDir(tempDir) - // Set headers before writing anything c.Writer.Header().Set("Content-Type", "application/x-git-receive-pack-result") c.Writer.WriteHeader(http.StatusOK) var response bytes.Buffer - // Initial progress message WriteSidebandLine(&response, 2, "remote: gitGost: Processing your anonymous contribution...") - // Process the packfile newSHA, commitMessage, receivedPRHash, err := git.ReceivePack(tempDir, body, owner, repo, prov.CloneURL(owner, repo), prov.TokenEnvVar()) if err != nil { utils.Log("Error receiving pack: %v", err) @@ -443,7 +409,6 @@ func ReceivePackHandler(c *gin.Context) { utils.Log("Commits received successfully, HEAD at: %s", newSHA) WriteSidebandLine(&response, 2, "remote: gitGost: Commits anonymized successfully") - // Create a fork of the repository WriteSidebandLine(&response, 2, "remote: gitGost: Creating fork...") forkOwner, err := prov.ForkRepo(owner, repo) if err != nil { @@ -462,7 +427,6 @@ func ReceivePackHandler(c *gin.Context) { isUpdate := false if receivedPRHash != "" { - // Update mode: the client sent an existing pr-hash branchFromHash := fmt.Sprintf("gitgost-%s", receivedPRHash) WriteSidebandLine(&response, 2, fmt.Sprintf("remote: gitGost: Updating existing PR (hash: %s)...", receivedPRHash)) @@ -472,7 +436,6 @@ func ReceivePackHandler(c *gin.Context) { } if branchExists { - // Push to the fork in the existing branch (force) WriteSidebandLine(&response, 2, "remote: gitGost: Pushing update to existing branch...") branch, err = git.PushToGitHub(owner, repo, tempDir, forkOwner, branchFromHash, prov.PushURL(forkOwner, repo), prov.TokenEnvVar()) if err != nil { @@ -484,12 +447,10 @@ func ReceivePackHandler(c *gin.Context) { return } if existingPRURL != "" { - // PR found: update successful prURL = existingPRURL isUpdate = true utils.Log("Updated existing branch: %s, PR: %s", branch, prURL) } else { - // Branch exists but PR was closed/merged: create new PR WriteSidebandLine(&response, 2, "remote: gitGost: PR was closed, creating new PR on existing branch...") prURL, err = prov.CreateMR(owner, repo, branch, forkOwner, commitMessage) if err != nil { @@ -507,14 +468,12 @@ func ReceivePackHandler(c *gin.Context) { } } } else { - // The hash does not correspond to an existing branch: create new PR utils.Log("PR hash not found, creating new PR") WriteSidebandLine(&response, 2, "remote: gitGost: Hash not found, creating new PR...") } } if !isUpdate { - // Normal flow: push to new branch and create PR WriteSidebandLine(&response, 2, "remote: gitGost: Pushing to fork...") branch, err = git.PushToGitHub(owner, repo, tempDir, forkOwner, "", prov.PushURL(forkOwner, repo), prov.TokenEnvVar()) if err != nil { @@ -529,7 +488,6 @@ func ReceivePackHandler(c *gin.Context) { utils.Log("Pushed to fork branch: %s", branch) WriteSidebandLine(&response, 2, fmt.Sprintf("remote: gitGost: Branch '%s' created", branch)) - // Create PR from the fork to the original repository WriteSidebandLine(&response, 2, "remote: gitGost: Creating pull request...") prURL, err = prov.CreateMR(owner, repo, branch, forkOwner, commitMessage) if err != nil { @@ -543,14 +501,11 @@ func ReceivePackHandler(c *gin.Context) { utils.Log("Created PR: %s", prURL) - // Record statistics if err := RecordPR(c.Request.Context(), owner, repo, prURL); err != nil { utils.Log("Error recording stats: %v", err) } } - // Register PR URL for potential burst rollback only while a burst alert is active; - // prune entries older than TTL regardless. if isGlobalBurstAlertActive() { nowPR := time.Now() recentBurstPRsMu.Lock() @@ -570,10 +525,8 @@ func ReceivePackHandler(c *gin.Context) { recentBurstPRsMu.Unlock() } - // Generate pr-hash for this branch (deterministic: owner/repo/branch) outPRHash := github.GeneratePRHash(owner, repo, branch) - // Publish ntfy event in background (does not block the Git response) go func() { ntfyTopic := github.NtfyTopicForPR(outPRHash) var ntfyTitle, ntfyMsg string @@ -590,7 +543,6 @@ func ReceivePackHandler(c *gin.Context) { } }() - // Track PR for on-demand status checking if prURL != "" { provShort := "gh" if strings.HasPrefix(c.Request.URL.Path, "/v1/gl/") { @@ -614,7 +566,6 @@ func ReceivePackHandler(c *gin.Context) { } } - // CLEAR SUCCESS MESSAGES WriteSidebandLine(&response, 2, "remote: ") WriteSidebandLine(&response, 2, "remote: ========================================") if isUpdate { @@ -641,15 +592,13 @@ func ReceivePackHandler(c *gin.Context) { WriteSidebandLine(&response, 2, "remote: ========================================") WriteSidebandLine(&response, 2, "remote: ") - // Standard Git response (sideband 1 = protocol data) WriteSidebandLine(&response, 1, "unpack ok\n") WriteSidebandLine(&response, 1, "ok refs/heads/main\n") - WritePktLine(&response, "") // final flush + WritePktLine(&response, "") c.Writer.Write(response.Bytes()) c.Writer.Flush() - // Small delay to allow Git to process the response and close its side first time.Sleep(100 * time.Millisecond) } @@ -660,9 +609,6 @@ func UploadPackDiscoveryHandler(c *gin.Context) { prov := providerFromPath(c.Request.URL.Path) token := os.Getenv(prov.TokenEnvVar()) - // Reenviar los query params del cliente (protocol=v2 y otros) además del - // service: sin protocol=v2 el servidor remoto responde en protocolo v1 y - // git no puede usar filtros (partial clone) ni la negociación eficiente. q := url.Values{} q.Set("service", "git-upload-pack") for k, vals := range c.Request.URL.Query() { @@ -704,7 +650,7 @@ func UploadPackHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") - const maxUploadBytes = 50 * 1024 * 1024 // 50 MB + const maxUploadBytes = 50 * 1024 * 1024 c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadBytes) body, err := io.ReadAll(c.Request.Body) if err != nil { @@ -729,7 +675,6 @@ func UploadPackHandler(c *gin.Context) { req.Header.Set("Accept", "application/x-git-upload-pack-result") req.Header.Set("User-Agent", "git/2.0") if gp := c.Request.Header.Get("Git-Protocol"); gp != "" { - // Reenviar la negociación de protocolo v2 del cliente al remoto. req.Header.Set("Git-Protocol", gp) } if ce := c.Request.Header.Get("Content-Encoding"); ce != "" { @@ -759,7 +704,6 @@ func basicAuth(username, password string) string { return base64.StdEncoding.EncodeToString([]byte(credentials)) } -// sendErrorResponse sends an error response in Git protocol format func sendErrorResponse(c *gin.Context, errorMsg string) { c.Writer.Header().Set("Content-Type", "application/x-git-receive-pack-result") c.Writer.WriteHeader(http.StatusOK) @@ -806,7 +750,6 @@ var ( sourceRepo = "https://github.com/livrasand/gitGost" ) -// SetBuildInfo allows main to inject compiled values with -ldflags func SetBuildInfo(hash, built, repo string) { commitHash = hash buildTime = built @@ -819,62 +762,44 @@ var ( dbOnce sync.Once secretKey []byte identityMu sync.Mutex - // karmaStore stores karma per hash (in-memory fallback) karmaStore = newBoundedMap[int](karmaStoreMax, 24*time.Hour) - // reportStore holds per-hash report state (count, first report, IPs) reportStore = newBoundedMap[reportState](reportStoreMax, reportWindow) flaggedStore = newBoundedMap[time.Time](flaggedStoreMax, flaggedCooldown) blockedStore = newBoundedMap[bool](blockedStoreMax, 0) - - // panicMode: service temporarily suspended panicMode bool panicMu sync.Mutex panicPassword string ntfyAdminTopic string - - // mentaAPIEndpoint/mentaAPIKey: Menta CAPTCHA verification config (empty = captcha disabled) mentaAPIEndpoint string mentaAPIKey string - - // rateLimitStore: PR counter per IP within a 1-hour window rateLimitStore = newBoundedMap[[]time.Time](rateLimitStoreMax, rateLimitWindow) rateLimitWindow = time.Hour rateLimitMaxPRs = 5 - - // globalBurst: tracks all push attempts globally to detect botnet/script activity - // across multiple IPs in a short time window globalBurstMu sync.Mutex - globalBurstTimes []time.Time // timestamps of all pushes - globalBurstIPs []string // IPs corresponding to each push - globalBurstWindow = 60 * time.Second // sliding window - globalBurstMaxTotal = 20 // max pushes globally in the window - globalBurstMaxIPs = 10 // max distinct IPs in the window - globalBurstAlerted bool // avoid repeated alerts - - // recentBurstPRs: PR URLs created during the current burst window, for rollback. - // Entries older than recentBurstPRsTTL are pruned on each registration. + globalBurstTimes []time.Time + globalBurstIPs []string + globalBurstWindow = 60 * time.Second + globalBurstMaxTotal = 20 + globalBurstMaxIPs = 10 + globalBurstAlerted bool recentBurstPRsMu sync.Mutex recentBurstPRs []string - recentBurstPRsAt []time.Time // creation time per PR URL - recentBurstPRsTTL = 2 * time.Hour // keep burst PRs for 2 hours max + recentBurstPRsAt []time.Time + recentBurstPRsTTL = 2 * time.Hour + - // actionTokens: short-lived tokens used in ntfy action buttons instead of panicPassword. - // Each token is single-use and expires after actionTokenTTL. actionTokens = newBoundedMap[time.Time](actionTokenMax, actionTokenTTL) actionTokenTTL = 10 * time.Minute - // adminRollbackLimit: simple rate limit for /admin/rollback (max 5 calls/min) rollbackLimitMu sync.Mutex rollbackLimitTimes []time.Time rollbackLimitMax = 5 rollbackLimitWin = time.Minute - // reportRateLimitStore: report counter per IP within reportRateLimitWindow. reportRateLimitStore = newBoundedMap[[]time.Time](reportRateLimitStoreMax, reportRateLimitWindow) reportRateLimitWindow = time.Hour reportRateLimitMax = 5 - // reportTokens: single-use tokens generated by GET /v1/moderation/report; valid for reportTokenTTL. reportTokens = newBoundedMap[time.Time](reportTokenMax, reportTokenTTL) reportTokenTTL = 10 * time.Minute @@ -883,7 +808,6 @@ var ( ) type anonymousIssueRequest struct { - // ... Title string `json:"title"` Body string `json:"body"` Labels []string `json:"labels"` @@ -903,13 +827,12 @@ const ( var reportPolicyHTML = template.HTML(`
  • 0–2 reports: internal log only.
  • 3–5 reports: hash flagged, 6h cooldown, karma reset.
  • 6+ reports: hash blocked; we attempt to remove its comments.
  • `) -// PR tracking store for on-demand status checking via /api/pr/:hash/status type prTrack struct { Owner string Repo string Number int PRURL string - Provider string // "gh" or "gl" + Provider string LastETag string AddedAt time.Time } @@ -921,7 +844,6 @@ var ( prTrackEvictionOnce sync.Once ) -// startPRTrackEviction inicia un goroutine que limpia entradas expiradas cada 10 min. func startPRTrackEviction() { prTrackEvictionOnce.Do(func() { go func() { @@ -940,7 +862,6 @@ func startPRTrackEviction() { }) } -// trackPR almacena metadatos de un PR para consultas de estado posteriores. func trackPR(prHash, owner, repo string, number int, prURL, provider string) { startPRTrackEviction() prTrackMu.Lock() @@ -955,10 +876,6 @@ func trackPR(prHash, owner, repo string, number int, prURL, provider string) { } } -// getPRTrack obtiene los metadatos de un PR por su hash. -// Retorna una copia (value) obtenida bajo el lock, incluyendo LastETag, -// para que el llamador pueda usarla sin condiciones de carrera. -// Elimina entradas expiradas para evitar acumulacion de stale data. func getPRTrack(prHash string) (prTrack, bool) { prTrackMu.Lock() defer prTrackMu.Unlock() @@ -973,7 +890,6 @@ func getPRTrack(prHash string) (prTrack, bool) { return *t, true } -// providerFromName devuelve el provider adecuado segun el nombre corto. func providerFromName(name string) provider.Provider { switch name { case "gl": @@ -985,7 +901,6 @@ func providerFromName(name string) provider.Provider { } } -// newActionToken generates a single-use token valid for actionTokenTTL and stores it. func newActionToken() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { @@ -996,8 +911,6 @@ func newActionToken() string { return token } -// consumeActionToken validates and removes a single-use action token. -// Returns true if the token was valid and not expired. func consumeActionToken(token string) bool { expiry, ok := actionTokens.Get(token) if !ok { @@ -1007,20 +920,16 @@ func consumeActionToken(token string) bool { return time.Now().Before(expiry) } -// InitPanicConfig initializes the panic button password and ntfy admin topic func InitPanicConfig(password, adminTopic string) { panicPassword = password ntfyAdminTopic = adminTopic } -// InitMentaConfig initializes the Menta CAPTCHA verification endpoint and tenant API key. func InitMentaConfig(apiEndpoint, apiKey string) { mentaAPIEndpoint = strings.TrimRight(apiEndpoint, "/") mentaAPIKey = apiKey } -// verifyMentaCaptcha valida un token de Menta contra el endpoint /verify configurado. -// Si MENTA_API_ENDPOINT no está configurado, no se exige captcha (comportamiento actual sin cambios). func verifyMentaCaptcha(token string) bool { if mentaAPIEndpoint == "" { return true diff --git a/internal/http/handlers_upload_test.go b/internal/http/handlers_upload_test.go index e8a04ad..9c734bc 100644 --- a/internal/http/handlers_upload_test.go +++ b/internal/http/handlers_upload_test.go @@ -11,7 +11,6 @@ import ( "github.com/gin-gonic/gin" ) -// mockTransport redirige peticiones a github.com al mockURL dado. type mockTransport struct { mockURL string base http.RoundTripper @@ -27,10 +26,8 @@ func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -// TestBasicAuth verifica que basicAuth codifique correctamente en base64 func TestBasicAuth(t *testing.T) { result := basicAuth("x-access-token", "mytoken") - // "x-access-token:mytoken" en base64 = "eC1hY2Nlc3MtdG9rZW46bXl0b2tlbg==" expected := "eC1hY2Nlc3MtdG9rZW46bXl0b2tlbg==" if result != expected { t.Errorf("basicAuth() = %q; want %q", result, expected) @@ -44,7 +41,6 @@ func TestBasicAuth_EmptyPassword(t *testing.T) { } } -// TestUploadPackDiscoveryHandler_NoToken verifica que la petición anónima llegue al upstream. func TestUploadPackDiscoveryHandler_NoToken(t *testing.T) { t.Setenv("GITHUB_TOKEN", "") @@ -76,7 +72,6 @@ func TestUploadPackDiscoveryHandler_NoToken(t *testing.T) { } } -// TestUploadPackHandler_NoToken verifica que la petición anónima llegue al upstream. func TestUploadPackHandler_NoToken(t *testing.T) { t.Setenv("GITHUB_TOKEN", "") @@ -108,18 +103,14 @@ func TestUploadPackHandler_NoToken(t *testing.T) { } } -// TestUploadPackDiscoveryHandler_ProxiesGitHub verifica que el handler haga proxy correcto func TestUploadPackDiscoveryHandler_ProxiesGitHub(t *testing.T) { fakeAdvertisement := "001e# service=git-upload-pack\n00000032abc123 refs/heads/main\n0000" - // Mock del servidor de GitHub mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verificar que llega con Authorization auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Basic ") { t.Errorf("Expected Basic auth header, got %q", auth) } - // Verificar User-Agent if r.Header.Get("User-Agent") != "git/2.0" { t.Errorf("Expected User-Agent git/2.0, got %q", r.Header.Get("User-Agent")) } @@ -131,7 +122,6 @@ func TestUploadPackDiscoveryHandler_ProxiesGitHub(t *testing.T) { t.Setenv("GITHUB_TOKEN", "test-token-123") - // Swappear uploadPackClient para redirigir github.com al mock origUploadPackClient := uploadPackClient uploadPackClient = &http.Client{ Transport: &mockTransport{mockURL: mockGitHub.URL, base: mockGitHub.Client().Transport}, @@ -157,13 +147,11 @@ func TestUploadPackDiscoveryHandler_ProxiesGitHub(t *testing.T) { } } -// TestUploadPackHandler_ProxiesGitHub verifica que el POST /git-upload-pack haga proxy correcto func TestUploadPackHandler_ProxiesGitHub(t *testing.T) { fakePackData := "0008NAK\n" receivedBody := "" mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verificar método y Content-Type if r.Method != "POST" { t.Errorf("Expected POST, got %s", r.Method) } @@ -181,7 +169,6 @@ func TestUploadPackHandler_ProxiesGitHub(t *testing.T) { t.Setenv("GITHUB_TOKEN", "test-token-456") - // Swappear uploadPackClient para redirigir github.com al mock origUploadPackClient := uploadPackClient uploadPackClient = &http.Client{ Transport: &mockTransport{mockURL: mockGitHub.URL, base: mockGitHub.Client().Transport}, @@ -209,7 +196,6 @@ func TestUploadPackHandler_ProxiesGitHub(t *testing.T) { } } -// TestInfoRefsRouter_UploadPack verifica que el router enrute git-upload-pack correctamente func TestInfoRefsRouter_UploadPack(t *testing.T) { t.Setenv("GITHUB_TOKEN", "") @@ -220,14 +206,12 @@ func TestInfoRefsRouter_UploadPack(t *testing.T) { if service == "git-receive-pack" { c.String(http.StatusOK, "receive-pack") } else if service == "git-upload-pack" { - // Sin token → 500, pero el routing llegó aquí c.String(http.StatusInternalServerError, "upload-pack-reached") } else { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unsupported service"}) } }) - // git-upload-pack debe llegar al handler correcto req, _ := http.NewRequest("GET", "/owner/repo/info/refs?service=git-upload-pack", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -235,7 +219,6 @@ func TestInfoRefsRouter_UploadPack(t *testing.T) { t.Errorf("git-upload-pack should reach upload-pack handler, got %q", w.Body.String()) } - // git-receive-pack debe llegar al handler correcto req, _ = http.NewRequest("GET", "/owner/repo/info/refs?service=git-receive-pack", nil) w = httptest.NewRecorder() r.ServeHTTP(w, req) @@ -243,7 +226,6 @@ func TestInfoRefsRouter_UploadPack(t *testing.T) { t.Errorf("git-receive-pack should reach receive-pack handler, got %q", w.Body.String()) } - // servicio desconocido debe retornar 400 req, _ = http.NewRequest("GET", "/owner/repo/info/refs?service=unknown", nil) w = httptest.NewRecorder() r.ServeHTTP(w, req) From 2348d889c6719f7ae807521476732bdc6717fa49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Livr=C3=A4do=20Sandoval?= <104039397+livrasand@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:54:10 -0700 Subject: [PATCH 7/7] Remove Spanish comments from internal/http/handlers.go Strip all Spanish-language inline comments from handlers.go (isPanicMode, isGlobalBurstAlertActive, recordGlobalBurst, notifyAdminGlobalBurst, checkRateLimit, notifyAdminRateLimit, PanicHandler, ServiceStatusHandler, RollbackBurstHandler, InitDatabase, RecordPR, StatsHandler, RecentPRsHandler, CreateAnonymousIssueHandler, GitLabIssueNotesProxyHandler, GitLabCommitCountHandler, GitLabAvatarHandler, GitLabCommitsHandler, GitLabCommitDetailHandler, GitHubDiscussionsProxyHandler, GitHubDiscussionDetailPro --- internal/http/handlers.go | 83 +++------------------------------------ 1 file changed, 5 insertions(+), 78 deletions(-) diff --git a/internal/http/handlers.go b/internal/http/handlers.go index f1cbd8a..45bafe9 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -966,28 +966,23 @@ func verifyMentaCaptcha(token string) bool { return result.Valid } -// isPanicMode returns whether the service is suspended func isPanicMode() bool { panicMu.Lock() defer panicMu.Unlock() return panicMode } -// isGlobalBurstAlertActive returns true when a global burst alert is currently active. func isGlobalBurstAlertActive() bool { globalBurstMu.Lock() defer globalBurstMu.Unlock() return globalBurstAlerted } -// recordGlobalBurst records a push attempt globally and notifies the admin if suspicious -// activity is detected (too many pushes in a short window, possibly from multiple IPs). func recordGlobalBurst(ip string) { now := time.Now() globalBurstMu.Lock() defer globalBurstMu.Unlock() - // Slide the window: discard entries older than globalBurstWindow cutoff := now.Add(-globalBurstWindow) newTimes := globalBurstTimes[:0] newIPs := globalBurstIPs[:0] @@ -1004,41 +999,35 @@ func recordGlobalBurst(ip string) { total := len(globalBurstTimes) - // Count distinct IPs in window seen := make(map[string]struct{}, total) for _, bip := range globalBurstIPs { seen[bip] = struct{}{} } distinctIPs := len(seen) - // Trigger alert if thresholds exceeded and not already alerted in this window if !globalBurstAlerted && (total >= globalBurstMaxTotal || distinctIPs >= globalBurstMaxIPs) { globalBurstAlerted = true go notifyAdminGlobalBurst(total, distinctIPs) } - // Reset alert flag once activity drops below half the threshold if globalBurstAlerted && total < globalBurstMaxTotal/2 && distinctIPs < globalBurstMaxIPs/2 { globalBurstAlerted = false } } -// notifyAdminGlobalBurst sends an ntfy alert about suspected botnet/script activity func notifyAdminGlobalBurst(total, distinctIPs int) { if ntfyAdminTopic == "" { return } serviceURL := github.NtfyServiceURL() - title := "🚨 Suspicious activity detected · gitGost" + title := "Suspicious activity detected · gitGost" msg := fmt.Sprintf( "%d push attempts from %d distinct IPs in the last %s. This may indicate bot, script, or coordinated abuse.", total, distinctIPs, globalBurstWindow, ) - // Generate single-use tokens per action (expire in 10 min, never expose panicPassword) tokActivate := newActionToken() tokRollback := newActionToken() tokDeactivate := newActionToken() - // ntfy action buttons: panic control + close burst PRs actions := fmt.Sprintf( `http, Activate Panic, %s/admin/panic, method=POST, body={"token":"%s","active":true}, clear=true; http, Close Burst PRs, %s/admin/rollback, method=POST, body={"token":"%s"}, clear=true; http, Deactivate Panic, %s/admin/panic, method=POST, body={"token":"%s","active":false}`, serviceURL, tokActivate, @@ -1050,12 +1039,9 @@ func notifyAdminGlobalBurst(total, distinctIPs int) { } } -// checkRateLimit checks if the IP has exceeded the PR rate limit per hour. -// Returns true if the request should be blocked. Notifies admin via ntfy on first excess. func checkRateLimit(ip string) bool { count := windowAdd(rateLimitStore, ip, time.Now(), rateLimitWindow, rateLimitMaxPRs) if count > rateLimitMaxPRs { - // Notify admin only once when the limit is first exceeded (at rateLimitMaxPRs+1) if count == rateLimitMaxPRs+1 { go notifyAdminRateLimit(ip, count) } @@ -1064,19 +1050,16 @@ func checkRateLimit(ip string) bool { return false } -// notifyAdminRateLimit sends an ntfy alert to the admin when an IP exceeds the rate limit func notifyAdminRateLimit(ip string, count int) { if ntfyAdminTopic == "" { return } serviceURL := github.NtfyServiceURL() - title := "⚠️ Rate limit exceeded · gitGost" + title := "Rate limit exceeded · gitGost" msg := fmt.Sprintf("IP %s exceeded the limit of %d PRs/hour (attempts: %d).", ip, rateLimitMaxPRs, count) - // Generate single-use tokens per action (expire in 10 min, never expose panicPassword) tokActivate := newActionToken() tokRollback := newActionToken() tokDeactivate := newActionToken() - // ntfy action buttons: panic control + close burst PRs actions := fmt.Sprintf( `http, Activate Panic, %s/admin/panic, method=POST, body={"token":"%s","active":true}, clear=true; http, Close Burst PRs, %s/admin/rollback, method=POST, body={"token":"%s"}, clear=true; http, Deactivate Panic, %s/admin/panic, method=POST, body={"token":"%s","active":false}`, serviceURL, tokActivate, @@ -1088,10 +1071,6 @@ func notifyAdminRateLimit(ip string, count int) { } } -// PanicHandler activates or deactivates panic mode -// POST /admin/panic body: {"password": "...", "active": true|false} -// -// or body: {"token": "", "active": true|false} func PanicHandler(c *gin.Context) { var req struct { Password string `json:"password"` @@ -1120,15 +1099,12 @@ func PanicHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"panic_mode": req.Active, "state": state}) } -// ServiceStatusHandler returns the current service status (used by the frontend) func ServiceStatusHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "panic_mode": isPanicMode(), }) } -// RollbackBurstHandler closes all PRs registered during the current burst window. -// POST /admin/rollback body: {"password": "..."} or {"token": ""} func RollbackBurstHandler(c *gin.Context) { var req struct { Password string `json:"password"` @@ -1139,7 +1115,6 @@ func RollbackBurstHandler(c *gin.Context) { return } - // Accept either the static password or a valid single-use action token authorized := (panicPassword != "" && req.Password == panicPassword) || (req.Token != "" && consumeActionToken(req.Token)) if !authorized { @@ -1147,7 +1122,6 @@ func RollbackBurstHandler(c *gin.Context) { return } - // Rate limit: max rollbackLimitMax calls per rollbackLimitWin now := time.Now() rollbackLimitMu.Lock() valid := rollbackLimitTimes[:0] @@ -1223,14 +1197,12 @@ func RollbackBurstHandler(c *gin.Context) { }) } -// InitDatabase inicializa el cliente de Supabase de forma thread-safe func InitDatabase(url, key string) { dbOnce.Do(func() { dbClient = database.NewSupabaseClient(url, key) }) } -// RecordPR registra un nuevo PR anonimizado en Supabase func RecordPR(ctx context.Context, owner, repo, prURL string) error { if dbClient == nil { return fmt.Errorf("database client not initialized") @@ -1238,7 +1210,6 @@ func RecordPR(ctx context.Context, owner, repo, prURL string) error { return dbClient.InsertPR(ctx, owner, repo, prURL) } -// StatsHandler maneja el endpoint de estadísticas func StatsHandler(c *gin.Context) { if dbClient == nil { c.JSON(http.StatusOK, gin.H{"total_prs": 0}) @@ -1270,7 +1241,6 @@ func StatsHandler(c *gin.Context) { "total_comments": totalComments, } - // Solo incluir last_updated si hay PRs if lastUpdated != nil { response["last_updated"] = lastUpdated } @@ -1278,7 +1248,6 @@ func StatsHandler(c *gin.Context) { c.JSON(http.StatusOK, response) } -// RecentPRsHandler devuelve los PRs recientes func RecentPRsHandler(c *gin.Context) { if dbClient == nil { c.JSON(http.StatusOK, gin.H{"prs": []database.PRRecord{}, "total": 0}) @@ -1305,7 +1274,6 @@ func RecentPRsHandler(c *gin.Context) { }) } -// CreateAnonymousIssueHandler crea una issue anónima con hash/karma/token func CreateAnonymousIssueHandler(c *gin.Context) { var req anonymousIssueRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -1352,14 +1320,11 @@ func CreateAnonymousIssueHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// GitLabIssueNotesProxyHandler proxea los comentarios de una issue de GitLab usando el token del servidor, -// permitiendo que usuarios anónimos sin cuenta vean los comentarios. func GitLabIssueNotesProxyHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") number := c.Param("number") - // Validar que number sea solo dígitos for _, r := range number { if r < '0' || r > '9' { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid issue number"}) @@ -1394,9 +1359,6 @@ func GitLabIssueNotesProxyHandler(c *gin.Context) { c.Data(resp.StatusCode, "application/json", body) } -// GitLabCommitCountHandler devuelve el numero total de commits de un proyecto GitLab. -// Proxea la API de GitLab para leer la cabecera X-Total si esta disponible (autenticado). -// Si X-Total no esta presente (acceso anonimo), cuenta commits via busqueda binaria. func GitLabCommitCountHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") @@ -1423,8 +1385,6 @@ func GitLabCommitCountHandler(c *gin.Context) { } defer resp.Body.Close() - // Si X-Total esta disponible en la pagina 1 (autenticado), devolverlo como valor negativo - // para indicar que es el total exacto. if page == 1 { if total := resp.Header.Get("X-Total"); total != "" { count, err := strconv.Atoi(total) @@ -1445,25 +1405,21 @@ func GitLabCommitCountHandler(c *gin.Context) { return len(items), nil } - // Pagina 1: ver si X-Total existe o contar items n, err := glFetch(1) if err != nil { c.JSON(http.StatusOK, gin.H{"total": 0}) return } - // Si n es negativo, es el total exacto de X-Total if n < 0 { c.JSON(http.StatusOK, gin.H{"total": -n}) return } - // Si la pagina 1 tiene menos de 100 commits, ese es el total if n < 100 { c.JSON(http.StatusOK, gin.H{"total": n}) return } - // Hay mas paginas. Usar crecimiento exponencial para encontrar un limite superior. lo, hi := 2, 2 for { n, err := glFetch(hi) @@ -1471,19 +1427,17 @@ func GitLabCommitCountHandler(c *gin.Context) { break } if n < 100 { - // Encontramos la ultima pagina exacta total := (hi-1)*100 + n c.JSON(http.StatusOK, gin.H{"total": total}) return } lo = hi + 1 hi *= 2 - if hi > 10000 { // Safety cap: 1,000,000 commits max + if hi > 10000 { break } } - // Busqueda binaria entre lo (no vacio) y hi (vacio) lastNonEmpty := lo - 1 firstEmpty := hi @@ -1496,7 +1450,6 @@ func GitLabCommitCountHandler(c *gin.Context) { if n > 0 { lastNonEmpty = mid if n < 100 { - // Ultima pagina encontrada total := (mid-1)*100 + n c.JSON(http.StatusOK, gin.H{"total": total}) return @@ -1507,7 +1460,6 @@ func GitLabCommitCountHandler(c *gin.Context) { } } - // Obtener el conteo exacto de la ultima pagina no vacia n, err = glFetch(lastNonEmpty) if err != nil { c.JSON(http.StatusOK, gin.H{"total": (lastNonEmpty-1)*100 + 100}) @@ -1517,7 +1469,6 @@ func GitLabCommitCountHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"total": total}) } -// GitLabAvatarHandler busca un usuario de GitLab por email y devuelve su avatar_url. func GitLabAvatarHandler(c *gin.Context) { email := c.Query("email") if email == "" { @@ -1549,7 +1500,6 @@ func GitLabAvatarHandler(c *gin.Context) { body, _ := io.ReadAll(resp.Body) - // GitLab Users API returns an array - extract first match's avatar_url var users []struct { AvatarURL string `json:"avatar_url"` } @@ -1561,8 +1511,6 @@ func GitLabAvatarHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"avatar_url": users[0].AvatarURL}) } -// GitLabCommitsHandler proxies la lista de commits de un proyecto GitLab. -// Evita problemas de CORS y permite autenticacion via GITLAB_TOKEN. func GitLabCommitsHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") @@ -1594,13 +1542,11 @@ func GitLabCommitsHandler(c *gin.Context) { c.Data(resp.StatusCode, "application/json", body) } -// GitLabCommitDetailHandler proxies el detalle de un commit individual (info + diff) de GitLab. func GitLabCommitDetailHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") sha := c.Param("sha") - // Validar que sha sea un hash hexadecimal valido if len(sha) < 6 || len(sha) > 64 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sha"}) return @@ -1618,7 +1564,6 @@ func GitLabCommitDetailHandler(c *gin.Context) { client := &http.Client{Timeout: 10 * time.Second} - // Fetch commit info and diff in parallel type commitResult struct { data []byte ok bool @@ -1661,14 +1606,12 @@ func GitLabCommitDetailHandler(c *gin.Context) { diffRes := <-chDiff - // Parse commit JSON to merge with diff data var commitData map[string]interface{} if err := json.Unmarshal(commitRes.data, &commitData); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "invalid commit data"}) return } - // Add diff files to the response if diffRes.ok && diffRes.data != nil { var diffData []interface{} if err := json.Unmarshal(diffRes.data, &diffData); err == nil { @@ -1679,8 +1622,6 @@ func GitLabCommitDetailHandler(c *gin.Context) { c.JSON(http.StatusOK, commitData) } -// GitHubDiscussionsProxyHandler consulta la GraphQL API de GitHub para discussions, -// evitando problemas de CORS y scraping de HTML. func GitHubDiscussionsProxyHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") @@ -1730,7 +1671,6 @@ func GitHubDiscussionsProxyHandler(c *gin.Context) { body, _ := io.ReadAll(resp.Body) - // Rate limit from GitHub GraphQL if resp.StatusCode == 403 { c.JSON(http.StatusTooManyRequests, gin.H{ "error": "rate_limited", @@ -1744,7 +1684,6 @@ func GitHubDiscussionsProxyHandler(c *gin.Context) { return } - // Parse GraphQL response var ghResp struct { Data struct { Repository struct { @@ -1790,8 +1729,6 @@ func GitHubDiscussionsProxyHandler(c *gin.Context) { }) } -// GitHubDiscussionDetailProxyHandler consulta la GraphQL API de GitHub para una sola discusión, -// incluyendo su cuerpo y comentarios, evitando problemas de CORS. func GitHubDiscussionDetailProxyHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") @@ -1921,8 +1858,6 @@ func GitHubDiscussionDetailProxyHandler(c *gin.Context) { c.JSON(http.StatusOK, disc) } -// GitHubWikiProxyHandler proxea contenido de wiki de GitHub desde raw.githubusercontent.com, -// evitando problemas de CORS y rate limiting desde el navegador. func GitHubWikiProxyHandler(c *gin.Context) { owner := c.Param("owner") repo := c.Param("repo") @@ -1931,7 +1866,6 @@ func GitHubWikiProxyHandler(c *gin.Context) { page = "Home" } - // intentar con .md primero, luego sin extension pageURLs := []string{ fmt.Sprintf("https://raw.githubusercontent.com/wiki/%s/%s/%s.md", owner, repo, page), fmt.Sprintf("https://raw.githubusercontent.com/wiki/%s/%s/%s", owner, repo, page), @@ -1962,7 +1896,6 @@ func GitHubWikiProxyHandler(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "wiki page not found"}) } -// CreateAnonymousCommentHandler publica comentario con hash/karma func CreateAnonymousCommentHandler(c *gin.Context) { var req anonymousCommentRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -2047,7 +1980,6 @@ func CreateAnonymousCommentHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// CreateAnonymousPRCommentHandler publica un comentario anónimo en un Pull Request func CreateAnonymousPRCommentHandler(c *gin.Context) { var req anonymousCommentRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -2130,7 +2062,6 @@ func CreateAnonymousPRCommentHandler(c *gin.Context) { }) } -// CreateAnonymousDiscussionCommentHandler publica un comentario anónimo en una Discussion de GitHub func CreateAnonymousDiscussionCommentHandler(c *gin.Context) { var req anonymousCommentRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -2249,7 +2180,6 @@ func consumeReportToken(token string) bool { return time.Now().Before(expiry) } -// ReportHashHandler permite reportar un hash func ReportHashHandler(c *gin.Context) { if c.Request.Method == http.MethodGet { hash := strings.TrimSpace(c.Query("hash")) @@ -2436,7 +2366,6 @@ func getSecretKey() []byte { b := make([]byte, 32) _, err := rand.Read(b) if err != nil { - // fallback b = []byte(time.Now().String()) } secretKey = b @@ -2495,14 +2424,12 @@ func getScheme(r *http.Request) string { return "http" } -// BadgeHandler serves various badges func BadgeHandler(c *gin.Context) { badge := c.Param("badge") switch badge { case "anonymous-friendly.svg": serveAnonymousFriendlyBadge(c) case "deployed.svg": - // Si el servicio está suspendido, mostrar badge rojo if isPanicMode() { serveSuspendedBadge(c) return @@ -2575,9 +2502,9 @@ func serveAnonymousFriendlyBadge(c *gin.Context) { } } - fillColor := "#4CAF50" // green if static or verified + fillColor := "#4CAF50" if repo != "" && !verified { - fillColor = "#9E9E9E" // gray if dynamic and not verified + fillColor = "#9E9E9E" } svg := fmt.Sprintf(`Anonymous Contributor FriendlyAnonymous Contributor Friendly`, fillColor)