git_lfs_locks.go
13186 bytes
1package web
2
3import (
4 "encoding/json"
5 "errors"
6 "net/http"
7 "net/url"
8 "strconv"
9 "strings"
10
11 "charm.land/log/v2"
12 "github.com/charmbracelet/soft-serve/pkg/access"
13 "github.com/charmbracelet/soft-serve/pkg/db"
14 "github.com/charmbracelet/soft-serve/pkg/db/models"
15 "github.com/charmbracelet/soft-serve/pkg/lfs"
16 "github.com/charmbracelet/soft-serve/pkg/proto"
17 "github.com/charmbracelet/soft-serve/pkg/store"
18 "github.com/gorilla/mux"
19)
20
21func serviceLfsLocks(w http.ResponseWriter, r *http.Request) {
22 switch r.Method {
23 case http.MethodGet:
24 serviceLfsLocksGet(w, r)
25 case http.MethodPost:
26 serviceLfsLocksCreate(w, r)
27 default:
28 renderMethodNotAllowed(w, r)
29 }
30}
31
32// POST: /<repo>.git/info/lfs/objects/locks
33func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
34 if !isLfs(r) {
35 renderNotAcceptable(w)
36 return
37 }
38
39 ctx := r.Context()
40 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
41
42 // Checked in withAccess too. Repeated here so a routing or middleware
43 // mistake cannot turn into an unauthorized lock.
44 if access.FromContext(ctx) < access.ReadWriteAccess {
45 askCredentials(w, r)
46 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
47 Message: "write access required",
48 })
49 return
50 }
51
52 var req lfs.LockCreateRequest
53 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
54 logger.Error("error decoding json", "err", err)
55 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
56 Message: "invalid request: " + err.Error(),
57 })
58 return
59 }
60
61 repo, ok := lfsRepository(w, r)
62 if !ok {
63 return
64 }
65
66 user := proto.UserFromContext(ctx)
67 if user == nil {
68 logger.Error("error getting user from context")
69 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
70 Message: "user not found",
71 })
72 return
73 }
74
75 dbx := db.FromContext(ctx)
76 datastore := store.FromContext(ctx)
77 if err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil {
78 err = db.WrapError(err)
79 if errors.Is(err, db.ErrDuplicateKey) {
80 errResp := lfs.LockResponse{
81 ErrorResponse: lfs.ErrorResponse{
82 Message: "lock already exists",
83 },
84 }
85 lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
86 if err == nil {
87 errResp.Lock = lfs.Lock{
88 ID: strconv.FormatInt(lock.ID, 10),
89 Path: lock.Path,
90 LockedAt: lock.CreatedAt,
91 }
92 lockOwner := lfs.Owner{
93 Name: user.Username(),
94 }
95 if lock.UserID != user.ID() {
96 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
97 if err != nil {
98 logger.Error("error getting lock owner", "err", err)
99 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
100 Message: "internal server error",
101 })
102 return
103 }
104 lockOwner.Name = owner.Username
105 }
106 errResp.Lock.Owner = lockOwner
107 }
108 renderJSON(w, http.StatusConflict, errResp)
109 return
110 }
111 logger.Error("error creating lock", "err", err)
112 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
113 Message: "internal server error",
114 })
115 return
116 }
117
118 lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
119 if err != nil {
120 logger.Error("error getting lock", "err", err)
121 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
122 Message: "internal server error",
123 })
124 return
125 }
126
127 renderJSON(w, http.StatusCreated, lfs.LockResponse{
128 Lock: lfs.Lock{
129 ID: strconv.FormatInt(lock.ID, 10),
130 Path: lock.Path,
131 LockedAt: lock.CreatedAt,
132 Owner: lfs.Owner{
133 Name: user.Username(),
134 },
135 },
136 })
137}
138
139// GET: /<repo>.git/info/lfs/objects/locks
140func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
141 accept := r.Header.Get("Accept")
142 if !strings.HasPrefix(accept, lfs.MediaType) {
143 renderNotAcceptable(w)
144 return
145 }
146
147 parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) {
148 path = values.Get("path")
149 idStr := values.Get("id")
150 if idStr != "" {
151 id, _ = strconv.ParseInt(idStr, 10, 64)
152 }
153 cursorStr := values.Get("cursor")
154 if cursorStr != "" {
155 cursor, _ = strconv.Atoi(cursorStr)
156 }
157 limitStr := values.Get("limit")
158 if limitStr != "" {
159 limit, _ = strconv.Atoi(limitStr)
160 }
161 refspec = values.Get("refspec")
162 return
163 }
164
165 ctx := r.Context()
166 // TODO: respect refspec
167 path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())
168 if limit > 100 {
169 limit = 100
170 } else if limit <= 0 {
171 limit = lfs.DefaultLocksLimit
172 }
173
174 // cursor is the page number
175 if cursor <= 0 {
176 cursor = 1
177 }
178
179 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
180 dbx := db.FromContext(ctx)
181 datastore := store.FromContext(ctx)
182 repo, ok := lfsRepository(w, r)
183 if !ok {
184 return
185 }
186
187 if id > 0 {
188 lock, err := datastore.GetLFSLockByID(ctx, dbx, repo.ID(), id)
189 if err != nil {
190 if errors.Is(err, db.ErrRecordNotFound) {
191 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
192 Message: "lock not found",
193 })
194 return
195 }
196 logger.Error("error getting lock", "err", err)
197 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
198 Message: "internal server error",
199 })
200 return
201 }
202
203 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
204 if err != nil {
205 logger.Error("error getting lock owner", "err", err)
206 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
207 Message: "internal server error",
208 })
209 return
210 }
211
212 renderJSON(w, http.StatusOK, lfs.LockListResponse{
213 Locks: []lfs.Lock{
214 {
215 ID: strconv.FormatInt(lock.ID, 10),
216 Path: lock.Path,
217 LockedAt: lock.CreatedAt,
218 Owner: lfs.Owner{
219 Name: owner.Username,
220 },
221 },
222 },
223 })
224 return
225 } else if path != "" {
226 lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)
227 if err != nil {
228 if errors.Is(err, db.ErrRecordNotFound) {
229 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
230 Message: "lock not found",
231 })
232 return
233 }
234 logger.Error("error getting lock", "err", err)
235 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
236 Message: "internal server error",
237 })
238 return
239 }
240
241 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
242 if err != nil {
243 logger.Error("error getting lock owner", "err", err)
244 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
245 Message: "internal server error",
246 })
247 return
248 }
249
250 renderJSON(w, http.StatusOK, lfs.LockListResponse{
251 Locks: []lfs.Lock{
252 {
253 ID: strconv.FormatInt(lock.ID, 10),
254 Path: lock.Path,
255 LockedAt: lock.CreatedAt,
256 Owner: lfs.Owner{
257 Name: owner.Username,
258 },
259 },
260 },
261 })
262 return
263 }
264
265 locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
266 if err != nil {
267 logger.Error("error getting locks", "err", err)
268 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
269 Message: "internal server error",
270 })
271 return
272 }
273
274 lockList := make([]lfs.Lock, len(locks))
275 users := map[int64]models.User{}
276 for i, lock := range locks {
277 owner, ok := users[lock.UserID]
278 if !ok {
279 owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
280 if err != nil {
281 logger.Error("error getting lock owner", "err", err)
282 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
283 Message: "internal server error",
284 })
285 return
286 }
287 users[lock.UserID] = owner
288 }
289
290 lockList[i] = lfs.Lock{
291 ID: strconv.FormatInt(lock.ID, 10),
292 Path: lock.Path,
293 LockedAt: lock.CreatedAt,
294 Owner: lfs.Owner{
295 Name: owner.Username,
296 },
297 }
298 }
299
300 resp := lfs.LockListResponse{
301 Locks: lockList,
302 }
303 if len(locks) == limit {
304 resp.NextCursor = strconv.Itoa(cursor + 1)
305 }
306
307 renderJSON(w, http.StatusOK, resp)
308}
309
310// POST: /<repo>.git/info/lfs/objects/locks/verify
311func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
312 if !isLfs(r) {
313 renderNotAcceptable(w)
314 return
315 }
316
317 ctx := r.Context()
318 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
319 repo, ok := lfsRepository(w, r)
320 if !ok {
321 return
322 }
323
324 var req lfs.LockVerifyRequest
325 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
326 logger.Error("error decoding request", "err", err)
327 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
328 Message: "invalid request: " + err.Error(),
329 })
330 return
331 }
332
333 // TODO: refspec
334 cursor, _ := strconv.Atoi(req.Cursor)
335 if cursor <= 0 {
336 cursor = 1
337 }
338
339 limit := req.Limit
340 if limit > 100 {
341 limit = 100
342 } else if limit <= 0 {
343 limit = lfs.DefaultLocksLimit
344 }
345
346 dbx := db.FromContext(ctx)
347 datastore := store.FromContext(ctx)
348 user := proto.UserFromContext(ctx)
349 ours := make([]lfs.Lock, 0)
350 theirs := make([]lfs.Lock, 0)
351
352 var resp lfs.LockVerifyResponse
353 locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
354 if err != nil {
355 logger.Error("error getting locks", "err", err)
356 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
357 Message: "internal server error",
358 })
359 return
360 }
361
362 users := map[int64]models.User{}
363 for _, lock := range locks {
364 owner, ok := users[lock.UserID]
365 if !ok {
366 owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
367 if err != nil {
368 logger.Error("error getting lock owner", "err", err)
369 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
370 Message: "internal server error",
371 })
372 return
373 }
374 users[lock.UserID] = owner
375 }
376
377 l := lfs.Lock{
378 ID: strconv.FormatInt(lock.ID, 10),
379 Path: lock.Path,
380 LockedAt: lock.CreatedAt,
381 Owner: lfs.Owner{
382 Name: owner.Username,
383 },
384 }
385
386 if user != nil && user.ID() == lock.UserID {
387 ours = append(ours, l)
388 } else {
389 theirs = append(theirs, l)
390 }
391 }
392
393 resp.Ours = ours
394 resp.Theirs = theirs
395
396 if len(locks) == limit {
397 resp.NextCursor = strconv.Itoa(cursor + 1)
398 }
399
400 renderJSON(w, http.StatusOK, resp)
401}
402
403// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock
404func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
405 if !isLfs(r) {
406 renderNotAcceptable(w)
407 return
408 }
409
410 ctx := r.Context()
411 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
412 lockIDStr := mux.Vars(r)["lock_id"]
413 if lockIDStr == "" {
414 logger.Error("error getting lock id")
415 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
416 Message: "invalid request",
417 })
418 return
419 }
420
421 lockID, err := strconv.ParseInt(lockIDStr, 10, 64)
422 if err != nil {
423 logger.Error("error parsing lock id", "err", err)
424 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
425 Message: "invalid request",
426 })
427 return
428 }
429
430 var req lfs.LockDeleteRequest
431 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
432 logger.Error("error decoding request", "err", err)
433 renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
434 Message: "invalid request: " + err.Error(),
435 })
436 return
437 }
438
439 dbx := db.FromContext(ctx)
440 datastore := store.FromContext(ctx)
441 repo, ok := lfsRepository(w, r)
442 if !ok {
443 return
444 }
445
446 // The lock being deleted. Looked up scoped to this repository so that a
447 // lock ID belonging to another repository cannot be read through it.
448 lock, err := datastore.GetLFSLockByID(ctx, dbx, repo.ID(), lockID)
449 if err != nil {
450 logger.Error("error getting lock", "err", err)
451 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
452 Message: "lock not found",
453 })
454 return
455 }
456
457 owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
458 if err != nil {
459 logger.Error("error getting lock owner", "err", err)
460 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
461 Message: "internal server error",
462 })
463 return
464 }
465
466 l := lfs.Lock{
467 ID: strconv.FormatInt(lock.ID, 10),
468 Path: lock.Path,
469 LockedAt: lock.CreatedAt,
470 Owner: lfs.Owner{
471 Name: owner.Username,
472 },
473 }
474
475 // Retrieve user context first for authorization checks
476 user := proto.UserFromContext(ctx)
477 if user == nil {
478 logger.Error("error getting user from context")
479 renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
480 Message: "unauthorized",
481 })
482 return
483 }
484
485 // Force delete another user's lock (requires admin privileges)
486 if req.Force {
487 if !user.IsAdmin() {
488 logger.Error("non-admin user attempted force delete", "user", user.Username())
489 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
490 Message: "admin access required for force delete",
491 })
492 return
493 }
494
495 if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
496 logger.Error("error deleting lock", "err", err)
497 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
498 Message: "internal server error",
499 })
500 return
501 }
502
503 renderJSON(w, http.StatusOK, l)
504 return
505 }
506
507 // Delete our own lock - verify ownership
508 if owner.ID != user.ID() {
509 logger.Error("error deleting another user's lock")
510 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
511 Message: "lock belongs to another user",
512 })
513 return
514 }
515
516 if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
517 logger.Error("error deleting lock", "err", err)
518 renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
519 Message: "internal server error",
520 })
521 return
522 }
523
524 renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})
525}