package modules import ( "encoding/json" "fmt" "log/slog" "net/http" "net/url" "path/filepath" "strings" "git.ofmax.li/go-git-server/internal/admin" "github.com/go-chi/chi/v5" ) // ModuleHandler handles Go module proxy requests and go-import metadata type ModuleHandler struct { reposDir string serverHost string } // normalizeImportPath creates a properly formatted full import path func (h *ModuleHandler) normalizeImportPath(modulePath string) (string, error) { if h.serverHost == "" { return "", fmt.Errorf("serverHost cannot be empty") } if modulePath == "" { return "", fmt.Errorf("modulePath cannot be empty") } if strings.Contains(modulePath, "..") { return "", fmt.Errorf("modulePath cannot contain '..'") } // Clean the inputs host := strings.TrimRight(h.serverHost, "/") module := strings.Trim(modulePath, "/") // Validate that module path doesn't contain invalid characters if strings.ContainsAny(module, " \t\n\r") { return "", fmt.Errorf("modulePath cannot contain whitespace characters") } return host + "/" + module, nil } // buildRepoURL creates a proper repository URL func (h *ModuleHandler) buildRepoURL(modulePath string) (string, error) { _, err := h.normalizeImportPath(modulePath) if err != nil { return "", err } // Use proper URL building to ensure valid URLs repoURL := &url.URL{ Scheme: "https", Host: h.serverHost, Path: "/" + strings.Trim(modulePath, "/"), } return repoURL.String(), nil } // generateGoImportHTML creates the HTML response for go-import requests func (h *ModuleHandler) generateGoImportHTML(modulePath string) (string, error) { fullImportPath, err := h.normalizeImportPath(modulePath) if err != nil { return "", fmt.Errorf("failed to normalize import path: %w", err) } repoURL, err := h.buildRepoURL(modulePath) if err != nil { return "", fmt.Errorf("failed to build repository URL: %w", err) } return fmt.Sprintf(`
go get %s `, fullImportPath, repoURL, fullImportPath, repoURL, repoURL, repoURL, fullImportPath), nil } // NewModuleHandler creates a new module handler with explicit routes for known repos func NewModuleHandler(reposDir, serverHost string, config *admin.ServerRepos) http.Handler { handler := &ModuleHandler{ reposDir: reposDir, serverHost: serverHost, } r := chi.NewRouter() if config == nil { slog.Warn("no server config provided, falling back to catch-all routing") r.Get("/*", handler.handleAllRequests) return r } // Register explicit routes only for repositories configured as Go modules for _, repo := range config.Repos { if !repo.GoModule { slog.Debug("skipping non-Go module repo", "repo", repo.Name) continue } // Use repo name as module path modulePath := repo.Name r.Get("/"+modulePath+"/@v/list", handler.createVersionListHandler(modulePath)) r.Get("/"+modulePath+"/@v/*", handler.createGenericVersionHandler(modulePath)) r.Get("/"+modulePath+"/@latest", handler.createLatestVersionHandler(modulePath)) r.Get("/"+modulePath, handler.createGoImportHandler(modulePath)) r.Get("/"+modulePath+"/", handler.createGoImportHandler(modulePath)) slog.Debug("registered Go module routes", "module", modulePath) } return r } // Handler creators that capture the module path func (h *ModuleHandler) createVersionListHandler(modulePath string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.handleVersionListForModule(w, r, modulePath) } } func (h *ModuleHandler) createLatestVersionHandler(modulePath string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.handleLatestVersionForModule(w, r, modulePath) } } func (h *ModuleHandler) createGoImportHandler(modulePath string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.handleGoImportForModule(w, r, modulePath) } } func (h *ModuleHandler) createGenericVersionHandler(modulePath string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if strings.HasSuffix(path, ".info") { version := ExtractVersion(path) h.handleVersionInfoForModule(w, r, modulePath, version) } else if strings.HasSuffix(path, ".mod") { version := ExtractVersion(path) h.handleModFileForModule(w, r, modulePath, version) } else if strings.HasSuffix(path, ".zip") { version := ExtractVersion(path) h.handleModuleZipForModule(w, r, modulePath, version) } else { http.NotFound(w, r) } } } // handleAllRequests routes to the appropriate handler based on the URL path func (h *ModuleHandler) handleAllRequests(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Route to specific handlers based on path patterns if strings.HasSuffix(path, "/@v/list") { h.handleVersionList(w, r) return } if strings.HasSuffix(path, "/@latest") { h.handleLatestVersion(w, r) return } if strings.Contains(path, "/@v/") { if strings.HasSuffix(path, ".info") { h.handleVersionInfo(w, r) return } if strings.HasSuffix(path, ".mod") { h.handleModFile(w, r) return } if strings.HasSuffix(path, ".zip") { h.handleModuleZip(w, r) return } } // Default to go-import handler for all other requests h.handleGoImport(w, r) } // New handler methods that accept module path as parameter func (h *ModuleHandler) handleVersionListForModule(w http.ResponseWriter, r *http.Request, modulePath string) { repoPath := filepath.Join(h.reposDir, modulePath+".git") versions, err := h.getVersions(repoPath) if err != nil { slog.Error("failed to get versions", "module", modulePath, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) for _, version := range versions { fmt.Fprintln(w, version) } slog.Debug("served version list", "module", modulePath, "count", len(versions)) } func (h *ModuleHandler) handleLatestVersionForModule(w http.ResponseWriter, r *http.Request, modulePath string) { repoPath := filepath.Join(h.reposDir, modulePath+".git") version, err := h.getLatestVersion(repoPath) if err != nil { slog.Error("failed to get latest version", "module", modulePath, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } timestamp, err := h.getVersionTimestamp(repoPath, version) if err != nil { slog.Error("failed to get version timestamp", "module", modulePath, "version", version, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } info := VersionInfo{ Version: version, Time: timestamp, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(info); err != nil { slog.Error("failed to encode JSON response", "error", err) } slog.Debug("served latest version", "module", modulePath, "version", version) } func (h *ModuleHandler) handleVersionInfoForModule(w http.ResponseWriter, r *http.Request, modulePath, version string) { repoPath := filepath.Join(h.reposDir, modulePath+".git") timestamp, err := h.getVersionTimestamp(repoPath, version) if err != nil { slog.Error("failed to get version timestamp", "module", modulePath, "version", version, "error", err) // Check if it's a repository access issue (500) vs version not found (404) if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "failed to open repository") { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } else { http.Error(w, "Not Found", http.StatusNotFound) } return } info := VersionInfo{ Version: version, Time: timestamp, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(info); err != nil { slog.Error("failed to encode JSON response", "error", err) } slog.Debug("served version info", "module", modulePath, "version", version) } func (h *ModuleHandler) handleModFileForModule(w http.ResponseWriter, r *http.Request, modulePath, version string) { repoPath := filepath.Join(h.reposDir, modulePath+".git") modContent, err := h.getModFile(repoPath, version) if err != nil { slog.Error("failed to get mod file", "module", modulePath, "version", version, "error", err) // Check if it's a repository access issue (500) vs version not found (404) if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "failed to open repository") { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } else { http.Error(w, "Not Found", http.StatusNotFound) } return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) if _, err := w.Write(modContent); err != nil { slog.Error("failed to write mod file response", "error", err) } slog.Debug("served mod file", "module", modulePath, "version", version) } func (h *ModuleHandler) handleModuleZipForModule(w http.ResponseWriter, r *http.Request, modulePath, version string) { repoPath := filepath.Join(h.reposDir, modulePath+".git") zipData, err := h.getModuleZip(repoPath, version) if err != nil { slog.Error("failed to get module zip", "module", modulePath, "version", version, "error", err) // Check if it's a repository access issue (500) vs version not found (404) if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "failed to open repository") { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } else { http.Error(w, "Not Found", http.StatusNotFound) } return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s@%s.zip", strings.ReplaceAll(modulePath, "/", "-"), version)) w.WriteHeader(http.StatusOK) if _, err := w.Write(zipData); err != nil { slog.Error("failed to write zip response", "error", err) } slog.Debug("served module zip", "module", modulePath, "version", version, "size", len(zipData)) } func (h *ModuleHandler) handleGoImportForModule(w http.ResponseWriter, r *http.Request, modulePath string) { // Only handle if go-get=1 parameter is present if r.URL.Query().Get("go-get") != "1" { http.NotFound(w, r) return } // Generate HTML with go-import meta tag html, err := h.generateGoImportHTML(modulePath) if err != nil { slog.Error("failed to generate go-import HTML", "module", modulePath, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte(html)); err != nil { slog.Error("failed to write go-import response", "error", err) } slog.Debug("served go-import", "module", modulePath) } // handleGoImport serves the go-import meta tag for module discovery func (h *ModuleHandler) handleGoImport(w http.ResponseWriter, r *http.Request) { // Only handle if go-get=1 parameter is present if r.URL.Query().Get("go-get") != "1" { http.NotFound(w, r) return } modulePath := ExtractModulePath(r.URL.Path) // Generate HTML with go-import meta tag html, err := h.generateGoImportHTML(modulePath) if err != nil { slog.Error("failed to generate go-import HTML", "module", modulePath, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte(html)); err != nil { slog.Error("failed to write go-import response", "error", err) } slog.Debug("served go-import", "module", modulePath) } // handleVersionList returns a list of available versions func (h *ModuleHandler) handleVersionList(w http.ResponseWriter, r *http.Request) { modulePath := ExtractModulePath(r.URL.Path) repoPath := filepath.Join(h.reposDir, modulePath+".git") versions, err := h.getVersions(repoPath) if err != nil { slog.Error("failed to get versions", "module", modulePath, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) for _, version := range versions { fmt.Fprintln(w, version) } slog.Debug("served version list", "module", modulePath, "count", len(versions)) } // handleLatestVersion returns the latest version information func (h *ModuleHandler) handleLatestVersion(w http.ResponseWriter, r *http.Request) { modulePath := ExtractModulePath(r.URL.Path) repoPath := filepath.Join(h.reposDir, modulePath+".git") version, err := h.getLatestVersion(repoPath) if err != nil { slog.Error("failed to get latest version", "module", modulePath, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } timestamp, err := h.getVersionTimestamp(repoPath, version) if err != nil { slog.Error("failed to get version timestamp", "module", modulePath, "version", version, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } info := VersionInfo{ Version: version, Time: timestamp, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(info); err != nil { slog.Error("failed to encode JSON response", "error", err) } slog.Debug("served latest version", "module", modulePath, "version", version) } // handleVersionInfo returns version metadata func (h *ModuleHandler) handleVersionInfo(w http.ResponseWriter, r *http.Request) { modulePath := ExtractModulePath(r.URL.Path) version := ExtractVersion(r.URL.Path) repoPath := filepath.Join(h.reposDir, modulePath+".git") timestamp, err := h.getVersionTimestamp(repoPath, version) if err != nil { slog.Error("failed to get version timestamp", "module", modulePath, "version", version, "error", err) // Check if it's a repository access issue (500) vs version not found (404) if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "failed to open repository") { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } else { http.Error(w, "Not Found", http.StatusNotFound) } return } info := VersionInfo{ Version: version, Time: timestamp, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(info); err != nil { slog.Error("failed to encode JSON response", "error", err) } slog.Debug("served version info", "module", modulePath, "version", version) } // handleModFile returns the go.mod file for a specific version func (h *ModuleHandler) handleModFile(w http.ResponseWriter, r *http.Request) { modulePath := ExtractModulePath(r.URL.Path) version := ExtractVersion(r.URL.Path) repoPath := filepath.Join(h.reposDir, modulePath+".git") modContent, err := h.getModFile(repoPath, version) if err != nil { slog.Error("failed to get mod file", "module", modulePath, "version", version, "error", err) // Check if it's a repository access issue (500) vs version not found (404) if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "failed to open repository") { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } else { http.Error(w, "Not Found", http.StatusNotFound) } return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) if _, err := w.Write(modContent); err != nil { slog.Error("failed to write mod file response", "error", err) } slog.Debug("served mod file", "module", modulePath, "version", version) } // handleModuleZip returns a zip archive of the module source func (h *ModuleHandler) handleModuleZip(w http.ResponseWriter, r *http.Request) { modulePath := ExtractModulePath(r.URL.Path) version := ExtractVersion(r.URL.Path) repoPath := filepath.Join(h.reposDir, modulePath+".git") zipData, err := h.getModuleZip(repoPath, version) if err != nil { slog.Error("failed to get module zip", "module", modulePath, "version", version, "error", err) // Check if it's a repository access issue (500) vs version not found (404) if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "failed to open repository") { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } else { http.Error(w, "Not Found", http.StatusNotFound) } return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s@%s.zip", strings.ReplaceAll(modulePath, "/", "-"), version)) w.WriteHeader(http.StatusOK) if _, err := w.Write(zipData); err != nil { slog.Error("failed to write zip response", "error", err) } slog.Debug("served module zip", "module", modulePath, "version", version, "size", len(zipData)) } // VersionInfo represents module version metadata type VersionInfo struct { Version string `json:"Version"` Time string `json:"Time"` }