git_lfs.go

  1package web
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"io/fs"
  9	"net/http"
 10	"path"
 11	"path/filepath"
 12	"strconv"
 13
 14	"github.com/charmbracelet/log"
 15	"github.com/charmbracelet/soft-serve/server/backend"
 16	"github.com/charmbracelet/soft-serve/server/config"
 17	"github.com/charmbracelet/soft-serve/server/db"
 18	"github.com/charmbracelet/soft-serve/server/lfs"
 19	"github.com/charmbracelet/soft-serve/server/storage"
 20	"github.com/charmbracelet/soft-serve/server/store"
 21	"goji.io/pat"
 22)
 23
 24// serviceLfsBatch handles a Git LFS batch requests.
 25// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
 26// TODO: support refname & authentication
 27// POST: /<repo>.git/info/lfs/objects/batch
 28func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 29	if r.Header.Get("Content-Type") != lfs.MediaType {
 30		renderNotAcceptable(w)
 31		return
 32	}
 33
 34	var batchRequest lfs.BatchRequest
 35	ctx := r.Context()
 36	logger := log.FromContext(ctx).WithPrefix("http.lfs")
 37
 38	defer r.Body.Close() // nolint: errcheck
 39	if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
 40		logger.Errorf("error decoding json: %s", err)
 41		return
 42	}
 43
 44	// We only accept basic transfers for now
 45	// Default to basic if no transfer is specified
 46	if len(batchRequest.Transfers) > 0 {
 47		var isBasic bool
 48		for _, t := range batchRequest.Transfers {
 49			if t == lfs.TransferBasic {
 50				isBasic = true
 51				break
 52			}
 53		}
 54
 55		if !isBasic {
 56			renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
 57				Message: "unsupported transfer",
 58			})
 59			return
 60		}
 61	}
 62
 63	be := backend.FromContext(ctx)
 64	name := pat.Param(r, "repo")
 65	repo, err := be.Repository(ctx, name)
 66	if err != nil {
 67		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
 68			Message: "repository not found",
 69		})
 70		return
 71	}
 72
 73	cfg := config.FromContext(ctx)
 74	dbx := db.FromContext(ctx)
 75	datastore := store.FromContext(ctx)
 76	// TODO: support S3 storage
 77	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
 78
 79	baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
 80
 81	var batchResponse lfs.BatchResponse
 82	batchResponse.Transfer = lfs.TransferBasic
 83	batchResponse.HashAlgo = lfs.HashAlgorithmSHA256
 84
 85	objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))
 86	// XXX: We don't support objects TTL for now, probably implement that with
 87	// S3 using object "expires_at" & "expires_in"
 88	switch batchRequest.Operation {
 89	case lfs.OperationDownload:
 90		for _, o := range batchRequest.Objects {
 91			stat, err := strg.Stat(path.Join("objects", o.RelativePath()))
 92			if err != nil && !errors.Is(err, fs.ErrNotExist) {
 93				logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err)
 94				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
 95					Message: "internal server error",
 96				})
 97				return
 98			}
 99
100			obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid)
101			if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
102				logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err)
103				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
104					Message: "internal server error",
105				})
106				return
107			}
108
109			if stat == nil {
110				objects = append(objects, &lfs.ObjectResponse{
111					Pointer: o,
112					Error: &lfs.ObjectError{
113						Code:    http.StatusNotFound,
114						Message: "object not found",
115					},
116				})
117			} else if stat.Size() != o.Size {
118				objects = append(objects, &lfs.ObjectResponse{
119					Pointer: o,
120					Error: &lfs.ObjectError{
121						Code:    http.StatusUnprocessableEntity,
122						Message: "size mismatch",
123					},
124				})
125			} else if o.IsValid() {
126				objects = append(objects, &lfs.ObjectResponse{
127					Pointer: o,
128					Actions: map[string]*lfs.Link{
129						lfs.ActionDownload: {
130							Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
131						},
132					},
133				})
134
135				// If the object doesn't exist in the database, create it
136				if stat != nil && obj.ID == 0 {
137					if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, stat.Size()); err != nil {
138						logger.Error("error creating object in datastore", "oid", o.Oid, "repo", name, "err", err)
139						renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
140							Message: "internal server error",
141						})
142						return
143					}
144				}
145			} else {
146				objects = append(objects, &lfs.ObjectResponse{
147					Pointer: o,
148					Error: &lfs.ObjectError{
149						Code:    http.StatusUnprocessableEntity,
150						Message: "invalid object",
151					},
152				})
153			}
154		}
155	case lfs.OperationUpload:
156		// Object upload logic happens in the "basic" API route
157		for _, o := range batchRequest.Objects {
158			if !o.IsValid() {
159				objects = append(objects, &lfs.ObjectResponse{
160					Pointer: o,
161					Error: &lfs.ObjectError{
162						Code:    http.StatusUnprocessableEntity,
163						Message: "invalid object",
164					},
165				})
166			} else {
167				objects = append(objects, &lfs.ObjectResponse{
168					Pointer: o,
169					Actions: map[string]*lfs.Link{
170						lfs.ActionUpload: {
171							Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
172						},
173						// Verify uploaded objects
174						// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification
175						lfs.ActionVerify: {
176							Href: fmt.Sprintf("%s/verify", baseHref),
177						},
178					},
179				})
180			}
181		}
182	default:
183		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
184			Message: "unsupported operation",
185		})
186		return
187	}
188
189	batchResponse.Objects = objects
190	renderJSON(w, http.StatusOK, batchResponse)
191}
192
193// serviceLfsBasic implements Git LFS basic transfer API
194// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md
195func serviceLfsBasic(w http.ResponseWriter, r *http.Request) {
196	switch r.Method {
197	case http.MethodGet:
198		serviceLfsBasicDownload(w, r)
199	case http.MethodPut:
200		serviceLfsBasicUpload(w, r)
201	}
202}
203
204// GET: /<repo>.git/info/lfs/objects/basic/<oid>
205func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
206	ctx := r.Context()
207	oid := pat.Param(r, "oid")
208	cfg := config.FromContext(ctx)
209	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
210	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
211
212	obj, err := strg.Open(path.Join("objects", oid))
213	if err != nil {
214		logger.Error("error opening object", "oid", oid, "err", err)
215		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
216			Message: "object not found",
217		})
218		return
219	}
220
221	stat, err := obj.Stat()
222	if err != nil {
223		logger.Error("error getting object stat", "oid", oid, "err", err)
224		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
225			Message: "internal server error",
226		})
227		return
228	}
229
230	defer obj.Close() // nolint: errcheck
231	if _, err := io.Copy(w, obj); err != nil {
232		logger.Error("error copying object to response", "oid", oid, "err", err)
233		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
234			Message: "internal server error",
235		})
236		return
237	}
238
239	w.Header().Set("Content-Type", "application/octet-stream")
240	w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10))
241	renderStatus(http.StatusOK)(w, nil)
242}
243
244// PUT: /<repo>.git/info/lfs/objects/basic/<oid>
245func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
246	if r.Header.Get("Content-Type") != "application/octet-stream" {
247		renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{
248			Message: "invalid content type",
249		})
250		return
251	}
252
253	ctx := r.Context()
254	oid := pat.Param(r, "oid")
255	cfg := config.FromContext(ctx)
256	be := backend.FromContext(ctx)
257	dbx := db.FromContext(ctx)
258	datastore := store.FromContext(ctx)
259	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
260	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
261	name := pat.Param(r, "repo")
262
263	defer r.Body.Close() // nolint: errcheck
264	repo, err := be.Repository(ctx, name)
265	if err != nil {
266		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
267			Message: "repository not found",
268		})
269		return
270	}
271
272	// NOTE: Git LFS client will retry uploading the same object if there was a
273	// partial error, so we need to skip existing objects.
274	if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {
275		// Object exists, skip request
276		io.Copy(io.Discard, r.Body) // nolint: errcheck
277		renderStatus(http.StatusOK)(w, nil)
278		return
279	} else if !errors.Is(err, db.ErrRecordNotFound) {
280		logger.Error("error getting object", "oid", oid, "err", err)
281		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
282			Message: "internal server error",
283		})
284		return
285	}
286
287	if err := strg.Put(path.Join("objects", oid), r.Body); err != nil {
288		logger.Error("error writing object", "oid", oid, "err", err)
289		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
290			Message: "internal server error",
291		})
292		return
293	}
294
295	size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
296	if err != nil {
297		logger.Error("error parsing content length", "err", err)
298		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
299			Message: "invalid content length",
300		})
301		return
302	}
303
304	if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), oid, size); err != nil {
305		logger.Error("error creating object", "oid", oid, "err", err)
306		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
307			Message: "internal server error",
308		})
309		return
310	}
311
312	renderStatus(http.StatusOK)(w, nil)
313}
314
315// POST: /<repo>.git/info/lfs/objects/basic/verify
316func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
317	var pointer lfs.Pointer
318	ctx := r.Context()
319	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
320	be := backend.FromContext(ctx)
321	name := pat.Param(r, "repo")
322	repo, err := be.Repository(ctx, name)
323	if err != nil {
324		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
325			Message: "repository not found",
326		})
327		return
328	}
329
330	defer r.Body.Close() // nolint: errcheck
331	if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {
332		logger.Error("error decoding json", "err", err)
333		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
334			Message: "invalid json",
335		})
336		return
337	}
338
339	cfg := config.FromContext(ctx)
340	dbx := db.FromContext(ctx)
341	datastore := store.FromContext(ctx)
342	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
343	if stat, err := strg.Stat(path.Join("objects", pointer.Oid)); err == nil {
344		// Verify object is in the database.
345		if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid); err != nil {
346			if errors.Is(err, db.ErrRecordNotFound) {
347				// Create missing object.
348				if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, stat.Size()); err != nil {
349					logger.Error("error creating object", "oid", pointer.Oid, "err", err)
350					renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
351						Message: "internal server error",
352					})
353					return
354				}
355			} else {
356				logger.Error("error getting object", "oid", pointer.Oid, "err", err)
357				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
358					Message: "internal server error",
359				})
360				return
361			}
362		}
363
364		if pointer.IsValid() && stat.Size() == pointer.Size {
365			renderStatus(http.StatusOK)(w, nil)
366			return
367		}
368	} else if errors.Is(err, fs.ErrNotExist) {
369		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
370			Message: "object not found",
371		})
372		return
373	} else {
374		logger.Error("error getting object", "oid", pointer.Oid, "err", err)
375		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
376			Message: "internal server error",
377		})
378		return
379	}
380}
381
382// POST: /<repo>.git/info/lfs/objects/locks
383func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
384	if r.Header.Get("Content-Type") != lfs.MediaType {
385		renderNotAcceptable(w)
386		return
387	}
388
389	panic("not implemented")
390}
391
392// renderJSON renders a JSON response with the given status code and value. It
393// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).
394func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {
395	w.Header().Set("Content-Type", lfs.MediaType)
396	renderStatus(statusCode)(w, nil)
397	if err := json.NewEncoder(w).Encode(v); err != nil {
398		log.Error("error encoding json", "err", err)
399	}
400}
401
402func renderNotAcceptable(w http.ResponseWriter) {
403	renderStatus(http.StatusNotAcceptable)(w, nil)
404}
405
406func hdrLfs(w http.ResponseWriter) {
407	w.Header().Set("Content-Type", lfs.MediaType)
408	w.Header().Set("Accept", lfs.MediaType)
409}