##// END OF EJS Templates
lfs: update the HTTP status codes in error cases...
Matt Harbison -
r37711:31a0d47d default
parent child Browse files
Show More
@@ -1,320 +1,327 b''
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
10 import datetime
11 import errno
11 import errno
12 import json
12 import json
13 import traceback
13 import traceback
14
14
15 from mercurial.hgweb import (
15 from mercurial.hgweb import (
16 common as hgwebcommon,
16 common as hgwebcommon,
17 )
17 )
18
18
19 from mercurial import (
19 from mercurial import (
20 pycompat,
20 pycompat,
21 )
21 )
22
22
23 from . import blobstore
23 from . import blobstore
24
24
25 HTTP_OK = hgwebcommon.HTTP_OK
25 HTTP_OK = hgwebcommon.HTTP_OK
26 HTTP_CREATED = hgwebcommon.HTTP_CREATED
26 HTTP_CREATED = hgwebcommon.HTTP_CREATED
27 HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST
27 HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST
28 HTTP_NOT_FOUND = hgwebcommon.HTTP_NOT_FOUND
28 HTTP_NOT_FOUND = hgwebcommon.HTTP_NOT_FOUND
29 HTTP_METHOD_NOT_ALLOWED = hgwebcommon.HTTP_METHOD_NOT_ALLOWED
30 HTTP_NOT_ACCEPTABLE = hgwebcommon.HTTP_NOT_ACCEPTABLE
31 HTTP_UNSUPPORTED_MEDIA_TYPE = hgwebcommon.HTTP_UNSUPPORTED_MEDIA_TYPE
29
32
30 def handlewsgirequest(orig, rctx, req, res, checkperm):
33 def handlewsgirequest(orig, rctx, req, res, checkperm):
31 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
34 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
32 request if it is left unprocessed by the wrapped method.
35 request if it is left unprocessed by the wrapped method.
33 """
36 """
34 if orig(rctx, req, res, checkperm):
37 if orig(rctx, req, res, checkperm):
35 return True
38 return True
36
39
37 if not rctx.repo.ui.configbool('experimental', 'lfs.serve'):
40 if not rctx.repo.ui.configbool('experimental', 'lfs.serve'):
38 return False
41 return False
39
42
40 if not req.dispatchpath:
43 if not req.dispatchpath:
41 return False
44 return False
42
45
43 try:
46 try:
44 if req.dispatchpath == b'.git/info/lfs/objects/batch':
47 if req.dispatchpath == b'.git/info/lfs/objects/batch':
45 checkperm(rctx, req, 'pull')
48 checkperm(rctx, req, 'pull')
46 return _processbatchrequest(rctx.repo, req, res)
49 return _processbatchrequest(rctx.repo, req, res)
47 # TODO: reserve and use a path in the proposed http wireprotocol /api/
50 # TODO: reserve and use a path in the proposed http wireprotocol /api/
48 # namespace?
51 # namespace?
49 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
52 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
50 return _processbasictransfer(rctx.repo, req, res,
53 return _processbasictransfer(rctx.repo, req, res,
51 lambda perm:
54 lambda perm:
52 checkperm(rctx, req, perm))
55 checkperm(rctx, req, perm))
53 return False
56 return False
54 except hgwebcommon.ErrorResponse as e:
57 except hgwebcommon.ErrorResponse as e:
55 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
58 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
56 # in the wrapped function. Should this be moved back to hgweb to
59 # in the wrapped function. Should this be moved back to hgweb to
57 # be a common handler?
60 # be a common handler?
58 for k, v in e.headers:
61 for k, v in e.headers:
59 res.headers[k] = v
62 res.headers[k] = v
60 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
63 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
61 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
64 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
62 return True
65 return True
63
66
64 def _sethttperror(res, code, message=None):
67 def _sethttperror(res, code, message=None):
65 res.status = hgwebcommon.statusmessage(code, message=message)
68 res.status = hgwebcommon.statusmessage(code, message=message)
66 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
69 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
67 res.setbodybytes(b'')
70 res.setbodybytes(b'')
68
71
69 def _logexception(req):
72 def _logexception(req):
70 """Write information about the current exception to wsgi.errors."""
73 """Write information about the current exception to wsgi.errors."""
71 tb = pycompat.sysbytes(traceback.format_exc())
74 tb = pycompat.sysbytes(traceback.format_exc())
72 errorlog = req.rawenv[r'wsgi.errors']
75 errorlog = req.rawenv[r'wsgi.errors']
73
76
74 uri = b''
77 uri = b''
75 if req.apppath:
78 if req.apppath:
76 uri += req.apppath
79 uri += req.apppath
77 uri += b'/' + req.dispatchpath
80 uri += b'/' + req.dispatchpath
78
81
79 errorlog.write(b"Exception happened while processing request '%s':\n%s" %
82 errorlog.write(b"Exception happened while processing request '%s':\n%s" %
80 (uri, tb))
83 (uri, tb))
81
84
82 def _processbatchrequest(repo, req, res):
85 def _processbatchrequest(repo, req, res):
83 """Handle a request for the Batch API, which is the gateway to granting file
86 """Handle a request for the Batch API, which is the gateway to granting file
84 access.
87 access.
85
88
86 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
89 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
87 """
90 """
88
91
89 # Mercurial client request:
92 # Mercurial client request:
90 #
93 #
91 # HOST: localhost:$HGPORT
94 # HOST: localhost:$HGPORT
92 # ACCEPT: application/vnd.git-lfs+json
95 # ACCEPT: application/vnd.git-lfs+json
93 # ACCEPT-ENCODING: identity
96 # ACCEPT-ENCODING: identity
94 # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316)
97 # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316)
95 # Content-Length: 125
98 # Content-Length: 125
96 # Content-Type: application/vnd.git-lfs+json
99 # Content-Type: application/vnd.git-lfs+json
97 #
100 #
98 # {
101 # {
99 # "objects": [
102 # "objects": [
100 # {
103 # {
101 # "oid": "31cf...8e5b"
104 # "oid": "31cf...8e5b"
102 # "size": 12
105 # "size": 12
103 # }
106 # }
104 # ]
107 # ]
105 # "operation": "upload"
108 # "operation": "upload"
106 # }
109 # }
107
110
108 if (req.method != b'POST'
111 if req.method != b'POST':
109 or req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json'
112 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED)
110 or req.headers[b'Accept'] != b'application/vnd.git-lfs+json'):
113 return True
111 # TODO: figure out what the proper handling for a bad request to the
114
112 # Batch API is.
115 if req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json':
113 _sethttperror(res, HTTP_BAD_REQUEST, b'Invalid Batch API request')
116 _sethttperror(res, HTTP_UNSUPPORTED_MEDIA_TYPE)
117 return True
118
119 if req.headers[b'Accept'] != b'application/vnd.git-lfs+json':
120 _sethttperror(res, HTTP_NOT_ACCEPTABLE)
114 return True
121 return True
115
122
116 # XXX: specify an encoding?
123 # XXX: specify an encoding?
117 lfsreq = json.loads(req.bodyfh.read())
124 lfsreq = json.loads(req.bodyfh.read())
118
125
119 # If no transfer handlers are explicitly requested, 'basic' is assumed.
126 # If no transfer handlers are explicitly requested, 'basic' is assumed.
120 if 'basic' not in lfsreq.get('transfers', ['basic']):
127 if 'basic' not in lfsreq.get('transfers', ['basic']):
121 _sethttperror(res, HTTP_BAD_REQUEST,
128 _sethttperror(res, HTTP_BAD_REQUEST,
122 b'Only the basic LFS transfer handler is supported')
129 b'Only the basic LFS transfer handler is supported')
123 return True
130 return True
124
131
125 operation = lfsreq.get('operation')
132 operation = lfsreq.get('operation')
126 if operation not in ('upload', 'download'):
133 if operation not in ('upload', 'download'):
127 _sethttperror(res, HTTP_BAD_REQUEST,
134 _sethttperror(res, HTTP_BAD_REQUEST,
128 b'Unsupported LFS transfer operation: %s' % operation)
135 b'Unsupported LFS transfer operation: %s' % operation)
129 return True
136 return True
130
137
131 localstore = repo.svfs.lfslocalblobstore
138 localstore = repo.svfs.lfslocalblobstore
132
139
133 objects = [p for p in _batchresponseobjects(req, lfsreq.get('objects', []),
140 objects = [p for p in _batchresponseobjects(req, lfsreq.get('objects', []),
134 operation, localstore)]
141 operation, localstore)]
135
142
136 rsp = {
143 rsp = {
137 'transfer': 'basic',
144 'transfer': 'basic',
138 'objects': objects,
145 'objects': objects,
139 }
146 }
140
147
141 res.status = hgwebcommon.statusmessage(HTTP_OK)
148 res.status = hgwebcommon.statusmessage(HTTP_OK)
142 res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json'
149 res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json'
143 res.setbodybytes(pycompat.bytestr(json.dumps(rsp)))
150 res.setbodybytes(pycompat.bytestr(json.dumps(rsp)))
144
151
145 return True
152 return True
146
153
147 def _batchresponseobjects(req, objects, action, store):
154 def _batchresponseobjects(req, objects, action, store):
148 """Yield one dictionary of attributes for the Batch API response for each
155 """Yield one dictionary of attributes for the Batch API response for each
149 object in the list.
156 object in the list.
150
157
151 req: The parsedrequest for the Batch API request
158 req: The parsedrequest for the Batch API request
152 objects: The list of objects in the Batch API object request list
159 objects: The list of objects in the Batch API object request list
153 action: 'upload' or 'download'
160 action: 'upload' or 'download'
154 store: The local blob store for servicing requests"""
161 store: The local blob store for servicing requests"""
155
162
156 # Successful lfs-test-server response to solict an upload:
163 # Successful lfs-test-server response to solict an upload:
157 # {
164 # {
158 # u'objects': [{
165 # u'objects': [{
159 # u'size': 12,
166 # u'size': 12,
160 # u'oid': u'31cf...8e5b',
167 # u'oid': u'31cf...8e5b',
161 # u'actions': {
168 # u'actions': {
162 # u'upload': {
169 # u'upload': {
163 # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b',
170 # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b',
164 # u'expires_at': u'0001-01-01T00:00:00Z',
171 # u'expires_at': u'0001-01-01T00:00:00Z',
165 # u'header': {
172 # u'header': {
166 # u'Accept': u'application/vnd.git-lfs'
173 # u'Accept': u'application/vnd.git-lfs'
167 # }
174 # }
168 # }
175 # }
169 # }
176 # }
170 # }]
177 # }]
171 # }
178 # }
172
179
173 # TODO: Sort out the expires_at/expires_in/authenticated keys.
180 # TODO: Sort out the expires_at/expires_in/authenticated keys.
174
181
175 for obj in objects:
182 for obj in objects:
176 # Convert unicode to ASCII to create a filesystem path
183 # Convert unicode to ASCII to create a filesystem path
177 oid = obj.get('oid').encode('ascii')
184 oid = obj.get('oid').encode('ascii')
178 rsp = {
185 rsp = {
179 'oid': oid,
186 'oid': oid,
180 'size': obj.get('size'), # XXX: should this check the local size?
187 'size': obj.get('size'), # XXX: should this check the local size?
181 #'authenticated': True,
188 #'authenticated': True,
182 }
189 }
183
190
184 exists = True
191 exists = True
185 verifies = False
192 verifies = False
186
193
187 # Verify an existing file on the upload request, so that the client is
194 # Verify an existing file on the upload request, so that the client is
188 # solicited to re-upload if it corrupt locally. Download requests are
195 # solicited to re-upload if it corrupt locally. Download requests are
189 # also verified, so the error can be flagged in the Batch API response.
196 # also verified, so the error can be flagged in the Batch API response.
190 # (Maybe we can use this to short circuit the download for `hg verify`,
197 # (Maybe we can use this to short circuit the download for `hg verify`,
191 # IFF the client can assert that the remote end is an hg server.)
198 # IFF the client can assert that the remote end is an hg server.)
192 # Otherwise, it's potentially overkill on download, since it is also
199 # Otherwise, it's potentially overkill on download, since it is also
193 # verified as the file is streamed to the caller.
200 # verified as the file is streamed to the caller.
194 try:
201 try:
195 verifies = store.verify(oid)
202 verifies = store.verify(oid)
196 except IOError as inst:
203 except IOError as inst:
197 if inst.errno != errno.ENOENT:
204 if inst.errno != errno.ENOENT:
198 _logexception(req)
205 _logexception(req)
199
206
200 rsp['error'] = {
207 rsp['error'] = {
201 'code': 500,
208 'code': 500,
202 'message': inst.strerror or 'Internal Server Server'
209 'message': inst.strerror or 'Internal Server Server'
203 }
210 }
204 yield rsp
211 yield rsp
205 continue
212 continue
206
213
207 exists = False
214 exists = False
208
215
209 # Items are always listed for downloads. They are dropped for uploads
216 # Items are always listed for downloads. They are dropped for uploads
210 # IFF they already exist locally.
217 # IFF they already exist locally.
211 if action == 'download':
218 if action == 'download':
212 if not exists:
219 if not exists:
213 rsp['error'] = {
220 rsp['error'] = {
214 'code': 404,
221 'code': 404,
215 'message': "The object does not exist"
222 'message': "The object does not exist"
216 }
223 }
217 yield rsp
224 yield rsp
218 continue
225 continue
219
226
220 elif not verifies:
227 elif not verifies:
221 rsp['error'] = {
228 rsp['error'] = {
222 'code': 422, # XXX: is this the right code?
229 'code': 422, # XXX: is this the right code?
223 'message': "The object is corrupt"
230 'message': "The object is corrupt"
224 }
231 }
225 yield rsp
232 yield rsp
226 continue
233 continue
227
234
228 elif verifies:
235 elif verifies:
229 yield rsp # Skip 'actions': already uploaded
236 yield rsp # Skip 'actions': already uploaded
230 continue
237 continue
231
238
232 expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10)
239 expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10)
233
240
234 rsp['actions'] = {
241 rsp['actions'] = {
235 '%s' % action: {
242 '%s' % action: {
236 'href': '%s%s/.hg/lfs/objects/%s'
243 'href': '%s%s/.hg/lfs/objects/%s'
237 % (req.baseurl, req.apppath, oid),
244 % (req.baseurl, req.apppath, oid),
238 # datetime.isoformat() doesn't include the 'Z' suffix
245 # datetime.isoformat() doesn't include the 'Z' suffix
239 "expires_at": expiresat.strftime('%Y-%m-%dT%H:%M:%SZ'),
246 "expires_at": expiresat.strftime('%Y-%m-%dT%H:%M:%SZ'),
240 'header': {
247 'header': {
241 # The spec doesn't mention the Accept header here, but avoid
248 # The spec doesn't mention the Accept header here, but avoid
242 # a gratuitous deviation from lfs-test-server in the test
249 # a gratuitous deviation from lfs-test-server in the test
243 # output.
250 # output.
244 'Accept': 'application/vnd.git-lfs'
251 'Accept': 'application/vnd.git-lfs'
245 }
252 }
246 }
253 }
247 }
254 }
248
255
249 yield rsp
256 yield rsp
250
257
251 def _processbasictransfer(repo, req, res, checkperm):
258 def _processbasictransfer(repo, req, res, checkperm):
252 """Handle a single file upload (PUT) or download (GET) action for the Basic
259 """Handle a single file upload (PUT) or download (GET) action for the Basic
253 Transfer Adapter.
260 Transfer Adapter.
254
261
255 After determining if the request is for an upload or download, the access
262 After determining if the request is for an upload or download, the access
256 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
263 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
257 before accessing the files.
264 before accessing the files.
258
265
259 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
266 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
260 """
267 """
261
268
262 method = req.method
269 method = req.method
263 oid = req.dispatchparts[-1]
270 oid = req.dispatchparts[-1]
264 localstore = repo.svfs.lfslocalblobstore
271 localstore = repo.svfs.lfslocalblobstore
265
272
266 if len(req.dispatchparts) != 4:
273 if len(req.dispatchparts) != 4:
267 _sethttperror(res, HTTP_NOT_FOUND)
274 _sethttperror(res, HTTP_NOT_FOUND)
268 return True
275 return True
269
276
270 if method == b'PUT':
277 if method == b'PUT':
271 checkperm('upload')
278 checkperm('upload')
272
279
273 # TODO: verify Content-Type?
280 # TODO: verify Content-Type?
274
281
275 existed = localstore.has(oid)
282 existed = localstore.has(oid)
276
283
277 # TODO: how to handle timeouts? The body proxy handles limiting to
284 # TODO: how to handle timeouts? The body proxy handles limiting to
278 # Content-Length, but what happens if a client sends less than it
285 # Content-Length, but what happens if a client sends less than it
279 # says it will?
286 # says it will?
280
287
281 statusmessage = hgwebcommon.statusmessage
288 statusmessage = hgwebcommon.statusmessage
282 try:
289 try:
283 localstore.download(oid, req.bodyfh)
290 localstore.download(oid, req.bodyfh)
284 res.status = statusmessage(HTTP_OK if existed else HTTP_CREATED)
291 res.status = statusmessage(HTTP_OK if existed else HTTP_CREATED)
285 except blobstore.LfsCorruptionError:
292 except blobstore.LfsCorruptionError:
286 _logexception(req)
293 _logexception(req)
287
294
288 # XXX: Is this the right code?
295 # XXX: Is this the right code?
289 res.status = statusmessage(422, b'corrupt blob')
296 res.status = statusmessage(422, b'corrupt blob')
290
297
291 # There's no payload here, but this is the header that lfs-test-server
298 # There's no payload here, but this is the header that lfs-test-server
292 # sends back. This eliminates some gratuitous test output conditionals.
299 # sends back. This eliminates some gratuitous test output conditionals.
293 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
300 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
294 res.setbodybytes(b'')
301 res.setbodybytes(b'')
295
302
296 return True
303 return True
297 elif method == b'GET':
304 elif method == b'GET':
298 checkperm('pull')
305 checkperm('pull')
299
306
300 res.status = hgwebcommon.statusmessage(HTTP_OK)
307 res.status = hgwebcommon.statusmessage(HTTP_OK)
301 res.headers[b'Content-Type'] = b'application/octet-stream'
308 res.headers[b'Content-Type'] = b'application/octet-stream'
302
309
303 try:
310 try:
304 # TODO: figure out how to send back the file in chunks, instead of
311 # TODO: figure out how to send back the file in chunks, instead of
305 # reading the whole thing. (Also figure out how to send back
312 # reading the whole thing. (Also figure out how to send back
306 # an error status if an IOError occurs after a partial write
313 # an error status if an IOError occurs after a partial write
307 # in that case. Here, everything is read before starting.)
314 # in that case. Here, everything is read before starting.)
308 res.setbodybytes(localstore.read(oid))
315 res.setbodybytes(localstore.read(oid))
309 except blobstore.LfsCorruptionError:
316 except blobstore.LfsCorruptionError:
310 _logexception(req)
317 _logexception(req)
311
318
312 # XXX: Is this the right code?
319 # XXX: Is this the right code?
313 res.status = hgwebcommon.statusmessage(422, b'corrupt blob')
320 res.status = hgwebcommon.statusmessage(422, b'corrupt blob')
314 res.setbodybytes(b'')
321 res.setbodybytes(b'')
315
322
316 return True
323 return True
317 else:
324 else:
318 _sethttperror(res, HTTP_BAD_REQUEST,
325 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED,
319 message=b'Unsupported LFS transfer method: %s' % method)
326 message=b'Unsupported LFS transfer method: %s' % method)
320 return True
327 return True
@@ -1,258 +1,260 b''
1 # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod
1 # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from __future__ import absolute_import
9 from __future__ import absolute_import
10
10
11 import base64
11 import base64
12 import errno
12 import errno
13 import mimetypes
13 import mimetypes
14 import os
14 import os
15 import stat
15 import stat
16
16
17 from .. import (
17 from .. import (
18 encoding,
18 encoding,
19 pycompat,
19 pycompat,
20 util,
20 util,
21 )
21 )
22
22
23 httpserver = util.httpserver
23 httpserver = util.httpserver
24
24
25 HTTP_OK = 200
25 HTTP_OK = 200
26 HTTP_CREATED = 201
26 HTTP_CREATED = 201
27 HTTP_NOT_MODIFIED = 304
27 HTTP_NOT_MODIFIED = 304
28 HTTP_BAD_REQUEST = 400
28 HTTP_BAD_REQUEST = 400
29 HTTP_UNAUTHORIZED = 401
29 HTTP_UNAUTHORIZED = 401
30 HTTP_FORBIDDEN = 403
30 HTTP_FORBIDDEN = 403
31 HTTP_NOT_FOUND = 404
31 HTTP_NOT_FOUND = 404
32 HTTP_METHOD_NOT_ALLOWED = 405
32 HTTP_METHOD_NOT_ALLOWED = 405
33 HTTP_NOT_ACCEPTABLE = 406
34 HTTP_UNSUPPORTED_MEDIA_TYPE = 415
33 HTTP_SERVER_ERROR = 500
35 HTTP_SERVER_ERROR = 500
34
36
35
37
36 def ismember(ui, username, userlist):
38 def ismember(ui, username, userlist):
37 """Check if username is a member of userlist.
39 """Check if username is a member of userlist.
38
40
39 If userlist has a single '*' member, all users are considered members.
41 If userlist has a single '*' member, all users are considered members.
40 Can be overridden by extensions to provide more complex authorization
42 Can be overridden by extensions to provide more complex authorization
41 schemes.
43 schemes.
42 """
44 """
43 return userlist == ['*'] or username in userlist
45 return userlist == ['*'] or username in userlist
44
46
45 def checkauthz(hgweb, req, op):
47 def checkauthz(hgweb, req, op):
46 '''Check permission for operation based on request data (including
48 '''Check permission for operation based on request data (including
47 authentication info). Return if op allowed, else raise an ErrorResponse
49 authentication info). Return if op allowed, else raise an ErrorResponse
48 exception.'''
50 exception.'''
49
51
50 user = req.remoteuser
52 user = req.remoteuser
51
53
52 deny_read = hgweb.configlist('web', 'deny_read')
54 deny_read = hgweb.configlist('web', 'deny_read')
53 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
55 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
54 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
56 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
55
57
56 allow_read = hgweb.configlist('web', 'allow_read')
58 allow_read = hgweb.configlist('web', 'allow_read')
57 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
59 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
58 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
59
61
60 if op == 'pull' and not hgweb.allowpull:
62 if op == 'pull' and not hgweb.allowpull:
61 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
63 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
62 elif op == 'pull' or op is None: # op is None for interface requests
64 elif op == 'pull' or op is None: # op is None for interface requests
63 return
65 return
64
66
65 # Allow LFS uploading via PUT requests
67 # Allow LFS uploading via PUT requests
66 if op == 'upload':
68 if op == 'upload':
67 if req.method != 'PUT':
69 if req.method != 'PUT':
68 msg = 'upload requires PUT request'
70 msg = 'upload requires PUT request'
69 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
71 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
70 # enforce that you can only push using POST requests
72 # enforce that you can only push using POST requests
71 elif req.method != 'POST':
73 elif req.method != 'POST':
72 msg = 'push requires POST request'
74 msg = 'push requires POST request'
73 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
75 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
74
76
75 # require ssl by default for pushing, auth info cannot be sniffed
77 # require ssl by default for pushing, auth info cannot be sniffed
76 # and replayed
78 # and replayed
77 if hgweb.configbool('web', 'push_ssl') and req.urlscheme != 'https':
79 if hgweb.configbool('web', 'push_ssl') and req.urlscheme != 'https':
78 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
80 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
79
81
80 deny = hgweb.configlist('web', 'deny_push')
82 deny = hgweb.configlist('web', 'deny_push')
81 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
83 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
82 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
84 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
83
85
84 allow = hgweb.configlist('web', 'allow-push')
86 allow = hgweb.configlist('web', 'allow-push')
85 if not (allow and ismember(hgweb.repo.ui, user, allow)):
87 if not (allow and ismember(hgweb.repo.ui, user, allow)):
86 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
88 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
87
89
88 # Hooks for hgweb permission checks; extensions can add hooks here.
90 # Hooks for hgweb permission checks; extensions can add hooks here.
89 # Each hook is invoked like this: hook(hgweb, request, operation),
91 # Each hook is invoked like this: hook(hgweb, request, operation),
90 # where operation is either read, pull, push or upload. Hooks should either
92 # where operation is either read, pull, push or upload. Hooks should either
91 # raise an ErrorResponse exception, or just return.
93 # raise an ErrorResponse exception, or just return.
92 #
94 #
93 # It is possible to do both authentication and authorization through
95 # It is possible to do both authentication and authorization through
94 # this.
96 # this.
95 permhooks = [checkauthz]
97 permhooks = [checkauthz]
96
98
97
99
98 class ErrorResponse(Exception):
100 class ErrorResponse(Exception):
99 def __init__(self, code, message=None, headers=None):
101 def __init__(self, code, message=None, headers=None):
100 if message is None:
102 if message is None:
101 message = _statusmessage(code)
103 message = _statusmessage(code)
102 Exception.__init__(self, pycompat.sysstr(message))
104 Exception.__init__(self, pycompat.sysstr(message))
103 self.code = code
105 self.code = code
104 if headers is None:
106 if headers is None:
105 headers = []
107 headers = []
106 self.headers = headers
108 self.headers = headers
107
109
108 class continuereader(object):
110 class continuereader(object):
109 """File object wrapper to handle HTTP 100-continue.
111 """File object wrapper to handle HTTP 100-continue.
110
112
111 This is used by servers so they automatically handle Expect: 100-continue
113 This is used by servers so they automatically handle Expect: 100-continue
112 request headers. On first read of the request body, the 100 Continue
114 request headers. On first read of the request body, the 100 Continue
113 response is sent. This should trigger the client into actually sending
115 response is sent. This should trigger the client into actually sending
114 the request body.
116 the request body.
115 """
117 """
116 def __init__(self, f, write):
118 def __init__(self, f, write):
117 self.f = f
119 self.f = f
118 self._write = write
120 self._write = write
119 self.continued = False
121 self.continued = False
120
122
121 def read(self, amt=-1):
123 def read(self, amt=-1):
122 if not self.continued:
124 if not self.continued:
123 self.continued = True
125 self.continued = True
124 self._write('HTTP/1.1 100 Continue\r\n\r\n')
126 self._write('HTTP/1.1 100 Continue\r\n\r\n')
125 return self.f.read(amt)
127 return self.f.read(amt)
126
128
127 def __getattr__(self, attr):
129 def __getattr__(self, attr):
128 if attr in ('close', 'readline', 'readlines', '__iter__'):
130 if attr in ('close', 'readline', 'readlines', '__iter__'):
129 return getattr(self.f, attr)
131 return getattr(self.f, attr)
130 raise AttributeError
132 raise AttributeError
131
133
132 def _statusmessage(code):
134 def _statusmessage(code):
133 responses = httpserver.basehttprequesthandler.responses
135 responses = httpserver.basehttprequesthandler.responses
134 return responses.get(code, ('Error', 'Unknown error'))[0]
136 return responses.get(code, ('Error', 'Unknown error'))[0]
135
137
136 def statusmessage(code, message=None):
138 def statusmessage(code, message=None):
137 return '%d %s' % (code, message or _statusmessage(code))
139 return '%d %s' % (code, message or _statusmessage(code))
138
140
139 def get_stat(spath, fn):
141 def get_stat(spath, fn):
140 """stat fn if it exists, spath otherwise"""
142 """stat fn if it exists, spath otherwise"""
141 cl_path = os.path.join(spath, fn)
143 cl_path = os.path.join(spath, fn)
142 if os.path.exists(cl_path):
144 if os.path.exists(cl_path):
143 return os.stat(cl_path)
145 return os.stat(cl_path)
144 else:
146 else:
145 return os.stat(spath)
147 return os.stat(spath)
146
148
147 def get_mtime(spath):
149 def get_mtime(spath):
148 return get_stat(spath, "00changelog.i")[stat.ST_MTIME]
150 return get_stat(spath, "00changelog.i")[stat.ST_MTIME]
149
151
150 def ispathsafe(path):
152 def ispathsafe(path):
151 """Determine if a path is safe to use for filesystem access."""
153 """Determine if a path is safe to use for filesystem access."""
152 parts = path.split('/')
154 parts = path.split('/')
153 for part in parts:
155 for part in parts:
154 if (part in ('', pycompat.oscurdir, pycompat.ospardir) or
156 if (part in ('', pycompat.oscurdir, pycompat.ospardir) or
155 pycompat.ossep in part or
157 pycompat.ossep in part or
156 pycompat.osaltsep is not None and pycompat.osaltsep in part):
158 pycompat.osaltsep is not None and pycompat.osaltsep in part):
157 return False
159 return False
158
160
159 return True
161 return True
160
162
161 def staticfile(directory, fname, res):
163 def staticfile(directory, fname, res):
162 """return a file inside directory with guessed Content-Type header
164 """return a file inside directory with guessed Content-Type header
163
165
164 fname always uses '/' as directory separator and isn't allowed to
166 fname always uses '/' as directory separator and isn't allowed to
165 contain unusual path components.
167 contain unusual path components.
166 Content-Type is guessed using the mimetypes module.
168 Content-Type is guessed using the mimetypes module.
167 Return an empty string if fname is illegal or file not found.
169 Return an empty string if fname is illegal or file not found.
168
170
169 """
171 """
170 if not ispathsafe(fname):
172 if not ispathsafe(fname):
171 return
173 return
172
174
173 fpath = os.path.join(*fname.split('/'))
175 fpath = os.path.join(*fname.split('/'))
174 if isinstance(directory, str):
176 if isinstance(directory, str):
175 directory = [directory]
177 directory = [directory]
176 for d in directory:
178 for d in directory:
177 path = os.path.join(d, fpath)
179 path = os.path.join(d, fpath)
178 if os.path.exists(path):
180 if os.path.exists(path):
179 break
181 break
180 try:
182 try:
181 os.stat(path)
183 os.stat(path)
182 ct = mimetypes.guess_type(pycompat.fsdecode(path))[0] or "text/plain"
184 ct = mimetypes.guess_type(pycompat.fsdecode(path))[0] or "text/plain"
183 with open(path, 'rb') as fh:
185 with open(path, 'rb') as fh:
184 data = fh.read()
186 data = fh.read()
185
187
186 res.headers['Content-Type'] = ct
188 res.headers['Content-Type'] = ct
187 res.setbodybytes(data)
189 res.setbodybytes(data)
188 return res
190 return res
189 except TypeError:
191 except TypeError:
190 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
192 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
191 except OSError as err:
193 except OSError as err:
192 if err.errno == errno.ENOENT:
194 if err.errno == errno.ENOENT:
193 raise ErrorResponse(HTTP_NOT_FOUND)
195 raise ErrorResponse(HTTP_NOT_FOUND)
194 else:
196 else:
195 raise ErrorResponse(HTTP_SERVER_ERROR,
197 raise ErrorResponse(HTTP_SERVER_ERROR,
196 encoding.strtolocal(err.strerror))
198 encoding.strtolocal(err.strerror))
197
199
198 def paritygen(stripecount, offset=0):
200 def paritygen(stripecount, offset=0):
199 """count parity of horizontal stripes for easier reading"""
201 """count parity of horizontal stripes for easier reading"""
200 if stripecount and offset:
202 if stripecount and offset:
201 # account for offset, e.g. due to building the list in reverse
203 # account for offset, e.g. due to building the list in reverse
202 count = (stripecount + offset) % stripecount
204 count = (stripecount + offset) % stripecount
203 parity = (stripecount + offset) // stripecount & 1
205 parity = (stripecount + offset) // stripecount & 1
204 else:
206 else:
205 count = 0
207 count = 0
206 parity = 0
208 parity = 0
207 while True:
209 while True:
208 yield parity
210 yield parity
209 count += 1
211 count += 1
210 if stripecount and count >= stripecount:
212 if stripecount and count >= stripecount:
211 parity = 1 - parity
213 parity = 1 - parity
212 count = 0
214 count = 0
213
215
214 def get_contact(config):
216 def get_contact(config):
215 """Return repo contact information or empty string.
217 """Return repo contact information or empty string.
216
218
217 web.contact is the primary source, but if that is not set, try
219 web.contact is the primary source, but if that is not set, try
218 ui.username or $EMAIL as a fallback to display something useful.
220 ui.username or $EMAIL as a fallback to display something useful.
219 """
221 """
220 return (config("web", "contact") or
222 return (config("web", "contact") or
221 config("ui", "username") or
223 config("ui", "username") or
222 encoding.environ.get("EMAIL") or "")
224 encoding.environ.get("EMAIL") or "")
223
225
224 def cspvalues(ui):
226 def cspvalues(ui):
225 """Obtain the Content-Security-Policy header and nonce value.
227 """Obtain the Content-Security-Policy header and nonce value.
226
228
227 Returns a 2-tuple of the CSP header value and the nonce value.
229 Returns a 2-tuple of the CSP header value and the nonce value.
228
230
229 First value is ``None`` if CSP isn't enabled. Second value is ``None``
231 First value is ``None`` if CSP isn't enabled. Second value is ``None``
230 if CSP isn't enabled or if the CSP header doesn't need a nonce.
232 if CSP isn't enabled or if the CSP header doesn't need a nonce.
231 """
233 """
232 # Without demandimport, "import uuid" could have an immediate side-effect
234 # Without demandimport, "import uuid" could have an immediate side-effect
233 # running "ldconfig" on Linux trying to find libuuid.
235 # running "ldconfig" on Linux trying to find libuuid.
234 # With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell
236 # With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell
235 # may pollute the terminal with:
237 # may pollute the terminal with:
236 #
238 #
237 # shell-init: error retrieving current directory: getcwd: cannot access
239 # shell-init: error retrieving current directory: getcwd: cannot access
238 # parent directories: No such file or directory
240 # parent directories: No such file or directory
239 #
241 #
240 # Python >= 2.7.13 has fixed it by running "ldconfig" directly without a
242 # Python >= 2.7.13 has fixed it by running "ldconfig" directly without a
241 # shell (hg changeset a09ae70f3489).
243 # shell (hg changeset a09ae70f3489).
242 #
244 #
243 # Moved "import uuid" from here so it's executed after we know we have
245 # Moved "import uuid" from here so it's executed after we know we have
244 # a sane cwd (i.e. after dispatch.py cwd check).
246 # a sane cwd (i.e. after dispatch.py cwd check).
245 #
247 #
246 # We can move it back once we no longer need Python <= 2.7.12 support.
248 # We can move it back once we no longer need Python <= 2.7.12 support.
247 import uuid
249 import uuid
248
250
249 # Don't allow untrusted CSP setting since it be disable protections
251 # Don't allow untrusted CSP setting since it be disable protections
250 # from a trusted/global source.
252 # from a trusted/global source.
251 csp = ui.config('web', 'csp', untrusted=False)
253 csp = ui.config('web', 'csp', untrusted=False)
252 nonce = None
254 nonce = None
253
255
254 if csp and '%nonce%' in csp:
256 if csp and '%nonce%' in csp:
255 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
257 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
256 csp = csp.replace('%nonce%', nonce)
258 csp = csp.replace('%nonce%', nonce)
257
259
258 return csp, nonce
260 return csp, nonce
General Comments 0
You need to be logged in to leave comments. Login now