diff --git a/modules/lfstransfer/backend/backend.go b/modules/lfstransfer/backend/backend.go index b55d87ac8611b..b9b59eed7c78c 100644 --- a/modules/lfstransfer/backend/backend.go +++ b/modules/lfstransfer/backend/backend.go @@ -8,7 +8,6 @@ import ( "context" "encoding/base64" "encoding/json" - "fmt" "io" "net/http" "strconv" @@ -24,7 +23,7 @@ const Version = "1" // Capabilities is a list of Git LFS capabilities supported by this package. var Capabilities = []string{ "version=" + Version, - // "locking", // no support yet in gitea backend + "locking", } var _ transfer.Backend = &GiteaBackend{} @@ -286,8 +285,5 @@ func (g *GiteaBackend) Verify(oid string, size int64, args transfer.Args) (trans // LockBackend implements transfer.Backend. func (g *GiteaBackend) LockBackend(_ transfer.Args) transfer.LockBackend { - // Gitea doesn't support the locking API - // this should never be called as we don't advertise the capability - panic(fmt.Errorf("backend doesn't implement locking")) - return (transfer.LockBackend)(nil) + return newGiteaLockBackend(g) } diff --git a/modules/lfstransfer/backend/lock.go b/modules/lfstransfer/backend/lock.go new file mode 100644 index 0000000000000..2c630f25f9abb --- /dev/null +++ b/modules/lfstransfer/backend/lock.go @@ -0,0 +1,291 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package backend + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "code.gitea.io/gitea/modules/lfstransfer/transfer" + lfslock "code.gitea.io/gitea/modules/structs" +) + +var _ transfer.LockBackend = &giteaLockBackend{} + +type giteaLockBackend struct { + ctx context.Context + g *GiteaBackend + server string + token string + logger transfer.Logger +} + +func newGiteaLockBackend(g *GiteaBackend) transfer.LockBackend { + server := g.server + "/locks" + return &giteaLockBackend{ctx: g.ctx, g: g, server: server, token: g.token, logger: g.logger} +} + +// Create implements transfer.LockBackend +func (g *giteaLockBackend) Create(path string, refname string) (transfer.Lock, error) { + reqBody := lfslock.LFSLockRequest{Path: path} + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + g.logger.Log("json marshal error", err) + return nil, err + } + url := g.server + headers := map[string]string{ + headerAuthorisation: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes) + resp, err := req.Response() + if err != nil { + g.logger.Log("http request error", err) + return nil, err + } + defer resp.Body.Close() + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + g.logger.Log("http read error", err) + return nil, err + } + if resp.StatusCode != http.StatusCreated { + g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode)) + return nil, statusCodeToErr(resp.StatusCode) + } + var respBody lfslock.LFSLockResponse + err = json.Unmarshal(respBytes, &respBody) + if err != nil { + g.logger.Log("json umarshal error", err) + return nil, err + } + + if respBody.Lock == nil { + g.logger.Log("api returned nil lock") + return nil, fmt.Errorf("api returned nil lock") + } + respLock := respBody.Lock + owner := userUnknown + if respLock.Owner != nil { + owner = respLock.Owner.Name + } + lock := newGiteaLock(g, respLock.ID, respLock.Path, respLock.LockedAt, owner) + return lock, nil +} + +// Unlock implements transfer.LockBackend +func (g *giteaLockBackend) Unlock(lock transfer.Lock) error { + reqBody := lfslock.LFSLockDeleteRequest{} + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + g.logger.Log("json marshal error", err) + return err + } + url := g.server + fmt.Sprintf("/%v/unlock", lock.ID()) + headers := map[string]string{ + headerAuthorisation: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes) + resp, err := req.Response() + if err != nil { + g.logger.Log("http request error", err) + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode)) + return statusCodeToErr(resp.StatusCode) + } + // no need to read response + + return nil +} + +// FromPath implements transfer.LockBackend +func (g *giteaLockBackend) FromPath(path string) (transfer.Lock, error) { + v := url.Values{ + argPath: []string{path}, + } + + respLocks, _, err := g.queryLocks(v) + if err != nil { + return nil, err + } + + if len(respLocks) == 0 { + return nil, transfer.ErrNotFound + } + return respLocks[0], nil + +} + +// FromID implements transfer.LockBackend +func (g *giteaLockBackend) FromID(id string) (transfer.Lock, error) { + v := url.Values{ + argID: []string{id}, + } + + respLocks, _, err := g.queryLocks(v) + if err != nil { + return nil, err + } + + if len(respLocks) == 0 { + return nil, transfer.ErrNotFound + } + return respLocks[0], nil +} + +// Range implements transfer.LockBackend +func (g *giteaLockBackend) Range(cursor string, limit int, iter func(transfer.Lock) error) (string, error) { + v := url.Values{ + argLimit: []string{strconv.FormatInt(int64(limit), 10)}, + } + if cursor != "" { + v[argCursor] = []string{cursor} + } + + respLocks, cursor, err := g.queryLocks(v) + if err != nil { + return "", err + } + + for _, lock := range respLocks { + err := iter(lock) + if err != nil { + return "", err + } + } + return cursor, nil +} + +func (g *giteaLockBackend) queryLocks(v url.Values) ([]transfer.Lock, string, error) { + url := g.server + "?" + v.Encode() + headers := map[string]string{ + headerAuthorisation: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodGet, headers, nil) + resp, err := req.Response() + if err != nil { + g.logger.Log("http request error", err) + return nil, "", err + } + defer resp.Body.Close() + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + g.logger.Log("http read error", err) + return nil, "", err + } + if resp.StatusCode != http.StatusOK { + g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode)) + return nil, "", statusCodeToErr(resp.StatusCode) + } + var respBody lfslock.LFSLockList + err = json.Unmarshal(respBytes, &respBody) + if err != nil { + g.logger.Log("json umarshal error", err) + return nil, "", err + } + + respLocks := make([]transfer.Lock, 0, len(respBody.Locks)) + for _, respLock := range respBody.Locks { + owner := userUnknown + if respLock.Owner != nil { + owner = respLock.Owner.Name + } + lock := newGiteaLock(g, respLock.ID, respLock.Path, respLock.LockedAt, owner) + respLocks = append(respLocks, lock) + } + return respLocks, respBody.Next, nil +} + +var _ transfer.Lock = &giteaLock{} + +type giteaLock struct { + g *giteaLockBackend + id string + path string + lockedAt time.Time + owner string +} + +func newGiteaLock(g *giteaLockBackend, id string, path string, lockedAt time.Time, owner string) transfer.Lock { + return &giteaLock{g: g, id: id, path: path, lockedAt: lockedAt, owner: owner} +} + +// Unlock implements transfer.Lock +func (g *giteaLock) Unlock() error { + return g.g.Unlock(g) +} + +// ID implements transfer.Lock +func (g *giteaLock) ID() string { + return g.id +} + +// Path implements transfer.Lock +func (g *giteaLock) Path() string { + return g.path +} + +// FormattedTimestamp implements transfer.Lock +func (g *giteaLock) FormattedTimestamp() string { + return g.lockedAt.UTC().Format(time.RFC3339) +} + +// OwnerName implements transfer.Lock +func (g *giteaLock) OwnerName() string { + return g.owner +} + +func (g *giteaLock) CurrentUser() (string, error) { + // TODO fish out from request + return userSelf, nil +} + +// AsLockSpec implements transfer.Lock +func (g *giteaLock) AsLockSpec(ownerID bool) ([]string, error) { + msgs := []string{ + fmt.Sprintf("lock %s", g.ID()), + fmt.Sprintf("path %s %s", g.ID(), g.Path()), + fmt.Sprintf("locked-at %s %s", g.ID(), g.FormattedTimestamp()), + fmt.Sprintf("ownername %s %s", g.ID(), g.OwnerName()), + } + if ownerID { + user, err := g.CurrentUser() + if err != nil { + return nil, fmt.Errorf("error getting current user: %w", err) + } + who := "theirs" + if user == g.OwnerName() { + who = "ours" + } + msgs = append(msgs, fmt.Sprintf("owner %s %s", g.ID(), who)) + } + return msgs, nil +} + +// AsArguments implements transfer.Lock +func (g *giteaLock) AsArguments() []string { + return []string{ + fmt.Sprintf("id=%s", g.ID()), + fmt.Sprintf("path=%s", g.Path()), + fmt.Sprintf("locked-at=%s", g.FormattedTimestamp()), + fmt.Sprintf("ownername=%s", g.OwnerName()), + } +} diff --git a/modules/lfstransfer/backend/util.go b/modules/lfstransfer/backend/util.go index 2c0909613656c..1ae5330f96f25 100644 --- a/modules/lfstransfer/backend/util.go +++ b/modules/lfstransfer/backend/util.go @@ -1,3 +1,6 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package backend import ( @@ -37,13 +40,22 @@ const ( // SSH protocol argument keys const ( + argCursor = "cursor" argExpiresAt = "expires-at" argID = "id" + argLimit = "limit" + argPath = "path" argRefname = "refname" argToken = "token" argTransfer = "transfer" ) +// unknown username constants +const ( + userUnknown = "unknown" + userSelf = "unknown-self" +) + // Operations enum const ( opNone = iota