##// END OF EJS Templates
hgweb: parse WSGI request into a data structure...
Gregory Szorc -
r36824:69b2d090 default
parent child Browse files
Show More
@@ -1,472 +1,466 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):
145 def templater(self, wsgireq):
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
148
149 proto = wsgireq.env.get('wsgi.url_scheme')
149 proto = wsgireq.env.get('wsgi.url_scheme')
150 if proto == 'https':
150 if proto == 'https':
151 proto = 'https'
151 proto = 'https'
152 default_port = '443'
152 default_port = '443'
153 else:
153 else:
154 proto = 'http'
154 proto = 'http'
155 default_port = '80'
155 default_port = '80'
156
156
157 port = wsgireq.env[r'SERVER_PORT']
157 port = wsgireq.env[r'SERVER_PORT']
158 port = port != default_port and (r':' + port) or r''
158 port = port != default_port and (r':' + port) or r''
159 urlbase = r'%s://%s%s' % (proto, wsgireq.env[r'SERVER_NAME'], port)
159 urlbase = r'%s://%s%s' % (proto, wsgireq.env[r'SERVER_NAME'], port)
160 logourl = self.config('web', 'logourl')
160 logourl = self.config('web', 'logourl')
161 logoimg = self.config('web', 'logoimg')
161 logoimg = self.config('web', 'logoimg')
162 staticurl = (self.config('web', 'staticurl')
162 staticurl = (self.config('web', 'staticurl')
163 or pycompat.sysbytes(wsgireq.url) + 'static/')
163 or pycompat.sysbytes(wsgireq.url) + 'static/')
164 if not staticurl.endswith('/'):
164 if not staticurl.endswith('/'):
165 staticurl += '/'
165 staticurl += '/'
166
166
167 # some functions for the templater
167 # some functions for the templater
168
168
169 def motd(**map):
169 def motd(**map):
170 yield self.config('web', 'motd')
170 yield self.config('web', 'motd')
171
171
172 # figure out which style to use
172 # figure out which style to use
173
173
174 vars = {}
174 vars = {}
175 styles, (style, mapfile) = getstyle(wsgireq, self.config,
175 styles, (style, mapfile) = getstyle(wsgireq, self.config,
176 self.templatepath)
176 self.templatepath)
177 if style == styles[0]:
177 if style == styles[0]:
178 vars['style'] = style
178 vars['style'] = style
179
179
180 sessionvars = webutil.sessionvars(vars, '?')
180 sessionvars = webutil.sessionvars(vars, '?')
181
181
182 if not self.reponame:
182 if not self.reponame:
183 self.reponame = (self.config('web', 'name', '')
183 self.reponame = (self.config('web', 'name', '')
184 or wsgireq.env.get('REPO_NAME')
184 or wsgireq.env.get('REPO_NAME')
185 or wsgireq.url.strip(r'/') or self.repo.root)
185 or wsgireq.url.strip(r'/') or self.repo.root)
186
186
187 def websubfilter(text):
187 def websubfilter(text):
188 return templatefilters.websub(text, self.websubtable)
188 return templatefilters.websub(text, self.websubtable)
189
189
190 # create the templater
190 # create the templater
191 # TODO: export all keywords: defaults = templatekw.keywords.copy()
191 # TODO: export all keywords: defaults = templatekw.keywords.copy()
192 defaults = {
192 defaults = {
193 'url': pycompat.sysbytes(wsgireq.url),
193 'url': pycompat.sysbytes(wsgireq.url),
194 'logourl': logourl,
194 'logourl': logourl,
195 'logoimg': logoimg,
195 'logoimg': logoimg,
196 'staticurl': staticurl,
196 'staticurl': staticurl,
197 'urlbase': urlbase,
197 'urlbase': urlbase,
198 'repo': self.reponame,
198 'repo': self.reponame,
199 'encoding': encoding.encoding,
199 'encoding': encoding.encoding,
200 'motd': motd,
200 'motd': motd,
201 'sessionvars': sessionvars,
201 'sessionvars': sessionvars,
202 'pathdef': makebreadcrumb(pycompat.sysbytes(wsgireq.url)),
202 'pathdef': makebreadcrumb(pycompat.sysbytes(wsgireq.url)),
203 'style': style,
203 'style': style,
204 'nonce': self.nonce,
204 'nonce': self.nonce,
205 }
205 }
206 tres = formatter.templateresources(self.repo.ui, self.repo)
206 tres = formatter.templateresources(self.repo.ui, self.repo)
207 tmpl = templater.templater.frommapfile(mapfile,
207 tmpl = templater.templater.frommapfile(mapfile,
208 filters={'websub': websubfilter},
208 filters={'websub': websubfilter},
209 defaults=defaults,
209 defaults=defaults,
210 resources=tres)
210 resources=tres)
211 return tmpl
211 return tmpl
212
212
213
213
214 class hgweb(object):
214 class hgweb(object):
215 """HTTP server for individual repositories.
215 """HTTP server for individual repositories.
216
216
217 Instances of this class serve HTTP responses for a particular
217 Instances of this class serve HTTP responses for a particular
218 repository.
218 repository.
219
219
220 Instances are typically used as WSGI applications.
220 Instances are typically used as WSGI applications.
221
221
222 Some servers are multi-threaded. On these servers, there may
222 Some servers are multi-threaded. On these servers, there may
223 be multiple active threads inside __call__.
223 be multiple active threads inside __call__.
224 """
224 """
225 def __init__(self, repo, name=None, baseui=None):
225 def __init__(self, repo, name=None, baseui=None):
226 if isinstance(repo, str):
226 if isinstance(repo, str):
227 if baseui:
227 if baseui:
228 u = baseui.copy()
228 u = baseui.copy()
229 else:
229 else:
230 u = uimod.ui.load()
230 u = uimod.ui.load()
231 r = hg.repository(u, repo)
231 r = hg.repository(u, repo)
232 else:
232 else:
233 # we trust caller to give us a private copy
233 # we trust caller to give us a private copy
234 r = repo
234 r = repo
235
235
236 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
236 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
237 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
237 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
238 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
238 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
239 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
239 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
240 # resolve file patterns relative to repo root
240 # resolve file patterns relative to repo root
241 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
241 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
242 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
242 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
243 # displaying bundling progress bar while serving feel wrong and may
243 # displaying bundling progress bar while serving feel wrong and may
244 # break some wsgi implementation.
244 # break some wsgi implementation.
245 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
245 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
246 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
246 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
247 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
247 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
248 self._lastrepo = self._repos[0]
248 self._lastrepo = self._repos[0]
249 hook.redirect(True)
249 hook.redirect(True)
250 self.reponame = name
250 self.reponame = name
251
251
252 def _webifyrepo(self, repo):
252 def _webifyrepo(self, repo):
253 repo = getwebview(repo)
253 repo = getwebview(repo)
254 self.websubtable = webutil.getwebsubs(repo)
254 self.websubtable = webutil.getwebsubs(repo)
255 return repo
255 return repo
256
256
257 @contextlib.contextmanager
257 @contextlib.contextmanager
258 def _obtainrepo(self):
258 def _obtainrepo(self):
259 """Obtain a repo unique to the caller.
259 """Obtain a repo unique to the caller.
260
260
261 Internally we maintain a stack of cachedlocalrepo instances
261 Internally we maintain a stack of cachedlocalrepo instances
262 to be handed out. If one is available, we pop it and return it,
262 to be handed out. If one is available, we pop it and return it,
263 ensuring it is up to date in the process. If one is not available,
263 ensuring it is up to date in the process. If one is not available,
264 we clone the most recently used repo instance and return it.
264 we clone the most recently used repo instance and return it.
265
265
266 It is currently possible for the stack to grow without bounds
266 It is currently possible for the stack to grow without bounds
267 if the server allows infinite threads. However, servers should
267 if the server allows infinite threads. However, servers should
268 have a thread limit, thus establishing our limit.
268 have a thread limit, thus establishing our limit.
269 """
269 """
270 if self._repos:
270 if self._repos:
271 cached = self._repos.pop()
271 cached = self._repos.pop()
272 r, created = cached.fetch()
272 r, created = cached.fetch()
273 else:
273 else:
274 cached = self._lastrepo.copy()
274 cached = self._lastrepo.copy()
275 r, created = cached.fetch()
275 r, created = cached.fetch()
276 if created:
276 if created:
277 r = self._webifyrepo(r)
277 r = self._webifyrepo(r)
278
278
279 self._lastrepo = cached
279 self._lastrepo = cached
280 self.mtime = cached.mtime
280 self.mtime = cached.mtime
281 try:
281 try:
282 yield r
282 yield r
283 finally:
283 finally:
284 self._repos.append(cached)
284 self._repos.append(cached)
285
285
286 def run(self):
286 def run(self):
287 """Start a server from CGI environment.
287 """Start a server from CGI environment.
288
288
289 Modern servers should be using WSGI and should avoid this
289 Modern servers should be using WSGI and should avoid this
290 method, if possible.
290 method, if possible.
291 """
291 """
292 if not encoding.environ.get('GATEWAY_INTERFACE',
292 if not encoding.environ.get('GATEWAY_INTERFACE',
293 '').startswith("CGI/1."):
293 '').startswith("CGI/1."):
294 raise RuntimeError("This function is only intended to be "
294 raise RuntimeError("This function is only intended to be "
295 "called while running as a CGI script.")
295 "called while running as a CGI script.")
296 wsgicgi.launch(self)
296 wsgicgi.launch(self)
297
297
298 def __call__(self, env, respond):
298 def __call__(self, env, respond):
299 """Run the WSGI application.
299 """Run the WSGI application.
300
300
301 This may be called by multiple threads.
301 This may be called by multiple threads.
302 """
302 """
303 req = requestmod.wsgirequest(env, respond)
303 req = requestmod.wsgirequest(env, respond)
304 return self.run_wsgi(req)
304 return self.run_wsgi(req)
305
305
306 def run_wsgi(self, wsgireq):
306 def run_wsgi(self, wsgireq):
307 """Internal method to run the WSGI application.
307 """Internal method to run the WSGI application.
308
308
309 This is typically only called by Mercurial. External consumers
309 This is typically only called by Mercurial. External consumers
310 should be using instances of this class as the WSGI application.
310 should be using instances of this class as the WSGI application.
311 """
311 """
312 with self._obtainrepo() as repo:
312 with self._obtainrepo() as repo:
313 profile = repo.ui.configbool('profiling', 'enabled')
313 profile = repo.ui.configbool('profiling', 'enabled')
314 with profiling.profile(repo.ui, enabled=profile):
314 with profiling.profile(repo.ui, enabled=profile):
315 for r in self._runwsgi(wsgireq, repo):
315 for r in self._runwsgi(wsgireq, repo):
316 yield r
316 yield r
317
317
318 def _runwsgi(self, wsgireq, repo):
318 def _runwsgi(self, wsgireq, repo):
319 req = requestmod.parserequestfromenv(wsgireq.env)
319 rctx = requestcontext(self, repo)
320 rctx = requestcontext(self, repo)
320
321
321 # This state is global across all threads.
322 # This state is global across all threads.
322 encoding.encoding = rctx.config('web', 'encoding')
323 encoding.encoding = rctx.config('web', 'encoding')
323 rctx.repo.ui.environ = wsgireq.env
324 rctx.repo.ui.environ = wsgireq.env
324
325
325 if rctx.csp:
326 if rctx.csp:
326 # hgwebdir may have added CSP header. Since we generate our own,
327 # hgwebdir may have added CSP header. Since we generate our own,
327 # replace it.
328 # replace it.
328 wsgireq.headers = [h for h in wsgireq.headers
329 wsgireq.headers = [h for h in wsgireq.headers
329 if h[0] != 'Content-Security-Policy']
330 if h[0] != 'Content-Security-Policy']
330 wsgireq.headers.append(('Content-Security-Policy', rctx.csp))
331 wsgireq.headers.append(('Content-Security-Policy', rctx.csp))
331
332
332 # work with CGI variables to create coherent structure
333 wsgireq.url = pycompat.sysstr(req.apppath)
333 # use SCRIPT_NAME, PATH_INFO and QUERY_STRING as well as our REPO_NAME
334
335 wsgireq.url = wsgireq.env[r'SCRIPT_NAME']
336 if not wsgireq.url.endswith(r'/'):
337 wsgireq.url += r'/'
338 if wsgireq.env.get('REPO_NAME'):
339 wsgireq.url += wsgireq.env[r'REPO_NAME'] + r'/'
340
334
341 if r'PATH_INFO' in wsgireq.env:
335 if r'PATH_INFO' in wsgireq.env:
342 parts = wsgireq.env[r'PATH_INFO'].strip(r'/').split(r'/')
336 parts = wsgireq.env[r'PATH_INFO'].strip(r'/').split(r'/')
343 repo_parts = wsgireq.env.get(r'REPO_NAME', r'').split(r'/')
337 repo_parts = wsgireq.env.get(r'REPO_NAME', r'').split(r'/')
344 if parts[:len(repo_parts)] == repo_parts:
338 if parts[:len(repo_parts)] == repo_parts:
345 parts = parts[len(repo_parts):]
339 parts = parts[len(repo_parts):]
346 query = r'/'.join(parts)
340 query = r'/'.join(parts)
347 else:
341 else:
348 query = wsgireq.env[r'QUERY_STRING'].partition(r'&')[0]
342 query = wsgireq.env[r'QUERY_STRING'].partition(r'&')[0]
349 query = query.partition(r';')[0]
343 query = query.partition(r';')[0]
350
344
351 # Route it to a wire protocol handler if it looks like a wire protocol
345 # Route it to a wire protocol handler if it looks like a wire protocol
352 # request.
346 # request.
353 protohandler = wireprotoserver.parsehttprequest(rctx, wsgireq, query,
347 protohandler = wireprotoserver.parsehttprequest(rctx, wsgireq, query,
354 self.check_perm)
348 self.check_perm)
355
349
356 if protohandler:
350 if protohandler:
357 try:
351 try:
358 if query:
352 if query:
359 raise ErrorResponse(HTTP_NOT_FOUND)
353 raise ErrorResponse(HTTP_NOT_FOUND)
360
354
361 return protohandler['dispatch']()
355 return protohandler['dispatch']()
362 except ErrorResponse as inst:
356 except ErrorResponse as inst:
363 return protohandler['handleerror'](inst)
357 return protohandler['handleerror'](inst)
364
358
365 # translate user-visible url structure to internal structure
359 # translate user-visible url structure to internal structure
366
360
367 args = query.split(r'/', 2)
361 args = query.split(r'/', 2)
368 if 'cmd' not in wsgireq.form and args and args[0]:
362 if 'cmd' not in wsgireq.form and args and args[0]:
369 cmd = args.pop(0)
363 cmd = args.pop(0)
370 style = cmd.rfind('-')
364 style = cmd.rfind('-')
371 if style != -1:
365 if style != -1:
372 wsgireq.form['style'] = [cmd[:style]]
366 wsgireq.form['style'] = [cmd[:style]]
373 cmd = cmd[style + 1:]
367 cmd = cmd[style + 1:]
374
368
375 # avoid accepting e.g. style parameter as command
369 # avoid accepting e.g. style parameter as command
376 if util.safehasattr(webcommands, cmd):
370 if util.safehasattr(webcommands, cmd):
377 wsgireq.form['cmd'] = [cmd]
371 wsgireq.form['cmd'] = [cmd]
378
372
379 if cmd == 'static':
373 if cmd == 'static':
380 wsgireq.form['file'] = ['/'.join(args)]
374 wsgireq.form['file'] = ['/'.join(args)]
381 else:
375 else:
382 if args and args[0]:
376 if args and args[0]:
383 node = args.pop(0).replace('%2F', '/')
377 node = args.pop(0).replace('%2F', '/')
384 wsgireq.form['node'] = [node]
378 wsgireq.form['node'] = [node]
385 if args:
379 if args:
386 wsgireq.form['file'] = args
380 wsgireq.form['file'] = args
387
381
388 ua = wsgireq.env.get('HTTP_USER_AGENT', '')
382 ua = wsgireq.env.get('HTTP_USER_AGENT', '')
389 if cmd == 'rev' and 'mercurial' in ua:
383 if cmd == 'rev' and 'mercurial' in ua:
390 wsgireq.form['style'] = ['raw']
384 wsgireq.form['style'] = ['raw']
391
385
392 if cmd == 'archive':
386 if cmd == 'archive':
393 fn = wsgireq.form['node'][0]
387 fn = wsgireq.form['node'][0]
394 for type_, spec in rctx.archivespecs.iteritems():
388 for type_, spec in rctx.archivespecs.iteritems():
395 ext = spec[2]
389 ext = spec[2]
396 if fn.endswith(ext):
390 if fn.endswith(ext):
397 wsgireq.form['node'] = [fn[:-len(ext)]]
391 wsgireq.form['node'] = [fn[:-len(ext)]]
398 wsgireq.form['type'] = [type_]
392 wsgireq.form['type'] = [type_]
399 else:
393 else:
400 cmd = wsgireq.form.get('cmd', [''])[0]
394 cmd = wsgireq.form.get('cmd', [''])[0]
401
395
402 # process the web interface request
396 # process the web interface request
403
397
404 try:
398 try:
405 tmpl = rctx.templater(wsgireq)
399 tmpl = rctx.templater(wsgireq)
406 ctype = tmpl('mimetype', encoding=encoding.encoding)
400 ctype = tmpl('mimetype', encoding=encoding.encoding)
407 ctype = templater.stringify(ctype)
401 ctype = templater.stringify(ctype)
408
402
409 # check read permissions non-static content
403 # check read permissions non-static content
410 if cmd != 'static':
404 if cmd != 'static':
411 self.check_perm(rctx, wsgireq, None)
405 self.check_perm(rctx, wsgireq, None)
412
406
413 if cmd == '':
407 if cmd == '':
414 wsgireq.form['cmd'] = [tmpl.cache['default']]
408 wsgireq.form['cmd'] = [tmpl.cache['default']]
415 cmd = wsgireq.form['cmd'][0]
409 cmd = wsgireq.form['cmd'][0]
416
410
417 # Don't enable caching if using a CSP nonce because then it wouldn't
411 # Don't enable caching if using a CSP nonce because then it wouldn't
418 # be a nonce.
412 # be a nonce.
419 if rctx.configbool('web', 'cache') and not rctx.nonce:
413 if rctx.configbool('web', 'cache') and not rctx.nonce:
420 caching(self, wsgireq) # sets ETag header or raises NOT_MODIFIED
414 caching(self, wsgireq) # sets ETag header or raises NOT_MODIFIED
421 if cmd not in webcommands.__all__:
415 if cmd not in webcommands.__all__:
422 msg = 'no such method: %s' % cmd
416 msg = 'no such method: %s' % cmd
423 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
417 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
424 elif cmd == 'file' and 'raw' in wsgireq.form.get('style', []):
418 elif cmd == 'file' and 'raw' in wsgireq.form.get('style', []):
425 rctx.ctype = ctype
419 rctx.ctype = ctype
426 content = webcommands.rawfile(rctx, wsgireq, tmpl)
420 content = webcommands.rawfile(rctx, wsgireq, tmpl)
427 else:
421 else:
428 content = getattr(webcommands, cmd)(rctx, wsgireq, tmpl)
422 content = getattr(webcommands, cmd)(rctx, wsgireq, tmpl)
429 wsgireq.respond(HTTP_OK, ctype)
423 wsgireq.respond(HTTP_OK, ctype)
430
424
431 return content
425 return content
432
426
433 except (error.LookupError, error.RepoLookupError) as err:
427 except (error.LookupError, error.RepoLookupError) as err:
434 wsgireq.respond(HTTP_NOT_FOUND, ctype)
428 wsgireq.respond(HTTP_NOT_FOUND, ctype)
435 msg = pycompat.bytestr(err)
429 msg = pycompat.bytestr(err)
436 if (util.safehasattr(err, 'name') and
430 if (util.safehasattr(err, 'name') and
437 not isinstance(err, error.ManifestLookupError)):
431 not isinstance(err, error.ManifestLookupError)):
438 msg = 'revision not found: %s' % err.name
432 msg = 'revision not found: %s' % err.name
439 return tmpl('error', error=msg)
433 return tmpl('error', error=msg)
440 except (error.RepoError, error.RevlogError) as inst:
434 except (error.RepoError, error.RevlogError) as inst:
441 wsgireq.respond(HTTP_SERVER_ERROR, ctype)
435 wsgireq.respond(HTTP_SERVER_ERROR, ctype)
442 return tmpl('error', error=pycompat.bytestr(inst))
436 return tmpl('error', error=pycompat.bytestr(inst))
443 except ErrorResponse as inst:
437 except ErrorResponse as inst:
444 wsgireq.respond(inst, ctype)
438 wsgireq.respond(inst, ctype)
445 if inst.code == HTTP_NOT_MODIFIED:
439 if inst.code == HTTP_NOT_MODIFIED:
446 # Not allowed to return a body on a 304
440 # Not allowed to return a body on a 304
447 return ['']
441 return ['']
448 return tmpl('error', error=pycompat.bytestr(inst))
442 return tmpl('error', error=pycompat.bytestr(inst))
449
443
450 def check_perm(self, rctx, req, op):
444 def check_perm(self, rctx, req, op):
451 for permhook in permhooks:
445 for permhook in permhooks:
452 permhook(rctx, req, op)
446 permhook(rctx, req, op)
453
447
454 def getwebview(repo):
448 def getwebview(repo):
455 """The 'web.view' config controls changeset filter to hgweb. Possible
449 """The 'web.view' config controls changeset filter to hgweb. Possible
456 values are ``served``, ``visible`` and ``all``. Default is ``served``.
450 values are ``served``, ``visible`` and ``all``. Default is ``served``.
457 The ``served`` filter only shows changesets that can be pulled from the
451 The ``served`` filter only shows changesets that can be pulled from the
458 hgweb instance. The``visible`` filter includes secret changesets but
452 hgweb instance. The``visible`` filter includes secret changesets but
459 still excludes "hidden" one.
453 still excludes "hidden" one.
460
454
461 See the repoview module for details.
455 See the repoview module for details.
462
456
463 The option has been around undocumented since Mercurial 2.5, but no
457 The option has been around undocumented since Mercurial 2.5, but no
464 user ever asked about it. So we better keep it undocumented for now."""
458 user ever asked about it. So we better keep it undocumented for now."""
465 # experimental config: web.view
459 # experimental config: web.view
466 viewconfig = repo.ui.config('web', 'view', untrusted=True)
460 viewconfig = repo.ui.config('web', 'view', untrusted=True)
467 if viewconfig == 'all':
461 if viewconfig == 'all':
468 return repo.unfiltered()
462 return repo.unfiltered()
469 elif viewconfig in repoview.filtertable:
463 elif viewconfig in repoview.filtertable:
470 return repo.filtered(viewconfig)
464 return repo.filtered(viewconfig)
471 else:
465 else:
472 return repo.filtered('served')
466 return repo.filtered('served')
@@ -1,158 +1,280 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.validate
14
15
15 from .common import (
16 from .common import (
16 ErrorResponse,
17 ErrorResponse,
17 HTTP_NOT_MODIFIED,
18 HTTP_NOT_MODIFIED,
18 statusmessage,
19 statusmessage,
19 )
20 )
20
21
22 from ..thirdparty import (
23 attr,
24 )
21 from .. import (
25 from .. import (
22 pycompat,
26 pycompat,
23 util,
27 util,
24 )
28 )
25
29
26 shortcuts = {
30 shortcuts = {
27 'cl': [('cmd', ['changelog']), ('rev', None)],
31 'cl': [('cmd', ['changelog']), ('rev', None)],
28 'sl': [('cmd', ['shortlog']), ('rev', None)],
32 'sl': [('cmd', ['shortlog']), ('rev', None)],
29 'cs': [('cmd', ['changeset']), ('node', None)],
33 'cs': [('cmd', ['changeset']), ('node', None)],
30 'f': [('cmd', ['file']), ('filenode', None)],
34 'f': [('cmd', ['file']), ('filenode', None)],
31 'fl': [('cmd', ['filelog']), ('filenode', None)],
35 'fl': [('cmd', ['filelog']), ('filenode', None)],
32 'fd': [('cmd', ['filediff']), ('node', None)],
36 'fd': [('cmd', ['filediff']), ('node', None)],
33 'fa': [('cmd', ['annotate']), ('filenode', None)],
37 'fa': [('cmd', ['annotate']), ('filenode', None)],
34 'mf': [('cmd', ['manifest']), ('manifest', None)],
38 'mf': [('cmd', ['manifest']), ('manifest', None)],
35 'ca': [('cmd', ['archive']), ('node', None)],
39 'ca': [('cmd', ['archive']), ('node', None)],
36 'tags': [('cmd', ['tags'])],
40 'tags': [('cmd', ['tags'])],
37 'tip': [('cmd', ['changeset']), ('node', ['tip'])],
41 'tip': [('cmd', ['changeset']), ('node', ['tip'])],
38 'static': [('cmd', ['static']), ('file', None)]
42 'static': [('cmd', ['static']), ('file', None)]
39 }
43 }
40
44
41 def normalize(form):
45 def normalize(form):
42 # first expand the shortcuts
46 # first expand the shortcuts
43 for k in shortcuts:
47 for k in shortcuts:
44 if k in form:
48 if k in form:
45 for name, value in shortcuts[k]:
49 for name, value in shortcuts[k]:
46 if value is None:
50 if value is None:
47 value = form[k]
51 value = form[k]
48 form[name] = value
52 form[name] = value
49 del form[k]
53 del form[k]
50 # And strip the values
54 # And strip the values
51 bytesform = {}
55 bytesform = {}
52 for k, v in form.iteritems():
56 for k, v in form.iteritems():
53 bytesform[pycompat.bytesurl(k)] = [
57 bytesform[pycompat.bytesurl(k)] = [
54 pycompat.bytesurl(i.strip()) for i in v]
58 pycompat.bytesurl(i.strip()) for i in v]
55 return bytesform
59 return bytesform
56
60
61 @attr.s(frozen=True)
62 class parsedrequest(object):
63 """Represents a parsed WSGI request / static HTTP request parameters."""
64
65 # Full URL for this request.
66 url = attr.ib()
67 # URL without any path components. Just <proto>://<host><port>.
68 baseurl = attr.ib()
69 # Advertised URL. Like ``url`` and ``baseurl`` but uses SERVER_NAME instead
70 # of HTTP: Host header for hostname. This is likely what clients used.
71 advertisedurl = attr.ib()
72 advertisedbaseurl = attr.ib()
73 # WSGI application path.
74 apppath = attr.ib()
75 # List of path parts to be used for dispatch.
76 dispatchparts = attr.ib()
77 # URL path component (no query string) used for dispatch.
78 dispatchpath = attr.ib()
79 # Raw query string (part after "?" in URL).
80 querystring = attr.ib()
81
82 def parserequestfromenv(env):
83 """Parse URL components from environment variables.
84
85 WSGI defines request attributes via environment variables. This function
86 parses the environment variables into a data structure.
87 """
88 # PEP-0333 defines the WSGI spec and is a useful reference for this code.
89
90 # We first validate that the incoming object conforms with the WSGI spec.
91 # We only want to be dealing with spec-conforming WSGI implementations.
92 # TODO enable this once we fix internal violations.
93 #wsgiref.validate.check_environ(env)
94
95 # PEP-0333 states that environment keys and values are native strings
96 # (bytes on Python 2 and str on Python 3). The code points for the Unicode
97 # strings on Python 3 must be between \00000-\000FF. We deal with bytes
98 # in Mercurial, so mass convert string keys and values to bytes.
99 if pycompat.ispy3:
100 env = {k.encode('latin-1'): v for k, v in env.iteritems()}
101 env = {k: v.encode('latin-1') if isinstance(v, str) else v
102 for k, v in env.iteritems()}
103
104 # https://www.python.org/dev/peps/pep-0333/#environ-variables defines
105 # the environment variables.
106 # https://www.python.org/dev/peps/pep-0333/#url-reconstruction defines
107 # how URLs are reconstructed.
108 fullurl = env['wsgi.url_scheme'] + '://'
109 advertisedfullurl = fullurl
110
111 def addport(s):
112 if env['wsgi.url_scheme'] == 'https':
113 if env['SERVER_PORT'] != '443':
114 s += ':' + env['SERVER_PORT']
115 else:
116 if env['SERVER_PORT'] != '80':
117 s += ':' + env['SERVER_PORT']
118
119 return s
120
121 if env.get('HTTP_HOST'):
122 fullurl += env['HTTP_HOST']
123 else:
124 fullurl += env['SERVER_NAME']
125 fullurl = addport(fullurl)
126
127 advertisedfullurl += env['SERVER_NAME']
128 advertisedfullurl = addport(advertisedfullurl)
129
130 baseurl = fullurl
131 advertisedbaseurl = advertisedfullurl
132
133 fullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
134 advertisedfullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
135 fullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
136 advertisedfullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
137
138 if env.get('QUERY_STRING'):
139 fullurl += '?' + env['QUERY_STRING']
140 advertisedfullurl += '?' + env['QUERY_STRING']
141
142 # When dispatching requests, we look at the URL components (PATH_INFO
143 # and QUERY_STRING) after the application root (SCRIPT_NAME). But hgwebdir
144 # has the concept of "virtual" repositories. This is defined via REPO_NAME.
145 # If REPO_NAME is defined, we append it to SCRIPT_NAME to form a new app
146 # root. We also exclude its path components from PATH_INFO when resolving
147 # the dispatch path.
148
149 # TODO the use of trailing slashes in apppath is arguably wrong. We need it
150 # to appease low-level parts of hgweb_mod for now.
151 apppath = env['SCRIPT_NAME']
152 if not apppath.endswith('/'):
153 apppath += '/'
154
155 if env.get('REPO_NAME'):
156 apppath += env.get('REPO_NAME') + '/'
157
158 if 'PATH_INFO' in env:
159 dispatchparts = env['PATH_INFO'].strip('/').split('/')
160
161 # Strip out repo parts.
162 repoparts = env.get('REPO_NAME', '').split('/')
163 if dispatchparts[:len(repoparts)] == repoparts:
164 dispatchparts = dispatchparts[len(repoparts):]
165 else:
166 dispatchparts = []
167
168 dispatchpath = '/'.join(dispatchparts)
169
170 querystring = env.get('QUERY_STRING', '')
171
172 return parsedrequest(url=fullurl, baseurl=baseurl,
173 advertisedurl=advertisedfullurl,
174 advertisedbaseurl=advertisedbaseurl,
175 apppath=apppath,
176 dispatchparts=dispatchparts, dispatchpath=dispatchpath,
177 querystring=querystring)
178
57 class wsgirequest(object):
179 class wsgirequest(object):
58 """Higher-level API for a WSGI request.
180 """Higher-level API for a WSGI request.
59
181
60 WSGI applications are invoked with 2 arguments. They are used to
182 WSGI applications are invoked with 2 arguments. They are used to
61 instantiate instances of this class, which provides higher-level APIs
183 instantiate instances of this class, which provides higher-level APIs
62 for obtaining request parameters, writing HTTP output, etc.
184 for obtaining request parameters, writing HTTP output, etc.
63 """
185 """
64 def __init__(self, wsgienv, start_response):
186 def __init__(self, wsgienv, start_response):
65 version = wsgienv[r'wsgi.version']
187 version = wsgienv[r'wsgi.version']
66 if (version < (1, 0)) or (version >= (2, 0)):
188 if (version < (1, 0)) or (version >= (2, 0)):
67 raise RuntimeError("Unknown and unsupported WSGI version %d.%d"
189 raise RuntimeError("Unknown and unsupported WSGI version %d.%d"
68 % version)
190 % version)
69 self.inp = wsgienv[r'wsgi.input']
191 self.inp = wsgienv[r'wsgi.input']
70 self.err = wsgienv[r'wsgi.errors']
192 self.err = wsgienv[r'wsgi.errors']
71 self.threaded = wsgienv[r'wsgi.multithread']
193 self.threaded = wsgienv[r'wsgi.multithread']
72 self.multiprocess = wsgienv[r'wsgi.multiprocess']
194 self.multiprocess = wsgienv[r'wsgi.multiprocess']
73 self.run_once = wsgienv[r'wsgi.run_once']
195 self.run_once = wsgienv[r'wsgi.run_once']
74 self.env = wsgienv
196 self.env = wsgienv
75 self.form = normalize(cgi.parse(self.inp,
197 self.form = normalize(cgi.parse(self.inp,
76 self.env,
198 self.env,
77 keep_blank_values=1))
199 keep_blank_values=1))
78 self._start_response = start_response
200 self._start_response = start_response
79 self.server_write = None
201 self.server_write = None
80 self.headers = []
202 self.headers = []
81
203
82 def __iter__(self):
204 def __iter__(self):
83 return iter([])
205 return iter([])
84
206
85 def read(self, count=-1):
207 def read(self, count=-1):
86 return self.inp.read(count)
208 return self.inp.read(count)
87
209
88 def drain(self):
210 def drain(self):
89 '''need to read all data from request, httplib is half-duplex'''
211 '''need to read all data from request, httplib is half-duplex'''
90 length = int(self.env.get('CONTENT_LENGTH') or 0)
212 length = int(self.env.get('CONTENT_LENGTH') or 0)
91 for s in util.filechunkiter(self.inp, limit=length):
213 for s in util.filechunkiter(self.inp, limit=length):
92 pass
214 pass
93
215
94 def respond(self, status, type, filename=None, body=None):
216 def respond(self, status, type, filename=None, body=None):
95 if not isinstance(type, str):
217 if not isinstance(type, str):
96 type = pycompat.sysstr(type)
218 type = pycompat.sysstr(type)
97 if self._start_response is not None:
219 if self._start_response is not None:
98 self.headers.append((r'Content-Type', type))
220 self.headers.append((r'Content-Type', type))
99 if filename:
221 if filename:
100 filename = (filename.rpartition('/')[-1]
222 filename = (filename.rpartition('/')[-1]
101 .replace('\\', '\\\\').replace('"', '\\"'))
223 .replace('\\', '\\\\').replace('"', '\\"'))
102 self.headers.append(('Content-Disposition',
224 self.headers.append(('Content-Disposition',
103 'inline; filename="%s"' % filename))
225 'inline; filename="%s"' % filename))
104 if body is not None:
226 if body is not None:
105 self.headers.append((r'Content-Length', str(len(body))))
227 self.headers.append((r'Content-Length', str(len(body))))
106
228
107 for k, v in self.headers:
229 for k, v in self.headers:
108 if not isinstance(v, str):
230 if not isinstance(v, str):
109 raise TypeError('header value must be string: %r' % (v,))
231 raise TypeError('header value must be string: %r' % (v,))
110
232
111 if isinstance(status, ErrorResponse):
233 if isinstance(status, ErrorResponse):
112 self.headers.extend(status.headers)
234 self.headers.extend(status.headers)
113 if status.code == HTTP_NOT_MODIFIED:
235 if status.code == HTTP_NOT_MODIFIED:
114 # RFC 2616 Section 10.3.5: 304 Not Modified has cases where
236 # RFC 2616 Section 10.3.5: 304 Not Modified has cases where
115 # it MUST NOT include any headers other than these and no
237 # it MUST NOT include any headers other than these and no
116 # body
238 # body
117 self.headers = [(k, v) for (k, v) in self.headers if
239 self.headers = [(k, v) for (k, v) in self.headers if
118 k in ('Date', 'ETag', 'Expires',
240 k in ('Date', 'ETag', 'Expires',
119 'Cache-Control', 'Vary')]
241 'Cache-Control', 'Vary')]
120 status = statusmessage(status.code, pycompat.bytestr(status))
242 status = statusmessage(status.code, pycompat.bytestr(status))
121 elif status == 200:
243 elif status == 200:
122 status = '200 Script output follows'
244 status = '200 Script output follows'
123 elif isinstance(status, int):
245 elif isinstance(status, int):
124 status = statusmessage(status)
246 status = statusmessage(status)
125
247
126 self.server_write = self._start_response(
248 self.server_write = self._start_response(
127 pycompat.sysstr(status), self.headers)
249 pycompat.sysstr(status), self.headers)
128 self._start_response = None
250 self._start_response = None
129 self.headers = []
251 self.headers = []
130 if body is not None:
252 if body is not None:
131 self.write(body)
253 self.write(body)
132 self.server_write = None
254 self.server_write = None
133
255
134 def write(self, thing):
256 def write(self, thing):
135 if thing:
257 if thing:
136 try:
258 try:
137 self.server_write(thing)
259 self.server_write(thing)
138 except socket.error as inst:
260 except socket.error as inst:
139 if inst[0] != errno.ECONNRESET:
261 if inst[0] != errno.ECONNRESET:
140 raise
262 raise
141
263
142 def writelines(self, lines):
264 def writelines(self, lines):
143 for line in lines:
265 for line in lines:
144 self.write(line)
266 self.write(line)
145
267
146 def flush(self):
268 def flush(self):
147 return None
269 return None
148
270
149 def close(self):
271 def close(self):
150 return None
272 return None
151
273
152 def wsgiapplication(app_maker):
274 def wsgiapplication(app_maker):
153 '''For compatibility with old CGI scripts. A plain hgweb() or hgwebdir()
275 '''For compatibility with old CGI scripts. A plain hgweb() or hgwebdir()
154 can and should now be used as a WSGI application.'''
276 can and should now be used as a WSGI application.'''
155 application = app_maker()
277 application = app_maker()
156 def run_wsgi(env, respond):
278 def run_wsgi(env, respond):
157 return application(env, respond)
279 return application(env, respond)
158 return run_wsgi
280 return run_wsgi
General Comments 0
You need to be logged in to leave comments. Login now