Skip to content

Commit b0bde5f

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 b0bde5f

File tree

5 files changed

+507
-1
lines changed

5 files changed

+507
-1
lines changed

cmd/serv.go

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

0 commit comments

Comments
 (0)