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