##// END OF EJS Templates
hgwebmod: use sysstr to check for attribute presence...
marmoute -
r51789:b2b8c25f default
parent child Browse files
Show More
@@ -1,527 +1,527 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 common,
43 43 request as requestmod,
44 44 webcommands,
45 45 webutil,
46 46 wsgicgi,
47 47 )
48 48
49 49
50 50 def getstyle(req, configfn, templatepath):
51 51 styles = (
52 52 req.qsparams.get(b'style', None),
53 53 configfn(b'web', b'style'),
54 54 b'paper',
55 55 )
56 56 return styles, _stylemap(styles, templatepath)
57 57
58 58
59 59 def _stylemap(styles, path=None):
60 60 """Return path to mapfile for a given style.
61 61
62 62 Searches mapfile in the following locations:
63 63 1. templatepath/style/map
64 64 2. templatepath/map-style
65 65 3. templatepath/map
66 66 """
67 67
68 68 for style in styles:
69 69 # only plain name is allowed to honor template paths
70 70 if (
71 71 not style
72 72 or style in (pycompat.oscurdir, pycompat.ospardir)
73 73 or pycompat.ossep in style
74 74 or pycompat.osaltsep
75 75 and pycompat.osaltsep in style
76 76 ):
77 77 continue
78 78 locations = (os.path.join(style, b'map'), b'map-' + style, b'map')
79 79
80 80 for location in locations:
81 81 mapfile, fp = templater.try_open_template(location, path)
82 82 if mapfile:
83 83 return style, mapfile, fp
84 84
85 85 raise RuntimeError(b"No hgweb templates found in %r" % path)
86 86
87 87
88 88 def makebreadcrumb(url, prefix=b''):
89 89 """Return a 'URL breadcrumb' list
90 90
91 91 A 'URL breadcrumb' is a list of URL-name pairs,
92 92 corresponding to each of the path items on a URL.
93 93 This can be used to create path navigation entries.
94 94 """
95 95 if url.endswith(b'/'):
96 96 url = url[:-1]
97 97 if prefix:
98 98 url = b'/' + prefix + url
99 99 relpath = url
100 100 if relpath.startswith(b'/'):
101 101 relpath = relpath[1:]
102 102
103 103 breadcrumb = []
104 104 urlel = url
105 105 pathitems = [b''] + relpath.split(b'/')
106 106 for pathel in reversed(pathitems):
107 107 if not pathel or not urlel:
108 108 break
109 109 breadcrumb.append({b'url': urlel, b'name': pathel})
110 110 urlel = os.path.dirname(urlel)
111 111 return templateutil.mappinglist(reversed(breadcrumb))
112 112
113 113
114 114 class requestcontext:
115 115 """Holds state/context for an individual request.
116 116
117 117 Servers can be multi-threaded. Holding state on the WSGI application
118 118 is prone to race conditions. Instances of this class exist to hold
119 119 mutable and race-free state for requests.
120 120 """
121 121
122 122 def __init__(self, app, repo, req, res):
123 123 self.repo = repo
124 124 self.reponame = app.reponame
125 125 self.req = req
126 126 self.res = res
127 127
128 128 # Only works if the filter actually support being upgraded to show
129 129 # visible changesets
130 130 current_filter = repo.filtername
131 131 if (
132 132 common.hashiddenaccess(repo, req)
133 133 and current_filter is not None
134 134 and current_filter + b'.hidden' in repoview.filtertable
135 135 ):
136 136 self.repo = self.repo.filtered(repo.filtername + b'.hidden')
137 137
138 138 self.maxchanges = self.configint(b'web', b'maxchanges')
139 139 self.stripecount = self.configint(b'web', b'stripes')
140 140 self.maxshortchanges = self.configint(b'web', b'maxshortchanges')
141 141 self.maxfiles = self.configint(b'web', b'maxfiles')
142 142 self.allowpull = self.configbool(b'web', b'allow-pull')
143 143
144 144 # we use untrusted=False to prevent a repo owner from using
145 145 # web.templates in .hg/hgrc to get access to any file readable
146 146 # by the user running the CGI script
147 147 self.templatepath = self.config(b'web', b'templates', untrusted=False)
148 148
149 149 # This object is more expensive to build than simple config values.
150 150 # It is shared across requests. The app will replace the object
151 151 # if it is updated. Since this is a reference and nothing should
152 152 # modify the underlying object, it should be constant for the lifetime
153 153 # of the request.
154 154 self.websubtable = app.websubtable
155 155
156 156 self.csp, self.nonce = cspvalues(self.repo.ui)
157 157
158 158 # Trust the settings from the .hg/hgrc files by default.
159 159 def config(self, *args, **kwargs):
160 160 kwargs.setdefault('untrusted', True)
161 161 return self.repo.ui.config(*args, **kwargs)
162 162
163 163 def configbool(self, *args, **kwargs):
164 164 kwargs.setdefault('untrusted', True)
165 165 return self.repo.ui.configbool(*args, **kwargs)
166 166
167 167 def configint(self, *args, **kwargs):
168 168 kwargs.setdefault('untrusted', True)
169 169 return self.repo.ui.configint(*args, **kwargs)
170 170
171 171 def configlist(self, *args, **kwargs):
172 172 kwargs.setdefault('untrusted', True)
173 173 return self.repo.ui.configlist(*args, **kwargs)
174 174
175 175 def archivelist(self, nodeid):
176 176 return webutil.archivelist(self.repo.ui, nodeid)
177 177
178 178 def templater(self, req):
179 179 # determine scheme, port and server name
180 180 # this is needed to create absolute urls
181 181 logourl = self.config(b'web', b'logourl')
182 182 logoimg = self.config(b'web', b'logoimg')
183 183 staticurl = (
184 184 self.config(b'web', b'staticurl')
185 185 or req.apppath.rstrip(b'/') + b'/static/'
186 186 )
187 187 if not staticurl.endswith(b'/'):
188 188 staticurl += b'/'
189 189
190 190 # figure out which style to use
191 191
192 192 vars = {}
193 193 styles, (style, mapfile, fp) = getstyle(
194 194 req, self.config, self.templatepath
195 195 )
196 196 if style == styles[0]:
197 197 vars[b'style'] = style
198 198
199 199 sessionvars = webutil.sessionvars(vars, b'?')
200 200
201 201 if not self.reponame:
202 202 self.reponame = (
203 203 self.config(b'web', b'name', b'')
204 204 or req.reponame
205 205 or req.apppath
206 206 or self.repo.root
207 207 )
208 208
209 209 filters = {}
210 210 templatefilter = registrar.templatefilter(filters)
211 211
212 212 @templatefilter(b'websub', intype=bytes)
213 213 def websubfilter(text):
214 214 return templatefilters.websub(text, self.websubtable)
215 215
216 216 # create the templater
217 217 # TODO: export all keywords: defaults = templatekw.keywords.copy()
218 218 defaults = {
219 219 b'url': req.apppath + b'/',
220 220 b'logourl': logourl,
221 221 b'logoimg': logoimg,
222 222 b'staticurl': staticurl,
223 223 b'urlbase': req.advertisedbaseurl,
224 224 b'repo': self.reponame,
225 225 b'encoding': encoding.encoding,
226 226 b'sessionvars': sessionvars,
227 227 b'pathdef': makebreadcrumb(req.apppath),
228 228 b'style': style,
229 229 b'nonce': self.nonce,
230 230 }
231 231 templatekeyword = registrar.templatekeyword(defaults)
232 232
233 233 @templatekeyword(b'motd', requires=())
234 234 def motd(context, mapping):
235 235 yield self.config(b'web', b'motd')
236 236
237 237 tres = formatter.templateresources(self.repo.ui, self.repo)
238 238 return templater.templater.frommapfile(
239 239 mapfile, fp=fp, filters=filters, defaults=defaults, resources=tres
240 240 )
241 241
242 242 def sendtemplate(self, name, **kwargs):
243 243 """Helper function to send a response generated from a template."""
244 244 if self.req.method != b'HEAD':
245 245 kwargs = pycompat.byteskwargs(kwargs)
246 246 self.res.setbodygen(self.tmpl.generate(name, kwargs))
247 247 return self.res.sendresponse()
248 248
249 249
250 250 class hgweb:
251 251 """HTTP server for individual repositories.
252 252
253 253 Instances of this class serve HTTP responses for a particular
254 254 repository.
255 255
256 256 Instances are typically used as WSGI applications.
257 257
258 258 Some servers are multi-threaded. On these servers, there may
259 259 be multiple active threads inside __call__.
260 260 """
261 261
262 262 def __init__(self, repo, name=None, baseui=None):
263 263 if isinstance(repo, bytes):
264 264 if baseui:
265 265 u = baseui.copy()
266 266 else:
267 267 u = uimod.ui.load()
268 268 extensions.loadall(u)
269 269 extensions.populateui(u)
270 270 r = hg.repository(u, repo)
271 271 else:
272 272 # we trust caller to give us a private copy
273 273 r = repo
274 274
275 275 r.ui.setconfig(b'ui', b'report_untrusted', b'off', b'hgweb')
276 276 r.baseui.setconfig(b'ui', b'report_untrusted', b'off', b'hgweb')
277 277 r.ui.setconfig(b'ui', b'nontty', b'true', b'hgweb')
278 278 r.baseui.setconfig(b'ui', b'nontty', b'true', b'hgweb')
279 279 # resolve file patterns relative to repo root
280 280 r.ui.setconfig(b'ui', b'forcecwd', r.root, b'hgweb')
281 281 r.baseui.setconfig(b'ui', b'forcecwd', r.root, b'hgweb')
282 282 # it's unlikely that we can replace signal handlers in WSGI server,
283 283 # and mod_wsgi issues a big warning. a plain hgweb process (with no
284 284 # threading) could replace signal handlers, but we don't bother
285 285 # conditionally enabling it.
286 286 r.ui.setconfig(b'ui', b'signal-safe-lock', b'false', b'hgweb')
287 287 r.baseui.setconfig(b'ui', b'signal-safe-lock', b'false', b'hgweb')
288 288 # displaying bundling progress bar while serving feel wrong and may
289 289 # break some wsgi implementation.
290 290 r.ui.setconfig(b'progress', b'disable', b'true', b'hgweb')
291 291 r.baseui.setconfig(b'progress', b'disable', b'true', b'hgweb')
292 292 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
293 293 self._lastrepo = self._repos[0]
294 294 hook.redirect(True)
295 295 self.reponame = name
296 296
297 297 def _webifyrepo(self, repo):
298 298 repo = getwebview(repo)
299 299 self.websubtable = webutil.getwebsubs(repo)
300 300 return repo
301 301
302 302 @contextlib.contextmanager
303 303 def _obtainrepo(self):
304 304 """Obtain a repo unique to the caller.
305 305
306 306 Internally we maintain a stack of cachedlocalrepo instances
307 307 to be handed out. If one is available, we pop it and return it,
308 308 ensuring it is up to date in the process. If one is not available,
309 309 we clone the most recently used repo instance and return it.
310 310
311 311 It is currently possible for the stack to grow without bounds
312 312 if the server allows infinite threads. However, servers should
313 313 have a thread limit, thus establishing our limit.
314 314 """
315 315 if self._repos:
316 316 cached = self._repos.pop()
317 317 r, created = cached.fetch()
318 318 else:
319 319 cached = self._lastrepo.copy()
320 320 r, created = cached.fetch()
321 321 if created:
322 322 r = self._webifyrepo(r)
323 323
324 324 self._lastrepo = cached
325 325 self.mtime = cached.mtime
326 326 try:
327 327 yield r
328 328 finally:
329 329 self._repos.append(cached)
330 330
331 331 def run(self):
332 332 """Start a server from CGI environment.
333 333
334 334 Modern servers should be using WSGI and should avoid this
335 335 method, if possible.
336 336 """
337 337 if not encoding.environ.get(b'GATEWAY_INTERFACE', b'').startswith(
338 338 b"CGI/1."
339 339 ):
340 340 raise RuntimeError(
341 341 b"This function is only intended to be "
342 342 b"called while running as a CGI script."
343 343 )
344 344 wsgicgi.launch(self)
345 345
346 346 def __call__(self, env, respond):
347 347 """Run the WSGI application.
348 348
349 349 This may be called by multiple threads.
350 350 """
351 351 req = requestmod.parserequestfromenv(env)
352 352 res = requestmod.wsgiresponse(req, respond)
353 353
354 354 return self.run_wsgi(req, res)
355 355
356 356 def run_wsgi(self, req, res):
357 357 """Internal method to run the WSGI application.
358 358
359 359 This is typically only called by Mercurial. External consumers
360 360 should be using instances of this class as the WSGI application.
361 361 """
362 362 with self._obtainrepo() as repo:
363 363 profile = repo.ui.configbool(b'profiling', b'enabled')
364 364 with profiling.profile(repo.ui, enabled=profile):
365 365 for r in self._runwsgi(req, res, repo):
366 366 yield r
367 367
368 368 def _runwsgi(self, req, res, repo):
369 369 rctx = requestcontext(self, repo, req, res)
370 370
371 371 # This state is global across all threads.
372 372 encoding.encoding = rctx.config(b'web', b'encoding')
373 373 rctx.repo.ui.environ = req.rawenv
374 374
375 375 if rctx.csp:
376 376 # hgwebdir may have added CSP header. Since we generate our own,
377 377 # replace it.
378 378 res.headers[b'Content-Security-Policy'] = rctx.csp
379 379
380 380 handled = wireprotoserver.handlewsgirequest(
381 381 rctx, req, res, self.check_perm
382 382 )
383 383 if handled:
384 384 return res.sendresponse()
385 385
386 386 # Old implementations of hgweb supported dispatching the request via
387 387 # the initial query string parameter instead of using PATH_INFO.
388 388 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having
389 389 # a value), we use it. Otherwise fall back to the query string.
390 390 if req.dispatchpath is not None:
391 391 query = req.dispatchpath
392 392 else:
393 393 query = req.querystring.partition(b'&')[0].partition(b';')[0]
394 394
395 395 # translate user-visible url structure to internal structure
396 396
397 397 args = query.split(b'/', 2)
398 398 if b'cmd' not in req.qsparams and args and args[0]:
399 399 cmd = args.pop(0)
400 400 style = cmd.rfind(b'-')
401 401 if style != -1:
402 402 req.qsparams[b'style'] = cmd[:style]
403 403 cmd = cmd[style + 1 :]
404 404
405 405 # avoid accepting e.g. style parameter as command
406 if util.safehasattr(webcommands, cmd):
406 if util.safehasattr(webcommands, pycompat.sysstr(cmd)):
407 407 req.qsparams[b'cmd'] = cmd
408 408
409 409 if cmd == b'static':
410 410 req.qsparams[b'file'] = b'/'.join(args)
411 411 else:
412 412 if args and args[0]:
413 413 node = args.pop(0).replace(b'%2F', b'/')
414 414 req.qsparams[b'node'] = node
415 415 if args:
416 416 if b'file' in req.qsparams:
417 417 del req.qsparams[b'file']
418 418 for a in args:
419 419 req.qsparams.add(b'file', a)
420 420
421 421 ua = req.headers.get(b'User-Agent', b'')
422 422 if cmd == b'rev' and b'mercurial' in ua:
423 423 req.qsparams[b'style'] = b'raw'
424 424
425 425 if cmd == b'archive':
426 426 fn = req.qsparams[b'node']
427 427 for type_, spec in webutil.archivespecs.items():
428 428 ext = spec[2]
429 429 if fn.endswith(ext):
430 430 req.qsparams[b'node'] = fn[: -len(ext)]
431 431 req.qsparams[b'type'] = type_
432 432 else:
433 433 cmd = req.qsparams.get(b'cmd', b'')
434 434
435 435 # process the web interface request
436 436
437 437 try:
438 438 rctx.tmpl = rctx.templater(req)
439 439 ctype = rctx.tmpl.render(
440 440 b'mimetype', {b'encoding': encoding.encoding}
441 441 )
442 442
443 443 # check read permissions non-static content
444 444 if cmd != b'static':
445 445 self.check_perm(rctx, req, None)
446 446
447 447 if cmd == b'':
448 448 req.qsparams[b'cmd'] = rctx.tmpl.render(b'default', {})
449 449 cmd = req.qsparams[b'cmd']
450 450
451 451 # Don't enable caching if using a CSP nonce because then it wouldn't
452 452 # be a nonce.
453 453 if rctx.configbool(b'web', b'cache') and not rctx.nonce:
454 454 tag = b'W/"%d"' % self.mtime
455 455 if req.headers.get(b'If-None-Match') == tag:
456 456 res.status = b'304 Not Modified'
457 457 # Content-Type may be defined globally. It isn't valid on a
458 458 # 304, so discard it.
459 459 try:
460 460 del res.headers[b'Content-Type']
461 461 except KeyError:
462 462 pass
463 463 # Response body not allowed on 304.
464 464 res.setbodybytes(b'')
465 465 return res.sendresponse()
466 466
467 467 res.headers[b'ETag'] = tag
468 468
469 469 if cmd not in webcommands.__all__:
470 470 msg = b'no such method: %s' % cmd
471 471 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
472 472 else:
473 473 # Set some globals appropriate for web handlers. Commands can
474 474 # override easily enough.
475 475 res.status = b'200 Script output follows'
476 476 res.headers[b'Content-Type'] = ctype
477 return getattr(webcommands, cmd)(rctx)
477 return getattr(webcommands, pycompat.sysstr(cmd))(rctx)
478 478
479 479 except (error.LookupError, error.RepoLookupError) as err:
480 480 msg = pycompat.bytestr(err)
481 481 if util.safehasattr(err, 'name') and not isinstance(
482 482 err, error.ManifestLookupError
483 483 ):
484 484 msg = b'revision not found: %s' % err.name
485 485
486 486 res.status = b'404 Not Found'
487 487 res.headers[b'Content-Type'] = ctype
488 488 return rctx.sendtemplate(b'error', error=msg)
489 489 except (error.RepoError, error.StorageError) as e:
490 490 res.status = b'500 Internal Server Error'
491 491 res.headers[b'Content-Type'] = ctype
492 492 return rctx.sendtemplate(b'error', error=pycompat.bytestr(e))
493 493 except error.Abort as e:
494 494 res.status = b'403 Forbidden'
495 495 res.headers[b'Content-Type'] = ctype
496 496 return rctx.sendtemplate(b'error', error=e.message)
497 497 except ErrorResponse as e:
498 498 for k, v in e.headers:
499 499 res.headers[k] = v
500 500 res.status = statusmessage(e.code, pycompat.bytestr(e))
501 501 res.headers[b'Content-Type'] = ctype
502 502 return rctx.sendtemplate(b'error', error=pycompat.bytestr(e))
503 503
504 504 def check_perm(self, rctx, req, op):
505 505 for permhook in permhooks:
506 506 permhook(rctx, req, op)
507 507
508 508
509 509 def getwebview(repo):
510 510 """The 'web.view' config controls changeset filter to hgweb. Possible
511 511 values are ``served``, ``visible`` and ``all``. Default is ``served``.
512 512 The ``served`` filter only shows changesets that can be pulled from the
513 513 hgweb instance. The``visible`` filter includes secret changesets but
514 514 still excludes "hidden" one.
515 515
516 516 See the repoview module for details.
517 517
518 518 The option has been around undocumented since Mercurial 2.5, but no
519 519 user ever asked about it. So we better keep it undocumented for now."""
520 520 # experimental config: web.view
521 521 viewconfig = repo.ui.config(b'web', b'view', untrusted=True)
522 522 if viewconfig == b'all':
523 523 return repo.unfiltered()
524 524 elif viewconfig in repoview.filtertable:
525 525 return repo.filtered(viewconfig)
526 526 else:
527 527 return repo.filtered(b'served')
General Comments 0
You need to be logged in to leave comments. Login now