aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/git-lfs-server/main.go329
-rw-r--r--go.mod21
-rw-r--r--go.sum51
3 files changed, 401 insertions, 0 deletions
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go
index 06ab7d0..c5b47a3 100644
--- a/cmd/git-lfs-server/main.go
+++ b/cmd/git-lfs-server/main.go
@@ -1 +1,330 @@
1package main 1package main
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/http/cgi"
10 "net/url"
11 "os"
12 "os/exec"
13 "path"
14 "regexp"
15 "slices"
16 "strconv"
17 "strings"
18 "time"
19 "unicode"
20
21 "github.com/minio/minio-go/v7"
22 "github.com/minio/minio-go/v7/pkg/credentials"
23)
24
25type operation string
26type transferAdapter string
27type hashAlgo string
28
29const (
30 operationDownload operation = "download"
31 operationUpload operation = "upload"
32 transferAdapterBasic transferAdapter = "basic"
33 hashAlgoSHA256 hashAlgo = "sha256"
34)
35
36const lfsMIME = "application/vnd.git-lfs+json"
37
38type batchRef struct {
39 Name string `json:"name"`
40}
41
42type batchRequestObject struct {
43 OID string `json:"oid"`
44 Size uint64 `json:"size"`
45}
46
47type batchRequest struct {
48 Operation operation `json:"operation"`
49 Transfers []transferAdapter `json:"transfers,omitempty"`
50 Ref *batchRef `json:"ref,omitempty"`
51 Objects []batchRequestObject `json:"objects"`
52 HashAlgo hashAlgo `json:"hash_algo,omitempty"`
53}
54
55type RFC3339SecondsTime time.Time
56
57func (t RFC3339SecondsTime) MarshalJSON() ([]byte, error) {
58 b := make([]byte, 0, len(time.RFC3339)+len(`""`))
59 b = append(b, '"')
60 b = time.Time(t).AppendFormat(b, time.RFC3339)
61 b = append(b, '"')
62 return b, nil
63}
64
65type SecondDuration time.Duration
66
67func (d SecondDuration) MarshalJSON() ([]byte, error) {
68 var b []byte
69 b = strconv.AppendInt(b, int64(time.Duration(d).Seconds()), 10)
70 return b, nil
71}
72
73type batchAction struct {
74 HRef *url.URL `json:"href"`
75 Header map[string]string `json:"header,omitempty"`
76 ExpiresIn *SecondDuration `json:"expires_in,omitempty"`
77 ExpiresAt *RFC3339SecondsTime `json:"expires_at,omitempty"`
78}
79
80type batchError struct {
81 Code int `json:"code"`
82 Message string `json:"message"`
83}
84
85type batchResponseObject struct {
86 OID string `json:"oid"`
87 Size uint64 `json:"size"`
88 Authenticated *bool `json:"authenticated"`
89 Actions map[operation]batchAction `json:"actions,omitempty"`
90 Error *batchError `json:"error,omitempty"`
91}
92
93type batchResponse struct {
94 Transfer transferAdapter `json:"transfer,omitempty"`
95 Objects []batchResponseObject `json:"objects"`
96 HashAlgo hashAlgo `json:"hash_algo,omitempty"`
97}
98
99var re = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`)
100
101type handler struct {
102 mc *minio.Client
103 bucket string
104 anonUser string
105}
106
107// Requires lowercase hash
108func isValidSHA256Hash(hash string) bool {
109 if len(hash) != 64 {
110 return false
111 }
112 for _, c := range hash {
113 if !unicode.Is(unicode.ASCII_Hex_Digit, c) {
114 return false
115 }
116 }
117 return true
118}
119
120type lfsError struct {
121 Message string `json:"message"`
122 DocumentationURL string `json:"documentation_url,omitempty"`
123 RequestID string `json:"request_id,omitempty"`
124}
125
126func makeRespError(w http.ResponseWriter, message string, code int) {
127 w.Header().Set("Content-Type", lfsMIME)
128 w.WriteHeader(code)
129 json.NewEncoder(w).Encode(lfsError{Message: message})
130}
131
132func makeObjError(obj parsedBatchObject, message string, code int) batchResponseObject {
133 return batchResponseObject{
134 OID: obj.fullHash,
135 Size: obj.size,
136 Error: &batchError{
137 Message: message,
138 Code: code,
139 },
140 }
141}
142
143func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject {
144 fullPath := path.Join(repo, obj.firstByte, obj.secondByte, obj.fullHash)
145 expiresIn := time.Hour * 24
146 expiresInSeconds := SecondDuration(expiresIn)
147
148 info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true})
149 if err != nil {
150 var resp minio.ErrorResponse
151 if errors.As(err, &resp) && resp.StatusCode == http.StatusNotFound {
152 return makeObjError(obj, "Object does not exist", http.StatusNotFound)
153 }
154 // TODO: consider not making this an object-specific, but rather a
155 // generic error such that the entire Batch API request fails.
156 return makeObjError(obj, "Failed to query object information", http.StatusInternalServerError)
157 }
158 if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash {
159 return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity)
160 }
161 if uint64(info.Size) != obj.size {
162 return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity)
163 }
164
165 presigned, err := h.mc.PresignedGetObject(ctx, h.bucket, fullPath, expiresIn, url.Values{})
166 if err != nil {
167 // TODO: consider not making this an object-specific, but rather a
168 // generic error such that the entire Batch API request fails.
169 return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError)
170 }
171
172 authenticated := true
173 return batchResponseObject{
174 OID: obj.fullHash,
175 Size: obj.size,
176 Authenticated: &authenticated,
177 Actions: map[operation]batchAction{
178 operationDownload: {
179 HRef: presigned,
180 ExpiresIn: &expiresInSeconds,
181 },
182 },
183 }
184}
185
186type parsedBatchObject struct {
187 firstByte string
188 secondByte string
189 fullHash string
190 size uint64
191}
192
193func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
194 submatches := re.FindStringSubmatch(r.URL.Path)
195 if len(submatches) != 1 {
196 makeRespError(w, "Not found", http.StatusNotFound)
197 return
198 }
199 repo := strings.TrimPrefix("/", path.Clean(submatches[0]))
200
201 if !slices.Contains(r.Header.Values("Accept"), lfsMIME) {
202 makeRespError(w, "Expected "+lfsMIME+" in list of acceptable response media types", http.StatusNotAcceptable)
203 return
204 }
205 if r.Header.Get("Content-Type") != lfsMIME {
206 makeRespError(w, "Expected request Content-Type to be "+lfsMIME, http.StatusUnsupportedMediaType)
207 return
208 }
209
210 var body batchRequest
211 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
212 makeRespError(w, "Failed to parse request body as JSON", http.StatusBadRequest)
213 return
214 }
215
216 if body.HashAlgo != hashAlgoSHA256 {
217 makeRespError(w, "Unsupported hash algorithm specified", http.StatusConflict)
218 return
219 }
220
221 // TODO: handle authentication
222 // right now, we're just trying to make everything publically accessible
223 if body.Operation == operationUpload {
224 makeRespError(w, "Upload operations are currently not supported", http.StatusForbidden)
225 return
226 }
227
228 if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) {
229 makeRespError(w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict)
230 return
231 }
232
233 gitoliteArgs := []string{"access", "-q", repo, h.anonUser, "R"}
234 if body.Ref != nil && body.Ref.Name != "" {
235 gitoliteArgs = append(gitoliteArgs, body.Ref.Name)
236 }
237 cmd := exec.Command("gitolite", gitoliteArgs...)
238 err := cmd.Run()
239 permGranted := err == nil
240 var exitErr *exec.ExitError
241 if err != nil && !errors.As(err, &exitErr) {
242 makeRespError(w, "Failed to query access information", http.StatusInternalServerError)
243 return
244 }
245 if !permGranted {
246 // TODO: when handling authorization, make sure to return 403 Forbidden
247 // here when the user *does* have read permissions, but is not allowed
248 // to write when requesting an upload operation.
249 makeRespError(w, "Repository not found", http.StatusNotFound)
250 return
251 }
252
253 var objects []parsedBatchObject
254 for _, obj := range body.Objects {
255 oid := strings.ToLower(obj.OID)
256 if !isValidSHA256Hash(oid) {
257 makeRespError(w, "Invalid hash format in object ID", http.StatusBadRequest)
258 return
259 }
260 objects = append(objects, parsedBatchObject{
261 firstByte: oid[:2],
262 secondByte: oid[2:4],
263 fullHash: oid,
264 size: obj.Size,
265 })
266 }
267
268 resp := batchResponse{
269 Transfer: transferAdapterBasic,
270 HashAlgo: hashAlgoSHA256,
271 }
272 for _, obj := range objects {
273 resp.Objects = append(resp.Objects, h.handleDownloadObject(r.Context(), repo, obj))
274 }
275
276 w.Header().Set("Content-Type", lfsMIME)
277 w.WriteHeader(http.StatusOK)
278 json.NewEncoder(w).Encode(resp)
279}
280
281func die(msg string, args ...any) {
282 fmt.Fprint(os.Stderr, "Error: ")
283 fmt.Fprintf(os.Stderr, msg, args...)
284 fmt.Fprint(os.Stderr, "\n")
285 os.Exit(1)
286}
287
288func main() {
289 endpoint := os.Getenv("S3_ENDPOINT")
290 accessKeyID := os.Getenv("S3_ACCESS_KEY_ID")
291 secretAccessKey := os.Getenv("S3_SECRET_ACCESS_KEY")
292 bucket := os.Getenv("S3_BUCKET")
293 anonUser := os.Getenv("ANON_USER")
294
295 if endpoint == "" {
296 die("Expected environment variable S3_ENDPOINT to be set")
297 }
298 if accessKeyID == "" {
299 die("Expected environment variable S3_ACCESS_KEY_ID to be set")
300 }
301 if secretAccessKey == "" {
302 die("Expected environment variable S3_SECRET_ACCESS_KEY to be set")
303 }
304 if bucket == "" {
305 die("Expected environment variable S3_BUCKET to be set")
306 }
307 if anonUser == "" {
308 die("Expected environment variable ANON_USER to be set")
309 }
310
311 mc, err := minio.New(endpoint, &minio.Options{
312 Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
313 Secure: true,
314 })
315 if err != nil {
316 die("Failed to create S3 client")
317 }
318
319 if err = cgi.Serve(&handler{mc, bucket, anonUser}); err != nil {
320 die("Failed to serve CGI: %s", err)
321 }
322}
323
324// Directory stucture:
325// - lfs/
326// - locks/
327// - objects/
328// - <1st OID byte>
329// - <2nd OID byte>
330// - <OID hash> <- this is the object
diff --git a/go.mod b/go.mod
index e5c27f4..751d2c6 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,24 @@
1module git.fautchen.eu/gitolfs3 1module git.fautchen.eu/gitolfs3
2 2
3go 1.21.5 3go 1.21.5
4
5require github.com/minio/minio-go/v7 v7.0.66
6
7require (
8 github.com/dustin/go-humanize v1.0.1 // indirect
9 github.com/google/uuid v1.5.0 // indirect
10 github.com/json-iterator/go v1.1.12 // indirect
11 github.com/klauspost/compress v1.17.4 // indirect
12 github.com/klauspost/cpuid/v2 v2.2.6 // indirect
13 github.com/minio/md5-simd v1.1.2 // indirect
14 github.com/minio/sha256-simd v1.0.1 // indirect
15 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
16 github.com/modern-go/reflect2 v1.0.2 // indirect
17 github.com/rs/xid v1.5.0 // indirect
18 github.com/sirupsen/logrus v1.9.3 // indirect
19 golang.org/x/crypto v0.16.0 // indirect
20 golang.org/x/net v0.19.0 // indirect
21 golang.org/x/sys v0.15.0 // indirect
22 golang.org/x/text v0.14.0 // indirect
23 gopkg.in/ini.v1 v1.67.0 // indirect
24)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..839d4c1
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,51 @@
1github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
5github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
6github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
7github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
8github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
9github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
10github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
11github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
12github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
13github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
14github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
15github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
16github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
17github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
18github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
19github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
20github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
21github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
22github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
23github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
24github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
25github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
26github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
27github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
28github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
29github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
30github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
31github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
32github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
33github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
34github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
35github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
36github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
37golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
38golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
39golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
40golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
41golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
42golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
43golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
44golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
45golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
46golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
47gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
48gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
49gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
50gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
51gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=