##// END OF EJS Templates
hgweb: inline caching() and port to modern mechanisms...
Gregory Szorc -
r36885:7ad6a275 default
parent child Browse files
Show More
@@ -1,257 +1,251 b''
1 # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod
1 # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod
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 base64
11 import base64
12 import errno
12 import errno
13 import mimetypes
13 import mimetypes
14 import os
14 import os
15 import stat
15 import stat
16
16
17 from .. import (
17 from .. import (
18 encoding,
18 encoding,
19 pycompat,
19 pycompat,
20 util,
20 util,
21 )
21 )
22
22
23 httpserver = util.httpserver
23 httpserver = util.httpserver
24
24
25 HTTP_OK = 200
25 HTTP_OK = 200
26 HTTP_NOT_MODIFIED = 304
26 HTTP_NOT_MODIFIED = 304
27 HTTP_BAD_REQUEST = 400
27 HTTP_BAD_REQUEST = 400
28 HTTP_UNAUTHORIZED = 401
28 HTTP_UNAUTHORIZED = 401
29 HTTP_FORBIDDEN = 403
29 HTTP_FORBIDDEN = 403
30 HTTP_NOT_FOUND = 404
30 HTTP_NOT_FOUND = 404
31 HTTP_METHOD_NOT_ALLOWED = 405
31 HTTP_METHOD_NOT_ALLOWED = 405
32 HTTP_SERVER_ERROR = 500
32 HTTP_SERVER_ERROR = 500
33
33
34
34
35 def ismember(ui, username, userlist):
35 def ismember(ui, username, userlist):
36 """Check if username is a member of userlist.
36 """Check if username is a member of userlist.
37
37
38 If userlist has a single '*' member, all users are considered members.
38 If userlist has a single '*' member, all users are considered members.
39 Can be overridden by extensions to provide more complex authorization
39 Can be overridden by extensions to provide more complex authorization
40 schemes.
40 schemes.
41 """
41 """
42 return userlist == ['*'] or username in userlist
42 return userlist == ['*'] or username in userlist
43
43
44 def checkauthz(hgweb, req, op):
44 def checkauthz(hgweb, req, op):
45 '''Check permission for operation based on request data (including
45 '''Check permission for operation based on request data (including
46 authentication info). Return if op allowed, else raise an ErrorResponse
46 authentication info). Return if op allowed, else raise an ErrorResponse
47 exception.'''
47 exception.'''
48
48
49 user = req.env.get(r'REMOTE_USER')
49 user = req.env.get(r'REMOTE_USER')
50
50
51 deny_read = hgweb.configlist('web', 'deny_read')
51 deny_read = hgweb.configlist('web', 'deny_read')
52 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
52 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
53 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
53 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
54
54
55 allow_read = hgweb.configlist('web', 'allow_read')
55 allow_read = hgweb.configlist('web', 'allow_read')
56 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
56 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
57 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
57 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
58
58
59 if op == 'pull' and not hgweb.allowpull:
59 if op == 'pull' and not hgweb.allowpull:
60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
61 elif op == 'pull' or op is None: # op is None for interface requests
61 elif op == 'pull' or op is None: # op is None for interface requests
62 return
62 return
63
63
64 # enforce that you can only push using POST requests
64 # enforce that you can only push using POST requests
65 if req.env[r'REQUEST_METHOD'] != r'POST':
65 if req.env[r'REQUEST_METHOD'] != r'POST':
66 msg = 'push requires POST request'
66 msg = 'push requires POST request'
67 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
67 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
68
68
69 # require ssl by default for pushing, auth info cannot be sniffed
69 # require ssl by default for pushing, auth info cannot be sniffed
70 # and replayed
70 # and replayed
71 scheme = req.env.get('wsgi.url_scheme')
71 scheme = req.env.get('wsgi.url_scheme')
72 if hgweb.configbool('web', 'push_ssl') and scheme != 'https':
72 if hgweb.configbool('web', 'push_ssl') and scheme != 'https':
73 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
73 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
74
74
75 deny = hgweb.configlist('web', 'deny_push')
75 deny = hgweb.configlist('web', 'deny_push')
76 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
76 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
77 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
77 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
78
78
79 allow = hgweb.configlist('web', 'allow-push')
79 allow = hgweb.configlist('web', 'allow-push')
80 if not (allow and ismember(hgweb.repo.ui, user, allow)):
80 if not (allow and ismember(hgweb.repo.ui, user, allow)):
81 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
81 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
82
82
83 # Hooks for hgweb permission checks; extensions can add hooks here.
83 # Hooks for hgweb permission checks; extensions can add hooks here.
84 # Each hook is invoked like this: hook(hgweb, request, operation),
84 # Each hook is invoked like this: hook(hgweb, request, operation),
85 # where operation is either read, pull or push. Hooks should either
85 # where operation is either read, pull or push. Hooks should either
86 # raise an ErrorResponse exception, or just return.
86 # raise an ErrorResponse exception, or just return.
87 #
87 #
88 # It is possible to do both authentication and authorization through
88 # It is possible to do both authentication and authorization through
89 # this.
89 # this.
90 permhooks = [checkauthz]
90 permhooks = [checkauthz]
91
91
92
92
93 class ErrorResponse(Exception):
93 class ErrorResponse(Exception):
94 def __init__(self, code, message=None, headers=None):
94 def __init__(self, code, message=None, headers=None):
95 if message is None:
95 if message is None:
96 message = _statusmessage(code)
96 message = _statusmessage(code)
97 Exception.__init__(self, pycompat.sysstr(message))
97 Exception.__init__(self, pycompat.sysstr(message))
98 self.code = code
98 self.code = code
99 if headers is None:
99 if headers is None:
100 headers = []
100 headers = []
101 self.headers = headers
101 self.headers = headers
102
102
103 class continuereader(object):
103 class continuereader(object):
104 """File object wrapper to handle HTTP 100-continue.
104 """File object wrapper to handle HTTP 100-continue.
105
105
106 This is used by servers so they automatically handle Expect: 100-continue
106 This is used by servers so they automatically handle Expect: 100-continue
107 request headers. On first read of the request body, the 100 Continue
107 request headers. On first read of the request body, the 100 Continue
108 response is sent. This should trigger the client into actually sending
108 response is sent. This should trigger the client into actually sending
109 the request body.
109 the request body.
110 """
110 """
111 def __init__(self, f, write):
111 def __init__(self, f, write):
112 self.f = f
112 self.f = f
113 self._write = write
113 self._write = write
114 self.continued = False
114 self.continued = False
115
115
116 def read(self, amt=-1):
116 def read(self, amt=-1):
117 if not self.continued:
117 if not self.continued:
118 self.continued = True
118 self.continued = True
119 self._write('HTTP/1.1 100 Continue\r\n\r\n')
119 self._write('HTTP/1.1 100 Continue\r\n\r\n')
120 return self.f.read(amt)
120 return self.f.read(amt)
121
121
122 def __getattr__(self, attr):
122 def __getattr__(self, attr):
123 if attr in ('close', 'readline', 'readlines', '__iter__'):
123 if attr in ('close', 'readline', 'readlines', '__iter__'):
124 return getattr(self.f, attr)
124 return getattr(self.f, attr)
125 raise AttributeError
125 raise AttributeError
126
126
127 def _statusmessage(code):
127 def _statusmessage(code):
128 responses = httpserver.basehttprequesthandler.responses
128 responses = httpserver.basehttprequesthandler.responses
129 return responses.get(code, ('Error', 'Unknown error'))[0]
129 return responses.get(code, ('Error', 'Unknown error'))[0]
130
130
131 def statusmessage(code, message=None):
131 def statusmessage(code, message=None):
132 return '%d %s' % (code, message or _statusmessage(code))
132 return '%d %s' % (code, message or _statusmessage(code))
133
133
134 def get_stat(spath, fn):
134 def get_stat(spath, fn):
135 """stat fn if it exists, spath otherwise"""
135 """stat fn if it exists, spath otherwise"""
136 cl_path = os.path.join(spath, fn)
136 cl_path = os.path.join(spath, fn)
137 if os.path.exists(cl_path):
137 if os.path.exists(cl_path):
138 return os.stat(cl_path)
138 return os.stat(cl_path)
139 else:
139 else:
140 return os.stat(spath)
140 return os.stat(spath)
141
141
142 def get_mtime(spath):
142 def get_mtime(spath):
143 return get_stat(spath, "00changelog.i")[stat.ST_MTIME]
143 return get_stat(spath, "00changelog.i")[stat.ST_MTIME]
144
144
145 def ispathsafe(path):
145 def ispathsafe(path):
146 """Determine if a path is safe to use for filesystem access."""
146 """Determine if a path is safe to use for filesystem access."""
147 parts = path.split('/')
147 parts = path.split('/')
148 for part in parts:
148 for part in parts:
149 if (part in ('', pycompat.oscurdir, pycompat.ospardir) or
149 if (part in ('', pycompat.oscurdir, pycompat.ospardir) or
150 pycompat.ossep in part or
150 pycompat.ossep in part or
151 pycompat.osaltsep is not None and pycompat.osaltsep in part):
151 pycompat.osaltsep is not None and pycompat.osaltsep in part):
152 return False
152 return False
153
153
154 return True
154 return True
155
155
156 def staticfile(directory, fname, req):
156 def staticfile(directory, fname, req):
157 """return a file inside directory with guessed Content-Type header
157 """return a file inside directory with guessed Content-Type header
158
158
159 fname always uses '/' as directory separator and isn't allowed to
159 fname always uses '/' as directory separator and isn't allowed to
160 contain unusual path components.
160 contain unusual path components.
161 Content-Type is guessed using the mimetypes module.
161 Content-Type is guessed using the mimetypes module.
162 Return an empty string if fname is illegal or file not found.
162 Return an empty string if fname is illegal or file not found.
163
163
164 """
164 """
165 if not ispathsafe(fname):
165 if not ispathsafe(fname):
166 return
166 return
167
167
168 fpath = os.path.join(*fname.split('/'))
168 fpath = os.path.join(*fname.split('/'))
169 if isinstance(directory, str):
169 if isinstance(directory, str):
170 directory = [directory]
170 directory = [directory]
171 for d in directory:
171 for d in directory:
172 path = os.path.join(d, fpath)
172 path = os.path.join(d, fpath)
173 if os.path.exists(path):
173 if os.path.exists(path):
174 break
174 break
175 try:
175 try:
176 os.stat(path)
176 os.stat(path)
177 ct = mimetypes.guess_type(pycompat.fsdecode(path))[0] or "text/plain"
177 ct = mimetypes.guess_type(pycompat.fsdecode(path))[0] or "text/plain"
178 with open(path, 'rb') as fh:
178 with open(path, 'rb') as fh:
179 data = fh.read()
179 data = fh.read()
180
180
181 req.respond(HTTP_OK, ct, body=data)
181 req.respond(HTTP_OK, ct, body=data)
182 except TypeError:
182 except TypeError:
183 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
183 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
184 except OSError as err:
184 except OSError as err:
185 if err.errno == errno.ENOENT:
185 if err.errno == errno.ENOENT:
186 raise ErrorResponse(HTTP_NOT_FOUND)
186 raise ErrorResponse(HTTP_NOT_FOUND)
187 else:
187 else:
188 raise ErrorResponse(HTTP_SERVER_ERROR,
188 raise ErrorResponse(HTTP_SERVER_ERROR,
189 encoding.strtolocal(err.strerror))
189 encoding.strtolocal(err.strerror))
190
190
191 def paritygen(stripecount, offset=0):
191 def paritygen(stripecount, offset=0):
192 """count parity of horizontal stripes for easier reading"""
192 """count parity of horizontal stripes for easier reading"""
193 if stripecount and offset:
193 if stripecount and offset:
194 # account for offset, e.g. due to building the list in reverse
194 # account for offset, e.g. due to building the list in reverse
195 count = (stripecount + offset) % stripecount
195 count = (stripecount + offset) % stripecount
196 parity = (stripecount + offset) // stripecount & 1
196 parity = (stripecount + offset) // stripecount & 1
197 else:
197 else:
198 count = 0
198 count = 0
199 parity = 0
199 parity = 0
200 while True:
200 while True:
201 yield parity
201 yield parity
202 count += 1
202 count += 1
203 if stripecount and count >= stripecount:
203 if stripecount and count >= stripecount:
204 parity = 1 - parity
204 parity = 1 - parity
205 count = 0
205 count = 0
206
206
207 def get_contact(config):
207 def get_contact(config):
208 """Return repo contact information or empty string.
208 """Return repo contact information or empty string.
209
209
210 web.contact is the primary source, but if that is not set, try
210 web.contact is the primary source, but if that is not set, try
211 ui.username or $EMAIL as a fallback to display something useful.
211 ui.username or $EMAIL as a fallback to display something useful.
212 """
212 """
213 return (config("web", "contact") or
213 return (config("web", "contact") or
214 config("ui", "username") or
214 config("ui", "username") or
215 encoding.environ.get("EMAIL") or "")
215 encoding.environ.get("EMAIL") or "")
216
216
217 def caching(web, req):
218 tag = r'W/"%d"' % web.mtime
219 if req.env.get('HTTP_IF_NONE_MATCH') == tag:
220 raise ErrorResponse(HTTP_NOT_MODIFIED)
221 req.headers.append(('ETag', tag))
222
223 def cspvalues(ui):
217 def cspvalues(ui):
224 """Obtain the Content-Security-Policy header and nonce value.
218 """Obtain the Content-Security-Policy header and nonce value.
225
219
226 Returns a 2-tuple of the CSP header value and the nonce value.
220 Returns a 2-tuple of the CSP header value and the nonce value.
227
221
228 First value is ``None`` if CSP isn't enabled. Second value is ``None``
222 First value is ``None`` if CSP isn't enabled. Second value is ``None``
229 if CSP isn't enabled or if the CSP header doesn't need a nonce.
223 if CSP isn't enabled or if the CSP header doesn't need a nonce.
230 """
224 """
231 # Without demandimport, "import uuid" could have an immediate side-effect
225 # Without demandimport, "import uuid" could have an immediate side-effect
232 # running "ldconfig" on Linux trying to find libuuid.
226 # running "ldconfig" on Linux trying to find libuuid.
233 # With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell
227 # With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell
234 # may pollute the terminal with:
228 # may pollute the terminal with:
235 #
229 #
236 # shell-init: error retrieving current directory: getcwd: cannot access
230 # shell-init: error retrieving current directory: getcwd: cannot access
237 # parent directories: No such file or directory
231 # parent directories: No such file or directory
238 #
232 #
239 # Python >= 2.7.13 has fixed it by running "ldconfig" directly without a
233 # Python >= 2.7.13 has fixed it by running "ldconfig" directly without a
240 # shell (hg changeset a09ae70f3489).
234 # shell (hg changeset a09ae70f3489).
241 #
235 #
242 # Moved "import uuid" from here so it's executed after we know we have
236 # Moved "import uuid" from here so it's executed after we know we have
243 # a sane cwd (i.e. after dispatch.py cwd check).
237 # a sane cwd (i.e. after dispatch.py cwd check).
244 #
238 #
245 # We can move it back once we no longer need Python <= 2.7.12 support.
239 # We can move it back once we no longer need Python <= 2.7.12 support.
246 import uuid
240 import uuid
247
241
248 # Don't allow untrusted CSP setting since it be disable protections
242 # Don't allow untrusted CSP setting since it be disable protections
249 # from a trusted/global source.
243 # from a trusted/global source.
250 csp = ui.config('web', 'csp', untrusted=False)
244 csp = ui.config('web', 'csp', untrusted=False)
251 nonce = None
245 nonce = None
252
246
253 if csp and '%nonce%' in csp:
247 if csp and '%nonce%' in csp:
254 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
248 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
255 csp = csp.replace('%nonce%', nonce)
249 csp = csp.replace('%nonce%', nonce)
256
250
257 return csp, nonce
251 return csp, nonce
@@ -1,443 +1,448 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,
22 cspvalues,
21 cspvalues,
23 permhooks,
22 permhooks,
24 )
23 )
25
24
26 from .. import (
25 from .. import (
27 encoding,
26 encoding,
28 error,
27 error,
29 formatter,
28 formatter,
30 hg,
29 hg,
31 hook,
30 hook,
32 profiling,
31 profiling,
33 pycompat,
32 pycompat,
34 repoview,
33 repoview,
35 templatefilters,
34 templatefilters,
36 templater,
35 templater,
37 ui as uimod,
36 ui as uimod,
38 util,
37 util,
39 wireprotoserver,
38 wireprotoserver,
40 )
39 )
41
40
42 from . import (
41 from . import (
43 request as requestmod,
42 request as requestmod,
44 webcommands,
43 webcommands,
45 webutil,
44 webutil,
46 wsgicgi,
45 wsgicgi,
47 )
46 )
48
47
49 archivespecs = util.sortdict((
48 archivespecs = util.sortdict((
50 ('zip', ('application/zip', 'zip', '.zip', None)),
49 ('zip', ('application/zip', 'zip', '.zip', None)),
51 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
50 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
52 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
51 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
53 ))
52 ))
54
53
55 def getstyle(req, configfn, templatepath):
54 def getstyle(req, configfn, templatepath):
56 styles = (
55 styles = (
57 req.qsparams.get('style', None),
56 req.qsparams.get('style', None),
58 configfn('web', 'style'),
57 configfn('web', 'style'),
59 'paper',
58 'paper',
60 )
59 )
61 return styles, templater.stylemap(styles, templatepath)
60 return styles, templater.stylemap(styles, templatepath)
62
61
63 def makebreadcrumb(url, prefix=''):
62 def makebreadcrumb(url, prefix=''):
64 '''Return a 'URL breadcrumb' list
63 '''Return a 'URL breadcrumb' list
65
64
66 A 'URL breadcrumb' is a list of URL-name pairs,
65 A 'URL breadcrumb' is a list of URL-name pairs,
67 corresponding to each of the path items on a URL.
66 corresponding to each of the path items on a URL.
68 This can be used to create path navigation entries.
67 This can be used to create path navigation entries.
69 '''
68 '''
70 if url.endswith('/'):
69 if url.endswith('/'):
71 url = url[:-1]
70 url = url[:-1]
72 if prefix:
71 if prefix:
73 url = '/' + prefix + url
72 url = '/' + prefix + url
74 relpath = url
73 relpath = url
75 if relpath.startswith('/'):
74 if relpath.startswith('/'):
76 relpath = relpath[1:]
75 relpath = relpath[1:]
77
76
78 breadcrumb = []
77 breadcrumb = []
79 urlel = url
78 urlel = url
80 pathitems = [''] + relpath.split('/')
79 pathitems = [''] + relpath.split('/')
81 for pathel in reversed(pathitems):
80 for pathel in reversed(pathitems):
82 if not pathel or not urlel:
81 if not pathel or not urlel:
83 break
82 break
84 breadcrumb.append({'url': urlel, 'name': pathel})
83 breadcrumb.append({'url': urlel, 'name': pathel})
85 urlel = os.path.dirname(urlel)
84 urlel = os.path.dirname(urlel)
86 return reversed(breadcrumb)
85 return reversed(breadcrumb)
87
86
88 class requestcontext(object):
87 class requestcontext(object):
89 """Holds state/context for an individual request.
88 """Holds state/context for an individual request.
90
89
91 Servers can be multi-threaded. Holding state on the WSGI application
90 Servers can be multi-threaded. Holding state on the WSGI application
92 is prone to race conditions. Instances of this class exist to hold
91 is prone to race conditions. Instances of this class exist to hold
93 mutable and race-free state for requests.
92 mutable and race-free state for requests.
94 """
93 """
95 def __init__(self, app, repo):
94 def __init__(self, app, repo):
96 self.repo = repo
95 self.repo = repo
97 self.reponame = app.reponame
96 self.reponame = app.reponame
98
97
99 self.archivespecs = archivespecs
98 self.archivespecs = archivespecs
100
99
101 self.maxchanges = self.configint('web', 'maxchanges')
100 self.maxchanges = self.configint('web', 'maxchanges')
102 self.stripecount = self.configint('web', 'stripes')
101 self.stripecount = self.configint('web', 'stripes')
103 self.maxshortchanges = self.configint('web', 'maxshortchanges')
102 self.maxshortchanges = self.configint('web', 'maxshortchanges')
104 self.maxfiles = self.configint('web', 'maxfiles')
103 self.maxfiles = self.configint('web', 'maxfiles')
105 self.allowpull = self.configbool('web', 'allow-pull')
104 self.allowpull = self.configbool('web', 'allow-pull')
106
105
107 # we use untrusted=False to prevent a repo owner from using
106 # we use untrusted=False to prevent a repo owner from using
108 # web.templates in .hg/hgrc to get access to any file readable
107 # web.templates in .hg/hgrc to get access to any file readable
109 # by the user running the CGI script
108 # by the user running the CGI script
110 self.templatepath = self.config('web', 'templates', untrusted=False)
109 self.templatepath = self.config('web', 'templates', untrusted=False)
111
110
112 # This object is more expensive to build than simple config values.
111 # This object is more expensive to build than simple config values.
113 # It is shared across requests. The app will replace the object
112 # It is shared across requests. The app will replace the object
114 # if it is updated. Since this is a reference and nothing should
113 # if it is updated. Since this is a reference and nothing should
115 # modify the underlying object, it should be constant for the lifetime
114 # modify the underlying object, it should be constant for the lifetime
116 # of the request.
115 # of the request.
117 self.websubtable = app.websubtable
116 self.websubtable = app.websubtable
118
117
119 self.csp, self.nonce = cspvalues(self.repo.ui)
118 self.csp, self.nonce = cspvalues(self.repo.ui)
120
119
121 # Trust the settings from the .hg/hgrc files by default.
120 # Trust the settings from the .hg/hgrc files by default.
122 def config(self, section, name, default=uimod._unset, untrusted=True):
121 def config(self, section, name, default=uimod._unset, untrusted=True):
123 return self.repo.ui.config(section, name, default,
122 return self.repo.ui.config(section, name, default,
124 untrusted=untrusted)
123 untrusted=untrusted)
125
124
126 def configbool(self, section, name, default=uimod._unset, untrusted=True):
125 def configbool(self, section, name, default=uimod._unset, untrusted=True):
127 return self.repo.ui.configbool(section, name, default,
126 return self.repo.ui.configbool(section, name, default,
128 untrusted=untrusted)
127 untrusted=untrusted)
129
128
130 def configint(self, section, name, default=uimod._unset, untrusted=True):
129 def configint(self, section, name, default=uimod._unset, untrusted=True):
131 return self.repo.ui.configint(section, name, default,
130 return self.repo.ui.configint(section, name, default,
132 untrusted=untrusted)
131 untrusted=untrusted)
133
132
134 def configlist(self, section, name, default=uimod._unset, untrusted=True):
133 def configlist(self, section, name, default=uimod._unset, untrusted=True):
135 return self.repo.ui.configlist(section, name, default,
134 return self.repo.ui.configlist(section, name, default,
136 untrusted=untrusted)
135 untrusted=untrusted)
137
136
138 def archivelist(self, nodeid):
137 def archivelist(self, nodeid):
139 allowed = self.configlist('web', 'allow_archive')
138 allowed = self.configlist('web', 'allow_archive')
140 for typ, spec in self.archivespecs.iteritems():
139 for typ, spec in self.archivespecs.iteritems():
141 if typ in allowed or self.configbool('web', 'allow%s' % typ):
140 if typ in allowed or self.configbool('web', 'allow%s' % typ):
142 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
141 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
143
142
144 def templater(self, req):
143 def templater(self, req):
145 # determine scheme, port and server name
144 # determine scheme, port and server name
146 # this is needed to create absolute urls
145 # this is needed to create absolute urls
147 logourl = self.config('web', 'logourl')
146 logourl = self.config('web', 'logourl')
148 logoimg = self.config('web', 'logoimg')
147 logoimg = self.config('web', 'logoimg')
149 staticurl = (self.config('web', 'staticurl')
148 staticurl = (self.config('web', 'staticurl')
150 or req.apppath + '/static/')
149 or req.apppath + '/static/')
151 if not staticurl.endswith('/'):
150 if not staticurl.endswith('/'):
152 staticurl += '/'
151 staticurl += '/'
153
152
154 # some functions for the templater
153 # some functions for the templater
155
154
156 def motd(**map):
155 def motd(**map):
157 yield self.config('web', 'motd')
156 yield self.config('web', 'motd')
158
157
159 # figure out which style to use
158 # figure out which style to use
160
159
161 vars = {}
160 vars = {}
162 styles, (style, mapfile) = getstyle(req, self.config,
161 styles, (style, mapfile) = getstyle(req, self.config,
163 self.templatepath)
162 self.templatepath)
164 if style == styles[0]:
163 if style == styles[0]:
165 vars['style'] = style
164 vars['style'] = style
166
165
167 sessionvars = webutil.sessionvars(vars, '?')
166 sessionvars = webutil.sessionvars(vars, '?')
168
167
169 if not self.reponame:
168 if not self.reponame:
170 self.reponame = (self.config('web', 'name', '')
169 self.reponame = (self.config('web', 'name', '')
171 or req.reponame
170 or req.reponame
172 or req.apppath
171 or req.apppath
173 or self.repo.root)
172 or self.repo.root)
174
173
175 def websubfilter(text):
174 def websubfilter(text):
176 return templatefilters.websub(text, self.websubtable)
175 return templatefilters.websub(text, self.websubtable)
177
176
178 # create the templater
177 # create the templater
179 # TODO: export all keywords: defaults = templatekw.keywords.copy()
178 # TODO: export all keywords: defaults = templatekw.keywords.copy()
180 defaults = {
179 defaults = {
181 'url': req.apppath + '/',
180 'url': req.apppath + '/',
182 'logourl': logourl,
181 'logourl': logourl,
183 'logoimg': logoimg,
182 'logoimg': logoimg,
184 'staticurl': staticurl,
183 'staticurl': staticurl,
185 'urlbase': req.advertisedbaseurl,
184 'urlbase': req.advertisedbaseurl,
186 'repo': self.reponame,
185 'repo': self.reponame,
187 'encoding': encoding.encoding,
186 'encoding': encoding.encoding,
188 'motd': motd,
187 'motd': motd,
189 'sessionvars': sessionvars,
188 'sessionvars': sessionvars,
190 'pathdef': makebreadcrumb(req.apppath),
189 'pathdef': makebreadcrumb(req.apppath),
191 'style': style,
190 'style': style,
192 'nonce': self.nonce,
191 'nonce': self.nonce,
193 }
192 }
194 tres = formatter.templateresources(self.repo.ui, self.repo)
193 tres = formatter.templateresources(self.repo.ui, self.repo)
195 tmpl = templater.templater.frommapfile(mapfile,
194 tmpl = templater.templater.frommapfile(mapfile,
196 filters={'websub': websubfilter},
195 filters={'websub': websubfilter},
197 defaults=defaults,
196 defaults=defaults,
198 resources=tres)
197 resources=tres)
199 return tmpl
198 return tmpl
200
199
201
200
202 class hgweb(object):
201 class hgweb(object):
203 """HTTP server for individual repositories.
202 """HTTP server for individual repositories.
204
203
205 Instances of this class serve HTTP responses for a particular
204 Instances of this class serve HTTP responses for a particular
206 repository.
205 repository.
207
206
208 Instances are typically used as WSGI applications.
207 Instances are typically used as WSGI applications.
209
208
210 Some servers are multi-threaded. On these servers, there may
209 Some servers are multi-threaded. On these servers, there may
211 be multiple active threads inside __call__.
210 be multiple active threads inside __call__.
212 """
211 """
213 def __init__(self, repo, name=None, baseui=None):
212 def __init__(self, repo, name=None, baseui=None):
214 if isinstance(repo, str):
213 if isinstance(repo, str):
215 if baseui:
214 if baseui:
216 u = baseui.copy()
215 u = baseui.copy()
217 else:
216 else:
218 u = uimod.ui.load()
217 u = uimod.ui.load()
219 r = hg.repository(u, repo)
218 r = hg.repository(u, repo)
220 else:
219 else:
221 # we trust caller to give us a private copy
220 # we trust caller to give us a private copy
222 r = repo
221 r = repo
223
222
224 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
223 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
225 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
224 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
226 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
225 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
227 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
226 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
228 # resolve file patterns relative to repo root
227 # resolve file patterns relative to repo root
229 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
228 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
230 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
229 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
231 # displaying bundling progress bar while serving feel wrong and may
230 # displaying bundling progress bar while serving feel wrong and may
232 # break some wsgi implementation.
231 # break some wsgi implementation.
233 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
232 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
234 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
233 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
235 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
234 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
236 self._lastrepo = self._repos[0]
235 self._lastrepo = self._repos[0]
237 hook.redirect(True)
236 hook.redirect(True)
238 self.reponame = name
237 self.reponame = name
239
238
240 def _webifyrepo(self, repo):
239 def _webifyrepo(self, repo):
241 repo = getwebview(repo)
240 repo = getwebview(repo)
242 self.websubtable = webutil.getwebsubs(repo)
241 self.websubtable = webutil.getwebsubs(repo)
243 return repo
242 return repo
244
243
245 @contextlib.contextmanager
244 @contextlib.contextmanager
246 def _obtainrepo(self):
245 def _obtainrepo(self):
247 """Obtain a repo unique to the caller.
246 """Obtain a repo unique to the caller.
248
247
249 Internally we maintain a stack of cachedlocalrepo instances
248 Internally we maintain a stack of cachedlocalrepo instances
250 to be handed out. If one is available, we pop it and return it,
249 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,
250 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.
251 we clone the most recently used repo instance and return it.
253
252
254 It is currently possible for the stack to grow without bounds
253 It is currently possible for the stack to grow without bounds
255 if the server allows infinite threads. However, servers should
254 if the server allows infinite threads. However, servers should
256 have a thread limit, thus establishing our limit.
255 have a thread limit, thus establishing our limit.
257 """
256 """
258 if self._repos:
257 if self._repos:
259 cached = self._repos.pop()
258 cached = self._repos.pop()
260 r, created = cached.fetch()
259 r, created = cached.fetch()
261 else:
260 else:
262 cached = self._lastrepo.copy()
261 cached = self._lastrepo.copy()
263 r, created = cached.fetch()
262 r, created = cached.fetch()
264 if created:
263 if created:
265 r = self._webifyrepo(r)
264 r = self._webifyrepo(r)
266
265
267 self._lastrepo = cached
266 self._lastrepo = cached
268 self.mtime = cached.mtime
267 self.mtime = cached.mtime
269 try:
268 try:
270 yield r
269 yield r
271 finally:
270 finally:
272 self._repos.append(cached)
271 self._repos.append(cached)
273
272
274 def run(self):
273 def run(self):
275 """Start a server from CGI environment.
274 """Start a server from CGI environment.
276
275
277 Modern servers should be using WSGI and should avoid this
276 Modern servers should be using WSGI and should avoid this
278 method, if possible.
277 method, if possible.
279 """
278 """
280 if not encoding.environ.get('GATEWAY_INTERFACE',
279 if not encoding.environ.get('GATEWAY_INTERFACE',
281 '').startswith("CGI/1."):
280 '').startswith("CGI/1."):
282 raise RuntimeError("This function is only intended to be "
281 raise RuntimeError("This function is only intended to be "
283 "called while running as a CGI script.")
282 "called while running as a CGI script.")
284 wsgicgi.launch(self)
283 wsgicgi.launch(self)
285
284
286 def __call__(self, env, respond):
285 def __call__(self, env, respond):
287 """Run the WSGI application.
286 """Run the WSGI application.
288
287
289 This may be called by multiple threads.
288 This may be called by multiple threads.
290 """
289 """
291 req = requestmod.wsgirequest(env, respond)
290 req = requestmod.wsgirequest(env, respond)
292 return self.run_wsgi(req)
291 return self.run_wsgi(req)
293
292
294 def run_wsgi(self, wsgireq):
293 def run_wsgi(self, wsgireq):
295 """Internal method to run the WSGI application.
294 """Internal method to run the WSGI application.
296
295
297 This is typically only called by Mercurial. External consumers
296 This is typically only called by Mercurial. External consumers
298 should be using instances of this class as the WSGI application.
297 should be using instances of this class as the WSGI application.
299 """
298 """
300 with self._obtainrepo() as repo:
299 with self._obtainrepo() as repo:
301 profile = repo.ui.configbool('profiling', 'enabled')
300 profile = repo.ui.configbool('profiling', 'enabled')
302 with profiling.profile(repo.ui, enabled=profile):
301 with profiling.profile(repo.ui, enabled=profile):
303 for r in self._runwsgi(wsgireq, repo):
302 for r in self._runwsgi(wsgireq, repo):
304 yield r
303 yield r
305
304
306 def _runwsgi(self, wsgireq, repo):
305 def _runwsgi(self, wsgireq, repo):
307 req = wsgireq.req
306 req = wsgireq.req
308 res = wsgireq.res
307 res = wsgireq.res
309 rctx = requestcontext(self, repo)
308 rctx = requestcontext(self, repo)
310
309
311 # This state is global across all threads.
310 # This state is global across all threads.
312 encoding.encoding = rctx.config('web', 'encoding')
311 encoding.encoding = rctx.config('web', 'encoding')
313 rctx.repo.ui.environ = wsgireq.env
312 rctx.repo.ui.environ = wsgireq.env
314
313
315 if rctx.csp:
314 if rctx.csp:
316 # hgwebdir may have added CSP header. Since we generate our own,
315 # hgwebdir may have added CSP header. Since we generate our own,
317 # replace it.
316 # replace it.
318 wsgireq.headers = [h for h in wsgireq.headers
317 wsgireq.headers = [h for h in wsgireq.headers
319 if h[0] != 'Content-Security-Policy']
318 if h[0] != 'Content-Security-Policy']
320 wsgireq.headers.append(('Content-Security-Policy', rctx.csp))
319 wsgireq.headers.append(('Content-Security-Policy', rctx.csp))
321 res.headers['Content-Security-Policy'] = rctx.csp
320 res.headers['Content-Security-Policy'] = rctx.csp
322
321
323 handled = wireprotoserver.handlewsgirequest(
322 handled = wireprotoserver.handlewsgirequest(
324 rctx, wsgireq, req, res, self.check_perm)
323 rctx, wsgireq, req, res, self.check_perm)
325 if handled:
324 if handled:
326 return res.sendresponse()
325 return res.sendresponse()
327
326
328 if req.havepathinfo:
327 if req.havepathinfo:
329 query = req.dispatchpath
328 query = req.dispatchpath
330 else:
329 else:
331 query = req.querystring.partition('&')[0].partition(';')[0]
330 query = req.querystring.partition('&')[0].partition(';')[0]
332
331
333 # translate user-visible url structure to internal structure
332 # translate user-visible url structure to internal structure
334
333
335 args = query.split('/', 2)
334 args = query.split('/', 2)
336 if 'cmd' not in req.qsparams and args and args[0]:
335 if 'cmd' not in req.qsparams and args and args[0]:
337 cmd = args.pop(0)
336 cmd = args.pop(0)
338 style = cmd.rfind('-')
337 style = cmd.rfind('-')
339 if style != -1:
338 if style != -1:
340 req.qsparams['style'] = cmd[:style]
339 req.qsparams['style'] = cmd[:style]
341 cmd = cmd[style + 1:]
340 cmd = cmd[style + 1:]
342
341
343 # avoid accepting e.g. style parameter as command
342 # avoid accepting e.g. style parameter as command
344 if util.safehasattr(webcommands, cmd):
343 if util.safehasattr(webcommands, cmd):
345 req.qsparams['cmd'] = cmd
344 req.qsparams['cmd'] = cmd
346
345
347 if cmd == 'static':
346 if cmd == 'static':
348 req.qsparams['file'] = '/'.join(args)
347 req.qsparams['file'] = '/'.join(args)
349 else:
348 else:
350 if args and args[0]:
349 if args and args[0]:
351 node = args.pop(0).replace('%2F', '/')
350 node = args.pop(0).replace('%2F', '/')
352 req.qsparams['node'] = node
351 req.qsparams['node'] = node
353 if args:
352 if args:
354 if 'file' in req.qsparams:
353 if 'file' in req.qsparams:
355 del req.qsparams['file']
354 del req.qsparams['file']
356 for a in args:
355 for a in args:
357 req.qsparams.add('file', a)
356 req.qsparams.add('file', a)
358
357
359 ua = req.headers.get('User-Agent', '')
358 ua = req.headers.get('User-Agent', '')
360 if cmd == 'rev' and 'mercurial' in ua:
359 if cmd == 'rev' and 'mercurial' in ua:
361 req.qsparams['style'] = 'raw'
360 req.qsparams['style'] = 'raw'
362
361
363 if cmd == 'archive':
362 if cmd == 'archive':
364 fn = req.qsparams['node']
363 fn = req.qsparams['node']
365 for type_, spec in rctx.archivespecs.iteritems():
364 for type_, spec in rctx.archivespecs.iteritems():
366 ext = spec[2]
365 ext = spec[2]
367 if fn.endswith(ext):
366 if fn.endswith(ext):
368 req.qsparams['node'] = fn[:-len(ext)]
367 req.qsparams['node'] = fn[:-len(ext)]
369 req.qsparams['type'] = type_
368 req.qsparams['type'] = type_
370 else:
369 else:
371 cmd = req.qsparams.get('cmd', '')
370 cmd = req.qsparams.get('cmd', '')
372
371
373 # process the web interface request
372 # process the web interface request
374
373
375 try:
374 try:
376 tmpl = rctx.templater(req)
375 tmpl = rctx.templater(req)
377 ctype = tmpl('mimetype', encoding=encoding.encoding)
376 ctype = tmpl('mimetype', encoding=encoding.encoding)
378 ctype = templater.stringify(ctype)
377 ctype = templater.stringify(ctype)
379
378
380 # check read permissions non-static content
379 # check read permissions non-static content
381 if cmd != 'static':
380 if cmd != 'static':
382 self.check_perm(rctx, wsgireq, None)
381 self.check_perm(rctx, wsgireq, None)
383
382
384 if cmd == '':
383 if cmd == '':
385 req.qsparams['cmd'] = tmpl.cache['default']
384 req.qsparams['cmd'] = tmpl.cache['default']
386 cmd = req.qsparams['cmd']
385 cmd = req.qsparams['cmd']
387
386
388 # Don't enable caching if using a CSP nonce because then it wouldn't
387 # Don't enable caching if using a CSP nonce because then it wouldn't
389 # be a nonce.
388 # be a nonce.
390 if rctx.configbool('web', 'cache') and not rctx.nonce:
389 if rctx.configbool('web', 'cache') and not rctx.nonce:
391 caching(self, wsgireq) # sets ETag header or raises NOT_MODIFIED
390 tag = 'W/"%d"' % self.mtime
391 if req.headers.get('If-None-Match') == tag:
392 raise ErrorResponse(HTTP_NOT_MODIFIED)
393
394 wsgireq.headers.append((r'ETag', pycompat.sysstr(tag)))
395 res.headers['ETag'] = tag
396
392 if cmd not in webcommands.__all__:
397 if cmd not in webcommands.__all__:
393 msg = 'no such method: %s' % cmd
398 msg = 'no such method: %s' % cmd
394 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
399 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
395 elif cmd == 'file' and req.qsparams.get('style') == 'raw':
400 elif cmd == 'file' and req.qsparams.get('style') == 'raw':
396 rctx.ctype = ctype
401 rctx.ctype = ctype
397 content = webcommands.rawfile(rctx, wsgireq, tmpl)
402 content = webcommands.rawfile(rctx, wsgireq, tmpl)
398 else:
403 else:
399 content = getattr(webcommands, cmd)(rctx, wsgireq, tmpl)
404 content = getattr(webcommands, cmd)(rctx, wsgireq, tmpl)
400 wsgireq.respond(HTTP_OK, ctype)
405 wsgireq.respond(HTTP_OK, ctype)
401
406
402 return content
407 return content
403
408
404 except (error.LookupError, error.RepoLookupError) as err:
409 except (error.LookupError, error.RepoLookupError) as err:
405 wsgireq.respond(HTTP_NOT_FOUND, ctype)
410 wsgireq.respond(HTTP_NOT_FOUND, ctype)
406 msg = pycompat.bytestr(err)
411 msg = pycompat.bytestr(err)
407 if (util.safehasattr(err, 'name') and
412 if (util.safehasattr(err, 'name') and
408 not isinstance(err, error.ManifestLookupError)):
413 not isinstance(err, error.ManifestLookupError)):
409 msg = 'revision not found: %s' % err.name
414 msg = 'revision not found: %s' % err.name
410 return tmpl('error', error=msg)
415 return tmpl('error', error=msg)
411 except (error.RepoError, error.RevlogError) as inst:
416 except (error.RepoError, error.RevlogError) as inst:
412 wsgireq.respond(HTTP_SERVER_ERROR, ctype)
417 wsgireq.respond(HTTP_SERVER_ERROR, ctype)
413 return tmpl('error', error=pycompat.bytestr(inst))
418 return tmpl('error', error=pycompat.bytestr(inst))
414 except ErrorResponse as inst:
419 except ErrorResponse as inst:
415 wsgireq.respond(inst, ctype)
420 wsgireq.respond(inst, ctype)
416 if inst.code == HTTP_NOT_MODIFIED:
421 if inst.code == HTTP_NOT_MODIFIED:
417 # Not allowed to return a body on a 304
422 # Not allowed to return a body on a 304
418 return ['']
423 return ['']
419 return tmpl('error', error=pycompat.bytestr(inst))
424 return tmpl('error', error=pycompat.bytestr(inst))
420
425
421 def check_perm(self, rctx, req, op):
426 def check_perm(self, rctx, req, op):
422 for permhook in permhooks:
427 for permhook in permhooks:
423 permhook(rctx, req, op)
428 permhook(rctx, req, op)
424
429
425 def getwebview(repo):
430 def getwebview(repo):
426 """The 'web.view' config controls changeset filter to hgweb. Possible
431 """The 'web.view' config controls changeset filter to hgweb. Possible
427 values are ``served``, ``visible`` and ``all``. Default is ``served``.
432 values are ``served``, ``visible`` and ``all``. Default is ``served``.
428 The ``served`` filter only shows changesets that can be pulled from the
433 The ``served`` filter only shows changesets that can be pulled from the
429 hgweb instance. The``visible`` filter includes secret changesets but
434 hgweb instance. The``visible`` filter includes secret changesets but
430 still excludes "hidden" one.
435 still excludes "hidden" one.
431
436
432 See the repoview module for details.
437 See the repoview module for details.
433
438
434 The option has been around undocumented since Mercurial 2.5, but no
439 The option has been around undocumented since Mercurial 2.5, but no
435 user ever asked about it. So we better keep it undocumented for now."""
440 user ever asked about it. So we better keep it undocumented for now."""
436 # experimental config: web.view
441 # experimental config: web.view
437 viewconfig = repo.ui.config('web', 'view', untrusted=True)
442 viewconfig = repo.ui.config('web', 'view', untrusted=True)
438 if viewconfig == 'all':
443 if viewconfig == 'all':
439 return repo.unfiltered()
444 return repo.unfiltered()
440 elif viewconfig in repoview.filtertable:
445 elif viewconfig in repoview.filtertable:
441 return repo.filtered(viewconfig)
446 return repo.filtered(viewconfig)
442 else:
447 else:
443 return repo.filtered('served')
448 return repo.filtered('served')
General Comments 0
You need to be logged in to leave comments. Login now