diff options
Diffstat (limited to 'cmd/git-lfs-server')
-rw-r--r-- | cmd/git-lfs-server/main.go | 329 |
1 files changed, 329 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 @@ | |||
1 | package main | 1 | package main |
2 | |||
3 | import ( | ||
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 | |||
25 | type operation string | ||
26 | type transferAdapter string | ||
27 | type hashAlgo string | ||
28 | |||
29 | const ( | ||
30 | operationDownload operation = "download" | ||
31 | operationUpload operation = "upload" | ||
32 | transferAdapterBasic transferAdapter = "basic" | ||
33 | hashAlgoSHA256 hashAlgo = "sha256" | ||
34 | ) | ||
35 | |||
36 | const lfsMIME = "application/vnd.git-lfs+json" | ||
37 | |||
38 | type batchRef struct { | ||
39 | Name string `json:"name"` | ||
40 | } | ||
41 | |||
42 | type batchRequestObject struct { | ||
43 | OID string `json:"oid"` | ||
44 | Size uint64 `json:"size"` | ||
45 | } | ||
46 | |||
47 | type 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 | |||
55 | type RFC3339SecondsTime time.Time | ||
56 | |||
57 | func (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 | |||
65 | type SecondDuration time.Duration | ||
66 | |||
67 | func (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 | |||
73 | type 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 | |||
80 | type batchError struct { | ||
81 | Code int `json:"code"` | ||
82 | Message string `json:"message"` | ||
83 | } | ||
84 | |||
85 | type 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 | |||
93 | type batchResponse struct { | ||
94 | Transfer transferAdapter `json:"transfer,omitempty"` | ||
95 | Objects []batchResponseObject `json:"objects"` | ||
96 | HashAlgo hashAlgo `json:"hash_algo,omitempty"` | ||
97 | } | ||
98 | |||
99 | var re = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`) | ||
100 | |||
101 | type handler struct { | ||
102 | mc *minio.Client | ||
103 | bucket string | ||
104 | anonUser string | ||
105 | } | ||
106 | |||
107 | // Requires lowercase hash | ||
108 | func 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 | |||
120 | type lfsError struct { | ||
121 | Message string `json:"message"` | ||
122 | DocumentationURL string `json:"documentation_url,omitempty"` | ||
123 | RequestID string `json:"request_id,omitempty"` | ||
124 | } | ||
125 | |||
126 | func 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 | |||
132 | func 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 | |||
143 | func (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 | |||
186 | type parsedBatchObject struct { | ||
187 | firstByte string | ||
188 | secondByte string | ||
189 | fullHash string | ||
190 | size uint64 | ||
191 | } | ||
192 | |||
193 | func (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 | |||
281 | func 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 | |||
288 | func 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 | ||