Skip to content

Commit 49c7aca

Browse files
modules/lfstransfer: add a backend and runner
Also add handler in runServ() The protocol lib supports locking but the backend does not, as neither does Gitea. Support can be added later and the capability advertised.
1 parent 42d1710 commit 49c7aca

File tree

5 files changed

+496
-1
lines changed

5 files changed

+496
-1
lines changed

cmd/serv.go

+14-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"code.gitea.io/gitea/models/perm"
2323
"code.gitea.io/gitea/modules/git"
2424
"code.gitea.io/gitea/modules/json"
25+
"code.gitea.io/gitea/modules/lfstransfer"
2526
"code.gitea.io/gitea/modules/log"
2627
"code.gitea.io/gitea/modules/pprof"
2728
"code.gitea.io/gitea/modules/private"
@@ -40,6 +41,7 @@ const (
4041
verbUploadArchive = "git-upload-archive"
4142
verbReceivePack = "git-receive-pack"
4243
verbLfsAuthenticate = "git-lfs-authenticate"
44+
verbLfsTransfer = "git-lfs-transfer"
4345
)
4446

4547
// CmdServ represents the available serv sub-command.
@@ -83,9 +85,11 @@ var (
8385
verbUploadArchive: true,
8486
verbReceivePack: true,
8587
verbLfsAuthenticate: true,
88+
verbLfsTransfer: true,
8689
}
8790
allowedCommandsLfs = map[string]bool{
8891
verbLfsAuthenticate: true,
92+
verbLfsTransfer: true,
8993
}
9094
alphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`)
9195
)
@@ -138,7 +142,7 @@ func getAccessMode(verb string, lfsVerb string) perm.AccessMode {
138142
return perm.AccessModeRead
139143
case verbReceivePack:
140144
return perm.AccessModeWrite
141-
case verbLfsAuthenticate:
145+
case verbLfsAuthenticate, verbLfsTransfer:
142146
switch lfsVerb {
143147
case "upload":
144148
return perm.AccessModeWrite
@@ -297,6 +301,15 @@ func runServ(c *cli.Context) error {
297301
return fail(ctx, extra.UserMsg, "ServCommand failed: %s", extra.Error)
298302
}
299303

304+
// LFS SSH protocol
305+
if verb == verbLfsTransfer {
306+
token, err := getLFSAuthToken(ctx, lfsVerb, results)
307+
if err != nil {
308+
return err
309+
}
310+
return lfstransfer.Main(ctx, repoPath, lfsVerb, token)
311+
}
312+
300313
// LFS token authentication
301314
if verb == verbLfsAuthenticate {
302315
url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName))
+293
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package backend
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"encoding/base64"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"strconv"
15+
16+
"code.gitea.io/gitea/modules/lfs"
17+
"code.gitea.io/gitea/modules/lfstransfer/transfer"
18+
"code.gitea.io/gitea/modules/setting"
19+
)
20+
21+
// Version is the git-lfs-transfer protocol version number.
22+
const Version = "1"
23+
24+
// Capabilities is a list of Git LFS capabilities supported by this package.
25+
var Capabilities = []string{
26+
"version=" + Version,
27+
// "locking", // no support yet in gitea backend
28+
}
29+
30+
var _ transfer.Backend = &GiteaBackend{}
31+
32+
// GiteaBackend is an adapter between git-lfs-transfer library and Gitea's internal LFS API
33+
type GiteaBackend struct {
34+
ctx context.Context
35+
server string
36+
op string
37+
token string
38+
logger transfer.Logger
39+
}
40+
41+
func New(ctx context.Context, repo string, op string, token string, logger transfer.Logger) transfer.Backend {
42+
// runServ guarantees repo will be in form [owner]/[name].git
43+
server := setting.LocalURL + "/" + repo + "/info/lfs"
44+
return &GiteaBackend{ctx: ctx, server: server, op: op, token: token, logger: logger}
45+
}
46+
47+
// Batch implements transfer.Backend
48+
func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args transfer.Args) ([]transfer.BatchItem, error) {
49+
reqBody := lfs.BatchRequest{Operation: g.op}
50+
if transfer, ok := args[argTransfer]; ok {
51+
reqBody.Transfers = []string{transfer}
52+
}
53+
if ref, ok := args[argRefname]; ok {
54+
reqBody.Ref = &lfs.Reference{Name: ref}
55+
}
56+
reqBody.Objects = make([]lfs.Pointer, len(pointers))
57+
for i := range pointers {
58+
reqBody.Objects[i].Oid = pointers[i].Oid
59+
reqBody.Objects[i].Size = pointers[i].Size
60+
}
61+
62+
bodyBytes, err := json.Marshal(reqBody)
63+
if err != nil {
64+
g.logger.Log("json marshal error", err)
65+
return nil, err
66+
}
67+
url := g.server + "/objects/batch"
68+
headers := map[string]string{
69+
headerAuthorisation: g.token,
70+
headerAccept: mimeGitLFS,
71+
headerContentType: mimeGitLFS,
72+
}
73+
req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
74+
resp, err := req.Response()
75+
if err != nil {
76+
g.logger.Log("http request error", err)
77+
return nil, err
78+
}
79+
if resp.StatusCode != http.StatusOK {
80+
g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode))
81+
return nil, statusCodeToErr(resp.StatusCode)
82+
}
83+
defer resp.Body.Close()
84+
respBytes, err := io.ReadAll(resp.Body)
85+
if err != nil {
86+
g.logger.Log("http read error", err)
87+
return nil, err
88+
}
89+
var respBody lfs.BatchResponse
90+
err = json.Unmarshal(respBytes, &respBody)
91+
if err != nil {
92+
g.logger.Log("json umarshal error", err)
93+
return nil, err
94+
}
95+
96+
// rebuild slice, we can't rely on order in resp being the same as req
97+
pointers = pointers[:0]
98+
opNum := opMap[g.op]
99+
for _, obj := range respBody.Objects {
100+
pointer := transfer.Pointer{Oid: obj.Pointer.Oid, Size: obj.Pointer.Size}
101+
item := transfer.BatchItem{Pointer: pointer, Args: map[string]string{}}
102+
switch opNum {
103+
case opDownload:
104+
if action, ok := obj.Actions[actionDownload]; ok {
105+
item.Present = true
106+
idMap := obj.Actions
107+
idMapBytes, err := json.Marshal(idMap)
108+
if err != nil {
109+
g.logger.Log("json marshal error", err)
110+
return nil, err
111+
}
112+
idMapStr := base64.StdEncoding.EncodeToString(idMapBytes)
113+
item.Args[argID] = idMapStr
114+
if authHeader, ok := action.Header[headerAuthorisation]; ok {
115+
authHeaderB64 := base64.StdEncoding.EncodeToString([]byte(authHeader))
116+
item.Args[argToken] = authHeaderB64
117+
}
118+
if action.ExpiresAt != nil {
119+
item.Args[argExpiresAt] = action.ExpiresAt.String()
120+
}
121+
} else {
122+
// must be an error, but the SSH protocol can't propagate individual errors
123+
g.logger.Log("object not found", obj.Pointer.Oid, obj.Pointer.Size)
124+
item.Present = false
125+
}
126+
case opUpload:
127+
if action, ok := obj.Actions[actionUpload]; ok {
128+
item.Present = false
129+
idMap := obj.Actions
130+
idMapBytes, err := json.Marshal(idMap)
131+
if err != nil {
132+
g.logger.Log("json marshal error", err)
133+
return nil, err
134+
}
135+
idMapStr := base64.StdEncoding.EncodeToString(idMapBytes)
136+
item.Args[argID] = idMapStr
137+
if authHeader, ok := action.Header[headerAuthorisation]; ok {
138+
authHeaderB64 := base64.StdEncoding.EncodeToString([]byte(authHeader))
139+
item.Args[argToken] = authHeaderB64
140+
}
141+
if action.ExpiresAt != nil {
142+
item.Args[argExpiresAt] = action.ExpiresAt.String()
143+
}
144+
} else {
145+
item.Present = true
146+
}
147+
}
148+
pointers = append(pointers, item)
149+
}
150+
return pointers, nil
151+
}
152+
153+
// Download implements transfer.Backend. The returned reader must be closed by the
154+
// caller.
155+
func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, int64, error) {
156+
idMapStr, exists := args[argID]
157+
if !exists {
158+
return nil, 0, ErrMissingID
159+
}
160+
idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr)
161+
if err != nil {
162+
g.logger.Log("base64 decode error", err)
163+
return nil, 0, transfer.ErrCorruptData
164+
}
165+
idMap := map[string]*lfs.Link{}
166+
err = json.Unmarshal(idMapBytes, &idMap)
167+
if err != nil {
168+
g.logger.Log("json unmarshal error", err)
169+
return nil, 0, transfer.ErrCorruptData
170+
}
171+
action, exists := idMap[actionDownload]
172+
if !exists {
173+
g.logger.Log("argument id incorrect")
174+
return nil, 0, transfer.ErrCorruptData
175+
}
176+
url := action.Href
177+
headers := map[string]string{
178+
headerAuthorisation: g.token,
179+
headerAccept: mimeOctetStream,
180+
}
181+
req := newInternalRequest(g.ctx, url, http.MethodGet, headers, nil)
182+
resp, err := req.Response()
183+
if err != nil {
184+
return nil, 0, err
185+
}
186+
if resp.StatusCode != http.StatusOK {
187+
return nil, 0, statusCodeToErr(resp.StatusCode)
188+
}
189+
defer resp.Body.Close()
190+
respBytes, err := io.ReadAll(resp.Body)
191+
if err != nil {
192+
return nil, 0, err
193+
}
194+
respSize := int64(len(respBytes))
195+
respBuf := io.NopCloser(bytes.NewBuffer(respBytes))
196+
return respBuf, respSize, nil
197+
}
198+
199+
// StartUpload implements transfer.Backend.
200+
func (g *GiteaBackend) Upload(oid string, size int64, r io.Reader, args transfer.Args) error {
201+
idMapStr, exists := args[argID]
202+
if !exists {
203+
return ErrMissingID
204+
}
205+
idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr)
206+
if err != nil {
207+
g.logger.Log("base64 decode error", err)
208+
return transfer.ErrCorruptData
209+
}
210+
idMap := map[string]*lfs.Link{}
211+
err = json.Unmarshal(idMapBytes, &idMap)
212+
if err != nil {
213+
g.logger.Log("json unmarshal error", err)
214+
return transfer.ErrCorruptData
215+
}
216+
action, exists := idMap[actionUpload]
217+
if !exists {
218+
g.logger.Log("argument id incorrect")
219+
return transfer.ErrCorruptData
220+
}
221+
url := action.Href
222+
headers := map[string]string{
223+
headerAuthorisation: g.token,
224+
headerContentType: mimeOctetStream,
225+
headerContentLength: strconv.FormatInt(size, 10),
226+
}
227+
reqBytes, err := io.ReadAll(r)
228+
if err != nil {
229+
return err
230+
}
231+
req := newInternalRequest(g.ctx, url, http.MethodPut, headers, reqBytes)
232+
resp, err := req.Response()
233+
if err != nil {
234+
return err
235+
}
236+
if resp.StatusCode != http.StatusOK {
237+
return statusCodeToErr(resp.StatusCode)
238+
}
239+
return nil
240+
}
241+
242+
// Verify implements transfer.Backend.
243+
func (g *GiteaBackend) Verify(oid string, size int64, args transfer.Args) (transfer.Status, error) {
244+
reqBody := lfs.Pointer{Oid: oid, Size: size}
245+
246+
bodyBytes, err := json.Marshal(reqBody)
247+
if err != nil {
248+
return transfer.NewStatus(transfer.StatusInternalServerError), err
249+
}
250+
idMapStr, exists := args[argID]
251+
if !exists {
252+
return transfer.NewStatus(transfer.StatusBadRequest, "missing argument: id"), ErrMissingID
253+
}
254+
idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr)
255+
if err != nil {
256+
g.logger.Log("base64 decode error", err)
257+
return transfer.NewStatus(transfer.StatusBadRequest, "corrupt argument: id"), transfer.ErrCorruptData
258+
}
259+
idMap := map[string]*lfs.Link{}
260+
err = json.Unmarshal(idMapBytes, &idMap)
261+
if err != nil {
262+
g.logger.Log("json unmarshal error", err)
263+
return transfer.NewStatus(transfer.StatusBadRequest, "corrupt argument: id"), transfer.ErrCorruptData
264+
}
265+
action, exists := idMap[actionVerify]
266+
if !exists {
267+
// the server sent no verify action
268+
return transfer.SuccessStatus(), nil
269+
}
270+
url := action.Href
271+
headers := map[string]string{
272+
headerAuthorisation: g.token,
273+
headerAccept: mimeGitLFS,
274+
headerContentType: mimeGitLFS,
275+
}
276+
req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
277+
resp, err := req.Response()
278+
if err != nil {
279+
return transfer.NewStatus(transfer.StatusInternalServerError), err
280+
}
281+
if resp.StatusCode != http.StatusOK {
282+
return transfer.NewStatus(uint32(resp.StatusCode), http.StatusText(resp.StatusCode)), statusCodeToErr(resp.StatusCode)
283+
}
284+
return transfer.SuccessStatus(), nil
285+
}
286+
287+
// LockBackend implements transfer.Backend.
288+
func (g *GiteaBackend) LockBackend(_ transfer.Args) transfer.LockBackend {
289+
// Gitea doesn't support the locking API
290+
// this should never be called as we don't advertise the capability
291+
panic(fmt.Errorf("backend doesn't implement locking"))
292+
return (transfer.LockBackend)(nil)
293+
}

0 commit comments

Comments
 (0)