##// END OF EJS Templates
hgweb: don't responsd to api requests unless feature is enabled...
Gregory Szorc -
r37111:db114320 default
parent child Browse files
Show More
@@ -1,461 +1,464
1 # hgweb/hgweb_mod.py - Web interface for a repository.
1 # hgweb/hgweb_mod.py - Web interface for a repository.
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from __future__ import absolute_import
9 from __future__ import absolute_import
10
10
11 import contextlib
11 import contextlib
12 import os
12 import os
13
13
14 from .common import (
14 from .common import (
15 ErrorResponse,
15 ErrorResponse,
16 HTTP_BAD_REQUEST,
16 HTTP_BAD_REQUEST,
17 cspvalues,
17 cspvalues,
18 permhooks,
18 permhooks,
19 statusmessage,
19 statusmessage,
20 )
20 )
21
21
22 from .. import (
22 from .. import (
23 encoding,
23 encoding,
24 error,
24 error,
25 formatter,
25 formatter,
26 hg,
26 hg,
27 hook,
27 hook,
28 profiling,
28 profiling,
29 pycompat,
29 pycompat,
30 repoview,
30 repoview,
31 templatefilters,
31 templatefilters,
32 templater,
32 templater,
33 ui as uimod,
33 ui as uimod,
34 util,
34 util,
35 wireprotoserver,
35 wireprotoserver,
36 )
36 )
37
37
38 from . import (
38 from . import (
39 request as requestmod,
39 request as requestmod,
40 webcommands,
40 webcommands,
41 webutil,
41 webutil,
42 wsgicgi,
42 wsgicgi,
43 )
43 )
44
44
45 archivespecs = util.sortdict((
45 archivespecs = util.sortdict((
46 ('zip', ('application/zip', 'zip', '.zip', None)),
46 ('zip', ('application/zip', 'zip', '.zip', None)),
47 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
47 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
48 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
48 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
49 ))
49 ))
50
50
51 def getstyle(req, configfn, templatepath):
51 def getstyle(req, configfn, templatepath):
52 styles = (
52 styles = (
53 req.qsparams.get('style', None),
53 req.qsparams.get('style', None),
54 configfn('web', 'style'),
54 configfn('web', 'style'),
55 'paper',
55 'paper',
56 )
56 )
57 return styles, templater.stylemap(styles, templatepath)
57 return styles, templater.stylemap(styles, templatepath)
58
58
59 def makebreadcrumb(url, prefix=''):
59 def makebreadcrumb(url, prefix=''):
60 '''Return a 'URL breadcrumb' list
60 '''Return a 'URL breadcrumb' list
61
61
62 A 'URL breadcrumb' is a list of URL-name pairs,
62 A 'URL breadcrumb' is a list of URL-name pairs,
63 corresponding to each of the path items on a URL.
63 corresponding to each of the path items on a URL.
64 This can be used to create path navigation entries.
64 This can be used to create path navigation entries.
65 '''
65 '''
66 if url.endswith('/'):
66 if url.endswith('/'):
67 url = url[:-1]
67 url = url[:-1]
68 if prefix:
68 if prefix:
69 url = '/' + prefix + url
69 url = '/' + prefix + url
70 relpath = url
70 relpath = url
71 if relpath.startswith('/'):
71 if relpath.startswith('/'):
72 relpath = relpath[1:]
72 relpath = relpath[1:]
73
73
74 breadcrumb = []
74 breadcrumb = []
75 urlel = url
75 urlel = url
76 pathitems = [''] + relpath.split('/')
76 pathitems = [''] + relpath.split('/')
77 for pathel in reversed(pathitems):
77 for pathel in reversed(pathitems):
78 if not pathel or not urlel:
78 if not pathel or not urlel:
79 break
79 break
80 breadcrumb.append({'url': urlel, 'name': pathel})
80 breadcrumb.append({'url': urlel, 'name': pathel})
81 urlel = os.path.dirname(urlel)
81 urlel = os.path.dirname(urlel)
82 return reversed(breadcrumb)
82 return reversed(breadcrumb)
83
83
84 class requestcontext(object):
84 class requestcontext(object):
85 """Holds state/context for an individual request.
85 """Holds state/context for an individual request.
86
86
87 Servers can be multi-threaded. Holding state on the WSGI application
87 Servers can be multi-threaded. Holding state on the WSGI application
88 is prone to race conditions. Instances of this class exist to hold
88 is prone to race conditions. Instances of this class exist to hold
89 mutable and race-free state for requests.
89 mutable and race-free state for requests.
90 """
90 """
91 def __init__(self, app, repo, req, res):
91 def __init__(self, app, repo, req, res):
92 self.repo = repo
92 self.repo = repo
93 self.reponame = app.reponame
93 self.reponame = app.reponame
94 self.req = req
94 self.req = req
95 self.res = res
95 self.res = res
96
96
97 self.archivespecs = archivespecs
97 self.archivespecs = archivespecs
98
98
99 self.maxchanges = self.configint('web', 'maxchanges')
99 self.maxchanges = self.configint('web', 'maxchanges')
100 self.stripecount = self.configint('web', 'stripes')
100 self.stripecount = self.configint('web', 'stripes')
101 self.maxshortchanges = self.configint('web', 'maxshortchanges')
101 self.maxshortchanges = self.configint('web', 'maxshortchanges')
102 self.maxfiles = self.configint('web', 'maxfiles')
102 self.maxfiles = self.configint('web', 'maxfiles')
103 self.allowpull = self.configbool('web', 'allow-pull')
103 self.allowpull = self.configbool('web', 'allow-pull')
104
104
105 # we use untrusted=False to prevent a repo owner from using
105 # we use untrusted=False to prevent a repo owner from using
106 # web.templates in .hg/hgrc to get access to any file readable
106 # web.templates in .hg/hgrc to get access to any file readable
107 # by the user running the CGI script
107 # by the user running the CGI script
108 self.templatepath = self.config('web', 'templates', untrusted=False)
108 self.templatepath = self.config('web', 'templates', untrusted=False)
109
109
110 # This object is more expensive to build than simple config values.
110 # This object is more expensive to build than simple config values.
111 # It is shared across requests. The app will replace the object
111 # It is shared across requests. The app will replace the object
112 # if it is updated. Since this is a reference and nothing should
112 # if it is updated. Since this is a reference and nothing should
113 # modify the underlying object, it should be constant for the lifetime
113 # modify the underlying object, it should be constant for the lifetime
114 # of the request.
114 # of the request.
115 self.websubtable = app.websubtable
115 self.websubtable = app.websubtable
116
116
117 self.csp, self.nonce = cspvalues(self.repo.ui)
117 self.csp, self.nonce = cspvalues(self.repo.ui)
118
118
119 # Trust the settings from the .hg/hgrc files by default.
119 # Trust the settings from the .hg/hgrc files by default.
120 def config(self, section, name, default=uimod._unset, untrusted=True):
120 def config(self, section, name, default=uimod._unset, untrusted=True):
121 return self.repo.ui.config(section, name, default,
121 return self.repo.ui.config(section, name, default,
122 untrusted=untrusted)
122 untrusted=untrusted)
123
123
124 def configbool(self, section, name, default=uimod._unset, untrusted=True):
124 def configbool(self, section, name, default=uimod._unset, untrusted=True):
125 return self.repo.ui.configbool(section, name, default,
125 return self.repo.ui.configbool(section, name, default,
126 untrusted=untrusted)
126 untrusted=untrusted)
127
127
128 def configint(self, section, name, default=uimod._unset, untrusted=True):
128 def configint(self, section, name, default=uimod._unset, untrusted=True):
129 return self.repo.ui.configint(section, name, default,
129 return self.repo.ui.configint(section, name, default,
130 untrusted=untrusted)
130 untrusted=untrusted)
131
131
132 def configlist(self, section, name, default=uimod._unset, untrusted=True):
132 def configlist(self, section, name, default=uimod._unset, untrusted=True):
133 return self.repo.ui.configlist(section, name, default,
133 return self.repo.ui.configlist(section, name, default,
134 untrusted=untrusted)
134 untrusted=untrusted)
135
135
136 def archivelist(self, nodeid):
136 def archivelist(self, nodeid):
137 allowed = self.configlist('web', 'allow_archive')
137 allowed = self.configlist('web', 'allow_archive')
138 for typ, spec in self.archivespecs.iteritems():
138 for typ, spec in self.archivespecs.iteritems():
139 if typ in allowed or self.configbool('web', 'allow%s' % typ):
139 if typ in allowed or self.configbool('web', 'allow%s' % typ):
140 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
140 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
141
141
142 def templater(self, req):
142 def templater(self, req):
143 # determine scheme, port and server name
143 # determine scheme, port and server name
144 # this is needed to create absolute urls
144 # this is needed to create absolute urls
145 logourl = self.config('web', 'logourl')
145 logourl = self.config('web', 'logourl')
146 logoimg = self.config('web', 'logoimg')
146 logoimg = self.config('web', 'logoimg')
147 staticurl = (self.config('web', 'staticurl')
147 staticurl = (self.config('web', 'staticurl')
148 or req.apppath + '/static/')
148 or req.apppath + '/static/')
149 if not staticurl.endswith('/'):
149 if not staticurl.endswith('/'):
150 staticurl += '/'
150 staticurl += '/'
151
151
152 # some functions for the templater
152 # some functions for the templater
153
153
154 def motd(**map):
154 def motd(**map):
155 yield self.config('web', 'motd')
155 yield self.config('web', 'motd')
156
156
157 # figure out which style to use
157 # figure out which style to use
158
158
159 vars = {}
159 vars = {}
160 styles, (style, mapfile) = getstyle(req, self.config,
160 styles, (style, mapfile) = getstyle(req, self.config,
161 self.templatepath)
161 self.templatepath)
162 if style == styles[0]:
162 if style == styles[0]:
163 vars['style'] = style
163 vars['style'] = style
164
164
165 sessionvars = webutil.sessionvars(vars, '?')
165 sessionvars = webutil.sessionvars(vars, '?')
166
166
167 if not self.reponame:
167 if not self.reponame:
168 self.reponame = (self.config('web', 'name', '')
168 self.reponame = (self.config('web', 'name', '')
169 or req.reponame
169 or req.reponame
170 or req.apppath
170 or req.apppath
171 or self.repo.root)
171 or self.repo.root)
172
172
173 def websubfilter(text):
173 def websubfilter(text):
174 return templatefilters.websub(text, self.websubtable)
174 return templatefilters.websub(text, self.websubtable)
175
175
176 # create the templater
176 # create the templater
177 # TODO: export all keywords: defaults = templatekw.keywords.copy()
177 # TODO: export all keywords: defaults = templatekw.keywords.copy()
178 defaults = {
178 defaults = {
179 'url': req.apppath + '/',
179 'url': req.apppath + '/',
180 'logourl': logourl,
180 'logourl': logourl,
181 'logoimg': logoimg,
181 'logoimg': logoimg,
182 'staticurl': staticurl,
182 'staticurl': staticurl,
183 'urlbase': req.advertisedbaseurl,
183 'urlbase': req.advertisedbaseurl,
184 'repo': self.reponame,
184 'repo': self.reponame,
185 'encoding': encoding.encoding,
185 'encoding': encoding.encoding,
186 'motd': motd,
186 'motd': motd,
187 'sessionvars': sessionvars,
187 'sessionvars': sessionvars,
188 'pathdef': makebreadcrumb(req.apppath),
188 'pathdef': makebreadcrumb(req.apppath),
189 'style': style,
189 'style': style,
190 'nonce': self.nonce,
190 'nonce': self.nonce,
191 }
191 }
192 tres = formatter.templateresources(self.repo.ui, self.repo)
192 tres = formatter.templateresources(self.repo.ui, self.repo)
193 tmpl = templater.templater.frommapfile(mapfile,
193 tmpl = templater.templater.frommapfile(mapfile,
194 filters={'websub': websubfilter},
194 filters={'websub': websubfilter},
195 defaults=defaults,
195 defaults=defaults,
196 resources=tres)
196 resources=tres)
197 return tmpl
197 return tmpl
198
198
199 def sendtemplate(self, name, **kwargs):
199 def sendtemplate(self, name, **kwargs):
200 """Helper function to send a response generated from a template."""
200 """Helper function to send a response generated from a template."""
201 kwargs = pycompat.byteskwargs(kwargs)
201 kwargs = pycompat.byteskwargs(kwargs)
202 self.res.setbodygen(self.tmpl.generate(name, kwargs))
202 self.res.setbodygen(self.tmpl.generate(name, kwargs))
203 return self.res.sendresponse()
203 return self.res.sendresponse()
204
204
205 class hgweb(object):
205 class hgweb(object):
206 """HTTP server for individual repositories.
206 """HTTP server for individual repositories.
207
207
208 Instances of this class serve HTTP responses for a particular
208 Instances of this class serve HTTP responses for a particular
209 repository.
209 repository.
210
210
211 Instances are typically used as WSGI applications.
211 Instances are typically used as WSGI applications.
212
212
213 Some servers are multi-threaded. On these servers, there may
213 Some servers are multi-threaded. On these servers, there may
214 be multiple active threads inside __call__.
214 be multiple active threads inside __call__.
215 """
215 """
216 def __init__(self, repo, name=None, baseui=None):
216 def __init__(self, repo, name=None, baseui=None):
217 if isinstance(repo, str):
217 if isinstance(repo, str):
218 if baseui:
218 if baseui:
219 u = baseui.copy()
219 u = baseui.copy()
220 else:
220 else:
221 u = uimod.ui.load()
221 u = uimod.ui.load()
222 r = hg.repository(u, repo)
222 r = hg.repository(u, repo)
223 else:
223 else:
224 # we trust caller to give us a private copy
224 # we trust caller to give us a private copy
225 r = repo
225 r = repo
226
226
227 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
227 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
228 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
228 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
229 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
229 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
230 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
230 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
231 # resolve file patterns relative to repo root
231 # resolve file patterns relative to repo root
232 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
232 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
233 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
233 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
234 # displaying bundling progress bar while serving feel wrong and may
234 # displaying bundling progress bar while serving feel wrong and may
235 # break some wsgi implementation.
235 # break some wsgi implementation.
236 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
236 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
237 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
237 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
238 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
238 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
239 self._lastrepo = self._repos[0]
239 self._lastrepo = self._repos[0]
240 hook.redirect(True)
240 hook.redirect(True)
241 self.reponame = name
241 self.reponame = name
242
242
243 def _webifyrepo(self, repo):
243 def _webifyrepo(self, repo):
244 repo = getwebview(repo)
244 repo = getwebview(repo)
245 self.websubtable = webutil.getwebsubs(repo)
245 self.websubtable = webutil.getwebsubs(repo)
246 return repo
246 return repo
247
247
248 @contextlib.contextmanager
248 @contextlib.contextmanager
249 def _obtainrepo(self):
249 def _obtainrepo(self):
250 """Obtain a repo unique to the caller.
250 """Obtain a repo unique to the caller.
251
251
252 Internally we maintain a stack of cachedlocalrepo instances
252 Internally we maintain a stack of cachedlocalrepo instances
253 to be handed out. If one is available, we pop it and return it,
253 to be handed out. If one is available, we pop it and return it,
254 ensuring it is up to date in the process. If one is not available,
254 ensuring it is up to date in the process. If one is not available,
255 we clone the most recently used repo instance and return it.
255 we clone the most recently used repo instance and return it.
256
256
257 It is currently possible for the stack to grow without bounds
257 It is currently possible for the stack to grow without bounds
258 if the server allows infinite threads. However, servers should
258 if the server allows infinite threads. However, servers should
259 have a thread limit, thus establishing our limit.
259 have a thread limit, thus establishing our limit.
260 """
260 """
261 if self._repos:
261 if self._repos:
262 cached = self._repos.pop()
262 cached = self._repos.pop()
263 r, created = cached.fetch()
263 r, created = cached.fetch()
264 else:
264 else:
265 cached = self._lastrepo.copy()
265 cached = self._lastrepo.copy()
266 r, created = cached.fetch()
266 r, created = cached.fetch()
267 if created:
267 if created:
268 r = self._webifyrepo(r)
268 r = self._webifyrepo(r)
269
269
270 self._lastrepo = cached
270 self._lastrepo = cached
271 self.mtime = cached.mtime
271 self.mtime = cached.mtime
272 try:
272 try:
273 yield r
273 yield r
274 finally:
274 finally:
275 self._repos.append(cached)
275 self._repos.append(cached)
276
276
277 def run(self):
277 def run(self):
278 """Start a server from CGI environment.
278 """Start a server from CGI environment.
279
279
280 Modern servers should be using WSGI and should avoid this
280 Modern servers should be using WSGI and should avoid this
281 method, if possible.
281 method, if possible.
282 """
282 """
283 if not encoding.environ.get('GATEWAY_INTERFACE',
283 if not encoding.environ.get('GATEWAY_INTERFACE',
284 '').startswith("CGI/1."):
284 '').startswith("CGI/1."):
285 raise RuntimeError("This function is only intended to be "
285 raise RuntimeError("This function is only intended to be "
286 "called while running as a CGI script.")
286 "called while running as a CGI script.")
287 wsgicgi.launch(self)
287 wsgicgi.launch(self)
288
288
289 def __call__(self, env, respond):
289 def __call__(self, env, respond):
290 """Run the WSGI application.
290 """Run the WSGI application.
291
291
292 This may be called by multiple threads.
292 This may be called by multiple threads.
293 """
293 """
294 req = requestmod.parserequestfromenv(env)
294 req = requestmod.parserequestfromenv(env)
295 res = requestmod.wsgiresponse(req, respond)
295 res = requestmod.wsgiresponse(req, respond)
296
296
297 return self.run_wsgi(req, res)
297 return self.run_wsgi(req, res)
298
298
299 def run_wsgi(self, req, res):
299 def run_wsgi(self, req, res):
300 """Internal method to run the WSGI application.
300 """Internal method to run the WSGI application.
301
301
302 This is typically only called by Mercurial. External consumers
302 This is typically only called by Mercurial. External consumers
303 should be using instances of this class as the WSGI application.
303 should be using instances of this class as the WSGI application.
304 """
304 """
305 with self._obtainrepo() as repo:
305 with self._obtainrepo() as repo:
306 profile = repo.ui.configbool('profiling', 'enabled')
306 profile = repo.ui.configbool('profiling', 'enabled')
307 with profiling.profile(repo.ui, enabled=profile):
307 with profiling.profile(repo.ui, enabled=profile):
308 for r in self._runwsgi(req, res, repo):
308 for r in self._runwsgi(req, res, repo):
309 yield r
309 yield r
310
310
311 def _runwsgi(self, req, res, repo):
311 def _runwsgi(self, req, res, repo):
312 rctx = requestcontext(self, repo, req, res)
312 rctx = requestcontext(self, repo, req, res)
313
313
314 # This state is global across all threads.
314 # This state is global across all threads.
315 encoding.encoding = rctx.config('web', 'encoding')
315 encoding.encoding = rctx.config('web', 'encoding')
316 rctx.repo.ui.environ = req.rawenv
316 rctx.repo.ui.environ = req.rawenv
317
317
318 if rctx.csp:
318 if rctx.csp:
319 # hgwebdir may have added CSP header. Since we generate our own,
319 # hgwebdir may have added CSP header. Since we generate our own,
320 # replace it.
320 # replace it.
321 res.headers['Content-Security-Policy'] = rctx.csp
321 res.headers['Content-Security-Policy'] = rctx.csp
322
322
323 # /api/* is reserved for various API implementations. Dispatch
323 # /api/* is reserved for various API implementations. Dispatch
324 # accordingly.
324 # accordingly. But URL paths can conflict with subrepos and virtual
325 if req.dispatchparts and req.dispatchparts[0] == b'api':
325 # repos in hgwebdir. So until we have a workaround for this, only
326 # expose the URLs if the feature is enabled.
327 apienabled = rctx.repo.ui.configbool('experimental', 'web.apiserver')
328 if apienabled and req.dispatchparts and req.dispatchparts[0] == b'api':
326 wireprotoserver.handlewsgiapirequest(rctx, req, res,
329 wireprotoserver.handlewsgiapirequest(rctx, req, res,
327 self.check_perm)
330 self.check_perm)
328 return res.sendresponse()
331 return res.sendresponse()
329
332
330 handled = wireprotoserver.handlewsgirequest(
333 handled = wireprotoserver.handlewsgirequest(
331 rctx, req, res, self.check_perm)
334 rctx, req, res, self.check_perm)
332 if handled:
335 if handled:
333 return res.sendresponse()
336 return res.sendresponse()
334
337
335 # Old implementations of hgweb supported dispatching the request via
338 # Old implementations of hgweb supported dispatching the request via
336 # the initial query string parameter instead of using PATH_INFO.
339 # the initial query string parameter instead of using PATH_INFO.
337 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having
340 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having
338 # a value), we use it. Otherwise fall back to the query string.
341 # a value), we use it. Otherwise fall back to the query string.
339 if req.dispatchpath is not None:
342 if req.dispatchpath is not None:
340 query = req.dispatchpath
343 query = req.dispatchpath
341 else:
344 else:
342 query = req.querystring.partition('&')[0].partition(';')[0]
345 query = req.querystring.partition('&')[0].partition(';')[0]
343
346
344 # translate user-visible url structure to internal structure
347 # translate user-visible url structure to internal structure
345
348
346 args = query.split('/', 2)
349 args = query.split('/', 2)
347 if 'cmd' not in req.qsparams and args and args[0]:
350 if 'cmd' not in req.qsparams and args and args[0]:
348 cmd = args.pop(0)
351 cmd = args.pop(0)
349 style = cmd.rfind('-')
352 style = cmd.rfind('-')
350 if style != -1:
353 if style != -1:
351 req.qsparams['style'] = cmd[:style]
354 req.qsparams['style'] = cmd[:style]
352 cmd = cmd[style + 1:]
355 cmd = cmd[style + 1:]
353
356
354 # avoid accepting e.g. style parameter as command
357 # avoid accepting e.g. style parameter as command
355 if util.safehasattr(webcommands, cmd):
358 if util.safehasattr(webcommands, cmd):
356 req.qsparams['cmd'] = cmd
359 req.qsparams['cmd'] = cmd
357
360
358 if cmd == 'static':
361 if cmd == 'static':
359 req.qsparams['file'] = '/'.join(args)
362 req.qsparams['file'] = '/'.join(args)
360 else:
363 else:
361 if args and args[0]:
364 if args and args[0]:
362 node = args.pop(0).replace('%2F', '/')
365 node = args.pop(0).replace('%2F', '/')
363 req.qsparams['node'] = node
366 req.qsparams['node'] = node
364 if args:
367 if args:
365 if 'file' in req.qsparams:
368 if 'file' in req.qsparams:
366 del req.qsparams['file']
369 del req.qsparams['file']
367 for a in args:
370 for a in args:
368 req.qsparams.add('file', a)
371 req.qsparams.add('file', a)
369
372
370 ua = req.headers.get('User-Agent', '')
373 ua = req.headers.get('User-Agent', '')
371 if cmd == 'rev' and 'mercurial' in ua:
374 if cmd == 'rev' and 'mercurial' in ua:
372 req.qsparams['style'] = 'raw'
375 req.qsparams['style'] = 'raw'
373
376
374 if cmd == 'archive':
377 if cmd == 'archive':
375 fn = req.qsparams['node']
378 fn = req.qsparams['node']
376 for type_, spec in rctx.archivespecs.iteritems():
379 for type_, spec in rctx.archivespecs.iteritems():
377 ext = spec[2]
380 ext = spec[2]
378 if fn.endswith(ext):
381 if fn.endswith(ext):
379 req.qsparams['node'] = fn[:-len(ext)]
382 req.qsparams['node'] = fn[:-len(ext)]
380 req.qsparams['type'] = type_
383 req.qsparams['type'] = type_
381 else:
384 else:
382 cmd = req.qsparams.get('cmd', '')
385 cmd = req.qsparams.get('cmd', '')
383
386
384 # process the web interface request
387 # process the web interface request
385
388
386 try:
389 try:
387 rctx.tmpl = rctx.templater(req)
390 rctx.tmpl = rctx.templater(req)
388 ctype = rctx.tmpl.render('mimetype',
391 ctype = rctx.tmpl.render('mimetype',
389 {'encoding': encoding.encoding})
392 {'encoding': encoding.encoding})
390
393
391 # check read permissions non-static content
394 # check read permissions non-static content
392 if cmd != 'static':
395 if cmd != 'static':
393 self.check_perm(rctx, req, None)
396 self.check_perm(rctx, req, None)
394
397
395 if cmd == '':
398 if cmd == '':
396 req.qsparams['cmd'] = rctx.tmpl.render('default', {})
399 req.qsparams['cmd'] = rctx.tmpl.render('default', {})
397 cmd = req.qsparams['cmd']
400 cmd = req.qsparams['cmd']
398
401
399 # Don't enable caching if using a CSP nonce because then it wouldn't
402 # Don't enable caching if using a CSP nonce because then it wouldn't
400 # be a nonce.
403 # be a nonce.
401 if rctx.configbool('web', 'cache') and not rctx.nonce:
404 if rctx.configbool('web', 'cache') and not rctx.nonce:
402 tag = 'W/"%d"' % self.mtime
405 tag = 'W/"%d"' % self.mtime
403 if req.headers.get('If-None-Match') == tag:
406 if req.headers.get('If-None-Match') == tag:
404 res.status = '304 Not Modified'
407 res.status = '304 Not Modified'
405 # Response body not allowed on 304.
408 # Response body not allowed on 304.
406 res.setbodybytes('')
409 res.setbodybytes('')
407 return res.sendresponse()
410 return res.sendresponse()
408
411
409 res.headers['ETag'] = tag
412 res.headers['ETag'] = tag
410
413
411 if cmd not in webcommands.__all__:
414 if cmd not in webcommands.__all__:
412 msg = 'no such method: %s' % cmd
415 msg = 'no such method: %s' % cmd
413 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
416 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
414 else:
417 else:
415 # Set some globals appropriate for web handlers. Commands can
418 # Set some globals appropriate for web handlers. Commands can
416 # override easily enough.
419 # override easily enough.
417 res.status = '200 Script output follows'
420 res.status = '200 Script output follows'
418 res.headers['Content-Type'] = ctype
421 res.headers['Content-Type'] = ctype
419 return getattr(webcommands, cmd)(rctx)
422 return getattr(webcommands, cmd)(rctx)
420
423
421 except (error.LookupError, error.RepoLookupError) as err:
424 except (error.LookupError, error.RepoLookupError) as err:
422 msg = pycompat.bytestr(err)
425 msg = pycompat.bytestr(err)
423 if (util.safehasattr(err, 'name') and
426 if (util.safehasattr(err, 'name') and
424 not isinstance(err, error.ManifestLookupError)):
427 not isinstance(err, error.ManifestLookupError)):
425 msg = 'revision not found: %s' % err.name
428 msg = 'revision not found: %s' % err.name
426
429
427 res.status = '404 Not Found'
430 res.status = '404 Not Found'
428 res.headers['Content-Type'] = ctype
431 res.headers['Content-Type'] = ctype
429 return rctx.sendtemplate('error', error=msg)
432 return rctx.sendtemplate('error', error=msg)
430 except (error.RepoError, error.RevlogError) as e:
433 except (error.RepoError, error.RevlogError) as e:
431 res.status = '500 Internal Server Error'
434 res.status = '500 Internal Server Error'
432 res.headers['Content-Type'] = ctype
435 res.headers['Content-Type'] = ctype
433 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
436 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
434 except ErrorResponse as e:
437 except ErrorResponse as e:
435 res.status = statusmessage(e.code, pycompat.bytestr(e))
438 res.status = statusmessage(e.code, pycompat.bytestr(e))
436 res.headers['Content-Type'] = ctype
439 res.headers['Content-Type'] = ctype
437 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
440 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
438
441
439 def check_perm(self, rctx, req, op):
442 def check_perm(self, rctx, req, op):
440 for permhook in permhooks:
443 for permhook in permhooks:
441 permhook(rctx, req, op)
444 permhook(rctx, req, op)
442
445
443 def getwebview(repo):
446 def getwebview(repo):
444 """The 'web.view' config controls changeset filter to hgweb. Possible
447 """The 'web.view' config controls changeset filter to hgweb. Possible
445 values are ``served``, ``visible`` and ``all``. Default is ``served``.
448 values are ``served``, ``visible`` and ``all``. Default is ``served``.
446 The ``served`` filter only shows changesets that can be pulled from the
449 The ``served`` filter only shows changesets that can be pulled from the
447 hgweb instance. The``visible`` filter includes secret changesets but
450 hgweb instance. The``visible`` filter includes secret changesets but
448 still excludes "hidden" one.
451 still excludes "hidden" one.
449
452
450 See the repoview module for details.
453 See the repoview module for details.
451
454
452 The option has been around undocumented since Mercurial 2.5, but no
455 The option has been around undocumented since Mercurial 2.5, but no
453 user ever asked about it. So we better keep it undocumented for now."""
456 user ever asked about it. So we better keep it undocumented for now."""
454 # experimental config: web.view
457 # experimental config: web.view
455 viewconfig = repo.ui.config('web', 'view', untrusted=True)
458 viewconfig = repo.ui.config('web', 'view', untrusted=True)
456 if viewconfig == 'all':
459 if viewconfig == 'all':
457 return repo.unfiltered()
460 return repo.unfiltered()
458 elif viewconfig in repoview.filtertable:
461 elif viewconfig in repoview.filtertable:
459 return repo.filtered(viewconfig)
462 return repo.filtered(viewconfig)
460 else:
463 else:
461 return repo.filtered('served')
464 return repo.filtered('served')
@@ -1,201 +1,291
1 $ send() {
1 $ send() {
2 > hg --verbose debugwireproto --peer raw http://$LOCALIP:$HGPORT/
2 > hg --verbose debugwireproto --peer raw http://$LOCALIP:$HGPORT/
3 > }
3 > }
4
4
5 $ hg init server
5 $ hg init server
6 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid
6 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid
7 $ cat hg.pid > $DAEMON_PIDS
7 $ cat hg.pid > $DAEMON_PIDS
8
8
9 Request to /api fails unless web.apiserver is enabled
9 Request to /api fails unless web.apiserver is enabled
10
10
11 $ send << EOF
11 $ get-with-headers.py $LOCALIP:$HGPORT api
12 > httprequest GET api
12 400 no such method: api
13 > user-agent: test
13
14 > EOF
14 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
15 using raw connection to peer
15 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US">
16 s> GET /api HTTP/1.1\r\n
16 <head>
17 s> Accept-Encoding: identity\r\n
17 <link rel="icon" href="/static/hgicon.png" type="image/png" />
18 s> user-agent: test\r\n
18 <meta name="robots" content="index, nofollow" />
19 s> host: $LOCALIP:$HGPORT\r\n (glob)
19 <link rel="stylesheet" href="/static/style-paper.css" type="text/css" />
20 s> \r\n
20 <script type="text/javascript" src="/static/mercurial.js"></script>
21 s> makefile('rb', None)
21
22 s> HTTP/1.1 404 Not Found\r\n
22 <title>$TESTTMP/server: error</title>
23 s> Server: testing stub value\r\n
23 </head>
24 s> Date: $HTTP_DATE$\r\n
24 <body>
25 s> Content-Type: text/plain\r\n
25
26 s> Content-Length: 44\r\n
26 <div class="container">
27 s> \r\n
27 <div class="menu">
28 s> Experimental API server endpoint not enabled
28 <div class="logo">
29 <a href="https://mercurial-scm.org/">
30 <img src="/static/hglogo.png" width=75 height=90 border=0 alt="mercurial" /></a>
31 </div>
32 <ul>
33 <li><a href="/shortlog">log</a></li>
34 <li><a href="/graph">graph</a></li>
35 <li><a href="/tags">tags</a></li>
36 <li><a href="/bookmarks">bookmarks</a></li>
37 <li><a href="/branches">branches</a></li>
38 </ul>
39 <ul>
40 <li><a href="/help">help</a></li>
41 </ul>
42 </div>
43
44 <div class="main">
45
46 <h2 class="breadcrumb"><a href="/">Mercurial</a> </h2>
47 <h3>error</h3>
48
49
50 <form class="search" action="/log">
51
52 <p><input name="rev" id="search1" type="text" size="30" value="" /></p>
53 <div id="hint">Find changesets by keywords (author, files, the commit message), revision
54 number or hash, or <a href="/help/revsets">revset expression</a>.</div>
55 </form>
56
57 <div class="description">
58 <p>
59 An error occurred while processing your request:
60 </p>
61 <p>
62 no such method: api
63 </p>
64 </div>
65 </div>
66 </div>
67
68
69
70 </body>
71 </html>
72
73 [1]
29
74
30 $ send << EOF
75 $ get-with-headers.py $LOCALIP:$HGPORT api/
31 > httprequest GET api/
76 400 no such method: api
32 > user-agent: test
77
33 > EOF
78 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
34 using raw connection to peer
79 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US">
35 s> GET /api/ HTTP/1.1\r\n
80 <head>
36 s> Accept-Encoding: identity\r\n
81 <link rel="icon" href="/static/hgicon.png" type="image/png" />
37 s> user-agent: test\r\n
82 <meta name="robots" content="index, nofollow" />
38 s> host: $LOCALIP:$HGPORT\r\n (glob)
83 <link rel="stylesheet" href="/static/style-paper.css" type="text/css" />
39 s> \r\n
84 <script type="text/javascript" src="/static/mercurial.js"></script>
40 s> makefile('rb', None)
85
41 s> HTTP/1.1 404 Not Found\r\n
86 <title>$TESTTMP/server: error</title>
42 s> Server: testing stub value\r\n
87 </head>
43 s> Date: $HTTP_DATE$\r\n
88 <body>
44 s> Content-Type: text/plain\r\n
89
45 s> Content-Length: 44\r\n
90 <div class="container">
46 s> \r\n
91 <div class="menu">
47 s> Experimental API server endpoint not enabled
92 <div class="logo">
93 <a href="https://mercurial-scm.org/">
94 <img src="/static/hglogo.png" width=75 height=90 border=0 alt="mercurial" /></a>
95 </div>
96 <ul>
97 <li><a href="/shortlog">log</a></li>
98 <li><a href="/graph">graph</a></li>
99 <li><a href="/tags">tags</a></li>
100 <li><a href="/bookmarks">bookmarks</a></li>
101 <li><a href="/branches">branches</a></li>
102 </ul>
103 <ul>
104 <li><a href="/help">help</a></li>
105 </ul>
106 </div>
107
108 <div class="main">
109
110 <h2 class="breadcrumb"><a href="/">Mercurial</a> </h2>
111 <h3>error</h3>
112
113
114 <form class="search" action="/log">
115
116 <p><input name="rev" id="search1" type="text" size="30" value="" /></p>
117 <div id="hint">Find changesets by keywords (author, files, the commit message), revision
118 number or hash, or <a href="/help/revsets">revset expression</a>.</div>
119 </form>
120
121 <div class="description">
122 <p>
123 An error occurred while processing your request:
124 </p>
125 <p>
126 no such method: api
127 </p>
128 </div>
129 </div>
130 </div>
131
132
133
134 </body>
135 </html>
136
137 [1]
48
138
49 Restart server with support for API server
139 Restart server with support for API server
50
140
51 $ killdaemons.py
141 $ killdaemons.py
52 $ cat > server/.hg/hgrc << EOF
142 $ cat > server/.hg/hgrc << EOF
53 > [experimental]
143 > [experimental]
54 > web.apiserver = true
144 > web.apiserver = true
55 > EOF
145 > EOF
56
146
57 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid
147 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid
58 $ cat hg.pid > $DAEMON_PIDS
148 $ cat hg.pid > $DAEMON_PIDS
59
149
60 /api lists available APIs (empty since none are available by default)
150 /api lists available APIs (empty since none are available by default)
61
151
62 $ send << EOF
152 $ send << EOF
63 > httprequest GET api
153 > httprequest GET api
64 > user-agent: test
154 > user-agent: test
65 > EOF
155 > EOF
66 using raw connection to peer
156 using raw connection to peer
67 s> GET /api HTTP/1.1\r\n
157 s> GET /api HTTP/1.1\r\n
68 s> Accept-Encoding: identity\r\n
158 s> Accept-Encoding: identity\r\n
69 s> user-agent: test\r\n
159 s> user-agent: test\r\n
70 s> host: $LOCALIP:$HGPORT\r\n (glob)
160 s> host: $LOCALIP:$HGPORT\r\n (glob)
71 s> \r\n
161 s> \r\n
72 s> makefile('rb', None)
162 s> makefile('rb', None)
73 s> HTTP/1.1 200 OK\r\n
163 s> HTTP/1.1 200 OK\r\n
74 s> Server: testing stub value\r\n
164 s> Server: testing stub value\r\n
75 s> Date: $HTTP_DATE$\r\n
165 s> Date: $HTTP_DATE$\r\n
76 s> Content-Type: text/plain\r\n
166 s> Content-Type: text/plain\r\n
77 s> Content-Length: 100\r\n
167 s> Content-Length: 100\r\n
78 s> \r\n
168 s> \r\n
79 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
169 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
80 s> \n
170 s> \n
81 s> (no available APIs)\n
171 s> (no available APIs)\n
82
172
83 $ send << EOF
173 $ send << EOF
84 > httprequest GET api/
174 > httprequest GET api/
85 > user-agent: test
175 > user-agent: test
86 > EOF
176 > EOF
87 using raw connection to peer
177 using raw connection to peer
88 s> GET /api/ HTTP/1.1\r\n
178 s> GET /api/ HTTP/1.1\r\n
89 s> Accept-Encoding: identity\r\n
179 s> Accept-Encoding: identity\r\n
90 s> user-agent: test\r\n
180 s> user-agent: test\r\n
91 s> host: $LOCALIP:$HGPORT\r\n (glob)
181 s> host: $LOCALIP:$HGPORT\r\n (glob)
92 s> \r\n
182 s> \r\n
93 s> makefile('rb', None)
183 s> makefile('rb', None)
94 s> HTTP/1.1 200 OK\r\n
184 s> HTTP/1.1 200 OK\r\n
95 s> Server: testing stub value\r\n
185 s> Server: testing stub value\r\n
96 s> Date: $HTTP_DATE$\r\n
186 s> Date: $HTTP_DATE$\r\n
97 s> Content-Type: text/plain\r\n
187 s> Content-Type: text/plain\r\n
98 s> Content-Length: 100\r\n
188 s> Content-Length: 100\r\n
99 s> \r\n
189 s> \r\n
100 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
190 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
101 s> \n
191 s> \n
102 s> (no available APIs)\n
192 s> (no available APIs)\n
103
193
104 Accessing an unknown API yields a 404
194 Accessing an unknown API yields a 404
105
195
106 $ send << EOF
196 $ send << EOF
107 > httprequest GET api/unknown
197 > httprequest GET api/unknown
108 > user-agent: test
198 > user-agent: test
109 > EOF
199 > EOF
110 using raw connection to peer
200 using raw connection to peer
111 s> GET /api/unknown HTTP/1.1\r\n
201 s> GET /api/unknown HTTP/1.1\r\n
112 s> Accept-Encoding: identity\r\n
202 s> Accept-Encoding: identity\r\n
113 s> user-agent: test\r\n
203 s> user-agent: test\r\n
114 s> host: $LOCALIP:$HGPORT\r\n (glob)
204 s> host: $LOCALIP:$HGPORT\r\n (glob)
115 s> \r\n
205 s> \r\n
116 s> makefile('rb', None)
206 s> makefile('rb', None)
117 s> HTTP/1.1 404 Not Found\r\n
207 s> HTTP/1.1 404 Not Found\r\n
118 s> Server: testing stub value\r\n
208 s> Server: testing stub value\r\n
119 s> Date: $HTTP_DATE$\r\n
209 s> Date: $HTTP_DATE$\r\n
120 s> Content-Type: text/plain\r\n
210 s> Content-Type: text/plain\r\n
121 s> Content-Length: 33\r\n
211 s> Content-Length: 33\r\n
122 s> \r\n
212 s> \r\n
123 s> Unknown API: unknown\n
213 s> Unknown API: unknown\n
124 s> Known APIs:
214 s> Known APIs:
125
215
126 Accessing a known but not enabled API yields a different error
216 Accessing a known but not enabled API yields a different error
127
217
128 $ send << EOF
218 $ send << EOF
129 > httprequest GET api/exp-http-v2-0001
219 > httprequest GET api/exp-http-v2-0001
130 > user-agent: test
220 > user-agent: test
131 > EOF
221 > EOF
132 using raw connection to peer
222 using raw connection to peer
133 s> GET /api/exp-http-v2-0001 HTTP/1.1\r\n
223 s> GET /api/exp-http-v2-0001 HTTP/1.1\r\n
134 s> Accept-Encoding: identity\r\n
224 s> Accept-Encoding: identity\r\n
135 s> user-agent: test\r\n
225 s> user-agent: test\r\n
136 s> host: $LOCALIP:$HGPORT\r\n (glob)
226 s> host: $LOCALIP:$HGPORT\r\n (glob)
137 s> \r\n
227 s> \r\n
138 s> makefile('rb', None)
228 s> makefile('rb', None)
139 s> HTTP/1.1 404 Not Found\r\n
229 s> HTTP/1.1 404 Not Found\r\n
140 s> Server: testing stub value\r\n
230 s> Server: testing stub value\r\n
141 s> Date: $HTTP_DATE$\r\n
231 s> Date: $HTTP_DATE$\r\n
142 s> Content-Type: text/plain\r\n
232 s> Content-Type: text/plain\r\n
143 s> Content-Length: 33\r\n
233 s> Content-Length: 33\r\n
144 s> \r\n
234 s> \r\n
145 s> API exp-http-v2-0001 not enabled\n
235 s> API exp-http-v2-0001 not enabled\n
146
236
147 Restart server with support for HTTP v2 API
237 Restart server with support for HTTP v2 API
148
238
149 $ killdaemons.py
239 $ killdaemons.py
150 $ cat > server/.hg/hgrc << EOF
240 $ cat > server/.hg/hgrc << EOF
151 > [experimental]
241 > [experimental]
152 > web.apiserver = true
242 > web.apiserver = true
153 > web.api.http-v2 = true
243 > web.api.http-v2 = true
154 > EOF
244 > EOF
155
245
156 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid
246 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid
157 $ cat hg.pid > $DAEMON_PIDS
247 $ cat hg.pid > $DAEMON_PIDS
158
248
159 /api lists the HTTP v2 protocol as available
249 /api lists the HTTP v2 protocol as available
160
250
161 $ send << EOF
251 $ send << EOF
162 > httprequest GET api
252 > httprequest GET api
163 > user-agent: test
253 > user-agent: test
164 > EOF
254 > EOF
165 using raw connection to peer
255 using raw connection to peer
166 s> GET /api HTTP/1.1\r\n
256 s> GET /api HTTP/1.1\r\n
167 s> Accept-Encoding: identity\r\n
257 s> Accept-Encoding: identity\r\n
168 s> user-agent: test\r\n
258 s> user-agent: test\r\n
169 s> host: $LOCALIP:$HGPORT\r\n (glob)
259 s> host: $LOCALIP:$HGPORT\r\n (glob)
170 s> \r\n
260 s> \r\n
171 s> makefile('rb', None)
261 s> makefile('rb', None)
172 s> HTTP/1.1 200 OK\r\n
262 s> HTTP/1.1 200 OK\r\n
173 s> Server: testing stub value\r\n
263 s> Server: testing stub value\r\n
174 s> Date: $HTTP_DATE$\r\n
264 s> Date: $HTTP_DATE$\r\n
175 s> Content-Type: text/plain\r\n
265 s> Content-Type: text/plain\r\n
176 s> Content-Length: 96\r\n
266 s> Content-Length: 96\r\n
177 s> \r\n
267 s> \r\n
178 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
268 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
179 s> \n
269 s> \n
180 s> exp-http-v2-0001
270 s> exp-http-v2-0001
181
271
182 $ send << EOF
272 $ send << EOF
183 > httprequest GET api/
273 > httprequest GET api/
184 > user-agent: test
274 > user-agent: test
185 > EOF
275 > EOF
186 using raw connection to peer
276 using raw connection to peer
187 s> GET /api/ HTTP/1.1\r\n
277 s> GET /api/ HTTP/1.1\r\n
188 s> Accept-Encoding: identity\r\n
278 s> Accept-Encoding: identity\r\n
189 s> user-agent: test\r\n
279 s> user-agent: test\r\n
190 s> host: $LOCALIP:$HGPORT\r\n (glob)
280 s> host: $LOCALIP:$HGPORT\r\n (glob)
191 s> \r\n
281 s> \r\n
192 s> makefile('rb', None)
282 s> makefile('rb', None)
193 s> HTTP/1.1 200 OK\r\n
283 s> HTTP/1.1 200 OK\r\n
194 s> Server: testing stub value\r\n
284 s> Server: testing stub value\r\n
195 s> Date: $HTTP_DATE$\r\n
285 s> Date: $HTTP_DATE$\r\n
196 s> Content-Type: text/plain\r\n
286 s> Content-Type: text/plain\r\n
197 s> Content-Length: 96\r\n
287 s> Content-Length: 96\r\n
198 s> \r\n
288 s> \r\n
199 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
289 s> APIs can be accessed at /api/<name>, where <name> can be one of the following:\n
200 s> \n
290 s> \n
201 s> exp-http-v2-0001
291 s> exp-http-v2-0001
General Comments 0
You need to be logged in to leave comments. Login now