diff options
| author | Max Resnick <max@ofmax.li> | 2025-08-01 22:10:20 -0700 |
|---|---|---|
| committer | Max Resnick <max@ofmax.li> | 2025-09-06 21:47:16 -0700 |
| commit | df0a52f53739a1bc05620f3f21533922488c0491 (patch) | |
| tree | b5c201bbf214a93d8ed934492bd888e8fc8a1388 /internal | |
| parent | 462bfd8fc9707a5eae3233e69f5b8a522972ed74 (diff) | |
| download | go-git-server-1.0.4-latest.df0a52f.11.tar.gz | |
feat: go-module support1.0.4-latest.df0a52f.11
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/model.go | 2 | ||||
| -rw-r--r-- | internal/git/handler_test.go | 1 | ||||
| -rw-r--r-- | internal/modules/git_ops.go | 270 | ||||
| -rw-r--r-- | internal/modules/git_ops_test.go | 277 | ||||
| -rw-r--r-- | internal/modules/handler.go | 473 | ||||
| -rw-r--r-- | internal/modules/handler_test.go | 550 | ||||
| -rw-r--r-- | internal/modules/middleware.go | 103 | ||||
| -rw-r--r-- | internal/modules/middleware_test.go | 216 |
8 files changed, 1891 insertions, 1 deletions
diff --git a/internal/admin/model.go b/internal/admin/model.go index 25262de..0740e1d 100644 --- a/internal/admin/model.go +++ b/internal/admin/model.go @@ -79,6 +79,8 @@ type GitRepo struct { Public bool `json:"public"` // Name game of repository Name string `json:"name"` + // GoModule indicates if this repo should be exposed as a Go module proxy + GoModule bool `json:"go_module"` // Web config settings GitWebConfig *GitWeb `json:"git_web_config"` // Permissions for authorization diff --git a/internal/git/handler_test.go b/internal/git/handler_test.go index 95f1bc7..ee80906 100644 --- a/internal/git/handler_test.go +++ b/internal/git/handler_test.go @@ -2,7 +2,6 @@ package git import ( "testing" - ) func TestGitHandler(t *testing.T) { diff --git a/internal/modules/git_ops.go b/internal/modules/git_ops.go new file mode 100644 index 0000000..5fd37c7 --- /dev/null +++ b/internal/modules/git_ops.go @@ -0,0 +1,270 @@ +package modules + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "path/filepath" + "sort" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// getVersions returns all available versions (tags) for a repository +func (h *ModuleHandler) getVersions(repoPath string) ([]string, error) { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to open repository: %w", err) + } + + tagRefs, err := repo.Tags() + if err != nil { + return nil, fmt.Errorf("failed to get tags: %w", err) + } + + semverVersions := semver.Collection{} + err = tagRefs.ForEach(func(ref *plumbing.Reference) error { + tagName := ref.Name().Short() + // Only include semantic version tags + if version, err := semver.NewVersion(tagName); err == nil { + semverVersions = append(semverVersions, version) + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to iterate tags: %w", err) + } + + // Sort versions in descending order (newest first) + sort.Sort(sort.Reverse(semverVersions)) + + // Convert back to strings with v prefix + versions := make([]string, len(semverVersions)) + for i, v := range semverVersions { + versions[i] = "v" + v.String() + } + + return versions, nil +} + +// getLatestVersion returns the latest version tag or a pseudo-version +func (h *ModuleHandler) getLatestVersion(repoPath string) (string, error) { + versions, err := h.getVersions(repoPath) + if err != nil { + return "", err + } + + if len(versions) > 0 { + return versions[0], nil + } + + // No tags found, generate pseudo-version from latest commit + return h.generatePseudoVersion(repoPath) +} + +// generatePseudoVersion creates a pseudo-version from the latest commit +func (h *ModuleHandler) generatePseudoVersion(repoPath string) (string, error) { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return "", fmt.Errorf("failed to open repository: %w", err) + } + + head, err := repo.Head() + if err != nil { + return "", fmt.Errorf("failed to get HEAD: %w", err) + } + + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + return "", fmt.Errorf("failed to get commit: %w", err) + } + + // Format: v0.0.0-yyyymmddhhmmss-abcdefabcdef + timestamp := commit.Committer.When.UTC().Format("20060102150405") + shortHash := head.Hash().String()[:12] + + return fmt.Sprintf("v0.0.0-%s-%s", timestamp, shortHash), nil +} + +// getVersionTimestamp returns the timestamp for a specific version +func (h *ModuleHandler) getVersionTimestamp(repoPath, version string) (string, error) { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return "", fmt.Errorf("failed to open repository: %w", err) + } + + // Try to resolve as tag first + tagRef, err := repo.Tag(version) + if err != nil && len(version) > 0 && version[0] == 'v' { + // Try without v prefix + tagRef, err = repo.Tag(version[1:]) + } + if err == nil { + // Try to get commit directly first (lightweight tag) + hash := tagRef.Hash() + if hash.IsZero() { + return "", fmt.Errorf("tag reference has zero hash for version %s", version) + } + + commit, err := repo.CommitObject(hash) + if err == nil && commit != nil { + return commit.Committer.When.UTC().Format(time.RFC3339), nil + } + + // Try as annotated tag + tagObj, err := repo.TagObject(hash) + if err == nil && tagObj != nil { + return tagObj.Tagger.When.UTC().Format(time.RFC3339), nil + } + + return "", fmt.Errorf("failed to get timestamp for tag %s: %w", version, err) + } + + // Try to resolve as commit hash + hash := plumbing.NewHash(version) + commit, err := repo.CommitObject(hash) + if err != nil { + return "", fmt.Errorf("version not found: %s", version) + } + + return commit.Committer.When.UTC().Format(time.RFC3339), nil +} + +// getModFile returns the go.mod file content for a specific version +func (h *ModuleHandler) getModFile(repoPath, version string) ([]byte, error) { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to open repository: %w", err) + } + + hash, err := h.resolveVersion(repo, version) + if err != nil { + return nil, err + } + + commit, err := repo.CommitObject(hash) + if err != nil { + return nil, fmt.Errorf("failed to get commit: %w", err) + } + + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("failed to get tree: %w", err) + } + + file, err := tree.File("go.mod") + if err != nil { + return nil, fmt.Errorf("go.mod not found: %w", err) + } + + content, err := file.Contents() + if err != nil { + return nil, fmt.Errorf("failed to read go.mod: %w", err) + } + + return []byte(content), nil +} + +// getModuleZip creates a zip archive of the module source for a specific version +func (h *ModuleHandler) getModuleZip(repoPath, version string) ([]byte, error) { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to open repository: %w", err) + } + + hash, err := h.resolveVersion(repo, version) + if err != nil { + return nil, err + } + + commit, err := repo.CommitObject(hash) + if err != nil { + return nil, fmt.Errorf("failed to get commit: %w", err) + } + + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("failed to get tree: %w", err) + } + + buf := bytes.Buffer{} + zipWriter := zip.NewWriter(&buf) + + // Walk the tree and add files to zip + err = tree.Files().ForEach(func(file *object.File) error { + // Skip files in nested modules (subdirectories with go.mod) + if h.isInNestedModule(tree, filepath.Dir(file.Name)) && file.Name != "go.mod" { + return nil + } + + fileWriter, err := zipWriter.Create(file.Name) + if err != nil { + return err + } + + reader, err := file.Reader() + if err != nil { + return err + } + defer reader.Close() + + _, err = io.Copy(fileWriter, reader) + return err + }) + + if err != nil { + return nil, fmt.Errorf("failed to create zip: %w", err) + } + + err = zipWriter.Close() + if err != nil { + return nil, fmt.Errorf("failed to close zip: %w", err) + } + + return buf.Bytes(), nil +} + +// resolveVersion converts a version string to a commit hash +func (h *ModuleHandler) resolveVersion(repo *git.Repository, version string) (plumbing.Hash, error) { + // Try as tag first + tagRef, err := repo.Tag(version) + if err == nil { + return tagRef.Hash(), nil + } + + // Try without v prefix if version starts with v + if len(version) > 0 && version[0] == 'v' { + tagRef, err := repo.Tag(version[1:]) + if err == nil { + return tagRef.Hash(), nil + } + } + + // Try as commit hash + if len(version) >= 7 { + hash := plumbing.NewHash(version) + _, err := repo.CommitObject(hash) + if err == nil { + return hash, nil + } + } + + return plumbing.ZeroHash, fmt.Errorf("version not found: %s", version) +} + +// isInNestedModule checks if a directory contains a go.mod file (nested module) +func (h *ModuleHandler) isInNestedModule(tree *object.Tree, dir string) bool { + if dir == "" || dir == "." { + return false + } + + modPath := filepath.Join(dir, "go.mod") + _, err := tree.File(modPath) + return err == nil +} diff --git a/internal/modules/git_ops_test.go b/internal/modules/git_ops_test.go new file mode 100644 index 0000000..3ab0492 --- /dev/null +++ b/internal/modules/git_ops_test.go @@ -0,0 +1,277 @@ +package modules + +import ( + "os" + "testing" + + "github.com/go-git/go-git/v5" +) + +func createTestRepo(t *testing.T) (*git.Repository, string) { + t.Helper() + + // Create a temporary directory for the test repo + tmpDir, err := os.MkdirTemp("", "test-repo-*") + if err != nil { + t.Fatal(err) + } + + // Initialize repository + repo, err := git.PlainInit(tmpDir, true) // bare repository + if err != nil { + t.Fatal(err) + } + + return repo, tmpDir +} + +func TestGetVersions(t *testing.T) { + handler := &ModuleHandler{} + + t.Run("repository_not_found", func(t *testing.T) { + versions, err := handler.getVersions("/nonexistent/path") + if err == nil { + t.Error("expected error for nonexistent repository") + } + if versions != nil { + t.Error("expected nil versions for nonexistent repository") + } + }) + + t.Run("empty_repository", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + versions, err := handler.getVersions(tmpDir) + if err != nil { + t.Errorf("expected no error for empty repository, got: %v", err) + } + if len(versions) != 0 { + t.Errorf("expected 0 versions for empty repository, got %d", len(versions)) + } + + _ = repo // Use repo to avoid unused variable + }) +} + +func TestGetLatestVersion(t *testing.T) { + handler := &ModuleHandler{} + + t.Run("repository_not_found", func(t *testing.T) { + version, err := handler.getLatestVersion("/nonexistent/path") + if err == nil { + t.Error("expected error for nonexistent repository") + } + if version != "" { + t.Error("expected empty version for nonexistent repository") + } + }) + + t.Run("empty_repository", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + // This should fail because there are no commits in the repository + version, err := handler.getLatestVersion(tmpDir) + if err == nil { + t.Error("expected error for empty repository") + } + if version != "" { + t.Error("expected empty version for empty repository") + } + + _ = repo + }) +} + +func TestGeneratePseudoVersion(t *testing.T) { + handler := &ModuleHandler{} + + t.Run("repository_not_found", func(t *testing.T) { + version, err := handler.generatePseudoVersion("/nonexistent/path") + if err == nil { + t.Error("expected error for nonexistent repository") + } + if version != "" { + t.Error("expected empty version for nonexistent repository") + } + }) +} + +func TestGetVersionTimestamp(t *testing.T) { + handler := &ModuleHandler{} + + t.Run("repository_not_found", func(t *testing.T) { + timestamp, err := handler.getVersionTimestamp("/nonexistent/path", "v1.0.0") + if err == nil { + t.Error("expected error for nonexistent repository") + } + if timestamp != "" { + t.Error("expected empty timestamp for nonexistent repository") + } + }) + + t.Run("version_not_found", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + timestamp, err := handler.getVersionTimestamp(tmpDir, "v9.9.9") + if err == nil { + t.Error("expected error for nonexistent version") + } + if timestamp != "" { + t.Error("expected empty timestamp for nonexistent version") + } + + _ = repo + }) + + t.Run("empty_version_string", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + _, err := handler.getVersionTimestamp(tmpDir, "") + if err == nil { + t.Error("expected error for empty version string") + } + + _ = repo + }) + + t.Run("version_with_v_prefix", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + // Test that version starting with 'v' is handled (should try both with and without) + _, err := handler.getVersionTimestamp(tmpDir, "v1.0.0") + if err == nil { + t.Error("expected error for nonexistent version") + } + + _ = repo + }) +} + +func TestGetModFile(t *testing.T) { + handler := &ModuleHandler{} + + t.Run("repository_not_found", func(t *testing.T) { + content, err := handler.getModFile("/nonexistent/path", "v1.0.0") + if err == nil { + t.Error("expected error for nonexistent repository") + } + if content != nil { + t.Error("expected nil content for nonexistent repository") + } + }) + + t.Run("version_not_found", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + content, err := handler.getModFile(tmpDir, "v9.9.9") + if err == nil { + t.Error("expected error for nonexistent version") + } + if content != nil { + t.Error("expected nil content for nonexistent version") + } + + _ = repo + }) +} + +func TestGetModuleZip(t *testing.T) { + handler := &ModuleHandler{} + + t.Run("repository_not_found", func(t *testing.T) { + zipData, err := handler.getModuleZip("/nonexistent/path", "v1.0.0") + if err == nil { + t.Error("expected error for nonexistent repository") + } + if zipData != nil { + t.Error("expected nil zip data for nonexistent repository") + } + }) + + t.Run("version_not_found", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + zipData, err := handler.getModuleZip(tmpDir, "v9.9.9") + if err == nil { + t.Error("expected error for nonexistent version") + } + if zipData != nil { + t.Error("expected nil zip data for nonexistent version") + } + + _ = repo + }) +} + +func TestResolveVersion(t *testing.T) { + handler := &ModuleHandler{} + + t.Run("nonexistent_tag", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + hash, err := handler.resolveVersion(repo, "v1.0.0") + if err == nil { + t.Error("expected error for nonexistent version") + } + if !hash.IsZero() { + t.Error("expected zero hash for nonexistent version") + } + }) + + t.Run("short_version_string", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + // Test with a version string that's too short to be a hash + hash, err := handler.resolveVersion(repo, "abc") + if err == nil { + t.Error("expected error for short version string") + } + if !hash.IsZero() { + t.Error("expected zero hash for short version string") + } + }) + + t.Run("version_with_v_prefix", func(t *testing.T) { + repo, tmpDir := createTestRepo(t) + defer os.RemoveAll(tmpDir) + + // Test version with 'v' prefix + hash, err := handler.resolveVersion(repo, "v1.2.3") + if err == nil { + t.Error("expected error for nonexistent version") + } + if !hash.IsZero() { + t.Error("expected zero hash for nonexistent version") + } + }) +} + +func TestIsInNestedModule(t *testing.T) { + handler := &ModuleHandler{} + + // We'll test this with a nil tree since setting up a real tree is complex + // This tests the edge cases and basic logic + + t.Run("root_directory", func(t *testing.T) { + isNested := handler.isInNestedModule(nil, "") + if isNested { + t.Error("root directory should not be considered nested module") + } + }) + + t.Run("current_directory", func(t *testing.T) { + isNested := handler.isInNestedModule(nil, ".") + if isNested { + t.Error("current directory should not be considered nested module") + } + }) +} diff --git a/internal/modules/handler.go b/internal/modules/handler.go new file mode 100644 index 0000000..9fa1094 --- /dev/null +++ b/internal/modules/handler.go @@ -0,0 +1,473 @@ +package modules + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "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 +} + +// 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 := fmt.Sprintf(`<!DOCTYPE html> +<html> +<head> + <meta name="go-import" content="%s git https://%s/%s"> + <meta name="go-source" content="%s https://%s/%s https://%s/%s/tree/{/dir} https://%s/%s/blob/{/dir}/{file}#L{line}"> +</head> +<body> + go get %s +</body> +</html>`, + modulePath, h.serverHost, modulePath, + modulePath, h.serverHost, modulePath, h.serverHost, modulePath, h.serverHost, modulePath, + modulePath) + + 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 := fmt.Sprintf(`<!DOCTYPE html> +<html> +<head> + <meta name="go-import" content="%s git https://%s/%s"> + <meta name="go-source" content="%s https://%s/%s https://%s/%s/tree/{/dir} https://%s/%s/blob/{/dir}/{file}#L{line}"> +</head> +<body> + go get %s +</body> +</html>`, + modulePath, h.serverHost, modulePath, + modulePath, h.serverHost, modulePath, h.serverHost, modulePath, h.serverHost, modulePath, + modulePath) + + 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"` +} diff --git a/internal/modules/handler_test.go b/internal/modules/handler_test.go new file mode 100644 index 0000000..b173f98 --- /dev/null +++ b/internal/modules/handler_test.go @@ -0,0 +1,550 @@ +package modules + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.ofmax.li/go-git-server/internal/admin" +) + +func TestNewModuleHandler_WithNilConfig(t *testing.T) { + handler := NewModuleHandler("/tmp/repos", "example.com", nil) + + // Test that it returns a valid http.Handler even with nil config + if handler == nil { + t.Error("NewModuleHandler should return a non-nil handler") + } +} + +func TestNewModuleHandler_WithConfig(t *testing.T) { + config := &admin.ServerRepos{ + Repos: []*admin.GitRepo{ + { + Name: "mylib", + GoModule: true, + }, + }, + } + + handler := NewModuleHandler("/tmp/repos", "example.com", config) + + // Test that it returns a valid http.Handler + if handler == nil { + t.Error("NewModuleHandler should return a non-nil handler") + } +} + +func TestHandleGoImport_ConfiguredModule(t *testing.T) { + config := &admin.ServerRepos{ + Repos: []*admin.GitRepo{ + { + Name: "mymodule", + GoModule: true, + }, + }, + } + + handler := NewModuleHandler("/tmp/repos", "git.example.com", config) + + req := httptest.NewRequest("GET", "/mymodule?go-get=1", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + body := w.Body.String() + + // Check for go-import meta tag + if !strings.Contains(body, `<meta name="go-import"`) { + t.Error("response should contain go-import meta tag") + } + + // Check for correct module path and VCS info + expectedContent := `content="mymodule git https://git.example.com/mymodule"` + if !strings.Contains(body, expectedContent) { + t.Errorf("response should contain %s", expectedContent) + } + + // Check for go-source meta tag + if !strings.Contains(body, `<meta name="go-source"`) { + t.Error("response should contain go-source meta tag") + } + + // Check content type + contentType := w.Header().Get("Content-Type") + if contentType != "text/html; charset=utf-8" { + t.Errorf("expected content type text/html; charset=utf-8, got %s", contentType) + } +} + +func TestModuleHandler_ServeHTTP_VersionList(t *testing.T) { + config := &admin.ServerRepos{ + Repos: []*admin.GitRepo{ + { + Name: "mymodule", + GoModule: true, + }, + }, + } + + handler := NewModuleHandler("/tmp/repos", "git.example.com", config) + + req := httptest.NewRequest("GET", "/mymodule/@v/list", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Since we don't have a real repo, this should return 500 (internal server error) + // But we can test that it tries to handle the request correctly + if w.Code == http.StatusOK { + contentType := w.Header().Get("Content-Type") + if contentType != "text/plain; charset=utf-8" { + t.Errorf("expected content type text/plain; charset=utf-8, got %s", contentType) + } + } +} + +func TestGoModuleConfiguration(t *testing.T) { + config := &admin.ServerRepos{ + Repos: []*admin.GitRepo{ + { + Name: "mylib", + GoModule: true, // This should have module endpoints + }, + { + Name: "website", + GoModule: false, // This should not have module endpoints + }, + }, + } + + handler := NewModuleHandler("/tmp/repos", "git.example.com", config) + + // Test Go module repo (should work) + t.Run("go_module_true", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mylib?go-get=1", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for Go module, got %d", w.Code) + } + }) + + // Test non-Go module repo (should return 404) + t.Run("go_module_false", func(t *testing.T) { + req := httptest.NewRequest("GET", "/website?go-get=1", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404 for non-Go module, got %d", w.Code) + } + }) + + // Test proxy endpoints for Go module + t.Run("go_module_proxy_endpoints", func(t *testing.T) { + endpoints := []string{ + "/mylib/@v/list", + "/mylib/@latest", + } + + for _, endpoint := range endpoints { + req := httptest.NewRequest("GET", endpoint, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Should get 500 (repo doesn't exist) not 404 (route doesn't exist) + if w.Code == http.StatusNotFound { + t.Errorf("endpoint %s should be routed (got 404, want 500 or 200)", endpoint) + } + } + }) + + // Test proxy endpoints for non-Go module (should all be 404) + t.Run("non_go_module_proxy_endpoints", func(t *testing.T) { + endpoints := []string{ + "/website/@v/list", + "/website/@latest", + } + + for _, endpoint := range endpoints { + req := httptest.NewRequest("GET", endpoint, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("endpoint %s should return 404 for non-Go module, got %d", endpoint, w.Code) + } + } + }) +} + +// Test the catch-all handler (handleAllRequests) - currently 0% coverage +func TestHandleAllRequests(t *testing.T) { + handler := &ModuleHandler{ + reposDir: "/tmp/repos", + serverHost: "git.example.com", + } + + t.Run("version_list_endpoint", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/list", nil) + w := httptest.NewRecorder() + + handler.handleAllRequests(w, req) + + // Should get 500 (repo doesn't exist) not 404 (route doesn't exist) + if w.Code == http.StatusNotFound { + t.Error("handleAllRequests should route version list requests") + } + }) + + t.Run("latest_endpoint", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@latest", nil) + w := httptest.NewRecorder() + + handler.handleAllRequests(w, req) + + // Should get 500 (repo doesn't exist) not 404 (route doesn't exist) + if w.Code == http.StatusNotFound { + t.Error("handleAllRequests should route latest version requests") + } + }) + + t.Run("version_info_endpoint", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.info", nil) + w := httptest.NewRecorder() + + handler.handleAllRequests(w, req) + + // Should get 500/404 (repo/version doesn't exist) not 404 (route doesn't exist) + if w.Code == http.StatusOK { + t.Error("expected error for nonexistent repository/version") + } + }) + + t.Run("mod_file_endpoint", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.mod", nil) + w := httptest.NewRecorder() + + handler.handleAllRequests(w, req) + + // Should get 500/404 (repo/version doesn't exist) not 404 (route doesn't exist) + if w.Code == http.StatusOK { + t.Error("expected error for nonexistent repository/version") + } + }) + + t.Run("zip_file_endpoint", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.zip", nil) + w := httptest.NewRecorder() + + handler.handleAllRequests(w, req) + + // Should get 500/404 (repo/version doesn't exist) not 404 (route doesn't exist) + if w.Code == http.StatusOK { + t.Error("expected error for nonexistent repository/version") + } + }) + + t.Run("go_import_request", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule?go-get=1", nil) + w := httptest.NewRecorder() + + handler.handleAllRequests(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 for go-import request, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "go-import") { + t.Error("response should contain go-import meta tag") + } + }) + + t.Run("regular_request", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule", nil) + w := httptest.NewRecorder() + + handler.handleAllRequests(w, req) + + // Should get 404 because go-get=1 is required for go-import + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for regular request without go-get, got %d", w.Code) + } + }) +} + +// Test non-"ForModule" handler functions - currently 0% coverage +func TestNonForModuleHandlers(t *testing.T) { + handler := &ModuleHandler{ + reposDir: "/tmp/repos", + serverHost: "git.example.com", + } + + t.Run("handleGoImport", func(t *testing.T) { + // Test without go-get parameter + req := httptest.NewRequest("GET", "/mymodule", nil) + w := httptest.NewRecorder() + + handler.handleGoImport(w, req) + + if w.Code != http.StatusNotFound { + t.Error("expected 404 when go-get parameter is missing") + } + + // Test with go-get=1 + req = httptest.NewRequest("GET", "/mymodule?go-get=1", nil) + w = httptest.NewRecorder() + + handler.handleGoImport(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 with go-get=1, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "go-import") { + t.Error("response should contain go-import meta tag") + } + }) + + t.Run("handleVersionList", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/list", nil) + w := httptest.NewRecorder() + + handler.handleVersionList(w, req) + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("handleLatestVersion", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@latest", nil) + w := httptest.NewRecorder() + + handler.handleLatestVersion(w, req) + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("handleVersionInfo", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.info", nil) + w := httptest.NewRecorder() + + handler.handleVersionInfo(w, req) + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("handleModFile", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.mod", nil) + w := httptest.NewRecorder() + + handler.handleModFile(w, req) + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("handleModuleZip", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.zip", nil) + w := httptest.NewRecorder() + + handler.handleModuleZip(w, req) + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) +} + +// Test ForModule handlers that have 0% coverage +func TestForModuleHandlersZeroCoverage(t *testing.T) { + handler := &ModuleHandler{ + reposDir: "/tmp/repos", + serverHost: "git.example.com", + } + + t.Run("handleVersionInfoForModule", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.info", nil) + w := httptest.NewRecorder() + + handler.handleVersionInfoForModule(w, req, "mymodule", "v1.0.0") + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("handleModFileForModule", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.mod", nil) + w := httptest.NewRecorder() + + handler.handleModFileForModule(w, req, "mymodule", "v1.0.0") + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("handleModuleZipForModule", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.zip", nil) + w := httptest.NewRecorder() + + handler.handleModuleZipForModule(w, req, "mymodule", "v1.0.0") + + // Should get 500 (repo doesn't exist) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("handleGoImportForModule_without_go_get", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule", nil) + w := httptest.NewRecorder() + + handler.handleGoImportForModule(w, req, "mymodule") + + if w.Code != http.StatusNotFound { + t.Error("expected 404 when go-get parameter is missing") + } + }) + + t.Run("handleGoImportForModule_with_go_get", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule?go-get=1", nil) + w := httptest.NewRecorder() + + handler.handleGoImportForModule(w, req, "mymodule") + + if w.Code != http.StatusOK { + t.Errorf("expected 200 with go-get=1, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "go-import") { + t.Error("response should contain go-import meta tag") + } + }) +} + +// Test createGenericVersionHandler which has low coverage (8.3%) +func TestCreateGenericVersionHandler(t *testing.T) { + handler := &ModuleHandler{ + reposDir: "/tmp/repos", + serverHost: "git.example.com", + } + + genericHandler := handler.createGenericVersionHandler("mymodule") + + t.Run("info_file", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.info", nil) + w := httptest.NewRecorder() + + genericHandler(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("mod_file", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.mod", nil) + w := httptest.NewRecorder() + + genericHandler(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("zip_file", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/v1.0.0.zip", nil) + w := httptest.NewRecorder() + + genericHandler(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for nonexistent repo, got %d", w.Code) + } + }) + + t.Run("unknown_endpoint", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/unknown", nil) + w := httptest.NewRecorder() + + genericHandler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for unknown endpoint, got %d", w.Code) + } + }) +} + +// Test middleware coverage +func TestModuleMiddleware(t *testing.T) { + moduleHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("module-response")) + }) + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("next-response")) + }) + + middleware := ModuleMiddleware(moduleHandler, nextHandler) + + t.Run("module_request_go_get", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule?go-get=1", nil) + w := httptest.NewRecorder() + + middleware.ServeHTTP(w, req) + + if w.Body.String() != "module-response" { + t.Error("should route to module handler for go-get requests") + } + }) + + t.Run("module_request_version_list", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule/@v/list", nil) + w := httptest.NewRecorder() + + middleware.ServeHTTP(w, req) + + if w.Body.String() != "module-response" { + t.Error("should route to module handler for version list requests") + } + }) + + t.Run("non_module_request", func(t *testing.T) { + req := httptest.NewRequest("GET", "/mymodule.git/info/refs", nil) + w := httptest.NewRecorder() + + middleware.ServeHTTP(w, req) + + if w.Body.String() != "next-response" { + t.Error("should route to next handler for git requests") + } + }) +} diff --git a/internal/modules/middleware.go b/internal/modules/middleware.go new file mode 100644 index 0000000..6159573 --- /dev/null +++ b/internal/modules/middleware.go @@ -0,0 +1,103 @@ +package modules + +import ( + "net/http" + "strings" +) + +// ModuleMiddleware handles Go module requests directly or passes to next handler +func ModuleMiddleware(moduleHandler http.Handler, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isModuleRequest(r) { + moduleHandler.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// isModuleRequest checks if the request is for Go module endpoints +func isModuleRequest(r *http.Request) bool { + // Check for go-import meta tag requests + if r.URL.Query().Get("go-get") == "1" { + return true + } + + // Check for Go module proxy endpoints + return isModuleProxyPath(r.URL.Path) +} + +// isModuleProxyPath checks if the path matches Go module proxy endpoints +func isModuleProxyPath(path string) bool { + // Module proxy endpoints: + // /{module}/@v/list + // /{module}/@v/{version}.info + // /{module}/@v/{version}.mod + // /{module}/@v/{version}.zip + // /{module}/@latest + + // Check for @latest endpoint + if strings.HasSuffix(path, "/@latest") { + return true + } + + // Check for @v/ endpoints + if !strings.Contains(path, "/@v/") { + return false + } + + // Valid @v/ endpoint suffixes + suffixes := []string{"/list", ".info", ".mod", ".zip"} + for _, suffix := range suffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } + + return false +} + +// ExtractModulePath extracts the module path from a request URL +func ExtractModulePath(path string) string { + // Remove leading slash + path = strings.TrimPrefix(path, "/") + + // For proxy endpoints, extract module path before /@v/ or /@latest + if idx := strings.Index(path, "/@v/"); idx != -1 { + return path[:idx] + } + + if strings.HasSuffix(path, "/@latest") { + return strings.TrimSuffix(path, "/@latest") + } + + // Remove trailing slash for clean module paths + return strings.TrimSuffix(path, "/") +} + +// ExtractVersion extracts the version from a module proxy request +func ExtractVersion(path string) string { + if !strings.Contains(path, "/@v/") { + return "" + } + + // Extract version from /@v/{version}.{suffix} + parts := strings.Split(path, "/@v/") + if len(parts) != 2 { + return "" + } + + versionPart := parts[1] + + // Special case for /list endpoint + if versionPart == "list" { + return "" + } + + // Remove suffix (.info, .mod, .zip) + if idx := strings.LastIndex(versionPart, "."); idx != -1 { + return versionPart[:idx] + } + + return versionPart +} diff --git a/internal/modules/middleware_test.go b/internal/modules/middleware_test.go new file mode 100644 index 0000000..e1868a7 --- /dev/null +++ b/internal/modules/middleware_test.go @@ -0,0 +1,216 @@ +package modules + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsModuleRequest(t *testing.T) { + tests := []struct { + name string + url string + want bool + method string + }{ + { + name: "go-import request", + url: "/example.com/mymodule?go-get=1", + want: true, + }, + { + name: "version list endpoint", + url: "/example.com/mymodule/@v/list", + want: true, + }, + { + name: "latest version endpoint", + url: "/example.com/mymodule/@latest", + want: true, + }, + { + name: "version info endpoint", + url: "/example.com/mymodule/@v/v1.0.0.info", + want: true, + }, + { + name: "mod file endpoint", + url: "/example.com/mymodule/@v/v1.0.0.mod", + want: true, + }, + { + name: "zip file endpoint", + url: "/example.com/mymodule/@v/v1.0.0.zip", + want: true, + }, + { + name: "regular git request", + url: "/example.com/mymodule.git/info/refs", + want: false, + }, + { + name: "regular path", + url: "/example.com/mymodule/README.md", + want: false, + }, + { + name: "go-get with different value", + url: "/example.com/mymodule?go-get=0", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + got := isModuleRequest(req) + if got != tt.want { + t.Errorf("isModuleRequest() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsModuleProxyPath(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + { + name: "version list", + path: "/example.com/mymodule/@v/list", + want: true, + }, + { + name: "latest version", + path: "/example.com/mymodule/@latest", + want: true, + }, + { + name: "version info", + path: "/example.com/mymodule/@v/v1.0.0.info", + want: true, + }, + { + name: "mod file", + path: "/example.com/mymodule/@v/v1.0.0.mod", + want: true, + }, + { + name: "zip file", + path: "/example.com/mymodule/@v/v1.0.0.zip", + want: true, + }, + { + name: "invalid @v path", + path: "/example.com/mymodule/@v/invalid", + want: false, + }, + { + name: "regular path", + path: "/example.com/mymodule/file.go", + want: false, + }, + { + name: "git path", + path: "/example.com/mymodule.git/info/refs", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isModuleProxyPath(tt.path) + if got != tt.want { + t.Errorf("isModuleProxyPath() = %v, want %v for path %s", got, tt.want, tt.path) + } + }) + } +} + +func TestExtractModulePath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "version list path", + path: "/example.com/mymodule/@v/list", + want: "example.com/mymodule", + }, + { + name: "latest version path", + path: "/example.com/mymodule/@latest", + want: "example.com/mymodule", + }, + { + name: "version info path", + path: "/example.com/mymodule/@v/v1.0.0.info", + want: "example.com/mymodule", + }, + { + name: "simple module path", + path: "/example.com/mymodule", + want: "example.com/mymodule", + }, + { + name: "nested module path", + path: "/github.com/user/repo/submodule/@v/list", + want: "github.com/user/repo/submodule", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractModulePath(tt.path) + if got != tt.want { + t.Errorf("ExtractModulePath() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractVersion(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "version info", + path: "/example.com/mymodule/@v/v1.0.0.info", + want: "v1.0.0", + }, + { + name: "mod file", + path: "/example.com/mymodule/@v/v1.2.3.mod", + want: "v1.2.3", + }, + { + name: "zip file", + path: "/example.com/mymodule/@v/v2.0.0-beta.1.zip", + want: "v2.0.0-beta.1", + }, + { + name: "no version in path", + path: "/example.com/mymodule/@v/list", + want: "", + }, + { + name: "latest endpoint", + path: "/example.com/mymodule/@latest", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractVersion(tt.path) + if got != tt.want { + t.Errorf("ExtractVersion() = %v, want %v", got, tt.want) + } + }) + } +} |