common.py
249 lines
| 8.2 KiB
| text/x-python
|
PythonLexer
|
r2391 | # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod | ||
|
r2356 | # | ||
# Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net> | ||||
|
r2859 | # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com> | ||
|
r2356 | # | ||
|
r8225 | # This software may be used and distributed according to the terms of the | ||
|
r10263 | # GNU General Public License version 2 or any later version. | ||
|
r2356 | |||
|
r27046 | from __future__ import absolute_import | ||
|
r30766 | import base64 | ||
|
r27046 | import errno | ||
import mimetypes | ||||
import os | ||||
|
r5561 | |||
|
r30615 | from .. import ( | ||
|
r30636 | encoding, | ||
|
r30615 | pycompat, | ||
util, | ||||
) | ||||
|
r29566 | |||
httpserver = util.httpserver | ||||
|
r5993 | HTTP_OK = 200 | ||
|
r12183 | HTTP_NOT_MODIFIED = 304 | ||
|
r5993 | HTTP_BAD_REQUEST = 400 | ||
|
r6926 | HTTP_UNAUTHORIZED = 401 | ||
|
r7029 | HTTP_FORBIDDEN = 403 | ||
|
r5993 | HTTP_NOT_FOUND = 404 | ||
|
r6926 | HTTP_METHOD_NOT_ALLOWED = 405 | ||
|
r5993 | HTTP_SERVER_ERROR = 500 | ||
|
r9910 | |||
|
r19032 | def ismember(ui, username, userlist): | ||
"""Check if username is a member of userlist. | ||||
If userlist has a single '*' member, all users are considered members. | ||||
|
r19951 | Can be overridden by extensions to provide more complex authorization | ||
|
r19032 | schemes. | ||
""" | ||||
return userlist == ['*'] or username in userlist | ||||
|
r9910 | def checkauthz(hgweb, req, op): | ||
'''Check permission for operation based on request data (including | ||||
authentication info). Return if op allowed, else raise an ErrorResponse | ||||
exception.''' | ||||
|
r36310 | user = req.env.get(r'REMOTE_USER') | ||
|
r9910 | |||
deny_read = hgweb.configlist('web', 'deny_read') | ||||
|
r19032 | if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)): | ||
|
r9910 | raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized') | ||
allow_read = hgweb.configlist('web', 'allow_read') | ||||
|
r19032 | if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)): | ||
|
r9910 | raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized') | ||
if op == 'pull' and not hgweb.allowpull: | ||||
raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized') | ||||
elif op == 'pull' or op is None: # op is None for interface requests | ||||
return | ||||
# enforce that you can only push using POST requests | ||||
|
r36310 | if req.env[r'REQUEST_METHOD'] != r'POST': | ||
|
r9910 | msg = 'push requires POST request' | ||
raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg) | ||||
# require ssl by default for pushing, auth info cannot be sniffed | ||||
# and replayed | ||||
scheme = req.env.get('wsgi.url_scheme') | ||||
|
r34586 | if hgweb.configbool('web', 'push_ssl') and scheme != 'https': | ||
|
r17456 | raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required') | ||
|
r9910 | |||
deny = hgweb.configlist('web', 'deny_push') | ||||
|
r19032 | if deny and (not user or ismember(hgweb.repo.ui, user, deny)): | ||
|
r9910 | raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized') | ||
|
r35029 | allow = hgweb.configlist('web', 'allow-push') | ||
|
r19032 | if not (allow and ismember(hgweb.repo.ui, user, allow)): | ||
|
r9910 | raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized') | ||
|
r14058 | # Hooks for hgweb permission checks; extensions can add hooks here. | ||
# Each hook is invoked like this: hook(hgweb, request, operation), | ||||
# where operation is either read, pull or push. Hooks should either | ||||
# raise an ErrorResponse exception, or just return. | ||||
# | ||||
# It is possible to do both authentication and authorization through | ||||
# this. | ||||
permhooks = [checkauthz] | ||||
|
r9910 | |||
|
r5561 | class ErrorResponse(Exception): | ||
|
r31390 | def __init__(self, code, message=None, headers=None): | ||
|
r13444 | if message is None: | ||
message = _statusmessage(code) | ||||
|
r36447 | Exception.__init__(self, pycompat.sysstr(message)) | ||
|
r5561 | self.code = code | ||
|
r31435 | if headers is None: | ||
headers = [] | ||||
self.headers = headers | ||||
|
r5563 | |||
|
r13570 | class continuereader(object): | ||
def __init__(self, f, write): | ||||
self.f = f | ||||
self._write = write | ||||
self.continued = False | ||||
def read(self, amt=-1): | ||||
if not self.continued: | ||||
self.continued = True | ||||
self._write('HTTP/1.1 100 Continue\r\n\r\n') | ||||
return self.f.read(amt) | ||||
def __getattr__(self, attr): | ||||
if attr in ('close', 'readline', 'readlines', '__iter__'): | ||||
return getattr(self.f, attr) | ||||
|
r16687 | raise AttributeError | ||
|
r5563 | |||
def _statusmessage(code): | ||||
|
r29566 | responses = httpserver.basehttprequesthandler.responses | ||
|
r5563 | return responses.get(code, ('Error', 'Unknown error'))[0] | ||
|
r5760 | |||
|
r9694 | def statusmessage(code, message=None): | ||
return '%d %s' % (code, message or _statusmessage(code)) | ||||
|
r2356 | |||
|
r25717 | def get_stat(spath, fn): | ||
"""stat fn if it exists, spath otherwise""" | ||||
|
r22577 | cl_path = os.path.join(spath, fn) | ||
|
r3853 | if os.path.exists(cl_path): | ||
|
r13958 | return os.stat(cl_path) | ||
|
r2356 | else: | ||
|
r13958 | return os.stat(spath) | ||
def get_mtime(spath): | ||||
|
r25717 | return get_stat(spath, "00changelog.i").st_mtime | ||
|
r2356 | |||
|
r31790 | def ispathsafe(path): | ||
"""Determine if a path is safe to use for filesystem access.""" | ||||
parts = path.split('/') | ||||
for part in parts: | ||||
|
r36666 | if (part in ('', pycompat.oscurdir, pycompat.ospardir) or | ||
|
r31790 | pycompat.ossep in part or | ||
pycompat.osaltsep is not None and pycompat.osaltsep in part): | ||||
return False | ||||
return True | ||||
|
r2514 | def staticfile(directory, fname, req): | ||
|
r5930 | """return a file inside directory with guessed Content-Type header | ||
|
r2356 | |||
fname always uses '/' as directory separator and isn't allowed to | ||||
contain unusual path components. | ||||
|
r5930 | Content-Type is guessed using the mimetypes module. | ||
|
r2356 | Return an empty string if fname is illegal or file not found. | ||
""" | ||||
|
r31790 | if not ispathsafe(fname): | ||
return | ||||
fpath = os.path.join(*fname.split('/')) | ||||
|
r7288 | if isinstance(directory, str): | ||
directory = [directory] | ||||
for d in directory: | ||||
path = os.path.join(d, fpath) | ||||
if os.path.exists(path): | ||||
break | ||||
|
r2356 | try: | ||
os.stat(path) | ||||
|
r34722 | ct = mimetypes.guess_type(pycompat.fsdecode(path))[0] or "text/plain" | ||
|
r31789 | with open(path, 'rb') as fh: | ||
data = fh.read() | ||||
|
r18352 | req.respond(HTTP_OK, ct, body=data) | ||
|
r5561 | except TypeError: | ||
|
r8761 | raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename') | ||
|
r25660 | except OSError as err: | ||
|
r5561 | if err.errno == errno.ENOENT: | ||
|
r5993 | raise ErrorResponse(HTTP_NOT_FOUND) | ||
|
r5561 | else: | ||
|
r34024 | raise ErrorResponse(HTTP_SERVER_ERROR, | ||
encoding.strtolocal(err.strerror)) | ||||
|
r3276 | |||
|
r4462 | def paritygen(stripecount, offset=0): | ||
"""count parity of horizontal stripes for easier reading""" | ||||
if stripecount and offset: | ||||
# account for offset, e.g. due to building the list in reverse | ||||
count = (stripecount + offset) % stripecount | ||||
|
r36415 | parity = (stripecount + offset) // stripecount & 1 | ||
|
r4462 | else: | ||
count = 0 | ||||
parity = 0 | ||||
while True: | ||||
yield parity | ||||
count += 1 | ||||
if stripecount and count >= stripecount: | ||||
parity = 1 - parity | ||||
count = 0 | ||||
|
r5779 | def get_contact(config): | ||
"""Return repo contact information or empty string. | ||||
web.contact is the primary source, but if that is not set, try | ||||
ui.username or $EMAIL as a fallback to display something useful. | ||||
""" | ||||
return (config("web", "contact") or | ||||
config("ui", "username") or | ||||
|
r30636 | encoding.environ.get("EMAIL") or "") | ||
|
r12183 | |||
def caching(web, req): | ||||
|
r34512 | tag = r'W/"%d"' % web.mtime | ||
|
r12183 | if req.env.get('HTTP_IF_NONE_MATCH') == tag: | ||
raise ErrorResponse(HTTP_NOT_MODIFIED) | ||||
req.headers.append(('ETag', tag)) | ||||
|
r30766 | |||
def cspvalues(ui): | ||||
"""Obtain the Content-Security-Policy header and nonce value. | ||||
Returns a 2-tuple of the CSP header value and the nonce value. | ||||
First value is ``None`` if CSP isn't enabled. Second value is ``None`` | ||||
if CSP isn't enabled or if the CSP header doesn't need a nonce. | ||||
""" | ||||
|
r34644 | # Without demandimport, "import uuid" could have an immediate side-effect | ||
# running "ldconfig" on Linux trying to find libuuid. | ||||
# With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell | ||||
# may pollute the terminal with: | ||||
# | ||||
# shell-init: error retrieving current directory: getcwd: cannot access | ||||
# parent directories: No such file or directory | ||||
# | ||||
# Python >= 2.7.13 has fixed it by running "ldconfig" directly without a | ||||
# shell (hg changeset a09ae70f3489). | ||||
# | ||||
# Moved "import uuid" from here so it's executed after we know we have | ||||
# a sane cwd (i.e. after dispatch.py cwd check). | ||||
# | ||||
# We can move it back once we no longer need Python <= 2.7.12 support. | ||||
import uuid | ||||
|
r30766 | # Don't allow untrusted CSP setting since it be disable protections | ||
# from a trusted/global source. | ||||
csp = ui.config('web', 'csp', untrusted=False) | ||||
nonce = None | ||||
if csp and '%nonce%' in csp: | ||||
nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=') | ||||
csp = csp.replace('%nonce%', nonce) | ||||
return csp, nonce | ||||