##// END OF EJS Templates
hgweb: rename req to wsgireq...
Gregory Szorc -
r36822:b9b968e2 default
parent child Browse files
Show More
@@ -1,473 +1,473 b''
1 1 # hgweb/hgweb_mod.py - Web interface for a repository.
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 from __future__ import absolute_import
10 10
11 11 import contextlib
12 12 import os
13 13
14 14 from .common import (
15 15 ErrorResponse,
16 16 HTTP_BAD_REQUEST,
17 17 HTTP_NOT_FOUND,
18 18 HTTP_NOT_MODIFIED,
19 19 HTTP_OK,
20 20 HTTP_SERVER_ERROR,
21 21 caching,
22 22 cspvalues,
23 23 permhooks,
24 24 )
25 from .request import wsgirequest
26 25
27 26 from .. import (
28 27 encoding,
29 28 error,
30 29 formatter,
31 30 hg,
32 31 hook,
33 32 profiling,
34 33 pycompat,
35 34 repoview,
36 35 templatefilters,
37 36 templater,
38 37 ui as uimod,
39 38 util,
40 39 wireprotoserver,
41 40 )
42 41
43 42 from . import (
43 request as requestmod,
44 44 webcommands,
45 45 webutil,
46 46 wsgicgi,
47 47 )
48 48
49 49 archivespecs = util.sortdict((
50 50 ('zip', ('application/zip', 'zip', '.zip', None)),
51 51 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
52 52 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
53 53 ))
54 54
55 55 def getstyle(req, configfn, templatepath):
56 56 fromreq = req.form.get('style', [None])[0]
57 57 styles = (
58 58 fromreq,
59 59 configfn('web', 'style'),
60 60 'paper',
61 61 )
62 62 return styles, templater.stylemap(styles, templatepath)
63 63
64 64 def makebreadcrumb(url, prefix=''):
65 65 '''Return a 'URL breadcrumb' list
66 66
67 67 A 'URL breadcrumb' is a list of URL-name pairs,
68 68 corresponding to each of the path items on a URL.
69 69 This can be used to create path navigation entries.
70 70 '''
71 71 if url.endswith('/'):
72 72 url = url[:-1]
73 73 if prefix:
74 74 url = '/' + prefix + url
75 75 relpath = url
76 76 if relpath.startswith('/'):
77 77 relpath = relpath[1:]
78 78
79 79 breadcrumb = []
80 80 urlel = url
81 81 pathitems = [''] + relpath.split('/')
82 82 for pathel in reversed(pathitems):
83 83 if not pathel or not urlel:
84 84 break
85 85 breadcrumb.append({'url': urlel, 'name': pathel})
86 86 urlel = os.path.dirname(urlel)
87 87 return reversed(breadcrumb)
88 88
89 89 class requestcontext(object):
90 90 """Holds state/context for an individual request.
91 91
92 92 Servers can be multi-threaded. Holding state on the WSGI application
93 93 is prone to race conditions. Instances of this class exist to hold
94 94 mutable and race-free state for requests.
95 95 """
96 96 def __init__(self, app, repo):
97 97 self.repo = repo
98 98 self.reponame = app.reponame
99 99
100 100 self.archivespecs = archivespecs
101 101
102 102 self.maxchanges = self.configint('web', 'maxchanges')
103 103 self.stripecount = self.configint('web', 'stripes')
104 104 self.maxshortchanges = self.configint('web', 'maxshortchanges')
105 105 self.maxfiles = self.configint('web', 'maxfiles')
106 106 self.allowpull = self.configbool('web', 'allow-pull')
107 107
108 108 # we use untrusted=False to prevent a repo owner from using
109 109 # web.templates in .hg/hgrc to get access to any file readable
110 110 # by the user running the CGI script
111 111 self.templatepath = self.config('web', 'templates', untrusted=False)
112 112
113 113 # This object is more expensive to build than simple config values.
114 114 # It is shared across requests. The app will replace the object
115 115 # if it is updated. Since this is a reference and nothing should
116 116 # modify the underlying object, it should be constant for the lifetime
117 117 # of the request.
118 118 self.websubtable = app.websubtable
119 119
120 120 self.csp, self.nonce = cspvalues(self.repo.ui)
121 121
122 122 # Trust the settings from the .hg/hgrc files by default.
123 123 def config(self, section, name, default=uimod._unset, untrusted=True):
124 124 return self.repo.ui.config(section, name, default,
125 125 untrusted=untrusted)
126 126
127 127 def configbool(self, section, name, default=uimod._unset, untrusted=True):
128 128 return self.repo.ui.configbool(section, name, default,
129 129 untrusted=untrusted)
130 130
131 131 def configint(self, section, name, default=uimod._unset, untrusted=True):
132 132 return self.repo.ui.configint(section, name, default,
133 133 untrusted=untrusted)
134 134
135 135 def configlist(self, section, name, default=uimod._unset, untrusted=True):
136 136 return self.repo.ui.configlist(section, name, default,
137 137 untrusted=untrusted)
138 138
139 139 def archivelist(self, nodeid):
140 140 allowed = self.configlist('web', 'allow_archive')
141 141 for typ, spec in self.archivespecs.iteritems():
142 142 if typ in allowed or self.configbool('web', 'allow%s' % typ):
143 143 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
144 144
145 def templater(self, req):
145 def templater(self, wsgireq):
146 146 # determine scheme, port and server name
147 147 # this is needed to create absolute urls
148 148
149 proto = req.env.get('wsgi.url_scheme')
149 proto = wsgireq.env.get('wsgi.url_scheme')
150 150 if proto == 'https':
151 151 proto = 'https'
152 152 default_port = '443'
153 153 else:
154 154 proto = 'http'
155 155 default_port = '80'
156 156
157 port = req.env[r'SERVER_PORT']
157 port = wsgireq.env[r'SERVER_PORT']
158 158 port = port != default_port and (r':' + port) or r''
159 urlbase = r'%s://%s%s' % (proto, req.env[r'SERVER_NAME'], port)
159 urlbase = r'%s://%s%s' % (proto, wsgireq.env[r'SERVER_NAME'], port)
160 160 logourl = self.config('web', 'logourl')
161 161 logoimg = self.config('web', 'logoimg')
162 162 staticurl = (self.config('web', 'staticurl')
163 or pycompat.sysbytes(req.url) + 'static/')
163 or pycompat.sysbytes(wsgireq.url) + 'static/')
164 164 if not staticurl.endswith('/'):
165 165 staticurl += '/'
166 166
167 167 # some functions for the templater
168 168
169 169 def motd(**map):
170 170 yield self.config('web', 'motd')
171 171
172 172 # figure out which style to use
173 173
174 174 vars = {}
175 styles, (style, mapfile) = getstyle(req, self.config,
175 styles, (style, mapfile) = getstyle(wsgireq, self.config,
176 176 self.templatepath)
177 177 if style == styles[0]:
178 178 vars['style'] = style
179 179
180 start = '&' if req.url[-1] == r'?' else '?'
180 start = '&' if wsgireq.url[-1] == r'?' else '?'
181 181 sessionvars = webutil.sessionvars(vars, start)
182 182
183 183 if not self.reponame:
184 184 self.reponame = (self.config('web', 'name', '')
185 or req.env.get('REPO_NAME')
186 or req.url.strip(r'/') or self.repo.root)
185 or wsgireq.env.get('REPO_NAME')
186 or wsgireq.url.strip(r'/') or self.repo.root)
187 187
188 188 def websubfilter(text):
189 189 return templatefilters.websub(text, self.websubtable)
190 190
191 191 # create the templater
192 192 # TODO: export all keywords: defaults = templatekw.keywords.copy()
193 193 defaults = {
194 'url': pycompat.sysbytes(req.url),
194 'url': pycompat.sysbytes(wsgireq.url),
195 195 'logourl': logourl,
196 196 'logoimg': logoimg,
197 197 'staticurl': staticurl,
198 198 'urlbase': urlbase,
199 199 'repo': self.reponame,
200 200 'encoding': encoding.encoding,
201 201 'motd': motd,
202 202 'sessionvars': sessionvars,
203 'pathdef': makebreadcrumb(pycompat.sysbytes(req.url)),
203 'pathdef': makebreadcrumb(pycompat.sysbytes(wsgireq.url)),
204 204 'style': style,
205 205 'nonce': self.nonce,
206 206 }
207 207 tres = formatter.templateresources(self.repo.ui, self.repo)
208 208 tmpl = templater.templater.frommapfile(mapfile,
209 209 filters={'websub': websubfilter},
210 210 defaults=defaults,
211 211 resources=tres)
212 212 return tmpl
213 213
214 214
215 215 class hgweb(object):
216 216 """HTTP server for individual repositories.
217 217
218 218 Instances of this class serve HTTP responses for a particular
219 219 repository.
220 220
221 221 Instances are typically used as WSGI applications.
222 222
223 223 Some servers are multi-threaded. On these servers, there may
224 224 be multiple active threads inside __call__.
225 225 """
226 226 def __init__(self, repo, name=None, baseui=None):
227 227 if isinstance(repo, str):
228 228 if baseui:
229 229 u = baseui.copy()
230 230 else:
231 231 u = uimod.ui.load()
232 232 r = hg.repository(u, repo)
233 233 else:
234 234 # we trust caller to give us a private copy
235 235 r = repo
236 236
237 237 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
238 238 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
239 239 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
240 240 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
241 241 # resolve file patterns relative to repo root
242 242 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
243 243 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
244 244 # displaying bundling progress bar while serving feel wrong and may
245 245 # break some wsgi implementation.
246 246 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
247 247 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
248 248 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
249 249 self._lastrepo = self._repos[0]
250 250 hook.redirect(True)
251 251 self.reponame = name
252 252
253 253 def _webifyrepo(self, repo):
254 254 repo = getwebview(repo)
255 255 self.websubtable = webutil.getwebsubs(repo)
256 256 return repo
257 257
258 258 @contextlib.contextmanager
259 259 def _obtainrepo(self):
260 260 """Obtain a repo unique to the caller.
261 261
262 262 Internally we maintain a stack of cachedlocalrepo instances
263 263 to be handed out. If one is available, we pop it and return it,
264 264 ensuring it is up to date in the process. If one is not available,
265 265 we clone the most recently used repo instance and return it.
266 266
267 267 It is currently possible for the stack to grow without bounds
268 268 if the server allows infinite threads. However, servers should
269 269 have a thread limit, thus establishing our limit.
270 270 """
271 271 if self._repos:
272 272 cached = self._repos.pop()
273 273 r, created = cached.fetch()
274 274 else:
275 275 cached = self._lastrepo.copy()
276 276 r, created = cached.fetch()
277 277 if created:
278 278 r = self._webifyrepo(r)
279 279
280 280 self._lastrepo = cached
281 281 self.mtime = cached.mtime
282 282 try:
283 283 yield r
284 284 finally:
285 285 self._repos.append(cached)
286 286
287 287 def run(self):
288 288 """Start a server from CGI environment.
289 289
290 290 Modern servers should be using WSGI and should avoid this
291 291 method, if possible.
292 292 """
293 293 if not encoding.environ.get('GATEWAY_INTERFACE',
294 294 '').startswith("CGI/1."):
295 295 raise RuntimeError("This function is only intended to be "
296 296 "called while running as a CGI script.")
297 297 wsgicgi.launch(self)
298 298
299 299 def __call__(self, env, respond):
300 300 """Run the WSGI application.
301 301
302 302 This may be called by multiple threads.
303 303 """
304 req = wsgirequest(env, respond)
304 req = requestmod.wsgirequest(env, respond)
305 305 return self.run_wsgi(req)
306 306
307 def run_wsgi(self, req):
307 def run_wsgi(self, wsgireq):
308 308 """Internal method to run the WSGI application.
309 309
310 310 This is typically only called by Mercurial. External consumers
311 311 should be using instances of this class as the WSGI application.
312 312 """
313 313 with self._obtainrepo() as repo:
314 314 profile = repo.ui.configbool('profiling', 'enabled')
315 315 with profiling.profile(repo.ui, enabled=profile):
316 for r in self._runwsgi(req, repo):
316 for r in self._runwsgi(wsgireq, repo):
317 317 yield r
318 318
319 def _runwsgi(self, req, repo):
319 def _runwsgi(self, wsgireq, repo):
320 320 rctx = requestcontext(self, repo)
321 321
322 322 # This state is global across all threads.
323 323 encoding.encoding = rctx.config('web', 'encoding')
324 rctx.repo.ui.environ = req.env
324 rctx.repo.ui.environ = wsgireq.env
325 325
326 326 if rctx.csp:
327 327 # hgwebdir may have added CSP header. Since we generate our own,
328 328 # replace it.
329 req.headers = [h for h in req.headers
330 if h[0] != 'Content-Security-Policy']
331 req.headers.append(('Content-Security-Policy', rctx.csp))
329 wsgireq.headers = [h for h in wsgireq.headers
330 if h[0] != 'Content-Security-Policy']
331 wsgireq.headers.append(('Content-Security-Policy', rctx.csp))
332 332
333 333 # work with CGI variables to create coherent structure
334 334 # use SCRIPT_NAME, PATH_INFO and QUERY_STRING as well as our REPO_NAME
335 335
336 req.url = req.env[r'SCRIPT_NAME']
337 if not req.url.endswith(r'/'):
338 req.url += r'/'
339 if req.env.get('REPO_NAME'):
340 req.url += req.env[r'REPO_NAME'] + r'/'
336 wsgireq.url = wsgireq.env[r'SCRIPT_NAME']
337 if not wsgireq.url.endswith(r'/'):
338 wsgireq.url += r'/'
339 if wsgireq.env.get('REPO_NAME'):
340 wsgireq.url += wsgireq.env[r'REPO_NAME'] + r'/'
341 341
342 if r'PATH_INFO' in req.env:
343 parts = req.env[r'PATH_INFO'].strip(r'/').split(r'/')
344 repo_parts = req.env.get(r'REPO_NAME', r'').split(r'/')
342 if r'PATH_INFO' in wsgireq.env:
343 parts = wsgireq.env[r'PATH_INFO'].strip(r'/').split(r'/')
344 repo_parts = wsgireq.env.get(r'REPO_NAME', r'').split(r'/')
345 345 if parts[:len(repo_parts)] == repo_parts:
346 346 parts = parts[len(repo_parts):]
347 347 query = r'/'.join(parts)
348 348 else:
349 query = req.env[r'QUERY_STRING'].partition(r'&')[0]
349 query = wsgireq.env[r'QUERY_STRING'].partition(r'&')[0]
350 350 query = query.partition(r';')[0]
351 351
352 352 # Route it to a wire protocol handler if it looks like a wire protocol
353 353 # request.
354 protohandler = wireprotoserver.parsehttprequest(rctx, req, query,
354 protohandler = wireprotoserver.parsehttprequest(rctx, wsgireq, query,
355 355 self.check_perm)
356 356
357 357 if protohandler:
358 358 try:
359 359 if query:
360 360 raise ErrorResponse(HTTP_NOT_FOUND)
361 361
362 362 return protohandler['dispatch']()
363 363 except ErrorResponse as inst:
364 364 return protohandler['handleerror'](inst)
365 365
366 366 # translate user-visible url structure to internal structure
367 367
368 368 args = query.split(r'/', 2)
369 if 'cmd' not in req.form and args and args[0]:
369 if 'cmd' not in wsgireq.form and args and args[0]:
370 370 cmd = args.pop(0)
371 371 style = cmd.rfind('-')
372 372 if style != -1:
373 req.form['style'] = [cmd[:style]]
373 wsgireq.form['style'] = [cmd[:style]]
374 374 cmd = cmd[style + 1:]
375 375
376 376 # avoid accepting e.g. style parameter as command
377 377 if util.safehasattr(webcommands, cmd):
378 req.form['cmd'] = [cmd]
378 wsgireq.form['cmd'] = [cmd]
379 379
380 380 if cmd == 'static':
381 req.form['file'] = ['/'.join(args)]
381 wsgireq.form['file'] = ['/'.join(args)]
382 382 else:
383 383 if args and args[0]:
384 384 node = args.pop(0).replace('%2F', '/')
385 req.form['node'] = [node]
385 wsgireq.form['node'] = [node]
386 386 if args:
387 req.form['file'] = args
387 wsgireq.form['file'] = args
388 388
389 ua = req.env.get('HTTP_USER_AGENT', '')
389 ua = wsgireq.env.get('HTTP_USER_AGENT', '')
390 390 if cmd == 'rev' and 'mercurial' in ua:
391 req.form['style'] = ['raw']
391 wsgireq.form['style'] = ['raw']
392 392
393 393 if cmd == 'archive':
394 fn = req.form['node'][0]
394 fn = wsgireq.form['node'][0]
395 395 for type_, spec in rctx.archivespecs.iteritems():
396 396 ext = spec[2]
397 397 if fn.endswith(ext):
398 req.form['node'] = [fn[:-len(ext)]]
399 req.form['type'] = [type_]
398 wsgireq.form['node'] = [fn[:-len(ext)]]
399 wsgireq.form['type'] = [type_]
400 400 else:
401 cmd = req.form.get('cmd', [''])[0]
401 cmd = wsgireq.form.get('cmd', [''])[0]
402 402
403 403 # process the web interface request
404 404
405 405 try:
406 tmpl = rctx.templater(req)
406 tmpl = rctx.templater(wsgireq)
407 407 ctype = tmpl('mimetype', encoding=encoding.encoding)
408 408 ctype = templater.stringify(ctype)
409 409
410 410 # check read permissions non-static content
411 411 if cmd != 'static':
412 self.check_perm(rctx, req, None)
412 self.check_perm(rctx, wsgireq, None)
413 413
414 414 if cmd == '':
415 req.form['cmd'] = [tmpl.cache['default']]
416 cmd = req.form['cmd'][0]
415 wsgireq.form['cmd'] = [tmpl.cache['default']]
416 cmd = wsgireq.form['cmd'][0]
417 417
418 418 # Don't enable caching if using a CSP nonce because then it wouldn't
419 419 # be a nonce.
420 420 if rctx.configbool('web', 'cache') and not rctx.nonce:
421 caching(self, req) # sets ETag header or raises NOT_MODIFIED
421 caching(self, wsgireq) # sets ETag header or raises NOT_MODIFIED
422 422 if cmd not in webcommands.__all__:
423 423 msg = 'no such method: %s' % cmd
424 424 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
425 elif cmd == 'file' and 'raw' in req.form.get('style', []):
425 elif cmd == 'file' and 'raw' in wsgireq.form.get('style', []):
426 426 rctx.ctype = ctype
427 content = webcommands.rawfile(rctx, req, tmpl)
427 content = webcommands.rawfile(rctx, wsgireq, tmpl)
428 428 else:
429 content = getattr(webcommands, cmd)(rctx, req, tmpl)
430 req.respond(HTTP_OK, ctype)
429 content = getattr(webcommands, cmd)(rctx, wsgireq, tmpl)
430 wsgireq.respond(HTTP_OK, ctype)
431 431
432 432 return content
433 433
434 434 except (error.LookupError, error.RepoLookupError) as err:
435 req.respond(HTTP_NOT_FOUND, ctype)
435 wsgireq.respond(HTTP_NOT_FOUND, ctype)
436 436 msg = pycompat.bytestr(err)
437 437 if (util.safehasattr(err, 'name') and
438 438 not isinstance(err, error.ManifestLookupError)):
439 439 msg = 'revision not found: %s' % err.name
440 440 return tmpl('error', error=msg)
441 441 except (error.RepoError, error.RevlogError) as inst:
442 req.respond(HTTP_SERVER_ERROR, ctype)
442 wsgireq.respond(HTTP_SERVER_ERROR, ctype)
443 443 return tmpl('error', error=pycompat.bytestr(inst))
444 444 except ErrorResponse as inst:
445 req.respond(inst, ctype)
445 wsgireq.respond(inst, ctype)
446 446 if inst.code == HTTP_NOT_MODIFIED:
447 447 # Not allowed to return a body on a 304
448 448 return ['']
449 449 return tmpl('error', error=pycompat.bytestr(inst))
450 450
451 451 def check_perm(self, rctx, req, op):
452 452 for permhook in permhooks:
453 453 permhook(rctx, req, op)
454 454
455 455 def getwebview(repo):
456 456 """The 'web.view' config controls changeset filter to hgweb. Possible
457 457 values are ``served``, ``visible`` and ``all``. Default is ``served``.
458 458 The ``served`` filter only shows changesets that can be pulled from the
459 459 hgweb instance. The``visible`` filter includes secret changesets but
460 460 still excludes "hidden" one.
461 461
462 462 See the repoview module for details.
463 463
464 464 The option has been around undocumented since Mercurial 2.5, but no
465 465 user ever asked about it. So we better keep it undocumented for now."""
466 466 # experimental config: web.view
467 467 viewconfig = repo.ui.config('web', 'view', untrusted=True)
468 468 if viewconfig == 'all':
469 469 return repo.unfiltered()
470 470 elif viewconfig in repoview.filtertable:
471 471 return repo.filtered(viewconfig)
472 472 else:
473 473 return repo.filtered('served')
@@ -1,539 +1,539 b''
1 1 # hgweb/hgwebdir_mod.py - Web interface for a directory of repositories.
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 from __future__ import absolute_import
10 10
11 11 import os
12 12 import re
13 13 import time
14 14
15 15 from ..i18n import _
16 16
17 17 from .common import (
18 18 ErrorResponse,
19 19 HTTP_NOT_FOUND,
20 20 HTTP_OK,
21 21 HTTP_SERVER_ERROR,
22 22 cspvalues,
23 23 get_contact,
24 24 get_mtime,
25 25 ismember,
26 26 paritygen,
27 27 staticfile,
28 28 )
29 from .request import wsgirequest
30 29
31 30 from .. import (
32 31 configitems,
33 32 encoding,
34 33 error,
35 34 hg,
36 35 profiling,
37 36 pycompat,
38 37 scmutil,
39 38 templater,
40 39 ui as uimod,
41 40 util,
42 41 )
43 42
44 43 from . import (
45 44 hgweb_mod,
45 request as requestmod,
46 46 webutil,
47 47 wsgicgi,
48 48 )
49 49 from ..utils import dateutil
50 50
51 51 def cleannames(items):
52 52 return [(util.pconvert(name).strip('/'), path) for name, path in items]
53 53
54 54 def findrepos(paths):
55 55 repos = []
56 56 for prefix, root in cleannames(paths):
57 57 roothead, roottail = os.path.split(root)
58 58 # "foo = /bar/*" or "foo = /bar/**" lets every repo /bar/N in or below
59 59 # /bar/ be served as as foo/N .
60 60 # '*' will not search inside dirs with .hg (except .hg/patches),
61 61 # '**' will search inside dirs with .hg (and thus also find subrepos).
62 62 try:
63 63 recurse = {'*': False, '**': True}[roottail]
64 64 except KeyError:
65 65 repos.append((prefix, root))
66 66 continue
67 67 roothead = os.path.normpath(os.path.abspath(roothead))
68 68 paths = scmutil.walkrepos(roothead, followsym=True, recurse=recurse)
69 69 repos.extend(urlrepos(prefix, roothead, paths))
70 70 return repos
71 71
72 72 def urlrepos(prefix, roothead, paths):
73 73 """yield url paths and filesystem paths from a list of repo paths
74 74
75 75 >>> conv = lambda seq: [(v, util.pconvert(p)) for v,p in seq]
76 76 >>> conv(urlrepos(b'hg', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
77 77 [('hg/r', '/opt/r'), ('hg/r/r', '/opt/r/r'), ('hg', '/opt')]
78 78 >>> conv(urlrepos(b'', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
79 79 [('r', '/opt/r'), ('r/r', '/opt/r/r'), ('', '/opt')]
80 80 """
81 81 for path in paths:
82 82 path = os.path.normpath(path)
83 83 yield (prefix + '/' +
84 84 util.pconvert(path[len(roothead):]).lstrip('/')).strip('/'), path
85 85
86 86 def geturlcgivars(baseurl, port):
87 87 """
88 88 Extract CGI variables from baseurl
89 89
90 90 >>> geturlcgivars(b"http://host.org/base", b"80")
91 91 ('host.org', '80', '/base')
92 92 >>> geturlcgivars(b"http://host.org:8000/base", b"80")
93 93 ('host.org', '8000', '/base')
94 94 >>> geturlcgivars(b'/base', 8000)
95 95 ('', '8000', '/base')
96 96 >>> geturlcgivars(b"base", b'8000')
97 97 ('', '8000', '/base')
98 98 >>> geturlcgivars(b"http://host", b'8000')
99 99 ('host', '8000', '/')
100 100 >>> geturlcgivars(b"http://host/", b'8000')
101 101 ('host', '8000', '/')
102 102 """
103 103 u = util.url(baseurl)
104 104 name = u.host or ''
105 105 if u.port:
106 106 port = u.port
107 107 path = u.path or ""
108 108 if not path.startswith('/'):
109 109 path = '/' + path
110 110
111 111 return name, pycompat.bytestr(port), path
112 112
113 113 class hgwebdir(object):
114 114 """HTTP server for multiple repositories.
115 115
116 116 Given a configuration, different repositories will be served depending
117 117 on the request path.
118 118
119 119 Instances are typically used as WSGI applications.
120 120 """
121 121 def __init__(self, conf, baseui=None):
122 122 self.conf = conf
123 123 self.baseui = baseui
124 124 self.ui = None
125 125 self.lastrefresh = 0
126 126 self.motd = None
127 127 self.refresh()
128 128
129 129 def refresh(self):
130 130 if self.ui:
131 131 refreshinterval = self.ui.configint('web', 'refreshinterval')
132 132 else:
133 133 item = configitems.coreitems['web']['refreshinterval']
134 134 refreshinterval = item.default
135 135
136 136 # refreshinterval <= 0 means to always refresh.
137 137 if (refreshinterval > 0 and
138 138 self.lastrefresh + refreshinterval > time.time()):
139 139 return
140 140
141 141 if self.baseui:
142 142 u = self.baseui.copy()
143 143 else:
144 144 u = uimod.ui.load()
145 145 u.setconfig('ui', 'report_untrusted', 'off', 'hgwebdir')
146 146 u.setconfig('ui', 'nontty', 'true', 'hgwebdir')
147 147 # displaying bundling progress bar while serving feels wrong and may
148 148 # break some wsgi implementations.
149 149 u.setconfig('progress', 'disable', 'true', 'hgweb')
150 150
151 151 if not isinstance(self.conf, (dict, list, tuple)):
152 152 map = {'paths': 'hgweb-paths'}
153 153 if not os.path.exists(self.conf):
154 154 raise error.Abort(_('config file %s not found!') % self.conf)
155 155 u.readconfig(self.conf, remap=map, trust=True)
156 156 paths = []
157 157 for name, ignored in u.configitems('hgweb-paths'):
158 158 for path in u.configlist('hgweb-paths', name):
159 159 paths.append((name, path))
160 160 elif isinstance(self.conf, (list, tuple)):
161 161 paths = self.conf
162 162 elif isinstance(self.conf, dict):
163 163 paths = self.conf.items()
164 164
165 165 repos = findrepos(paths)
166 166 for prefix, root in u.configitems('collections'):
167 167 prefix = util.pconvert(prefix)
168 168 for path in scmutil.walkrepos(root, followsym=True):
169 169 repo = os.path.normpath(path)
170 170 name = util.pconvert(repo)
171 171 if name.startswith(prefix):
172 172 name = name[len(prefix):]
173 173 repos.append((name.lstrip('/'), repo))
174 174
175 175 self.repos = repos
176 176 self.ui = u
177 177 encoding.encoding = self.ui.config('web', 'encoding')
178 178 self.style = self.ui.config('web', 'style')
179 179 self.templatepath = self.ui.config('web', 'templates', untrusted=False)
180 180 self.stripecount = self.ui.config('web', 'stripes')
181 181 if self.stripecount:
182 182 self.stripecount = int(self.stripecount)
183 183 self._baseurl = self.ui.config('web', 'baseurl')
184 184 prefix = self.ui.config('web', 'prefix')
185 185 if prefix.startswith('/'):
186 186 prefix = prefix[1:]
187 187 if prefix.endswith('/'):
188 188 prefix = prefix[:-1]
189 189 self.prefix = prefix
190 190 self.lastrefresh = time.time()
191 191
192 192 def run(self):
193 193 if not encoding.environ.get('GATEWAY_INTERFACE',
194 194 '').startswith("CGI/1."):
195 195 raise RuntimeError("This function is only intended to be "
196 196 "called while running as a CGI script.")
197 197 wsgicgi.launch(self)
198 198
199 199 def __call__(self, env, respond):
200 req = wsgirequest(env, respond)
201 return self.run_wsgi(req)
200 wsgireq = requestmod.wsgirequest(env, respond)
201 return self.run_wsgi(wsgireq)
202 202
203 def read_allowed(self, ui, req):
203 def read_allowed(self, ui, wsgireq):
204 204 """Check allow_read and deny_read config options of a repo's ui object
205 205 to determine user permissions. By default, with neither option set (or
206 206 both empty), allow all users to read the repo. There are two ways a
207 207 user can be denied read access: (1) deny_read is not empty, and the
208 208 user is unauthenticated or deny_read contains user (or *), and (2)
209 209 allow_read is not empty and the user is not in allow_read. Return True
210 210 if user is allowed to read the repo, else return False."""
211 211
212 user = req.env.get('REMOTE_USER')
212 user = wsgireq.env.get('REMOTE_USER')
213 213
214 214 deny_read = ui.configlist('web', 'deny_read', untrusted=True)
215 215 if deny_read and (not user or ismember(ui, user, deny_read)):
216 216 return False
217 217
218 218 allow_read = ui.configlist('web', 'allow_read', untrusted=True)
219 219 # by default, allow reading if no allow_read option has been set
220 220 if (not allow_read) or ismember(ui, user, allow_read):
221 221 return True
222 222
223 223 return False
224 224
225 def run_wsgi(self, req):
225 def run_wsgi(self, wsgireq):
226 226 profile = self.ui.configbool('profiling', 'enabled')
227 227 with profiling.profile(self.ui, enabled=profile):
228 for r in self._runwsgi(req):
228 for r in self._runwsgi(wsgireq):
229 229 yield r
230 230
231 def _runwsgi(self, req):
231 def _runwsgi(self, wsgireq):
232 232 try:
233 233 self.refresh()
234 234
235 235 csp, nonce = cspvalues(self.ui)
236 236 if csp:
237 req.headers.append(('Content-Security-Policy', csp))
237 wsgireq.headers.append(('Content-Security-Policy', csp))
238 238
239 virtual = req.env.get("PATH_INFO", "").strip('/')
240 tmpl = self.templater(req, nonce)
239 virtual = wsgireq.env.get("PATH_INFO", "").strip('/')
240 tmpl = self.templater(wsgireq, nonce)
241 241 ctype = tmpl('mimetype', encoding=encoding.encoding)
242 242 ctype = templater.stringify(ctype)
243 243
244 244 # a static file
245 if virtual.startswith('static/') or 'static' in req.form:
245 if virtual.startswith('static/') or 'static' in wsgireq.form:
246 246 if virtual.startswith('static/'):
247 247 fname = virtual[7:]
248 248 else:
249 fname = req.form['static'][0]
249 fname = wsgireq.form['static'][0]
250 250 static = self.ui.config("web", "static", None,
251 251 untrusted=False)
252 252 if not static:
253 253 tp = self.templatepath or templater.templatepaths()
254 254 if isinstance(tp, str):
255 255 tp = [tp]
256 256 static = [os.path.join(p, 'static') for p in tp]
257 staticfile(static, fname, req)
257 staticfile(static, fname, wsgireq)
258 258 return []
259 259
260 260 # top-level index
261 261
262 262 repos = dict(self.repos)
263 263
264 264 if (not virtual or virtual == 'index') and virtual not in repos:
265 req.respond(HTTP_OK, ctype)
266 return self.makeindex(req, tmpl)
265 wsgireq.respond(HTTP_OK, ctype)
266 return self.makeindex(wsgireq, tmpl)
267 267
268 268 # nested indexes and hgwebs
269 269
270 270 if virtual.endswith('/index') and virtual not in repos:
271 271 subdir = virtual[:-len('index')]
272 272 if any(r.startswith(subdir) for r in repos):
273 req.respond(HTTP_OK, ctype)
274 return self.makeindex(req, tmpl, subdir)
273 wsgireq.respond(HTTP_OK, ctype)
274 return self.makeindex(wsgireq, tmpl, subdir)
275 275
276 276 def _virtualdirs():
277 277 # Check the full virtual path, each parent, and the root ('')
278 278 if virtual != '':
279 279 yield virtual
280 280
281 281 for p in util.finddirs(virtual):
282 282 yield p
283 283
284 284 yield ''
285 285
286 286 for virtualrepo in _virtualdirs():
287 287 real = repos.get(virtualrepo)
288 288 if real:
289 req.env['REPO_NAME'] = virtualrepo
289 wsgireq.env['REPO_NAME'] = virtualrepo
290 290 try:
291 291 # ensure caller gets private copy of ui
292 292 repo = hg.repository(self.ui.copy(), real)
293 return hgweb_mod.hgweb(repo).run_wsgi(req)
293 return hgweb_mod.hgweb(repo).run_wsgi(wsgireq)
294 294 except IOError as inst:
295 295 msg = encoding.strtolocal(inst.strerror)
296 296 raise ErrorResponse(HTTP_SERVER_ERROR, msg)
297 297 except error.RepoError as inst:
298 298 raise ErrorResponse(HTTP_SERVER_ERROR, bytes(inst))
299 299
300 300 # browse subdirectories
301 301 subdir = virtual + '/'
302 302 if [r for r in repos if r.startswith(subdir)]:
303 req.respond(HTTP_OK, ctype)
304 return self.makeindex(req, tmpl, subdir)
303 wsgireq.respond(HTTP_OK, ctype)
304 return self.makeindex(wsgireq, tmpl, subdir)
305 305
306 306 # prefixes not found
307 req.respond(HTTP_NOT_FOUND, ctype)
307 wsgireq.respond(HTTP_NOT_FOUND, ctype)
308 308 return tmpl("notfound", repo=virtual)
309 309
310 310 except ErrorResponse as err:
311 req.respond(err, ctype)
311 wsgireq.respond(err, ctype)
312 312 return tmpl('error', error=err.message or '')
313 313 finally:
314 314 tmpl = None
315 315
316 def makeindex(self, req, tmpl, subdir=""):
316 def makeindex(self, wsgireq, tmpl, subdir=""):
317 317
318 318 def archivelist(ui, nodeid, url):
319 319 allowed = ui.configlist("web", "allow_archive", untrusted=True)
320 320 archives = []
321 321 for typ, spec in hgweb_mod.archivespecs.iteritems():
322 322 if typ in allowed or ui.configbool("web", "allow" + typ,
323 323 untrusted=True):
324 324 archives.append({"type": typ, "extension": spec[2],
325 325 "node": nodeid, "url": url})
326 326 return archives
327 327
328 328 def rawentries(subdir="", **map):
329 329
330 330 descend = self.ui.configbool('web', 'descend')
331 331 collapse = self.ui.configbool('web', 'collapse')
332 332 seenrepos = set()
333 333 seendirs = set()
334 334 for name, path in self.repos:
335 335
336 336 if not name.startswith(subdir):
337 337 continue
338 338 name = name[len(subdir):]
339 339 directory = False
340 340
341 341 if '/' in name:
342 342 if not descend:
343 343 continue
344 344
345 345 nameparts = name.split('/')
346 346 rootname = nameparts[0]
347 347
348 348 if not collapse:
349 349 pass
350 350 elif rootname in seendirs:
351 351 continue
352 352 elif rootname in seenrepos:
353 353 pass
354 354 else:
355 355 directory = True
356 356 name = rootname
357 357
358 358 # redefine the path to refer to the directory
359 359 discarded = '/'.join(nameparts[1:])
360 360
361 361 # remove name parts plus accompanying slash
362 362 path = path[:-len(discarded) - 1]
363 363
364 364 try:
365 365 r = hg.repository(self.ui, path)
366 366 directory = False
367 367 except (IOError, error.RepoError):
368 368 pass
369 369
370 370 parts = [name]
371 371 parts.insert(0, '/' + subdir.rstrip('/'))
372 if req.env['SCRIPT_NAME']:
373 parts.insert(0, req.env['SCRIPT_NAME'])
372 if wsgireq.env['SCRIPT_NAME']:
373 parts.insert(0, wsgireq.env['SCRIPT_NAME'])
374 374 url = re.sub(r'/+', '/', '/'.join(parts) + '/')
375 375
376 376 # show either a directory entry or a repository
377 377 if directory:
378 378 # get the directory's time information
379 379 try:
380 380 d = (get_mtime(path), dateutil.makedate()[1])
381 381 except OSError:
382 382 continue
383 383
384 384 # add '/' to the name to make it obvious that
385 385 # the entry is a directory, not a regular repository
386 386 row = {'contact': "",
387 387 'contact_sort': "",
388 388 'name': name + '/',
389 389 'name_sort': name,
390 390 'url': url,
391 391 'description': "",
392 392 'description_sort': "",
393 393 'lastchange': d,
394 394 'lastchange_sort': d[1]-d[0],
395 395 'archives': [],
396 396 'isdirectory': True,
397 397 'labels': [],
398 398 }
399 399
400 400 seendirs.add(name)
401 401 yield row
402 402 continue
403 403
404 404 u = self.ui.copy()
405 405 try:
406 406 u.readconfig(os.path.join(path, '.hg', 'hgrc'))
407 407 except Exception as e:
408 408 u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
409 409 continue
410 410 def get(section, name, default=uimod._unset):
411 411 return u.config(section, name, default, untrusted=True)
412 412
413 413 if u.configbool("web", "hidden", untrusted=True):
414 414 continue
415 415
416 if not self.read_allowed(u, req):
416 if not self.read_allowed(u, wsgireq):
417 417 continue
418 418
419 419 # update time with local timezone
420 420 try:
421 421 r = hg.repository(self.ui, path)
422 422 except IOError:
423 423 u.warn(_('error accessing repository at %s\n') % path)
424 424 continue
425 425 except error.RepoError:
426 426 u.warn(_('error accessing repository at %s\n') % path)
427 427 continue
428 428 try:
429 429 d = (get_mtime(r.spath), dateutil.makedate()[1])
430 430 except OSError:
431 431 continue
432 432
433 433 contact = get_contact(get)
434 434 description = get("web", "description")
435 435 seenrepos.add(name)
436 436 name = get("web", "name", name)
437 437 row = {'contact': contact or "unknown",
438 438 'contact_sort': contact.upper() or "unknown",
439 439 'name': name,
440 440 'name_sort': name,
441 441 'url': url,
442 442 'description': description or "unknown",
443 443 'description_sort': description.upper() or "unknown",
444 444 'lastchange': d,
445 445 'lastchange_sort': d[1]-d[0],
446 446 'archives': archivelist(u, "tip", url),
447 447 'isdirectory': None,
448 448 'labels': u.configlist('web', 'labels', untrusted=True),
449 449 }
450 450
451 451 yield row
452 452
453 453 sortdefault = None, False
454 454 def entries(sortcolumn="", descending=False, subdir="", **map):
455 455 rows = rawentries(subdir=subdir, **map)
456 456
457 457 if sortcolumn and sortdefault != (sortcolumn, descending):
458 458 sortkey = '%s_sort' % sortcolumn
459 459 rows = sorted(rows, key=lambda x: x[sortkey],
460 460 reverse=descending)
461 461 for row, parity in zip(rows, paritygen(self.stripecount)):
462 462 row['parity'] = parity
463 463 yield row
464 464
465 465 self.refresh()
466 466 sortable = ["name", "description", "contact", "lastchange"]
467 467 sortcolumn, descending = sortdefault
468 if 'sort' in req.form:
469 sortcolumn = req.form['sort'][0]
468 if 'sort' in wsgireq.form:
469 sortcolumn = wsgireq.form['sort'][0]
470 470 descending = sortcolumn.startswith('-')
471 471 if descending:
472 472 sortcolumn = sortcolumn[1:]
473 473 if sortcolumn not in sortable:
474 474 sortcolumn = ""
475 475
476 476 sort = [("sort_%s" % column,
477 477 "%s%s" % ((not descending and column == sortcolumn)
478 478 and "-" or "", column))
479 479 for column in sortable]
480 480
481 481 self.refresh()
482 self.updatereqenv(req.env)
482 self.updatereqenv(wsgireq.env)
483 483
484 484 return tmpl("index", entries=entries, subdir=subdir,
485 485 pathdef=hgweb_mod.makebreadcrumb('/' + subdir, self.prefix),
486 486 sortcolumn=sortcolumn, descending=descending,
487 487 **dict(sort))
488 488
489 def templater(self, req, nonce):
489 def templater(self, wsgireq, nonce):
490 490
491 491 def motd(**map):
492 492 if self.motd is not None:
493 493 yield self.motd
494 494 else:
495 495 yield config('web', 'motd')
496 496
497 497 def config(section, name, default=uimod._unset, untrusted=True):
498 498 return self.ui.config(section, name, default, untrusted)
499 499
500 self.updatereqenv(req.env)
500 self.updatereqenv(wsgireq.env)
501 501
502 url = req.env.get('SCRIPT_NAME', '')
502 url = wsgireq.env.get('SCRIPT_NAME', '')
503 503 if not url.endswith('/'):
504 504 url += '/'
505 505
506 506 vars = {}
507 styles, (style, mapfile) = hgweb_mod.getstyle(req, config,
507 styles, (style, mapfile) = hgweb_mod.getstyle(wsgireq, config,
508 508 self.templatepath)
509 509 if style == styles[0]:
510 510 vars['style'] = style
511 511
512 512 start = r'&' if url[-1] == r'?' else r'?'
513 513 sessionvars = webutil.sessionvars(vars, start)
514 514 logourl = config('web', 'logourl')
515 515 logoimg = config('web', 'logoimg')
516 516 staticurl = config('web', 'staticurl') or url + 'static/'
517 517 if not staticurl.endswith('/'):
518 518 staticurl += '/'
519 519
520 520 defaults = {
521 521 "encoding": encoding.encoding,
522 522 "motd": motd,
523 523 "url": url,
524 524 "logourl": logourl,
525 525 "logoimg": logoimg,
526 526 "staticurl": staticurl,
527 527 "sessionvars": sessionvars,
528 528 "style": style,
529 529 "nonce": nonce,
530 530 }
531 531 tmpl = templater.templater.frommapfile(mapfile, defaults=defaults)
532 532 return tmpl
533 533
534 534 def updatereqenv(self, env):
535 535 if self._baseurl is not None:
536 536 name, port, path = geturlcgivars(self._baseurl, env['SERVER_PORT'])
537 537 env['SERVER_NAME'] = name
538 538 env['SERVER_PORT'] = port
539 539 env['SCRIPT_NAME'] = path
@@ -1,651 +1,651 b''
1 1 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
2 2 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
3 3 #
4 4 # This software may be used and distributed according to the terms of the
5 5 # GNU General Public License version 2 or any later version.
6 6
7 7 from __future__ import absolute_import
8 8
9 9 import contextlib
10 10 import struct
11 11 import sys
12 12 import threading
13 13
14 14 from .i18n import _
15 15 from . import (
16 16 encoding,
17 17 error,
18 18 hook,
19 19 pycompat,
20 20 util,
21 21 wireproto,
22 22 wireprototypes,
23 23 )
24 24
25 25 stringio = util.stringio
26 26
27 27 urlerr = util.urlerr
28 28 urlreq = util.urlreq
29 29
30 30 HTTP_OK = 200
31 31
32 32 HGTYPE = 'application/mercurial-0.1'
33 33 HGTYPE2 = 'application/mercurial-0.2'
34 34 HGERRTYPE = 'application/hg-error'
35 35
36 36 SSHV1 = wireprototypes.SSHV1
37 37 SSHV2 = wireprototypes.SSHV2
38 38
39 def decodevaluefromheaders(req, headerprefix):
39 def decodevaluefromheaders(wsgireq, headerprefix):
40 40 """Decode a long value from multiple HTTP request headers.
41 41
42 42 Returns the value as a bytes, not a str.
43 43 """
44 44 chunks = []
45 45 i = 1
46 46 prefix = headerprefix.upper().replace(r'-', r'_')
47 47 while True:
48 v = req.env.get(r'HTTP_%s_%d' % (prefix, i))
48 v = wsgireq.env.get(r'HTTP_%s_%d' % (prefix, i))
49 49 if v is None:
50 50 break
51 51 chunks.append(pycompat.bytesurl(v))
52 52 i += 1
53 53
54 54 return ''.join(chunks)
55 55
56 56 class httpv1protocolhandler(wireprototypes.baseprotocolhandler):
57 def __init__(self, req, ui, checkperm):
58 self._req = req
57 def __init__(self, wsgireq, ui, checkperm):
58 self._wsgireq = wsgireq
59 59 self._ui = ui
60 60 self._checkperm = checkperm
61 61
62 62 @property
63 63 def name(self):
64 64 return 'http-v1'
65 65
66 66 def getargs(self, args):
67 67 knownargs = self._args()
68 68 data = {}
69 69 keys = args.split()
70 70 for k in keys:
71 71 if k == '*':
72 72 star = {}
73 73 for key in knownargs.keys():
74 74 if key != 'cmd' and key not in keys:
75 75 star[key] = knownargs[key][0]
76 76 data['*'] = star
77 77 else:
78 78 data[k] = knownargs[k][0]
79 79 return [data[k] for k in keys]
80 80
81 81 def _args(self):
82 args = util.rapply(pycompat.bytesurl, self._req.form.copy())
83 postlen = int(self._req.env.get(r'HTTP_X_HGARGS_POST', 0))
82 args = util.rapply(pycompat.bytesurl, self._wsgireq.form.copy())
83 postlen = int(self._wsgireq.env.get(r'HTTP_X_HGARGS_POST', 0))
84 84 if postlen:
85 85 args.update(urlreq.parseqs(
86 self._req.read(postlen), keep_blank_values=True))
86 self._wsgireq.read(postlen), keep_blank_values=True))
87 87 return args
88 88
89 argvalue = decodevaluefromheaders(self._req, r'X-HgArg')
89 argvalue = decodevaluefromheaders(self._wsgireq, r'X-HgArg')
90 90 args.update(urlreq.parseqs(argvalue, keep_blank_values=True))
91 91 return args
92 92
93 93 def forwardpayload(self, fp):
94 if r'HTTP_CONTENT_LENGTH' in self._req.env:
95 length = int(self._req.env[r'HTTP_CONTENT_LENGTH'])
94 if r'HTTP_CONTENT_LENGTH' in self._wsgireq.env:
95 length = int(self._wsgireq.env[r'HTTP_CONTENT_LENGTH'])
96 96 else:
97 length = int(self._req.env[r'CONTENT_LENGTH'])
97 length = int(self._wsgireq.env[r'CONTENT_LENGTH'])
98 98 # If httppostargs is used, we need to read Content-Length
99 99 # minus the amount that was consumed by args.
100 length -= int(self._req.env.get(r'HTTP_X_HGARGS_POST', 0))
101 for s in util.filechunkiter(self._req, limit=length):
100 length -= int(self._wsgireq.env.get(r'HTTP_X_HGARGS_POST', 0))
101 for s in util.filechunkiter(self._wsgireq, limit=length):
102 102 fp.write(s)
103 103
104 104 @contextlib.contextmanager
105 105 def mayberedirectstdio(self):
106 106 oldout = self._ui.fout
107 107 olderr = self._ui.ferr
108 108
109 109 out = util.stringio()
110 110
111 111 try:
112 112 self._ui.fout = out
113 113 self._ui.ferr = out
114 114 yield out
115 115 finally:
116 116 self._ui.fout = oldout
117 117 self._ui.ferr = olderr
118 118
119 119 def client(self):
120 120 return 'remote:%s:%s:%s' % (
121 self._req.env.get('wsgi.url_scheme') or 'http',
122 urlreq.quote(self._req.env.get('REMOTE_HOST', '')),
123 urlreq.quote(self._req.env.get('REMOTE_USER', '')))
121 self._wsgireq.env.get('wsgi.url_scheme') or 'http',
122 urlreq.quote(self._wsgireq.env.get('REMOTE_HOST', '')),
123 urlreq.quote(self._wsgireq.env.get('REMOTE_USER', '')))
124 124
125 125 def addcapabilities(self, repo, caps):
126 126 caps.append('httpheader=%d' %
127 127 repo.ui.configint('server', 'maxhttpheaderlen'))
128 128 if repo.ui.configbool('experimental', 'httppostargs'):
129 129 caps.append('httppostargs')
130 130
131 131 # FUTURE advertise 0.2rx once support is implemented
132 132 # FUTURE advertise minrx and mintx after consulting config option
133 133 caps.append('httpmediatype=0.1rx,0.1tx,0.2tx')
134 134
135 135 compengines = wireproto.supportedcompengines(repo.ui, util.SERVERROLE)
136 136 if compengines:
137 137 comptypes = ','.join(urlreq.quote(e.wireprotosupport().name)
138 138 for e in compengines)
139 139 caps.append('compression=%s' % comptypes)
140 140
141 141 return caps
142 142
143 143 def checkperm(self, perm):
144 144 return self._checkperm(perm)
145 145
146 146 # This method exists mostly so that extensions like remotefilelog can
147 147 # disable a kludgey legacy method only over http. As of early 2018,
148 148 # there are no other known users, so with any luck we can discard this
149 149 # hook if remotefilelog becomes a first-party extension.
150 150 def iscmd(cmd):
151 151 return cmd in wireproto.commands
152 152
153 def parsehttprequest(rctx, req, query, checkperm):
153 def parsehttprequest(rctx, wsgireq, query, checkperm):
154 154 """Parse the HTTP request for a wire protocol request.
155 155
156 156 If the current request appears to be a wire protocol request, this
157 157 function returns a dict with details about that request, including
158 158 an ``abstractprotocolserver`` instance suitable for handling the
159 159 request. Otherwise, ``None`` is returned.
160 160
161 ``req`` is a ``wsgirequest`` instance.
161 ``wsgireq`` is a ``wsgirequest`` instance.
162 162 """
163 163 repo = rctx.repo
164 164
165 165 # HTTP version 1 wire protocol requests are denoted by a "cmd" query
166 166 # string parameter. If it isn't present, this isn't a wire protocol
167 167 # request.
168 if 'cmd' not in req.form:
168 if 'cmd' not in wsgireq.form:
169 169 return None
170 170
171 cmd = req.form['cmd'][0]
171 cmd = wsgireq.form['cmd'][0]
172 172
173 173 # The "cmd" request parameter is used by both the wire protocol and hgweb.
174 174 # While not all wire protocol commands are available for all transports,
175 175 # if we see a "cmd" value that resembles a known wire protocol command, we
176 176 # route it to a protocol handler. This is better than routing possible
177 177 # wire protocol requests to hgweb because it prevents hgweb from using
178 178 # known wire protocol commands and it is less confusing for machine
179 179 # clients.
180 180 if not iscmd(cmd):
181 181 return None
182 182
183 proto = httpv1protocolhandler(req, repo.ui,
184 lambda perm: checkperm(rctx, req, perm))
183 proto = httpv1protocolhandler(wsgireq, repo.ui,
184 lambda perm: checkperm(rctx, wsgireq, perm))
185 185
186 186 return {
187 187 'cmd': cmd,
188 188 'proto': proto,
189 'dispatch': lambda: _callhttp(repo, req, proto, cmd),
190 'handleerror': lambda ex: _handlehttperror(ex, req, cmd),
189 'dispatch': lambda: _callhttp(repo, wsgireq, proto, cmd),
190 'handleerror': lambda ex: _handlehttperror(ex, wsgireq, cmd),
191 191 }
192 192
193 def _httpresponsetype(ui, req, prefer_uncompressed):
193 def _httpresponsetype(ui, wsgireq, prefer_uncompressed):
194 194 """Determine the appropriate response type and compression settings.
195 195
196 196 Returns a tuple of (mediatype, compengine, engineopts).
197 197 """
198 198 # Determine the response media type and compression engine based
199 199 # on the request parameters.
200 protocaps = decodevaluefromheaders(req, r'X-HgProto').split(' ')
200 protocaps = decodevaluefromheaders(wsgireq, r'X-HgProto').split(' ')
201 201
202 202 if '0.2' in protocaps:
203 203 # All clients are expected to support uncompressed data.
204 204 if prefer_uncompressed:
205 205 return HGTYPE2, util._noopengine(), {}
206 206
207 207 # Default as defined by wire protocol spec.
208 208 compformats = ['zlib', 'none']
209 209 for cap in protocaps:
210 210 if cap.startswith('comp='):
211 211 compformats = cap[5:].split(',')
212 212 break
213 213
214 214 # Now find an agreed upon compression format.
215 215 for engine in wireproto.supportedcompengines(ui, util.SERVERROLE):
216 216 if engine.wireprotosupport().name in compformats:
217 217 opts = {}
218 218 level = ui.configint('server', '%slevel' % engine.name())
219 219 if level is not None:
220 220 opts['level'] = level
221 221
222 222 return HGTYPE2, engine, opts
223 223
224 224 # No mutually supported compression format. Fall back to the
225 225 # legacy protocol.
226 226
227 227 # Don't allow untrusted settings because disabling compression or
228 228 # setting a very high compression level could lead to flooding
229 229 # the server's network or CPU.
230 230 opts = {'level': ui.configint('server', 'zliblevel')}
231 231 return HGTYPE, util.compengines['zlib'], opts
232 232
233 def _callhttp(repo, req, proto, cmd):
233 def _callhttp(repo, wsgireq, proto, cmd):
234 234 def genversion2(gen, engine, engineopts):
235 235 # application/mercurial-0.2 always sends a payload header
236 236 # identifying the compression engine.
237 237 name = engine.wireprotosupport().name
238 238 assert 0 < len(name) < 256
239 239 yield struct.pack('B', len(name))
240 240 yield name
241 241
242 242 for chunk in gen:
243 243 yield chunk
244 244
245 245 if not wireproto.commands.commandavailable(cmd, proto):
246 req.respond(HTTP_OK, HGERRTYPE,
247 body=_('requested wire protocol command is not available '
248 'over HTTP'))
246 wsgireq.respond(HTTP_OK, HGERRTYPE,
247 body=_('requested wire protocol command is not '
248 'available over HTTP'))
249 249 return []
250 250
251 251 proto.checkperm(wireproto.commands[cmd].permission)
252 252
253 253 rsp = wireproto.dispatch(repo, proto, cmd)
254 254
255 255 if isinstance(rsp, bytes):
256 req.respond(HTTP_OK, HGTYPE, body=rsp)
256 wsgireq.respond(HTTP_OK, HGTYPE, body=rsp)
257 257 return []
258 258 elif isinstance(rsp, wireprototypes.bytesresponse):
259 req.respond(HTTP_OK, HGTYPE, body=rsp.data)
259 wsgireq.respond(HTTP_OK, HGTYPE, body=rsp.data)
260 260 return []
261 261 elif isinstance(rsp, wireprototypes.streamreslegacy):
262 262 gen = rsp.gen
263 req.respond(HTTP_OK, HGTYPE)
263 wsgireq.respond(HTTP_OK, HGTYPE)
264 264 return gen
265 265 elif isinstance(rsp, wireprototypes.streamres):
266 266 gen = rsp.gen
267 267
268 268 # This code for compression should not be streamres specific. It
269 269 # is here because we only compress streamres at the moment.
270 270 mediatype, engine, engineopts = _httpresponsetype(
271 repo.ui, req, rsp.prefer_uncompressed)
271 repo.ui, wsgireq, rsp.prefer_uncompressed)
272 272 gen = engine.compressstream(gen, engineopts)
273 273
274 274 if mediatype == HGTYPE2:
275 275 gen = genversion2(gen, engine, engineopts)
276 276
277 req.respond(HTTP_OK, mediatype)
277 wsgireq.respond(HTTP_OK, mediatype)
278 278 return gen
279 279 elif isinstance(rsp, wireprototypes.pushres):
280 280 rsp = '%d\n%s' % (rsp.res, rsp.output)
281 req.respond(HTTP_OK, HGTYPE, body=rsp)
281 wsgireq.respond(HTTP_OK, HGTYPE, body=rsp)
282 282 return []
283 283 elif isinstance(rsp, wireprototypes.pusherr):
284 284 # This is the httplib workaround documented in _handlehttperror().
285 req.drain()
285 wsgireq.drain()
286 286
287 287 rsp = '0\n%s\n' % rsp.res
288 req.respond(HTTP_OK, HGTYPE, body=rsp)
288 wsgireq.respond(HTTP_OK, HGTYPE, body=rsp)
289 289 return []
290 290 elif isinstance(rsp, wireprototypes.ooberror):
291 291 rsp = rsp.message
292 req.respond(HTTP_OK, HGERRTYPE, body=rsp)
292 wsgireq.respond(HTTP_OK, HGERRTYPE, body=rsp)
293 293 return []
294 294 raise error.ProgrammingError('hgweb.protocol internal failure', rsp)
295 295
296 def _handlehttperror(e, req, cmd):
296 def _handlehttperror(e, wsgireq, cmd):
297 297 """Called when an ErrorResponse is raised during HTTP request processing."""
298 298
299 299 # Clients using Python's httplib are stateful: the HTTP client
300 300 # won't process an HTTP response until all request data is
301 301 # sent to the server. The intent of this code is to ensure
302 302 # we always read HTTP request data from the client, thus
303 303 # ensuring httplib transitions to a state that allows it to read
304 304 # the HTTP response. In other words, it helps prevent deadlocks
305 305 # on clients using httplib.
306 306
307 if (req.env[r'REQUEST_METHOD'] == r'POST' and
307 if (wsgireq.env[r'REQUEST_METHOD'] == r'POST' and
308 308 # But not if Expect: 100-continue is being used.
309 (req.env.get('HTTP_EXPECT',
310 '').lower() != '100-continue') or
309 (wsgireq.env.get('HTTP_EXPECT',
310 '').lower() != '100-continue') or
311 311 # Or the non-httplib HTTP library is being advertised by
312 312 # the client.
313 req.env.get('X-HgHttp2', '')):
314 req.drain()
313 wsgireq.env.get('X-HgHttp2', '')):
314 wsgireq.drain()
315 315 else:
316 req.headers.append((r'Connection', r'Close'))
316 wsgireq.headers.append((r'Connection', r'Close'))
317 317
318 318 # TODO This response body assumes the failed command was
319 319 # "unbundle." That assumption is not always valid.
320 req.respond(e, HGTYPE, body='0\n%s\n' % pycompat.bytestr(e))
320 wsgireq.respond(e, HGTYPE, body='0\n%s\n' % pycompat.bytestr(e))
321 321
322 322 return ''
323 323
324 324 def _sshv1respondbytes(fout, value):
325 325 """Send a bytes response for protocol version 1."""
326 326 fout.write('%d\n' % len(value))
327 327 fout.write(value)
328 328 fout.flush()
329 329
330 330 def _sshv1respondstream(fout, source):
331 331 write = fout.write
332 332 for chunk in source.gen:
333 333 write(chunk)
334 334 fout.flush()
335 335
336 336 def _sshv1respondooberror(fout, ferr, rsp):
337 337 ferr.write(b'%s\n-\n' % rsp)
338 338 ferr.flush()
339 339 fout.write(b'\n')
340 340 fout.flush()
341 341
342 342 class sshv1protocolhandler(wireprototypes.baseprotocolhandler):
343 343 """Handler for requests services via version 1 of SSH protocol."""
344 344 def __init__(self, ui, fin, fout):
345 345 self._ui = ui
346 346 self._fin = fin
347 347 self._fout = fout
348 348
349 349 @property
350 350 def name(self):
351 351 return wireprototypes.SSHV1
352 352
353 353 def getargs(self, args):
354 354 data = {}
355 355 keys = args.split()
356 356 for n in xrange(len(keys)):
357 357 argline = self._fin.readline()[:-1]
358 358 arg, l = argline.split()
359 359 if arg not in keys:
360 360 raise error.Abort(_("unexpected parameter %r") % arg)
361 361 if arg == '*':
362 362 star = {}
363 363 for k in xrange(int(l)):
364 364 argline = self._fin.readline()[:-1]
365 365 arg, l = argline.split()
366 366 val = self._fin.read(int(l))
367 367 star[arg] = val
368 368 data['*'] = star
369 369 else:
370 370 val = self._fin.read(int(l))
371 371 data[arg] = val
372 372 return [data[k] for k in keys]
373 373
374 374 def forwardpayload(self, fpout):
375 375 # We initially send an empty response. This tells the client it is
376 376 # OK to start sending data. If a client sees any other response, it
377 377 # interprets it as an error.
378 378 _sshv1respondbytes(self._fout, b'')
379 379
380 380 # The file is in the form:
381 381 #
382 382 # <chunk size>\n<chunk>
383 383 # ...
384 384 # 0\n
385 385 count = int(self._fin.readline())
386 386 while count:
387 387 fpout.write(self._fin.read(count))
388 388 count = int(self._fin.readline())
389 389
390 390 @contextlib.contextmanager
391 391 def mayberedirectstdio(self):
392 392 yield None
393 393
394 394 def client(self):
395 395 client = encoding.environ.get('SSH_CLIENT', '').split(' ', 1)[0]
396 396 return 'remote:ssh:' + client
397 397
398 398 def addcapabilities(self, repo, caps):
399 399 return caps
400 400
401 401 def checkperm(self, perm):
402 402 pass
403 403
404 404 class sshv2protocolhandler(sshv1protocolhandler):
405 405 """Protocol handler for version 2 of the SSH protocol."""
406 406
407 407 @property
408 408 def name(self):
409 409 return wireprototypes.SSHV2
410 410
411 411 def _runsshserver(ui, repo, fin, fout, ev):
412 412 # This function operates like a state machine of sorts. The following
413 413 # states are defined:
414 414 #
415 415 # protov1-serving
416 416 # Server is in protocol version 1 serving mode. Commands arrive on
417 417 # new lines. These commands are processed in this state, one command
418 418 # after the other.
419 419 #
420 420 # protov2-serving
421 421 # Server is in protocol version 2 serving mode.
422 422 #
423 423 # upgrade-initial
424 424 # The server is going to process an upgrade request.
425 425 #
426 426 # upgrade-v2-filter-legacy-handshake
427 427 # The protocol is being upgraded to version 2. The server is expecting
428 428 # the legacy handshake from version 1.
429 429 #
430 430 # upgrade-v2-finish
431 431 # The upgrade to version 2 of the protocol is imminent.
432 432 #
433 433 # shutdown
434 434 # The server is shutting down, possibly in reaction to a client event.
435 435 #
436 436 # And here are their transitions:
437 437 #
438 438 # protov1-serving -> shutdown
439 439 # When server receives an empty request or encounters another
440 440 # error.
441 441 #
442 442 # protov1-serving -> upgrade-initial
443 443 # An upgrade request line was seen.
444 444 #
445 445 # upgrade-initial -> upgrade-v2-filter-legacy-handshake
446 446 # Upgrade to version 2 in progress. Server is expecting to
447 447 # process a legacy handshake.
448 448 #
449 449 # upgrade-v2-filter-legacy-handshake -> shutdown
450 450 # Client did not fulfill upgrade handshake requirements.
451 451 #
452 452 # upgrade-v2-filter-legacy-handshake -> upgrade-v2-finish
453 453 # Client fulfilled version 2 upgrade requirements. Finishing that
454 454 # upgrade.
455 455 #
456 456 # upgrade-v2-finish -> protov2-serving
457 457 # Protocol upgrade to version 2 complete. Server can now speak protocol
458 458 # version 2.
459 459 #
460 460 # protov2-serving -> protov1-serving
461 461 # Ths happens by default since protocol version 2 is the same as
462 462 # version 1 except for the handshake.
463 463
464 464 state = 'protov1-serving'
465 465 proto = sshv1protocolhandler(ui, fin, fout)
466 466 protoswitched = False
467 467
468 468 while not ev.is_set():
469 469 if state == 'protov1-serving':
470 470 # Commands are issued on new lines.
471 471 request = fin.readline()[:-1]
472 472
473 473 # Empty lines signal to terminate the connection.
474 474 if not request:
475 475 state = 'shutdown'
476 476 continue
477 477
478 478 # It looks like a protocol upgrade request. Transition state to
479 479 # handle it.
480 480 if request.startswith(b'upgrade '):
481 481 if protoswitched:
482 482 _sshv1respondooberror(fout, ui.ferr,
483 483 b'cannot upgrade protocols multiple '
484 484 b'times')
485 485 state = 'shutdown'
486 486 continue
487 487
488 488 state = 'upgrade-initial'
489 489 continue
490 490
491 491 available = wireproto.commands.commandavailable(request, proto)
492 492
493 493 # This command isn't available. Send an empty response and go
494 494 # back to waiting for a new command.
495 495 if not available:
496 496 _sshv1respondbytes(fout, b'')
497 497 continue
498 498
499 499 rsp = wireproto.dispatch(repo, proto, request)
500 500
501 501 if isinstance(rsp, bytes):
502 502 _sshv1respondbytes(fout, rsp)
503 503 elif isinstance(rsp, wireprototypes.bytesresponse):
504 504 _sshv1respondbytes(fout, rsp.data)
505 505 elif isinstance(rsp, wireprototypes.streamres):
506 506 _sshv1respondstream(fout, rsp)
507 507 elif isinstance(rsp, wireprototypes.streamreslegacy):
508 508 _sshv1respondstream(fout, rsp)
509 509 elif isinstance(rsp, wireprototypes.pushres):
510 510 _sshv1respondbytes(fout, b'')
511 511 _sshv1respondbytes(fout, b'%d' % rsp.res)
512 512 elif isinstance(rsp, wireprototypes.pusherr):
513 513 _sshv1respondbytes(fout, rsp.res)
514 514 elif isinstance(rsp, wireprototypes.ooberror):
515 515 _sshv1respondooberror(fout, ui.ferr, rsp.message)
516 516 else:
517 517 raise error.ProgrammingError('unhandled response type from '
518 518 'wire protocol command: %s' % rsp)
519 519
520 520 # For now, protocol version 2 serving just goes back to version 1.
521 521 elif state == 'protov2-serving':
522 522 state = 'protov1-serving'
523 523 continue
524 524
525 525 elif state == 'upgrade-initial':
526 526 # We should never transition into this state if we've switched
527 527 # protocols.
528 528 assert not protoswitched
529 529 assert proto.name == wireprototypes.SSHV1
530 530
531 531 # Expected: upgrade <token> <capabilities>
532 532 # If we get something else, the request is malformed. It could be
533 533 # from a future client that has altered the upgrade line content.
534 534 # We treat this as an unknown command.
535 535 try:
536 536 token, caps = request.split(b' ')[1:]
537 537 except ValueError:
538 538 _sshv1respondbytes(fout, b'')
539 539 state = 'protov1-serving'
540 540 continue
541 541
542 542 # Send empty response if we don't support upgrading protocols.
543 543 if not ui.configbool('experimental', 'sshserver.support-v2'):
544 544 _sshv1respondbytes(fout, b'')
545 545 state = 'protov1-serving'
546 546 continue
547 547
548 548 try:
549 549 caps = urlreq.parseqs(caps)
550 550 except ValueError:
551 551 _sshv1respondbytes(fout, b'')
552 552 state = 'protov1-serving'
553 553 continue
554 554
555 555 # We don't see an upgrade request to protocol version 2. Ignore
556 556 # the upgrade request.
557 557 wantedprotos = caps.get(b'proto', [b''])[0]
558 558 if SSHV2 not in wantedprotos:
559 559 _sshv1respondbytes(fout, b'')
560 560 state = 'protov1-serving'
561 561 continue
562 562
563 563 # It looks like we can honor this upgrade request to protocol 2.
564 564 # Filter the rest of the handshake protocol request lines.
565 565 state = 'upgrade-v2-filter-legacy-handshake'
566 566 continue
567 567
568 568 elif state == 'upgrade-v2-filter-legacy-handshake':
569 569 # Client should have sent legacy handshake after an ``upgrade``
570 570 # request. Expected lines:
571 571 #
572 572 # hello
573 573 # between
574 574 # pairs 81
575 575 # 0000...-0000...
576 576
577 577 ok = True
578 578 for line in (b'hello', b'between', b'pairs 81'):
579 579 request = fin.readline()[:-1]
580 580
581 581 if request != line:
582 582 _sshv1respondooberror(fout, ui.ferr,
583 583 b'malformed handshake protocol: '
584 584 b'missing %s' % line)
585 585 ok = False
586 586 state = 'shutdown'
587 587 break
588 588
589 589 if not ok:
590 590 continue
591 591
592 592 request = fin.read(81)
593 593 if request != b'%s-%s' % (b'0' * 40, b'0' * 40):
594 594 _sshv1respondooberror(fout, ui.ferr,
595 595 b'malformed handshake protocol: '
596 596 b'missing between argument value')
597 597 state = 'shutdown'
598 598 continue
599 599
600 600 state = 'upgrade-v2-finish'
601 601 continue
602 602
603 603 elif state == 'upgrade-v2-finish':
604 604 # Send the upgrade response.
605 605 fout.write(b'upgraded %s %s\n' % (token, SSHV2))
606 606 servercaps = wireproto.capabilities(repo, proto)
607 607 rsp = b'capabilities: %s' % servercaps.data
608 608 fout.write(b'%d\n%s\n' % (len(rsp), rsp))
609 609 fout.flush()
610 610
611 611 proto = sshv2protocolhandler(ui, fin, fout)
612 612 protoswitched = True
613 613
614 614 state = 'protov2-serving'
615 615 continue
616 616
617 617 elif state == 'shutdown':
618 618 break
619 619
620 620 else:
621 621 raise error.ProgrammingError('unhandled ssh server state: %s' %
622 622 state)
623 623
624 624 class sshserver(object):
625 625 def __init__(self, ui, repo, logfh=None):
626 626 self._ui = ui
627 627 self._repo = repo
628 628 self._fin = ui.fin
629 629 self._fout = ui.fout
630 630
631 631 # Log write I/O to stdout and stderr if configured.
632 632 if logfh:
633 633 self._fout = util.makeloggingfileobject(
634 634 logfh, self._fout, 'o', logdata=True)
635 635 ui.ferr = util.makeloggingfileobject(
636 636 logfh, ui.ferr, 'e', logdata=True)
637 637
638 638 hook.redirect(True)
639 639 ui.fout = repo.ui.fout = ui.ferr
640 640
641 641 # Prevent insertion/deletion of CRs
642 642 util.setbinary(self._fin)
643 643 util.setbinary(self._fout)
644 644
645 645 def serve_forever(self):
646 646 self.serveuntil(threading.Event())
647 647 sys.exit(0)
648 648
649 649 def serveuntil(self, ev):
650 650 """Serve until a threading.Event is set."""
651 651 _runsshserver(self._ui, self._repo, self._fin, self._fout, ev)
General Comments 0
You need to be logged in to leave comments. Login now