aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax Resnick <max@ofmax.li>2025-08-01 22:10:20 -0700
committerMax Resnick <max@ofmax.li>2025-09-06 21:47:16 -0700
commitdf0a52f53739a1bc05620f3f21533922488c0491 (patch)
treeb5c201bbf214a93d8ed934492bd888e8fc8a1388
parent462bfd8fc9707a5eae3233e69f5b8a522972ed74 (diff)
downloadgo-git-server-1.0.4-latest.df0a52f.11.tar.gz
feat: go-module support1.0.4-latest.df0a52f.11
-rw-r--r--CLAUDE.md35
-rw-r--r--cmd/main.go21
-rw-r--r--gitserver.yaml15
-rw-r--r--go.mod22
-rw-r--r--go.sum26
-rw-r--r--internal/admin/model.go2
-rw-r--r--internal/git/handler_test.go1
-rw-r--r--internal/modules/git_ops.go270
-rw-r--r--internal/modules/git_ops_test.go277
-rw-r--r--internal/modules/handler.go473
-rw-r--r--internal/modules/handler_test.go550
-rw-r--r--internal/modules/middleware.go103
-rw-r--r--internal/modules/middleware_test.go216
-rw-r--r--justfile4
14 files changed, 1995 insertions, 20 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index f92eb34..141a867 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/go.mod b/go.mod
index 20b8d9c..d67c622 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index c954d75..dfdc18f 100644
--- a/go.sum
+++ b/go.sum
@@ -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)
+ }
+ })
+ }
+}
diff --git a/justfile b/justfile
index 276baa0..430818f 100644
--- a/justfile
+++ b/justfile
@@ -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