##// END OF EJS Templates
hgweb: make parsedrequest part of wsgirequest...
Gregory Szorc -
r36872:1f7d9024 default
parent child Browse files
Show More
@@ -1,438 +1,438 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 HTTP_NOT_FOUND,
17 HTTP_NOT_FOUND,
18 HTTP_NOT_MODIFIED,
18 HTTP_NOT_MODIFIED,
19 HTTP_OK,
19 HTTP_OK,
20 HTTP_SERVER_ERROR,
20 HTTP_SERVER_ERROR,
21 caching,
21 caching,
22 cspvalues,
22 cspvalues,
23 permhooks,
23 permhooks,
24 )
24 )
25
25
26 from .. import (
26 from .. import (
27 encoding,
27 encoding,
28 error,
28 error,
29 formatter,
29 formatter,
30 hg,
30 hg,
31 hook,
31 hook,
32 profiling,
32 profiling,
33 pycompat,
33 pycompat,
34 repoview,
34 repoview,
35 templatefilters,
35 templatefilters,
36 templater,
36 templater,
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 archivespecs = util.sortdict((
49 archivespecs = util.sortdict((
50 ('zip', ('application/zip', 'zip', '.zip', None)),
50 ('zip', ('application/zip', 'zip', '.zip', None)),
51 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
51 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
52 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
52 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
53 ))
53 ))
54
54
55 def getstyle(req, configfn, templatepath):
55 def getstyle(req, configfn, templatepath):
56 fromreq = req.form.get('style', [None])[0]
56 fromreq = req.form.get('style', [None])[0]
57 styles = (
57 styles = (
58 fromreq,
58 fromreq,
59 configfn('web', 'style'),
59 configfn('web', 'style'),
60 'paper',
60 'paper',
61 )
61 )
62 return styles, templater.stylemap(styles, templatepath)
62 return styles, templater.stylemap(styles, templatepath)
63
63
64 def makebreadcrumb(url, prefix=''):
64 def makebreadcrumb(url, prefix=''):
65 '''Return a 'URL breadcrumb' list
65 '''Return a 'URL breadcrumb' list
66
66
67 A 'URL breadcrumb' is a list of URL-name pairs,
67 A 'URL breadcrumb' is a list of URL-name pairs,
68 corresponding to each of the path items on a URL.
68 corresponding to each of the path items on a URL.
69 This can be used to create path navigation entries.
69 This can be used to create path navigation entries.
70 '''
70 '''
71 if url.endswith('/'):
71 if url.endswith('/'):
72 url = url[:-1]
72 url = url[:-1]
73 if prefix:
73 if prefix:
74 url = '/' + prefix + url
74 url = '/' + prefix + url
75 relpath = url
75 relpath = url
76 if relpath.startswith('/'):
76 if relpath.startswith('/'):
77 relpath = relpath[1:]
77 relpath = relpath[1:]
78
78
79 breadcrumb = []
79 breadcrumb = []
80 urlel = url
80 urlel = url
81 pathitems = [''] + relpath.split('/')
81 pathitems = [''] + relpath.split('/')
82 for pathel in reversed(pathitems):
82 for pathel in reversed(pathitems):
83 if not pathel or not urlel:
83 if not pathel or not urlel:
84 break
84 break
85 breadcrumb.append({'url': urlel, 'name': pathel})
85 breadcrumb.append({'url': urlel, 'name': pathel})
86 urlel = os.path.dirname(urlel)
86 urlel = os.path.dirname(urlel)
87 return reversed(breadcrumb)
87 return reversed(breadcrumb)
88
88
89 class requestcontext(object):
89 class requestcontext(object):
90 """Holds state/context for an individual request.
90 """Holds state/context for an individual request.
91
91
92 Servers can be multi-threaded. Holding state on the WSGI application
92 Servers can be multi-threaded. Holding state on the WSGI application
93 is prone to race conditions. Instances of this class exist to hold
93 is prone to race conditions. Instances of this class exist to hold
94 mutable and race-free state for requests.
94 mutable and race-free state for requests.
95 """
95 """
96 def __init__(self, app, repo):
96 def __init__(self, app, repo):
97 self.repo = repo
97 self.repo = repo
98 self.reponame = app.reponame
98 self.reponame = app.reponame
99
99
100 self.archivespecs = archivespecs
100 self.archivespecs = archivespecs
101
101
102 self.maxchanges = self.configint('web', 'maxchanges')
102 self.maxchanges = self.configint('web', 'maxchanges')
103 self.stripecount = self.configint('web', 'stripes')
103 self.stripecount = self.configint('web', 'stripes')
104 self.maxshortchanges = self.configint('web', 'maxshortchanges')
104 self.maxshortchanges = self.configint('web', 'maxshortchanges')
105 self.maxfiles = self.configint('web', 'maxfiles')
105 self.maxfiles = self.configint('web', 'maxfiles')
106 self.allowpull = self.configbool('web', 'allow-pull')
106 self.allowpull = self.configbool('web', 'allow-pull')
107
107
108 # we use untrusted=False to prevent a repo owner from using
108 # we use untrusted=False to prevent a repo owner from using
109 # web.templates in .hg/hgrc to get access to any file readable
109 # web.templates in .hg/hgrc to get access to any file readable
110 # by the user running the CGI script
110 # by the user running the CGI script
111 self.templatepath = self.config('web', 'templates', untrusted=False)
111 self.templatepath = self.config('web', 'templates', untrusted=False)
112
112
113 # This object is more expensive to build than simple config values.
113 # This object is more expensive to build than simple config values.
114 # It is shared across requests. The app will replace the object
114 # It is shared across requests. The app will replace the object
115 # if it is updated. Since this is a reference and nothing should
115 # if it is updated. Since this is a reference and nothing should
116 # modify the underlying object, it should be constant for the lifetime
116 # modify the underlying object, it should be constant for the lifetime
117 # of the request.
117 # of the request.
118 self.websubtable = app.websubtable
118 self.websubtable = app.websubtable
119
119
120 self.csp, self.nonce = cspvalues(self.repo.ui)
120 self.csp, self.nonce = cspvalues(self.repo.ui)
121
121
122 # Trust the settings from the .hg/hgrc files by default.
122 # Trust the settings from the .hg/hgrc files by default.
123 def config(self, section, name, default=uimod._unset, untrusted=True):
123 def config(self, section, name, default=uimod._unset, untrusted=True):
124 return self.repo.ui.config(section, name, default,
124 return self.repo.ui.config(section, name, default,
125 untrusted=untrusted)
125 untrusted=untrusted)
126
126
127 def configbool(self, section, name, default=uimod._unset, untrusted=True):
127 def configbool(self, section, name, default=uimod._unset, untrusted=True):
128 return self.repo.ui.configbool(section, name, default,
128 return self.repo.ui.configbool(section, name, default,
129 untrusted=untrusted)
129 untrusted=untrusted)
130
130
131 def configint(self, section, name, default=uimod._unset, untrusted=True):
131 def configint(self, section, name, default=uimod._unset, untrusted=True):
132 return self.repo.ui.configint(section, name, default,
132 return self.repo.ui.configint(section, name, default,
133 untrusted=untrusted)
133 untrusted=untrusted)
134
134
135 def configlist(self, section, name, default=uimod._unset, untrusted=True):
135 def configlist(self, section, name, default=uimod._unset, untrusted=True):
136 return self.repo.ui.configlist(section, name, default,
136 return self.repo.ui.configlist(section, name, default,
137 untrusted=untrusted)
137 untrusted=untrusted)
138
138
139 def archivelist(self, nodeid):
139 def archivelist(self, nodeid):
140 allowed = self.configlist('web', 'allow_archive')
140 allowed = self.configlist('web', 'allow_archive')
141 for typ, spec in self.archivespecs.iteritems():
141 for typ, spec in self.archivespecs.iteritems():
142 if typ in allowed or self.configbool('web', 'allow%s' % typ):
142 if typ in allowed or self.configbool('web', 'allow%s' % typ):
143 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
143 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
144
144
145 def templater(self, wsgireq, req):
145 def templater(self, wsgireq, req):
146 # determine scheme, port and server name
146 # determine scheme, port and server name
147 # this is needed to create absolute urls
147 # this is needed to create absolute urls
148 logourl = self.config('web', 'logourl')
148 logourl = self.config('web', 'logourl')
149 logoimg = self.config('web', 'logoimg')
149 logoimg = self.config('web', 'logoimg')
150 staticurl = (self.config('web', 'staticurl')
150 staticurl = (self.config('web', 'staticurl')
151 or req.apppath + '/static/')
151 or req.apppath + '/static/')
152 if not staticurl.endswith('/'):
152 if not staticurl.endswith('/'):
153 staticurl += '/'
153 staticurl += '/'
154
154
155 # some functions for the templater
155 # some functions for the templater
156
156
157 def motd(**map):
157 def motd(**map):
158 yield self.config('web', 'motd')
158 yield self.config('web', 'motd')
159
159
160 # figure out which style to use
160 # figure out which style to use
161
161
162 vars = {}
162 vars = {}
163 styles, (style, mapfile) = getstyle(wsgireq, self.config,
163 styles, (style, mapfile) = getstyle(wsgireq, self.config,
164 self.templatepath)
164 self.templatepath)
165 if style == styles[0]:
165 if style == styles[0]:
166 vars['style'] = style
166 vars['style'] = style
167
167
168 sessionvars = webutil.sessionvars(vars, '?')
168 sessionvars = webutil.sessionvars(vars, '?')
169
169
170 if not self.reponame:
170 if not self.reponame:
171 self.reponame = (self.config('web', 'name', '')
171 self.reponame = (self.config('web', 'name', '')
172 or wsgireq.env.get('REPO_NAME')
172 or wsgireq.env.get('REPO_NAME')
173 or req.apppath or self.repo.root)
173 or req.apppath or self.repo.root)
174
174
175 def websubfilter(text):
175 def websubfilter(text):
176 return templatefilters.websub(text, self.websubtable)
176 return templatefilters.websub(text, self.websubtable)
177
177
178 # create the templater
178 # create the templater
179 # TODO: export all keywords: defaults = templatekw.keywords.copy()
179 # TODO: export all keywords: defaults = templatekw.keywords.copy()
180 defaults = {
180 defaults = {
181 'url': req.apppath + '/',
181 'url': req.apppath + '/',
182 'logourl': logourl,
182 'logourl': logourl,
183 'logoimg': logoimg,
183 'logoimg': logoimg,
184 'staticurl': staticurl,
184 'staticurl': staticurl,
185 'urlbase': req.advertisedbaseurl,
185 'urlbase': req.advertisedbaseurl,
186 'repo': self.reponame,
186 'repo': self.reponame,
187 'encoding': encoding.encoding,
187 'encoding': encoding.encoding,
188 'motd': motd,
188 'motd': motd,
189 'sessionvars': sessionvars,
189 'sessionvars': sessionvars,
190 'pathdef': makebreadcrumb(req.apppath),
190 'pathdef': makebreadcrumb(req.apppath),
191 'style': style,
191 'style': style,
192 'nonce': self.nonce,
192 'nonce': self.nonce,
193 }
193 }
194 tres = formatter.templateresources(self.repo.ui, self.repo)
194 tres = formatter.templateresources(self.repo.ui, self.repo)
195 tmpl = templater.templater.frommapfile(mapfile,
195 tmpl = templater.templater.frommapfile(mapfile,
196 filters={'websub': websubfilter},
196 filters={'websub': websubfilter},
197 defaults=defaults,
197 defaults=defaults,
198 resources=tres)
198 resources=tres)
199 return tmpl
199 return tmpl
200
200
201
201
202 class hgweb(object):
202 class hgweb(object):
203 """HTTP server for individual repositories.
203 """HTTP server for individual repositories.
204
204
205 Instances of this class serve HTTP responses for a particular
205 Instances of this class serve HTTP responses for a particular
206 repository.
206 repository.
207
207
208 Instances are typically used as WSGI applications.
208 Instances are typically used as WSGI applications.
209
209
210 Some servers are multi-threaded. On these servers, there may
210 Some servers are multi-threaded. On these servers, there may
211 be multiple active threads inside __call__.
211 be multiple active threads inside __call__.
212 """
212 """
213 def __init__(self, repo, name=None, baseui=None):
213 def __init__(self, repo, name=None, baseui=None):
214 if isinstance(repo, str):
214 if isinstance(repo, str):
215 if baseui:
215 if baseui:
216 u = baseui.copy()
216 u = baseui.copy()
217 else:
217 else:
218 u = uimod.ui.load()
218 u = uimod.ui.load()
219 r = hg.repository(u, repo)
219 r = hg.repository(u, repo)
220 else:
220 else:
221 # we trust caller to give us a private copy
221 # we trust caller to give us a private copy
222 r = repo
222 r = repo
223
223
224 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
224 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
225 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
225 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
226 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
226 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
227 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
227 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
228 # resolve file patterns relative to repo root
228 # resolve file patterns relative to repo root
229 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
229 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
230 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
230 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
231 # displaying bundling progress bar while serving feel wrong and may
231 # displaying bundling progress bar while serving feel wrong and may
232 # break some wsgi implementation.
232 # break some wsgi implementation.
233 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
233 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
234 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
234 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
235 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
235 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
236 self._lastrepo = self._repos[0]
236 self._lastrepo = self._repos[0]
237 hook.redirect(True)
237 hook.redirect(True)
238 self.reponame = name
238 self.reponame = name
239
239
240 def _webifyrepo(self, repo):
240 def _webifyrepo(self, repo):
241 repo = getwebview(repo)
241 repo = getwebview(repo)
242 self.websubtable = webutil.getwebsubs(repo)
242 self.websubtable = webutil.getwebsubs(repo)
243 return repo
243 return repo
244
244
245 @contextlib.contextmanager
245 @contextlib.contextmanager
246 def _obtainrepo(self):
246 def _obtainrepo(self):
247 """Obtain a repo unique to the caller.
247 """Obtain a repo unique to the caller.
248
248
249 Internally we maintain a stack of cachedlocalrepo instances
249 Internally we maintain a stack of cachedlocalrepo instances
250 to be handed out. If one is available, we pop it and return it,
250 to be handed out. If one is available, we pop it and return it,
251 ensuring it is up to date in the process. If one is not available,
251 ensuring it is up to date in the process. If one is not available,
252 we clone the most recently used repo instance and return it.
252 we clone the most recently used repo instance and return it.
253
253
254 It is currently possible for the stack to grow without bounds
254 It is currently possible for the stack to grow without bounds
255 if the server allows infinite threads. However, servers should
255 if the server allows infinite threads. However, servers should
256 have a thread limit, thus establishing our limit.
256 have a thread limit, thus establishing our limit.
257 """
257 """
258 if self._repos:
258 if self._repos:
259 cached = self._repos.pop()
259 cached = self._repos.pop()
260 r, created = cached.fetch()
260 r, created = cached.fetch()
261 else:
261 else:
262 cached = self._lastrepo.copy()
262 cached = self._lastrepo.copy()
263 r, created = cached.fetch()
263 r, created = cached.fetch()
264 if created:
264 if created:
265 r = self._webifyrepo(r)
265 r = self._webifyrepo(r)
266
266
267 self._lastrepo = cached
267 self._lastrepo = cached
268 self.mtime = cached.mtime
268 self.mtime = cached.mtime
269 try:
269 try:
270 yield r
270 yield r
271 finally:
271 finally:
272 self._repos.append(cached)
272 self._repos.append(cached)
273
273
274 def run(self):
274 def run(self):
275 """Start a server from CGI environment.
275 """Start a server from CGI environment.
276
276
277 Modern servers should be using WSGI and should avoid this
277 Modern servers should be using WSGI and should avoid this
278 method, if possible.
278 method, if possible.
279 """
279 """
280 if not encoding.environ.get('GATEWAY_INTERFACE',
280 if not encoding.environ.get('GATEWAY_INTERFACE',
281 '').startswith("CGI/1."):
281 '').startswith("CGI/1."):
282 raise RuntimeError("This function is only intended to be "
282 raise RuntimeError("This function is only intended to be "
283 "called while running as a CGI script.")
283 "called while running as a CGI script.")
284 wsgicgi.launch(self)
284 wsgicgi.launch(self)
285
285
286 def __call__(self, env, respond):
286 def __call__(self, env, respond):
287 """Run the WSGI application.
287 """Run the WSGI application.
288
288
289 This may be called by multiple threads.
289 This may be called by multiple threads.
290 """
290 """
291 req = requestmod.wsgirequest(env, respond)
291 req = requestmod.wsgirequest(env, respond)
292 return self.run_wsgi(req)
292 return self.run_wsgi(req)
293
293
294 def run_wsgi(self, wsgireq):
294 def run_wsgi(self, wsgireq):
295 """Internal method to run the WSGI application.
295 """Internal method to run the WSGI application.
296
296
297 This is typically only called by Mercurial. External consumers
297 This is typically only called by Mercurial. External consumers
298 should be using instances of this class as the WSGI application.
298 should be using instances of this class as the WSGI application.
299 """
299 """
300 with self._obtainrepo() as repo:
300 with self._obtainrepo() as repo:
301 profile = repo.ui.configbool('profiling', 'enabled')
301 profile = repo.ui.configbool('profiling', 'enabled')
302 with profiling.profile(repo.ui, enabled=profile):
302 with profiling.profile(repo.ui, enabled=profile):
303 for r in self._runwsgi(wsgireq, repo):
303 for r in self._runwsgi(wsgireq, repo):
304 yield r
304 yield r
305
305
306 def _runwsgi(self, wsgireq, repo):
306 def _runwsgi(self, wsgireq, repo):
307 req = requestmod.parserequestfromenv(wsgireq.env)
307 req = wsgireq.req
308 rctx = requestcontext(self, repo)
308 rctx = requestcontext(self, repo)
309
309
310 # This state is global across all threads.
310 # This state is global across all threads.
311 encoding.encoding = rctx.config('web', 'encoding')
311 encoding.encoding = rctx.config('web', 'encoding')
312 rctx.repo.ui.environ = wsgireq.env
312 rctx.repo.ui.environ = wsgireq.env
313
313
314 if rctx.csp:
314 if rctx.csp:
315 # hgwebdir may have added CSP header. Since we generate our own,
315 # hgwebdir may have added CSP header. Since we generate our own,
316 # replace it.
316 # replace it.
317 wsgireq.headers = [h for h in wsgireq.headers
317 wsgireq.headers = [h for h in wsgireq.headers
318 if h[0] != 'Content-Security-Policy']
318 if h[0] != 'Content-Security-Policy']
319 wsgireq.headers.append(('Content-Security-Policy', rctx.csp))
319 wsgireq.headers.append(('Content-Security-Policy', rctx.csp))
320
320
321 handled, res = wireprotoserver.handlewsgirequest(
321 handled, res = wireprotoserver.handlewsgirequest(
322 rctx, wsgireq, req, self.check_perm)
322 rctx, wsgireq, req, self.check_perm)
323 if handled:
323 if handled:
324 return res
324 return res
325
325
326 if req.havepathinfo:
326 if req.havepathinfo:
327 query = req.dispatchpath
327 query = req.dispatchpath
328 else:
328 else:
329 query = req.querystring.partition('&')[0].partition(';')[0]
329 query = req.querystring.partition('&')[0].partition(';')[0]
330
330
331 # translate user-visible url structure to internal structure
331 # translate user-visible url structure to internal structure
332
332
333 args = query.split('/', 2)
333 args = query.split('/', 2)
334 if 'cmd' not in wsgireq.form and args and args[0]:
334 if 'cmd' not in wsgireq.form and args and args[0]:
335 cmd = args.pop(0)
335 cmd = args.pop(0)
336 style = cmd.rfind('-')
336 style = cmd.rfind('-')
337 if style != -1:
337 if style != -1:
338 wsgireq.form['style'] = [cmd[:style]]
338 wsgireq.form['style'] = [cmd[:style]]
339 cmd = cmd[style + 1:]
339 cmd = cmd[style + 1:]
340
340
341 # avoid accepting e.g. style parameter as command
341 # avoid accepting e.g. style parameter as command
342 if util.safehasattr(webcommands, cmd):
342 if util.safehasattr(webcommands, cmd):
343 wsgireq.form['cmd'] = [cmd]
343 wsgireq.form['cmd'] = [cmd]
344
344
345 if cmd == 'static':
345 if cmd == 'static':
346 wsgireq.form['file'] = ['/'.join(args)]
346 wsgireq.form['file'] = ['/'.join(args)]
347 else:
347 else:
348 if args and args[0]:
348 if args and args[0]:
349 node = args.pop(0).replace('%2F', '/')
349 node = args.pop(0).replace('%2F', '/')
350 wsgireq.form['node'] = [node]
350 wsgireq.form['node'] = [node]
351 if args:
351 if args:
352 wsgireq.form['file'] = args
352 wsgireq.form['file'] = args
353
353
354 ua = req.headers.get('User-Agent', '')
354 ua = req.headers.get('User-Agent', '')
355 if cmd == 'rev' and 'mercurial' in ua:
355 if cmd == 'rev' and 'mercurial' in ua:
356 wsgireq.form['style'] = ['raw']
356 wsgireq.form['style'] = ['raw']
357
357
358 if cmd == 'archive':
358 if cmd == 'archive':
359 fn = wsgireq.form['node'][0]
359 fn = wsgireq.form['node'][0]
360 for type_, spec in rctx.archivespecs.iteritems():
360 for type_, spec in rctx.archivespecs.iteritems():
361 ext = spec[2]
361 ext = spec[2]
362 if fn.endswith(ext):
362 if fn.endswith(ext):
363 wsgireq.form['node'] = [fn[:-len(ext)]]
363 wsgireq.form['node'] = [fn[:-len(ext)]]
364 wsgireq.form['type'] = [type_]
364 wsgireq.form['type'] = [type_]
365 else:
365 else:
366 cmd = wsgireq.form.get('cmd', [''])[0]
366 cmd = wsgireq.form.get('cmd', [''])[0]
367
367
368 # process the web interface request
368 # process the web interface request
369
369
370 try:
370 try:
371 tmpl = rctx.templater(wsgireq, req)
371 tmpl = rctx.templater(wsgireq, req)
372 ctype = tmpl('mimetype', encoding=encoding.encoding)
372 ctype = tmpl('mimetype', encoding=encoding.encoding)
373 ctype = templater.stringify(ctype)
373 ctype = templater.stringify(ctype)
374
374
375 # check read permissions non-static content
375 # check read permissions non-static content
376 if cmd != 'static':
376 if cmd != 'static':
377 self.check_perm(rctx, wsgireq, None)
377 self.check_perm(rctx, wsgireq, None)
378
378
379 if cmd == '':
379 if cmd == '':
380 wsgireq.form['cmd'] = [tmpl.cache['default']]
380 wsgireq.form['cmd'] = [tmpl.cache['default']]
381 cmd = wsgireq.form['cmd'][0]
381 cmd = wsgireq.form['cmd'][0]
382
382
383 # Don't enable caching if using a CSP nonce because then it wouldn't
383 # Don't enable caching if using a CSP nonce because then it wouldn't
384 # be a nonce.
384 # be a nonce.
385 if rctx.configbool('web', 'cache') and not rctx.nonce:
385 if rctx.configbool('web', 'cache') and not rctx.nonce:
386 caching(self, wsgireq) # sets ETag header or raises NOT_MODIFIED
386 caching(self, wsgireq) # sets ETag header or raises NOT_MODIFIED
387 if cmd not in webcommands.__all__:
387 if cmd not in webcommands.__all__:
388 msg = 'no such method: %s' % cmd
388 msg = 'no such method: %s' % cmd
389 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
389 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
390 elif cmd == 'file' and 'raw' in wsgireq.form.get('style', []):
390 elif cmd == 'file' and 'raw' in wsgireq.form.get('style', []):
391 rctx.ctype = ctype
391 rctx.ctype = ctype
392 content = webcommands.rawfile(rctx, wsgireq, tmpl)
392 content = webcommands.rawfile(rctx, wsgireq, tmpl)
393 else:
393 else:
394 content = getattr(webcommands, cmd)(rctx, wsgireq, tmpl)
394 content = getattr(webcommands, cmd)(rctx, wsgireq, tmpl)
395 wsgireq.respond(HTTP_OK, ctype)
395 wsgireq.respond(HTTP_OK, ctype)
396
396
397 return content
397 return content
398
398
399 except (error.LookupError, error.RepoLookupError) as err:
399 except (error.LookupError, error.RepoLookupError) as err:
400 wsgireq.respond(HTTP_NOT_FOUND, ctype)
400 wsgireq.respond(HTTP_NOT_FOUND, ctype)
401 msg = pycompat.bytestr(err)
401 msg = pycompat.bytestr(err)
402 if (util.safehasattr(err, 'name') and
402 if (util.safehasattr(err, 'name') and
403 not isinstance(err, error.ManifestLookupError)):
403 not isinstance(err, error.ManifestLookupError)):
404 msg = 'revision not found: %s' % err.name
404 msg = 'revision not found: %s' % err.name
405 return tmpl('error', error=msg)
405 return tmpl('error', error=msg)
406 except (error.RepoError, error.RevlogError) as inst:
406 except (error.RepoError, error.RevlogError) as inst:
407 wsgireq.respond(HTTP_SERVER_ERROR, ctype)
407 wsgireq.respond(HTTP_SERVER_ERROR, ctype)
408 return tmpl('error', error=pycompat.bytestr(inst))
408 return tmpl('error', error=pycompat.bytestr(inst))
409 except ErrorResponse as inst:
409 except ErrorResponse as inst:
410 wsgireq.respond(inst, ctype)
410 wsgireq.respond(inst, ctype)
411 if inst.code == HTTP_NOT_MODIFIED:
411 if inst.code == HTTP_NOT_MODIFIED:
412 # Not allowed to return a body on a 304
412 # Not allowed to return a body on a 304
413 return ['']
413 return ['']
414 return tmpl('error', error=pycompat.bytestr(inst))
414 return tmpl('error', error=pycompat.bytestr(inst))
415
415
416 def check_perm(self, rctx, req, op):
416 def check_perm(self, rctx, req, op):
417 for permhook in permhooks:
417 for permhook in permhooks:
418 permhook(rctx, req, op)
418 permhook(rctx, req, op)
419
419
420 def getwebview(repo):
420 def getwebview(repo):
421 """The 'web.view' config controls changeset filter to hgweb. Possible
421 """The 'web.view' config controls changeset filter to hgweb. Possible
422 values are ``served``, ``visible`` and ``all``. Default is ``served``.
422 values are ``served``, ``visible`` and ``all``. Default is ``served``.
423 The ``served`` filter only shows changesets that can be pulled from the
423 The ``served`` filter only shows changesets that can be pulled from the
424 hgweb instance. The``visible`` filter includes secret changesets but
424 hgweb instance. The``visible`` filter includes secret changesets but
425 still excludes "hidden" one.
425 still excludes "hidden" one.
426
426
427 See the repoview module for details.
427 See the repoview module for details.
428
428
429 The option has been around undocumented since Mercurial 2.5, but no
429 The option has been around undocumented since Mercurial 2.5, but no
430 user ever asked about it. So we better keep it undocumented for now."""
430 user ever asked about it. So we better keep it undocumented for now."""
431 # experimental config: web.view
431 # experimental config: web.view
432 viewconfig = repo.ui.config('web', 'view', untrusted=True)
432 viewconfig = repo.ui.config('web', 'view', untrusted=True)
433 if viewconfig == 'all':
433 if viewconfig == 'all':
434 return repo.unfiltered()
434 return repo.unfiltered()
435 elif viewconfig in repoview.filtertable:
435 elif viewconfig in repoview.filtertable:
436 return repo.filtered(viewconfig)
436 return repo.filtered(viewconfig)
437 else:
437 else:
438 return repo.filtered('served')
438 return repo.filtered('served')
@@ -1,538 +1,543 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 os
11 import os
12 import re
12 import re
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_NOT_FOUND,
19 HTTP_NOT_FOUND,
20 HTTP_OK,
20 HTTP_OK,
21 HTTP_SERVER_ERROR,
21 HTTP_SERVER_ERROR,
22 cspvalues,
22 cspvalues,
23 get_contact,
23 get_contact,
24 get_mtime,
24 get_mtime,
25 ismember,
25 ismember,
26 paritygen,
26 paritygen,
27 staticfile,
27 staticfile,
28 )
28 )
29
29
30 from .. import (
30 from .. import (
31 configitems,
31 configitems,
32 encoding,
32 encoding,
33 error,
33 error,
34 hg,
34 hg,
35 profiling,
35 profiling,
36 pycompat,
36 pycompat,
37 scmutil,
37 scmutil,
38 templater,
38 templater,
39 ui as uimod,
39 ui as uimod,
40 util,
40 util,
41 )
41 )
42
42
43 from . import (
43 from . import (
44 hgweb_mod,
44 hgweb_mod,
45 request as requestmod,
45 request as requestmod,
46 webutil,
46 webutil,
47 wsgicgi,
47 wsgicgi,
48 )
48 )
49 from ..utils import dateutil
49 from ..utils import dateutil
50
50
51 def cleannames(items):
51 def cleannames(items):
52 return [(util.pconvert(name).strip('/'), path) for name, path in items]
52 return [(util.pconvert(name).strip('/'), path) for name, path in items]
53
53
54 def findrepos(paths):
54 def findrepos(paths):
55 repos = []
55 repos = []
56 for prefix, root in cleannames(paths):
56 for prefix, root in cleannames(paths):
57 roothead, roottail = os.path.split(root)
57 roothead, roottail = os.path.split(root)
58 # "foo = /bar/*" or "foo = /bar/**" lets every repo /bar/N in or below
58 # "foo = /bar/*" or "foo = /bar/**" lets every repo /bar/N in or below
59 # /bar/ be served as as foo/N .
59 # /bar/ be served as as foo/N .
60 # '*' will not search inside dirs with .hg (except .hg/patches),
60 # '*' will not search inside dirs with .hg (except .hg/patches),
61 # '**' will search inside dirs with .hg (and thus also find subrepos).
61 # '**' will search inside dirs with .hg (and thus also find subrepos).
62 try:
62 try:
63 recurse = {'*': False, '**': True}[roottail]
63 recurse = {'*': False, '**': True}[roottail]
64 except KeyError:
64 except KeyError:
65 repos.append((prefix, root))
65 repos.append((prefix, root))
66 continue
66 continue
67 roothead = os.path.normpath(os.path.abspath(roothead))
67 roothead = os.path.normpath(os.path.abspath(roothead))
68 paths = scmutil.walkrepos(roothead, followsym=True, recurse=recurse)
68 paths = scmutil.walkrepos(roothead, followsym=True, recurse=recurse)
69 repos.extend(urlrepos(prefix, roothead, paths))
69 repos.extend(urlrepos(prefix, roothead, paths))
70 return repos
70 return repos
71
71
72 def urlrepos(prefix, roothead, paths):
72 def urlrepos(prefix, roothead, paths):
73 """yield url paths and filesystem paths from a list of repo paths
73 """yield url paths and filesystem paths from a list of repo paths
74
74
75 >>> conv = lambda seq: [(v, util.pconvert(p)) for v,p in seq]
75 >>> conv = lambda seq: [(v, util.pconvert(p)) for v,p in seq]
76 >>> conv(urlrepos(b'hg', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
76 >>> conv(urlrepos(b'hg', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
77 [('hg/r', '/opt/r'), ('hg/r/r', '/opt/r/r'), ('hg', '/opt')]
77 [('hg/r', '/opt/r'), ('hg/r/r', '/opt/r/r'), ('hg', '/opt')]
78 >>> conv(urlrepos(b'', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
78 >>> conv(urlrepos(b'', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
79 [('r', '/opt/r'), ('r/r', '/opt/r/r'), ('', '/opt')]
79 [('r', '/opt/r'), ('r/r', '/opt/r/r'), ('', '/opt')]
80 """
80 """
81 for path in paths:
81 for path in paths:
82 path = os.path.normpath(path)
82 path = os.path.normpath(path)
83 yield (prefix + '/' +
83 yield (prefix + '/' +
84 util.pconvert(path[len(roothead):]).lstrip('/')).strip('/'), path
84 util.pconvert(path[len(roothead):]).lstrip('/')).strip('/'), path
85
85
86 def geturlcgivars(baseurl, port):
86 def geturlcgivars(baseurl, port):
87 """
87 """
88 Extract CGI variables from baseurl
88 Extract CGI variables from baseurl
89
89
90 >>> geturlcgivars(b"http://host.org/base", b"80")
90 >>> geturlcgivars(b"http://host.org/base", b"80")
91 ('host.org', '80', '/base')
91 ('host.org', '80', '/base')
92 >>> geturlcgivars(b"http://host.org:8000/base", b"80")
92 >>> geturlcgivars(b"http://host.org:8000/base", b"80")
93 ('host.org', '8000', '/base')
93 ('host.org', '8000', '/base')
94 >>> geturlcgivars(b'/base', 8000)
94 >>> geturlcgivars(b'/base', 8000)
95 ('', '8000', '/base')
95 ('', '8000', '/base')
96 >>> geturlcgivars(b"base", b'8000')
96 >>> geturlcgivars(b"base", b'8000')
97 ('', '8000', '/base')
97 ('', '8000', '/base')
98 >>> geturlcgivars(b"http://host", b'8000')
98 >>> geturlcgivars(b"http://host", b'8000')
99 ('host', '8000', '/')
99 ('host', '8000', '/')
100 >>> geturlcgivars(b"http://host/", b'8000')
100 >>> geturlcgivars(b"http://host/", b'8000')
101 ('host', '8000', '/')
101 ('host', '8000', '/')
102 """
102 """
103 u = util.url(baseurl)
103 u = util.url(baseurl)
104 name = u.host or ''
104 name = u.host or ''
105 if u.port:
105 if u.port:
106 port = u.port
106 port = u.port
107 path = u.path or ""
107 path = u.path or ""
108 if not path.startswith('/'):
108 if not path.startswith('/'):
109 path = '/' + path
109 path = '/' + path
110
110
111 return name, pycompat.bytestr(port), path
111 return name, pycompat.bytestr(port), path
112
112
113 class hgwebdir(object):
113 class hgwebdir(object):
114 """HTTP server for multiple repositories.
114 """HTTP server for multiple repositories.
115
115
116 Given a configuration, different repositories will be served depending
116 Given a configuration, different repositories will be served depending
117 on the request path.
117 on the request path.
118
118
119 Instances are typically used as WSGI applications.
119 Instances are typically used as WSGI applications.
120 """
120 """
121 def __init__(self, conf, baseui=None):
121 def __init__(self, conf, baseui=None):
122 self.conf = conf
122 self.conf = conf
123 self.baseui = baseui
123 self.baseui = baseui
124 self.ui = None
124 self.ui = None
125 self.lastrefresh = 0
125 self.lastrefresh = 0
126 self.motd = None
126 self.motd = None
127 self.refresh()
127 self.refresh()
128
128
129 def refresh(self):
129 def refresh(self):
130 if self.ui:
130 if self.ui:
131 refreshinterval = self.ui.configint('web', 'refreshinterval')
131 refreshinterval = self.ui.configint('web', 'refreshinterval')
132 else:
132 else:
133 item = configitems.coreitems['web']['refreshinterval']
133 item = configitems.coreitems['web']['refreshinterval']
134 refreshinterval = item.default
134 refreshinterval = item.default
135
135
136 # refreshinterval <= 0 means to always refresh.
136 # refreshinterval <= 0 means to always refresh.
137 if (refreshinterval > 0 and
137 if (refreshinterval > 0 and
138 self.lastrefresh + refreshinterval > time.time()):
138 self.lastrefresh + refreshinterval > time.time()):
139 return
139 return
140
140
141 if self.baseui:
141 if self.baseui:
142 u = self.baseui.copy()
142 u = self.baseui.copy()
143 else:
143 else:
144 u = uimod.ui.load()
144 u = uimod.ui.load()
145 u.setconfig('ui', 'report_untrusted', 'off', 'hgwebdir')
145 u.setconfig('ui', 'report_untrusted', 'off', 'hgwebdir')
146 u.setconfig('ui', 'nontty', 'true', 'hgwebdir')
146 u.setconfig('ui', 'nontty', 'true', 'hgwebdir')
147 # displaying bundling progress bar while serving feels wrong and may
147 # displaying bundling progress bar while serving feels wrong and may
148 # break some wsgi implementations.
148 # break some wsgi implementations.
149 u.setconfig('progress', 'disable', 'true', 'hgweb')
149 u.setconfig('progress', 'disable', 'true', 'hgweb')
150
150
151 if not isinstance(self.conf, (dict, list, tuple)):
151 if not isinstance(self.conf, (dict, list, tuple)):
152 map = {'paths': 'hgweb-paths'}
152 map = {'paths': 'hgweb-paths'}
153 if not os.path.exists(self.conf):
153 if not os.path.exists(self.conf):
154 raise error.Abort(_('config file %s not found!') % self.conf)
154 raise error.Abort(_('config file %s not found!') % self.conf)
155 u.readconfig(self.conf, remap=map, trust=True)
155 u.readconfig(self.conf, remap=map, trust=True)
156 paths = []
156 paths = []
157 for name, ignored in u.configitems('hgweb-paths'):
157 for name, ignored in u.configitems('hgweb-paths'):
158 for path in u.configlist('hgweb-paths', name):
158 for path in u.configlist('hgweb-paths', name):
159 paths.append((name, path))
159 paths.append((name, path))
160 elif isinstance(self.conf, (list, tuple)):
160 elif isinstance(self.conf, (list, tuple)):
161 paths = self.conf
161 paths = self.conf
162 elif isinstance(self.conf, dict):
162 elif isinstance(self.conf, dict):
163 paths = self.conf.items()
163 paths = self.conf.items()
164
164
165 repos = findrepos(paths)
165 repos = findrepos(paths)
166 for prefix, root in u.configitems('collections'):
166 for prefix, root in u.configitems('collections'):
167 prefix = util.pconvert(prefix)
167 prefix = util.pconvert(prefix)
168 for path in scmutil.walkrepos(root, followsym=True):
168 for path in scmutil.walkrepos(root, followsym=True):
169 repo = os.path.normpath(path)
169 repo = os.path.normpath(path)
170 name = util.pconvert(repo)
170 name = util.pconvert(repo)
171 if name.startswith(prefix):
171 if name.startswith(prefix):
172 name = name[len(prefix):]
172 name = name[len(prefix):]
173 repos.append((name.lstrip('/'), repo))
173 repos.append((name.lstrip('/'), repo))
174
174
175 self.repos = repos
175 self.repos = repos
176 self.ui = u
176 self.ui = u
177 encoding.encoding = self.ui.config('web', 'encoding')
177 encoding.encoding = self.ui.config('web', 'encoding')
178 self.style = self.ui.config('web', 'style')
178 self.style = self.ui.config('web', 'style')
179 self.templatepath = self.ui.config('web', 'templates', untrusted=False)
179 self.templatepath = self.ui.config('web', 'templates', untrusted=False)
180 self.stripecount = self.ui.config('web', 'stripes')
180 self.stripecount = self.ui.config('web', 'stripes')
181 if self.stripecount:
181 if self.stripecount:
182 self.stripecount = int(self.stripecount)
182 self.stripecount = int(self.stripecount)
183 self._baseurl = self.ui.config('web', 'baseurl')
183 self._baseurl = self.ui.config('web', 'baseurl')
184 prefix = self.ui.config('web', 'prefix')
184 prefix = self.ui.config('web', 'prefix')
185 if prefix.startswith('/'):
185 if prefix.startswith('/'):
186 prefix = prefix[1:]
186 prefix = prefix[1:]
187 if prefix.endswith('/'):
187 if prefix.endswith('/'):
188 prefix = prefix[:-1]
188 prefix = prefix[:-1]
189 self.prefix = prefix
189 self.prefix = prefix
190 self.lastrefresh = time.time()
190 self.lastrefresh = time.time()
191
191
192 def run(self):
192 def run(self):
193 if not encoding.environ.get('GATEWAY_INTERFACE',
193 if not encoding.environ.get('GATEWAY_INTERFACE',
194 '').startswith("CGI/1."):
194 '').startswith("CGI/1."):
195 raise RuntimeError("This function is only intended to be "
195 raise RuntimeError("This function is only intended to be "
196 "called while running as a CGI script.")
196 "called while running as a CGI script.")
197 wsgicgi.launch(self)
197 wsgicgi.launch(self)
198
198
199 def __call__(self, env, respond):
199 def __call__(self, env, respond):
200 wsgireq = requestmod.wsgirequest(env, respond)
200 wsgireq = requestmod.wsgirequest(env, respond)
201 return self.run_wsgi(wsgireq)
201 return self.run_wsgi(wsgireq)
202
202
203 def read_allowed(self, ui, wsgireq):
203 def read_allowed(self, ui, wsgireq):
204 """Check allow_read and deny_read config options of a repo's ui object
204 """Check allow_read and deny_read config options of a repo's ui object
205 to determine user permissions. By default, with neither option set (or
205 to determine user permissions. By default, with neither option set (or
206 both empty), allow all users to read the repo. There are two ways a
206 both empty), allow all users to read the repo. There are two ways a
207 user can be denied read access: (1) deny_read is not empty, and the
207 user can be denied read access: (1) deny_read is not empty, and the
208 user is unauthenticated or deny_read contains user (or *), and (2)
208 user is unauthenticated or deny_read contains user (or *), and (2)
209 allow_read is not empty and the user is not in allow_read. Return True
209 allow_read is not empty and the user is not in allow_read. Return True
210 if user is allowed to read the repo, else return False."""
210 if user is allowed to read the repo, else return False."""
211
211
212 user = wsgireq.env.get('REMOTE_USER')
212 user = wsgireq.env.get('REMOTE_USER')
213
213
214 deny_read = ui.configlist('web', 'deny_read', untrusted=True)
214 deny_read = ui.configlist('web', 'deny_read', untrusted=True)
215 if deny_read and (not user or ismember(ui, user, deny_read)):
215 if deny_read and (not user or ismember(ui, user, deny_read)):
216 return False
216 return False
217
217
218 allow_read = ui.configlist('web', 'allow_read', untrusted=True)
218 allow_read = ui.configlist('web', 'allow_read', untrusted=True)
219 # by default, allow reading if no allow_read option has been set
219 # by default, allow reading if no allow_read option has been set
220 if (not allow_read) or ismember(ui, user, allow_read):
220 if (not allow_read) or ismember(ui, user, allow_read):
221 return True
221 return True
222
222
223 return False
223 return False
224
224
225 def run_wsgi(self, wsgireq):
225 def run_wsgi(self, wsgireq):
226 profile = self.ui.configbool('profiling', 'enabled')
226 profile = self.ui.configbool('profiling', 'enabled')
227 with profiling.profile(self.ui, enabled=profile):
227 with profiling.profile(self.ui, enabled=profile):
228 for r in self._runwsgi(wsgireq):
228 for r in self._runwsgi(wsgireq):
229 yield r
229 yield r
230
230
231 def _runwsgi(self, wsgireq):
231 def _runwsgi(self, wsgireq):
232 try:
232 try:
233 self.refresh()
233 self.refresh()
234
234
235 csp, nonce = cspvalues(self.ui)
235 csp, nonce = cspvalues(self.ui)
236 if csp:
236 if csp:
237 wsgireq.headers.append(('Content-Security-Policy', csp))
237 wsgireq.headers.append(('Content-Security-Policy', csp))
238
238
239 virtual = wsgireq.env.get("PATH_INFO", "").strip('/')
239 virtual = wsgireq.env.get("PATH_INFO", "").strip('/')
240 tmpl = self.templater(wsgireq, nonce)
240 tmpl = self.templater(wsgireq, nonce)
241 ctype = tmpl('mimetype', encoding=encoding.encoding)
241 ctype = tmpl('mimetype', encoding=encoding.encoding)
242 ctype = templater.stringify(ctype)
242 ctype = templater.stringify(ctype)
243
243
244 # a static file
244 # a static file
245 if virtual.startswith('static/') or 'static' in wsgireq.form:
245 if virtual.startswith('static/') or 'static' in wsgireq.form:
246 if virtual.startswith('static/'):
246 if virtual.startswith('static/'):
247 fname = virtual[7:]
247 fname = virtual[7:]
248 else:
248 else:
249 fname = wsgireq.form['static'][0]
249 fname = wsgireq.form['static'][0]
250 static = self.ui.config("web", "static", None,
250 static = self.ui.config("web", "static", None,
251 untrusted=False)
251 untrusted=False)
252 if not static:
252 if not static:
253 tp = self.templatepath or templater.templatepaths()
253 tp = self.templatepath or templater.templatepaths()
254 if isinstance(tp, str):
254 if isinstance(tp, str):
255 tp = [tp]
255 tp = [tp]
256 static = [os.path.join(p, 'static') for p in tp]
256 static = [os.path.join(p, 'static') for p in tp]
257 staticfile(static, fname, wsgireq)
257 staticfile(static, fname, wsgireq)
258 return []
258 return []
259
259
260 # top-level index
260 # top-level index
261
261
262 repos = dict(self.repos)
262 repos = dict(self.repos)
263
263
264 if (not virtual or virtual == 'index') and virtual not in repos:
264 if (not virtual or virtual == 'index') and virtual not in repos:
265 wsgireq.respond(HTTP_OK, ctype)
265 wsgireq.respond(HTTP_OK, ctype)
266 return self.makeindex(wsgireq, tmpl)
266 return self.makeindex(wsgireq, tmpl)
267
267
268 # nested indexes and hgwebs
268 # nested indexes and hgwebs
269
269
270 if virtual.endswith('/index') and virtual not in repos:
270 if virtual.endswith('/index') and virtual not in repos:
271 subdir = virtual[:-len('index')]
271 subdir = virtual[:-len('index')]
272 if any(r.startswith(subdir) for r in repos):
272 if any(r.startswith(subdir) for r in repos):
273 wsgireq.respond(HTTP_OK, ctype)
273 wsgireq.respond(HTTP_OK, ctype)
274 return self.makeindex(wsgireq, tmpl, subdir)
274 return self.makeindex(wsgireq, tmpl, subdir)
275
275
276 def _virtualdirs():
276 def _virtualdirs():
277 # Check the full virtual path, each parent, and the root ('')
277 # Check the full virtual path, each parent, and the root ('')
278 if virtual != '':
278 if virtual != '':
279 yield virtual
279 yield virtual
280
280
281 for p in util.finddirs(virtual):
281 for p in util.finddirs(virtual):
282 yield p
282 yield p
283
283
284 yield ''
284 yield ''
285
285
286 for virtualrepo in _virtualdirs():
286 for virtualrepo in _virtualdirs():
287 real = repos.get(virtualrepo)
287 real = repos.get(virtualrepo)
288 if real:
288 if real:
289 wsgireq.env['REPO_NAME'] = virtualrepo
289 wsgireq.env['REPO_NAME'] = virtualrepo
290 # We have to re-parse because of updated environment
291 # variable.
292 # TODO this is kind of hacky and we should have a better
293 # way of doing this than with REPO_NAME side-effects.
294 wsgireq.req = requestmod.parserequestfromenv(wsgireq.env)
290 try:
295 try:
291 # ensure caller gets private copy of ui
296 # ensure caller gets private copy of ui
292 repo = hg.repository(self.ui.copy(), real)
297 repo = hg.repository(self.ui.copy(), real)
293 return hgweb_mod.hgweb(repo).run_wsgi(wsgireq)
298 return hgweb_mod.hgweb(repo).run_wsgi(wsgireq)
294 except IOError as inst:
299 except IOError as inst:
295 msg = encoding.strtolocal(inst.strerror)
300 msg = encoding.strtolocal(inst.strerror)
296 raise ErrorResponse(HTTP_SERVER_ERROR, msg)
301 raise ErrorResponse(HTTP_SERVER_ERROR, msg)
297 except error.RepoError as inst:
302 except error.RepoError as inst:
298 raise ErrorResponse(HTTP_SERVER_ERROR, bytes(inst))
303 raise ErrorResponse(HTTP_SERVER_ERROR, bytes(inst))
299
304
300 # browse subdirectories
305 # browse subdirectories
301 subdir = virtual + '/'
306 subdir = virtual + '/'
302 if [r for r in repos if r.startswith(subdir)]:
307 if [r for r in repos if r.startswith(subdir)]:
303 wsgireq.respond(HTTP_OK, ctype)
308 wsgireq.respond(HTTP_OK, ctype)
304 return self.makeindex(wsgireq, tmpl, subdir)
309 return self.makeindex(wsgireq, tmpl, subdir)
305
310
306 # prefixes not found
311 # prefixes not found
307 wsgireq.respond(HTTP_NOT_FOUND, ctype)
312 wsgireq.respond(HTTP_NOT_FOUND, ctype)
308 return tmpl("notfound", repo=virtual)
313 return tmpl("notfound", repo=virtual)
309
314
310 except ErrorResponse as err:
315 except ErrorResponse as err:
311 wsgireq.respond(err, ctype)
316 wsgireq.respond(err, ctype)
312 return tmpl('error', error=err.message or '')
317 return tmpl('error', error=err.message or '')
313 finally:
318 finally:
314 tmpl = None
319 tmpl = None
315
320
316 def makeindex(self, wsgireq, tmpl, subdir=""):
321 def makeindex(self, wsgireq, tmpl, subdir=""):
317
322
318 def archivelist(ui, nodeid, url):
323 def archivelist(ui, nodeid, url):
319 allowed = ui.configlist("web", "allow_archive", untrusted=True)
324 allowed = ui.configlist("web", "allow_archive", untrusted=True)
320 archives = []
325 archives = []
321 for typ, spec in hgweb_mod.archivespecs.iteritems():
326 for typ, spec in hgweb_mod.archivespecs.iteritems():
322 if typ in allowed or ui.configbool("web", "allow" + typ,
327 if typ in allowed or ui.configbool("web", "allow" + typ,
323 untrusted=True):
328 untrusted=True):
324 archives.append({"type": typ, "extension": spec[2],
329 archives.append({"type": typ, "extension": spec[2],
325 "node": nodeid, "url": url})
330 "node": nodeid, "url": url})
326 return archives
331 return archives
327
332
328 def rawentries(subdir="", **map):
333 def rawentries(subdir="", **map):
329
334
330 descend = self.ui.configbool('web', 'descend')
335 descend = self.ui.configbool('web', 'descend')
331 collapse = self.ui.configbool('web', 'collapse')
336 collapse = self.ui.configbool('web', 'collapse')
332 seenrepos = set()
337 seenrepos = set()
333 seendirs = set()
338 seendirs = set()
334 for name, path in self.repos:
339 for name, path in self.repos:
335
340
336 if not name.startswith(subdir):
341 if not name.startswith(subdir):
337 continue
342 continue
338 name = name[len(subdir):]
343 name = name[len(subdir):]
339 directory = False
344 directory = False
340
345
341 if '/' in name:
346 if '/' in name:
342 if not descend:
347 if not descend:
343 continue
348 continue
344
349
345 nameparts = name.split('/')
350 nameparts = name.split('/')
346 rootname = nameparts[0]
351 rootname = nameparts[0]
347
352
348 if not collapse:
353 if not collapse:
349 pass
354 pass
350 elif rootname in seendirs:
355 elif rootname in seendirs:
351 continue
356 continue
352 elif rootname in seenrepos:
357 elif rootname in seenrepos:
353 pass
358 pass
354 else:
359 else:
355 directory = True
360 directory = True
356 name = rootname
361 name = rootname
357
362
358 # redefine the path to refer to the directory
363 # redefine the path to refer to the directory
359 discarded = '/'.join(nameparts[1:])
364 discarded = '/'.join(nameparts[1:])
360
365
361 # remove name parts plus accompanying slash
366 # remove name parts plus accompanying slash
362 path = path[:-len(discarded) - 1]
367 path = path[:-len(discarded) - 1]
363
368
364 try:
369 try:
365 r = hg.repository(self.ui, path)
370 r = hg.repository(self.ui, path)
366 directory = False
371 directory = False
367 except (IOError, error.RepoError):
372 except (IOError, error.RepoError):
368 pass
373 pass
369
374
370 parts = [name]
375 parts = [name]
371 parts.insert(0, '/' + subdir.rstrip('/'))
376 parts.insert(0, '/' + subdir.rstrip('/'))
372 if wsgireq.env['SCRIPT_NAME']:
377 if wsgireq.env['SCRIPT_NAME']:
373 parts.insert(0, wsgireq.env['SCRIPT_NAME'])
378 parts.insert(0, wsgireq.env['SCRIPT_NAME'])
374 url = re.sub(r'/+', '/', '/'.join(parts) + '/')
379 url = re.sub(r'/+', '/', '/'.join(parts) + '/')
375
380
376 # show either a directory entry or a repository
381 # show either a directory entry or a repository
377 if directory:
382 if directory:
378 # get the directory's time information
383 # get the directory's time information
379 try:
384 try:
380 d = (get_mtime(path), dateutil.makedate()[1])
385 d = (get_mtime(path), dateutil.makedate()[1])
381 except OSError:
386 except OSError:
382 continue
387 continue
383
388
384 # add '/' to the name to make it obvious that
389 # add '/' to the name to make it obvious that
385 # the entry is a directory, not a regular repository
390 # the entry is a directory, not a regular repository
386 row = {'contact': "",
391 row = {'contact': "",
387 'contact_sort': "",
392 'contact_sort': "",
388 'name': name + '/',
393 'name': name + '/',
389 'name_sort': name,
394 'name_sort': name,
390 'url': url,
395 'url': url,
391 'description': "",
396 'description': "",
392 'description_sort': "",
397 'description_sort': "",
393 'lastchange': d,
398 'lastchange': d,
394 'lastchange_sort': d[1]-d[0],
399 'lastchange_sort': d[1]-d[0],
395 'archives': [],
400 'archives': [],
396 'isdirectory': True,
401 'isdirectory': True,
397 'labels': [],
402 'labels': [],
398 }
403 }
399
404
400 seendirs.add(name)
405 seendirs.add(name)
401 yield row
406 yield row
402 continue
407 continue
403
408
404 u = self.ui.copy()
409 u = self.ui.copy()
405 try:
410 try:
406 u.readconfig(os.path.join(path, '.hg', 'hgrc'))
411 u.readconfig(os.path.join(path, '.hg', 'hgrc'))
407 except Exception as e:
412 except Exception as e:
408 u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
413 u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
409 continue
414 continue
410 def get(section, name, default=uimod._unset):
415 def get(section, name, default=uimod._unset):
411 return u.config(section, name, default, untrusted=True)
416 return u.config(section, name, default, untrusted=True)
412
417
413 if u.configbool("web", "hidden", untrusted=True):
418 if u.configbool("web", "hidden", untrusted=True):
414 continue
419 continue
415
420
416 if not self.read_allowed(u, wsgireq):
421 if not self.read_allowed(u, wsgireq):
417 continue
422 continue
418
423
419 # update time with local timezone
424 # update time with local timezone
420 try:
425 try:
421 r = hg.repository(self.ui, path)
426 r = hg.repository(self.ui, path)
422 except IOError:
427 except IOError:
423 u.warn(_('error accessing repository at %s\n') % path)
428 u.warn(_('error accessing repository at %s\n') % path)
424 continue
429 continue
425 except error.RepoError:
430 except error.RepoError:
426 u.warn(_('error accessing repository at %s\n') % path)
431 u.warn(_('error accessing repository at %s\n') % path)
427 continue
432 continue
428 try:
433 try:
429 d = (get_mtime(r.spath), dateutil.makedate()[1])
434 d = (get_mtime(r.spath), dateutil.makedate()[1])
430 except OSError:
435 except OSError:
431 continue
436 continue
432
437
433 contact = get_contact(get)
438 contact = get_contact(get)
434 description = get("web", "description")
439 description = get("web", "description")
435 seenrepos.add(name)
440 seenrepos.add(name)
436 name = get("web", "name", name)
441 name = get("web", "name", name)
437 row = {'contact': contact or "unknown",
442 row = {'contact': contact or "unknown",
438 'contact_sort': contact.upper() or "unknown",
443 'contact_sort': contact.upper() or "unknown",
439 'name': name,
444 'name': name,
440 'name_sort': name,
445 'name_sort': name,
441 'url': url,
446 'url': url,
442 'description': description or "unknown",
447 'description': description or "unknown",
443 'description_sort': description.upper() or "unknown",
448 'description_sort': description.upper() or "unknown",
444 'lastchange': d,
449 'lastchange': d,
445 'lastchange_sort': d[1]-d[0],
450 'lastchange_sort': d[1]-d[0],
446 'archives': archivelist(u, "tip", url),
451 'archives': archivelist(u, "tip", url),
447 'isdirectory': None,
452 'isdirectory': None,
448 'labels': u.configlist('web', 'labels', untrusted=True),
453 'labels': u.configlist('web', 'labels', untrusted=True),
449 }
454 }
450
455
451 yield row
456 yield row
452
457
453 sortdefault = None, False
458 sortdefault = None, False
454 def entries(sortcolumn="", descending=False, subdir="", **map):
459 def entries(sortcolumn="", descending=False, subdir="", **map):
455 rows = rawentries(subdir=subdir, **map)
460 rows = rawentries(subdir=subdir, **map)
456
461
457 if sortcolumn and sortdefault != (sortcolumn, descending):
462 if sortcolumn and sortdefault != (sortcolumn, descending):
458 sortkey = '%s_sort' % sortcolumn
463 sortkey = '%s_sort' % sortcolumn
459 rows = sorted(rows, key=lambda x: x[sortkey],
464 rows = sorted(rows, key=lambda x: x[sortkey],
460 reverse=descending)
465 reverse=descending)
461 for row, parity in zip(rows, paritygen(self.stripecount)):
466 for row, parity in zip(rows, paritygen(self.stripecount)):
462 row['parity'] = parity
467 row['parity'] = parity
463 yield row
468 yield row
464
469
465 self.refresh()
470 self.refresh()
466 sortable = ["name", "description", "contact", "lastchange"]
471 sortable = ["name", "description", "contact", "lastchange"]
467 sortcolumn, descending = sortdefault
472 sortcolumn, descending = sortdefault
468 if 'sort' in wsgireq.form:
473 if 'sort' in wsgireq.form:
469 sortcolumn = wsgireq.form['sort'][0]
474 sortcolumn = wsgireq.form['sort'][0]
470 descending = sortcolumn.startswith('-')
475 descending = sortcolumn.startswith('-')
471 if descending:
476 if descending:
472 sortcolumn = sortcolumn[1:]
477 sortcolumn = sortcolumn[1:]
473 if sortcolumn not in sortable:
478 if sortcolumn not in sortable:
474 sortcolumn = ""
479 sortcolumn = ""
475
480
476 sort = [("sort_%s" % column,
481 sort = [("sort_%s" % column,
477 "%s%s" % ((not descending and column == sortcolumn)
482 "%s%s" % ((not descending and column == sortcolumn)
478 and "-" or "", column))
483 and "-" or "", column))
479 for column in sortable]
484 for column in sortable]
480
485
481 self.refresh()
486 self.refresh()
482 self.updatereqenv(wsgireq.env)
487 self.updatereqenv(wsgireq.env)
483
488
484 return tmpl("index", entries=entries, subdir=subdir,
489 return tmpl("index", entries=entries, subdir=subdir,
485 pathdef=hgweb_mod.makebreadcrumb('/' + subdir, self.prefix),
490 pathdef=hgweb_mod.makebreadcrumb('/' + subdir, self.prefix),
486 sortcolumn=sortcolumn, descending=descending,
491 sortcolumn=sortcolumn, descending=descending,
487 **dict(sort))
492 **dict(sort))
488
493
489 def templater(self, wsgireq, nonce):
494 def templater(self, wsgireq, nonce):
490
495
491 def motd(**map):
496 def motd(**map):
492 if self.motd is not None:
497 if self.motd is not None:
493 yield self.motd
498 yield self.motd
494 else:
499 else:
495 yield config('web', 'motd')
500 yield config('web', 'motd')
496
501
497 def config(section, name, default=uimod._unset, untrusted=True):
502 def config(section, name, default=uimod._unset, untrusted=True):
498 return self.ui.config(section, name, default, untrusted)
503 return self.ui.config(section, name, default, untrusted)
499
504
500 self.updatereqenv(wsgireq.env)
505 self.updatereqenv(wsgireq.env)
501
506
502 url = wsgireq.env.get('SCRIPT_NAME', '')
507 url = wsgireq.env.get('SCRIPT_NAME', '')
503 if not url.endswith('/'):
508 if not url.endswith('/'):
504 url += '/'
509 url += '/'
505
510
506 vars = {}
511 vars = {}
507 styles, (style, mapfile) = hgweb_mod.getstyle(wsgireq, config,
512 styles, (style, mapfile) = hgweb_mod.getstyle(wsgireq, config,
508 self.templatepath)
513 self.templatepath)
509 if style == styles[0]:
514 if style == styles[0]:
510 vars['style'] = style
515 vars['style'] = style
511
516
512 sessionvars = webutil.sessionvars(vars, r'?')
517 sessionvars = webutil.sessionvars(vars, r'?')
513 logourl = config('web', 'logourl')
518 logourl = config('web', 'logourl')
514 logoimg = config('web', 'logoimg')
519 logoimg = config('web', 'logoimg')
515 staticurl = config('web', 'staticurl') or url + 'static/'
520 staticurl = config('web', 'staticurl') or url + 'static/'
516 if not staticurl.endswith('/'):
521 if not staticurl.endswith('/'):
517 staticurl += '/'
522 staticurl += '/'
518
523
519 defaults = {
524 defaults = {
520 "encoding": encoding.encoding,
525 "encoding": encoding.encoding,
521 "motd": motd,
526 "motd": motd,
522 "url": url,
527 "url": url,
523 "logourl": logourl,
528 "logourl": logourl,
524 "logoimg": logoimg,
529 "logoimg": logoimg,
525 "staticurl": staticurl,
530 "staticurl": staticurl,
526 "sessionvars": sessionvars,
531 "sessionvars": sessionvars,
527 "style": style,
532 "style": style,
528 "nonce": nonce,
533 "nonce": nonce,
529 }
534 }
530 tmpl = templater.templater.frommapfile(mapfile, defaults=defaults)
535 tmpl = templater.templater.frommapfile(mapfile, defaults=defaults)
531 return tmpl
536 return tmpl
532
537
533 def updatereqenv(self, env):
538 def updatereqenv(self, env):
534 if self._baseurl is not None:
539 if self._baseurl is not None:
535 name, port, path = geturlcgivars(self._baseurl, env['SERVER_PORT'])
540 name, port, path = geturlcgivars(self._baseurl, env['SERVER_PORT'])
536 env['SERVER_NAME'] = name
541 env['SERVER_NAME'] = name
537 env['SERVER_PORT'] = port
542 env['SERVER_PORT'] = port
538 env['SCRIPT_NAME'] = path
543 env['SCRIPT_NAME'] = path
@@ -1,361 +1,363 b''
1 # hgweb/request.py - An http request from either CGI or the standalone server.
1 # hgweb/request.py - An http request from either CGI or the standalone server.
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 cgi
11 import cgi
12 import errno
12 import errno
13 import socket
13 import socket
14 import wsgiref.headers as wsgiheaders
14 import wsgiref.headers as wsgiheaders
15 #import wsgiref.validate
15 #import wsgiref.validate
16
16
17 from .common import (
17 from .common import (
18 ErrorResponse,
18 ErrorResponse,
19 HTTP_NOT_MODIFIED,
19 HTTP_NOT_MODIFIED,
20 statusmessage,
20 statusmessage,
21 )
21 )
22
22
23 from ..thirdparty import (
23 from ..thirdparty import (
24 attr,
24 attr,
25 )
25 )
26 from .. import (
26 from .. import (
27 pycompat,
27 pycompat,
28 util,
28 util,
29 )
29 )
30
30
31 shortcuts = {
31 shortcuts = {
32 'cl': [('cmd', ['changelog']), ('rev', None)],
32 'cl': [('cmd', ['changelog']), ('rev', None)],
33 'sl': [('cmd', ['shortlog']), ('rev', None)],
33 'sl': [('cmd', ['shortlog']), ('rev', None)],
34 'cs': [('cmd', ['changeset']), ('node', None)],
34 'cs': [('cmd', ['changeset']), ('node', None)],
35 'f': [('cmd', ['file']), ('filenode', None)],
35 'f': [('cmd', ['file']), ('filenode', None)],
36 'fl': [('cmd', ['filelog']), ('filenode', None)],
36 'fl': [('cmd', ['filelog']), ('filenode', None)],
37 'fd': [('cmd', ['filediff']), ('node', None)],
37 'fd': [('cmd', ['filediff']), ('node', None)],
38 'fa': [('cmd', ['annotate']), ('filenode', None)],
38 'fa': [('cmd', ['annotate']), ('filenode', None)],
39 'mf': [('cmd', ['manifest']), ('manifest', None)],
39 'mf': [('cmd', ['manifest']), ('manifest', None)],
40 'ca': [('cmd', ['archive']), ('node', None)],
40 'ca': [('cmd', ['archive']), ('node', None)],
41 'tags': [('cmd', ['tags'])],
41 'tags': [('cmd', ['tags'])],
42 'tip': [('cmd', ['changeset']), ('node', ['tip'])],
42 'tip': [('cmd', ['changeset']), ('node', ['tip'])],
43 'static': [('cmd', ['static']), ('file', None)]
43 'static': [('cmd', ['static']), ('file', None)]
44 }
44 }
45
45
46 def normalize(form):
46 def normalize(form):
47 # first expand the shortcuts
47 # first expand the shortcuts
48 for k in shortcuts:
48 for k in shortcuts:
49 if k in form:
49 if k in form:
50 for name, value in shortcuts[k]:
50 for name, value in shortcuts[k]:
51 if value is None:
51 if value is None:
52 value = form[k]
52 value = form[k]
53 form[name] = value
53 form[name] = value
54 del form[k]
54 del form[k]
55 # And strip the values
55 # And strip the values
56 bytesform = {}
56 bytesform = {}
57 for k, v in form.iteritems():
57 for k, v in form.iteritems():
58 bytesform[pycompat.bytesurl(k)] = [
58 bytesform[pycompat.bytesurl(k)] = [
59 pycompat.bytesurl(i.strip()) for i in v]
59 pycompat.bytesurl(i.strip()) for i in v]
60 return bytesform
60 return bytesform
61
61
62 @attr.s(frozen=True)
62 @attr.s(frozen=True)
63 class parsedrequest(object):
63 class parsedrequest(object):
64 """Represents a parsed WSGI request / static HTTP request parameters."""
64 """Represents a parsed WSGI request / static HTTP request parameters."""
65
65
66 # Request method.
66 # Request method.
67 method = attr.ib()
67 method = attr.ib()
68 # Full URL for this request.
68 # Full URL for this request.
69 url = attr.ib()
69 url = attr.ib()
70 # URL without any path components. Just <proto>://<host><port>.
70 # URL without any path components. Just <proto>://<host><port>.
71 baseurl = attr.ib()
71 baseurl = attr.ib()
72 # Advertised URL. Like ``url`` and ``baseurl`` but uses SERVER_NAME instead
72 # Advertised URL. Like ``url`` and ``baseurl`` but uses SERVER_NAME instead
73 # of HTTP: Host header for hostname. This is likely what clients used.
73 # of HTTP: Host header for hostname. This is likely what clients used.
74 advertisedurl = attr.ib()
74 advertisedurl = attr.ib()
75 advertisedbaseurl = attr.ib()
75 advertisedbaseurl = attr.ib()
76 # WSGI application path.
76 # WSGI application path.
77 apppath = attr.ib()
77 apppath = attr.ib()
78 # List of path parts to be used for dispatch.
78 # List of path parts to be used for dispatch.
79 dispatchparts = attr.ib()
79 dispatchparts = attr.ib()
80 # URL path component (no query string) used for dispatch.
80 # URL path component (no query string) used for dispatch.
81 dispatchpath = attr.ib()
81 dispatchpath = attr.ib()
82 # Whether there is a path component to this request. This can be true
82 # Whether there is a path component to this request. This can be true
83 # when ``dispatchpath`` is empty due to REPO_NAME muckery.
83 # when ``dispatchpath`` is empty due to REPO_NAME muckery.
84 havepathinfo = attr.ib()
84 havepathinfo = attr.ib()
85 # Raw query string (part after "?" in URL).
85 # Raw query string (part after "?" in URL).
86 querystring = attr.ib()
86 querystring = attr.ib()
87 # List of 2-tuples of query string arguments.
87 # List of 2-tuples of query string arguments.
88 querystringlist = attr.ib()
88 querystringlist = attr.ib()
89 # Dict of query string arguments. Values are lists with at least 1 item.
89 # Dict of query string arguments. Values are lists with at least 1 item.
90 querystringdict = attr.ib()
90 querystringdict = attr.ib()
91 # wsgiref.headers.Headers instance. Operates like a dict with case
91 # wsgiref.headers.Headers instance. Operates like a dict with case
92 # insensitive keys.
92 # insensitive keys.
93 headers = attr.ib()
93 headers = attr.ib()
94
94
95 def parserequestfromenv(env):
95 def parserequestfromenv(env):
96 """Parse URL components from environment variables.
96 """Parse URL components from environment variables.
97
97
98 WSGI defines request attributes via environment variables. This function
98 WSGI defines request attributes via environment variables. This function
99 parses the environment variables into a data structure.
99 parses the environment variables into a data structure.
100 """
100 """
101 # PEP-0333 defines the WSGI spec and is a useful reference for this code.
101 # PEP-0333 defines the WSGI spec and is a useful reference for this code.
102
102
103 # We first validate that the incoming object conforms with the WSGI spec.
103 # We first validate that the incoming object conforms with the WSGI spec.
104 # We only want to be dealing with spec-conforming WSGI implementations.
104 # We only want to be dealing with spec-conforming WSGI implementations.
105 # TODO enable this once we fix internal violations.
105 # TODO enable this once we fix internal violations.
106 #wsgiref.validate.check_environ(env)
106 #wsgiref.validate.check_environ(env)
107
107
108 # PEP-0333 states that environment keys and values are native strings
108 # PEP-0333 states that environment keys and values are native strings
109 # (bytes on Python 2 and str on Python 3). The code points for the Unicode
109 # (bytes on Python 2 and str on Python 3). The code points for the Unicode
110 # strings on Python 3 must be between \00000-\000FF. We deal with bytes
110 # strings on Python 3 must be between \00000-\000FF. We deal with bytes
111 # in Mercurial, so mass convert string keys and values to bytes.
111 # in Mercurial, so mass convert string keys and values to bytes.
112 if pycompat.ispy3:
112 if pycompat.ispy3:
113 env = {k.encode('latin-1'): v for k, v in env.iteritems()}
113 env = {k.encode('latin-1'): v for k, v in env.iteritems()}
114 env = {k: v.encode('latin-1') if isinstance(v, str) else v
114 env = {k: v.encode('latin-1') if isinstance(v, str) else v
115 for k, v in env.iteritems()}
115 for k, v in env.iteritems()}
116
116
117 # https://www.python.org/dev/peps/pep-0333/#environ-variables defines
117 # https://www.python.org/dev/peps/pep-0333/#environ-variables defines
118 # the environment variables.
118 # the environment variables.
119 # https://www.python.org/dev/peps/pep-0333/#url-reconstruction defines
119 # https://www.python.org/dev/peps/pep-0333/#url-reconstruction defines
120 # how URLs are reconstructed.
120 # how URLs are reconstructed.
121 fullurl = env['wsgi.url_scheme'] + '://'
121 fullurl = env['wsgi.url_scheme'] + '://'
122 advertisedfullurl = fullurl
122 advertisedfullurl = fullurl
123
123
124 def addport(s):
124 def addport(s):
125 if env['wsgi.url_scheme'] == 'https':
125 if env['wsgi.url_scheme'] == 'https':
126 if env['SERVER_PORT'] != '443':
126 if env['SERVER_PORT'] != '443':
127 s += ':' + env['SERVER_PORT']
127 s += ':' + env['SERVER_PORT']
128 else:
128 else:
129 if env['SERVER_PORT'] != '80':
129 if env['SERVER_PORT'] != '80':
130 s += ':' + env['SERVER_PORT']
130 s += ':' + env['SERVER_PORT']
131
131
132 return s
132 return s
133
133
134 if env.get('HTTP_HOST'):
134 if env.get('HTTP_HOST'):
135 fullurl += env['HTTP_HOST']
135 fullurl += env['HTTP_HOST']
136 else:
136 else:
137 fullurl += env['SERVER_NAME']
137 fullurl += env['SERVER_NAME']
138 fullurl = addport(fullurl)
138 fullurl = addport(fullurl)
139
139
140 advertisedfullurl += env['SERVER_NAME']
140 advertisedfullurl += env['SERVER_NAME']
141 advertisedfullurl = addport(advertisedfullurl)
141 advertisedfullurl = addport(advertisedfullurl)
142
142
143 baseurl = fullurl
143 baseurl = fullurl
144 advertisedbaseurl = advertisedfullurl
144 advertisedbaseurl = advertisedfullurl
145
145
146 fullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
146 fullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
147 advertisedfullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
147 advertisedfullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
148 fullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
148 fullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
149 advertisedfullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
149 advertisedfullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
150
150
151 if env.get('QUERY_STRING'):
151 if env.get('QUERY_STRING'):
152 fullurl += '?' + env['QUERY_STRING']
152 fullurl += '?' + env['QUERY_STRING']
153 advertisedfullurl += '?' + env['QUERY_STRING']
153 advertisedfullurl += '?' + env['QUERY_STRING']
154
154
155 # When dispatching requests, we look at the URL components (PATH_INFO
155 # When dispatching requests, we look at the URL components (PATH_INFO
156 # and QUERY_STRING) after the application root (SCRIPT_NAME). But hgwebdir
156 # and QUERY_STRING) after the application root (SCRIPT_NAME). But hgwebdir
157 # has the concept of "virtual" repositories. This is defined via REPO_NAME.
157 # has the concept of "virtual" repositories. This is defined via REPO_NAME.
158 # If REPO_NAME is defined, we append it to SCRIPT_NAME to form a new app
158 # If REPO_NAME is defined, we append it to SCRIPT_NAME to form a new app
159 # root. We also exclude its path components from PATH_INFO when resolving
159 # root. We also exclude its path components from PATH_INFO when resolving
160 # the dispatch path.
160 # the dispatch path.
161
161
162 apppath = env['SCRIPT_NAME']
162 apppath = env['SCRIPT_NAME']
163
163
164 if env.get('REPO_NAME'):
164 if env.get('REPO_NAME'):
165 if not apppath.endswith('/'):
165 if not apppath.endswith('/'):
166 apppath += '/'
166 apppath += '/'
167
167
168 apppath += env.get('REPO_NAME')
168 apppath += env.get('REPO_NAME')
169
169
170 if 'PATH_INFO' in env:
170 if 'PATH_INFO' in env:
171 dispatchparts = env['PATH_INFO'].strip('/').split('/')
171 dispatchparts = env['PATH_INFO'].strip('/').split('/')
172
172
173 # Strip out repo parts.
173 # Strip out repo parts.
174 repoparts = env.get('REPO_NAME', '').split('/')
174 repoparts = env.get('REPO_NAME', '').split('/')
175 if dispatchparts[:len(repoparts)] == repoparts:
175 if dispatchparts[:len(repoparts)] == repoparts:
176 dispatchparts = dispatchparts[len(repoparts):]
176 dispatchparts = dispatchparts[len(repoparts):]
177 else:
177 else:
178 dispatchparts = []
178 dispatchparts = []
179
179
180 dispatchpath = '/'.join(dispatchparts)
180 dispatchpath = '/'.join(dispatchparts)
181
181
182 querystring = env.get('QUERY_STRING', '')
182 querystring = env.get('QUERY_STRING', '')
183
183
184 # We store as a list so we have ordering information. We also store as
184 # We store as a list so we have ordering information. We also store as
185 # a dict to facilitate fast lookup.
185 # a dict to facilitate fast lookup.
186 querystringlist = util.urlreq.parseqsl(querystring, keep_blank_values=True)
186 querystringlist = util.urlreq.parseqsl(querystring, keep_blank_values=True)
187
187
188 querystringdict = {}
188 querystringdict = {}
189 for k, v in querystringlist:
189 for k, v in querystringlist:
190 if k in querystringdict:
190 if k in querystringdict:
191 querystringdict[k].append(v)
191 querystringdict[k].append(v)
192 else:
192 else:
193 querystringdict[k] = [v]
193 querystringdict[k] = [v]
194
194
195 # HTTP_* keys contain HTTP request headers. The Headers structure should
195 # HTTP_* keys contain HTTP request headers. The Headers structure should
196 # perform case normalization for us. We just rewrite underscore to dash
196 # perform case normalization for us. We just rewrite underscore to dash
197 # so keys match what likely went over the wire.
197 # so keys match what likely went over the wire.
198 headers = []
198 headers = []
199 for k, v in env.iteritems():
199 for k, v in env.iteritems():
200 if k.startswith('HTTP_'):
200 if k.startswith('HTTP_'):
201 headers.append((k[len('HTTP_'):].replace('_', '-'), v))
201 headers.append((k[len('HTTP_'):].replace('_', '-'), v))
202
202
203 headers = wsgiheaders.Headers(headers)
203 headers = wsgiheaders.Headers(headers)
204
204
205 # This is kind of a lie because the HTTP header wasn't explicitly
205 # This is kind of a lie because the HTTP header wasn't explicitly
206 # sent. But for all intents and purposes it should be OK to lie about
206 # sent. But for all intents and purposes it should be OK to lie about
207 # this, since a consumer will either either value to determine how many
207 # this, since a consumer will either either value to determine how many
208 # bytes are available to read.
208 # bytes are available to read.
209 if 'CONTENT_LENGTH' in env and 'HTTP_CONTENT_LENGTH' not in env:
209 if 'CONTENT_LENGTH' in env and 'HTTP_CONTENT_LENGTH' not in env:
210 headers['Content-Length'] = env['CONTENT_LENGTH']
210 headers['Content-Length'] = env['CONTENT_LENGTH']
211
211
212 return parsedrequest(method=env['REQUEST_METHOD'],
212 return parsedrequest(method=env['REQUEST_METHOD'],
213 url=fullurl, baseurl=baseurl,
213 url=fullurl, baseurl=baseurl,
214 advertisedurl=advertisedfullurl,
214 advertisedurl=advertisedfullurl,
215 advertisedbaseurl=advertisedbaseurl,
215 advertisedbaseurl=advertisedbaseurl,
216 apppath=apppath,
216 apppath=apppath,
217 dispatchparts=dispatchparts, dispatchpath=dispatchpath,
217 dispatchparts=dispatchparts, dispatchpath=dispatchpath,
218 havepathinfo='PATH_INFO' in env,
218 havepathinfo='PATH_INFO' in env,
219 querystring=querystring,
219 querystring=querystring,
220 querystringlist=querystringlist,
220 querystringlist=querystringlist,
221 querystringdict=querystringdict,
221 querystringdict=querystringdict,
222 headers=headers)
222 headers=headers)
223
223
224 class wsgirequest(object):
224 class wsgirequest(object):
225 """Higher-level API for a WSGI request.
225 """Higher-level API for a WSGI request.
226
226
227 WSGI applications are invoked with 2 arguments. They are used to
227 WSGI applications are invoked with 2 arguments. They are used to
228 instantiate instances of this class, which provides higher-level APIs
228 instantiate instances of this class, which provides higher-level APIs
229 for obtaining request parameters, writing HTTP output, etc.
229 for obtaining request parameters, writing HTTP output, etc.
230 """
230 """
231 def __init__(self, wsgienv, start_response):
231 def __init__(self, wsgienv, start_response):
232 version = wsgienv[r'wsgi.version']
232 version = wsgienv[r'wsgi.version']
233 if (version < (1, 0)) or (version >= (2, 0)):
233 if (version < (1, 0)) or (version >= (2, 0)):
234 raise RuntimeError("Unknown and unsupported WSGI version %d.%d"
234 raise RuntimeError("Unknown and unsupported WSGI version %d.%d"
235 % version)
235 % version)
236 self.inp = wsgienv[r'wsgi.input']
236 self.inp = wsgienv[r'wsgi.input']
237
237
238 if r'HTTP_CONTENT_LENGTH' in wsgienv:
238 if r'HTTP_CONTENT_LENGTH' in wsgienv:
239 self.inp = util.cappedreader(self.inp,
239 self.inp = util.cappedreader(self.inp,
240 int(wsgienv[r'HTTP_CONTENT_LENGTH']))
240 int(wsgienv[r'HTTP_CONTENT_LENGTH']))
241 elif r'CONTENT_LENGTH' in wsgienv:
241 elif r'CONTENT_LENGTH' in wsgienv:
242 self.inp = util.cappedreader(self.inp,
242 self.inp = util.cappedreader(self.inp,
243 int(wsgienv[r'CONTENT_LENGTH']))
243 int(wsgienv[r'CONTENT_LENGTH']))
244
244
245 self.err = wsgienv[r'wsgi.errors']
245 self.err = wsgienv[r'wsgi.errors']
246 self.threaded = wsgienv[r'wsgi.multithread']
246 self.threaded = wsgienv[r'wsgi.multithread']
247 self.multiprocess = wsgienv[r'wsgi.multiprocess']
247 self.multiprocess = wsgienv[r'wsgi.multiprocess']
248 self.run_once = wsgienv[r'wsgi.run_once']
248 self.run_once = wsgienv[r'wsgi.run_once']
249 self.env = wsgienv
249 self.env = wsgienv
250 self.form = normalize(cgi.parse(self.inp,
250 self.form = normalize(cgi.parse(self.inp,
251 self.env,
251 self.env,
252 keep_blank_values=1))
252 keep_blank_values=1))
253 self._start_response = start_response
253 self._start_response = start_response
254 self.server_write = None
254 self.server_write = None
255 self.headers = []
255 self.headers = []
256
256
257 self.req = parserequestfromenv(wsgienv)
258
257 def respond(self, status, type, filename=None, body=None):
259 def respond(self, status, type, filename=None, body=None):
258 if not isinstance(type, str):
260 if not isinstance(type, str):
259 type = pycompat.sysstr(type)
261 type = pycompat.sysstr(type)
260 if self._start_response is not None:
262 if self._start_response is not None:
261 self.headers.append((r'Content-Type', type))
263 self.headers.append((r'Content-Type', type))
262 if filename:
264 if filename:
263 filename = (filename.rpartition('/')[-1]
265 filename = (filename.rpartition('/')[-1]
264 .replace('\\', '\\\\').replace('"', '\\"'))
266 .replace('\\', '\\\\').replace('"', '\\"'))
265 self.headers.append(('Content-Disposition',
267 self.headers.append(('Content-Disposition',
266 'inline; filename="%s"' % filename))
268 'inline; filename="%s"' % filename))
267 if body is not None:
269 if body is not None:
268 self.headers.append((r'Content-Length', str(len(body))))
270 self.headers.append((r'Content-Length', str(len(body))))
269
271
270 for k, v in self.headers:
272 for k, v in self.headers:
271 if not isinstance(v, str):
273 if not isinstance(v, str):
272 raise TypeError('header value must be string: %r' % (v,))
274 raise TypeError('header value must be string: %r' % (v,))
273
275
274 if isinstance(status, ErrorResponse):
276 if isinstance(status, ErrorResponse):
275 self.headers.extend(status.headers)
277 self.headers.extend(status.headers)
276 if status.code == HTTP_NOT_MODIFIED:
278 if status.code == HTTP_NOT_MODIFIED:
277 # RFC 2616 Section 10.3.5: 304 Not Modified has cases where
279 # RFC 2616 Section 10.3.5: 304 Not Modified has cases where
278 # it MUST NOT include any headers other than these and no
280 # it MUST NOT include any headers other than these and no
279 # body
281 # body
280 self.headers = [(k, v) for (k, v) in self.headers if
282 self.headers = [(k, v) for (k, v) in self.headers if
281 k in ('Date', 'ETag', 'Expires',
283 k in ('Date', 'ETag', 'Expires',
282 'Cache-Control', 'Vary')]
284 'Cache-Control', 'Vary')]
283 status = statusmessage(status.code, pycompat.bytestr(status))
285 status = statusmessage(status.code, pycompat.bytestr(status))
284 elif status == 200:
286 elif status == 200:
285 status = '200 Script output follows'
287 status = '200 Script output follows'
286 elif isinstance(status, int):
288 elif isinstance(status, int):
287 status = statusmessage(status)
289 status = statusmessage(status)
288
290
289 # Various HTTP clients (notably httplib) won't read the HTTP
291 # Various HTTP clients (notably httplib) won't read the HTTP
290 # response until the HTTP request has been sent in full. If servers
292 # response until the HTTP request has been sent in full. If servers
291 # (us) send a response before the HTTP request has been fully sent,
293 # (us) send a response before the HTTP request has been fully sent,
292 # the connection may deadlock because neither end is reading.
294 # the connection may deadlock because neither end is reading.
293 #
295 #
294 # We work around this by "draining" the request data before
296 # We work around this by "draining" the request data before
295 # sending any response in some conditions.
297 # sending any response in some conditions.
296 drain = False
298 drain = False
297 close = False
299 close = False
298
300
299 # If the client sent Expect: 100-continue, we assume it is smart
301 # If the client sent Expect: 100-continue, we assume it is smart
300 # enough to deal with the server sending a response before reading
302 # enough to deal with the server sending a response before reading
301 # the request. (httplib doesn't do this.)
303 # the request. (httplib doesn't do this.)
302 if self.env.get(r'HTTP_EXPECT', r'').lower() == r'100-continue':
304 if self.env.get(r'HTTP_EXPECT', r'').lower() == r'100-continue':
303 pass
305 pass
304 # Only tend to request methods that have bodies. Strictly speaking,
306 # Only tend to request methods that have bodies. Strictly speaking,
305 # we should sniff for a body. But this is fine for our existing
307 # we should sniff for a body. But this is fine for our existing
306 # WSGI applications.
308 # WSGI applications.
307 elif self.env[r'REQUEST_METHOD'] not in (r'POST', r'PUT'):
309 elif self.env[r'REQUEST_METHOD'] not in (r'POST', r'PUT'):
308 pass
310 pass
309 else:
311 else:
310 # If we don't know how much data to read, there's no guarantee
312 # If we don't know how much data to read, there's no guarantee
311 # that we can drain the request responsibly. The WSGI
313 # that we can drain the request responsibly. The WSGI
312 # specification only says that servers *should* ensure the
314 # specification only says that servers *should* ensure the
313 # input stream doesn't overrun the actual request. So there's
315 # input stream doesn't overrun the actual request. So there's
314 # no guarantee that reading until EOF won't corrupt the stream
316 # no guarantee that reading until EOF won't corrupt the stream
315 # state.
317 # state.
316 if not isinstance(self.inp, util.cappedreader):
318 if not isinstance(self.inp, util.cappedreader):
317 close = True
319 close = True
318 else:
320 else:
319 # We /could/ only drain certain HTTP response codes. But 200
321 # We /could/ only drain certain HTTP response codes. But 200
320 # and non-200 wire protocol responses both require draining.
322 # and non-200 wire protocol responses both require draining.
321 # Since we have a capped reader in place for all situations
323 # Since we have a capped reader in place for all situations
322 # where we drain, it is safe to read from that stream. We'll
324 # where we drain, it is safe to read from that stream. We'll
323 # either do a drain or no-op if we're already at EOF.
325 # either do a drain or no-op if we're already at EOF.
324 drain = True
326 drain = True
325
327
326 if close:
328 if close:
327 self.headers.append((r'Connection', r'Close'))
329 self.headers.append((r'Connection', r'Close'))
328
330
329 if drain:
331 if drain:
330 assert isinstance(self.inp, util.cappedreader)
332 assert isinstance(self.inp, util.cappedreader)
331 while True:
333 while True:
332 chunk = self.inp.read(32768)
334 chunk = self.inp.read(32768)
333 if not chunk:
335 if not chunk:
334 break
336 break
335
337
336 self.server_write = self._start_response(
338 self.server_write = self._start_response(
337 pycompat.sysstr(status), self.headers)
339 pycompat.sysstr(status), self.headers)
338 self._start_response = None
340 self._start_response = None
339 self.headers = []
341 self.headers = []
340 if body is not None:
342 if body is not None:
341 self.write(body)
343 self.write(body)
342 self.server_write = None
344 self.server_write = None
343
345
344 def write(self, thing):
346 def write(self, thing):
345 if thing:
347 if thing:
346 try:
348 try:
347 self.server_write(thing)
349 self.server_write(thing)
348 except socket.error as inst:
350 except socket.error as inst:
349 if inst[0] != errno.ECONNRESET:
351 if inst[0] != errno.ECONNRESET:
350 raise
352 raise
351
353
352 def flush(self):
354 def flush(self):
353 return None
355 return None
354
356
355 def wsgiapplication(app_maker):
357 def wsgiapplication(app_maker):
356 '''For compatibility with old CGI scripts. A plain hgweb() or hgwebdir()
358 '''For compatibility with old CGI scripts. A plain hgweb() or hgwebdir()
357 can and should now be used as a WSGI application.'''
359 can and should now be used as a WSGI application.'''
358 application = app_maker()
360 application = app_maker()
359 def run_wsgi(env, respond):
361 def run_wsgi(env, respond):
360 return application(env, respond)
362 return application(env, respond)
361 return run_wsgi
363 return run_wsgi
General Comments 0
You need to be logged in to leave comments. Login now