398 lines
13 KiB
Python
398 lines
13 KiB
Python
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
|
# this repository contains the full copyright notices and license terms.
|
|
import base64
|
|
import collections
|
|
import gzip
|
|
import logging
|
|
import time
|
|
import zlib
|
|
from functools import wraps
|
|
|
|
try:
|
|
from http import HTTPStatus
|
|
except ImportError:
|
|
from http import client as HTTPStatus
|
|
|
|
from werkzeug import exceptions
|
|
from werkzeug.datastructures import Authorization
|
|
from werkzeug.exceptions import abort
|
|
from werkzeug.utils import redirect, send_file
|
|
from werkzeug.wrappers import Request as _Request
|
|
from werkzeug.wrappers import Response
|
|
|
|
from trytond import backend, config, security
|
|
from trytond.exceptions import RateLimitException, UserError, UserWarning
|
|
from trytond.pool import Pool
|
|
from trytond.tools import cached_property
|
|
from trytond.transaction import Transaction, TransactionError, check_access
|
|
|
|
__all__ = [
|
|
'HTTPStatus',
|
|
'Request',
|
|
'Response',
|
|
'abort',
|
|
'allow_null_origin',
|
|
'exceptions',
|
|
'redirect',
|
|
'send_file',
|
|
'set_max_request_size',
|
|
'user_application',
|
|
'with_pool',
|
|
'with_transaction',
|
|
'encode_session_cookie',
|
|
'decode_session_cookie',
|
|
'add_auth_cookies',
|
|
'remove_auth_cookies',
|
|
'add_cookie',
|
|
'remove_cookie',
|
|
'TRYTON_SESSION_COOKIE',
|
|
]
|
|
|
|
TRYTON_SESSION_COOKIE = 'tryton_session'
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def encode_session_cookie(login, userid, token):
|
|
return ':'.join((login, userid, token))
|
|
|
|
|
|
def decode_session_cookie(cookie):
|
|
return cookie.rsplit(':', 2)
|
|
|
|
|
|
def add_cookie(response, database, name, value):
|
|
response.set_cookie(name, value,
|
|
max_age=config.getint('session', 'max_age'),
|
|
path=f'/{database}',
|
|
domain=config.get('session', 'cookie_domain'),
|
|
secure=True, httponly=True, samesite='Strict')
|
|
|
|
|
|
def add_auth_cookies(response, database, username, userid, token):
|
|
session_cookie = encode_session_cookie(username, userid, token)
|
|
add_cookie(response, database, TRYTON_SESSION_COOKIE, session_cookie)
|
|
|
|
|
|
def remove_cookie(response, database, name):
|
|
response.set_cookie(
|
|
name, '', expires=0, path=f'/{database}',
|
|
domain=config.get('session', 'cookie_domain'),
|
|
secure=True, httponly=True, samesite='Strict')
|
|
|
|
|
|
def remove_auth_cookies(response, database):
|
|
remove_cookie(response, database, TRYTON_SESSION_COOKIE)
|
|
|
|
|
|
class Request(_Request):
|
|
|
|
view_args = None
|
|
|
|
def __repr__(self):
|
|
args = []
|
|
try:
|
|
if self.url is None or isinstance(self.url, str):
|
|
url = self.url
|
|
else:
|
|
url = self.url.decode(getattr(self, 'url_charset', 'utf-8'))
|
|
auth = self.authorization
|
|
if auth:
|
|
args.append("%s@%s" % (
|
|
auth.get('userid', auth.username), self.remote_addr))
|
|
else:
|
|
args.append(self.remote_addr)
|
|
args.append("'%s'" % url)
|
|
args.append("[%s]" % self.method)
|
|
if self.view_args:
|
|
args.append("%s" % (self.rpc_method or ''))
|
|
except Exception:
|
|
args.append("(invalid WSGI environ)")
|
|
return "<%s %s>" % (
|
|
self.__class__.__name__, " ".join(filter(None, args)))
|
|
|
|
@property
|
|
def decoded_data(self):
|
|
if self.content_encoding == 'gzip':
|
|
if self.user_id:
|
|
return gzip.decompress(self.data)
|
|
else:
|
|
abort(HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
else:
|
|
return self.data
|
|
|
|
@property
|
|
def parsed_data(self):
|
|
return self.data
|
|
|
|
@property
|
|
def rpc_method(self):
|
|
return
|
|
|
|
@property
|
|
def rpc_params(self):
|
|
return
|
|
|
|
@cached_property
|
|
def session(self):
|
|
cookie = self.cookies.get(TRYTON_SESSION_COOKIE)
|
|
if cookie:
|
|
try:
|
|
username, userid, token = decode_session_cookie(cookie)
|
|
session = Session('cookie', username, int(userid), token)
|
|
except ValueError:
|
|
session = None
|
|
elif self.authorization and self.authorization.type == 'session':
|
|
session = Session(
|
|
'authorization',
|
|
self.authorization.username,
|
|
int(self.authorization.get('userid')),
|
|
self.authorization.get('session'))
|
|
else:
|
|
session = None
|
|
return session
|
|
|
|
@cached_property
|
|
def authorization(self):
|
|
authorization = super().authorization
|
|
if authorization is None:
|
|
header = self.headers.get('Authorization')
|
|
return parse_authorization_header(header)
|
|
elif authorization.type == 'session':
|
|
# Werkzeug may parse the session as parameters
|
|
# if the base64 uses the padding sign '='
|
|
if authorization.token is None:
|
|
header = self.headers.get('Authorization')
|
|
return parse_authorization_header(header)
|
|
else:
|
|
return parse_session(authorization.token)
|
|
return authorization
|
|
|
|
@cached_property
|
|
def user_id(self):
|
|
assert self.view_args is not None
|
|
database_name = self.view_args.get('database_name')
|
|
if not database_name:
|
|
return None
|
|
auth = self.authorization
|
|
if not self.session and not auth:
|
|
return None
|
|
context = {'_request': self.context}
|
|
if self.session:
|
|
user_id = security.check(
|
|
database_name, self.session.userid, self.session.token,
|
|
context=context)
|
|
elif auth.username:
|
|
parameters = getattr(auth, 'parameters', auth)
|
|
try:
|
|
user_id = security.login(
|
|
database_name, auth.username, parameters, cache=False,
|
|
context=context)
|
|
except RateLimitException:
|
|
abort(HTTPStatus.TOO_MANY_REQUESTS)
|
|
else:
|
|
user_id = None
|
|
return user_id
|
|
|
|
@cached_property
|
|
def context(self):
|
|
return {
|
|
'remote_addr': self.remote_addr,
|
|
'http_host': self.environ.get('HTTP_HOST'),
|
|
'scheme': self.scheme,
|
|
'is_secure': self.is_secure,
|
|
}
|
|
|
|
|
|
Session = collections.namedtuple('Session', 'type username userid token')
|
|
|
|
|
|
def parse_authorization_header(value):
|
|
if not value:
|
|
return
|
|
if isinstance(value, bytes):
|
|
value = value.decode('latin1')
|
|
try:
|
|
auth_type, auth_info = value.split(None, 1)
|
|
auth_type = auth_type.lower()
|
|
except ValueError:
|
|
return
|
|
if auth_type == 'session':
|
|
return parse_session(auth_info)
|
|
else:
|
|
authorization = Authorization(auth_type)
|
|
authorization.token = auth_info
|
|
return authorization
|
|
|
|
|
|
def parse_session(token):
|
|
try:
|
|
username, userid, session = (
|
|
base64.b64decode(token).decode().split(':', 3))
|
|
userid = int(userid)
|
|
except Exception:
|
|
return
|
|
return Authorization('session', {
|
|
'username': username,
|
|
'userid': userid,
|
|
'session': session,
|
|
})
|
|
|
|
|
|
def set_max_request_size(size):
|
|
def decorator(func):
|
|
func.max_request_size = size
|
|
return func
|
|
return decorator
|
|
|
|
|
|
def allow_null_origin(func):
|
|
func.allow_null_origin = True
|
|
return func
|
|
|
|
|
|
def with_pool(func):
|
|
@wraps(func)
|
|
def wrapper(request, database_name, *args, **kwargs):
|
|
database_list = Pool.database_list()
|
|
if database_name not in database_list:
|
|
with Transaction().start(None, 0, readonly=True) as transaction:
|
|
hostname = config.get_hostname(request.host)
|
|
db_list = transaction.database.list(hostname=hostname)
|
|
if database_name not in db_list:
|
|
abort(HTTPStatus.NOT_FOUND)
|
|
pool = Pool(database_name)
|
|
if database_name not in database_list:
|
|
with Transaction().start(database_name, 0, readonly=True):
|
|
pool.init()
|
|
|
|
try:
|
|
result = func(request, pool, *args, **kwargs)
|
|
except (UserError, UserWarning) as e:
|
|
if request.rpc_method:
|
|
raise
|
|
else:
|
|
abort(HTTPStatus.BAD_REQUEST, e)
|
|
except exceptions.HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
if request.rpc_method:
|
|
raise
|
|
else:
|
|
abort(HTTPStatus.INTERNAL_SERVER_ERROR, e)
|
|
return result
|
|
return wrapper
|
|
|
|
|
|
def with_transaction(readonly=None, user=0, context=None, timeout=None):
|
|
from trytond.worker import run_task
|
|
|
|
def decorator(func):
|
|
@wraps(func)
|
|
def wrapper(request, pool, *args, **kwargs):
|
|
readonly_ = readonly # can not modify non local
|
|
if readonly_ is None:
|
|
if request.method in {'POST', 'PUT', 'DELETE', 'PATCH'}:
|
|
readonly_ = False
|
|
else:
|
|
readonly_ = True
|
|
if context is None:
|
|
context_ = {}
|
|
else:
|
|
context_ = context.copy()
|
|
context_['_request'] = request.context
|
|
if user == 'request':
|
|
user_ = request.user_id
|
|
else:
|
|
user_ = user
|
|
retry = config.getint('database', 'retry')
|
|
count = 0
|
|
transaction_extras = {}
|
|
while True:
|
|
if count:
|
|
time.sleep(0.02 * count)
|
|
with Transaction().start(
|
|
pool.database_name, user_, readonly=readonly_,
|
|
context=context_, timeout=timeout,
|
|
**transaction_extras) as transaction:
|
|
try:
|
|
result = func(request, pool, *args, **kwargs)
|
|
except TransactionError as e:
|
|
transaction.rollback()
|
|
transaction.tasks.clear()
|
|
e.fix(transaction_extras)
|
|
continue
|
|
except backend.DatabaseOperationalError:
|
|
if count < retry and not readonly_:
|
|
transaction.rollback()
|
|
transaction.tasks.clear()
|
|
count += 1
|
|
logger.debug("Retry: %i", count)
|
|
continue
|
|
raise
|
|
# Need to commit to unlock SQLite database
|
|
transaction.commit()
|
|
while transaction.tasks:
|
|
task_id = transaction.tasks.pop()
|
|
run_task(pool, task_id)
|
|
return result
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def user_application(name, json=True):
|
|
from .jsonrpc import JSONEncoder
|
|
from .jsonrpc import json as json_
|
|
|
|
def decorator(func):
|
|
@wraps(func)
|
|
def wrapper(request, *args, **kwargs):
|
|
pool = Pool()
|
|
UserApplication = pool.get('res.user.application')
|
|
|
|
authorization = request.authorization
|
|
if authorization is None:
|
|
header = request.headers.get('Authorization')
|
|
authorization = parse_authorization_header(header)
|
|
if authorization is None:
|
|
abort(HTTPStatus.UNAUTHORIZED)
|
|
|
|
if authorization.type == 'bearer':
|
|
token = getattr(authorization, 'token', '')
|
|
elif authorization.type == 'basic':
|
|
token = authorization.get('password')
|
|
else:
|
|
abort(HTTPStatus.UNAUTHORIZED)
|
|
|
|
application = UserApplication.check(token, name)
|
|
if not application:
|
|
abort(HTTPStatus.UNAUTHORIZED)
|
|
transaction = Transaction()
|
|
# TODO language
|
|
with transaction.set_user(application.user.id), \
|
|
check_access():
|
|
response = func(request, *args, **kwargs)
|
|
if not isinstance(response, Response) and json:
|
|
response = Response(json_.dumps(response, cls=JSONEncoder),
|
|
content_type='application/json')
|
|
return response
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
class GzipStream:
|
|
def __init__(self, data, compresslevel=6):
|
|
if isinstance(data, str):
|
|
data = [data]
|
|
self.iterator = data
|
|
self.compressor = zlib.compressobj(level=compresslevel, wbits=31)
|
|
|
|
def __iter__(self):
|
|
for chunk in self.iterator:
|
|
data = chunk.encode('utf-8')
|
|
compressed = self.compressor.compress(data)
|
|
if compressed:
|
|
yield compressed
|
|
tail = self.compressor.flush()
|
|
if tail:
|
|
yield tail
|