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