##// END OF EJS Templates
lfs: add server side support for the Batch API
Matt Harbison -
r37166:ea6fc585 default
parent child Browse files
Show More
@@ -1,75 +1,246
1 # wireprotolfsserver.py - lfs protocol server side implementation
1 # wireprotolfsserver.py - lfs protocol server side implementation
2 #
2 #
3 # Copyright 2018 Matt Harbison <matt_harbison@yahoo.com>
3 # Copyright 2018 Matt Harbison <matt_harbison@yahoo.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 from __future__ import absolute_import
8 from __future__ import absolute_import
9
9
10 import datetime
11 import errno
12 import json
13
10 from mercurial.hgweb import (
14 from mercurial.hgweb import (
11 common as hgwebcommon,
15 common as hgwebcommon,
12 )
16 )
13
17
14 from mercurial import (
18 from mercurial import (
15 pycompat,
19 pycompat,
16 )
20 )
17
21
22 HTTP_OK = hgwebcommon.HTTP_OK
23 HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST
24
18 def handlewsgirequest(orig, rctx, req, res, checkperm):
25 def handlewsgirequest(orig, rctx, req, res, checkperm):
19 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
26 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
20 request if it is left unprocessed by the wrapped method.
27 request if it is left unprocessed by the wrapped method.
21 """
28 """
22 if orig(rctx, req, res, checkperm):
29 if orig(rctx, req, res, checkperm):
23 return True
30 return True
24
31
25 if not req.dispatchpath:
32 if not req.dispatchpath:
26 return False
33 return False
27
34
28 try:
35 try:
29 if req.dispatchpath == b'.git/info/lfs/objects/batch':
36 if req.dispatchpath == b'.git/info/lfs/objects/batch':
30 checkperm(rctx, req, 'pull')
37 checkperm(rctx, req, 'pull')
31 return _processbatchrequest(rctx.repo, req, res)
38 return _processbatchrequest(rctx.repo, req, res)
32 # TODO: reserve and use a path in the proposed http wireprotocol /api/
39 # TODO: reserve and use a path in the proposed http wireprotocol /api/
33 # namespace?
40 # namespace?
34 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
41 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
35 return _processbasictransfer(rctx.repo, req, res,
42 return _processbasictransfer(rctx.repo, req, res,
36 lambda perm:
43 lambda perm:
37 checkperm(rctx, req, perm))
44 checkperm(rctx, req, perm))
38 return False
45 return False
39 except hgwebcommon.ErrorResponse as e:
46 except hgwebcommon.ErrorResponse as e:
40 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
47 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
41 # in the wrapped function. Should this be moved back to hgweb to
48 # in the wrapped function. Should this be moved back to hgweb to
42 # be a common handler?
49 # be a common handler?
43 for k, v in e.headers:
50 for k, v in e.headers:
44 res.headers[k] = v
51 res.headers[k] = v
45 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
52 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
46 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
53 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
47 return True
54 return True
48
55
56 def _sethttperror(res, code, message=None):
57 res.status = hgwebcommon.statusmessage(code, message=message)
58 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
59 res.setbodybytes(b'')
60
49 def _processbatchrequest(repo, req, res):
61 def _processbatchrequest(repo, req, res):
50 """Handle a request for the Batch API, which is the gateway to granting file
62 """Handle a request for the Batch API, which is the gateway to granting file
51 access.
63 access.
52
64
53 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
65 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
54 """
66 """
55 return False
67
68 # Mercurial client request:
69 #
70 # HOST: localhost:$HGPORT
71 # ACCEPT: application/vnd.git-lfs+json
72 # ACCEPT-ENCODING: identity
73 # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316)
74 # Content-Length: 125
75 # Content-Type: application/vnd.git-lfs+json
76 #
77 # {
78 # "objects": [
79 # {
80 # "oid": "31cf...8e5b"
81 # "size": 12
82 # }
83 # ]
84 # "operation": "upload"
85 # }
86
87 if (req.method != b'POST'
88 or req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json'
89 or req.headers[b'Accept'] != b'application/vnd.git-lfs+json'):
90 # TODO: figure out what the proper handling for a bad request to the
91 # Batch API is.
92 _sethttperror(res, HTTP_BAD_REQUEST, b'Invalid Batch API request')
93 return True
94
95 # XXX: specify an encoding?
96 lfsreq = json.loads(req.bodyfh.read())
97
98 # If no transfer handlers are explicitly requested, 'basic' is assumed.
99 if 'basic' not in lfsreq.get('transfers', ['basic']):
100 _sethttperror(res, HTTP_BAD_REQUEST,
101 b'Only the basic LFS transfer handler is supported')
102 return True
103
104 operation = lfsreq.get('operation')
105 if operation not in ('upload', 'download'):
106 _sethttperror(res, HTTP_BAD_REQUEST,
107 b'Unsupported LFS transfer operation: %s' % operation)
108 return True
109
110 localstore = repo.svfs.lfslocalblobstore
111
112 objects = [p for p in _batchresponseobjects(req, lfsreq.get('objects', []),
113 operation, localstore)]
114
115 rsp = {
116 'transfer': 'basic',
117 'objects': objects,
118 }
119
120 res.status = hgwebcommon.statusmessage(HTTP_OK)
121 res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json'
122 res.setbodybytes(pycompat.bytestr(json.dumps(rsp)))
123
124 return True
125
126 def _batchresponseobjects(req, objects, action, store):
127 """Yield one dictionary of attributes for the Batch API response for each
128 object in the list.
129
130 req: The parsedrequest for the Batch API request
131 objects: The list of objects in the Batch API object request list
132 action: 'upload' or 'download'
133 store: The local blob store for servicing requests"""
134
135 # Successful lfs-test-server response to solict an upload:
136 # {
137 # u'objects': [{
138 # u'size': 12,
139 # u'oid': u'31cf...8e5b',
140 # u'actions': {
141 # u'upload': {
142 # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b',
143 # u'expires_at': u'0001-01-01T00:00:00Z',
144 # u'header': {
145 # u'Accept': u'application/vnd.git-lfs'
146 # }
147 # }
148 # }
149 # }]
150 # }
151
152 # TODO: Sort out the expires_at/expires_in/authenticated keys.
153
154 for obj in objects:
155 # Convert unicode to ASCII to create a filesystem path
156 oid = obj.get('oid').encode('ascii')
157 rsp = {
158 'oid': oid,
159 'size': obj.get('size'), # XXX: should this check the local size?
160 #'authenticated': True,
161 }
162
163 exists = True
164 verifies = False
165
166 # Verify an existing file on the upload request, so that the client is
167 # solicited to re-upload if it corrupt locally. Download requests are
168 # also verified, so the error can be flagged in the Batch API response.
169 # (Maybe we can use this to short circuit the download for `hg verify`,
170 # IFF the client can assert that the remote end is an hg server.)
171 # Otherwise, it's potentially overkill on download, since it is also
172 # verified as the file is streamed to the caller.
173 try:
174 verifies = store.verify(oid)
175 except IOError as inst:
176 if inst.errno != errno.ENOENT:
177 rsp['error'] = {
178 'code': 500,
179 'message': inst.strerror or 'Internal Server Server'
180 }
181 yield rsp
182 continue
183
184 exists = False
185
186 # Items are always listed for downloads. They are dropped for uploads
187 # IFF they already exist locally.
188 if action == 'download':
189 if not exists:
190 rsp['error'] = {
191 'code': 404,
192 'message': "The object does not exist"
193 }
194 yield rsp
195 continue
196
197 elif not verifies:
198 rsp['error'] = {
199 'code': 422, # XXX: is this the right code?
200 'message': "The object is corrupt"
201 }
202 yield rsp
203 continue
204
205 elif verifies:
206 yield rsp # Skip 'actions': already uploaded
207 continue
208
209 expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10)
210
211 rsp['actions'] = {
212 '%s' % action: {
213 # TODO: Account for the --prefix, if any.
214 'href': '%s/.hg/lfs/objects/%s' % (req.baseurl, oid),
215 # datetime.isoformat() doesn't include the 'Z' suffix
216 "expires_at": expiresat.strftime('%Y-%m-%dT%H:%M:%SZ'),
217 'header': {
218 # The spec doesn't mention the Accept header here, but avoid
219 # a gratuitous deviation from lfs-test-server in the test
220 # output.
221 'Accept': 'application/vnd.git-lfs'
222 }
223 }
224 }
225
226 yield rsp
56
227
57 def _processbasictransfer(repo, req, res, checkperm):
228 def _processbasictransfer(repo, req, res, checkperm):
58 """Handle a single file upload (PUT) or download (GET) action for the Basic
229 """Handle a single file upload (PUT) or download (GET) action for the Basic
59 Transfer Adapter.
230 Transfer Adapter.
60
231
61 After determining if the request is for an upload or download, the access
232 After determining if the request is for an upload or download, the access
62 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
233 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
63 before accessing the files.
234 before accessing the files.
64
235
65 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
236 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
66 """
237 """
67
238
68 method = req.method
239 method = req.method
69
240
70 if method == b'PUT':
241 if method == b'PUT':
71 checkperm('upload')
242 checkperm('upload')
72 elif method == b'GET':
243 elif method == b'GET':
73 checkperm('pull')
244 checkperm('pull')
74
245
75 return False
246 return False
General Comments 0
You need to be logged in to leave comments. Login now