##// END OF EJS Templates
hgweb: strip trailing '/' in apppath before appending '/static/' (issue5943)...
Cédric Krier -
r38768:258d90f6 @90 stable
parent child Browse files
Show More
@@ -1,472 +1,472 b''
1 # hgweb/hgweb_mod.py - Web interface for a repository.
1 # hgweb/hgweb_mod.py - Web interface for a repository.
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from __future__ import absolute_import
9 from __future__ import absolute_import
10
10
11 import contextlib
11 import contextlib
12 import os
12 import os
13
13
14 from .common import (
14 from .common import (
15 ErrorResponse,
15 ErrorResponse,
16 HTTP_BAD_REQUEST,
16 HTTP_BAD_REQUEST,
17 cspvalues,
17 cspvalues,
18 permhooks,
18 permhooks,
19 statusmessage,
19 statusmessage,
20 )
20 )
21
21
22 from .. import (
22 from .. import (
23 encoding,
23 encoding,
24 error,
24 error,
25 formatter,
25 formatter,
26 hg,
26 hg,
27 hook,
27 hook,
28 profiling,
28 profiling,
29 pycompat,
29 pycompat,
30 registrar,
30 registrar,
31 repoview,
31 repoview,
32 templatefilters,
32 templatefilters,
33 templater,
33 templater,
34 templateutil,
34 templateutil,
35 ui as uimod,
35 ui as uimod,
36 util,
36 util,
37 wireprotoserver,
37 wireprotoserver,
38 )
38 )
39
39
40 from . import (
40 from . import (
41 request as requestmod,
41 request as requestmod,
42 webcommands,
42 webcommands,
43 webutil,
43 webutil,
44 wsgicgi,
44 wsgicgi,
45 )
45 )
46
46
47 def getstyle(req, configfn, templatepath):
47 def getstyle(req, configfn, templatepath):
48 styles = (
48 styles = (
49 req.qsparams.get('style', None),
49 req.qsparams.get('style', None),
50 configfn('web', 'style'),
50 configfn('web', 'style'),
51 'paper',
51 'paper',
52 )
52 )
53 return styles, templater.stylemap(styles, templatepath)
53 return styles, templater.stylemap(styles, templatepath)
54
54
55 def makebreadcrumb(url, prefix=''):
55 def makebreadcrumb(url, prefix=''):
56 '''Return a 'URL breadcrumb' list
56 '''Return a 'URL breadcrumb' list
57
57
58 A 'URL breadcrumb' is a list of URL-name pairs,
58 A 'URL breadcrumb' is a list of URL-name pairs,
59 corresponding to each of the path items on a URL.
59 corresponding to each of the path items on a URL.
60 This can be used to create path navigation entries.
60 This can be used to create path navigation entries.
61 '''
61 '''
62 if url.endswith('/'):
62 if url.endswith('/'):
63 url = url[:-1]
63 url = url[:-1]
64 if prefix:
64 if prefix:
65 url = '/' + prefix + url
65 url = '/' + prefix + url
66 relpath = url
66 relpath = url
67 if relpath.startswith('/'):
67 if relpath.startswith('/'):
68 relpath = relpath[1:]
68 relpath = relpath[1:]
69
69
70 breadcrumb = []
70 breadcrumb = []
71 urlel = url
71 urlel = url
72 pathitems = [''] + relpath.split('/')
72 pathitems = [''] + relpath.split('/')
73 for pathel in reversed(pathitems):
73 for pathel in reversed(pathitems):
74 if not pathel or not urlel:
74 if not pathel or not urlel:
75 break
75 break
76 breadcrumb.append({'url': urlel, 'name': pathel})
76 breadcrumb.append({'url': urlel, 'name': pathel})
77 urlel = os.path.dirname(urlel)
77 urlel = os.path.dirname(urlel)
78 return templateutil.mappinglist(reversed(breadcrumb))
78 return templateutil.mappinglist(reversed(breadcrumb))
79
79
80 class requestcontext(object):
80 class requestcontext(object):
81 """Holds state/context for an individual request.
81 """Holds state/context for an individual request.
82
82
83 Servers can be multi-threaded. Holding state on the WSGI application
83 Servers can be multi-threaded. Holding state on the WSGI application
84 is prone to race conditions. Instances of this class exist to hold
84 is prone to race conditions. Instances of this class exist to hold
85 mutable and race-free state for requests.
85 mutable and race-free state for requests.
86 """
86 """
87 def __init__(self, app, repo, req, res):
87 def __init__(self, app, repo, req, res):
88 self.repo = repo
88 self.repo = repo
89 self.reponame = app.reponame
89 self.reponame = app.reponame
90 self.req = req
90 self.req = req
91 self.res = res
91 self.res = res
92
92
93 self.maxchanges = self.configint('web', 'maxchanges')
93 self.maxchanges = self.configint('web', 'maxchanges')
94 self.stripecount = self.configint('web', 'stripes')
94 self.stripecount = self.configint('web', 'stripes')
95 self.maxshortchanges = self.configint('web', 'maxshortchanges')
95 self.maxshortchanges = self.configint('web', 'maxshortchanges')
96 self.maxfiles = self.configint('web', 'maxfiles')
96 self.maxfiles = self.configint('web', 'maxfiles')
97 self.allowpull = self.configbool('web', 'allow-pull')
97 self.allowpull = self.configbool('web', 'allow-pull')
98
98
99 # we use untrusted=False to prevent a repo owner from using
99 # we use untrusted=False to prevent a repo owner from using
100 # web.templates in .hg/hgrc to get access to any file readable
100 # web.templates in .hg/hgrc to get access to any file readable
101 # by the user running the CGI script
101 # by the user running the CGI script
102 self.templatepath = self.config('web', 'templates', untrusted=False)
102 self.templatepath = self.config('web', 'templates', untrusted=False)
103
103
104 # This object is more expensive to build than simple config values.
104 # This object is more expensive to build than simple config values.
105 # It is shared across requests. The app will replace the object
105 # It is shared across requests. The app will replace the object
106 # if it is updated. Since this is a reference and nothing should
106 # if it is updated. Since this is a reference and nothing should
107 # modify the underlying object, it should be constant for the lifetime
107 # modify the underlying object, it should be constant for the lifetime
108 # of the request.
108 # of the request.
109 self.websubtable = app.websubtable
109 self.websubtable = app.websubtable
110
110
111 self.csp, self.nonce = cspvalues(self.repo.ui)
111 self.csp, self.nonce = cspvalues(self.repo.ui)
112
112
113 # Trust the settings from the .hg/hgrc files by default.
113 # Trust the settings from the .hg/hgrc files by default.
114 def config(self, section, name, default=uimod._unset, untrusted=True):
114 def config(self, section, name, default=uimod._unset, untrusted=True):
115 return self.repo.ui.config(section, name, default,
115 return self.repo.ui.config(section, name, default,
116 untrusted=untrusted)
116 untrusted=untrusted)
117
117
118 def configbool(self, section, name, default=uimod._unset, untrusted=True):
118 def configbool(self, section, name, default=uimod._unset, untrusted=True):
119 return self.repo.ui.configbool(section, name, default,
119 return self.repo.ui.configbool(section, name, default,
120 untrusted=untrusted)
120 untrusted=untrusted)
121
121
122 def configint(self, section, name, default=uimod._unset, untrusted=True):
122 def configint(self, section, name, default=uimod._unset, untrusted=True):
123 return self.repo.ui.configint(section, name, default,
123 return self.repo.ui.configint(section, name, default,
124 untrusted=untrusted)
124 untrusted=untrusted)
125
125
126 def configlist(self, section, name, default=uimod._unset, untrusted=True):
126 def configlist(self, section, name, default=uimod._unset, untrusted=True):
127 return self.repo.ui.configlist(section, name, default,
127 return self.repo.ui.configlist(section, name, default,
128 untrusted=untrusted)
128 untrusted=untrusted)
129
129
130 def archivelist(self, nodeid):
130 def archivelist(self, nodeid):
131 return webutil.archivelist(self.repo.ui, nodeid)
131 return webutil.archivelist(self.repo.ui, nodeid)
132
132
133 def templater(self, req):
133 def templater(self, req):
134 # determine scheme, port and server name
134 # determine scheme, port and server name
135 # this is needed to create absolute urls
135 # this is needed to create absolute urls
136 logourl = self.config('web', 'logourl')
136 logourl = self.config('web', 'logourl')
137 logoimg = self.config('web', 'logoimg')
137 logoimg = self.config('web', 'logoimg')
138 staticurl = (self.config('web', 'staticurl')
138 staticurl = (self.config('web', 'staticurl')
139 or req.apppath + '/static/')
139 or req.apppath.rstrip('/') + '/static/')
140 if not staticurl.endswith('/'):
140 if not staticurl.endswith('/'):
141 staticurl += '/'
141 staticurl += '/'
142
142
143 # some functions for the templater
143 # some functions for the templater
144
144
145 def motd(**map):
145 def motd(**map):
146 yield self.config('web', 'motd')
146 yield self.config('web', 'motd')
147
147
148 # figure out which style to use
148 # figure out which style to use
149
149
150 vars = {}
150 vars = {}
151 styles, (style, mapfile) = getstyle(req, self.config,
151 styles, (style, mapfile) = getstyle(req, self.config,
152 self.templatepath)
152 self.templatepath)
153 if style == styles[0]:
153 if style == styles[0]:
154 vars['style'] = style
154 vars['style'] = style
155
155
156 sessionvars = webutil.sessionvars(vars, '?')
156 sessionvars = webutil.sessionvars(vars, '?')
157
157
158 if not self.reponame:
158 if not self.reponame:
159 self.reponame = (self.config('web', 'name', '')
159 self.reponame = (self.config('web', 'name', '')
160 or req.reponame
160 or req.reponame
161 or req.apppath
161 or req.apppath
162 or self.repo.root)
162 or self.repo.root)
163
163
164 filters = {}
164 filters = {}
165 templatefilter = registrar.templatefilter(filters)
165 templatefilter = registrar.templatefilter(filters)
166 @templatefilter('websub', intype=bytes)
166 @templatefilter('websub', intype=bytes)
167 def websubfilter(text):
167 def websubfilter(text):
168 return templatefilters.websub(text, self.websubtable)
168 return templatefilters.websub(text, self.websubtable)
169
169
170 # create the templater
170 # create the templater
171 # TODO: export all keywords: defaults = templatekw.keywords.copy()
171 # TODO: export all keywords: defaults = templatekw.keywords.copy()
172 defaults = {
172 defaults = {
173 'url': req.apppath + '/',
173 'url': req.apppath + '/',
174 'logourl': logourl,
174 'logourl': logourl,
175 'logoimg': logoimg,
175 'logoimg': logoimg,
176 'staticurl': staticurl,
176 'staticurl': staticurl,
177 'urlbase': req.advertisedbaseurl,
177 'urlbase': req.advertisedbaseurl,
178 'repo': self.reponame,
178 'repo': self.reponame,
179 'encoding': encoding.encoding,
179 'encoding': encoding.encoding,
180 'motd': motd,
180 'motd': motd,
181 'sessionvars': sessionvars,
181 'sessionvars': sessionvars,
182 'pathdef': makebreadcrumb(req.apppath),
182 'pathdef': makebreadcrumb(req.apppath),
183 'style': style,
183 'style': style,
184 'nonce': self.nonce,
184 'nonce': self.nonce,
185 }
185 }
186 tres = formatter.templateresources(self.repo.ui, self.repo)
186 tres = formatter.templateresources(self.repo.ui, self.repo)
187 tmpl = templater.templater.frommapfile(mapfile,
187 tmpl = templater.templater.frommapfile(mapfile,
188 filters=filters,
188 filters=filters,
189 defaults=defaults,
189 defaults=defaults,
190 resources=tres)
190 resources=tres)
191 return tmpl
191 return tmpl
192
192
193 def sendtemplate(self, name, **kwargs):
193 def sendtemplate(self, name, **kwargs):
194 """Helper function to send a response generated from a template."""
194 """Helper function to send a response generated from a template."""
195 kwargs = pycompat.byteskwargs(kwargs)
195 kwargs = pycompat.byteskwargs(kwargs)
196 self.res.setbodygen(self.tmpl.generate(name, kwargs))
196 self.res.setbodygen(self.tmpl.generate(name, kwargs))
197 return self.res.sendresponse()
197 return self.res.sendresponse()
198
198
199 class hgweb(object):
199 class hgweb(object):
200 """HTTP server for individual repositories.
200 """HTTP server for individual repositories.
201
201
202 Instances of this class serve HTTP responses for a particular
202 Instances of this class serve HTTP responses for a particular
203 repository.
203 repository.
204
204
205 Instances are typically used as WSGI applications.
205 Instances are typically used as WSGI applications.
206
206
207 Some servers are multi-threaded. On these servers, there may
207 Some servers are multi-threaded. On these servers, there may
208 be multiple active threads inside __call__.
208 be multiple active threads inside __call__.
209 """
209 """
210 def __init__(self, repo, name=None, baseui=None):
210 def __init__(self, repo, name=None, baseui=None):
211 if isinstance(repo, bytes):
211 if isinstance(repo, bytes):
212 if baseui:
212 if baseui:
213 u = baseui.copy()
213 u = baseui.copy()
214 else:
214 else:
215 u = uimod.ui.load()
215 u = uimod.ui.load()
216 r = hg.repository(u, repo)
216 r = hg.repository(u, repo)
217 else:
217 else:
218 # we trust caller to give us a private copy
218 # we trust caller to give us a private copy
219 r = repo
219 r = repo
220
220
221 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
221 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
222 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
222 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
223 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
223 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
224 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
224 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
225 # resolve file patterns relative to repo root
225 # resolve file patterns relative to repo root
226 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
226 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
227 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
227 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
228 # it's unlikely that we can replace signal handlers in WSGI server,
228 # it's unlikely that we can replace signal handlers in WSGI server,
229 # and mod_wsgi issues a big warning. a plain hgweb process (with no
229 # and mod_wsgi issues a big warning. a plain hgweb process (with no
230 # threading) could replace signal handlers, but we don't bother
230 # threading) could replace signal handlers, but we don't bother
231 # conditionally enabling it.
231 # conditionally enabling it.
232 r.ui.setconfig('ui', 'signal-safe-lock', 'false', 'hgweb')
232 r.ui.setconfig('ui', 'signal-safe-lock', 'false', 'hgweb')
233 r.baseui.setconfig('ui', 'signal-safe-lock', 'false', 'hgweb')
233 r.baseui.setconfig('ui', 'signal-safe-lock', 'false', 'hgweb')
234 # displaying bundling progress bar while serving feel wrong and may
234 # displaying bundling progress bar while serving feel wrong and may
235 # break some wsgi implementation.
235 # break some wsgi implementation.
236 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
236 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
237 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
237 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
238 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
238 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
239 self._lastrepo = self._repos[0]
239 self._lastrepo = self._repos[0]
240 hook.redirect(True)
240 hook.redirect(True)
241 self.reponame = name
241 self.reponame = name
242
242
243 def _webifyrepo(self, repo):
243 def _webifyrepo(self, repo):
244 repo = getwebview(repo)
244 repo = getwebview(repo)
245 self.websubtable = webutil.getwebsubs(repo)
245 self.websubtable = webutil.getwebsubs(repo)
246 return repo
246 return repo
247
247
248 @contextlib.contextmanager
248 @contextlib.contextmanager
249 def _obtainrepo(self):
249 def _obtainrepo(self):
250 """Obtain a repo unique to the caller.
250 """Obtain a repo unique to the caller.
251
251
252 Internally we maintain a stack of cachedlocalrepo instances
252 Internally we maintain a stack of cachedlocalrepo instances
253 to be handed out. If one is available, we pop it and return it,
253 to be handed out. If one is available, we pop it and return it,
254 ensuring it is up to date in the process. If one is not available,
254 ensuring it is up to date in the process. If one is not available,
255 we clone the most recently used repo instance and return it.
255 we clone the most recently used repo instance and return it.
256
256
257 It is currently possible for the stack to grow without bounds
257 It is currently possible for the stack to grow without bounds
258 if the server allows infinite threads. However, servers should
258 if the server allows infinite threads. However, servers should
259 have a thread limit, thus establishing our limit.
259 have a thread limit, thus establishing our limit.
260 """
260 """
261 if self._repos:
261 if self._repos:
262 cached = self._repos.pop()
262 cached = self._repos.pop()
263 r, created = cached.fetch()
263 r, created = cached.fetch()
264 else:
264 else:
265 cached = self._lastrepo.copy()
265 cached = self._lastrepo.copy()
266 r, created = cached.fetch()
266 r, created = cached.fetch()
267 if created:
267 if created:
268 r = self._webifyrepo(r)
268 r = self._webifyrepo(r)
269
269
270 self._lastrepo = cached
270 self._lastrepo = cached
271 self.mtime = cached.mtime
271 self.mtime = cached.mtime
272 try:
272 try:
273 yield r
273 yield r
274 finally:
274 finally:
275 self._repos.append(cached)
275 self._repos.append(cached)
276
276
277 def run(self):
277 def run(self):
278 """Start a server from CGI environment.
278 """Start a server from CGI environment.
279
279
280 Modern servers should be using WSGI and should avoid this
280 Modern servers should be using WSGI and should avoid this
281 method, if possible.
281 method, if possible.
282 """
282 """
283 if not encoding.environ.get('GATEWAY_INTERFACE',
283 if not encoding.environ.get('GATEWAY_INTERFACE',
284 '').startswith("CGI/1."):
284 '').startswith("CGI/1."):
285 raise RuntimeError("This function is only intended to be "
285 raise RuntimeError("This function is only intended to be "
286 "called while running as a CGI script.")
286 "called while running as a CGI script.")
287 wsgicgi.launch(self)
287 wsgicgi.launch(self)
288
288
289 def __call__(self, env, respond):
289 def __call__(self, env, respond):
290 """Run the WSGI application.
290 """Run the WSGI application.
291
291
292 This may be called by multiple threads.
292 This may be called by multiple threads.
293 """
293 """
294 req = requestmod.parserequestfromenv(env)
294 req = requestmod.parserequestfromenv(env)
295 res = requestmod.wsgiresponse(req, respond)
295 res = requestmod.wsgiresponse(req, respond)
296
296
297 return self.run_wsgi(req, res)
297 return self.run_wsgi(req, res)
298
298
299 def run_wsgi(self, req, res):
299 def run_wsgi(self, req, res):
300 """Internal method to run the WSGI application.
300 """Internal method to run the WSGI application.
301
301
302 This is typically only called by Mercurial. External consumers
302 This is typically only called by Mercurial. External consumers
303 should be using instances of this class as the WSGI application.
303 should be using instances of this class as the WSGI application.
304 """
304 """
305 with self._obtainrepo() as repo:
305 with self._obtainrepo() as repo:
306 profile = repo.ui.configbool('profiling', 'enabled')
306 profile = repo.ui.configbool('profiling', 'enabled')
307 with profiling.profile(repo.ui, enabled=profile):
307 with profiling.profile(repo.ui, enabled=profile):
308 for r in self._runwsgi(req, res, repo):
308 for r in self._runwsgi(req, res, repo):
309 yield r
309 yield r
310
310
311 def _runwsgi(self, req, res, repo):
311 def _runwsgi(self, req, res, repo):
312 rctx = requestcontext(self, repo, req, res)
312 rctx = requestcontext(self, repo, req, res)
313
313
314 # This state is global across all threads.
314 # This state is global across all threads.
315 encoding.encoding = rctx.config('web', 'encoding')
315 encoding.encoding = rctx.config('web', 'encoding')
316 rctx.repo.ui.environ = req.rawenv
316 rctx.repo.ui.environ = req.rawenv
317
317
318 if rctx.csp:
318 if rctx.csp:
319 # hgwebdir may have added CSP header. Since we generate our own,
319 # hgwebdir may have added CSP header. Since we generate our own,
320 # replace it.
320 # replace it.
321 res.headers['Content-Security-Policy'] = rctx.csp
321 res.headers['Content-Security-Policy'] = rctx.csp
322
322
323 # /api/* is reserved for various API implementations. Dispatch
323 # /api/* is reserved for various API implementations. Dispatch
324 # accordingly. But URL paths can conflict with subrepos and virtual
324 # accordingly. But URL paths can conflict with subrepos and virtual
325 # repos in hgwebdir. So until we have a workaround for this, only
325 # repos in hgwebdir. So until we have a workaround for this, only
326 # expose the URLs if the feature is enabled.
326 # expose the URLs if the feature is enabled.
327 apienabled = rctx.repo.ui.configbool('experimental', 'web.apiserver')
327 apienabled = rctx.repo.ui.configbool('experimental', 'web.apiserver')
328 if apienabled and req.dispatchparts and req.dispatchparts[0] == b'api':
328 if apienabled and req.dispatchparts and req.dispatchparts[0] == b'api':
329 wireprotoserver.handlewsgiapirequest(rctx, req, res,
329 wireprotoserver.handlewsgiapirequest(rctx, req, res,
330 self.check_perm)
330 self.check_perm)
331 return res.sendresponse()
331 return res.sendresponse()
332
332
333 handled = wireprotoserver.handlewsgirequest(
333 handled = wireprotoserver.handlewsgirequest(
334 rctx, req, res, self.check_perm)
334 rctx, req, res, self.check_perm)
335 if handled:
335 if handled:
336 return res.sendresponse()
336 return res.sendresponse()
337
337
338 # Old implementations of hgweb supported dispatching the request via
338 # Old implementations of hgweb supported dispatching the request via
339 # the initial query string parameter instead of using PATH_INFO.
339 # the initial query string parameter instead of using PATH_INFO.
340 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having
340 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having
341 # a value), we use it. Otherwise fall back to the query string.
341 # a value), we use it. Otherwise fall back to the query string.
342 if req.dispatchpath is not None:
342 if req.dispatchpath is not None:
343 query = req.dispatchpath
343 query = req.dispatchpath
344 else:
344 else:
345 query = req.querystring.partition('&')[0].partition(';')[0]
345 query = req.querystring.partition('&')[0].partition(';')[0]
346
346
347 # translate user-visible url structure to internal structure
347 # translate user-visible url structure to internal structure
348
348
349 args = query.split('/', 2)
349 args = query.split('/', 2)
350 if 'cmd' not in req.qsparams and args and args[0]:
350 if 'cmd' not in req.qsparams and args and args[0]:
351 cmd = args.pop(0)
351 cmd = args.pop(0)
352 style = cmd.rfind('-')
352 style = cmd.rfind('-')
353 if style != -1:
353 if style != -1:
354 req.qsparams['style'] = cmd[:style]
354 req.qsparams['style'] = cmd[:style]
355 cmd = cmd[style + 1:]
355 cmd = cmd[style + 1:]
356
356
357 # avoid accepting e.g. style parameter as command
357 # avoid accepting e.g. style parameter as command
358 if util.safehasattr(webcommands, cmd):
358 if util.safehasattr(webcommands, cmd):
359 req.qsparams['cmd'] = cmd
359 req.qsparams['cmd'] = cmd
360
360
361 if cmd == 'static':
361 if cmd == 'static':
362 req.qsparams['file'] = '/'.join(args)
362 req.qsparams['file'] = '/'.join(args)
363 else:
363 else:
364 if args and args[0]:
364 if args and args[0]:
365 node = args.pop(0).replace('%2F', '/')
365 node = args.pop(0).replace('%2F', '/')
366 req.qsparams['node'] = node
366 req.qsparams['node'] = node
367 if args:
367 if args:
368 if 'file' in req.qsparams:
368 if 'file' in req.qsparams:
369 del req.qsparams['file']
369 del req.qsparams['file']
370 for a in args:
370 for a in args:
371 req.qsparams.add('file', a)
371 req.qsparams.add('file', a)
372
372
373 ua = req.headers.get('User-Agent', '')
373 ua = req.headers.get('User-Agent', '')
374 if cmd == 'rev' and 'mercurial' in ua:
374 if cmd == 'rev' and 'mercurial' in ua:
375 req.qsparams['style'] = 'raw'
375 req.qsparams['style'] = 'raw'
376
376
377 if cmd == 'archive':
377 if cmd == 'archive':
378 fn = req.qsparams['node']
378 fn = req.qsparams['node']
379 for type_, spec in webutil.archivespecs.iteritems():
379 for type_, spec in webutil.archivespecs.iteritems():
380 ext = spec[2]
380 ext = spec[2]
381 if fn.endswith(ext):
381 if fn.endswith(ext):
382 req.qsparams['node'] = fn[:-len(ext)]
382 req.qsparams['node'] = fn[:-len(ext)]
383 req.qsparams['type'] = type_
383 req.qsparams['type'] = type_
384 else:
384 else:
385 cmd = req.qsparams.get('cmd', '')
385 cmd = req.qsparams.get('cmd', '')
386
386
387 # process the web interface request
387 # process the web interface request
388
388
389 try:
389 try:
390 rctx.tmpl = rctx.templater(req)
390 rctx.tmpl = rctx.templater(req)
391 ctype = rctx.tmpl.render('mimetype',
391 ctype = rctx.tmpl.render('mimetype',
392 {'encoding': encoding.encoding})
392 {'encoding': encoding.encoding})
393
393
394 # check read permissions non-static content
394 # check read permissions non-static content
395 if cmd != 'static':
395 if cmd != 'static':
396 self.check_perm(rctx, req, None)
396 self.check_perm(rctx, req, None)
397
397
398 if cmd == '':
398 if cmd == '':
399 req.qsparams['cmd'] = rctx.tmpl.render('default', {})
399 req.qsparams['cmd'] = rctx.tmpl.render('default', {})
400 cmd = req.qsparams['cmd']
400 cmd = req.qsparams['cmd']
401
401
402 # Don't enable caching if using a CSP nonce because then it wouldn't
402 # Don't enable caching if using a CSP nonce because then it wouldn't
403 # be a nonce.
403 # be a nonce.
404 if rctx.configbool('web', 'cache') and not rctx.nonce:
404 if rctx.configbool('web', 'cache') and not rctx.nonce:
405 tag = 'W/"%d"' % self.mtime
405 tag = 'W/"%d"' % self.mtime
406 if req.headers.get('If-None-Match') == tag:
406 if req.headers.get('If-None-Match') == tag:
407 res.status = '304 Not Modified'
407 res.status = '304 Not Modified'
408 # Content-Type may be defined globally. It isn't valid on a
408 # Content-Type may be defined globally. It isn't valid on a
409 # 304, so discard it.
409 # 304, so discard it.
410 try:
410 try:
411 del res.headers[b'Content-Type']
411 del res.headers[b'Content-Type']
412 except KeyError:
412 except KeyError:
413 pass
413 pass
414 # Response body not allowed on 304.
414 # Response body not allowed on 304.
415 res.setbodybytes('')
415 res.setbodybytes('')
416 return res.sendresponse()
416 return res.sendresponse()
417
417
418 res.headers['ETag'] = tag
418 res.headers['ETag'] = tag
419
419
420 if cmd not in webcommands.__all__:
420 if cmd not in webcommands.__all__:
421 msg = 'no such method: %s' % cmd
421 msg = 'no such method: %s' % cmd
422 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
422 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
423 else:
423 else:
424 # Set some globals appropriate for web handlers. Commands can
424 # Set some globals appropriate for web handlers. Commands can
425 # override easily enough.
425 # override easily enough.
426 res.status = '200 Script output follows'
426 res.status = '200 Script output follows'
427 res.headers['Content-Type'] = ctype
427 res.headers['Content-Type'] = ctype
428 return getattr(webcommands, cmd)(rctx)
428 return getattr(webcommands, cmd)(rctx)
429
429
430 except (error.LookupError, error.RepoLookupError) as err:
430 except (error.LookupError, error.RepoLookupError) as err:
431 msg = pycompat.bytestr(err)
431 msg = pycompat.bytestr(err)
432 if (util.safehasattr(err, 'name') and
432 if (util.safehasattr(err, 'name') and
433 not isinstance(err, error.ManifestLookupError)):
433 not isinstance(err, error.ManifestLookupError)):
434 msg = 'revision not found: %s' % err.name
434 msg = 'revision not found: %s' % err.name
435
435
436 res.status = '404 Not Found'
436 res.status = '404 Not Found'
437 res.headers['Content-Type'] = ctype
437 res.headers['Content-Type'] = ctype
438 return rctx.sendtemplate('error', error=msg)
438 return rctx.sendtemplate('error', error=msg)
439 except (error.RepoError, error.RevlogError) as e:
439 except (error.RepoError, error.RevlogError) as e:
440 res.status = '500 Internal Server Error'
440 res.status = '500 Internal Server Error'
441 res.headers['Content-Type'] = ctype
441 res.headers['Content-Type'] = ctype
442 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
442 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
443 except ErrorResponse as e:
443 except ErrorResponse as e:
444 for k, v in e.headers:
444 for k, v in e.headers:
445 res.headers[k] = v
445 res.headers[k] = v
446 res.status = statusmessage(e.code, pycompat.bytestr(e))
446 res.status = statusmessage(e.code, pycompat.bytestr(e))
447 res.headers['Content-Type'] = ctype
447 res.headers['Content-Type'] = ctype
448 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
448 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
449
449
450 def check_perm(self, rctx, req, op):
450 def check_perm(self, rctx, req, op):
451 for permhook in permhooks:
451 for permhook in permhooks:
452 permhook(rctx, req, op)
452 permhook(rctx, req, op)
453
453
454 def getwebview(repo):
454 def getwebview(repo):
455 """The 'web.view' config controls changeset filter to hgweb. Possible
455 """The 'web.view' config controls changeset filter to hgweb. Possible
456 values are ``served``, ``visible`` and ``all``. Default is ``served``.
456 values are ``served``, ``visible`` and ``all``. Default is ``served``.
457 The ``served`` filter only shows changesets that can be pulled from the
457 The ``served`` filter only shows changesets that can be pulled from the
458 hgweb instance. The``visible`` filter includes secret changesets but
458 hgweb instance. The``visible`` filter includes secret changesets but
459 still excludes "hidden" one.
459 still excludes "hidden" one.
460
460
461 See the repoview module for details.
461 See the repoview module for details.
462
462
463 The option has been around undocumented since Mercurial 2.5, but no
463 The option has been around undocumented since Mercurial 2.5, but no
464 user ever asked about it. So we better keep it undocumented for now."""
464 user ever asked about it. So we better keep it undocumented for now."""
465 # experimental config: web.view
465 # experimental config: web.view
466 viewconfig = repo.ui.config('web', 'view', untrusted=True)
466 viewconfig = repo.ui.config('web', 'view', untrusted=True)
467 if viewconfig == 'all':
467 if viewconfig == 'all':
468 return repo.unfiltered()
468 return repo.unfiltered()
469 elif viewconfig in repoview.filtertable:
469 elif viewconfig in repoview.filtertable:
470 return repo.filtered(viewconfig)
470 return repo.filtered(viewconfig)
471 else:
471 else:
472 return repo.filtered('served')
472 return repo.filtered('served')
@@ -1,533 +1,533 b''
1 # hgweb/hgwebdir_mod.py - Web interface for a directory of repositories.
1 # hgweb/hgwebdir_mod.py - Web interface for a directory of repositories.
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from __future__ import absolute_import
9 from __future__ import absolute_import
10
10
11 import gc
11 import gc
12 import os
12 import os
13 import time
13 import time
14
14
15 from ..i18n import _
15 from ..i18n import _
16
16
17 from .common import (
17 from .common import (
18 ErrorResponse,
18 ErrorResponse,
19 HTTP_SERVER_ERROR,
19 HTTP_SERVER_ERROR,
20 cspvalues,
20 cspvalues,
21 get_contact,
21 get_contact,
22 get_mtime,
22 get_mtime,
23 ismember,
23 ismember,
24 paritygen,
24 paritygen,
25 staticfile,
25 staticfile,
26 statusmessage,
26 statusmessage,
27 )
27 )
28
28
29 from .. import (
29 from .. import (
30 configitems,
30 configitems,
31 encoding,
31 encoding,
32 error,
32 error,
33 hg,
33 hg,
34 profiling,
34 profiling,
35 pycompat,
35 pycompat,
36 scmutil,
36 scmutil,
37 templater,
37 templater,
38 templateutil,
38 templateutil,
39 ui as uimod,
39 ui as uimod,
40 util,
40 util,
41 )
41 )
42
42
43 from . import (
43 from . import (
44 hgweb_mod,
44 hgweb_mod,
45 request as requestmod,
45 request as requestmod,
46 webutil,
46 webutil,
47 wsgicgi,
47 wsgicgi,
48 )
48 )
49 from ..utils import dateutil
49 from ..utils import dateutil
50
50
51 def cleannames(items):
51 def cleannames(items):
52 return [(util.pconvert(name).strip('/'), path) for name, path in items]
52 return [(util.pconvert(name).strip('/'), path) for name, path in items]
53
53
54 def findrepos(paths):
54 def findrepos(paths):
55 repos = []
55 repos = []
56 for prefix, root in cleannames(paths):
56 for prefix, root in cleannames(paths):
57 roothead, roottail = os.path.split(root)
57 roothead, roottail = os.path.split(root)
58 # "foo = /bar/*" or "foo = /bar/**" lets every repo /bar/N in or below
58 # "foo = /bar/*" or "foo = /bar/**" lets every repo /bar/N in or below
59 # /bar/ be served as as foo/N .
59 # /bar/ be served as as foo/N .
60 # '*' will not search inside dirs with .hg (except .hg/patches),
60 # '*' will not search inside dirs with .hg (except .hg/patches),
61 # '**' will search inside dirs with .hg (and thus also find subrepos).
61 # '**' will search inside dirs with .hg (and thus also find subrepos).
62 try:
62 try:
63 recurse = {'*': False, '**': True}[roottail]
63 recurse = {'*': False, '**': True}[roottail]
64 except KeyError:
64 except KeyError:
65 repos.append((prefix, root))
65 repos.append((prefix, root))
66 continue
66 continue
67 roothead = os.path.normpath(os.path.abspath(roothead))
67 roothead = os.path.normpath(os.path.abspath(roothead))
68 paths = scmutil.walkrepos(roothead, followsym=True, recurse=recurse)
68 paths = scmutil.walkrepos(roothead, followsym=True, recurse=recurse)
69 repos.extend(urlrepos(prefix, roothead, paths))
69 repos.extend(urlrepos(prefix, roothead, paths))
70 return repos
70 return repos
71
71
72 def urlrepos(prefix, roothead, paths):
72 def urlrepos(prefix, roothead, paths):
73 """yield url paths and filesystem paths from a list of repo paths
73 """yield url paths and filesystem paths from a list of repo paths
74
74
75 >>> conv = lambda seq: [(v, util.pconvert(p)) for v,p in seq]
75 >>> conv = lambda seq: [(v, util.pconvert(p)) for v,p in seq]
76 >>> conv(urlrepos(b'hg', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
76 >>> conv(urlrepos(b'hg', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
77 [('hg/r', '/opt/r'), ('hg/r/r', '/opt/r/r'), ('hg', '/opt')]
77 [('hg/r', '/opt/r'), ('hg/r/r', '/opt/r/r'), ('hg', '/opt')]
78 >>> conv(urlrepos(b'', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
78 >>> conv(urlrepos(b'', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
79 [('r', '/opt/r'), ('r/r', '/opt/r/r'), ('', '/opt')]
79 [('r', '/opt/r'), ('r/r', '/opt/r/r'), ('', '/opt')]
80 """
80 """
81 for path in paths:
81 for path in paths:
82 path = os.path.normpath(path)
82 path = os.path.normpath(path)
83 yield (prefix + '/' +
83 yield (prefix + '/' +
84 util.pconvert(path[len(roothead):]).lstrip('/')).strip('/'), path
84 util.pconvert(path[len(roothead):]).lstrip('/')).strip('/'), path
85
85
86 def readallowed(ui, req):
86 def readallowed(ui, req):
87 """Check allow_read and deny_read config options of a repo's ui object
87 """Check allow_read and deny_read config options of a repo's ui object
88 to determine user permissions. By default, with neither option set (or
88 to determine user permissions. By default, with neither option set (or
89 both empty), allow all users to read the repo. There are two ways a
89 both empty), allow all users to read the repo. There are two ways a
90 user can be denied read access: (1) deny_read is not empty, and the
90 user can be denied read access: (1) deny_read is not empty, and the
91 user is unauthenticated or deny_read contains user (or *), and (2)
91 user is unauthenticated or deny_read contains user (or *), and (2)
92 allow_read is not empty and the user is not in allow_read. Return True
92 allow_read is not empty and the user is not in allow_read. Return True
93 if user is allowed to read the repo, else return False."""
93 if user is allowed to read the repo, else return False."""
94
94
95 user = req.remoteuser
95 user = req.remoteuser
96
96
97 deny_read = ui.configlist('web', 'deny_read', untrusted=True)
97 deny_read = ui.configlist('web', 'deny_read', untrusted=True)
98 if deny_read and (not user or ismember(ui, user, deny_read)):
98 if deny_read and (not user or ismember(ui, user, deny_read)):
99 return False
99 return False
100
100
101 allow_read = ui.configlist('web', 'allow_read', untrusted=True)
101 allow_read = ui.configlist('web', 'allow_read', untrusted=True)
102 # by default, allow reading if no allow_read option has been set
102 # by default, allow reading if no allow_read option has been set
103 if not allow_read or ismember(ui, user, allow_read):
103 if not allow_read or ismember(ui, user, allow_read):
104 return True
104 return True
105
105
106 return False
106 return False
107
107
108 def rawindexentries(ui, repos, req, subdir=''):
108 def rawindexentries(ui, repos, req, subdir=''):
109 descend = ui.configbool('web', 'descend')
109 descend = ui.configbool('web', 'descend')
110 collapse = ui.configbool('web', 'collapse')
110 collapse = ui.configbool('web', 'collapse')
111 seenrepos = set()
111 seenrepos = set()
112 seendirs = set()
112 seendirs = set()
113 for name, path in repos:
113 for name, path in repos:
114
114
115 if not name.startswith(subdir):
115 if not name.startswith(subdir):
116 continue
116 continue
117 name = name[len(subdir):]
117 name = name[len(subdir):]
118 directory = False
118 directory = False
119
119
120 if '/' in name:
120 if '/' in name:
121 if not descend:
121 if not descend:
122 continue
122 continue
123
123
124 nameparts = name.split('/')
124 nameparts = name.split('/')
125 rootname = nameparts[0]
125 rootname = nameparts[0]
126
126
127 if not collapse:
127 if not collapse:
128 pass
128 pass
129 elif rootname in seendirs:
129 elif rootname in seendirs:
130 continue
130 continue
131 elif rootname in seenrepos:
131 elif rootname in seenrepos:
132 pass
132 pass
133 else:
133 else:
134 directory = True
134 directory = True
135 name = rootname
135 name = rootname
136
136
137 # redefine the path to refer to the directory
137 # redefine the path to refer to the directory
138 discarded = '/'.join(nameparts[1:])
138 discarded = '/'.join(nameparts[1:])
139
139
140 # remove name parts plus accompanying slash
140 # remove name parts plus accompanying slash
141 path = path[:-len(discarded) - 1]
141 path = path[:-len(discarded) - 1]
142
142
143 try:
143 try:
144 r = hg.repository(ui, path)
144 r = hg.repository(ui, path)
145 directory = False
145 directory = False
146 except (IOError, error.RepoError):
146 except (IOError, error.RepoError):
147 pass
147 pass
148
148
149 parts = [
149 parts = [
150 req.apppath.strip('/'),
150 req.apppath.strip('/'),
151 subdir.strip('/'),
151 subdir.strip('/'),
152 name.strip('/'),
152 name.strip('/'),
153 ]
153 ]
154 url = '/' + '/'.join(p for p in parts if p) + '/'
154 url = '/' + '/'.join(p for p in parts if p) + '/'
155
155
156 # show either a directory entry or a repository
156 # show either a directory entry or a repository
157 if directory:
157 if directory:
158 # get the directory's time information
158 # get the directory's time information
159 try:
159 try:
160 d = (get_mtime(path), dateutil.makedate()[1])
160 d = (get_mtime(path), dateutil.makedate()[1])
161 except OSError:
161 except OSError:
162 continue
162 continue
163
163
164 # add '/' to the name to make it obvious that
164 # add '/' to the name to make it obvious that
165 # the entry is a directory, not a regular repository
165 # the entry is a directory, not a regular repository
166 row = {'contact': "",
166 row = {'contact': "",
167 'contact_sort': "",
167 'contact_sort': "",
168 'name': name + '/',
168 'name': name + '/',
169 'name_sort': name,
169 'name_sort': name,
170 'url': url,
170 'url': url,
171 'description': "",
171 'description': "",
172 'description_sort': "",
172 'description_sort': "",
173 'lastchange': d,
173 'lastchange': d,
174 'lastchange_sort': d[1] - d[0],
174 'lastchange_sort': d[1] - d[0],
175 'archives': templateutil.mappinglist([]),
175 'archives': templateutil.mappinglist([]),
176 'isdirectory': True,
176 'isdirectory': True,
177 'labels': templateutil.hybridlist([], name='label'),
177 'labels': templateutil.hybridlist([], name='label'),
178 }
178 }
179
179
180 seendirs.add(name)
180 seendirs.add(name)
181 yield row
181 yield row
182 continue
182 continue
183
183
184 u = ui.copy()
184 u = ui.copy()
185 try:
185 try:
186 u.readconfig(os.path.join(path, '.hg', 'hgrc'))
186 u.readconfig(os.path.join(path, '.hg', 'hgrc'))
187 except Exception as e:
187 except Exception as e:
188 u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
188 u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
189 continue
189 continue
190
190
191 def get(section, name, default=uimod._unset):
191 def get(section, name, default=uimod._unset):
192 return u.config(section, name, default, untrusted=True)
192 return u.config(section, name, default, untrusted=True)
193
193
194 if u.configbool("web", "hidden", untrusted=True):
194 if u.configbool("web", "hidden", untrusted=True):
195 continue
195 continue
196
196
197 if not readallowed(u, req):
197 if not readallowed(u, req):
198 continue
198 continue
199
199
200 # update time with local timezone
200 # update time with local timezone
201 try:
201 try:
202 r = hg.repository(ui, path)
202 r = hg.repository(ui, path)
203 except IOError:
203 except IOError:
204 u.warn(_('error accessing repository at %s\n') % path)
204 u.warn(_('error accessing repository at %s\n') % path)
205 continue
205 continue
206 except error.RepoError:
206 except error.RepoError:
207 u.warn(_('error accessing repository at %s\n') % path)
207 u.warn(_('error accessing repository at %s\n') % path)
208 continue
208 continue
209 try:
209 try:
210 d = (get_mtime(r.spath), dateutil.makedate()[1])
210 d = (get_mtime(r.spath), dateutil.makedate()[1])
211 except OSError:
211 except OSError:
212 continue
212 continue
213
213
214 contact = get_contact(get)
214 contact = get_contact(get)
215 description = get("web", "description")
215 description = get("web", "description")
216 seenrepos.add(name)
216 seenrepos.add(name)
217 name = get("web", "name", name)
217 name = get("web", "name", name)
218 labels = u.configlist('web', 'labels', untrusted=True)
218 labels = u.configlist('web', 'labels', untrusted=True)
219 row = {'contact': contact or "unknown",
219 row = {'contact': contact or "unknown",
220 'contact_sort': contact.upper() or "unknown",
220 'contact_sort': contact.upper() or "unknown",
221 'name': name,
221 'name': name,
222 'name_sort': name,
222 'name_sort': name,
223 'url': url,
223 'url': url,
224 'description': description or "unknown",
224 'description': description or "unknown",
225 'description_sort': description.upper() or "unknown",
225 'description_sort': description.upper() or "unknown",
226 'lastchange': d,
226 'lastchange': d,
227 'lastchange_sort': d[1] - d[0],
227 'lastchange_sort': d[1] - d[0],
228 'archives': webutil.archivelist(u, "tip", url),
228 'archives': webutil.archivelist(u, "tip", url),
229 'isdirectory': None,
229 'isdirectory': None,
230 'labels': templateutil.hybridlist(labels, name='label'),
230 'labels': templateutil.hybridlist(labels, name='label'),
231 }
231 }
232
232
233 yield row
233 yield row
234
234
235 def _indexentriesgen(context, ui, repos, req, stripecount, sortcolumn,
235 def _indexentriesgen(context, ui, repos, req, stripecount, sortcolumn,
236 descending, subdir):
236 descending, subdir):
237 rows = rawindexentries(ui, repos, req, subdir=subdir)
237 rows = rawindexentries(ui, repos, req, subdir=subdir)
238
238
239 sortdefault = None, False
239 sortdefault = None, False
240
240
241 if sortcolumn and sortdefault != (sortcolumn, descending):
241 if sortcolumn and sortdefault != (sortcolumn, descending):
242 sortkey = '%s_sort' % sortcolumn
242 sortkey = '%s_sort' % sortcolumn
243 rows = sorted(rows, key=lambda x: x[sortkey],
243 rows = sorted(rows, key=lambda x: x[sortkey],
244 reverse=descending)
244 reverse=descending)
245
245
246 for row, parity in zip(rows, paritygen(stripecount)):
246 for row, parity in zip(rows, paritygen(stripecount)):
247 row['parity'] = parity
247 row['parity'] = parity
248 yield row
248 yield row
249
249
250 def indexentries(ui, repos, req, stripecount, sortcolumn='',
250 def indexentries(ui, repos, req, stripecount, sortcolumn='',
251 descending=False, subdir=''):
251 descending=False, subdir=''):
252 args = (ui, repos, req, stripecount, sortcolumn, descending, subdir)
252 args = (ui, repos, req, stripecount, sortcolumn, descending, subdir)
253 return templateutil.mappinggenerator(_indexentriesgen, args=args)
253 return templateutil.mappinggenerator(_indexentriesgen, args=args)
254
254
255 class hgwebdir(object):
255 class hgwebdir(object):
256 """HTTP server for multiple repositories.
256 """HTTP server for multiple repositories.
257
257
258 Given a configuration, different repositories will be served depending
258 Given a configuration, different repositories will be served depending
259 on the request path.
259 on the request path.
260
260
261 Instances are typically used as WSGI applications.
261 Instances are typically used as WSGI applications.
262 """
262 """
263 def __init__(self, conf, baseui=None):
263 def __init__(self, conf, baseui=None):
264 self.conf = conf
264 self.conf = conf
265 self.baseui = baseui
265 self.baseui = baseui
266 self.ui = None
266 self.ui = None
267 self.lastrefresh = 0
267 self.lastrefresh = 0
268 self.motd = None
268 self.motd = None
269 self.refresh()
269 self.refresh()
270
270
271 def refresh(self):
271 def refresh(self):
272 if self.ui:
272 if self.ui:
273 refreshinterval = self.ui.configint('web', 'refreshinterval')
273 refreshinterval = self.ui.configint('web', 'refreshinterval')
274 else:
274 else:
275 item = configitems.coreitems['web']['refreshinterval']
275 item = configitems.coreitems['web']['refreshinterval']
276 refreshinterval = item.default
276 refreshinterval = item.default
277
277
278 # refreshinterval <= 0 means to always refresh.
278 # refreshinterval <= 0 means to always refresh.
279 if (refreshinterval > 0 and
279 if (refreshinterval > 0 and
280 self.lastrefresh + refreshinterval > time.time()):
280 self.lastrefresh + refreshinterval > time.time()):
281 return
281 return
282
282
283 if self.baseui:
283 if self.baseui:
284 u = self.baseui.copy()
284 u = self.baseui.copy()
285 else:
285 else:
286 u = uimod.ui.load()
286 u = uimod.ui.load()
287 u.setconfig('ui', 'report_untrusted', 'off', 'hgwebdir')
287 u.setconfig('ui', 'report_untrusted', 'off', 'hgwebdir')
288 u.setconfig('ui', 'nontty', 'true', 'hgwebdir')
288 u.setconfig('ui', 'nontty', 'true', 'hgwebdir')
289 # displaying bundling progress bar while serving feels wrong and may
289 # displaying bundling progress bar while serving feels wrong and may
290 # break some wsgi implementations.
290 # break some wsgi implementations.
291 u.setconfig('progress', 'disable', 'true', 'hgweb')
291 u.setconfig('progress', 'disable', 'true', 'hgweb')
292
292
293 if not isinstance(self.conf, (dict, list, tuple)):
293 if not isinstance(self.conf, (dict, list, tuple)):
294 map = {'paths': 'hgweb-paths'}
294 map = {'paths': 'hgweb-paths'}
295 if not os.path.exists(self.conf):
295 if not os.path.exists(self.conf):
296 raise error.Abort(_('config file %s not found!') % self.conf)
296 raise error.Abort(_('config file %s not found!') % self.conf)
297 u.readconfig(self.conf, remap=map, trust=True)
297 u.readconfig(self.conf, remap=map, trust=True)
298 paths = []
298 paths = []
299 for name, ignored in u.configitems('hgweb-paths'):
299 for name, ignored in u.configitems('hgweb-paths'):
300 for path in u.configlist('hgweb-paths', name):
300 for path in u.configlist('hgweb-paths', name):
301 paths.append((name, path))
301 paths.append((name, path))
302 elif isinstance(self.conf, (list, tuple)):
302 elif isinstance(self.conf, (list, tuple)):
303 paths = self.conf
303 paths = self.conf
304 elif isinstance(self.conf, dict):
304 elif isinstance(self.conf, dict):
305 paths = self.conf.items()
305 paths = self.conf.items()
306
306
307 repos = findrepos(paths)
307 repos = findrepos(paths)
308 for prefix, root in u.configitems('collections'):
308 for prefix, root in u.configitems('collections'):
309 prefix = util.pconvert(prefix)
309 prefix = util.pconvert(prefix)
310 for path in scmutil.walkrepos(root, followsym=True):
310 for path in scmutil.walkrepos(root, followsym=True):
311 repo = os.path.normpath(path)
311 repo = os.path.normpath(path)
312 name = util.pconvert(repo)
312 name = util.pconvert(repo)
313 if name.startswith(prefix):
313 if name.startswith(prefix):
314 name = name[len(prefix):]
314 name = name[len(prefix):]
315 repos.append((name.lstrip('/'), repo))
315 repos.append((name.lstrip('/'), repo))
316
316
317 self.repos = repos
317 self.repos = repos
318 self.ui = u
318 self.ui = u
319 encoding.encoding = self.ui.config('web', 'encoding')
319 encoding.encoding = self.ui.config('web', 'encoding')
320 self.style = self.ui.config('web', 'style')
320 self.style = self.ui.config('web', 'style')
321 self.templatepath = self.ui.config('web', 'templates', untrusted=False)
321 self.templatepath = self.ui.config('web', 'templates', untrusted=False)
322 self.stripecount = self.ui.config('web', 'stripes')
322 self.stripecount = self.ui.config('web', 'stripes')
323 if self.stripecount:
323 if self.stripecount:
324 self.stripecount = int(self.stripecount)
324 self.stripecount = int(self.stripecount)
325 prefix = self.ui.config('web', 'prefix')
325 prefix = self.ui.config('web', 'prefix')
326 if prefix.startswith('/'):
326 if prefix.startswith('/'):
327 prefix = prefix[1:]
327 prefix = prefix[1:]
328 if prefix.endswith('/'):
328 if prefix.endswith('/'):
329 prefix = prefix[:-1]
329 prefix = prefix[:-1]
330 self.prefix = prefix
330 self.prefix = prefix
331 self.lastrefresh = time.time()
331 self.lastrefresh = time.time()
332
332
333 def run(self):
333 def run(self):
334 if not encoding.environ.get('GATEWAY_INTERFACE',
334 if not encoding.environ.get('GATEWAY_INTERFACE',
335 '').startswith("CGI/1."):
335 '').startswith("CGI/1."):
336 raise RuntimeError("This function is only intended to be "
336 raise RuntimeError("This function is only intended to be "
337 "called while running as a CGI script.")
337 "called while running as a CGI script.")
338 wsgicgi.launch(self)
338 wsgicgi.launch(self)
339
339
340 def __call__(self, env, respond):
340 def __call__(self, env, respond):
341 baseurl = self.ui.config('web', 'baseurl')
341 baseurl = self.ui.config('web', 'baseurl')
342 req = requestmod.parserequestfromenv(env, altbaseurl=baseurl)
342 req = requestmod.parserequestfromenv(env, altbaseurl=baseurl)
343 res = requestmod.wsgiresponse(req, respond)
343 res = requestmod.wsgiresponse(req, respond)
344
344
345 return self.run_wsgi(req, res)
345 return self.run_wsgi(req, res)
346
346
347 def run_wsgi(self, req, res):
347 def run_wsgi(self, req, res):
348 profile = self.ui.configbool('profiling', 'enabled')
348 profile = self.ui.configbool('profiling', 'enabled')
349 with profiling.profile(self.ui, enabled=profile):
349 with profiling.profile(self.ui, enabled=profile):
350 try:
350 try:
351 for r in self._runwsgi(req, res):
351 for r in self._runwsgi(req, res):
352 yield r
352 yield r
353 finally:
353 finally:
354 # There are known cycles in localrepository that prevent
354 # There are known cycles in localrepository that prevent
355 # those objects (and tons of held references) from being
355 # those objects (and tons of held references) from being
356 # collected through normal refcounting. We mitigate those
356 # collected through normal refcounting. We mitigate those
357 # leaks by performing an explicit GC on every request.
357 # leaks by performing an explicit GC on every request.
358 # TODO remove this once leaks are fixed.
358 # TODO remove this once leaks are fixed.
359 # TODO only run this on requests that create localrepository
359 # TODO only run this on requests that create localrepository
360 # instances instead of every request.
360 # instances instead of every request.
361 gc.collect()
361 gc.collect()
362
362
363 def _runwsgi(self, req, res):
363 def _runwsgi(self, req, res):
364 try:
364 try:
365 self.refresh()
365 self.refresh()
366
366
367 csp, nonce = cspvalues(self.ui)
367 csp, nonce = cspvalues(self.ui)
368 if csp:
368 if csp:
369 res.headers['Content-Security-Policy'] = csp
369 res.headers['Content-Security-Policy'] = csp
370
370
371 virtual = req.dispatchpath.strip('/')
371 virtual = req.dispatchpath.strip('/')
372 tmpl = self.templater(req, nonce)
372 tmpl = self.templater(req, nonce)
373 ctype = tmpl.render('mimetype', {'encoding': encoding.encoding})
373 ctype = tmpl.render('mimetype', {'encoding': encoding.encoding})
374
374
375 # Global defaults. These can be overridden by any handler.
375 # Global defaults. These can be overridden by any handler.
376 res.status = '200 Script output follows'
376 res.status = '200 Script output follows'
377 res.headers['Content-Type'] = ctype
377 res.headers['Content-Type'] = ctype
378
378
379 # a static file
379 # a static file
380 if virtual.startswith('static/') or 'static' in req.qsparams:
380 if virtual.startswith('static/') or 'static' in req.qsparams:
381 if virtual.startswith('static/'):
381 if virtual.startswith('static/'):
382 fname = virtual[7:]
382 fname = virtual[7:]
383 else:
383 else:
384 fname = req.qsparams['static']
384 fname = req.qsparams['static']
385 static = self.ui.config("web", "static", None,
385 static = self.ui.config("web", "static", None,
386 untrusted=False)
386 untrusted=False)
387 if not static:
387 if not static:
388 tp = self.templatepath or templater.templatepaths()
388 tp = self.templatepath or templater.templatepaths()
389 if isinstance(tp, str):
389 if isinstance(tp, str):
390 tp = [tp]
390 tp = [tp]
391 static = [os.path.join(p, 'static') for p in tp]
391 static = [os.path.join(p, 'static') for p in tp]
392
392
393 staticfile(static, fname, res)
393 staticfile(static, fname, res)
394 return res.sendresponse()
394 return res.sendresponse()
395
395
396 # top-level index
396 # top-level index
397
397
398 repos = dict(self.repos)
398 repos = dict(self.repos)
399
399
400 if (not virtual or virtual == 'index') and virtual not in repos:
400 if (not virtual or virtual == 'index') and virtual not in repos:
401 return self.makeindex(req, res, tmpl)
401 return self.makeindex(req, res, tmpl)
402
402
403 # nested indexes and hgwebs
403 # nested indexes and hgwebs
404
404
405 if virtual.endswith('/index') and virtual not in repos:
405 if virtual.endswith('/index') and virtual not in repos:
406 subdir = virtual[:-len('index')]
406 subdir = virtual[:-len('index')]
407 if any(r.startswith(subdir) for r in repos):
407 if any(r.startswith(subdir) for r in repos):
408 return self.makeindex(req, res, tmpl, subdir)
408 return self.makeindex(req, res, tmpl, subdir)
409
409
410 def _virtualdirs():
410 def _virtualdirs():
411 # Check the full virtual path, each parent, and the root ('')
411 # Check the full virtual path, each parent, and the root ('')
412 if virtual != '':
412 if virtual != '':
413 yield virtual
413 yield virtual
414
414
415 for p in util.finddirs(virtual):
415 for p in util.finddirs(virtual):
416 yield p
416 yield p
417
417
418 yield ''
418 yield ''
419
419
420 for virtualrepo in _virtualdirs():
420 for virtualrepo in _virtualdirs():
421 real = repos.get(virtualrepo)
421 real = repos.get(virtualrepo)
422 if real:
422 if real:
423 # Re-parse the WSGI environment to take into account our
423 # Re-parse the WSGI environment to take into account our
424 # repository path component.
424 # repository path component.
425 uenv = req.rawenv
425 uenv = req.rawenv
426 if pycompat.ispy3:
426 if pycompat.ispy3:
427 uenv = {k.decode('latin1'): v for k, v in
427 uenv = {k.decode('latin1'): v for k, v in
428 uenv.iteritems()}
428 uenv.iteritems()}
429 req = requestmod.parserequestfromenv(
429 req = requestmod.parserequestfromenv(
430 uenv, reponame=virtualrepo,
430 uenv, reponame=virtualrepo,
431 altbaseurl=self.ui.config('web', 'baseurl'),
431 altbaseurl=self.ui.config('web', 'baseurl'),
432 # Reuse wrapped body file object otherwise state
432 # Reuse wrapped body file object otherwise state
433 # tracking can get confused.
433 # tracking can get confused.
434 bodyfh=req.bodyfh)
434 bodyfh=req.bodyfh)
435 try:
435 try:
436 # ensure caller gets private copy of ui
436 # ensure caller gets private copy of ui
437 repo = hg.repository(self.ui.copy(), real)
437 repo = hg.repository(self.ui.copy(), real)
438 return hgweb_mod.hgweb(repo).run_wsgi(req, res)
438 return hgweb_mod.hgweb(repo).run_wsgi(req, res)
439 except IOError as inst:
439 except IOError as inst:
440 msg = encoding.strtolocal(inst.strerror)
440 msg = encoding.strtolocal(inst.strerror)
441 raise ErrorResponse(HTTP_SERVER_ERROR, msg)
441 raise ErrorResponse(HTTP_SERVER_ERROR, msg)
442 except error.RepoError as inst:
442 except error.RepoError as inst:
443 raise ErrorResponse(HTTP_SERVER_ERROR, bytes(inst))
443 raise ErrorResponse(HTTP_SERVER_ERROR, bytes(inst))
444
444
445 # browse subdirectories
445 # browse subdirectories
446 subdir = virtual + '/'
446 subdir = virtual + '/'
447 if [r for r in repos if r.startswith(subdir)]:
447 if [r for r in repos if r.startswith(subdir)]:
448 return self.makeindex(req, res, tmpl, subdir)
448 return self.makeindex(req, res, tmpl, subdir)
449
449
450 # prefixes not found
450 # prefixes not found
451 res.status = '404 Not Found'
451 res.status = '404 Not Found'
452 res.setbodygen(tmpl.generate('notfound', {'repo': virtual}))
452 res.setbodygen(tmpl.generate('notfound', {'repo': virtual}))
453 return res.sendresponse()
453 return res.sendresponse()
454
454
455 except ErrorResponse as e:
455 except ErrorResponse as e:
456 res.status = statusmessage(e.code, pycompat.bytestr(e))
456 res.status = statusmessage(e.code, pycompat.bytestr(e))
457 res.setbodygen(tmpl.generate('error', {'error': e.message or ''}))
457 res.setbodygen(tmpl.generate('error', {'error': e.message or ''}))
458 return res.sendresponse()
458 return res.sendresponse()
459 finally:
459 finally:
460 tmpl = None
460 tmpl = None
461
461
462 def makeindex(self, req, res, tmpl, subdir=""):
462 def makeindex(self, req, res, tmpl, subdir=""):
463 self.refresh()
463 self.refresh()
464 sortable = ["name", "description", "contact", "lastchange"]
464 sortable = ["name", "description", "contact", "lastchange"]
465 sortcolumn, descending = None, False
465 sortcolumn, descending = None, False
466 if 'sort' in req.qsparams:
466 if 'sort' in req.qsparams:
467 sortcolumn = req.qsparams['sort']
467 sortcolumn = req.qsparams['sort']
468 descending = sortcolumn.startswith('-')
468 descending = sortcolumn.startswith('-')
469 if descending:
469 if descending:
470 sortcolumn = sortcolumn[1:]
470 sortcolumn = sortcolumn[1:]
471 if sortcolumn not in sortable:
471 if sortcolumn not in sortable:
472 sortcolumn = ""
472 sortcolumn = ""
473
473
474 sort = [("sort_%s" % column,
474 sort = [("sort_%s" % column,
475 "%s%s" % ((not descending and column == sortcolumn)
475 "%s%s" % ((not descending and column == sortcolumn)
476 and "-" or "", column))
476 and "-" or "", column))
477 for column in sortable]
477 for column in sortable]
478
478
479 self.refresh()
479 self.refresh()
480
480
481 entries = indexentries(self.ui, self.repos, req,
481 entries = indexentries(self.ui, self.repos, req,
482 self.stripecount, sortcolumn=sortcolumn,
482 self.stripecount, sortcolumn=sortcolumn,
483 descending=descending, subdir=subdir)
483 descending=descending, subdir=subdir)
484
484
485 mapping = {
485 mapping = {
486 'entries': entries,
486 'entries': entries,
487 'subdir': subdir,
487 'subdir': subdir,
488 'pathdef': hgweb_mod.makebreadcrumb('/' + subdir, self.prefix),
488 'pathdef': hgweb_mod.makebreadcrumb('/' + subdir, self.prefix),
489 'sortcolumn': sortcolumn,
489 'sortcolumn': sortcolumn,
490 'descending': descending,
490 'descending': descending,
491 }
491 }
492 mapping.update(sort)
492 mapping.update(sort)
493 res.setbodygen(tmpl.generate('index', mapping))
493 res.setbodygen(tmpl.generate('index', mapping))
494 return res.sendresponse()
494 return res.sendresponse()
495
495
496 def templater(self, req, nonce):
496 def templater(self, req, nonce):
497
497
498 def motd(**map):
498 def motd(**map):
499 if self.motd is not None:
499 if self.motd is not None:
500 yield self.motd
500 yield self.motd
501 else:
501 else:
502 yield config('web', 'motd')
502 yield config('web', 'motd')
503
503
504 def config(section, name, default=uimod._unset, untrusted=True):
504 def config(section, name, default=uimod._unset, untrusted=True):
505 return self.ui.config(section, name, default, untrusted)
505 return self.ui.config(section, name, default, untrusted)
506
506
507 vars = {}
507 vars = {}
508 styles, (style, mapfile) = hgweb_mod.getstyle(req, config,
508 styles, (style, mapfile) = hgweb_mod.getstyle(req, config,
509 self.templatepath)
509 self.templatepath)
510 if style == styles[0]:
510 if style == styles[0]:
511 vars['style'] = style
511 vars['style'] = style
512
512
513 sessionvars = webutil.sessionvars(vars, r'?')
513 sessionvars = webutil.sessionvars(vars, r'?')
514 logourl = config('web', 'logourl')
514 logourl = config('web', 'logourl')
515 logoimg = config('web', 'logoimg')
515 logoimg = config('web', 'logoimg')
516 staticurl = (config('web', 'staticurl')
516 staticurl = (config('web', 'staticurl')
517 or req.apppath + '/static/')
517 or req.apppath.rstrip('/') + '/static/')
518 if not staticurl.endswith('/'):
518 if not staticurl.endswith('/'):
519 staticurl += '/'
519 staticurl += '/'
520
520
521 defaults = {
521 defaults = {
522 "encoding": encoding.encoding,
522 "encoding": encoding.encoding,
523 "motd": motd,
523 "motd": motd,
524 "url": req.apppath + '/',
524 "url": req.apppath + '/',
525 "logourl": logourl,
525 "logourl": logourl,
526 "logoimg": logoimg,
526 "logoimg": logoimg,
527 "staticurl": staticurl,
527 "staticurl": staticurl,
528 "sessionvars": sessionvars,
528 "sessionvars": sessionvars,
529 "style": style,
529 "style": style,
530 "nonce": nonce,
530 "nonce": nonce,
531 }
531 }
532 tmpl = templater.templater.frommapfile(mapfile, defaults=defaults)
532 tmpl = templater.templater.frommapfile(mapfile, defaults=defaults)
533 return tmpl
533 return tmpl
General Comments 0
You need to be logged in to leave comments. Login now