##// END OF EJS Templates
hgweb: skip body creation of HEAD for most requests...
Joerg Sonnenberger -
r50741:fda5a4b8 default
parent child Browse files
Show More
@@ -1,515 +1,516 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 Olivia Mackall <olivia@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
10 10 import contextlib
11 11 import os
12 12
13 13 from .common import (
14 14 ErrorResponse,
15 15 HTTP_BAD_REQUEST,
16 16 cspvalues,
17 17 permhooks,
18 18 statusmessage,
19 19 )
20 20 from ..pycompat import getattr
21 21
22 22 from .. import (
23 23 encoding,
24 24 error,
25 25 extensions,
26 26 formatter,
27 27 hg,
28 28 hook,
29 29 profiling,
30 30 pycompat,
31 31 registrar,
32 32 repoview,
33 33 templatefilters,
34 34 templater,
35 35 templateutil,
36 36 ui as uimod,
37 37 util,
38 38 wireprotoserver,
39 39 )
40 40
41 41 from . import (
42 42 request as requestmod,
43 43 webcommands,
44 44 webutil,
45 45 wsgicgi,
46 46 )
47 47
48 48
49 49 def getstyle(req, configfn, templatepath):
50 50 styles = (
51 51 req.qsparams.get(b'style', None),
52 52 configfn(b'web', b'style'),
53 53 b'paper',
54 54 )
55 55 return styles, _stylemap(styles, templatepath)
56 56
57 57
58 58 def _stylemap(styles, path=None):
59 59 """Return path to mapfile for a given style.
60 60
61 61 Searches mapfile in the following locations:
62 62 1. templatepath/style/map
63 63 2. templatepath/map-style
64 64 3. templatepath/map
65 65 """
66 66
67 67 for style in styles:
68 68 # only plain name is allowed to honor template paths
69 69 if (
70 70 not style
71 71 or style in (pycompat.oscurdir, pycompat.ospardir)
72 72 or pycompat.ossep in style
73 73 or pycompat.osaltsep
74 74 and pycompat.osaltsep in style
75 75 ):
76 76 continue
77 77 locations = (os.path.join(style, b'map'), b'map-' + style, b'map')
78 78
79 79 for location in locations:
80 80 mapfile, fp = templater.try_open_template(location, path)
81 81 if mapfile:
82 82 return style, mapfile, fp
83 83
84 84 raise RuntimeError(b"No hgweb templates found in %r" % path)
85 85
86 86
87 87 def makebreadcrumb(url, prefix=b''):
88 88 """Return a 'URL breadcrumb' list
89 89
90 90 A 'URL breadcrumb' is a list of URL-name pairs,
91 91 corresponding to each of the path items on a URL.
92 92 This can be used to create path navigation entries.
93 93 """
94 94 if url.endswith(b'/'):
95 95 url = url[:-1]
96 96 if prefix:
97 97 url = b'/' + prefix + url
98 98 relpath = url
99 99 if relpath.startswith(b'/'):
100 100 relpath = relpath[1:]
101 101
102 102 breadcrumb = []
103 103 urlel = url
104 104 pathitems = [b''] + relpath.split(b'/')
105 105 for pathel in reversed(pathitems):
106 106 if not pathel or not urlel:
107 107 break
108 108 breadcrumb.append({b'url': urlel, b'name': pathel})
109 109 urlel = os.path.dirname(urlel)
110 110 return templateutil.mappinglist(reversed(breadcrumb))
111 111
112 112
113 113 class requestcontext:
114 114 """Holds state/context for an individual request.
115 115
116 116 Servers can be multi-threaded. Holding state on the WSGI application
117 117 is prone to race conditions. Instances of this class exist to hold
118 118 mutable and race-free state for requests.
119 119 """
120 120
121 121 def __init__(self, app, repo, req, res):
122 122 self.repo = repo
123 123 self.reponame = app.reponame
124 124 self.req = req
125 125 self.res = res
126 126
127 127 self.maxchanges = self.configint(b'web', b'maxchanges')
128 128 self.stripecount = self.configint(b'web', b'stripes')
129 129 self.maxshortchanges = self.configint(b'web', b'maxshortchanges')
130 130 self.maxfiles = self.configint(b'web', b'maxfiles')
131 131 self.allowpull = self.configbool(b'web', b'allow-pull')
132 132
133 133 # we use untrusted=False to prevent a repo owner from using
134 134 # web.templates in .hg/hgrc to get access to any file readable
135 135 # by the user running the CGI script
136 136 self.templatepath = self.config(b'web', b'templates', untrusted=False)
137 137
138 138 # This object is more expensive to build than simple config values.
139 139 # It is shared across requests. The app will replace the object
140 140 # if it is updated. Since this is a reference and nothing should
141 141 # modify the underlying object, it should be constant for the lifetime
142 142 # of the request.
143 143 self.websubtable = app.websubtable
144 144
145 145 self.csp, self.nonce = cspvalues(self.repo.ui)
146 146
147 147 # Trust the settings from the .hg/hgrc files by default.
148 148 def config(self, *args, **kwargs):
149 149 kwargs.setdefault('untrusted', True)
150 150 return self.repo.ui.config(*args, **kwargs)
151 151
152 152 def configbool(self, *args, **kwargs):
153 153 kwargs.setdefault('untrusted', True)
154 154 return self.repo.ui.configbool(*args, **kwargs)
155 155
156 156 def configint(self, *args, **kwargs):
157 157 kwargs.setdefault('untrusted', True)
158 158 return self.repo.ui.configint(*args, **kwargs)
159 159
160 160 def configlist(self, *args, **kwargs):
161 161 kwargs.setdefault('untrusted', True)
162 162 return self.repo.ui.configlist(*args, **kwargs)
163 163
164 164 def archivelist(self, nodeid):
165 165 return webutil.archivelist(self.repo.ui, nodeid)
166 166
167 167 def templater(self, req):
168 168 # determine scheme, port and server name
169 169 # this is needed to create absolute urls
170 170 logourl = self.config(b'web', b'logourl')
171 171 logoimg = self.config(b'web', b'logoimg')
172 172 staticurl = (
173 173 self.config(b'web', b'staticurl')
174 174 or req.apppath.rstrip(b'/') + b'/static/'
175 175 )
176 176 if not staticurl.endswith(b'/'):
177 177 staticurl += b'/'
178 178
179 179 # figure out which style to use
180 180
181 181 vars = {}
182 182 styles, (style, mapfile, fp) = getstyle(
183 183 req, self.config, self.templatepath
184 184 )
185 185 if style == styles[0]:
186 186 vars[b'style'] = style
187 187
188 188 sessionvars = webutil.sessionvars(vars, b'?')
189 189
190 190 if not self.reponame:
191 191 self.reponame = (
192 192 self.config(b'web', b'name', b'')
193 193 or req.reponame
194 194 or req.apppath
195 195 or self.repo.root
196 196 )
197 197
198 198 filters = {}
199 199 templatefilter = registrar.templatefilter(filters)
200 200
201 201 @templatefilter(b'websub', intype=bytes)
202 202 def websubfilter(text):
203 203 return templatefilters.websub(text, self.websubtable)
204 204
205 205 # create the templater
206 206 # TODO: export all keywords: defaults = templatekw.keywords.copy()
207 207 defaults = {
208 208 b'url': req.apppath + b'/',
209 209 b'logourl': logourl,
210 210 b'logoimg': logoimg,
211 211 b'staticurl': staticurl,
212 212 b'urlbase': req.advertisedbaseurl,
213 213 b'repo': self.reponame,
214 214 b'encoding': encoding.encoding,
215 215 b'sessionvars': sessionvars,
216 216 b'pathdef': makebreadcrumb(req.apppath),
217 217 b'style': style,
218 218 b'nonce': self.nonce,
219 219 }
220 220 templatekeyword = registrar.templatekeyword(defaults)
221 221
222 222 @templatekeyword(b'motd', requires=())
223 223 def motd(context, mapping):
224 224 yield self.config(b'web', b'motd')
225 225
226 226 tres = formatter.templateresources(self.repo.ui, self.repo)
227 227 return templater.templater.frommapfile(
228 228 mapfile, fp=fp, filters=filters, defaults=defaults, resources=tres
229 229 )
230 230
231 231 def sendtemplate(self, name, **kwargs):
232 232 """Helper function to send a response generated from a template."""
233 kwargs = pycompat.byteskwargs(kwargs)
234 self.res.setbodygen(self.tmpl.generate(name, kwargs))
233 if self.req.method != b'HEAD':
234 kwargs = pycompat.byteskwargs(kwargs)
235 self.res.setbodygen(self.tmpl.generate(name, kwargs))
235 236 return self.res.sendresponse()
236 237
237 238
238 239 class hgweb:
239 240 """HTTP server for individual repositories.
240 241
241 242 Instances of this class serve HTTP responses for a particular
242 243 repository.
243 244
244 245 Instances are typically used as WSGI applications.
245 246
246 247 Some servers are multi-threaded. On these servers, there may
247 248 be multiple active threads inside __call__.
248 249 """
249 250
250 251 def __init__(self, repo, name=None, baseui=None):
251 252 if isinstance(repo, bytes):
252 253 if baseui:
253 254 u = baseui.copy()
254 255 else:
255 256 u = uimod.ui.load()
256 257 extensions.loadall(u)
257 258 extensions.populateui(u)
258 259 r = hg.repository(u, repo)
259 260 else:
260 261 # we trust caller to give us a private copy
261 262 r = repo
262 263
263 264 r.ui.setconfig(b'ui', b'report_untrusted', b'off', b'hgweb')
264 265 r.baseui.setconfig(b'ui', b'report_untrusted', b'off', b'hgweb')
265 266 r.ui.setconfig(b'ui', b'nontty', b'true', b'hgweb')
266 267 r.baseui.setconfig(b'ui', b'nontty', b'true', b'hgweb')
267 268 # resolve file patterns relative to repo root
268 269 r.ui.setconfig(b'ui', b'forcecwd', r.root, b'hgweb')
269 270 r.baseui.setconfig(b'ui', b'forcecwd', r.root, b'hgweb')
270 271 # it's unlikely that we can replace signal handlers in WSGI server,
271 272 # and mod_wsgi issues a big warning. a plain hgweb process (with no
272 273 # threading) could replace signal handlers, but we don't bother
273 274 # conditionally enabling it.
274 275 r.ui.setconfig(b'ui', b'signal-safe-lock', b'false', b'hgweb')
275 276 r.baseui.setconfig(b'ui', b'signal-safe-lock', b'false', b'hgweb')
276 277 # displaying bundling progress bar while serving feel wrong and may
277 278 # break some wsgi implementation.
278 279 r.ui.setconfig(b'progress', b'disable', b'true', b'hgweb')
279 280 r.baseui.setconfig(b'progress', b'disable', b'true', b'hgweb')
280 281 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
281 282 self._lastrepo = self._repos[0]
282 283 hook.redirect(True)
283 284 self.reponame = name
284 285
285 286 def _webifyrepo(self, repo):
286 287 repo = getwebview(repo)
287 288 self.websubtable = webutil.getwebsubs(repo)
288 289 return repo
289 290
290 291 @contextlib.contextmanager
291 292 def _obtainrepo(self):
292 293 """Obtain a repo unique to the caller.
293 294
294 295 Internally we maintain a stack of cachedlocalrepo instances
295 296 to be handed out. If one is available, we pop it and return it,
296 297 ensuring it is up to date in the process. If one is not available,
297 298 we clone the most recently used repo instance and return it.
298 299
299 300 It is currently possible for the stack to grow without bounds
300 301 if the server allows infinite threads. However, servers should
301 302 have a thread limit, thus establishing our limit.
302 303 """
303 304 if self._repos:
304 305 cached = self._repos.pop()
305 306 r, created = cached.fetch()
306 307 else:
307 308 cached = self._lastrepo.copy()
308 309 r, created = cached.fetch()
309 310 if created:
310 311 r = self._webifyrepo(r)
311 312
312 313 self._lastrepo = cached
313 314 self.mtime = cached.mtime
314 315 try:
315 316 yield r
316 317 finally:
317 318 self._repos.append(cached)
318 319
319 320 def run(self):
320 321 """Start a server from CGI environment.
321 322
322 323 Modern servers should be using WSGI and should avoid this
323 324 method, if possible.
324 325 """
325 326 if not encoding.environ.get(b'GATEWAY_INTERFACE', b'').startswith(
326 327 b"CGI/1."
327 328 ):
328 329 raise RuntimeError(
329 330 b"This function is only intended to be "
330 331 b"called while running as a CGI script."
331 332 )
332 333 wsgicgi.launch(self)
333 334
334 335 def __call__(self, env, respond):
335 336 """Run the WSGI application.
336 337
337 338 This may be called by multiple threads.
338 339 """
339 340 req = requestmod.parserequestfromenv(env)
340 341 res = requestmod.wsgiresponse(req, respond)
341 342
342 343 return self.run_wsgi(req, res)
343 344
344 345 def run_wsgi(self, req, res):
345 346 """Internal method to run the WSGI application.
346 347
347 348 This is typically only called by Mercurial. External consumers
348 349 should be using instances of this class as the WSGI application.
349 350 """
350 351 with self._obtainrepo() as repo:
351 352 profile = repo.ui.configbool(b'profiling', b'enabled')
352 353 with profiling.profile(repo.ui, enabled=profile):
353 354 for r in self._runwsgi(req, res, repo):
354 355 yield r
355 356
356 357 def _runwsgi(self, req, res, repo):
357 358 rctx = requestcontext(self, repo, req, res)
358 359
359 360 # This state is global across all threads.
360 361 encoding.encoding = rctx.config(b'web', b'encoding')
361 362 rctx.repo.ui.environ = req.rawenv
362 363
363 364 if rctx.csp:
364 365 # hgwebdir may have added CSP header. Since we generate our own,
365 366 # replace it.
366 367 res.headers[b'Content-Security-Policy'] = rctx.csp
367 368
368 369 handled = wireprotoserver.handlewsgirequest(
369 370 rctx, req, res, self.check_perm
370 371 )
371 372 if handled:
372 373 return res.sendresponse()
373 374
374 375 # Old implementations of hgweb supported dispatching the request via
375 376 # the initial query string parameter instead of using PATH_INFO.
376 377 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having
377 378 # a value), we use it. Otherwise fall back to the query string.
378 379 if req.dispatchpath is not None:
379 380 query = req.dispatchpath
380 381 else:
381 382 query = req.querystring.partition(b'&')[0].partition(b';')[0]
382 383
383 384 # translate user-visible url structure to internal structure
384 385
385 386 args = query.split(b'/', 2)
386 387 if b'cmd' not in req.qsparams and args and args[0]:
387 388 cmd = args.pop(0)
388 389 style = cmd.rfind(b'-')
389 390 if style != -1:
390 391 req.qsparams[b'style'] = cmd[:style]
391 392 cmd = cmd[style + 1 :]
392 393
393 394 # avoid accepting e.g. style parameter as command
394 395 if util.safehasattr(webcommands, cmd):
395 396 req.qsparams[b'cmd'] = cmd
396 397
397 398 if cmd == b'static':
398 399 req.qsparams[b'file'] = b'/'.join(args)
399 400 else:
400 401 if args and args[0]:
401 402 node = args.pop(0).replace(b'%2F', b'/')
402 403 req.qsparams[b'node'] = node
403 404 if args:
404 405 if b'file' in req.qsparams:
405 406 del req.qsparams[b'file']
406 407 for a in args:
407 408 req.qsparams.add(b'file', a)
408 409
409 410 ua = req.headers.get(b'User-Agent', b'')
410 411 if cmd == b'rev' and b'mercurial' in ua:
411 412 req.qsparams[b'style'] = b'raw'
412 413
413 414 if cmd == b'archive':
414 415 fn = req.qsparams[b'node']
415 416 for type_, spec in webutil.archivespecs.items():
416 417 ext = spec[2]
417 418 if fn.endswith(ext):
418 419 req.qsparams[b'node'] = fn[: -len(ext)]
419 420 req.qsparams[b'type'] = type_
420 421 else:
421 422 cmd = req.qsparams.get(b'cmd', b'')
422 423
423 424 # process the web interface request
424 425
425 426 try:
426 427 rctx.tmpl = rctx.templater(req)
427 428 ctype = rctx.tmpl.render(
428 429 b'mimetype', {b'encoding': encoding.encoding}
429 430 )
430 431
431 432 # check read permissions non-static content
432 433 if cmd != b'static':
433 434 self.check_perm(rctx, req, None)
434 435
435 436 if cmd == b'':
436 437 req.qsparams[b'cmd'] = rctx.tmpl.render(b'default', {})
437 438 cmd = req.qsparams[b'cmd']
438 439
439 440 # Don't enable caching if using a CSP nonce because then it wouldn't
440 441 # be a nonce.
441 442 if rctx.configbool(b'web', b'cache') and not rctx.nonce:
442 443 tag = b'W/"%d"' % self.mtime
443 444 if req.headers.get(b'If-None-Match') == tag:
444 445 res.status = b'304 Not Modified'
445 446 # Content-Type may be defined globally. It isn't valid on a
446 447 # 304, so discard it.
447 448 try:
448 449 del res.headers[b'Content-Type']
449 450 except KeyError:
450 451 pass
451 452 # Response body not allowed on 304.
452 453 res.setbodybytes(b'')
453 454 return res.sendresponse()
454 455
455 456 res.headers[b'ETag'] = tag
456 457
457 458 if cmd not in webcommands.__all__:
458 459 msg = b'no such method: %s' % cmd
459 460 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
460 461 else:
461 462 # Set some globals appropriate for web handlers. Commands can
462 463 # override easily enough.
463 464 res.status = b'200 Script output follows'
464 465 res.headers[b'Content-Type'] = ctype
465 466 return getattr(webcommands, cmd)(rctx)
466 467
467 468 except (error.LookupError, error.RepoLookupError) as err:
468 469 msg = pycompat.bytestr(err)
469 470 if util.safehasattr(err, b'name') and not isinstance(
470 471 err, error.ManifestLookupError
471 472 ):
472 473 msg = b'revision not found: %s' % err.name
473 474
474 475 res.status = b'404 Not Found'
475 476 res.headers[b'Content-Type'] = ctype
476 477 return rctx.sendtemplate(b'error', error=msg)
477 478 except (error.RepoError, error.StorageError) as e:
478 479 res.status = b'500 Internal Server Error'
479 480 res.headers[b'Content-Type'] = ctype
480 481 return rctx.sendtemplate(b'error', error=pycompat.bytestr(e))
481 482 except error.Abort as e:
482 483 res.status = b'403 Forbidden'
483 484 res.headers[b'Content-Type'] = ctype
484 485 return rctx.sendtemplate(b'error', error=e.message)
485 486 except ErrorResponse as e:
486 487 for k, v in e.headers:
487 488 res.headers[k] = v
488 489 res.status = statusmessage(e.code, pycompat.bytestr(e))
489 490 res.headers[b'Content-Type'] = ctype
490 491 return rctx.sendtemplate(b'error', error=pycompat.bytestr(e))
491 492
492 493 def check_perm(self, rctx, req, op):
493 494 for permhook in permhooks:
494 495 permhook(rctx, req, op)
495 496
496 497
497 498 def getwebview(repo):
498 499 """The 'web.view' config controls changeset filter to hgweb. Possible
499 500 values are ``served``, ``visible`` and ``all``. Default is ``served``.
500 501 The ``served`` filter only shows changesets that can be pulled from the
501 502 hgweb instance. The``visible`` filter includes secret changesets but
502 503 still excludes "hidden" one.
503 504
504 505 See the repoview module for details.
505 506
506 507 The option has been around undocumented since Mercurial 2.5, but no
507 508 user ever asked about it. So we better keep it undocumented for now."""
508 509 # experimental config: web.view
509 510 viewconfig = repo.ui.config(b'web', b'view', untrusted=True)
510 511 if viewconfig == b'all':
511 512 return repo.unfiltered()
512 513 elif viewconfig in repoview.filtertable:
513 514 return repo.filtered(viewconfig)
514 515 else:
515 516 return repo.filtered(b'served')
@@ -1,632 +1,635 b''
1 1 # hgweb/request.py - An http request from either CGI or the standalone server.
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005, 2006 Olivia Mackall <olivia@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
10 10 # import wsgiref.validate
11 11
12 12 from ..thirdparty import attr
13 13 from .. import (
14 14 encoding,
15 15 error,
16 16 pycompat,
17 17 util,
18 18 )
19 19 from ..utils import (
20 20 urlutil,
21 21 )
22 22
23 23
24 24 class multidict:
25 25 """A dict like object that can store multiple values for a key.
26 26
27 27 Used to store parsed request parameters.
28 28
29 29 This is inspired by WebOb's class of the same name.
30 30 """
31 31
32 32 def __init__(self):
33 33 self._items = {}
34 34
35 35 def __getitem__(self, key):
36 36 """Returns the last set value for a key."""
37 37 return self._items[key][-1]
38 38
39 39 def __setitem__(self, key, value):
40 40 """Replace a values for a key with a new value."""
41 41 self._items[key] = [value]
42 42
43 43 def __delitem__(self, key):
44 44 """Delete all values for a key."""
45 45 del self._items[key]
46 46
47 47 def __contains__(self, key):
48 48 return key in self._items
49 49
50 50 def __len__(self):
51 51 return len(self._items)
52 52
53 53 def get(self, key, default=None):
54 54 try:
55 55 return self.__getitem__(key)
56 56 except KeyError:
57 57 return default
58 58
59 59 def add(self, key, value):
60 60 """Add a new value for a key. Does not replace existing values."""
61 61 self._items.setdefault(key, []).append(value)
62 62
63 63 def getall(self, key):
64 64 """Obtains all values for a key."""
65 65 return self._items.get(key, [])
66 66
67 67 def getone(self, key):
68 68 """Obtain a single value for a key.
69 69
70 70 Raises KeyError if key not defined or it has multiple values set.
71 71 """
72 72 vals = self._items[key]
73 73
74 74 if len(vals) > 1:
75 75 raise KeyError(b'multiple values for %r' % key)
76 76
77 77 return vals[0]
78 78
79 79 def asdictoflists(self):
80 80 return {k: list(v) for k, v in self._items.items()}
81 81
82 82
83 83 @attr.s(frozen=True)
84 84 class parsedrequest:
85 85 """Represents a parsed WSGI request.
86 86
87 87 Contains both parsed parameters as well as a handle on the input stream.
88 88 """
89 89
90 90 # Request method.
91 91 method = attr.ib()
92 92 # Full URL for this request.
93 93 url = attr.ib()
94 94 # URL without any path components. Just <proto>://<host><port>.
95 95 baseurl = attr.ib()
96 96 # Advertised URL. Like ``url`` and ``baseurl`` but uses SERVER_NAME instead
97 97 # of HTTP: Host header for hostname. This is likely what clients used.
98 98 advertisedurl = attr.ib()
99 99 advertisedbaseurl = attr.ib()
100 100 # URL scheme (part before ``://``). e.g. ``http`` or ``https``.
101 101 urlscheme = attr.ib()
102 102 # Value of REMOTE_USER, if set, or None.
103 103 remoteuser = attr.ib()
104 104 # Value of REMOTE_HOST, if set, or None.
105 105 remotehost = attr.ib()
106 106 # Relative WSGI application path. If defined, will begin with a
107 107 # ``/``.
108 108 apppath = attr.ib()
109 109 # List of path parts to be used for dispatch.
110 110 dispatchparts = attr.ib()
111 111 # URL path component (no query string) used for dispatch. Can be
112 112 # ``None`` to signal no path component given to the request, an
113 113 # empty string to signal a request to the application's root URL,
114 114 # or a string not beginning with ``/`` containing the requested
115 115 # path under the application.
116 116 dispatchpath = attr.ib()
117 117 # The name of the repository being accessed.
118 118 reponame = attr.ib()
119 119 # Raw query string (part after "?" in URL).
120 120 querystring = attr.ib()
121 121 # multidict of query string parameters.
122 122 qsparams = attr.ib()
123 123 # wsgiref.headers.Headers instance. Operates like a dict with case
124 124 # insensitive keys.
125 125 headers = attr.ib()
126 126 # Request body input stream.
127 127 bodyfh = attr.ib()
128 128 # WSGI environment dict, unmodified.
129 129 rawenv = attr.ib()
130 130
131 131
132 132 def parserequestfromenv(env, reponame=None, altbaseurl=None, bodyfh=None):
133 133 """Parse URL components from environment variables.
134 134
135 135 WSGI defines request attributes via environment variables. This function
136 136 parses the environment variables into a data structure.
137 137
138 138 If ``reponame`` is defined, the leading path components matching that
139 139 string are effectively shifted from ``PATH_INFO`` to ``SCRIPT_NAME``.
140 140 This simulates the world view of a WSGI application that processes
141 141 requests from the base URL of a repo.
142 142
143 143 If ``altbaseurl`` (typically comes from ``web.baseurl`` config option)
144 144 is defined, it is used - instead of the WSGI environment variables - for
145 145 constructing URL components up to and including the WSGI application path.
146 146 For example, if the current WSGI application is at ``/repo`` and a request
147 147 is made to ``/rev/@`` with this argument set to
148 148 ``http://myserver:9000/prefix``, the URL and path components will resolve as
149 149 if the request were to ``http://myserver:9000/prefix/rev/@``. In other
150 150 words, ``wsgi.url_scheme``, ``SERVER_NAME``, ``SERVER_PORT``, and
151 151 ``SCRIPT_NAME`` are all effectively replaced by components from this URL.
152 152
153 153 ``bodyfh`` can be used to specify a file object to read the request body
154 154 from. If not defined, ``wsgi.input`` from the environment dict is used.
155 155 """
156 156 # PEP 3333 defines the WSGI spec and is a useful reference for this code.
157 157
158 158 # We first validate that the incoming object conforms with the WSGI spec.
159 159 # We only want to be dealing with spec-conforming WSGI implementations.
160 160 # TODO enable this once we fix internal violations.
161 161 # wsgiref.validate.check_environ(env)
162 162
163 163 # PEP-0333 states that environment keys and values are native strings.
164 164 # The code points for the Unicode strings on Python 3 must be between
165 165 # \00000-\000FF. We deal with bytes in Mercurial, so mass convert string
166 166 # keys and values to bytes.
167 167 def tobytes(s):
168 168 if not isinstance(s, str):
169 169 return s
170 170 if pycompat.iswindows:
171 171 # This is what mercurial.encoding does for os.environ on
172 172 # Windows.
173 173 return encoding.strtolocal(s)
174 174 else:
175 175 # This is what is documented to be used for os.environ on Unix.
176 176 return pycompat.fsencode(s)
177 177
178 178 env = {tobytes(k): tobytes(v) for k, v in env.items()}
179 179
180 180 # Some hosting solutions are emulating hgwebdir, and dispatching directly
181 181 # to an hgweb instance using this environment variable. This was always
182 182 # checked prior to d7fd203e36cc; keep doing so to avoid breaking them.
183 183 if not reponame:
184 184 reponame = env.get(b'REPO_NAME')
185 185
186 186 if altbaseurl:
187 187 altbaseurl = urlutil.url(altbaseurl)
188 188
189 189 # https://www.python.org/dev/peps/pep-0333/#environ-variables defines
190 190 # the environment variables.
191 191 # https://www.python.org/dev/peps/pep-0333/#url-reconstruction defines
192 192 # how URLs are reconstructed.
193 193 fullurl = env[b'wsgi.url_scheme'] + b'://'
194 194
195 195 if altbaseurl and altbaseurl.scheme:
196 196 advertisedfullurl = altbaseurl.scheme + b'://'
197 197 else:
198 198 advertisedfullurl = fullurl
199 199
200 200 def addport(s, port):
201 201 if s.startswith(b'https://'):
202 202 if port != b'443':
203 203 s += b':' + port
204 204 else:
205 205 if port != b'80':
206 206 s += b':' + port
207 207
208 208 return s
209 209
210 210 if env.get(b'HTTP_HOST'):
211 211 fullurl += env[b'HTTP_HOST']
212 212 else:
213 213 fullurl += env[b'SERVER_NAME']
214 214 fullurl = addport(fullurl, env[b'SERVER_PORT'])
215 215
216 216 if altbaseurl and altbaseurl.host:
217 217 advertisedfullurl += altbaseurl.host
218 218
219 219 if altbaseurl.port:
220 220 port = altbaseurl.port
221 221 elif altbaseurl.scheme == b'http' and not altbaseurl.port:
222 222 port = b'80'
223 223 elif altbaseurl.scheme == b'https' and not altbaseurl.port:
224 224 port = b'443'
225 225 else:
226 226 port = env[b'SERVER_PORT']
227 227
228 228 advertisedfullurl = addport(advertisedfullurl, port)
229 229 else:
230 230 advertisedfullurl += env[b'SERVER_NAME']
231 231 advertisedfullurl = addport(advertisedfullurl, env[b'SERVER_PORT'])
232 232
233 233 baseurl = fullurl
234 234 advertisedbaseurl = advertisedfullurl
235 235
236 236 fullurl += util.urlreq.quote(env.get(b'SCRIPT_NAME', b''))
237 237 fullurl += util.urlreq.quote(env.get(b'PATH_INFO', b''))
238 238
239 239 if altbaseurl:
240 240 path = altbaseurl.path or b''
241 241 if path and not path.startswith(b'/'):
242 242 path = b'/' + path
243 243 advertisedfullurl += util.urlreq.quote(path)
244 244 else:
245 245 advertisedfullurl += util.urlreq.quote(env.get(b'SCRIPT_NAME', b''))
246 246
247 247 advertisedfullurl += util.urlreq.quote(env.get(b'PATH_INFO', b''))
248 248
249 249 if env.get(b'QUERY_STRING'):
250 250 fullurl += b'?' + env[b'QUERY_STRING']
251 251 advertisedfullurl += b'?' + env[b'QUERY_STRING']
252 252
253 253 # If ``reponame`` is defined, that must be a prefix on PATH_INFO
254 254 # that represents the repository being dispatched to. When computing
255 255 # the dispatch info, we ignore these leading path components.
256 256
257 257 if altbaseurl:
258 258 apppath = altbaseurl.path or b''
259 259 if apppath and not apppath.startswith(b'/'):
260 260 apppath = b'/' + apppath
261 261 else:
262 262 apppath = env.get(b'SCRIPT_NAME', b'')
263 263
264 264 if reponame:
265 265 repoprefix = b'/' + reponame.strip(b'/')
266 266
267 267 if not env.get(b'PATH_INFO'):
268 268 raise error.ProgrammingError(b'reponame requires PATH_INFO')
269 269
270 270 if not env[b'PATH_INFO'].startswith(repoprefix):
271 271 raise error.ProgrammingError(
272 272 b'PATH_INFO does not begin with repo '
273 273 b'name: %s (%s)' % (env[b'PATH_INFO'], reponame)
274 274 )
275 275
276 276 dispatchpath = env[b'PATH_INFO'][len(repoprefix) :]
277 277
278 278 if dispatchpath and not dispatchpath.startswith(b'/'):
279 279 raise error.ProgrammingError(
280 280 b'reponame prefix of PATH_INFO does '
281 281 b'not end at path delimiter: %s (%s)'
282 282 % (env[b'PATH_INFO'], reponame)
283 283 )
284 284
285 285 apppath = apppath.rstrip(b'/') + repoprefix
286 286 dispatchparts = dispatchpath.strip(b'/').split(b'/')
287 287 dispatchpath = b'/'.join(dispatchparts)
288 288
289 289 elif b'PATH_INFO' in env:
290 290 if env[b'PATH_INFO'].strip(b'/'):
291 291 dispatchparts = env[b'PATH_INFO'].strip(b'/').split(b'/')
292 292 dispatchpath = b'/'.join(dispatchparts)
293 293 else:
294 294 dispatchparts = []
295 295 dispatchpath = b''
296 296 else:
297 297 dispatchparts = []
298 298 dispatchpath = None
299 299
300 300 querystring = env.get(b'QUERY_STRING', b'')
301 301
302 302 # We store as a list so we have ordering information. We also store as
303 303 # a dict to facilitate fast lookup.
304 304 qsparams = multidict()
305 305 for k, v in util.urlreq.parseqsl(querystring, keep_blank_values=True):
306 306 qsparams.add(k, v)
307 307
308 308 # HTTP_* keys contain HTTP request headers. The Headers structure should
309 309 # perform case normalization for us. We just rewrite underscore to dash
310 310 # so keys match what likely went over the wire.
311 311 headers = []
312 312 for k, v in env.items():
313 313 if k.startswith(b'HTTP_'):
314 314 headers.append((k[len(b'HTTP_') :].replace(b'_', b'-'), v))
315 315
316 316 from . import wsgiheaders # avoid cycle
317 317
318 318 headers = wsgiheaders.Headers(headers)
319 319
320 320 # This is kind of a lie because the HTTP header wasn't explicitly
321 321 # sent. But for all intents and purposes it should be OK to lie about
322 322 # this, since a consumer will either either value to determine how many
323 323 # bytes are available to read.
324 324 if b'CONTENT_LENGTH' in env and b'HTTP_CONTENT_LENGTH' not in env:
325 325 headers[b'Content-Length'] = env[b'CONTENT_LENGTH']
326 326
327 327 if b'CONTENT_TYPE' in env and b'HTTP_CONTENT_TYPE' not in env:
328 328 headers[b'Content-Type'] = env[b'CONTENT_TYPE']
329 329
330 330 if bodyfh is None:
331 331 bodyfh = env[b'wsgi.input']
332 332 if b'Content-Length' in headers:
333 333 bodyfh = util.cappedreader(
334 334 bodyfh, int(headers[b'Content-Length'] or b'0')
335 335 )
336 336
337 337 return parsedrequest(
338 338 method=env[b'REQUEST_METHOD'],
339 339 url=fullurl,
340 340 baseurl=baseurl,
341 341 advertisedurl=advertisedfullurl,
342 342 advertisedbaseurl=advertisedbaseurl,
343 343 urlscheme=env[b'wsgi.url_scheme'],
344 344 remoteuser=env.get(b'REMOTE_USER'),
345 345 remotehost=env.get(b'REMOTE_HOST'),
346 346 apppath=apppath,
347 347 dispatchparts=dispatchparts,
348 348 dispatchpath=dispatchpath,
349 349 reponame=reponame,
350 350 querystring=querystring,
351 351 qsparams=qsparams,
352 352 headers=headers,
353 353 bodyfh=bodyfh,
354 354 rawenv=env,
355 355 )
356 356
357 357
358 358 class offsettrackingwriter:
359 359 """A file object like object that is append only and tracks write count.
360 360
361 361 Instances are bound to a callable. This callable is called with data
362 362 whenever a ``write()`` is attempted.
363 363
364 364 Instances track the amount of written data so they can answer ``tell()``
365 365 requests.
366 366
367 367 The intent of this class is to wrap the ``write()`` function returned by
368 368 a WSGI ``start_response()`` function. Since ``write()`` is a callable and
369 369 not a file object, it doesn't implement other file object methods.
370 370 """
371 371
372 372 def __init__(self, writefn):
373 373 self._write = writefn
374 374 self._offset = 0
375 375
376 376 def write(self, s):
377 377 res = self._write(s)
378 378 # Some Python objects don't report the number of bytes written.
379 379 if res is None:
380 380 self._offset += len(s)
381 381 else:
382 382 self._offset += res
383 383
384 384 def flush(self):
385 385 pass
386 386
387 387 def tell(self):
388 388 return self._offset
389 389
390 390
391 391 class wsgiresponse:
392 392 """Represents a response to a WSGI request.
393 393
394 394 A response consists of a status line, headers, and a body.
395 395
396 396 Consumers must populate the ``status`` and ``headers`` fields and
397 397 make a call to a ``setbody*()`` method before the response can be
398 398 issued.
399 399
400 400 When it is time to start sending the response over the wire,
401 401 ``sendresponse()`` is called. It handles emitting the header portion
402 402 of the response message. It then yields chunks of body data to be
403 403 written to the peer. Typically, the WSGI application itself calls
404 404 and returns the value from ``sendresponse()``.
405 405 """
406 406
407 407 def __init__(self, req, startresponse):
408 408 """Create an empty response tied to a specific request.
409 409
410 410 ``req`` is a ``parsedrequest``. ``startresponse`` is the
411 411 ``start_response`` function passed to the WSGI application.
412 412 """
413 413 self._req = req
414 414 self._startresponse = startresponse
415 415
416 416 self.status = None
417 417 from . import wsgiheaders # avoid cycle
418 418
419 419 self.headers = wsgiheaders.Headers([])
420 420
421 421 self._bodybytes = None
422 422 self._bodygen = None
423 423 self._bodywillwrite = False
424 424 self._started = False
425 425 self._bodywritefn = None
426 426
427 427 def _verifybody(self):
428 428 if (
429 429 self._bodybytes is not None
430 430 or self._bodygen is not None
431 431 or self._bodywillwrite
432 432 ):
433 433 raise error.ProgrammingError(b'cannot define body multiple times')
434 434
435 435 def setbodybytes(self, b):
436 436 """Define the response body as static bytes.
437 437
438 438 The empty string signals that there is no response body.
439 439 """
440 440 self._verifybody()
441 441 self._bodybytes = b
442 442 self.headers[b'Content-Length'] = b'%d' % len(b)
443 443
444 444 def setbodygen(self, gen):
445 445 """Define the response body as a generator of bytes."""
446 446 self._verifybody()
447 447 self._bodygen = gen
448 448
449 449 def setbodywillwrite(self):
450 450 """Signal an intent to use write() to emit the response body.
451 451
452 452 **This is the least preferred way to send a body.**
453 453
454 454 It is preferred for WSGI applications to emit a generator of chunks
455 455 constituting the response body. However, some consumers can't emit
456 456 data this way. So, WSGI provides a way to obtain a ``write(data)``
457 457 function that can be used to synchronously perform an unbuffered
458 458 write.
459 459
460 460 Calling this function signals an intent to produce the body in this
461 461 manner.
462 462 """
463 463 self._verifybody()
464 464 self._bodywillwrite = True
465 465
466 466 def sendresponse(self):
467 467 """Send the generated response to the client.
468 468
469 469 Before this is called, ``status`` must be set and one of
470 470 ``setbodybytes()`` or ``setbodygen()`` must be called.
471 471
472 472 Calling this method multiple times is not allowed.
473 473 """
474 474 if self._started:
475 475 raise error.ProgrammingError(
476 476 b'sendresponse() called multiple times'
477 477 )
478 478
479 479 self._started = True
480 480
481 481 if not self.status:
482 482 raise error.ProgrammingError(b'status line not defined')
483 483
484 484 if (
485 485 self._bodybytes is None
486 486 and self._bodygen is None
487 487 and not self._bodywillwrite
488 and self._req.method != b'HEAD'
488 489 ):
489 490 raise error.ProgrammingError(b'response body not defined')
490 491
491 492 # RFC 7232 Section 4.1 states that a 304 MUST generate one of
492 493 # {Cache-Control, Content-Location, Date, ETag, Expires, Vary}
493 494 # and SHOULD NOT generate other headers unless they could be used
494 495 # to guide cache updates. Furthermore, RFC 7230 Section 3.3.2
495 496 # states that no response body can be issued. Content-Length can
496 497 # be sent. But if it is present, it should be the size of the response
497 498 # that wasn't transferred.
498 499 if self.status.startswith(b'304 '):
499 500 # setbodybytes('') will set C-L to 0. This doesn't conform with the
500 501 # spec. So remove it.
501 502 if self.headers.get(b'Content-Length') == b'0':
502 503 del self.headers[b'Content-Length']
503 504
504 505 # Strictly speaking, this is too strict. But until it causes
505 506 # problems, let's be strict.
506 507 badheaders = {
507 508 k
508 509 for k in self.headers.keys()
509 510 if k.lower()
510 511 not in (
511 512 b'date',
512 513 b'etag',
513 514 b'expires',
514 515 b'cache-control',
515 516 b'content-location',
516 517 b'content-security-policy',
517 518 b'vary',
518 519 )
519 520 }
520 521 if badheaders:
521 522 raise error.ProgrammingError(
522 523 b'illegal header on 304 response: %s'
523 524 % b', '.join(sorted(badheaders))
524 525 )
525 526
526 527 if self._bodygen is not None or self._bodywillwrite:
527 528 raise error.ProgrammingError(
528 529 b"must use setbodybytes('') with 304 responses"
529 530 )
530 531
531 532 # Various HTTP clients (notably httplib) won't read the HTTP response
532 533 # until the HTTP request has been sent in full. If servers (us) send a
533 534 # response before the HTTP request has been fully sent, the connection
534 535 # may deadlock because neither end is reading.
535 536 #
536 537 # We work around this by "draining" the request data before
537 538 # sending any response in some conditions.
538 539 drain = False
539 540 close = False
540 541
541 542 # If the client sent Expect: 100-continue, we assume it is smart enough
542 543 # to deal with the server sending a response before reading the request.
543 544 # (httplib doesn't do this.)
544 545 if self._req.headers.get(b'Expect', b'').lower() == b'100-continue':
545 546 pass
546 547 # Only tend to request methods that have bodies. Strictly speaking,
547 548 # we should sniff for a body. But this is fine for our existing
548 549 # WSGI applications.
549 550 elif self._req.method not in (b'POST', b'PUT'):
550 551 pass
551 552 else:
552 553 # If we don't know how much data to read, there's no guarantee
553 554 # that we can drain the request responsibly. The WSGI
554 555 # specification only says that servers *should* ensure the
555 556 # input stream doesn't overrun the actual request. So there's
556 557 # no guarantee that reading until EOF won't corrupt the stream
557 558 # state.
558 559 if not isinstance(self._req.bodyfh, util.cappedreader):
559 560 close = True
560 561 else:
561 562 # We /could/ only drain certain HTTP response codes. But 200 and
562 563 # non-200 wire protocol responses both require draining. Since
563 564 # we have a capped reader in place for all situations where we
564 565 # drain, it is safe to read from that stream. We'll either do
565 566 # a drain or no-op if we're already at EOF.
566 567 drain = True
567 568
568 569 if close:
569 570 self.headers[b'Connection'] = b'Close'
570 571
571 572 if drain:
572 573 assert isinstance(self._req.bodyfh, util.cappedreader)
573 574 while True:
574 575 chunk = self._req.bodyfh.read(32768)
575 576 if not chunk:
576 577 break
577 578
578 579 strheaders = [
579 580 (pycompat.strurl(k), pycompat.strurl(v))
580 581 for k, v in self.headers.items()
581 582 ]
582 583 write = self._startresponse(pycompat.sysstr(self.status), strheaders)
583 584
584 585 if self._bodybytes:
585 586 yield self._bodybytes
586 587 elif self._bodygen:
587 588 for chunk in self._bodygen:
588 589 # PEP-3333 says that output must be bytes. And some WSGI
589 590 # implementations enforce this. We cast bytes-like types here
590 591 # for convenience.
591 592 if isinstance(chunk, bytearray):
592 593 chunk = bytes(chunk)
593 594
594 595 yield chunk
595 596 elif self._bodywillwrite:
596 597 self._bodywritefn = write
598 elif self._req.method == b'HEAD':
599 pass
597 600 else:
598 601 error.ProgrammingError(b'do not know how to send body')
599 602
600 603 def getbodyfile(self):
601 604 """Obtain a file object like object representing the response body.
602 605
603 606 For this to work, you must call ``setbodywillwrite()`` and then
604 607 ``sendresponse()`` first. ``sendresponse()`` is a generator and the
605 608 function won't run to completion unless the generator is advanced. The
606 609 generator yields not items. The easiest way to consume it is with
607 610 ``list(res.sendresponse())``, which should resolve to an empty list -
608 611 ``[]``.
609 612 """
610 613 if not self._bodywillwrite:
611 614 raise error.ProgrammingError(b'must call setbodywillwrite() first')
612 615
613 616 if not self._started:
614 617 raise error.ProgrammingError(
615 618 b'must call sendresponse() first; did '
616 619 b'you remember to consume it since it '
617 620 b'is a generator?'
618 621 )
619 622
620 623 assert self._bodywritefn
621 624 return offsettrackingwriter(self._bodywritefn)
622 625
623 626
624 627 def wsgiapplication(app_maker):
625 628 """For compatibility with old CGI scripts. A plain hgweb() or hgwebdir()
626 629 can and should now be used as a WSGI application."""
627 630 application = app_maker()
628 631
629 632 def run_wsgi(env, respond):
630 633 return application(env, respond)
631 634
632 635 return run_wsgi
@@ -1,417 +1,424 b''
1 1 # hgweb/server.py - The standalone hg web server.
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005-2007 Olivia Mackall <olivia@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
10 10 import errno
11 11 import os
12 12 import socket
13 13 import sys
14 14 import traceback
15 15 import wsgiref.validate
16 16
17 17 from ..i18n import _
18 18 from ..pycompat import (
19 19 getattr,
20 20 open,
21 21 )
22 22
23 23 from .. import (
24 24 encoding,
25 25 error,
26 26 pycompat,
27 27 util,
28 28 )
29 29 from ..utils import (
30 30 urlutil,
31 31 )
32 32
33 33 httpservermod = util.httpserver
34 34 socketserver = util.socketserver
35 35 urlerr = util.urlerr
36 36 urlreq = util.urlreq
37 37
38 38 from . import common
39 39
40 40
41 41 def _splitURI(uri):
42 42 """Return path and query that has been split from uri
43 43
44 44 Just like CGI environment, the path is unquoted, the query is
45 45 not.
46 46 """
47 47 if '?' in uri:
48 48 path, query = uri.split('?', 1)
49 49 else:
50 50 path, query = uri, r''
51 51 return urlreq.unquote(path), query
52 52
53 53
54 54 class _error_logger:
55 55 def __init__(self, handler):
56 56 self.handler = handler
57 57
58 58 def flush(self):
59 59 pass
60 60
61 61 def write(self, str):
62 62 self.writelines(str.split(b'\n'))
63 63
64 64 def writelines(self, seq):
65 65 for msg in seq:
66 66 self.handler.log_error("HG error: %s", encoding.strfromlocal(msg))
67 67
68 68
69 69 class _httprequesthandler(httpservermod.basehttprequesthandler):
70 70
71 71 url_scheme = b'http'
72 72
73 73 @staticmethod
74 74 def preparehttpserver(httpserver, ui):
75 75 """Prepare .socket of new HTTPServer instance"""
76 76
77 77 def __init__(self, *args, **kargs):
78 78 self.protocol_version = r'HTTP/1.1'
79 79 httpservermod.basehttprequesthandler.__init__(self, *args, **kargs)
80 80
81 81 def _log_any(self, fp, format, *args):
82 82 fp.write(
83 83 pycompat.sysbytes(
84 84 r"%s - - [%s] %s"
85 85 % (
86 86 self.client_address[0],
87 87 self.log_date_time_string(),
88 88 format % args,
89 89 )
90 90 )
91 91 + b'\n'
92 92 )
93 93 fp.flush()
94 94
95 95 def log_error(self, format, *args):
96 96 self._log_any(self.server.errorlog, format, *args)
97 97
98 98 def log_message(self, format, *args):
99 99 self._log_any(self.server.accesslog, format, *args)
100 100
101 101 def log_request(self, code='-', size='-'):
102 102 xheaders = []
103 103 if util.safehasattr(self, b'headers'):
104 104 xheaders = [
105 105 h for h in self.headers.items() if h[0].startswith('x-')
106 106 ]
107 107 self.log_message(
108 108 '"%s" %s %s%s',
109 109 self.requestline,
110 110 str(code),
111 111 str(size),
112 112 ''.join([' %s:%s' % h for h in sorted(xheaders)]),
113 113 )
114 114
115 115 def do_write(self):
116 116 try:
117 117 self.do_hgweb()
118 118 except BrokenPipeError:
119 119 pass
120 120
121 121 def do_POST(self):
122 122 try:
123 123 self.do_write()
124 124 except Exception as e:
125 125 # I/O below could raise another exception. So log the original
126 126 # exception first to ensure it is recorded.
127 127 if not (
128 128 isinstance(e, (OSError, socket.error))
129 129 and e.errno == errno.ECONNRESET
130 130 ):
131 131 tb = "".join(traceback.format_exception(*sys.exc_info()))
132 132 # We need a native-string newline to poke in the log
133 133 # message, because we won't get a newline when using an
134 134 # r-string. This is the easy way out.
135 135 newline = chr(10)
136 136 self.log_error(
137 137 r"Exception happened during processing "
138 138 "request '%s':%s%s",
139 139 self.path,
140 140 newline,
141 141 tb,
142 142 )
143 143
144 144 self._start_response("500 Internal Server Error", [])
145 145 self._write(b"Internal Server Error")
146 146 self._done()
147 147
148 148 def do_PUT(self):
149 149 self.do_POST()
150 150
151 151 def do_GET(self):
152 152 self.do_POST()
153 153
154 def do_HEAD(self):
155 self.do_POST()
156
154 157 def do_hgweb(self):
155 158 self.sent_headers = False
156 159 path, query = _splitURI(self.path)
157 160
158 161 # Ensure the slicing of path below is valid
159 162 if path != self.server.prefix and not path.startswith(
160 163 self.server.prefix + b'/'
161 164 ):
162 165 self._start_response(pycompat.strurl(common.statusmessage(404)), [])
163 166 if self.command == 'POST':
164 167 # Paranoia: tell the client we're going to close the
165 168 # socket so they don't try and reuse a socket that
166 169 # might have a POST body waiting to confuse us. We do
167 170 # this by directly munging self.saved_headers because
168 171 # self._start_response ignores Connection headers.
169 172 self.saved_headers = [('Connection', 'Close')]
170 173 self._write(b"Not Found")
171 174 self._done()
172 175 return
173 176
174 177 env = {}
175 178 env['GATEWAY_INTERFACE'] = 'CGI/1.1'
176 179 env['REQUEST_METHOD'] = self.command
177 180 env['SERVER_NAME'] = self.server.server_name
178 181 env['SERVER_PORT'] = str(self.server.server_port)
179 182 env['REQUEST_URI'] = self.path
180 183 env['SCRIPT_NAME'] = pycompat.sysstr(self.server.prefix)
181 184 env['PATH_INFO'] = pycompat.sysstr(path[len(self.server.prefix) :])
182 185 env['REMOTE_HOST'] = self.client_address[0]
183 186 env['REMOTE_ADDR'] = self.client_address[0]
184 187 env['QUERY_STRING'] = query or ''
185 188
186 189 if self.headers.get_content_type() is None:
187 190 env['CONTENT_TYPE'] = self.headers.get_default_type()
188 191 else:
189 192 env['CONTENT_TYPE'] = self.headers.get_content_type()
190 193 length = self.headers.get('content-length')
191 194 if length:
192 195 env['CONTENT_LENGTH'] = length
193 196 for header in [
194 197 h
195 198 for h in self.headers.keys()
196 199 if h.lower() not in ('content-type', 'content-length')
197 200 ]:
198 201 hkey = 'HTTP_' + header.replace('-', '_').upper()
199 202 hval = self.headers.get(header)
200 203 hval = hval.replace('\n', '').strip()
201 204 if hval:
202 205 env[hkey] = hval
203 206 env['SERVER_PROTOCOL'] = self.request_version
204 207 env['wsgi.version'] = (1, 0)
205 208 env['wsgi.url_scheme'] = pycompat.sysstr(self.url_scheme)
206 209 if env.get('HTTP_EXPECT', b'').lower() == b'100-continue':
207 210 self.rfile = common.continuereader(self.rfile, self.wfile.write)
208 211
209 212 env['wsgi.input'] = self.rfile
210 213 env['wsgi.errors'] = _error_logger(self)
211 214 env['wsgi.multithread'] = isinstance(
212 215 self.server, socketserver.ThreadingMixIn
213 216 )
214 217 if util.safehasattr(socketserver, b'ForkingMixIn'):
215 218 env['wsgi.multiprocess'] = isinstance(
216 219 self.server, socketserver.ForkingMixIn
217 220 )
218 221 else:
219 222 env['wsgi.multiprocess'] = False
220 223
221 224 env['wsgi.run_once'] = 0
222 225
223 226 wsgiref.validate.check_environ(env)
224 227
225 228 self.saved_status = None
226 229 self.saved_headers = []
227 230 self.length = None
228 231 self._chunked = None
229 232 for chunk in self.server.application(env, self._start_response):
230 233 self._write(chunk)
231 234 if not self.sent_headers:
232 235 self.send_headers()
233 236 self._done()
234 237
235 238 def send_headers(self):
236 239 if not self.saved_status:
237 240 raise AssertionError(
238 241 b"Sending headers before start_response() called"
239 242 )
240 243 saved_status = self.saved_status.split(None, 1)
241 244 saved_status[0] = int(saved_status[0])
242 245 self.send_response(*saved_status)
243 246 self.length = None
244 247 self._chunked = False
245 248 for h in self.saved_headers:
246 249 self.send_header(*h)
247 250 if h[0].lower() == 'content-length':
248 251 self.length = int(h[1])
249 if self.length is None and saved_status[0] != common.HTTP_NOT_MODIFIED:
252 if (
253 self.length is None
254 and saved_status[0] != common.HTTP_NOT_MODIFIED
255 and self.command != 'HEAD'
256 ):
250 257 self._chunked = (
251 258 not self.close_connection and self.request_version == 'HTTP/1.1'
252 259 )
253 260 if self._chunked:
254 261 self.send_header('Transfer-Encoding', 'chunked')
255 262 else:
256 263 self.send_header('Connection', 'close')
257 264 self.end_headers()
258 265 self.sent_headers = True
259 266
260 267 def _start_response(self, http_status, headers, exc_info=None):
261 268 assert isinstance(http_status, str)
262 269 code, msg = http_status.split(None, 1)
263 270 code = int(code)
264 271 self.saved_status = http_status
265 272 bad_headers = ('connection', 'transfer-encoding')
266 273 self.saved_headers = [
267 274 h for h in headers if h[0].lower() not in bad_headers
268 275 ]
269 276 return self._write
270 277
271 278 def _write(self, data):
272 279 if not self.saved_status:
273 280 raise AssertionError(b"data written before start_response() called")
274 281 elif not self.sent_headers:
275 282 self.send_headers()
276 283 if self.length is not None:
277 284 if len(data) > self.length:
278 285 raise AssertionError(
279 286 b"Content-length header sent, but more "
280 287 b"bytes than specified are being written."
281 288 )
282 289 self.length = self.length - len(data)
283 290 elif self._chunked and data:
284 291 data = b'%x\r\n%s\r\n' % (len(data), data)
285 292 self.wfile.write(data)
286 293 self.wfile.flush()
287 294
288 295 def _done(self):
289 296 if self._chunked:
290 297 self.wfile.write(b'0\r\n\r\n')
291 298 self.wfile.flush()
292 299
293 300 def version_string(self):
294 301 if self.server.serverheader:
295 302 return encoding.strfromlocal(self.server.serverheader)
296 303 return httpservermod.basehttprequesthandler.version_string(self)
297 304
298 305
299 306 class _httprequesthandlerssl(_httprequesthandler):
300 307 """HTTPS handler based on Python's ssl module"""
301 308
302 309 url_scheme = b'https'
303 310
304 311 @staticmethod
305 312 def preparehttpserver(httpserver, ui):
306 313 try:
307 314 from .. import sslutil
308 315
309 316 sslutil.wrapserversocket
310 317 except ImportError:
311 318 raise error.Abort(_(b"SSL support is unavailable"))
312 319
313 320 certfile = ui.config(b'web', b'certificate')
314 321
315 322 # These config options are currently only meant for testing. Use
316 323 # at your own risk.
317 324 cafile = ui.config(b'devel', b'servercafile')
318 325 reqcert = ui.configbool(b'devel', b'serverrequirecert')
319 326
320 327 httpserver.socket = sslutil.wrapserversocket(
321 328 httpserver.socket,
322 329 ui,
323 330 certfile=certfile,
324 331 cafile=cafile,
325 332 requireclientcert=reqcert,
326 333 )
327 334
328 335 def setup(self):
329 336 self.connection = self.request
330 337 self.rfile = self.request.makefile("rb", self.rbufsize)
331 338 self.wfile = self.request.makefile("wb", self.wbufsize)
332 339
333 340
334 341 try:
335 342 import threading
336 343
337 344 threading.active_count() # silence pyflakes and bypass demandimport
338 345 _mixin = socketserver.ThreadingMixIn
339 346 except ImportError:
340 347 if util.safehasattr(os, b"fork"):
341 348 _mixin = socketserver.ForkingMixIn
342 349 else:
343 350
344 351 class _mixin:
345 352 pass
346 353
347 354
348 355 def openlog(opt, default):
349 356 if opt and opt != b'-':
350 357 return open(opt, b'ab')
351 358 return default
352 359
353 360
354 361 class MercurialHTTPServer(_mixin, httpservermod.httpserver, object):
355 362
356 363 # SO_REUSEADDR has broken semantics on windows
357 364 if pycompat.iswindows:
358 365 allow_reuse_address = 0
359 366
360 367 def __init__(self, ui, app, addr, handler, **kwargs):
361 368 httpservermod.httpserver.__init__(self, addr, handler, **kwargs)
362 369 self.daemon_threads = True
363 370 self.application = app
364 371
365 372 handler.preparehttpserver(self, ui)
366 373
367 374 prefix = ui.config(b'web', b'prefix')
368 375 if prefix:
369 376 prefix = b'/' + prefix.strip(b'/')
370 377 self.prefix = prefix
371 378
372 379 alog = openlog(ui.config(b'web', b'accesslog'), ui.fout)
373 380 elog = openlog(ui.config(b'web', b'errorlog'), ui.ferr)
374 381 self.accesslog = alog
375 382 self.errorlog = elog
376 383
377 384 self.addr, self.port = self.socket.getsockname()[0:2]
378 385 self.fqaddr = self.server_name
379 386
380 387 self.serverheader = ui.config(b'web', b'server-header')
381 388
382 389
383 390 class IPv6HTTPServer(MercurialHTTPServer):
384 391 address_family = getattr(socket, 'AF_INET6', None)
385 392
386 393 def __init__(self, *args, **kwargs):
387 394 if self.address_family is None:
388 395 raise error.RepoError(_(b'IPv6 is not available on this system'))
389 396 super(IPv6HTTPServer, self).__init__(*args, **kwargs)
390 397
391 398
392 399 def create_server(ui, app):
393 400
394 401 if ui.config(b'web', b'certificate'):
395 402 handler = _httprequesthandlerssl
396 403 else:
397 404 handler = _httprequesthandler
398 405
399 406 if ui.configbool(b'web', b'ipv6'):
400 407 cls = IPv6HTTPServer
401 408 else:
402 409 cls = MercurialHTTPServer
403 410
404 411 # ugly hack due to python issue5853 (for threaded use)
405 412 import mimetypes
406 413
407 414 mimetypes.init()
408 415
409 416 address = ui.config(b'web', b'address')
410 417 port = urlutil.getport(ui.config(b'web', b'port'))
411 418 try:
412 419 return cls(ui, app, (address, port), handler)
413 420 except socket.error as inst:
414 421 raise error.Abort(
415 422 _(b"cannot start server at '%s:%d': %s")
416 423 % (address, port, encoding.strtolocal(inst.args[1]))
417 424 )
@@ -1,1594 +1,1597 b''
1 1 #
2 2 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 3 # Copyright 2005-2007 Olivia Mackall <olivia@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8
9 9 import copy
10 10 import mimetypes
11 11 import os
12 12 import re
13 13
14 14 from ..i18n import _
15 15 from ..node import hex, short
16 16 from ..pycompat import getattr
17 17
18 18 from .common import (
19 19 ErrorResponse,
20 20 HTTP_FORBIDDEN,
21 21 HTTP_NOT_FOUND,
22 22 get_contact,
23 23 paritygen,
24 24 staticfile,
25 25 )
26 26
27 27 from .. import (
28 28 archival,
29 29 dagop,
30 30 encoding,
31 31 error,
32 32 graphmod,
33 33 pycompat,
34 34 revset,
35 35 revsetlang,
36 36 scmutil,
37 37 smartset,
38 38 templateutil,
39 39 )
40 40
41 41 from ..utils import stringutil
42 42
43 43 from . import webutil
44 44
45 45 __all__ = []
46 46 commands = {}
47 47
48 48
49 49 class webcommand:
50 50 """Decorator used to register a web command handler.
51 51
52 52 The decorator takes as its positional arguments the name/path the
53 53 command should be accessible under.
54 54
55 55 When called, functions receive as arguments a ``requestcontext``,
56 56 ``wsgirequest``, and a templater instance for generatoring output.
57 57 The functions should populate the ``rctx.res`` object with details
58 58 about the HTTP response.
59 59
60 60 The function returns a generator to be consumed by the WSGI application.
61 61 For most commands, this should be the result from
62 62 ``web.res.sendresponse()``. Many commands will call ``web.sendtemplate()``
63 63 to render a template.
64 64
65 65 Usage:
66 66
67 67 @webcommand('mycommand')
68 68 def mycommand(web):
69 69 pass
70 70 """
71 71
72 72 def __init__(self, name):
73 73 self.name = name
74 74
75 75 def __call__(self, func):
76 76 __all__.append(self.name)
77 77 commands[self.name] = func
78 78 return func
79 79
80 80
81 81 @webcommand(b'log')
82 82 def log(web):
83 83 """
84 84 /log[/{revision}[/{path}]]
85 85 --------------------------
86 86
87 87 Show repository or file history.
88 88
89 89 For URLs of the form ``/log/{revision}``, a list of changesets starting at
90 90 the specified changeset identifier is shown. If ``{revision}`` is not
91 91 defined, the default is ``tip``. This form is equivalent to the
92 92 ``changelog`` handler.
93 93
94 94 For URLs of the form ``/log/{revision}/{file}``, the history for a specific
95 95 file will be shown. This form is equivalent to the ``filelog`` handler.
96 96 """
97 97
98 98 if web.req.qsparams.get(b'file'):
99 99 return filelog(web)
100 100 else:
101 101 return changelog(web)
102 102
103 103
104 104 @webcommand(b'rawfile')
105 105 def rawfile(web):
106 106 guessmime = web.configbool(b'web', b'guessmime')
107 107
108 108 path = webutil.cleanpath(web.repo, web.req.qsparams.get(b'file', b''))
109 109 if not path:
110 110 return manifest(web)
111 111
112 112 try:
113 113 fctx = webutil.filectx(web.repo, web.req)
114 114 except error.LookupError as inst:
115 115 try:
116 116 return manifest(web)
117 117 except ErrorResponse:
118 118 raise inst
119 119
120 120 path = fctx.path()
121 121 text = fctx.data()
122 122 mt = b'application/binary'
123 123 if guessmime:
124 124 mt = mimetypes.guess_type(pycompat.fsdecode(path))[0]
125 125 if mt is None:
126 126 if stringutil.binary(text):
127 127 mt = b'application/binary'
128 128 else:
129 129 mt = b'text/plain'
130 130 else:
131 131 mt = pycompat.sysbytes(mt)
132 132
133 133 if mt.startswith(b'text/'):
134 134 mt += b'; charset="%s"' % encoding.encoding
135 135
136 136 web.res.headers[b'Content-Type'] = mt
137 137 filename = (
138 138 path.rpartition(b'/')[-1].replace(b'\\', b'\\\\').replace(b'"', b'\\"')
139 139 )
140 140 web.res.headers[b'Content-Disposition'] = (
141 141 b'inline; filename="%s"' % filename
142 142 )
143 143 web.res.setbodybytes(text)
144 144 return web.res.sendresponse()
145 145
146 146
147 147 def _filerevision(web, fctx):
148 148 f = fctx.path()
149 149 text = fctx.data()
150 150 parity = paritygen(web.stripecount)
151 151 ishead = fctx.filenode() in fctx.filelog().heads()
152 152
153 153 if stringutil.binary(text):
154 154 mt = pycompat.sysbytes(
155 155 mimetypes.guess_type(pycompat.fsdecode(f))[0]
156 156 or r'application/octet-stream'
157 157 )
158 158 text = b'(binary:%s)' % mt
159 159
160 160 def lines(context):
161 161 for lineno, t in enumerate(text.splitlines(True)):
162 162 yield {
163 163 b"line": t,
164 164 b"lineid": b"l%d" % (lineno + 1),
165 165 b"linenumber": b"% 6d" % (lineno + 1),
166 166 b"parity": next(parity),
167 167 }
168 168
169 169 return web.sendtemplate(
170 170 b'filerevision',
171 171 file=f,
172 172 path=webutil.up(f),
173 173 text=templateutil.mappinggenerator(lines),
174 174 symrev=webutil.symrevorshortnode(web.req, fctx),
175 175 rename=webutil.renamelink(fctx),
176 176 permissions=fctx.manifest().flags(f),
177 177 ishead=int(ishead),
178 178 **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))
179 179 )
180 180
181 181
182 182 @webcommand(b'file')
183 183 def file(web):
184 184 """
185 185 /file/{revision}[/{path}]
186 186 -------------------------
187 187
188 188 Show information about a directory or file in the repository.
189 189
190 190 Info about the ``path`` given as a URL parameter will be rendered.
191 191
192 192 If ``path`` is a directory, information about the entries in that
193 193 directory will be rendered. This form is equivalent to the ``manifest``
194 194 handler.
195 195
196 196 If ``path`` is a file, information about that file will be shown via
197 197 the ``filerevision`` template.
198 198
199 199 If ``path`` is not defined, information about the root directory will
200 200 be rendered.
201 201 """
202 202 if web.req.qsparams.get(b'style') == b'raw':
203 203 return rawfile(web)
204 204
205 205 path = webutil.cleanpath(web.repo, web.req.qsparams.get(b'file', b''))
206 206 if not path:
207 207 return manifest(web)
208 208 try:
209 209 return _filerevision(web, webutil.filectx(web.repo, web.req))
210 210 except error.LookupError as inst:
211 211 try:
212 212 return manifest(web)
213 213 except ErrorResponse:
214 214 raise inst
215 215
216 216
217 217 def _search(web):
218 218 MODE_REVISION = b'rev'
219 219 MODE_KEYWORD = b'keyword'
220 220 MODE_REVSET = b'revset'
221 221
222 222 def revsearch(ctx):
223 223 yield ctx
224 224
225 225 def keywordsearch(query):
226 226 lower = encoding.lower
227 227 qw = lower(query).split()
228 228
229 229 def revgen():
230 230 cl = web.repo.changelog
231 231 for i in range(len(web.repo) - 1, 0, -100):
232 232 l = []
233 233 for j in cl.revs(max(0, i - 99), i):
234 234 ctx = web.repo[j]
235 235 l.append(ctx)
236 236 l.reverse()
237 237 for e in l:
238 238 yield e
239 239
240 240 for ctx in revgen():
241 241 miss = 0
242 242 for q in qw:
243 243 if not (
244 244 q in lower(ctx.user())
245 245 or q in lower(ctx.description())
246 246 or q in lower(b" ".join(ctx.files()))
247 247 ):
248 248 miss = 1
249 249 break
250 250 if miss:
251 251 continue
252 252
253 253 yield ctx
254 254
255 255 def revsetsearch(revs):
256 256 for r in revs:
257 257 yield web.repo[r]
258 258
259 259 searchfuncs = {
260 260 MODE_REVISION: (revsearch, b'exact revision search'),
261 261 MODE_KEYWORD: (keywordsearch, b'literal keyword search'),
262 262 MODE_REVSET: (revsetsearch, b'revset expression search'),
263 263 }
264 264
265 265 def getsearchmode(query):
266 266 try:
267 267 ctx = scmutil.revsymbol(web.repo, query)
268 268 except (error.RepoError, error.LookupError):
269 269 # query is not an exact revision pointer, need to
270 270 # decide if it's a revset expression or keywords
271 271 pass
272 272 else:
273 273 return MODE_REVISION, ctx
274 274
275 275 revdef = b'reverse(%s)' % query
276 276 try:
277 277 tree = revsetlang.parse(revdef)
278 278 except error.ParseError:
279 279 # can't parse to a revset tree
280 280 return MODE_KEYWORD, query
281 281
282 282 if revsetlang.depth(tree) <= 2:
283 283 # no revset syntax used
284 284 return MODE_KEYWORD, query
285 285
286 286 if any(
287 287 (token, (value or b'')[:3]) == (b'string', b're:')
288 288 for token, value, pos in revsetlang.tokenize(revdef)
289 289 ):
290 290 return MODE_KEYWORD, query
291 291
292 292 funcsused = revsetlang.funcsused(tree)
293 293 if not funcsused.issubset(revset.safesymbols):
294 294 return MODE_KEYWORD, query
295 295
296 296 try:
297 297 mfunc = revset.match(
298 298 web.repo.ui, revdef, lookup=revset.lookupfn(web.repo)
299 299 )
300 300 revs = mfunc(web.repo)
301 301 return MODE_REVSET, revs
302 302 # ParseError: wrongly placed tokens, wrongs arguments, etc
303 303 # RepoLookupError: no such revision, e.g. in 'revision:'
304 304 # Abort: bookmark/tag not exists
305 305 # LookupError: ambiguous identifier, e.g. in '(bc)' on a large repo
306 306 except (
307 307 error.ParseError,
308 308 error.RepoLookupError,
309 309 error.Abort,
310 310 LookupError,
311 311 ):
312 312 return MODE_KEYWORD, query
313 313
314 314 def changelist(context):
315 315 count = 0
316 316
317 317 for ctx in searchfunc[0](funcarg):
318 318 count += 1
319 319 n = scmutil.binnode(ctx)
320 320 showtags = webutil.showtag(web.repo, b'changelogtag', n)
321 321 files = webutil.listfilediffs(ctx.files(), n, web.maxfiles)
322 322
323 323 lm = webutil.commonentry(web.repo, ctx)
324 324 lm.update(
325 325 {
326 326 b'parity': next(parity),
327 327 b'changelogtag': showtags,
328 328 b'files': files,
329 329 }
330 330 )
331 331 yield lm
332 332
333 333 if count >= revcount:
334 334 break
335 335
336 336 query = web.req.qsparams[b'rev']
337 337 revcount = web.maxchanges
338 338 if b'revcount' in web.req.qsparams:
339 339 try:
340 340 revcount = int(web.req.qsparams.get(b'revcount', revcount))
341 341 revcount = max(revcount, 1)
342 342 web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
343 343 except ValueError:
344 344 pass
345 345
346 346 lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
347 347 lessvars[b'revcount'] = max(revcount // 2, 1)
348 348 lessvars[b'rev'] = query
349 349 morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
350 350 morevars[b'revcount'] = revcount * 2
351 351 morevars[b'rev'] = query
352 352
353 353 mode, funcarg = getsearchmode(query)
354 354
355 355 if b'forcekw' in web.req.qsparams:
356 356 showforcekw = b''
357 357 showunforcekw = searchfuncs[mode][1]
358 358 mode = MODE_KEYWORD
359 359 funcarg = query
360 360 else:
361 361 if mode != MODE_KEYWORD:
362 362 showforcekw = searchfuncs[MODE_KEYWORD][1]
363 363 else:
364 364 showforcekw = b''
365 365 showunforcekw = b''
366 366
367 367 searchfunc = searchfuncs[mode]
368 368
369 369 tip = web.repo[b'tip']
370 370 parity = paritygen(web.stripecount)
371 371
372 372 return web.sendtemplate(
373 373 b'search',
374 374 query=query,
375 375 node=tip.hex(),
376 376 symrev=b'tip',
377 377 entries=templateutil.mappinggenerator(changelist, name=b'searchentry'),
378 378 archives=web.archivelist(b'tip'),
379 379 morevars=morevars,
380 380 lessvars=lessvars,
381 381 modedesc=searchfunc[1],
382 382 showforcekw=showforcekw,
383 383 showunforcekw=showunforcekw,
384 384 )
385 385
386 386
387 387 @webcommand(b'changelog')
388 388 def changelog(web, shortlog=False):
389 389 """
390 390 /changelog[/{revision}]
391 391 -----------------------
392 392
393 393 Show information about multiple changesets.
394 394
395 395 If the optional ``revision`` URL argument is absent, information about
396 396 all changesets starting at ``tip`` will be rendered. If the ``revision``
397 397 argument is present, changesets will be shown starting from the specified
398 398 revision.
399 399
400 400 If ``revision`` is absent, the ``rev`` query string argument may be
401 401 defined. This will perform a search for changesets.
402 402
403 403 The argument for ``rev`` can be a single revision, a revision set,
404 404 or a literal keyword to search for in changeset data (equivalent to
405 405 :hg:`log -k`).
406 406
407 407 The ``revcount`` query string argument defines the maximum numbers of
408 408 changesets to render.
409 409
410 410 For non-searches, the ``changelog`` template will be rendered.
411 411 """
412 412
413 413 query = b''
414 414 if b'node' in web.req.qsparams:
415 415 ctx = webutil.changectx(web.repo, web.req)
416 416 symrev = webutil.symrevorshortnode(web.req, ctx)
417 417 elif b'rev' in web.req.qsparams:
418 418 return _search(web)
419 419 else:
420 420 ctx = web.repo[b'tip']
421 421 symrev = b'tip'
422 422
423 423 def changelist(maxcount):
424 424 revs = []
425 425 if pos != -1:
426 426 revs = web.repo.changelog.revs(pos, 0)
427 427
428 428 for entry in webutil.changelistentries(web, revs, maxcount, parity):
429 429 yield entry
430 430
431 431 if shortlog:
432 432 revcount = web.maxshortchanges
433 433 else:
434 434 revcount = web.maxchanges
435 435
436 436 if b'revcount' in web.req.qsparams:
437 437 try:
438 438 revcount = int(web.req.qsparams.get(b'revcount', revcount))
439 439 revcount = max(revcount, 1)
440 440 web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
441 441 except ValueError:
442 442 pass
443 443
444 444 lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
445 445 lessvars[b'revcount'] = max(revcount // 2, 1)
446 446 morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
447 447 morevars[b'revcount'] = revcount * 2
448 448
449 449 count = len(web.repo)
450 450 pos = ctx.rev()
451 451 parity = paritygen(web.stripecount)
452 452
453 453 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
454 454
455 455 entries = list(changelist(revcount + 1))
456 456 latestentry = entries[:1]
457 457 if len(entries) > revcount:
458 458 nextentry = entries[-1:]
459 459 entries = entries[:-1]
460 460 else:
461 461 nextentry = []
462 462
463 463 return web.sendtemplate(
464 464 b'shortlog' if shortlog else b'changelog',
465 465 changenav=changenav,
466 466 node=ctx.hex(),
467 467 rev=pos,
468 468 symrev=symrev,
469 469 changesets=count,
470 470 entries=templateutil.mappinglist(entries),
471 471 latestentry=templateutil.mappinglist(latestentry),
472 472 nextentry=templateutil.mappinglist(nextentry),
473 473 archives=web.archivelist(b'tip'),
474 474 revcount=revcount,
475 475 morevars=morevars,
476 476 lessvars=lessvars,
477 477 query=query,
478 478 )
479 479
480 480
481 481 @webcommand(b'shortlog')
482 482 def shortlog(web):
483 483 """
484 484 /shortlog
485 485 ---------
486 486
487 487 Show basic information about a set of changesets.
488 488
489 489 This accepts the same parameters as the ``changelog`` handler. The only
490 490 difference is the ``shortlog`` template will be rendered instead of the
491 491 ``changelog`` template.
492 492 """
493 493 return changelog(web, shortlog=True)
494 494
495 495
496 496 @webcommand(b'changeset')
497 497 def changeset(web):
498 498 """
499 499 /changeset[/{revision}]
500 500 -----------------------
501 501
502 502 Show information about a single changeset.
503 503
504 504 A URL path argument is the changeset identifier to show. See ``hg help
505 505 revisions`` for possible values. If not defined, the ``tip`` changeset
506 506 will be shown.
507 507
508 508 The ``changeset`` template is rendered. Contents of the ``changesettag``,
509 509 ``changesetbookmark``, ``filenodelink``, ``filenolink``, and the many
510 510 templates related to diffs may all be used to produce the output.
511 511 """
512 512 ctx = webutil.changectx(web.repo, web.req)
513 513
514 514 return web.sendtemplate(b'changeset', **webutil.changesetentry(web, ctx))
515 515
516 516
517 517 rev = webcommand(b'rev')(changeset)
518 518
519 519
520 520 def decodepath(path):
521 521 # type: (bytes) -> bytes
522 522 """Hook for mapping a path in the repository to a path in the
523 523 working copy.
524 524
525 525 Extensions (e.g., largefiles) can override this to remap files in
526 526 the virtual file system presented by the manifest command below."""
527 527 return path
528 528
529 529
530 530 @webcommand(b'manifest')
531 531 def manifest(web):
532 532 """
533 533 /manifest[/{revision}[/{path}]]
534 534 -------------------------------
535 535
536 536 Show information about a directory.
537 537
538 538 If the URL path arguments are omitted, information about the root
539 539 directory for the ``tip`` changeset will be shown.
540 540
541 541 Because this handler can only show information for directories, it
542 542 is recommended to use the ``file`` handler instead, as it can handle both
543 543 directories and files.
544 544
545 545 The ``manifest`` template will be rendered for this handler.
546 546 """
547 547 if b'node' in web.req.qsparams:
548 548 ctx = webutil.changectx(web.repo, web.req)
549 549 symrev = webutil.symrevorshortnode(web.req, ctx)
550 550 else:
551 551 ctx = web.repo[b'tip']
552 552 symrev = b'tip'
553 553 path = webutil.cleanpath(web.repo, web.req.qsparams.get(b'file', b''))
554 554 mf = ctx.manifest()
555 555 node = scmutil.binnode(ctx)
556 556
557 557 files = {}
558 558 dirs = {}
559 559 parity = paritygen(web.stripecount)
560 560
561 561 if path and path[-1:] != b"/":
562 562 path += b"/"
563 563 l = len(path)
564 564 abspath = b"/" + path
565 565
566 566 for full, n in mf.items():
567 567 # the virtual path (working copy path) used for the full
568 568 # (repository) path
569 569 f = decodepath(full)
570 570
571 571 if f[:l] != path:
572 572 continue
573 573 remain = f[l:]
574 574 elements = remain.split(b'/')
575 575 if len(elements) == 1:
576 576 files[remain] = full
577 577 else:
578 578 h = dirs # need to retain ref to dirs (root)
579 579 for elem in elements[0:-1]:
580 580 if elem not in h:
581 581 h[elem] = {}
582 582 h = h[elem]
583 583 if len(h) > 1:
584 584 break
585 585 h[None] = None # denotes files present
586 586
587 587 if mf and not files and not dirs:
588 588 raise ErrorResponse(HTTP_NOT_FOUND, b'path not found: ' + path)
589 589
590 590 def filelist(context):
591 591 for f in sorted(files):
592 592 full = files[f]
593 593
594 594 fctx = ctx.filectx(full)
595 595 yield {
596 596 b"file": full,
597 597 b"parity": next(parity),
598 598 b"basename": f,
599 599 b"date": fctx.date(),
600 600 b"size": fctx.size(),
601 601 b"permissions": mf.flags(full),
602 602 }
603 603
604 604 def dirlist(context):
605 605 for d in sorted(dirs):
606 606
607 607 emptydirs = []
608 608 h = dirs[d]
609 609 while isinstance(h, dict) and len(h) == 1:
610 610 k, v = next(iter(h.items()))
611 611 if v:
612 612 emptydirs.append(k)
613 613 h = v
614 614
615 615 path = b"%s%s" % (abspath, d)
616 616 yield {
617 617 b"parity": next(parity),
618 618 b"path": path,
619 619 # pytype: disable=wrong-arg-types
620 620 b"emptydirs": b"/".join(emptydirs),
621 621 # pytype: enable=wrong-arg-types
622 622 b"basename": d,
623 623 }
624 624
625 625 return web.sendtemplate(
626 626 b'manifest',
627 627 symrev=symrev,
628 628 path=abspath,
629 629 up=webutil.up(abspath),
630 630 upparity=next(parity),
631 631 fentries=templateutil.mappinggenerator(filelist),
632 632 dentries=templateutil.mappinggenerator(dirlist),
633 633 archives=web.archivelist(hex(node)),
634 634 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))
635 635 )
636 636
637 637
638 638 @webcommand(b'tags')
639 639 def tags(web):
640 640 """
641 641 /tags
642 642 -----
643 643
644 644 Show information about tags.
645 645
646 646 No arguments are accepted.
647 647
648 648 The ``tags`` template is rendered.
649 649 """
650 650 i = list(reversed(web.repo.tagslist()))
651 651 parity = paritygen(web.stripecount)
652 652
653 653 def entries(context, notip, latestonly):
654 654 t = i
655 655 if notip:
656 656 t = [(k, n) for k, n in i if k != b"tip"]
657 657 if latestonly:
658 658 t = t[:1]
659 659 for k, n in t:
660 660 yield {
661 661 b"parity": next(parity),
662 662 b"tag": k,
663 663 b"date": web.repo[n].date(),
664 664 b"node": hex(n),
665 665 }
666 666
667 667 return web.sendtemplate(
668 668 b'tags',
669 669 node=hex(web.repo.changelog.tip()),
670 670 entries=templateutil.mappinggenerator(entries, args=(False, False)),
671 671 entriesnotip=templateutil.mappinggenerator(entries, args=(True, False)),
672 672 latestentry=templateutil.mappinggenerator(entries, args=(True, True)),
673 673 )
674 674
675 675
676 676 @webcommand(b'bookmarks')
677 677 def bookmarks(web):
678 678 """
679 679 /bookmarks
680 680 ----------
681 681
682 682 Show information about bookmarks.
683 683
684 684 No arguments are accepted.
685 685
686 686 The ``bookmarks`` template is rendered.
687 687 """
688 688 i = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
689 689 sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
690 690 i = sorted(i, key=sortkey, reverse=True)
691 691 parity = paritygen(web.stripecount)
692 692
693 693 def entries(context, latestonly):
694 694 t = i
695 695 if latestonly:
696 696 t = i[:1]
697 697 for k, n in t:
698 698 yield {
699 699 b"parity": next(parity),
700 700 b"bookmark": k,
701 701 b"date": web.repo[n].date(),
702 702 b"node": hex(n),
703 703 }
704 704
705 705 if i:
706 706 latestrev = i[0][1]
707 707 else:
708 708 latestrev = -1
709 709 lastdate = web.repo[latestrev].date()
710 710
711 711 return web.sendtemplate(
712 712 b'bookmarks',
713 713 node=hex(web.repo.changelog.tip()),
714 714 lastchange=templateutil.mappinglist([{b'date': lastdate}]),
715 715 entries=templateutil.mappinggenerator(entries, args=(False,)),
716 716 latestentry=templateutil.mappinggenerator(entries, args=(True,)),
717 717 )
718 718
719 719
720 720 @webcommand(b'branches')
721 721 def branches(web):
722 722 """
723 723 /branches
724 724 ---------
725 725
726 726 Show information about branches.
727 727
728 728 All known branches are contained in the output, even closed branches.
729 729
730 730 No arguments are accepted.
731 731
732 732 The ``branches`` template is rendered.
733 733 """
734 734 entries = webutil.branchentries(web.repo, web.stripecount)
735 735 latestentry = webutil.branchentries(web.repo, web.stripecount, 1)
736 736
737 737 return web.sendtemplate(
738 738 b'branches',
739 739 node=hex(web.repo.changelog.tip()),
740 740 entries=entries,
741 741 latestentry=latestentry,
742 742 )
743 743
744 744
745 745 @webcommand(b'summary')
746 746 def summary(web):
747 747 """
748 748 /summary
749 749 --------
750 750
751 751 Show a summary of repository state.
752 752
753 753 Information about the latest changesets, bookmarks, tags, and branches
754 754 is captured by this handler.
755 755
756 756 The ``summary`` template is rendered.
757 757 """
758 758 i = reversed(web.repo.tagslist())
759 759
760 760 def tagentries(context):
761 761 parity = paritygen(web.stripecount)
762 762 count = 0
763 763 for k, n in i:
764 764 if k == b"tip": # skip tip
765 765 continue
766 766
767 767 count += 1
768 768 if count > 10: # limit to 10 tags
769 769 break
770 770
771 771 yield {
772 772 b'parity': next(parity),
773 773 b'tag': k,
774 774 b'node': hex(n),
775 775 b'date': web.repo[n].date(),
776 776 }
777 777
778 778 def bookmarks(context):
779 779 parity = paritygen(web.stripecount)
780 780 marks = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
781 781 sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
782 782 marks = sorted(marks, key=sortkey, reverse=True)
783 783 for k, n in marks[:10]: # limit to 10 bookmarks
784 784 yield {
785 785 b'parity': next(parity),
786 786 b'bookmark': k,
787 787 b'date': web.repo[n].date(),
788 788 b'node': hex(n),
789 789 }
790 790
791 791 def changelist(context):
792 792 parity = paritygen(web.stripecount, offset=start - end)
793 793 l = [] # build a list in forward order for efficiency
794 794 revs = []
795 795 if start < end:
796 796 revs = web.repo.changelog.revs(start, end - 1)
797 797 for i in revs:
798 798 ctx = web.repo[i]
799 799 lm = webutil.commonentry(web.repo, ctx)
800 800 lm[b'parity'] = next(parity)
801 801 l.append(lm)
802 802
803 803 for entry in reversed(l):
804 804 yield entry
805 805
806 806 tip = web.repo[b'tip']
807 807 count = len(web.repo)
808 808 start = max(0, count - web.maxchanges)
809 809 end = min(count, start + web.maxchanges)
810 810
811 811 desc = web.config(b"web", b"description")
812 812 if not desc:
813 813 desc = b'unknown'
814 814 labels = web.configlist(b'web', b'labels')
815 815
816 816 return web.sendtemplate(
817 817 b'summary',
818 818 desc=desc,
819 819 owner=get_contact(web.config) or b'unknown',
820 820 lastchange=tip.date(),
821 821 tags=templateutil.mappinggenerator(tagentries, name=b'tagentry'),
822 822 bookmarks=templateutil.mappinggenerator(bookmarks),
823 823 branches=webutil.branchentries(web.repo, web.stripecount, 10),
824 824 shortlog=templateutil.mappinggenerator(
825 825 changelist, name=b'shortlogentry'
826 826 ),
827 827 node=tip.hex(),
828 828 symrev=b'tip',
829 829 archives=web.archivelist(b'tip'),
830 830 labels=templateutil.hybridlist(labels, name=b'label'),
831 831 )
832 832
833 833
834 834 @webcommand(b'filediff')
835 835 def filediff(web):
836 836 """
837 837 /diff/{revision}/{path}
838 838 -----------------------
839 839
840 840 Show how a file changed in a particular commit.
841 841
842 842 The ``filediff`` template is rendered.
843 843
844 844 This handler is registered under both the ``/diff`` and ``/filediff``
845 845 paths. ``/diff`` is used in modern code.
846 846 """
847 847 fctx, ctx = None, None
848 848 try:
849 849 fctx = webutil.filectx(web.repo, web.req)
850 850 except LookupError:
851 851 ctx = webutil.changectx(web.repo, web.req)
852 852 path = webutil.cleanpath(web.repo, web.req.qsparams[b'file'])
853 853 if path not in ctx.files():
854 854 raise
855 855
856 856 if fctx is not None:
857 857 path = fctx.path()
858 858 ctx = fctx.changectx()
859 859 basectx = ctx.p1()
860 860
861 861 style = web.config(b'web', b'style')
862 862 if b'style' in web.req.qsparams:
863 863 style = web.req.qsparams[b'style']
864 864
865 865 diffs = webutil.diffs(web, ctx, basectx, [path], style)
866 866 if fctx is not None:
867 867 rename = webutil.renamelink(fctx)
868 868 ctx = fctx
869 869 else:
870 870 rename = templateutil.mappinglist([])
871 871 ctx = ctx
872 872
873 873 return web.sendtemplate(
874 874 b'filediff',
875 875 file=path,
876 876 symrev=webutil.symrevorshortnode(web.req, ctx),
877 877 rename=rename,
878 878 diff=diffs,
879 879 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))
880 880 )
881 881
882 882
883 883 diff = webcommand(b'diff')(filediff)
884 884
885 885
886 886 @webcommand(b'comparison')
887 887 def comparison(web):
888 888 """
889 889 /comparison/{revision}/{path}
890 890 -----------------------------
891 891
892 892 Show a comparison between the old and new versions of a file from changes
893 893 made on a particular revision.
894 894
895 895 This is similar to the ``diff`` handler. However, this form features
896 896 a split or side-by-side diff rather than a unified diff.
897 897
898 898 The ``context`` query string argument can be used to control the lines of
899 899 context in the diff.
900 900
901 901 The ``filecomparison`` template is rendered.
902 902 """
903 903 ctx = webutil.changectx(web.repo, web.req)
904 904 if b'file' not in web.req.qsparams:
905 905 raise ErrorResponse(HTTP_NOT_FOUND, b'file not given')
906 906 path = webutil.cleanpath(web.repo, web.req.qsparams[b'file'])
907 907
908 908 parsecontext = lambda v: v == b'full' and -1 or int(v)
909 909 if b'context' in web.req.qsparams:
910 910 context = parsecontext(web.req.qsparams[b'context'])
911 911 else:
912 912 context = parsecontext(web.config(b'web', b'comparisoncontext'))
913 913
914 914 def filelines(f):
915 915 if f.isbinary():
916 916 mt = pycompat.sysbytes(
917 917 mimetypes.guess_type(pycompat.fsdecode(f.path()))[0]
918 918 or r'application/octet-stream'
919 919 )
920 920 return [_(b'(binary file %s, hash: %s)') % (mt, hex(f.filenode()))]
921 921 return f.data().splitlines()
922 922
923 923 fctx = None
924 924 parent = ctx.p1()
925 925 leftrev = parent.rev()
926 926 leftnode = parent.node()
927 927 rightrev = ctx.rev()
928 928 rightnode = scmutil.binnode(ctx)
929 929 if path in ctx:
930 930 fctx = ctx[path]
931 931 rightlines = filelines(fctx)
932 932 if path not in parent:
933 933 leftlines = ()
934 934 else:
935 935 pfctx = parent[path]
936 936 leftlines = filelines(pfctx)
937 937 else:
938 938 rightlines = ()
939 939 pfctx = ctx.p1()[path]
940 940 leftlines = filelines(pfctx)
941 941
942 942 comparison = webutil.compare(context, leftlines, rightlines)
943 943 if fctx is not None:
944 944 rename = webutil.renamelink(fctx)
945 945 ctx = fctx
946 946 else:
947 947 rename = templateutil.mappinglist([])
948 948 ctx = ctx
949 949
950 950 return web.sendtemplate(
951 951 b'filecomparison',
952 952 file=path,
953 953 symrev=webutil.symrevorshortnode(web.req, ctx),
954 954 rename=rename,
955 955 leftrev=leftrev,
956 956 leftnode=hex(leftnode),
957 957 rightrev=rightrev,
958 958 rightnode=hex(rightnode),
959 959 comparison=comparison,
960 960 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))
961 961 )
962 962
963 963
964 964 @webcommand(b'annotate')
965 965 def annotate(web):
966 966 """
967 967 /annotate/{revision}/{path}
968 968 ---------------------------
969 969
970 970 Show changeset information for each line in a file.
971 971
972 972 The ``ignorews``, ``ignorewsamount``, ``ignorewseol``, and
973 973 ``ignoreblanklines`` query string arguments have the same meaning as
974 974 their ``[annotate]`` config equivalents. It uses the hgrc boolean
975 975 parsing logic to interpret the value. e.g. ``0`` and ``false`` are
976 976 false and ``1`` and ``true`` are true. If not defined, the server
977 977 default settings are used.
978 978
979 979 The ``fileannotate`` template is rendered.
980 980 """
981 981 fctx = webutil.filectx(web.repo, web.req)
982 982 f = fctx.path()
983 983 parity = paritygen(web.stripecount)
984 984 ishead = fctx.filenode() in fctx.filelog().heads()
985 985
986 986 # parents() is called once per line and several lines likely belong to
987 987 # same revision. So it is worth caching.
988 988 # TODO there are still redundant operations within basefilectx.parents()
989 989 # and from the fctx.annotate() call itself that could be cached.
990 990 parentscache = {}
991 991
992 992 def parents(context, f):
993 993 rev = f.rev()
994 994 if rev not in parentscache:
995 995 parentscache[rev] = []
996 996 for p in f.parents():
997 997 entry = {
998 998 b'node': p.hex(),
999 999 b'rev': p.rev(),
1000 1000 }
1001 1001 parentscache[rev].append(entry)
1002 1002
1003 1003 for p in parentscache[rev]:
1004 1004 yield p
1005 1005
1006 1006 def annotate(context):
1007 1007 if fctx.isbinary():
1008 1008 mt = pycompat.sysbytes(
1009 1009 mimetypes.guess_type(pycompat.fsdecode(fctx.path()))[0]
1010 1010 or r'application/octet-stream'
1011 1011 )
1012 1012 lines = [
1013 1013 dagop.annotateline(
1014 1014 fctx=fctx.filectx(fctx.filerev()),
1015 1015 lineno=1,
1016 1016 text=b'(binary:%s)' % mt,
1017 1017 )
1018 1018 ]
1019 1019 else:
1020 1020 lines = webutil.annotate(web.req, fctx, web.repo.ui)
1021 1021
1022 1022 previousrev = None
1023 1023 blockparitygen = paritygen(1)
1024 1024 for lineno, aline in enumerate(lines):
1025 1025 f = aline.fctx
1026 1026 rev = f.rev()
1027 1027 if rev != previousrev:
1028 1028 blockhead = True
1029 1029 blockparity = next(blockparitygen)
1030 1030 else:
1031 1031 blockhead = None
1032 1032 previousrev = rev
1033 1033 yield {
1034 1034 b"parity": next(parity),
1035 1035 b"node": f.hex(),
1036 1036 b"rev": rev,
1037 1037 b"author": f.user(),
1038 1038 b"parents": templateutil.mappinggenerator(parents, args=(f,)),
1039 1039 b"desc": f.description(),
1040 1040 b"extra": f.extra(),
1041 1041 b"file": f.path(),
1042 1042 b"blockhead": blockhead,
1043 1043 b"blockparity": blockparity,
1044 1044 b"targetline": aline.lineno,
1045 1045 b"line": aline.text,
1046 1046 b"lineno": lineno + 1,
1047 1047 b"lineid": b"l%d" % (lineno + 1),
1048 1048 b"linenumber": b"% 6d" % (lineno + 1),
1049 1049 b"revdate": f.date(),
1050 1050 }
1051 1051
1052 1052 diffopts = webutil.difffeatureopts(web.req, web.repo.ui, b'annotate')
1053 1053 diffopts = {k: getattr(diffopts, k) for k in diffopts.defaults}
1054 1054
1055 1055 return web.sendtemplate(
1056 1056 b'fileannotate',
1057 1057 file=f,
1058 1058 annotate=templateutil.mappinggenerator(annotate),
1059 1059 path=webutil.up(f),
1060 1060 symrev=webutil.symrevorshortnode(web.req, fctx),
1061 1061 rename=webutil.renamelink(fctx),
1062 1062 permissions=fctx.manifest().flags(f),
1063 1063 ishead=int(ishead),
1064 1064 diffopts=templateutil.hybriddict(diffopts),
1065 1065 **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))
1066 1066 )
1067 1067
1068 1068
1069 1069 @webcommand(b'filelog')
1070 1070 def filelog(web):
1071 1071 """
1072 1072 /filelog/{revision}/{path}
1073 1073 --------------------------
1074 1074
1075 1075 Show information about the history of a file in the repository.
1076 1076
1077 1077 The ``revcount`` query string argument can be defined to control the
1078 1078 maximum number of entries to show.
1079 1079
1080 1080 The ``filelog`` template will be rendered.
1081 1081 """
1082 1082
1083 1083 try:
1084 1084 fctx = webutil.filectx(web.repo, web.req)
1085 1085 f = fctx.path()
1086 1086 fl = fctx.filelog()
1087 1087 except error.LookupError:
1088 1088 f = webutil.cleanpath(web.repo, web.req.qsparams[b'file'])
1089 1089 fl = web.repo.file(f)
1090 1090 numrevs = len(fl)
1091 1091 if not numrevs: # file doesn't exist at all
1092 1092 raise
1093 1093 rev = webutil.changectx(web.repo, web.req).rev()
1094 1094 first = fl.linkrev(0)
1095 1095 if rev < first: # current rev is from before file existed
1096 1096 raise
1097 1097 frev = numrevs - 1
1098 1098 while fl.linkrev(frev) > rev:
1099 1099 frev -= 1
1100 1100 fctx = web.repo.filectx(f, fl.linkrev(frev))
1101 1101
1102 1102 revcount = web.maxshortchanges
1103 1103 if b'revcount' in web.req.qsparams:
1104 1104 try:
1105 1105 revcount = int(web.req.qsparams.get(b'revcount', revcount))
1106 1106 revcount = max(revcount, 1)
1107 1107 web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
1108 1108 except ValueError:
1109 1109 pass
1110 1110
1111 1111 lrange = webutil.linerange(web.req)
1112 1112
1113 1113 lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1114 1114 lessvars[b'revcount'] = max(revcount // 2, 1)
1115 1115 morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1116 1116 morevars[b'revcount'] = revcount * 2
1117 1117
1118 1118 patch = b'patch' in web.req.qsparams
1119 1119 if patch:
1120 1120 lessvars[b'patch'] = morevars[b'patch'] = web.req.qsparams[b'patch']
1121 1121 descend = b'descend' in web.req.qsparams
1122 1122 if descend:
1123 1123 lessvars[b'descend'] = morevars[b'descend'] = web.req.qsparams[
1124 1124 b'descend'
1125 1125 ]
1126 1126
1127 1127 count = fctx.filerev() + 1
1128 1128 start = max(0, count - revcount) # first rev on this page
1129 1129 end = min(count, start + revcount) # last rev on this page
1130 1130 parity = paritygen(web.stripecount, offset=start - end)
1131 1131
1132 1132 repo = web.repo
1133 1133 filelog = fctx.filelog()
1134 1134 revs = [
1135 1135 filerev
1136 1136 for filerev in filelog.revs(start, end - 1)
1137 1137 if filelog.linkrev(filerev) in repo
1138 1138 ]
1139 1139 entries = []
1140 1140
1141 1141 diffstyle = web.config(b'web', b'style')
1142 1142 if b'style' in web.req.qsparams:
1143 1143 diffstyle = web.req.qsparams[b'style']
1144 1144
1145 1145 def diff(fctx, linerange=None):
1146 1146 ctx = fctx.changectx()
1147 1147 basectx = ctx.p1()
1148 1148 path = fctx.path()
1149 1149 return webutil.diffs(
1150 1150 web,
1151 1151 ctx,
1152 1152 basectx,
1153 1153 [path],
1154 1154 diffstyle,
1155 1155 linerange=linerange,
1156 1156 lineidprefix=b'%s-' % ctx.hex()[:12],
1157 1157 )
1158 1158
1159 1159 linerange = None
1160 1160 if lrange is not None:
1161 1161 assert lrange is not None # help pytype (!?)
1162 1162 linerange = webutil.formatlinerange(*lrange)
1163 1163 # deactivate numeric nav links when linerange is specified as this
1164 1164 # would required a dedicated "revnav" class
1165 1165 nav = templateutil.mappinglist([])
1166 1166 if descend:
1167 1167 it = dagop.blockdescendants(fctx, *lrange)
1168 1168 else:
1169 1169 it = dagop.blockancestors(fctx, *lrange)
1170 1170 for i, (c, lr) in enumerate(it, 1):
1171 1171 diffs = None
1172 1172 if patch:
1173 1173 diffs = diff(c, linerange=lr)
1174 1174 # follow renames accross filtered (not in range) revisions
1175 1175 path = c.path()
1176 1176 lm = webutil.commonentry(repo, c)
1177 1177 lm.update(
1178 1178 {
1179 1179 b'parity': next(parity),
1180 1180 b'filerev': c.rev(),
1181 1181 b'file': path,
1182 1182 b'diff': diffs,
1183 1183 b'linerange': webutil.formatlinerange(*lr),
1184 1184 b'rename': templateutil.mappinglist([]),
1185 1185 }
1186 1186 )
1187 1187 entries.append(lm)
1188 1188 if i == revcount:
1189 1189 break
1190 1190 lessvars[b'linerange'] = webutil.formatlinerange(*lrange)
1191 1191 morevars[b'linerange'] = lessvars[b'linerange']
1192 1192 else:
1193 1193 for i in revs:
1194 1194 iterfctx = fctx.filectx(i)
1195 1195 diffs = None
1196 1196 if patch:
1197 1197 diffs = diff(iterfctx)
1198 1198 lm = webutil.commonentry(repo, iterfctx)
1199 1199 lm.update(
1200 1200 {
1201 1201 b'parity': next(parity),
1202 1202 b'filerev': i,
1203 1203 b'file': f,
1204 1204 b'diff': diffs,
1205 1205 b'rename': webutil.renamelink(iterfctx),
1206 1206 }
1207 1207 )
1208 1208 entries.append(lm)
1209 1209 entries.reverse()
1210 1210 revnav = webutil.filerevnav(web.repo, fctx.path())
1211 1211 nav = revnav.gen(end - 1, revcount, count)
1212 1212
1213 1213 latestentry = entries[:1]
1214 1214
1215 1215 return web.sendtemplate(
1216 1216 b'filelog',
1217 1217 file=f,
1218 1218 nav=nav,
1219 1219 symrev=webutil.symrevorshortnode(web.req, fctx),
1220 1220 entries=templateutil.mappinglist(entries),
1221 1221 descend=descend,
1222 1222 patch=patch,
1223 1223 latestentry=templateutil.mappinglist(latestentry),
1224 1224 linerange=linerange,
1225 1225 revcount=revcount,
1226 1226 morevars=morevars,
1227 1227 lessvars=lessvars,
1228 1228 **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))
1229 1229 )
1230 1230
1231 1231
1232 1232 @webcommand(b'archive')
1233 1233 def archive(web):
1234 1234 """
1235 1235 /archive/{revision}.{format}[/{path}]
1236 1236 -------------------------------------
1237 1237
1238 1238 Obtain an archive of repository content.
1239 1239
1240 1240 The content and type of the archive is defined by a URL path parameter.
1241 1241 ``format`` is the file extension of the archive type to be generated. e.g.
1242 1242 ``zip`` or ``tar.bz2``. Not all archive types may be allowed by your
1243 1243 server configuration.
1244 1244
1245 1245 The optional ``path`` URL parameter controls content to include in the
1246 1246 archive. If omitted, every file in the specified revision is present in the
1247 1247 archive. If included, only the specified file or contents of the specified
1248 1248 directory will be included in the archive.
1249 1249
1250 1250 No template is used for this handler. Raw, binary content is generated.
1251 1251 """
1252 1252
1253 1253 type_ = web.req.qsparams.get(b'type')
1254 1254 allowed = web.configlist(b"web", b"allow-archive")
1255 1255 key = web.req.qsparams[b'node']
1256 1256
1257 1257 if type_ not in webutil.archivespecs:
1258 1258 msg = b'Unsupported archive type: %s' % stringutil.pprint(type_)
1259 1259 raise ErrorResponse(HTTP_NOT_FOUND, msg)
1260 1260
1261 1261 if not ((type_ in allowed or web.configbool(b"web", b"allow" + type_))):
1262 1262 msg = b'Archive type not allowed: %s' % type_
1263 1263 raise ErrorResponse(HTTP_FORBIDDEN, msg)
1264 1264
1265 1265 reponame = re.sub(br"\W+", b"-", os.path.basename(web.reponame))
1266 1266 cnode = web.repo.lookup(key)
1267 1267 arch_version = key
1268 1268 if cnode == key or key == b'tip':
1269 1269 arch_version = short(cnode)
1270 1270 name = b"%s-%s" % (reponame, arch_version)
1271 1271
1272 1272 ctx = webutil.changectx(web.repo, web.req)
1273 1273 match = scmutil.match(ctx, [])
1274 1274 file = web.req.qsparams.get(b'file')
1275 1275 if file:
1276 1276 pats = [b'path:' + file]
1277 1277 match = scmutil.match(ctx, pats, default=b'path')
1278 1278 if pats:
1279 1279 files = [f for f in ctx.manifest().keys() if match(f)]
1280 1280 if not files:
1281 1281 raise ErrorResponse(
1282 1282 HTTP_NOT_FOUND, b'file(s) not found: %s' % file
1283 1283 )
1284 1284
1285 1285 mimetype, artype, extension, encoding = webutil.archivespecs[type_]
1286 1286
1287 1287 web.res.headers[b'Content-Type'] = mimetype
1288 1288 web.res.headers[b'Content-Disposition'] = b'attachment; filename=%s%s' % (
1289 1289 name,
1290 1290 extension,
1291 1291 )
1292 1292
1293 1293 if encoding:
1294 1294 web.res.headers[b'Content-Encoding'] = encoding
1295 1295
1296 1296 web.res.setbodywillwrite()
1297 1297 if list(web.res.sendresponse()):
1298 1298 raise error.ProgrammingError(
1299 1299 b'sendresponse() should not emit data if writing later'
1300 1300 )
1301 1301
1302 if web.req.method == b'HEAD':
1303 return []
1304
1302 1305 bodyfh = web.res.getbodyfile()
1303 1306
1304 1307 archival.archive(
1305 1308 web.repo,
1306 1309 bodyfh,
1307 1310 cnode,
1308 1311 artype,
1309 1312 prefix=name,
1310 1313 match=match,
1311 1314 subrepos=web.configbool(b"web", b"archivesubrepos"),
1312 1315 )
1313 1316
1314 1317 return []
1315 1318
1316 1319
1317 1320 @webcommand(b'static')
1318 1321 def static(web):
1319 1322 fname = web.req.qsparams[b'file']
1320 1323 # a repo owner may set web.static in .hg/hgrc to get any file
1321 1324 # readable by the user running the CGI script
1322 1325 static = web.config(b"web", b"static", untrusted=False)
1323 1326 staticfile(web.templatepath, static, fname, web.res)
1324 1327 return web.res.sendresponse()
1325 1328
1326 1329
1327 1330 @webcommand(b'graph')
1328 1331 def graph(web):
1329 1332 """
1330 1333 /graph[/{revision}]
1331 1334 -------------------
1332 1335
1333 1336 Show information about the graphical topology of the repository.
1334 1337
1335 1338 Information rendered by this handler can be used to create visual
1336 1339 representations of repository topology.
1337 1340
1338 1341 The ``revision`` URL parameter controls the starting changeset. If it's
1339 1342 absent, the default is ``tip``.
1340 1343
1341 1344 The ``revcount`` query string argument can define the number of changesets
1342 1345 to show information for.
1343 1346
1344 1347 The ``graphtop`` query string argument can specify the starting changeset
1345 1348 for producing ``jsdata`` variable that is used for rendering graph in
1346 1349 JavaScript. By default it has the same value as ``revision``.
1347 1350
1348 1351 This handler will render the ``graph`` template.
1349 1352 """
1350 1353
1351 1354 if b'node' in web.req.qsparams:
1352 1355 ctx = webutil.changectx(web.repo, web.req)
1353 1356 symrev = webutil.symrevorshortnode(web.req, ctx)
1354 1357 else:
1355 1358 ctx = web.repo[b'tip']
1356 1359 symrev = b'tip'
1357 1360 rev = ctx.rev()
1358 1361
1359 1362 bg_height = 39
1360 1363 revcount = web.maxshortchanges
1361 1364 if b'revcount' in web.req.qsparams:
1362 1365 try:
1363 1366 revcount = int(web.req.qsparams.get(b'revcount', revcount))
1364 1367 revcount = max(revcount, 1)
1365 1368 web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
1366 1369 except ValueError:
1367 1370 pass
1368 1371
1369 1372 lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1370 1373 lessvars[b'revcount'] = max(revcount // 2, 1)
1371 1374 morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1372 1375 morevars[b'revcount'] = revcount * 2
1373 1376
1374 1377 graphtop = web.req.qsparams.get(b'graphtop', ctx.hex())
1375 1378 graphvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1376 1379 graphvars[b'graphtop'] = graphtop
1377 1380
1378 1381 count = len(web.repo)
1379 1382 pos = rev
1380 1383
1381 1384 uprev = min(max(0, count - 1), rev + revcount)
1382 1385 downrev = max(0, rev - revcount)
1383 1386 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
1384 1387
1385 1388 tree = []
1386 1389 nextentry = []
1387 1390 lastrev = 0
1388 1391 if pos != -1:
1389 1392 allrevs = web.repo.changelog.revs(pos, 0)
1390 1393 revs = []
1391 1394 for i in allrevs:
1392 1395 revs.append(i)
1393 1396 if len(revs) >= revcount + 1:
1394 1397 break
1395 1398
1396 1399 if len(revs) > revcount:
1397 1400 nextentry = [webutil.commonentry(web.repo, web.repo[revs[-1]])]
1398 1401 revs = revs[:-1]
1399 1402
1400 1403 lastrev = revs[-1]
1401 1404
1402 1405 # We have to feed a baseset to dagwalker as it is expecting smartset
1403 1406 # object. This does not have a big impact on hgweb performance itself
1404 1407 # since hgweb graphing code is not itself lazy yet.
1405 1408 dag = graphmod.dagwalker(web.repo, smartset.baseset(revs))
1406 1409 # As we said one line above... not lazy.
1407 1410 tree = list(
1408 1411 item
1409 1412 for item in graphmod.colored(dag, web.repo)
1410 1413 if item[1] == graphmod.CHANGESET
1411 1414 )
1412 1415
1413 1416 def fulltree():
1414 1417 pos = web.repo[graphtop].rev()
1415 1418 tree = []
1416 1419 if pos != -1:
1417 1420 revs = web.repo.changelog.revs(pos, lastrev)
1418 1421 dag = graphmod.dagwalker(web.repo, smartset.baseset(revs))
1419 1422 tree = list(
1420 1423 item
1421 1424 for item in graphmod.colored(dag, web.repo)
1422 1425 if item[1] == graphmod.CHANGESET
1423 1426 )
1424 1427 return tree
1425 1428
1426 1429 def jsdata(context):
1427 1430 for (id, type, ctx, vtx, edges) in fulltree():
1428 1431 yield {
1429 1432 b'node': pycompat.bytestr(ctx),
1430 1433 b'graphnode': webutil.getgraphnode(web.repo, ctx),
1431 1434 b'vertex': vtx,
1432 1435 b'edges': edges,
1433 1436 }
1434 1437
1435 1438 def nodes(context):
1436 1439 parity = paritygen(web.stripecount)
1437 1440 for row, (id, type, ctx, vtx, edges) in enumerate(tree):
1438 1441 entry = webutil.commonentry(web.repo, ctx)
1439 1442 edgedata = [
1440 1443 {
1441 1444 b'col': edge[0],
1442 1445 b'nextcol': edge[1],
1443 1446 b'color': (edge[2] - 1) % 6 + 1,
1444 1447 b'width': edge[3],
1445 1448 b'bcolor': edge[4],
1446 1449 }
1447 1450 for edge in edges
1448 1451 ]
1449 1452
1450 1453 entry.update(
1451 1454 {
1452 1455 b'col': vtx[0],
1453 1456 b'color': (vtx[1] - 1) % 6 + 1,
1454 1457 b'parity': next(parity),
1455 1458 b'edges': templateutil.mappinglist(edgedata),
1456 1459 b'row': row,
1457 1460 b'nextrow': row + 1,
1458 1461 }
1459 1462 )
1460 1463
1461 1464 yield entry
1462 1465
1463 1466 rows = len(tree)
1464 1467
1465 1468 return web.sendtemplate(
1466 1469 b'graph',
1467 1470 rev=rev,
1468 1471 symrev=symrev,
1469 1472 revcount=revcount,
1470 1473 uprev=uprev,
1471 1474 lessvars=lessvars,
1472 1475 morevars=morevars,
1473 1476 downrev=downrev,
1474 1477 graphvars=graphvars,
1475 1478 rows=rows,
1476 1479 bg_height=bg_height,
1477 1480 changesets=count,
1478 1481 nextentry=templateutil.mappinglist(nextentry),
1479 1482 jsdata=templateutil.mappinggenerator(jsdata),
1480 1483 nodes=templateutil.mappinggenerator(nodes),
1481 1484 node=ctx.hex(),
1482 1485 archives=web.archivelist(b'tip'),
1483 1486 changenav=changenav,
1484 1487 )
1485 1488
1486 1489
1487 1490 def _getdoc(e):
1488 1491 doc = e[0].__doc__
1489 1492 if doc:
1490 1493 doc = _(doc).partition(b'\n')[0]
1491 1494 else:
1492 1495 doc = _(b'(no help text available)')
1493 1496 return doc
1494 1497
1495 1498
1496 1499 @webcommand(b'help')
1497 1500 def help(web):
1498 1501 """
1499 1502 /help[/{topic}]
1500 1503 ---------------
1501 1504
1502 1505 Render help documentation.
1503 1506
1504 1507 This web command is roughly equivalent to :hg:`help`. If a ``topic``
1505 1508 is defined, that help topic will be rendered. If not, an index of
1506 1509 available help topics will be rendered.
1507 1510
1508 1511 The ``help`` template will be rendered when requesting help for a topic.
1509 1512 ``helptopics`` will be rendered for the index of help topics.
1510 1513 """
1511 1514 from .. import commands, help as helpmod # avoid cycle
1512 1515
1513 1516 topicname = web.req.qsparams.get(b'node')
1514 1517 if not topicname:
1515 1518
1516 1519 def topics(context):
1517 1520 for h in helpmod.helptable:
1518 1521 entries, summary, _doc = h[0:3]
1519 1522 yield {b'topic': entries[0], b'summary': summary}
1520 1523
1521 1524 early, other = [], []
1522 1525 primary = lambda s: s.partition(b'|')[0]
1523 1526 for c, e in commands.table.items():
1524 1527 doc = _getdoc(e)
1525 1528 if b'DEPRECATED' in doc or c.startswith(b'debug'):
1526 1529 continue
1527 1530 cmd = primary(c)
1528 1531 if getattr(e[0], 'helpbasic', False):
1529 1532 early.append((cmd, doc))
1530 1533 else:
1531 1534 other.append((cmd, doc))
1532 1535
1533 1536 early.sort()
1534 1537 other.sort()
1535 1538
1536 1539 def earlycommands(context):
1537 1540 for c, doc in early:
1538 1541 yield {b'topic': c, b'summary': doc}
1539 1542
1540 1543 def othercommands(context):
1541 1544 for c, doc in other:
1542 1545 yield {b'topic': c, b'summary': doc}
1543 1546
1544 1547 return web.sendtemplate(
1545 1548 b'helptopics',
1546 1549 topics=templateutil.mappinggenerator(topics),
1547 1550 earlycommands=templateutil.mappinggenerator(earlycommands),
1548 1551 othercommands=templateutil.mappinggenerator(othercommands),
1549 1552 title=b'Index',
1550 1553 )
1551 1554
1552 1555 # Render an index of sub-topics.
1553 1556 if topicname in helpmod.subtopics:
1554 1557 topics = []
1555 1558 for entries, summary, _doc in helpmod.subtopics[topicname]:
1556 1559 topics.append(
1557 1560 {
1558 1561 b'topic': b'%s.%s' % (topicname, entries[0]),
1559 1562 b'basename': entries[0],
1560 1563 b'summary': summary,
1561 1564 }
1562 1565 )
1563 1566
1564 1567 return web.sendtemplate(
1565 1568 b'helptopics',
1566 1569 topics=templateutil.mappinglist(topics),
1567 1570 title=topicname,
1568 1571 subindex=True,
1569 1572 )
1570 1573
1571 1574 u = webutil.wsgiui.load()
1572 1575 u.verbose = True
1573 1576
1574 1577 # Render a page from a sub-topic.
1575 1578 if b'.' in topicname:
1576 1579 # TODO implement support for rendering sections, like
1577 1580 # `hg help` works.
1578 1581 topic, subtopic = topicname.split(b'.', 1)
1579 1582 if topic not in helpmod.subtopics:
1580 1583 raise ErrorResponse(HTTP_NOT_FOUND)
1581 1584 else:
1582 1585 topic = topicname
1583 1586 subtopic = None
1584 1587
1585 1588 try:
1586 1589 doc = helpmod.help_(u, commands, topic, subtopic=subtopic)
1587 1590 except error.Abort:
1588 1591 raise ErrorResponse(HTTP_NOT_FOUND)
1589 1592
1590 1593 return web.sendtemplate(b'help', topic=topicname, doc=doc)
1591 1594
1592 1595
1593 1596 # tell hggettext to extract docstrings from these functions:
1594 1597 i18nfunctions = commands.values()
General Comments 0
You need to be logged in to leave comments. Login now