This commit is the 11th commit of a comprehensive refactor of our HTTP framework. See odoo/odoo#78857 for complete historic, discussions and rationnals. See also [REF] core: HTTPocalypse (9) ORM initialization. Where `request._serve_db` is reponsible for initializing the ORM, `request._serve_ir_http` is responsible to do the match -> authenticate -> pre dispatch -> dispatch -> post dispatch sequence. This sequence is very much similar to `_serve_nodb` with the notable addition of the authenticate step. Where `_serve_nodb` was working in a db-free environment, calling the methods of (Http|Json)Dispatcher right away, `_serve_ir_http` delegates to the `ir.http` abstract model which in turn use the dispatchers. The ir.http model is crucial for several modules, web, portal and website on top. They override the various methods to add advenced capabilities to the http framework. An important changement to the ir.http model in this work is regarding the `_dispatch` method. Before this work it was that method that was responsible of doing the sequence match -> pre dispatch -> ... . It was decided to move the responsibility to http.py in order to prevent modules from taking over the entire framework. One such take-over was done in the `http_routing` addon and resulted in arguably very poor and fragile code. All modules must now carefully override the correct method. You need to do smart stuff on the URL prior to matching an endpoint? You should override `_match`. You need to save a special option from the query- strings to the session before calling the controller? Override `_pre_dispatch`. PR: odoo#78857 Task: 2571224
427 lines
17 KiB
Python
427 lines
17 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
#----------------------------------------------------------
|
|
# ir_http modular http routing
|
|
#----------------------------------------------------------
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import sys
|
|
import traceback
|
|
|
|
import werkzeug
|
|
import werkzeug.exceptions
|
|
import werkzeug.routing
|
|
import werkzeug.utils
|
|
|
|
import odoo
|
|
from odoo import api, http, models, tools, SUPERUSER_ID
|
|
from odoo.exceptions import AccessDenied, AccessError, MissingError
|
|
from odoo.http import request, content_disposition, Response, ROUTING_KEYS
|
|
from odoo.service import security
|
|
from odoo.tools import consteq, submap
|
|
from odoo.tools.mimetypes import guess_mimetype
|
|
from odoo.modules.module import get_resource_path, get_module_path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RequestUID(object):
|
|
def __init__(self, **kw):
|
|
self.__dict__.update(kw)
|
|
|
|
|
|
class ModelConverter(werkzeug.routing.BaseConverter):
|
|
|
|
def __init__(self, url_map, model=False):
|
|
super(ModelConverter, self).__init__(url_map)
|
|
self.model = model
|
|
self.regex = r'([0-9]+)'
|
|
|
|
def to_python(self, value):
|
|
_uid = RequestUID(value=value, converter=self)
|
|
env = api.Environment(request.cr, _uid, request.context)
|
|
return env[self.model].browse(int(value))
|
|
|
|
def to_url(self, value):
|
|
return value.id
|
|
|
|
|
|
class ModelsConverter(werkzeug.routing.BaseConverter):
|
|
|
|
def __init__(self, url_map, model=False):
|
|
super(ModelsConverter, self).__init__(url_map)
|
|
self.model = model
|
|
# TODO add support for slug in the form [A-Za-z0-9-] bla-bla-89 -> id 89
|
|
self.regex = r'([0-9,]+)'
|
|
|
|
def to_python(self, value):
|
|
_uid = RequestUID(value=value, converter=self)
|
|
env = api.Environment(request.cr, _uid, request.context)
|
|
return env[self.model].browse(int(v) for v in value.split(','))
|
|
|
|
def to_url(self, value):
|
|
return ",".join(value.ids)
|
|
|
|
|
|
class SignedIntConverter(werkzeug.routing.NumberConverter):
|
|
regex = r'-?\d+'
|
|
num_convert = int
|
|
|
|
|
|
class IrHttp(models.AbstractModel):
|
|
_name = 'ir.http'
|
|
_description = "HTTP Routing"
|
|
|
|
#------------------------------------------------------
|
|
# Routing map
|
|
#------------------------------------------------------
|
|
|
|
@classmethod
|
|
def _get_converters(cls):
|
|
return {'model': ModelConverter, 'models': ModelsConverter, 'int': SignedIntConverter}
|
|
|
|
@classmethod
|
|
def _match(cls, path_info, key=None):
|
|
rule, args = cls.routing_map().bind_to_environ(request.httprequest.environ).match(path_info=path_info, return_rule=True)
|
|
return rule, args
|
|
|
|
@classmethod
|
|
def _auth_method_user(cls):
|
|
if request.env.uid is None:
|
|
raise http.SessionExpiredException("Session expired")
|
|
|
|
@classmethod
|
|
def _auth_method_none(cls):
|
|
request.env = api.Environment(request.env.cr, None, request.env.context)
|
|
|
|
@classmethod
|
|
def _auth_method_public(cls):
|
|
if request.env.uid is None:
|
|
public_user = request.env.ref('base.public_user')
|
|
request.update_env(user=public_user.id)
|
|
|
|
@classmethod
|
|
def _authenticate(cls, endpoint):
|
|
auth = 'none' if http.is_cors_preflight(request, endpoint) else endpoint.routing['auth']
|
|
|
|
try:
|
|
if request.session.uid is not None:
|
|
if not security.check_session(request.session, request.env):
|
|
request.session.logout(keep_db=True)
|
|
request.env = api.Environment(request.env.cr, None, request.session.context)
|
|
getattr(cls, f'_auth_method_{auth}')()
|
|
except (AccessDenied, http.SessionExpiredException, werkzeug.exceptions.HTTPException):
|
|
raise
|
|
except Exception:
|
|
_logger.info("Exception during request Authentication.", exc_info=True)
|
|
raise AccessDenied()
|
|
|
|
@classmethod
|
|
def _pre_dispatch(cls, rule, args):
|
|
request.dispatcher.pre_dispatch(rule, args)
|
|
|
|
# Replace uid placeholder by the current request.env.uid
|
|
for key, val in list(args.items()):
|
|
if isinstance(val, models.BaseModel) and isinstance(val._uid, RequestUID):
|
|
args[key] = val.with_user(request.env.uid)
|
|
|
|
@classmethod
|
|
def _dispatch(cls, endpoint):
|
|
result = endpoint(**request.params)
|
|
if isinstance(result, Response) and result.is_qweb:
|
|
result.flatten()
|
|
return result
|
|
|
|
@classmethod
|
|
def _post_dispatch(cls, response):
|
|
request.dispatcher.post_dispatch(response)
|
|
|
|
@classmethod
|
|
def _handle_error(cls, exception):
|
|
return request.dispatcher.handle_error(exception)
|
|
|
|
@classmethod
|
|
def _serve_attachment(cls):
|
|
env = request.env(user=SUPERUSER_ID)
|
|
attach = env['ir.attachment'].get_serve_attachment(request.httprequest.path, extra_fields=['name', 'checksum'])
|
|
if attach:
|
|
wdate = attach[0]['__last_update']
|
|
datas = attach[0]['datas'] or b''
|
|
name = attach[0]['name']
|
|
checksum = attach[0]['checksum'] or hashlib.sha512(datas).hexdigest()[:64] # sha512/256
|
|
|
|
if (not datas and name != request.httprequest.path and
|
|
name.startswith(('http://', 'https://', '/'))):
|
|
return request.redirect(name, 301, local=False)
|
|
|
|
response = werkzeug.wrappers.Response()
|
|
response.last_modified = wdate
|
|
|
|
response.set_etag(checksum)
|
|
response.make_conditional(request.httprequest)
|
|
|
|
if response.status_code == 304:
|
|
return response
|
|
|
|
response.mimetype = attach[0]['mimetype'] or 'application/octet-stream'
|
|
response.data = base64.b64decode(datas)
|
|
return response
|
|
|
|
@classmethod
|
|
def _serve_fallback(cls):
|
|
# serve attachment
|
|
attach = cls._serve_attachment()
|
|
if attach:
|
|
return attach
|
|
|
|
@classmethod
|
|
def _redirect(cls, location, code=303):
|
|
return werkzeug.utils.redirect(location, code=code, Response=Response)
|
|
|
|
@classmethod
|
|
def _generate_routing_rules(cls, modules, converters):
|
|
return http._generate_routing_rules(modules, False, converters)
|
|
|
|
@classmethod
|
|
def routing_map(cls, key=None):
|
|
|
|
if not hasattr(cls, '_routing_map'):
|
|
cls._routing_map = {}
|
|
cls._rewrite_len = {}
|
|
|
|
if key not in cls._routing_map:
|
|
_logger.info("Generating routing map for key %s" % str(key))
|
|
installed = request.env.registry._init_modules.union(odoo.conf.server_wide_modules)
|
|
if tools.config['test_enable'] and odoo.modules.module.current_test:
|
|
installed.add(odoo.modules.module.current_test)
|
|
mods = sorted(installed)
|
|
# Note : when routing map is generated, we put it on the class `cls`
|
|
# to make it available for all instance. Since `env` create an new instance
|
|
# of the model, each instance will regenared its own routing map and thus
|
|
# regenerate its EndPoint. The routing map should be static.
|
|
routing_map = werkzeug.routing.Map(strict_slashes=False, converters=cls._get_converters())
|
|
for url, endpoint in cls._generate_routing_rules(mods, converters=cls._get_converters()):
|
|
routing = submap(endpoint.routing, ROUTING_KEYS)
|
|
if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
|
|
routing['methods'] = routing['methods'] + ['OPTIONS']
|
|
rule = werkzeug.routing.Rule(url, endpoint=endpoint, **routing)
|
|
rule.merge_slashes = False
|
|
routing_map.add(rule)
|
|
cls._routing_map[key] = routing_map
|
|
return cls._routing_map[key]
|
|
|
|
@classmethod
|
|
def _clear_routing_map(cls):
|
|
if hasattr(cls, '_routing_map'):
|
|
cls._routing_map = {}
|
|
_logger.debug("Clear routing map")
|
|
|
|
@api.autovacuum
|
|
def _gc_sessions(self):
|
|
http.root.session_store.vacuum()
|
|
|
|
#------------------------------------------------------
|
|
# Binary server
|
|
#------------------------------------------------------
|
|
|
|
@classmethod
|
|
def _xmlid_to_obj(cls, env, xmlid):
|
|
return env.ref(xmlid, False)
|
|
|
|
def _get_record_and_check(self, xmlid=None, model=None, id=None, field='datas', access_token=None):
|
|
# get object and content
|
|
record = None
|
|
if xmlid:
|
|
record = self._xmlid_to_obj(self.env, xmlid)
|
|
elif id and model in self.env:
|
|
record = self.env[model].browse(int(id))
|
|
|
|
# obj exists
|
|
if not record or field not in record:
|
|
return None, 404
|
|
|
|
try:
|
|
if model == 'ir.attachment':
|
|
record_sudo = record.sudo()
|
|
if access_token and not consteq(record_sudo.access_token or '', access_token):
|
|
return None, 403
|
|
elif (access_token and consteq(record_sudo.access_token or '', access_token)):
|
|
record = record_sudo
|
|
elif record_sudo.public:
|
|
record = record_sudo
|
|
elif self.env.user.has_group('base.group_portal'):
|
|
# Check the read access on the record linked to the attachment
|
|
# eg: Allow to download an attachment on a task from /my/tasks/task_id
|
|
record.check('read')
|
|
record = record_sudo
|
|
|
|
# check read access
|
|
try:
|
|
# We have prefetched some fields of record, among which the field
|
|
# 'write_date' used by '__last_update' below. In order to check
|
|
# access on record, we have to invalidate its cache first.
|
|
if not record.env.su:
|
|
record._cache.clear()
|
|
record['__last_update']
|
|
except AccessError:
|
|
return None, 403
|
|
|
|
return record, 200
|
|
except MissingError:
|
|
return None, 404
|
|
|
|
@classmethod
|
|
def _binary_ir_attachment_redirect_content(cls, record, default_mimetype='application/octet-stream'):
|
|
# mainly used for theme images attachemnts
|
|
status = content = filename = filehash = None
|
|
mimetype = getattr(record, 'mimetype', False)
|
|
if record.type == 'url' and record.url:
|
|
# if url in in the form /somehint server locally
|
|
url_match = re.match("^/(\w+)/(.+)$", record.url)
|
|
if url_match:
|
|
module = url_match.group(1)
|
|
module_path = get_module_path(module)
|
|
module_resource_path = get_resource_path(module, url_match.group(2))
|
|
|
|
if module_path and module_resource_path:
|
|
module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/'
|
|
module_resource_path = os.path.normpath(module_resource_path)
|
|
if module_resource_path.startswith(module_path):
|
|
with open(module_resource_path, 'rb') as f:
|
|
content = f.read()
|
|
status = 200
|
|
filename = os.path.basename(module_resource_path)
|
|
mimetype = record.mimetype
|
|
filehash = record.checksum
|
|
|
|
if not content:
|
|
status = 301
|
|
content = record.url
|
|
|
|
return status, content, filename, mimetype, filehash
|
|
|
|
def _binary_record_content(
|
|
self, record, field='raw', filename=None,
|
|
filename_field='name', default_mimetype='application/octet-stream'):
|
|
|
|
model = record._name
|
|
mimetype = 'mimetype' in record and record.mimetype or False
|
|
content = None
|
|
filehash = 'checksum' in record and record['checksum'] or False
|
|
|
|
field_def = record._fields[field]
|
|
if field_def.type == 'binary' and field_def.attachment and not field_def.related:
|
|
if model != 'ir.attachment':
|
|
field_attachment = self.env['ir.attachment'].sudo().search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['raw', 'mimetype', 'checksum'], limit=1)
|
|
if field_attachment:
|
|
mimetype = field_attachment[0]['mimetype']
|
|
content = field_attachment[0]['raw']
|
|
filehash = field_attachment[0]['checksum']
|
|
else:
|
|
mimetype = record['mimetype']
|
|
content = record['raw']
|
|
filehash = record['checksum']
|
|
|
|
if not content:
|
|
if model == 'ir.attachment':
|
|
content = record.raw
|
|
else:
|
|
data = record[field] or b''
|
|
content = base64.b64decode(data)
|
|
filehash = '"%s"' % hashlib.md5(str(content).encode('utf-8')).hexdigest()
|
|
|
|
# filename
|
|
default_filename = False
|
|
if not filename:
|
|
if filename_field in record:
|
|
filename = record[filename_field]
|
|
if not filename:
|
|
default_filename = True
|
|
filename = "%s-%s-%s" % (record._name, record.id, field)
|
|
|
|
if not mimetype:
|
|
mimetype = guess_mimetype(content, default=default_mimetype)
|
|
|
|
# extension
|
|
_, existing_extension = os.path.splitext(filename)
|
|
if not existing_extension or default_filename:
|
|
extension = mimetypes.guess_extension(mimetype)
|
|
if extension:
|
|
filename = "%s%s" % (filename, extension)
|
|
|
|
if not filehash:
|
|
filehash = '"%s"' % hashlib.md5(str(base64.b64encode(content)).encode('utf-8')).hexdigest()
|
|
|
|
status = 200 if content else 404
|
|
return status, content, filename, mimetype, filehash
|
|
|
|
def _binary_set_headers(self, status, filename, mimetype, unique, filehash=None, download=False):
|
|
headers = [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff'), ('Content-Security-Policy', "default-src 'none'")]
|
|
# cache
|
|
etag = bool(request) and request.httprequest.headers.get('If-None-Match')
|
|
status = status or 200
|
|
if filehash:
|
|
headers.append(('ETag', filehash))
|
|
if etag == filehash and status == 200:
|
|
status = 304
|
|
headers.append(('Cache-Control', 'max-age=%s' % (http.STATIC_CACHE_LONG if unique else 0)))
|
|
# content-disposition default name
|
|
if download:
|
|
headers.append(('Content-Disposition', content_disposition(filename)))
|
|
|
|
return (status, headers)
|
|
|
|
def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='raw',
|
|
unique=False, filename=None, filename_field='name', download=False,
|
|
mimetype=None, default_mimetype='application/octet-stream',
|
|
access_token=None):
|
|
""" Get file, attachment or downloadable content
|
|
|
|
If the ``xmlid`` and ``id`` parameter is omitted, fetches the default value for the
|
|
binary field (via ``default_get``), otherwise fetches the field for
|
|
that precise record.
|
|
|
|
:param str xmlid: xmlid of the record
|
|
:param str model: name of the model to fetch the binary from
|
|
:param int id: id of the record from which to fetch the binary
|
|
:param str field: binary field
|
|
:param bool unique: add a max-age for the cache control
|
|
:param str filename: choose a filename
|
|
:param str filename_field: if not create an filename with model-id-field
|
|
:param bool download: apply headers to download the file
|
|
:param str mimetype: mintype of the field (for headers)
|
|
:param str default_mimetype: default mintype if no mintype found
|
|
:param str access_token: optional token for unauthenticated access
|
|
only available for ir.attachment
|
|
:returns: (status, headers, content)
|
|
"""
|
|
record, status = self._get_record_and_check(xmlid=xmlid, model=model, id=id, field=field, access_token=access_token)
|
|
|
|
if not record:
|
|
return (status or 404, [], None)
|
|
|
|
content, headers, status = None, [], None
|
|
|
|
if record._name == 'ir.attachment':
|
|
status, content, filename, mimetype, filehash = self._binary_ir_attachment_redirect_content(record, default_mimetype=default_mimetype)
|
|
if not content:
|
|
status, content, filename, mimetype, filehash = self._binary_record_content(
|
|
record, field=field, filename=filename, filename_field=filename_field,
|
|
default_mimetype='application/octet-stream')
|
|
|
|
status, headers = self._binary_set_headers(
|
|
status, filename, mimetype, unique, filehash=filehash, download=download)
|
|
|
|
return status, headers, content
|
|
|
|
def _response_by_status(self, status, headers, content):
|
|
if status == 304:
|
|
return werkzeug.wrappers.Response(status=status, headers=headers)
|
|
elif status == 301:
|
|
return request.redirect(content, code=301, local=False)
|
|
elif status != 200:
|
|
raise request.not_found()
|