##// END OF EJS Templates
hgweb: add a `message` attribute to `hgweb.common.ErrorResponse`...
Connor Sheehan -
r43192:8d9322b6 default
parent child Browse files
Show More
@@ -1,262 +1,263 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_CREATED = 201
26 HTTP_CREATED = 201
27 HTTP_NOT_MODIFIED = 304
27 HTTP_NOT_MODIFIED = 304
28 HTTP_BAD_REQUEST = 400
28 HTTP_BAD_REQUEST = 400
29 HTTP_UNAUTHORIZED = 401
29 HTTP_UNAUTHORIZED = 401
30 HTTP_FORBIDDEN = 403
30 HTTP_FORBIDDEN = 403
31 HTTP_NOT_FOUND = 404
31 HTTP_NOT_FOUND = 404
32 HTTP_METHOD_NOT_ALLOWED = 405
32 HTTP_METHOD_NOT_ALLOWED = 405
33 HTTP_NOT_ACCEPTABLE = 406
33 HTTP_NOT_ACCEPTABLE = 406
34 HTTP_UNSUPPORTED_MEDIA_TYPE = 415
34 HTTP_UNSUPPORTED_MEDIA_TYPE = 415
35 HTTP_SERVER_ERROR = 500
35 HTTP_SERVER_ERROR = 500
36
36
37
37
38 def ismember(ui, username, userlist):
38 def ismember(ui, username, userlist):
39 """Check if username is a member of userlist.
39 """Check if username is a member of userlist.
40
40
41 If userlist has a single '*' member, all users are considered members.
41 If userlist has a single '*' member, all users are considered members.
42 Can be overridden by extensions to provide more complex authorization
42 Can be overridden by extensions to provide more complex authorization
43 schemes.
43 schemes.
44 """
44 """
45 return userlist == ['*'] or username in userlist
45 return userlist == ['*'] or username in userlist
46
46
47 def checkauthz(hgweb, req, op):
47 def checkauthz(hgweb, req, op):
48 '''Check permission for operation based on request data (including
48 '''Check permission for operation based on request data (including
49 authentication info). Return if op allowed, else raise an ErrorResponse
49 authentication info). Return if op allowed, else raise an ErrorResponse
50 exception.'''
50 exception.'''
51
51
52 user = req.remoteuser
52 user = req.remoteuser
53
53
54 deny_read = hgweb.configlist('web', 'deny_read')
54 deny_read = hgweb.configlist('web', 'deny_read')
55 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
55 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
56 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
56 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
57
57
58 allow_read = hgweb.configlist('web', 'allow_read')
58 allow_read = hgweb.configlist('web', 'allow_read')
59 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
59 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
61
61
62 if op == 'pull' and not hgweb.allowpull:
62 if op == 'pull' and not hgweb.allowpull:
63 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
63 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
64 elif op == 'pull' or op is None: # op is None for interface requests
64 elif op == 'pull' or op is None: # op is None for interface requests
65 return
65 return
66
66
67 # Allow LFS uploading via PUT requests
67 # Allow LFS uploading via PUT requests
68 if op == 'upload':
68 if op == 'upload':
69 if req.method != 'PUT':
69 if req.method != 'PUT':
70 msg = 'upload requires PUT request'
70 msg = 'upload requires PUT request'
71 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
71 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
72 # enforce that you can only push using POST requests
72 # enforce that you can only push using POST requests
73 elif req.method != 'POST':
73 elif req.method != 'POST':
74 msg = 'push requires POST request'
74 msg = 'push requires POST request'
75 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
75 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
76
76
77 # require ssl by default for pushing, auth info cannot be sniffed
77 # require ssl by default for pushing, auth info cannot be sniffed
78 # and replayed
78 # and replayed
79 if hgweb.configbool('web', 'push_ssl') and req.urlscheme != 'https':
79 if hgweb.configbool('web', 'push_ssl') and req.urlscheme != 'https':
80 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
80 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
81
81
82 deny = hgweb.configlist('web', 'deny_push')
82 deny = hgweb.configlist('web', 'deny_push')
83 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
83 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
84 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
84 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
85
85
86 allow = hgweb.configlist('web', 'allow-push')
86 allow = hgweb.configlist('web', 'allow-push')
87 if not (allow and ismember(hgweb.repo.ui, user, allow)):
87 if not (allow and ismember(hgweb.repo.ui, user, allow)):
88 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
88 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
89
89
90 # Hooks for hgweb permission checks; extensions can add hooks here.
90 # Hooks for hgweb permission checks; extensions can add hooks here.
91 # Each hook is invoked like this: hook(hgweb, request, operation),
91 # Each hook is invoked like this: hook(hgweb, request, operation),
92 # where operation is either read, pull, push or upload. Hooks should either
92 # where operation is either read, pull, push or upload. Hooks should either
93 # raise an ErrorResponse exception, or just return.
93 # raise an ErrorResponse exception, or just return.
94 #
94 #
95 # It is possible to do both authentication and authorization through
95 # It is possible to do both authentication and authorization through
96 # this.
96 # this.
97 permhooks = [checkauthz]
97 permhooks = [checkauthz]
98
98
99
99
100 class ErrorResponse(Exception):
100 class ErrorResponse(Exception):
101 def __init__(self, code, message=None, headers=None):
101 def __init__(self, code, message=None, headers=None):
102 if message is None:
102 if message is None:
103 message = _statusmessage(code)
103 message = _statusmessage(code)
104 Exception.__init__(self, pycompat.sysstr(message))
104 Exception.__init__(self, pycompat.sysstr(message))
105 self.code = code
105 self.code = code
106 if headers is None:
106 if headers is None:
107 headers = []
107 headers = []
108 self.headers = headers
108 self.headers = headers
109 self.message = message
109
110
110 class continuereader(object):
111 class continuereader(object):
111 """File object wrapper to handle HTTP 100-continue.
112 """File object wrapper to handle HTTP 100-continue.
112
113
113 This is used by servers so they automatically handle Expect: 100-continue
114 This is used by servers so they automatically handle Expect: 100-continue
114 request headers. On first read of the request body, the 100 Continue
115 request headers. On first read of the request body, the 100 Continue
115 response is sent. This should trigger the client into actually sending
116 response is sent. This should trigger the client into actually sending
116 the request body.
117 the request body.
117 """
118 """
118 def __init__(self, f, write):
119 def __init__(self, f, write):
119 self.f = f
120 self.f = f
120 self._write = write
121 self._write = write
121 self.continued = False
122 self.continued = False
122
123
123 def read(self, amt=-1):
124 def read(self, amt=-1):
124 if not self.continued:
125 if not self.continued:
125 self.continued = True
126 self.continued = True
126 self._write('HTTP/1.1 100 Continue\r\n\r\n')
127 self._write('HTTP/1.1 100 Continue\r\n\r\n')
127 return self.f.read(amt)
128 return self.f.read(amt)
128
129
129 def __getattr__(self, attr):
130 def __getattr__(self, attr):
130 if attr in ('close', 'readline', 'readlines', '__iter__'):
131 if attr in ('close', 'readline', 'readlines', '__iter__'):
131 return getattr(self.f, attr)
132 return getattr(self.f, attr)
132 raise AttributeError
133 raise AttributeError
133
134
134 def _statusmessage(code):
135 def _statusmessage(code):
135 responses = httpserver.basehttprequesthandler.responses
136 responses = httpserver.basehttprequesthandler.responses
136 return pycompat.bytesurl(
137 return pycompat.bytesurl(
137 responses.get(code, (r'Error', r'Unknown error'))[0])
138 responses.get(code, (r'Error', r'Unknown error'))[0])
138
139
139 def statusmessage(code, message=None):
140 def statusmessage(code, message=None):
140 return '%d %s' % (code, message or _statusmessage(code))
141 return '%d %s' % (code, message or _statusmessage(code))
141
142
142 def get_stat(spath, fn):
143 def get_stat(spath, fn):
143 """stat fn if it exists, spath otherwise"""
144 """stat fn if it exists, spath otherwise"""
144 cl_path = os.path.join(spath, fn)
145 cl_path = os.path.join(spath, fn)
145 if os.path.exists(cl_path):
146 if os.path.exists(cl_path):
146 return os.stat(cl_path)
147 return os.stat(cl_path)
147 else:
148 else:
148 return os.stat(spath)
149 return os.stat(spath)
149
150
150 def get_mtime(spath):
151 def get_mtime(spath):
151 return get_stat(spath, "00changelog.i")[stat.ST_MTIME]
152 return get_stat(spath, "00changelog.i")[stat.ST_MTIME]
152
153
153 def ispathsafe(path):
154 def ispathsafe(path):
154 """Determine if a path is safe to use for filesystem access."""
155 """Determine if a path is safe to use for filesystem access."""
155 parts = path.split('/')
156 parts = path.split('/')
156 for part in parts:
157 for part in parts:
157 if (part in ('', pycompat.oscurdir, pycompat.ospardir) or
158 if (part in ('', pycompat.oscurdir, pycompat.ospardir) or
158 pycompat.ossep in part or
159 pycompat.ossep in part or
159 pycompat.osaltsep is not None and pycompat.osaltsep in part):
160 pycompat.osaltsep is not None and pycompat.osaltsep in part):
160 return False
161 return False
161
162
162 return True
163 return True
163
164
164 def staticfile(directory, fname, res):
165 def staticfile(directory, fname, res):
165 """return a file inside directory with guessed Content-Type header
166 """return a file inside directory with guessed Content-Type header
166
167
167 fname always uses '/' as directory separator and isn't allowed to
168 fname always uses '/' as directory separator and isn't allowed to
168 contain unusual path components.
169 contain unusual path components.
169 Content-Type is guessed using the mimetypes module.
170 Content-Type is guessed using the mimetypes module.
170 Return an empty string if fname is illegal or file not found.
171 Return an empty string if fname is illegal or file not found.
171
172
172 """
173 """
173 if not ispathsafe(fname):
174 if not ispathsafe(fname):
174 return
175 return
175
176
176 fpath = os.path.join(*fname.split('/'))
177 fpath = os.path.join(*fname.split('/'))
177 if isinstance(directory, str):
178 if isinstance(directory, str):
178 directory = [directory]
179 directory = [directory]
179 for d in directory:
180 for d in directory:
180 path = os.path.join(d, fpath)
181 path = os.path.join(d, fpath)
181 if os.path.exists(path):
182 if os.path.exists(path):
182 break
183 break
183 try:
184 try:
184 os.stat(path)
185 os.stat(path)
185 ct = pycompat.sysbytes(
186 ct = pycompat.sysbytes(
186 mimetypes.guess_type(pycompat.fsdecode(path))[0] or r"text/plain")
187 mimetypes.guess_type(pycompat.fsdecode(path))[0] or r"text/plain")
187 with open(path, 'rb') as fh:
188 with open(path, 'rb') as fh:
188 data = fh.read()
189 data = fh.read()
189
190
190 res.headers['Content-Type'] = ct
191 res.headers['Content-Type'] = ct
191 res.setbodybytes(data)
192 res.setbodybytes(data)
192 return res
193 return res
193 except TypeError:
194 except TypeError:
194 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
195 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
195 except OSError as err:
196 except OSError as err:
196 if err.errno == errno.ENOENT:
197 if err.errno == errno.ENOENT:
197 raise ErrorResponse(HTTP_NOT_FOUND)
198 raise ErrorResponse(HTTP_NOT_FOUND)
198 else:
199 else:
199 raise ErrorResponse(HTTP_SERVER_ERROR,
200 raise ErrorResponse(HTTP_SERVER_ERROR,
200 encoding.strtolocal(err.strerror))
201 encoding.strtolocal(err.strerror))
201
202
202 def paritygen(stripecount, offset=0):
203 def paritygen(stripecount, offset=0):
203 """count parity of horizontal stripes for easier reading"""
204 """count parity of horizontal stripes for easier reading"""
204 if stripecount and offset:
205 if stripecount and offset:
205 # account for offset, e.g. due to building the list in reverse
206 # account for offset, e.g. due to building the list in reverse
206 count = (stripecount + offset) % stripecount
207 count = (stripecount + offset) % stripecount
207 parity = (stripecount + offset) // stripecount & 1
208 parity = (stripecount + offset) // stripecount & 1
208 else:
209 else:
209 count = 0
210 count = 0
210 parity = 0
211 parity = 0
211 while True:
212 while True:
212 yield parity
213 yield parity
213 count += 1
214 count += 1
214 if stripecount and count >= stripecount:
215 if stripecount and count >= stripecount:
215 parity = 1 - parity
216 parity = 1 - parity
216 count = 0
217 count = 0
217
218
218 def get_contact(config):
219 def get_contact(config):
219 """Return repo contact information or empty string.
220 """Return repo contact information or empty string.
220
221
221 web.contact is the primary source, but if that is not set, try
222 web.contact is the primary source, but if that is not set, try
222 ui.username or $EMAIL as a fallback to display something useful.
223 ui.username or $EMAIL as a fallback to display something useful.
223 """
224 """
224 return (config("web", "contact") or
225 return (config("web", "contact") or
225 config("ui", "username") or
226 config("ui", "username") or
226 encoding.environ.get("EMAIL") or "")
227 encoding.environ.get("EMAIL") or "")
227
228
228 def cspvalues(ui):
229 def cspvalues(ui):
229 """Obtain the Content-Security-Policy header and nonce value.
230 """Obtain the Content-Security-Policy header and nonce value.
230
231
231 Returns a 2-tuple of the CSP header value and the nonce value.
232 Returns a 2-tuple of the CSP header value and the nonce value.
232
233
233 First value is ``None`` if CSP isn't enabled. Second value is ``None``
234 First value is ``None`` if CSP isn't enabled. Second value is ``None``
234 if CSP isn't enabled or if the CSP header doesn't need a nonce.
235 if CSP isn't enabled or if the CSP header doesn't need a nonce.
235 """
236 """
236 # Without demandimport, "import uuid" could have an immediate side-effect
237 # Without demandimport, "import uuid" could have an immediate side-effect
237 # running "ldconfig" on Linux trying to find libuuid.
238 # running "ldconfig" on Linux trying to find libuuid.
238 # With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell
239 # With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell
239 # may pollute the terminal with:
240 # may pollute the terminal with:
240 #
241 #
241 # shell-init: error retrieving current directory: getcwd: cannot access
242 # shell-init: error retrieving current directory: getcwd: cannot access
242 # parent directories: No such file or directory
243 # parent directories: No such file or directory
243 #
244 #
244 # Python >= 2.7.13 has fixed it by running "ldconfig" directly without a
245 # Python >= 2.7.13 has fixed it by running "ldconfig" directly without a
245 # shell (hg changeset a09ae70f3489).
246 # shell (hg changeset a09ae70f3489).
246 #
247 #
247 # Moved "import uuid" from here so it's executed after we know we have
248 # Moved "import uuid" from here so it's executed after we know we have
248 # a sane cwd (i.e. after dispatch.py cwd check).
249 # a sane cwd (i.e. after dispatch.py cwd check).
249 #
250 #
250 # We can move it back once we no longer need Python <= 2.7.12 support.
251 # We can move it back once we no longer need Python <= 2.7.12 support.
251 import uuid
252 import uuid
252
253
253 # Don't allow untrusted CSP setting since it be disable protections
254 # Don't allow untrusted CSP setting since it be disable protections
254 # from a trusted/global source.
255 # from a trusted/global source.
255 csp = ui.config('web', 'csp', untrusted=False)
256 csp = ui.config('web', 'csp', untrusted=False)
256 nonce = None
257 nonce = None
257
258
258 if csp and '%nonce%' in csp:
259 if csp and '%nonce%' in csp:
259 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
260 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
260 csp = csp.replace('%nonce%', nonce)
261 csp = csp.replace('%nonce%', nonce)
261
262
262 return csp, nonce
263 return csp, nonce
General Comments 0
You need to be logged in to leave comments. Login now