46 ) |
46 ) |
47 |
47 |
48 |
48 |
49 def getstyle(req, configfn, templatepath): |
49 def getstyle(req, configfn, templatepath): |
50 styles = ( |
50 styles = ( |
51 req.qsparams.get('style', None), |
51 req.qsparams.get(b'style', None), |
52 configfn('web', 'style'), |
52 configfn(b'web', b'style'), |
53 'paper', |
53 b'paper', |
54 ) |
54 ) |
55 return styles, templater.stylemap(styles, templatepath) |
55 return styles, templater.stylemap(styles, templatepath) |
56 |
56 |
57 |
57 |
58 def makebreadcrumb(url, prefix=''): |
58 def makebreadcrumb(url, prefix=b''): |
59 '''Return a 'URL breadcrumb' list |
59 '''Return a 'URL breadcrumb' list |
60 |
60 |
61 A 'URL breadcrumb' is a list of URL-name pairs, |
61 A 'URL breadcrumb' is a list of URL-name pairs, |
62 corresponding to each of the path items on a URL. |
62 corresponding to each of the path items on a URL. |
63 This can be used to create path navigation entries. |
63 This can be used to create path navigation entries. |
64 ''' |
64 ''' |
65 if url.endswith('/'): |
65 if url.endswith(b'/'): |
66 url = url[:-1] |
66 url = url[:-1] |
67 if prefix: |
67 if prefix: |
68 url = '/' + prefix + url |
68 url = b'/' + prefix + url |
69 relpath = url |
69 relpath = url |
70 if relpath.startswith('/'): |
70 if relpath.startswith(b'/'): |
71 relpath = relpath[1:] |
71 relpath = relpath[1:] |
72 |
72 |
73 breadcrumb = [] |
73 breadcrumb = [] |
74 urlel = url |
74 urlel = url |
75 pathitems = [''] + relpath.split('/') |
75 pathitems = [b''] + relpath.split(b'/') |
76 for pathel in reversed(pathitems): |
76 for pathel in reversed(pathitems): |
77 if not pathel or not urlel: |
77 if not pathel or not urlel: |
78 break |
78 break |
79 breadcrumb.append({'url': urlel, 'name': pathel}) |
79 breadcrumb.append({b'url': urlel, b'name': pathel}) |
80 urlel = os.path.dirname(urlel) |
80 urlel = os.path.dirname(urlel) |
81 return templateutil.mappinglist(reversed(breadcrumb)) |
81 return templateutil.mappinglist(reversed(breadcrumb)) |
82 |
82 |
83 |
83 |
84 class requestcontext(object): |
84 class requestcontext(object): |
93 self.repo = repo |
93 self.repo = repo |
94 self.reponame = app.reponame |
94 self.reponame = app.reponame |
95 self.req = req |
95 self.req = req |
96 self.res = res |
96 self.res = res |
97 |
97 |
98 self.maxchanges = self.configint('web', 'maxchanges') |
98 self.maxchanges = self.configint(b'web', b'maxchanges') |
99 self.stripecount = self.configint('web', 'stripes') |
99 self.stripecount = self.configint(b'web', b'stripes') |
100 self.maxshortchanges = self.configint('web', 'maxshortchanges') |
100 self.maxshortchanges = self.configint(b'web', b'maxshortchanges') |
101 self.maxfiles = self.configint('web', 'maxfiles') |
101 self.maxfiles = self.configint(b'web', b'maxfiles') |
102 self.allowpull = self.configbool('web', 'allow-pull') |
102 self.allowpull = self.configbool(b'web', b'allow-pull') |
103 |
103 |
104 # we use untrusted=False to prevent a repo owner from using |
104 # we use untrusted=False to prevent a repo owner from using |
105 # web.templates in .hg/hgrc to get access to any file readable |
105 # web.templates in .hg/hgrc to get access to any file readable |
106 # by the user running the CGI script |
106 # by the user running the CGI script |
107 self.templatepath = self.config('web', 'templates', untrusted=False) |
107 self.templatepath = self.config(b'web', b'templates', untrusted=False) |
108 |
108 |
109 # This object is more expensive to build than simple config values. |
109 # This object is more expensive to build than simple config values. |
110 # It is shared across requests. The app will replace the object |
110 # It is shared across requests. The app will replace the object |
111 # if it is updated. Since this is a reference and nothing should |
111 # if it is updated. Since this is a reference and nothing should |
112 # modify the underlying object, it should be constant for the lifetime |
112 # modify the underlying object, it should be constant for the lifetime |
138 return webutil.archivelist(self.repo.ui, nodeid) |
138 return webutil.archivelist(self.repo.ui, nodeid) |
139 |
139 |
140 def templater(self, req): |
140 def templater(self, req): |
141 # determine scheme, port and server name |
141 # determine scheme, port and server name |
142 # this is needed to create absolute urls |
142 # this is needed to create absolute urls |
143 logourl = self.config('web', 'logourl') |
143 logourl = self.config(b'web', b'logourl') |
144 logoimg = self.config('web', 'logoimg') |
144 logoimg = self.config(b'web', b'logoimg') |
145 staticurl = ( |
145 staticurl = ( |
146 self.config('web', 'staticurl') |
146 self.config(b'web', b'staticurl') |
147 or req.apppath.rstrip('/') + '/static/' |
147 or req.apppath.rstrip(b'/') + b'/static/' |
148 ) |
148 ) |
149 if not staticurl.endswith('/'): |
149 if not staticurl.endswith(b'/'): |
150 staticurl += '/' |
150 staticurl += b'/' |
151 |
151 |
152 # figure out which style to use |
152 # figure out which style to use |
153 |
153 |
154 vars = {} |
154 vars = {} |
155 styles, (style, mapfile) = getstyle(req, self.config, self.templatepath) |
155 styles, (style, mapfile) = getstyle(req, self.config, self.templatepath) |
156 if style == styles[0]: |
156 if style == styles[0]: |
157 vars['style'] = style |
157 vars[b'style'] = style |
158 |
158 |
159 sessionvars = webutil.sessionvars(vars, '?') |
159 sessionvars = webutil.sessionvars(vars, b'?') |
160 |
160 |
161 if not self.reponame: |
161 if not self.reponame: |
162 self.reponame = ( |
162 self.reponame = ( |
163 self.config('web', 'name', '') |
163 self.config(b'web', b'name', b'') |
164 or req.reponame |
164 or req.reponame |
165 or req.apppath |
165 or req.apppath |
166 or self.repo.root |
166 or self.repo.root |
167 ) |
167 ) |
168 |
168 |
169 filters = {} |
169 filters = {} |
170 templatefilter = registrar.templatefilter(filters) |
170 templatefilter = registrar.templatefilter(filters) |
171 |
171 |
172 @templatefilter('websub', intype=bytes) |
172 @templatefilter(b'websub', intype=bytes) |
173 def websubfilter(text): |
173 def websubfilter(text): |
174 return templatefilters.websub(text, self.websubtable) |
174 return templatefilters.websub(text, self.websubtable) |
175 |
175 |
176 # create the templater |
176 # create the templater |
177 # TODO: export all keywords: defaults = templatekw.keywords.copy() |
177 # TODO: export all keywords: defaults = templatekw.keywords.copy() |
178 defaults = { |
178 defaults = { |
179 'url': req.apppath + '/', |
179 b'url': req.apppath + b'/', |
180 'logourl': logourl, |
180 b'logourl': logourl, |
181 'logoimg': logoimg, |
181 b'logoimg': logoimg, |
182 'staticurl': staticurl, |
182 b'staticurl': staticurl, |
183 'urlbase': req.advertisedbaseurl, |
183 b'urlbase': req.advertisedbaseurl, |
184 'repo': self.reponame, |
184 b'repo': self.reponame, |
185 'encoding': encoding.encoding, |
185 b'encoding': encoding.encoding, |
186 'sessionvars': sessionvars, |
186 b'sessionvars': sessionvars, |
187 'pathdef': makebreadcrumb(req.apppath), |
187 b'pathdef': makebreadcrumb(req.apppath), |
188 'style': style, |
188 b'style': style, |
189 'nonce': self.nonce, |
189 b'nonce': self.nonce, |
190 } |
190 } |
191 templatekeyword = registrar.templatekeyword(defaults) |
191 templatekeyword = registrar.templatekeyword(defaults) |
192 |
192 |
193 @templatekeyword('motd', requires=()) |
193 @templatekeyword(b'motd', requires=()) |
194 def motd(context, mapping): |
194 def motd(context, mapping): |
195 yield self.config('web', 'motd') |
195 yield self.config(b'web', b'motd') |
196 |
196 |
197 tres = formatter.templateresources(self.repo.ui, self.repo) |
197 tres = formatter.templateresources(self.repo.ui, self.repo) |
198 tmpl = templater.templater.frommapfile( |
198 tmpl = templater.templater.frommapfile( |
199 mapfile, filters=filters, defaults=defaults, resources=tres |
199 mapfile, filters=filters, defaults=defaults, resources=tres |
200 ) |
200 ) |
230 r = hg.repository(u, repo) |
230 r = hg.repository(u, repo) |
231 else: |
231 else: |
232 # we trust caller to give us a private copy |
232 # we trust caller to give us a private copy |
233 r = repo |
233 r = repo |
234 |
234 |
235 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb') |
235 r.ui.setconfig(b'ui', b'report_untrusted', b'off', b'hgweb') |
236 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb') |
236 r.baseui.setconfig(b'ui', b'report_untrusted', b'off', b'hgweb') |
237 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb') |
237 r.ui.setconfig(b'ui', b'nontty', b'true', b'hgweb') |
238 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb') |
238 r.baseui.setconfig(b'ui', b'nontty', b'true', b'hgweb') |
239 # resolve file patterns relative to repo root |
239 # resolve file patterns relative to repo root |
240 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb') |
240 r.ui.setconfig(b'ui', b'forcecwd', r.root, b'hgweb') |
241 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb') |
241 r.baseui.setconfig(b'ui', b'forcecwd', r.root, b'hgweb') |
242 # it's unlikely that we can replace signal handlers in WSGI server, |
242 # it's unlikely that we can replace signal handlers in WSGI server, |
243 # and mod_wsgi issues a big warning. a plain hgweb process (with no |
243 # and mod_wsgi issues a big warning. a plain hgweb process (with no |
244 # threading) could replace signal handlers, but we don't bother |
244 # threading) could replace signal handlers, but we don't bother |
245 # conditionally enabling it. |
245 # conditionally enabling it. |
246 r.ui.setconfig('ui', 'signal-safe-lock', 'false', 'hgweb') |
246 r.ui.setconfig(b'ui', b'signal-safe-lock', b'false', b'hgweb') |
247 r.baseui.setconfig('ui', 'signal-safe-lock', 'false', 'hgweb') |
247 r.baseui.setconfig(b'ui', b'signal-safe-lock', b'false', b'hgweb') |
248 # displaying bundling progress bar while serving feel wrong and may |
248 # displaying bundling progress bar while serving feel wrong and may |
249 # break some wsgi implementation. |
249 # break some wsgi implementation. |
250 r.ui.setconfig('progress', 'disable', 'true', 'hgweb') |
250 r.ui.setconfig(b'progress', b'disable', b'true', b'hgweb') |
251 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb') |
251 r.baseui.setconfig(b'progress', b'disable', b'true', b'hgweb') |
252 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))] |
252 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))] |
253 self._lastrepo = self._repos[0] |
253 self._lastrepo = self._repos[0] |
254 hook.redirect(True) |
254 hook.redirect(True) |
255 self.reponame = name |
255 self.reponame = name |
256 |
256 |
318 |
318 |
319 This is typically only called by Mercurial. External consumers |
319 This is typically only called by Mercurial. External consumers |
320 should be using instances of this class as the WSGI application. |
320 should be using instances of this class as the WSGI application. |
321 """ |
321 """ |
322 with self._obtainrepo() as repo: |
322 with self._obtainrepo() as repo: |
323 profile = repo.ui.configbool('profiling', 'enabled') |
323 profile = repo.ui.configbool(b'profiling', b'enabled') |
324 with profiling.profile(repo.ui, enabled=profile): |
324 with profiling.profile(repo.ui, enabled=profile): |
325 for r in self._runwsgi(req, res, repo): |
325 for r in self._runwsgi(req, res, repo): |
326 yield r |
326 yield r |
327 |
327 |
328 def _runwsgi(self, req, res, repo): |
328 def _runwsgi(self, req, res, repo): |
329 rctx = requestcontext(self, repo, req, res) |
329 rctx = requestcontext(self, repo, req, res) |
330 |
330 |
331 # This state is global across all threads. |
331 # This state is global across all threads. |
332 encoding.encoding = rctx.config('web', 'encoding') |
332 encoding.encoding = rctx.config(b'web', b'encoding') |
333 rctx.repo.ui.environ = req.rawenv |
333 rctx.repo.ui.environ = req.rawenv |
334 |
334 |
335 if rctx.csp: |
335 if rctx.csp: |
336 # hgwebdir may have added CSP header. Since we generate our own, |
336 # hgwebdir may have added CSP header. Since we generate our own, |
337 # replace it. |
337 # replace it. |
338 res.headers['Content-Security-Policy'] = rctx.csp |
338 res.headers[b'Content-Security-Policy'] = rctx.csp |
339 |
339 |
340 # /api/* is reserved for various API implementations. Dispatch |
340 # /api/* is reserved for various API implementations. Dispatch |
341 # accordingly. But URL paths can conflict with subrepos and virtual |
341 # accordingly. But URL paths can conflict with subrepos and virtual |
342 # repos in hgwebdir. So until we have a workaround for this, only |
342 # repos in hgwebdir. So until we have a workaround for this, only |
343 # expose the URLs if the feature is enabled. |
343 # expose the URLs if the feature is enabled. |
344 apienabled = rctx.repo.ui.configbool('experimental', 'web.apiserver') |
344 apienabled = rctx.repo.ui.configbool(b'experimental', b'web.apiserver') |
345 if apienabled and req.dispatchparts and req.dispatchparts[0] == b'api': |
345 if apienabled and req.dispatchparts and req.dispatchparts[0] == b'api': |
346 wireprotoserver.handlewsgiapirequest( |
346 wireprotoserver.handlewsgiapirequest( |
347 rctx, req, res, self.check_perm |
347 rctx, req, res, self.check_perm |
348 ) |
348 ) |
349 return res.sendresponse() |
349 return res.sendresponse() |
359 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having |
359 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having |
360 # a value), we use it. Otherwise fall back to the query string. |
360 # a value), we use it. Otherwise fall back to the query string. |
361 if req.dispatchpath is not None: |
361 if req.dispatchpath is not None: |
362 query = req.dispatchpath |
362 query = req.dispatchpath |
363 else: |
363 else: |
364 query = req.querystring.partition('&')[0].partition(';')[0] |
364 query = req.querystring.partition(b'&')[0].partition(b';')[0] |
365 |
365 |
366 # translate user-visible url structure to internal structure |
366 # translate user-visible url structure to internal structure |
367 |
367 |
368 args = query.split('/', 2) |
368 args = query.split(b'/', 2) |
369 if 'cmd' not in req.qsparams and args and args[0]: |
369 if b'cmd' not in req.qsparams and args and args[0]: |
370 cmd = args.pop(0) |
370 cmd = args.pop(0) |
371 style = cmd.rfind('-') |
371 style = cmd.rfind(b'-') |
372 if style != -1: |
372 if style != -1: |
373 req.qsparams['style'] = cmd[:style] |
373 req.qsparams[b'style'] = cmd[:style] |
374 cmd = cmd[style + 1 :] |
374 cmd = cmd[style + 1 :] |
375 |
375 |
376 # avoid accepting e.g. style parameter as command |
376 # avoid accepting e.g. style parameter as command |
377 if util.safehasattr(webcommands, cmd): |
377 if util.safehasattr(webcommands, cmd): |
378 req.qsparams['cmd'] = cmd |
378 req.qsparams[b'cmd'] = cmd |
379 |
379 |
380 if cmd == 'static': |
380 if cmd == b'static': |
381 req.qsparams['file'] = '/'.join(args) |
381 req.qsparams[b'file'] = b'/'.join(args) |
382 else: |
382 else: |
383 if args and args[0]: |
383 if args and args[0]: |
384 node = args.pop(0).replace('%2F', '/') |
384 node = args.pop(0).replace(b'%2F', b'/') |
385 req.qsparams['node'] = node |
385 req.qsparams[b'node'] = node |
386 if args: |
386 if args: |
387 if 'file' in req.qsparams: |
387 if b'file' in req.qsparams: |
388 del req.qsparams['file'] |
388 del req.qsparams[b'file'] |
389 for a in args: |
389 for a in args: |
390 req.qsparams.add('file', a) |
390 req.qsparams.add(b'file', a) |
391 |
391 |
392 ua = req.headers.get('User-Agent', '') |
392 ua = req.headers.get(b'User-Agent', b'') |
393 if cmd == 'rev' and 'mercurial' in ua: |
393 if cmd == b'rev' and b'mercurial' in ua: |
394 req.qsparams['style'] = 'raw' |
394 req.qsparams[b'style'] = b'raw' |
395 |
395 |
396 if cmd == 'archive': |
396 if cmd == b'archive': |
397 fn = req.qsparams['node'] |
397 fn = req.qsparams[b'node'] |
398 for type_, spec in webutil.archivespecs.iteritems(): |
398 for type_, spec in webutil.archivespecs.iteritems(): |
399 ext = spec[2] |
399 ext = spec[2] |
400 if fn.endswith(ext): |
400 if fn.endswith(ext): |
401 req.qsparams['node'] = fn[: -len(ext)] |
401 req.qsparams[b'node'] = fn[: -len(ext)] |
402 req.qsparams['type'] = type_ |
402 req.qsparams[b'type'] = type_ |
403 else: |
403 else: |
404 cmd = req.qsparams.get('cmd', '') |
404 cmd = req.qsparams.get(b'cmd', b'') |
405 |
405 |
406 # process the web interface request |
406 # process the web interface request |
407 |
407 |
408 try: |
408 try: |
409 rctx.tmpl = rctx.templater(req) |
409 rctx.tmpl = rctx.templater(req) |
410 ctype = rctx.tmpl.render( |
410 ctype = rctx.tmpl.render( |
411 'mimetype', {'encoding': encoding.encoding} |
411 b'mimetype', {b'encoding': encoding.encoding} |
412 ) |
412 ) |
413 |
413 |
414 # check read permissions non-static content |
414 # check read permissions non-static content |
415 if cmd != 'static': |
415 if cmd != b'static': |
416 self.check_perm(rctx, req, None) |
416 self.check_perm(rctx, req, None) |
417 |
417 |
418 if cmd == '': |
418 if cmd == b'': |
419 req.qsparams['cmd'] = rctx.tmpl.render('default', {}) |
419 req.qsparams[b'cmd'] = rctx.tmpl.render(b'default', {}) |
420 cmd = req.qsparams['cmd'] |
420 cmd = req.qsparams[b'cmd'] |
421 |
421 |
422 # Don't enable caching if using a CSP nonce because then it wouldn't |
422 # Don't enable caching if using a CSP nonce because then it wouldn't |
423 # be a nonce. |
423 # be a nonce. |
424 if rctx.configbool('web', 'cache') and not rctx.nonce: |
424 if rctx.configbool(b'web', b'cache') and not rctx.nonce: |
425 tag = 'W/"%d"' % self.mtime |
425 tag = b'W/"%d"' % self.mtime |
426 if req.headers.get('If-None-Match') == tag: |
426 if req.headers.get(b'If-None-Match') == tag: |
427 res.status = '304 Not Modified' |
427 res.status = b'304 Not Modified' |
428 # Content-Type may be defined globally. It isn't valid on a |
428 # Content-Type may be defined globally. It isn't valid on a |
429 # 304, so discard it. |
429 # 304, so discard it. |
430 try: |
430 try: |
431 del res.headers[b'Content-Type'] |
431 del res.headers[b'Content-Type'] |
432 except KeyError: |
432 except KeyError: |
433 pass |
433 pass |
434 # Response body not allowed on 304. |
434 # Response body not allowed on 304. |
435 res.setbodybytes('') |
435 res.setbodybytes(b'') |
436 return res.sendresponse() |
436 return res.sendresponse() |
437 |
437 |
438 res.headers['ETag'] = tag |
438 res.headers[b'ETag'] = tag |
439 |
439 |
440 if cmd not in webcommands.__all__: |
440 if cmd not in webcommands.__all__: |
441 msg = 'no such method: %s' % cmd |
441 msg = b'no such method: %s' % cmd |
442 raise ErrorResponse(HTTP_BAD_REQUEST, msg) |
442 raise ErrorResponse(HTTP_BAD_REQUEST, msg) |
443 else: |
443 else: |
444 # Set some globals appropriate for web handlers. Commands can |
444 # Set some globals appropriate for web handlers. Commands can |
445 # override easily enough. |
445 # override easily enough. |
446 res.status = '200 Script output follows' |
446 res.status = b'200 Script output follows' |
447 res.headers['Content-Type'] = ctype |
447 res.headers[b'Content-Type'] = ctype |
448 return getattr(webcommands, cmd)(rctx) |
448 return getattr(webcommands, cmd)(rctx) |
449 |
449 |
450 except (error.LookupError, error.RepoLookupError) as err: |
450 except (error.LookupError, error.RepoLookupError) as err: |
451 msg = pycompat.bytestr(err) |
451 msg = pycompat.bytestr(err) |
452 if util.safehasattr(err, 'name') and not isinstance( |
452 if util.safehasattr(err, b'name') and not isinstance( |
453 err, error.ManifestLookupError |
453 err, error.ManifestLookupError |
454 ): |
454 ): |
455 msg = 'revision not found: %s' % err.name |
455 msg = b'revision not found: %s' % err.name |
456 |
456 |
457 res.status = '404 Not Found' |
457 res.status = b'404 Not Found' |
458 res.headers['Content-Type'] = ctype |
458 res.headers[b'Content-Type'] = ctype |
459 return rctx.sendtemplate('error', error=msg) |
459 return rctx.sendtemplate(b'error', error=msg) |
460 except (error.RepoError, error.StorageError) as e: |
460 except (error.RepoError, error.StorageError) as e: |
461 res.status = '500 Internal Server Error' |
461 res.status = b'500 Internal Server Error' |
462 res.headers['Content-Type'] = ctype |
462 res.headers[b'Content-Type'] = ctype |
463 return rctx.sendtemplate('error', error=pycompat.bytestr(e)) |
463 return rctx.sendtemplate(b'error', error=pycompat.bytestr(e)) |
464 except error.Abort as e: |
464 except error.Abort as e: |
465 res.status = '403 Forbidden' |
465 res.status = b'403 Forbidden' |
466 res.headers['Content-Type'] = ctype |
466 res.headers[b'Content-Type'] = ctype |
467 return rctx.sendtemplate('error', error=pycompat.bytestr(e)) |
467 return rctx.sendtemplate(b'error', error=pycompat.bytestr(e)) |
468 except ErrorResponse as e: |
468 except ErrorResponse as e: |
469 for k, v in e.headers: |
469 for k, v in e.headers: |
470 res.headers[k] = v |
470 res.headers[k] = v |
471 res.status = statusmessage(e.code, pycompat.bytestr(e)) |
471 res.status = statusmessage(e.code, pycompat.bytestr(e)) |
472 res.headers['Content-Type'] = ctype |
472 res.headers[b'Content-Type'] = ctype |
473 return rctx.sendtemplate('error', error=pycompat.bytestr(e)) |
473 return rctx.sendtemplate(b'error', error=pycompat.bytestr(e)) |
474 |
474 |
475 def check_perm(self, rctx, req, op): |
475 def check_perm(self, rctx, req, op): |
476 for permhook in permhooks: |
476 for permhook in permhooks: |
477 permhook(rctx, req, op) |
477 permhook(rctx, req, op) |
478 |
478 |