From df0a52f53739a1bc05620f3f21533922488c0491 Mon Sep 17 00:00:00 2001 From: Max Resnick Date: Fri, 1 Aug 2025 22:10:20 -0700 Subject: feat: go-module support --- internal/admin/model.go | 2 + internal/git/handler_test.go | 1 - internal/modules/git_ops.go | 270 ++++++++++++++++++ internal/modules/git_ops_test.go | 277 ++++++++++++++++++ internal/modules/handler.go | 473 +++++++++++++++++++++++++++++++ internal/modules/handler_test.go | 550 ++++++++++++++++++++++++++++++++++++ internal/modules/middleware.go | 103 +++++++ internal/modules/middleware_test.go | 216 ++++++++++++++ 8 files changed, 1891 insertions(+), 1 deletion(-) create mode 100644 internal/modules/git_ops.go create mode 100644 internal/modules/git_ops_test.go create mode 100644 internal/modules/handler.go create mode 100644 internal/modules/handler_test.go create mode 100644 internal/modules/middleware.go create mode 100644 internal/modules/middleware_test.go (limited to 'internal') 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(` + + + + + + + go get %s + +`, + 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(` + + + + + + + go get %s + +`, + 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, `