common.py
289 lines
| 8.9 KiB
| text/x-python
|
PythonLexer
Eric Hopper
|
r2391 | # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod | ||
Eric Hopper
|
r2356 | # | ||
# Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net> | ||||
Vadim Gelfer
|
r2859 | # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com> | ||
Eric Hopper
|
r2356 | # | ||
Martin Geisler
|
r8225 | # This software may be used and distributed according to the terms of the | ||
Matt Mackall
|
r10263 | # GNU General Public License version 2 or any later version. | ||
Eric Hopper
|
r2356 | |||
Yuya Nishihara
|
r27046 | from __future__ import absolute_import | ||
Gregory Szorc
|
r30766 | import base64 | ||
Yuya Nishihara
|
r27046 | import errno | ||
import mimetypes | ||||
import os | ||||
Augie Fackler
|
r36799 | import stat | ||
Bryan O'Sullivan
|
r5561 | |||
Gregory Szorc
|
r43359 | from ..pycompat import ( | ||
getattr, | ||||
open, | ||||
) | ||||
Pulkit Goyal
|
r30615 | from .. import ( | ||
Pulkit Goyal
|
r30636 | encoding, | ||
Pulkit Goyal
|
r30615 | pycompat, | ||
util, | ||||
) | ||||
Pulkit Goyal
|
r29566 | |||
httpserver = util.httpserver | ||||
Dirkjan Ochtman
|
r5993 | HTTP_OK = 200 | ||
Matt Harbison
|
r37167 | HTTP_CREATED = 201 | ||
Dirkjan Ochtman
|
r12183 | HTTP_NOT_MODIFIED = 304 | ||
Dirkjan Ochtman
|
r5993 | HTTP_BAD_REQUEST = 400 | ||
Dirkjan Ochtman
|
r6926 | HTTP_UNAUTHORIZED = 401 | ||
Rocco Rutte
|
r7029 | HTTP_FORBIDDEN = 403 | ||
Dirkjan Ochtman
|
r5993 | HTTP_NOT_FOUND = 404 | ||
Dirkjan Ochtman
|
r6926 | HTTP_METHOD_NOT_ALLOWED = 405 | ||
Matt Harbison
|
r37711 | HTTP_NOT_ACCEPTABLE = 406 | ||
HTTP_UNSUPPORTED_MEDIA_TYPE = 415 | ||||
Dirkjan Ochtman
|
r5993 | HTTP_SERVER_ERROR = 500 | ||
Sune Foldager
|
r9910 | |||
Wagner Bruna
|
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. | ||||
Mads Kiilerich
|
r19951 | Can be overridden by extensions to provide more complex authorization | ||
Wagner Bruna
|
r19032 | schemes. | ||
""" | ||||
Augie Fackler
|
r43347 | return userlist == [b'*'] or username in userlist | ||
Wagner Bruna
|
r19032 | |||
Augie Fackler
|
r43346 | |||
Sune Foldager
|
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.''' | ||||
Gregory Szorc
|
r36893 | user = req.remoteuser | ||
Sune Foldager
|
r9910 | |||
Augie Fackler
|
r43347 | deny_read = hgweb.configlist(b'web', b'deny_read') | ||
Wagner Bruna
|
r19032 | if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)): | ||
Augie Fackler
|
r43347 | raise ErrorResponse(HTTP_UNAUTHORIZED, b'read not authorized') | ||
Sune Foldager
|
r9910 | |||
Augie Fackler
|
r43347 | allow_read = hgweb.configlist(b'web', b'allow_read') | ||
Wagner Bruna
|
r19032 | if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)): | ||
Augie Fackler
|
r43347 | raise ErrorResponse(HTTP_UNAUTHORIZED, b'read not authorized') | ||
Sune Foldager
|
r9910 | |||
Augie Fackler
|
r43347 | if op == b'pull' and not hgweb.allowpull: | ||
raise ErrorResponse(HTTP_UNAUTHORIZED, b'pull not authorized') | ||||
elif op == b'pull' or op is None: # op is None for interface requests | ||||
Sune Foldager
|
r9910 | return | ||
Matt Harbison
|
r37165 | # Allow LFS uploading via PUT requests | ||
Augie Fackler
|
r43347 | if op == b'upload': | ||
if req.method != b'PUT': | ||||
msg = b'upload requires PUT request' | ||||
Matt Harbison
|
r37165 | raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg) | ||
Sune Foldager
|
r9910 | # enforce that you can only push using POST requests | ||
Augie Fackler
|
r43347 | elif req.method != b'POST': | ||
msg = b'push requires POST request' | ||||
Sune Foldager
|
r9910 | raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg) | ||
# require ssl by default for pushing, auth info cannot be sniffed | ||||
# and replayed | ||||
Augie Fackler
|
r43347 | if hgweb.configbool(b'web', b'push_ssl') and req.urlscheme != b'https': | ||
raise ErrorResponse(HTTP_FORBIDDEN, b'ssl required') | ||||
Sune Foldager
|
r9910 | |||
Augie Fackler
|
r43347 | deny = hgweb.configlist(b'web', b'deny_push') | ||
Wagner Bruna
|
r19032 | if deny and (not user or ismember(hgweb.repo.ui, user, deny)): | ||
Augie Fackler
|
r43347 | raise ErrorResponse(HTTP_UNAUTHORIZED, b'push not authorized') | ||
Sune Foldager
|
r9910 | |||
Augie Fackler
|
r43347 | allow = hgweb.configlist(b'web', b'allow-push') | ||
Wagner Bruna
|
r19032 | if not (allow and ismember(hgweb.repo.ui, user, allow)): | ||
Augie Fackler
|
r43347 | raise ErrorResponse(HTTP_UNAUTHORIZED, b'push not authorized') | ||
Sune Foldager
|
r9910 | |||
Augie Fackler
|
r43346 | |||
Martin Geisler
|
r14058 | # Hooks for hgweb permission checks; extensions can add hooks here. | ||
# Each hook is invoked like this: hook(hgweb, request, operation), | ||||
Matt Harbison
|
r37165 | # where operation is either read, pull, push or upload. Hooks should either | ||
Martin Geisler
|
r14058 | # raise an ErrorResponse exception, or just return. | ||
# | ||||
# It is possible to do both authentication and authorization through | ||||
# this. | ||||
permhooks = [checkauthz] | ||||
Sune Foldager
|
r9910 | |||
Bryan O'Sullivan
|
r5561 | class ErrorResponse(Exception): | ||
Gregory Szorc
|
r31390 | def __init__(self, code, message=None, headers=None): | ||
Mads Kiilerich
|
r13444 | if message is None: | ||
message = _statusmessage(code) | ||||
Augie Fackler
|
r36447 | Exception.__init__(self, pycompat.sysstr(message)) | ||
Bryan O'Sullivan
|
r5561 | self.code = code | ||
Pierre-Yves David
|
r31435 | if headers is None: | ||
headers = [] | ||||
self.headers = headers | ||||
Connor Sheehan
|
r43192 | self.message = message | ||
Bryan O'Sullivan
|
r5563 | |||
Augie Fackler
|
r43346 | |||
Augie Fackler
|
r13570 | class continuereader(object): | ||
Gregory Szorc
|
r36869 | """File object wrapper to handle HTTP 100-continue. | ||
This is used by servers so they automatically handle Expect: 100-continue | ||||
request headers. On first read of the request body, the 100 Continue | ||||
response is sent. This should trigger the client into actually sending | ||||
the request body. | ||||
""" | ||||
Augie Fackler
|
r43346 | |||
Augie Fackler
|
r13570 | 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 | ||||
Augie Fackler
|
r43347 | self._write(b'HTTP/1.1 100 Continue\r\n\r\n') | ||
Augie Fackler
|
r13570 | return self.f.read(amt) | ||
def __getattr__(self, attr): | ||||
Augie Fackler
|
r43347 | if attr in (b'close', b'readline', b'readlines', b'__iter__'): | ||
Augie Fackler
|
r13570 | return getattr(self.f, attr) | ||
Brodie Rao
|
r16687 | raise AttributeError | ||
Bryan O'Sullivan
|
r5563 | |||
Augie Fackler
|
r43346 | |||
Bryan O'Sullivan
|
r5563 | def _statusmessage(code): | ||
Pulkit Goyal
|
r29566 | responses = httpserver.basehttprequesthandler.responses | ||
Augie Fackler
|
r37752 | return pycompat.bytesurl( | ||
Augie Fackler
|
r43346 | responses.get(code, (r'Error', r'Unknown error'))[0] | ||
) | ||||
Thomas Arendsen Hein
|
r5760 | |||
Sune Foldager
|
r9694 | def statusmessage(code, message=None): | ||
Augie Fackler
|
r43347 | return b'%d %s' % (code, message or _statusmessage(code)) | ||
Eric Hopper
|
r2356 | |||
Augie Fackler
|
r43346 | |||
Pierre-Yves David
|
r25717 | def get_stat(spath, fn): | ||
"""stat fn if it exists, spath otherwise""" | ||||
Anton Shestakov
|
r22577 | cl_path = os.path.join(spath, fn) | ||
Benoit Boissinot
|
r3853 | if os.path.exists(cl_path): | ||
Martin Geisler
|
r13958 | return os.stat(cl_path) | ||
Eric Hopper
|
r2356 | else: | ||
Martin Geisler
|
r13958 | return os.stat(spath) | ||
Augie Fackler
|
r43346 | |||
Martin Geisler
|
r13958 | def get_mtime(spath): | ||
Augie Fackler
|
r43347 | return get_stat(spath, b"00changelog.i")[stat.ST_MTIME] | ||
Eric Hopper
|
r2356 | |||
Augie Fackler
|
r43346 | |||
Gregory Szorc
|
r31790 | def ispathsafe(path): | ||
"""Determine if a path is safe to use for filesystem access.""" | ||||
Augie Fackler
|
r43347 | parts = path.split(b'/') | ||
Gregory Szorc
|
r31790 | for part in parts: | ||
Augie Fackler
|
r43346 | if ( | ||
Augie Fackler
|
r43347 | part in (b'', pycompat.oscurdir, pycompat.ospardir) | ||
Augie Fackler
|
r43346 | or pycompat.ossep in part | ||
or pycompat.osaltsep is not None | ||||
and pycompat.osaltsep in part | ||||
): | ||||
Gregory Szorc
|
r31790 | return False | ||
return True | ||||
Augie Fackler
|
r43346 | |||
Gregory Szorc
|
r36889 | def staticfile(directory, fname, res): | ||
Dirkjan Ochtman
|
r5930 | """return a file inside directory with guessed Content-Type header | ||
Eric Hopper
|
r2356 | |||
fname always uses '/' as directory separator and isn't allowed to | ||||
contain unusual path components. | ||||
Dirkjan Ochtman
|
r5930 | Content-Type is guessed using the mimetypes module. | ||
Eric Hopper
|
r2356 | Return an empty string if fname is illegal or file not found. | ||
""" | ||||
Gregory Szorc
|
r31790 | if not ispathsafe(fname): | ||
return | ||||
Augie Fackler
|
r43347 | fpath = os.path.join(*fname.split(b'/')) | ||
Brendan Cully
|
r7288 | if isinstance(directory, str): | ||
directory = [directory] | ||||
for d in directory: | ||||
path = os.path.join(d, fpath) | ||||
if os.path.exists(path): | ||||
break | ||||
Eric Hopper
|
r2356 | try: | ||
os.stat(path) | ||||
Gregory Szorc
|
r40194 | ct = pycompat.sysbytes( | ||
Augie Fackler
|
r43346 | mimetypes.guess_type(pycompat.fsdecode(path))[0] or r"text/plain" | ||
) | ||||
Augie Fackler
|
r43347 | with open(path, b'rb') as fh: | ||
Gregory Szorc
|
r31789 | data = fh.read() | ||
Augie Fackler
|
r43347 | res.headers[b'Content-Type'] = ct | ||
Gregory Szorc
|
r36889 | res.setbodybytes(data) | ||
return res | ||||
Bryan O'Sullivan
|
r5561 | except TypeError: | ||
Augie Fackler
|
r43347 | raise ErrorResponse(HTTP_SERVER_ERROR, b'illegal filename') | ||
Gregory Szorc
|
r25660 | except OSError as err: | ||
Bryan O'Sullivan
|
r5561 | if err.errno == errno.ENOENT: | ||
Dirkjan Ochtman
|
r5993 | raise ErrorResponse(HTTP_NOT_FOUND) | ||
Bryan O'Sullivan
|
r5561 | else: | ||
Augie Fackler
|
r43346 | raise ErrorResponse( | ||
HTTP_SERVER_ERROR, encoding.strtolocal(err.strerror) | ||||
) | ||||
Thomas Arendsen Hein
|
r3276 | |||
Thomas Arendsen Hein
|
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 | ||||
Pulkit Goyal
|
r36415 | parity = (stripecount + offset) // stripecount & 1 | ||
Thomas Arendsen Hein
|
r4462 | else: | ||
count = 0 | ||||
parity = 0 | ||||
while True: | ||||
yield parity | ||||
count += 1 | ||||
if stripecount and count >= stripecount: | ||||
parity = 1 - parity | ||||
count = 0 | ||||
Augie Fackler
|
r43346 | |||
Thomas Arendsen Hein
|
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. | ||||
""" | ||||
Augie Fackler
|
r43346 | return ( | ||
Augie Fackler
|
r43347 | config(b"web", b"contact") | ||
or config(b"ui", b"username") | ||||
or encoding.environ.get(b"EMAIL") | ||||
or b"" | ||||
Augie Fackler
|
r43346 | ) | ||
Dirkjan Ochtman
|
r12183 | |||
Gregory Szorc
|
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. | ||||
""" | ||||
Jun Wu
|
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 | ||||
Gregory Szorc
|
r30766 | # Don't allow untrusted CSP setting since it be disable protections | ||
# from a trusted/global source. | ||||
Augie Fackler
|
r43347 | csp = ui.config(b'web', b'csp', untrusted=False) | ||
Gregory Szorc
|
r30766 | nonce = None | ||
Augie Fackler
|
r43347 | if csp and b'%nonce%' in csp: | ||
nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip(b'=') | ||||
csp = csp.replace(b'%nonce%', nonce) | ||||
Gregory Szorc
|
r30766 | |||
return csp, nonce | ||||