diff --git a/go.mod b/go.mod index 9c7069233..7c03a19ec 100644 --- a/go.mod +++ b/go.mod @@ -19,9 +19,9 @@ require ( require ( github.com/caarlos0/env/v8 v8.0.0 - github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230724210439-640f4fb1e14e + github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245 github.com/charmbracelet/keygen v0.4.3 - github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 + github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 github.com/go-jose/go-jose/v3 v3.0.0 github.com/gobwas/glob v0.2.3 diff --git a/go.sum b/go.sum index 863fb20e2..bf526c8d8 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= -github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230724210439-640f4fb1e14e h1:PoAjCrpdHDShzxaV8Aa9wXJ71jbA0Ji4rDPlV3uoycA= -github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230724210439-640f4fb1e14e/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245 h1:PeGKqKX84IAFhFSWjTyPGiLzzEPcv94C9qKsYBk2nbQ= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU= github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgyyO1Mg= @@ -38,6 +38,8 @@ github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZ github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g= github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8= +github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2 h1:0O3FNIElGsbl/nnUpeUVHqET7ZETJz6cUQocn/CKhoU= +github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8= github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo= github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg= github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4= diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql index fed48900f..4b7d050d6 100644 --- a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql +++ b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql @@ -25,4 +25,8 @@ CREATE TABLE IF NOT EXISTS lfs_locks ( FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT user_id_fk + FOREIGN KEY(user_id) REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE CASCADE ); diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql index 0a43d6849..0fdf70151 100644 --- a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql +++ b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql @@ -24,5 +24,9 @@ CREATE TABLE IF NOT EXISTS lfs_locks ( CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT user_id_fk + FOREIGN KEY(user_id) REFERENCES users(id) + ON DELETE CASCADE ON UPDATE CASCADE ); diff --git a/server/db/migrate/0003_password_tokens_sqlite.down.sql b/server/db/migrate/0003_password_tokens_sqlite.down.sql index 11507c254..430f007e7 100644 --- a/server/db/migrate/0003_password_tokens_sqlite.down.sql +++ b/server/db/migrate/0003_password_tokens_sqlite.down.sql @@ -1,4 +1,5 @@ DROP TABLE IF EXISTS access_tokens; +DROP TABLE IF EXISTS users_old; ALTER TABLE users RENAME TO users_old; diff --git a/server/git/lfs.go b/server/git/lfs.go index 287ad09cb..7b01752b9 100644 --- a/server/git/lfs.go +++ b/server/git/lfs.go @@ -100,7 +100,7 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error { } // Batch implements transfer.Backend. -func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ ...string) ([]transfer.BatchItem, error) { +func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ map[string]string) ([]transfer.BatchItem, error) { items := make([]transfer.BatchItem, 0) for _, p := range pointers { obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), p.Oid) @@ -130,7 +130,7 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ ...string) } // Download implements transfer.Backend. -func (t *lfsTransfer) Download(oid string, _ ...string) (fs.File, error) { +func (t *lfsTransfer) Download(oid string, args map[string]string) (fs.File, error) { cfg := config.FromContext(t.ctx) strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs")) pointer := transfer.Pointer{Oid: oid} @@ -144,7 +144,7 @@ type uploadObject struct { } // StartUpload implements transfer.Backend. -func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ ...string) (interface{}, error) { +func (t *lfsTransfer) StartUpload(oid string, r io.Reader, args map[string]string) (interface{}, error) { if r == nil { return nil, fmt.Errorf("no reader: %w", transfer.ErrMissingData) } @@ -180,7 +180,7 @@ func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ ...string) (interfa } // FinishUpload implements transfer.Backend. -func (t *lfsTransfer) FinishUpload(state interface{}, args ...string) error { +func (t *lfsTransfer) FinishUpload(state interface{}, args map[string]string) error { upl, ok := state.(uploadObject) if !ok { return errors.New("invalid state") @@ -251,20 +251,21 @@ func (t *lfsTransfer) Verify(oid string, args map[string]string) (transfer.Statu type lfsLockBackend struct { *lfsTransfer + args map[string]string user proto.User } var _ transfer.LockBackend = (*lfsLockBackend)(nil) // LockBackend implements transfer.Backend. -func (t *lfsTransfer) LockBackend() transfer.LockBackend { +func (t *lfsTransfer) LockBackend(args map[string]string) transfer.LockBackend { user := proto.UserFromContext(t.ctx) if user == nil { t.logger.Errorf("no user in context while creating lock backend, repo %s", t.repo.Name()) return nil } - return &lfsLockBackend{t, user} + return &lfsLockBackend{t, args, user} } // Create implements transfer.LockBackend. @@ -300,14 +301,14 @@ func (l *lfsLockBackend) Create(path string, refname string) (transfer.Lock, err // FromID implements transfer.LockBackend. func (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) { var lock LFSLock - user := proto.UserFromContext(l.ctx) - if user == nil { - return nil, errors.New("no user in context") + iid, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return nil, err } if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { var err error - lock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, user.ID(), id) + lock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), iid) if err != nil { return db.WrapError(err) } @@ -315,6 +316,9 @@ func (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) { lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return nil, transfer.ErrNotFound + } l.logger.Errorf("error getting lock: %v", err) return nil, err } @@ -338,6 +342,9 @@ func (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) { lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return nil, transfer.ErrNotFound + } l.logger.Errorf("error getting lock: %v", err) return nil, err } @@ -348,15 +355,32 @@ func (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) { } // Range implements transfer.LockBackend. -func (l *lfsLockBackend) Range(fn func(transfer.Lock) error) error { +func (l *lfsLockBackend) Range(cursor string, limit int, fn func(transfer.Lock) error) (string, error) { + var nextCursor string var locks []*LFSLock + page, _ := strconv.Atoi(cursor) + if page <= 0 { + page = 1 + } + + if limit <= 0 { + limit = lfs.DefaultLocksLimit + } else if limit > 100 { + limit = 100 + } + if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { - mlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID()) + l.logger.Debug("getting locks", "limit", limit, "page", page) + mlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID(), page, limit) if err != nil { return db.WrapError(err) } + if len(mlocks) == limit { + nextCursor = strconv.Itoa(page + 1) + } + users := make(map[int64]models.User, 0) for _, mlock := range mlocks { owner, ok := users[mlock.UserID] @@ -374,25 +398,39 @@ func (l *lfsLockBackend) Range(fn func(transfer.Lock) error) error { return nil }); err != nil { - return err + return "", err } for _, lock := range locks { if err := fn(lock); err != nil { - return err + return "", err } } - return nil + return nextCursor, nil } // Unlock implements transfer.LockBackend. func (l *lfsLockBackend) Unlock(lock transfer.Lock) error { - return l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { + id, err := strconv.ParseInt(lock.ID(), 10, 64) + if err != nil { + return err + } + + err = l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { return db.WrapError( - l.store.DeleteLFSLockForUserByID(l.ctx, tx, l.user.ID(), lock.ID()), + l.store.DeleteLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), id), ) }) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return transfer.ErrNotFound + } + l.logger.Error("error unlocking lock", "err", err) + return err + } + + return nil } // LFSLock is a Git LFS lock object. diff --git a/server/lfs/common.go b/server/lfs/common.go index c306f1a65..aa98c6527 100644 --- a/server/lfs/common.go +++ b/server/lfs/common.go @@ -22,6 +22,10 @@ const ( // ActionVerify is the action name for a verify request. ActionVerify = "verify" + + // DefaultLocksLimit is the default number of locks to return in a single + // request. + DefaultLocksLimit = 20 ) // Pointer contains LFS pointer data @@ -102,19 +106,22 @@ type AuthenticateResponse struct { // https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-request-schema.json type LockCreateRequest struct { Path string `json:"path"` - Ref Reference `json:"ref"` + Ref Reference `json:"ref,omitempty"` +} + +// Owner contains the owner data for a lock. +type Owner struct { + Name string `json:"name"` } // Lock contains the response data for creating a lock. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md // https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-response-schema.json type Lock struct { - ID string `json:"id"` - Path string `json:"path"` - LockedAt string `json:"locked_at"` - Owner struct { - Name string `json:"name"` - } `json:"owner,omitempty"` + ID string `json:"id"` + Path string `json:"path"` + LockedAt time.Time `json:"locked_at"` + Owner Owner `json:"owner,omitempty"` } // LockDeleteRequest contains the request data for deleting a lock. @@ -135,7 +142,7 @@ type LockListResponse struct { // LockVerifyRequest contains the request data for verifying a lock. type LockVerifyRequest struct { - Ref Reference `json:"ref"` + Ref Reference `json:"ref,omitempty"` Cursor string `json:"cursor,omitempty"` Limit int `json:"limit,omitempty"` } @@ -148,3 +155,9 @@ type LockVerifyResponse struct { Theirs []Lock `json:"theirs"` NextCursor string `json:"next_cursor,omitempty"` } + +// LockResponse contains the response data for a lock. +type LockResponse struct { + Lock Lock `json:"lock"` + ErrorResponse +} diff --git a/server/ssh/git.go b/server/ssh/git.go index ec639f68f..02b59e478 100644 --- a/server/ssh/git.go +++ b/server/ssh/git.go @@ -132,10 +132,6 @@ func handleGit(s ssh.Session) { return case git.LFSTransferService, git.LFSAuthenticateService: - if service == git.LFSTransferService { - return - } - if accessLevel < access.ReadWriteAccess { sshFatal(s, git.ErrNotAuthed) return diff --git a/server/store/database/lfs.go b/server/store/database/lfs.go index 0233dec7a..64fef3716 100644 --- a/server/store/database/lfs.go +++ b/server/store/database/lfs.go @@ -2,7 +2,6 @@ package database import ( "context" - "strconv" "strings" "github.com/charmbracelet/soft-serve/server/db" @@ -37,17 +36,43 @@ func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID } // GetLFSLocks implements store.LFSStore. -func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSLock, error) { +func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) { + if page <= 0 { + page = 1 + } + var locks []models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks - WHERE repo_id = ?; + WHERE repo_id = ? + ORDER BY updated_at DESC + LIMIT ? OFFSET ?; `) - err := tx.SelectContext(ctx, &locks, query, repoID) + err := tx.SelectContext(ctx, &locks, query, repoID, limit, (page-1)*limit) return locks, db.WrapError(err) } +func (s *lfsStore) GetLFSLocksWithCount(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) { + locks, err := s.GetLFSLocks(ctx, tx, repoID, page, limit) + if err != nil { + return nil, 0, err + } + + var count int64 + query := tx.Rebind(` + SELECT COUNT(*) + FROM lfs_locks + WHERE repo_id = ?; + `) + err = tx.GetContext(ctx, &count, query, repoID) + if err != nil { + return nil, 0, db.WrapError(err) + } + + return locks, count, nil +} + // GetLFSLocksForUser implements store.LFSStore. func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) { var locks []models.LFSLock @@ -61,16 +86,16 @@ func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID i } // GetLFSLocksForPath implements store.LFSStore. -func (*lfsStore) GetLFSLocksForPath(ctx context.Context, tx db.Handler, repoID int64, path string) ([]models.LFSLock, error) { +func (*lfsStore) GetLFSLockForPath(ctx context.Context, tx db.Handler, repoID int64, path string) (models.LFSLock, error) { path = sanitizePath(path) - var locks []models.LFSLock + var lock models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE repo_id = ? AND path = ?; `) - err := tx.SelectContext(ctx, &locks, query, repoID, path) - return locks, db.WrapError(err) + err := tx.GetContext(ctx, &lock, query, repoID, path) + return lock, db.WrapError(err) } // GetLFSLockForUserPath implements store.LFSStore. @@ -87,51 +112,46 @@ func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoI } // GetLFSLockByID implements store.LFSStore. -func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id string) (models.LFSLock, error) { - iid, err := strconv.Atoi(id) - if err != nil { - return models.LFSLock{}, err - } - +func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) { var lock models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE lfs_locks.id = ?; `) - err = tx.GetContext(ctx, &lock, query, iid) + err := tx.GetContext(ctx, &lock, query, id) return lock, db.WrapError(err) } // GetLFSLockForUserByID implements store.LFSStore. -func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, userID int64, id string) (models.LFSLock, error) { - iid, err := strconv.Atoi(id) - if err != nil { - return models.LFSLock{}, err - } - +func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) { var lock models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks - WHERE id = ? AND user_id = ?; + WHERE id = ? AND user_id = ? AND repo_id = ?; `) - err = tx.GetContext(ctx, &lock, query, iid, userID) + err := tx.GetContext(ctx, &lock, query, id, userID, repoID) return lock, db.WrapError(err) } // DeleteLFSLockForUserByID implements store.LFSStore. -func (*lfsStore) DeleteLFSLockForUserByID(ctx context.Context, tx db.Handler, userID int64, id string) error { - iid, err := strconv.Atoi(id) - if err != nil { - return err - } +func (*lfsStore) DeleteLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) error { + query := tx.Rebind(` + DELETE FROM lfs_locks + WHERE repo_id = ? AND user_id = ? AND id = ?; + `) + _, err := tx.ExecContext(ctx, query, repoID, userID, id) + return db.WrapError(err) +} +// DeleteLFSLock implements store.LFSStore. +func (*lfsStore) DeleteLFSLock(ctx context.Context, tx db.Handler, repoID int64, id int64) error { query := tx.Rebind(` DELETE FROM lfs_locks - WHERE user_id = ? AND id = ?; + WHERE repo_id = ? AND id = ?; `) - _, err = tx.ExecContext(ctx, query, userID, iid) + _, err := tx.ExecContext(ctx, query, repoID, id) return db.WrapError(err) } diff --git a/server/store/lfs.go b/server/store/lfs.go index 7632d2472..067285eac 100644 --- a/server/store/lfs.go +++ b/server/store/lfs.go @@ -16,11 +16,13 @@ type LFSStore interface { DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error - GetLFSLocks(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSLock, error) + GetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) + GetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) - GetLFSLocksForPath(ctx context.Context, h db.Handler, repoID int64, path string) ([]models.LFSLock, error) + GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error) GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) - GetLFSLockByID(ctx context.Context, h db.Handler, id string) (models.LFSLock, error) - GetLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) (models.LFSLock, error) - DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) error + GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error) + GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) + DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error + DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error } diff --git a/server/web/auth.go b/server/web/auth.go index f8ff047ed..067108fe4 100644 --- a/server/web/auth.go +++ b/server/web/auth.go @@ -35,7 +35,6 @@ func authenticate(r *http.Request) (proto.User, error) { case "bearer": claims, err := getJWTClaims(ctx, parts[1]) if err != nil { - logger.Error("failed to get jwt claims", "err", err) return nil, err } @@ -69,8 +68,12 @@ func authenticate(r *http.Request) (proto.User, error) { return nil, proto.ErrUserNotFound } +// ErrInvalidToken is returned when a token is invalid. +var ErrInvalidToken = errors.New("invalid token") + func getJWTClaims(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) { cfg := config.FromContext(ctx) + logger := log.FromContext(ctx).WithPrefix("http.auth") kp, err := cfg.SSH.KeyPair() if err != nil { return nil, err @@ -93,12 +96,13 @@ func getJWTClaims(ctx context.Context, bearer string) (*jwt.RegisteredClaims, er jwt.WithAudience(repo.Name()), ) if err != nil { - return nil, err + logger.Error("failed to parse jwt", "err", err) + return nil, ErrInvalidToken } claims, ok := token.Claims.(*jwt.RegisteredClaims) if !token.Valid || !ok { - return nil, errors.New("invalid token") + return nil, ErrInvalidToken } return claims, nil diff --git a/server/web/git.go b/server/web/git.go index 8ed317a48..b781f6431 100644 --- a/server/web/git.go +++ b/server/web/git.go @@ -49,21 +49,25 @@ func (g GitRoute) Match(r *http.Request) *http.Request { repo := utils.SanitizeRepo(m[1]) var service git.Service - var oid string // LFS object ID + var oid string // LFS object ID + var lockID string // LFS lock ID switch { case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()): service = git.UploadPackService case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()): service = git.ReceivePackService - case strings.HasPrefix(r.URL.Path, m[1]+"/info/lfs/objects/basic/"): - if len(m) > 2 { + case len(m) > 2: + if strings.HasPrefix(file, "info/lfs/objects/basic/") { oid = m[2] + } else if strings.HasPrefix(file, "info/lfs/locks/") && strings.HasSuffix(file, "/unlock") { + lockID = m[2] } fallthrough case strings.HasPrefix(file, "info/lfs"): service = gitLfsService } + ctx = context.WithValue(ctx, pattern.Variable("lock_id"), lockID) ctx = context.WithValue(ctx, pattern.Variable("oid"), oid) ctx = context.WithValue(ctx, pattern.Variable("service"), service.String()) ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(cfg.DataPath, "repos", repo+".git")) @@ -188,12 +192,21 @@ var gitRoutes = []GitRoute{ handler: serviceLfsBasicVerify, }, // Git LFS locks - // TODO: implement locks - // { - // pattern: regexp.MustCompile(`(.*?)/info/lfs/locks$`), - // method: []string{http.MethodPost}, - // handler: serviceLfsLocksCreate, - // }, + { + pattern: regexp.MustCompile(`(.*?)/info/lfs/locks$`), + method: []string{http.MethodPost, http.MethodGet}, + handler: serviceLfsLocks, + }, + { + pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/verify$`), + method: []string{http.MethodPost}, + handler: serviceLfsLocksVerify, + }, + { + pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/([0-9]+)/unlock$`), + method: []string{http.MethodPost}, + handler: serviceLfsLocksDelete, + }, } // withAccess handles auth. @@ -219,7 +232,10 @@ func withAccess(next http.Handler) http.HandlerFunc { user, err := authenticate(r) if err != nil { - if !errors.Is(err, proto.ErrUserNotFound) { + switch { + case errors.Is(err, ErrInvalidToken): + case errors.Is(err, proto.ErrUserNotFound): + default: logger.Error("failed to authenticate", "err", err) } } @@ -260,7 +276,10 @@ func withAccess(next http.Handler) http.HandlerFunc { switch { case strings.HasPrefix(file, "info/lfs/locks"): switch { - case strings.HasSuffix(file, "locks/verify"): + case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost: + // Create lock, list locks, and delete lock require write access + fallthrough + case strings.HasSuffix(file, "lfs/locks/verify"): // Locks verify requires write access // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2 if accessLevel < access.ReadWriteAccess { @@ -285,7 +304,6 @@ func withAccess(next http.Handler) http.HandlerFunc { case http.MethodPost: // Basic verify } - case strings.HasPrefix(file, "info/lfs/objects/batch"): } if accessLevel < access.ReadOnlyAccess { hdr := `Basic realm="Git LFS" charset="UTF-8", Token, Bearer` diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go index d17fbc073..f243ab973 100644 --- a/server/web/git_lfs.go +++ b/server/web/git_lfs.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "net/http" + "net/url" "path" "path/filepath" "strconv" @@ -16,6 +17,7 @@ import ( "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/models" "github.com/charmbracelet/soft-serve/server/git" "github.com/charmbracelet/soft-serve/server/lfs" "github.com/charmbracelet/soft-serve/server/proto" @@ -29,7 +31,7 @@ const gitLfsService git.Service = "git-lfs-service" // serviceLfsBatch handles a Git LFS batch requests. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md -// TODO: support refname & authentication +// TODO: support refname // POST: /.git/info/lfs/objects/batch func serviceLfsBatch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -419,6 +421,17 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) { } } +func serviceLfsLocks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + serviceLfsLocksGet(w, r) + case http.MethodPost: + serviceLfsLocksCreate(w, r) + default: + renderMethodNotAllowed(w, r) + } +} + // POST: /.git/info/lfs/objects/locks func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { @@ -426,7 +439,488 @@ func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) { return } - panic("not implemented") + ctx := r.Context() + logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") + + var req lfs.LockCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Error("error decoding json", "err", err) + renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ + Message: "invalid request", + }) + return + } + + repo := proto.RepositoryFromContext(ctx) + if repo == nil { + logger.Error("error getting repository from context") + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "repository not found", + }) + return + } + + user := proto.UserFromContext(ctx) + if user == nil { + logger.Error("error getting user from context") + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "user not found", + }) + return + } + + dbx := db.FromContext(ctx) + datastore := store.FromContext(ctx) + if err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil { + err = db.WrapError(err) + if errors.Is(err, db.ErrDuplicateKey) { + errResp := lfs.LockResponse{ + ErrorResponse: lfs.ErrorResponse{ + Message: "lock already exists", + }, + } + lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path) + if err == nil { + errResp.Lock = lfs.Lock{ + ID: strconv.FormatInt(lock.ID, 10), + Path: lock.Path, + LockedAt: lock.CreatedAt, + } + lockOwner := lfs.Owner{ + Name: user.Username(), + } + if lock.UserID != user.ID() { + owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) + if err != nil { + logger.Error("error getting lock owner", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + lockOwner.Name = owner.Username + } + errResp.Lock.Owner = lockOwner + } + renderJSON(w, http.StatusConflict, errResp) + return + } + logger.Error("error creating lock", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path) + if err != nil { + logger.Error("error getting lock", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + renderJSON(w, http.StatusCreated, lfs.LockResponse{ + Lock: lfs.Lock{ + ID: strconv.FormatInt(lock.ID, 10), + Path: lock.Path, + LockedAt: lock.CreatedAt, + Owner: lfs.Owner{ + Name: user.Username(), + }, + }, + }) +} + +// GET: /.git/info/lfs/objects/locks +func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("Accept") + if !strings.HasPrefix(accept, lfs.MediaType) { + renderNotAcceptable(w) + return + } + + parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) { + path = values.Get("path") + idStr := values.Get("id") + if idStr != "" { + id, _ = strconv.ParseInt(idStr, 10, 64) + } + cursorStr := values.Get("cursor") + if cursorStr != "" { + cursor, _ = strconv.Atoi(cursorStr) + } + limitStr := values.Get("limit") + if limitStr != "" { + limit, _ = strconv.Atoi(limitStr) + } + refspec = values.Get("refspec") + return + } + + ctx := r.Context() + // TODO: respect refspec + path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query()) + if limit > 100 { + limit = 100 + } else if limit <= 0 { + limit = lfs.DefaultLocksLimit + } + + // cursor is the page number + if cursor <= 0 { + cursor = 1 + } + + logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") + dbx := db.FromContext(ctx) + datastore := store.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + if repo == nil { + logger.Error("error getting repository from context") + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "repository not found", + }) + return + } + + if id > 0 { + lock, err := datastore.GetLFSLockByID(ctx, dbx, id) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "lock not found", + }) + return + } + logger.Error("error getting lock", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) + if err != nil { + logger.Error("error getting lock owner", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + renderJSON(w, http.StatusOK, lfs.LockListResponse{ + Locks: []lfs.Lock{ + { + ID: strconv.FormatInt(lock.ID, 10), + Path: lock.Path, + LockedAt: lock.CreatedAt, + Owner: lfs.Owner{ + Name: owner.Username, + }, + }, + }, + }) + return + } else if path != "" { + lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path) + if err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "lock not found", + }) + return + } + logger.Error("error getting lock", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) + if err != nil { + logger.Error("error getting lock owner", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + renderJSON(w, http.StatusOK, lfs.LockListResponse{ + Locks: []lfs.Lock{ + { + ID: strconv.FormatInt(lock.ID, 10), + Path: lock.Path, + LockedAt: lock.CreatedAt, + Owner: lfs.Owner{ + Name: owner.Username, + }, + }, + }, + }) + return + } else { + locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit) + if err != nil { + logger.Error("error getting locks", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + lockList := make([]lfs.Lock, len(locks)) + users := map[int64]models.User{} + for i, lock := range locks { + owner, ok := users[lock.UserID] + if !ok { + owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID) + if err != nil { + logger.Error("error getting lock owner", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + users[lock.UserID] = owner + } + + lockList[i] = lfs.Lock{ + ID: strconv.FormatInt(lock.ID, 10), + Path: lock.Path, + LockedAt: lock.CreatedAt, + Owner: lfs.Owner{ + Name: owner.Username, + }, + } + } + + resp := lfs.LockListResponse{ + Locks: lockList, + } + if len(locks) == limit { + resp.NextCursor = strconv.Itoa(cursor + 1) + } + + renderJSON(w, http.StatusOK, resp) + return + } +} + +// POST: /.git/info/lfs/objects/locks/verify +func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) { + if !isLfs(r) { + renderNotAcceptable(w) + return + } + + ctx := r.Context() + logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") + repo := proto.RepositoryFromContext(ctx) + if repo == nil { + logger.Error("error getting repository from context") + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "repository not found", + }) + return + } + + var req lfs.LockVerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Error("error decoding request", "err", err) + renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ + Message: "invalid request", + }) + return + } + + // TODO: refspec + cursor, _ := strconv.Atoi(req.Cursor) + if cursor <= 0 { + cursor = 1 + } + + limit := req.Limit + if limit > 100 { + limit = 100 + } else if limit <= 0 { + limit = lfs.DefaultLocksLimit + } + + dbx := db.FromContext(ctx) + datastore := store.FromContext(ctx) + user := proto.UserFromContext(ctx) + ours := make([]lfs.Lock, 0) + theirs := make([]lfs.Lock, 0) + + var resp lfs.LockVerifyResponse + locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit) + if err != nil { + logger.Error("error getting locks", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + users := map[int64]models.User{} + for _, lock := range locks { + owner, ok := users[lock.UserID] + if !ok { + owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID) + if err != nil { + logger.Error("error getting lock owner", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + users[lock.UserID] = owner + } + + l := lfs.Lock{ + ID: strconv.FormatInt(lock.ID, 10), + Path: lock.Path, + LockedAt: lock.CreatedAt, + Owner: lfs.Owner{ + Name: owner.Username, + }, + } + + if user != nil && user.ID() == lock.UserID { + ours = append(ours, l) + } else { + theirs = append(theirs, l) + } + } + + resp.Ours = ours + resp.Theirs = theirs + + if len(locks) == limit { + resp.NextCursor = strconv.Itoa(cursor + 1) + } + + renderJSON(w, http.StatusOK, resp) +} + +// POST: /.git/info/lfs/objects/locks/:lockID/unlock +func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) { + if !isLfs(r) { + renderNotAcceptable(w) + return + } + + ctx := r.Context() + logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") + lockIDStr := pat.Param(r, "lock_id") + if lockIDStr == "" { + logger.Error("error getting lock id") + renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ + Message: "invalid request", + }) + return + } + + lockID, err := strconv.ParseInt(lockIDStr, 10, 64) + if err != nil { + logger.Error("error parsing lock id", "err", err) + renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ + Message: "invalid request", + }) + return + } + + var req lfs.LockDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Error("error decoding request", "err", err) + renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ + Message: "invalid request", + }) + return + } + + dbx := db.FromContext(ctx) + datastore := store.FromContext(ctx) + repo := proto.RepositoryFromContext(ctx) + if repo == nil { + logger.Error("error getting repository from context") + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "repository not found", + }) + return + } + + // The lock being deleted + lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID) + if err != nil { + logger.Error("error getting lock", "err", err) + renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ + Message: "lock not found", + }) + return + } + + owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) + if err != nil { + logger.Error("error getting lock owner", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + // Delete another user's lock + l := lfs.Lock{ + ID: strconv.FormatInt(lock.ID, 10), + Path: lock.Path, + LockedAt: lock.CreatedAt, + Owner: lfs.Owner{ + Name: owner.Username, + }, + } + if req.Force { + if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil { + logger.Error("error deleting lock", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + renderJSON(w, http.StatusOK, l) + return + } + + // Delete our own lock + user := proto.UserFromContext(ctx) + if user == nil { + logger.Error("error getting user from context") + renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{ + Message: "unauthorized", + }) + return + } + + if owner.ID != user.ID() { + logger.Error("error deleting another user's lock") + renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ + Message: "lock belongs to another user", + }) + return + } + + if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil { + logger.Error("error deleting lock", "err", err) + renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ + Message: "internal server error", + }) + return + } + + renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l}) } // renderJSON renders a JSON response with the given status code and value. It