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 | |
| parent | 462bfd8fc9707a5eae3233e69f5b8a522972ed74 (diff) | |
| download | go-git-server-df0a52f53739a1bc05620f3f21533922488c0491.tar.gz | |
feat: go-module support1.0.4-latest.df0a52f.11
| -rw-r--r-- | CLAUDE.md | 35 | ||||
| -rw-r--r-- | cmd/main.go | 21 | ||||
| -rw-r--r-- | gitserver.yaml | 15 | ||||
| -rw-r--r-- | go.mod | 22 | ||||
| -rw-r--r-- | go.sum | 26 | ||||
| -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 | ||||
| -rw-r--r-- | justfile | 4 |
14 files changed, 1995 insertions, 20 deletions
@@ -13,6 +13,7 @@ This is `go-git-server`, an experimental Git HTTP server in Go that provides aut - `internal/git/handler.go` - Git HTTP backend CGI wrapper - `internal/authz/` - Authentication/authorization middleware using Casbin - `internal/admin/` - Administrative services and configuration management + - `internal/modules/` - Go module proxy endpoints and go-import metadata - **Configuration**: Uses Casbin for RBAC with auth model (`auth_model.ini`) and policy files - **Authentication**: Token-based system with bcrypt hashing stored in CSV format - **Authorization**: Role-based access control with roles like admin, maintainers, bots @@ -63,10 +64,10 @@ just clean ### Token Management ```bash # Generate new authentication token -TMPDIR=/tmp/go-build go run cmd/tokentool/main.go -generate -name <username> +TMPDIR=$PWD/testdata go run cmd/tokentool/main.go -generate -name <username> # List existing tokens -TMPDIR=/tmp/go-build go run cmd/tokentool/main.go -list +TMPDIR=$PWD/testdata go run cmd/tokentool/main.go -list # Generate token directly from main binary ./main -g @@ -80,7 +81,8 @@ TMPDIR=/tmp/go-build go run cmd/tokentool/main.go -list - **Linting**: Uses golangci-lint - **Build System**: Uses `just` (justfile) for task automation - **Container**: Designed for Kubernetes deployment with minimal dependencies -- **TMPDIR**: Use `TMPDIR=/tmp/go-build` for `go run` commands if /tmp filesystem doesn't allow executables +- **TMPDIR**: **CRITICAL** - /tmp filesystem doesn't allow executables. Always use `TMPDIR=$PWD/testdata` for `go run` commands +- **Go Module Support**: Implements basic Go module proxy protocol for `go get` compatibility ## Large File Support @@ -96,6 +98,33 @@ git config http.postBuffer 524288000 This setting allows git to handle large file pushes over HTTP without timing out. +## Go Module Support + +The server implements basic Go module proxy functionality to enable `go get` operations: + +### Usage +```bash +# Enable go module proxy (make repositories discoverable) +go get yourdomain.com/repo + +# The server automatically serves: +# - go-import meta tags for module discovery +# - Module proxy endpoints (@v/list, @latest, .info, .mod, .zip) +# - Version metadata from git tags +``` + +### Features +- **Module Discovery**: Serves go-import HTML meta tags +- **Version Listing**: Lists available versions from git tags +- **Pseudo-versions**: Generates pseudo-versions for untagged commits +- **Module Archives**: Creates zip files from git repository content +- **go.mod Serving**: Extracts go.mod files from specific versions + +### Limitations +- Basic implementation focused on compatibility, not full Athens-style features +- No caching, authentication bypass, or advanced proxy features +- Requires properly tagged semantic versions (v1.0.0, v1.2.3, etc.) + ## Configuration Files - `gitserver.yaml` - Server configuration and repository definitions diff --git a/cmd/main.go b/cmd/main.go index bda8bf6..0cd9eec 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -11,6 +11,7 @@ import ( "git.ofmax.li/go-git-server/internal/admin" "git.ofmax.li/go-git-server/internal/authz" "git.ofmax.li/go-git-server/internal/git" + "git.ofmax.li/go-git-server/internal/modules" ) var ( @@ -19,6 +20,7 @@ var ( backendCommand string loggingLevel string addr string + serverHost string modelPath string policyPath string serverConfigPath string @@ -87,16 +89,26 @@ func main() { identities.Register(id, name) } + // Create git and module handlers + gitHandler := git.GitHttpBackendHandler(reposDir, backendCommand) + moduleHandler := modules.NewModuleHandler(reposDir, serverHost, adminSvc.Conf) + router := http.NewServeMux() // TODO we don't want to use a global // de-reference args - router.Handle("/mgmt/", admin.Hooks(adminSvc, git.GitHttpBackendHandler(reposDir, backendCommand))) - router.Handle("/", git.GitHttpBackendHandler(reposDir, backendCommand)) - mux := authz.Authentication(tokens, identities, authz.Authorization(adminSvc, router)) + router.Handle("/mgmt/", admin.Hooks(adminSvc, gitHandler)) + + // Apply authentication to git operations + authenticatedGitHandler := authz.Authentication(tokens, identities, authz.Authorization(adminSvc, gitHandler)) + + // Apply module middleware (handles module requests directly, passes git requests to auth) + moduleMiddleware := modules.ModuleMiddleware(moduleHandler, authenticatedGitHandler) + + router.Handle("/", moduleMiddleware) server := &http.Server{ Addr: addr, ReadHeaderTimeout: 5 * time.Second, - Handler: mux, + Handler: router, } slog.Error("error while running exiting", slog.Any("error", server.ListenAndServe())) os.Exit(1) @@ -110,6 +122,7 @@ func init() { flag.StringVar(&backendCommand, "c", "git http-backend", "CGI binary to execute") flag.StringVar(&loggingLevel, "e", logLevel, "set log level") flag.StringVar(&addr, "l", ":8080", "Address/port to listen on") + flag.StringVar(&serverHost, "h", "localhost:8080", "Public hostname for Go module discovery") flag.StringVar(&modelPath, "m", "./auth_model.ini", "casbin authentication model") flag.StringVar(&policyPath, "p", "./policy.csv", "casbin auth policy") flag.StringVar(&tokenFilePath, "t", "./tokens.csv", "casbin auth policy") diff --git a/gitserver.yaml b/gitserver.yaml index e3485b7..3d7e947 100644 --- a/gitserver.yaml +++ b/gitserver.yaml @@ -4,6 +4,21 @@ version: "v1alpha1" repos: - name: mgmt public: false + go_module: false # Management repo, not a Go module + permissions: + - role: admin + mode: 1 +- name: mylib + public: true + go_module: true # This repo will be exposed as a Go module + permissions: + - role: admin + mode: 1 + - role: maintainers + mode: 1 +- name: website + public: true + go_module: false # Website repo, not a Go module permissions: - role: admin mode: 1 @@ -5,35 +5,37 @@ go 1.24 require ( github.com/casbin/casbin/v2 v2.104.0 github.com/go-git/go-billy/v5 v5.6.2 - github.com/go-git/go-git/v5 v5.14.0 + github.com/go-git/go-git/v5 v5.16.2 github.com/stretchr/testify v1.10.0 go.starlark.net v0.0.0-20230525235612-a134d8f9ddca - golang.org/x/crypto v0.36.0 + golang.org/x/crypto v0.41.0 gopkg.in/ini.v1 v1.67.0 sigs.k8s.io/yaml v1.4.0 ) require ( - dario.cat/mergo v1.0.1 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect github.com/casbin/govaluate v1.3.0 // indirect - github.com/cloudflare/circl v1.6.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -1,12 +1,18 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -25,6 +31,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,6 +46,8 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= @@ -46,6 +56,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= +github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= @@ -73,6 +85,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= +github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -84,6 +98,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY= +github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -93,6 +109,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= @@ -109,6 +127,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= @@ -122,6 +142,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -136,14 +158,18 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 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) + } + }) + } +} @@ -91,8 +91,8 @@ docker-push registry=DEFAULT_REGISTRY: docker push {{registry}}/go-git-server:$new_ver run repo=(TEMPDIR): - git clone --bare mgmt {{repo}}/mgmt.git - go run cmd/main.go -a -r {{repo}} + cp tests/test_gitserver.yaml {{repo}}/gitserver.yaml + go run cmd/main.go -s {{repo}}/gitserver.yaml test: golangci-lint run |