1# -*- coding: utf-8 -*- 2#---------------------------------------------------------- 3# ir_http modular http routing 4#---------------------------------------------------------- 5import base64 6import hashlib 7import logging 8import mimetypes 9import os 10import re 11import sys 12import traceback 13 14import werkzeug 15import werkzeug.exceptions 16import werkzeug.routing 17import werkzeug.urls 18import werkzeug.utils 19 20import odoo 21from odoo import api, http, models, tools, SUPERUSER_ID 22from odoo.exceptions import AccessDenied, AccessError, MissingError 23from odoo.http import request, content_disposition 24from odoo.tools import consteq, pycompat 25from odoo.tools.mimetypes import guess_mimetype 26from odoo.modules.module import get_resource_path, get_module_path 27 28from odoo.http import ALLOWED_DEBUG_MODES 29from odoo.tools.misc import str2bool 30 31_logger = logging.getLogger(__name__) 32 33 34class RequestUID(object): 35 def __init__(self, **kw): 36 self.__dict__.update(kw) 37 38 39class ModelConverter(werkzeug.routing.BaseConverter): 40 41 def __init__(self, url_map, model=False): 42 super(ModelConverter, self).__init__(url_map) 43 self.model = model 44 self.regex = r'([0-9]+)' 45 46 def to_python(self, value): 47 _uid = RequestUID(value=value, converter=self) 48 env = api.Environment(request.cr, _uid, request.context) 49 return env[self.model].browse(int(value)) 50 51 def to_url(self, value): 52 return value.id 53 54 55class ModelsConverter(werkzeug.routing.BaseConverter): 56 57 def __init__(self, url_map, model=False): 58 super(ModelsConverter, self).__init__(url_map) 59 self.model = model 60 # TODO add support for slug in the form [A-Za-z0-9-] bla-bla-89 -> id 89 61 self.regex = r'([0-9,]+)' 62 63 def to_python(self, value): 64 _uid = RequestUID(value=value, converter=self) 65 env = api.Environment(request.cr, _uid, request.context) 66 return env[self.model].browse(int(v) for v in value.split(',')) 67 68 def to_url(self, value): 69 return ",".join(value.ids) 70 71 72class SignedIntConverter(werkzeug.routing.NumberConverter): 73 regex = r'-?\d+' 74 num_convert = int 75 76 77class IrHttp(models.AbstractModel): 78 _name = 'ir.http' 79 _description = "HTTP Routing" 80 81 #------------------------------------------------------ 82 # Routing map 83 #------------------------------------------------------ 84 85 @classmethod 86 def _get_converters(cls): 87 return {'model': ModelConverter, 'models': ModelsConverter, 'int': SignedIntConverter} 88 89 @classmethod 90 def _match(cls, path_info, key=None): 91 return cls.routing_map().bind_to_environ(request.httprequest.environ).match(path_info=path_info, return_rule=True) 92 93 @classmethod 94 def _auth_method_user(cls): 95 request.uid = request.session.uid 96 if not request.uid: 97 raise http.SessionExpiredException("Session expired") 98 99 @classmethod 100 def _auth_method_none(cls): 101 request.uid = None 102 103 @classmethod 104 def _auth_method_public(cls): 105 if not request.session.uid: 106 request.uid = request.env.ref('base.public_user').id 107 else: 108 request.uid = request.session.uid 109 110 @classmethod 111 def _authenticate(cls, endpoint): 112 auth_method = endpoint.routing["auth"] 113 if request._is_cors_preflight(endpoint): 114 auth_method = 'none' 115 try: 116 if request.session.uid: 117 try: 118 request.session.check_security() 119 # what if error in security.check() 120 # -> res_users.check() 121 # -> res_users._check_credentials() 122 except (AccessDenied, http.SessionExpiredException): 123 # All other exceptions mean undetermined status (e.g. connection pool full), 124 # let them bubble up 125 request.session.logout(keep_db=True) 126 if request.uid is None: 127 getattr(cls, "_auth_method_%s" % auth_method)() 128 except (AccessDenied, http.SessionExpiredException, werkzeug.exceptions.HTTPException): 129 raise 130 except Exception: 131 _logger.info("Exception during request Authentication.", exc_info=True) 132 raise AccessDenied() 133 return auth_method 134 135 @classmethod 136 def _handle_debug(cls): 137 # Store URL debug mode (might be empty) into session 138 if 'debug' in request.httprequest.args: 139 debug_mode = [] 140 for debug in request.httprequest.args['debug'].split(','): 141 if debug not in ALLOWED_DEBUG_MODES: 142 debug = '1' if str2bool(debug, debug) else '' 143 debug_mode.append(debug) 144 debug_mode = ','.join(debug_mode) 145 146 # Write on session only when needed 147 if debug_mode != request.session.debug: 148 request.session.debug = debug_mode 149 150 @classmethod 151 def _serve_attachment(cls): 152 env = api.Environment(request.cr, SUPERUSER_ID, request.context) 153 attach = env['ir.attachment'].get_serve_attachment(request.httprequest.path, extra_fields=['name', 'checksum']) 154 if attach: 155 wdate = attach[0]['__last_update'] 156 datas = attach[0]['datas'] or b'' 157 name = attach[0]['name'] 158 checksum = attach[0]['checksum'] or hashlib.sha512(datas).hexdigest()[:64] # sha512/256 159 160 if (not datas and name != request.httprequest.path and 161 name.startswith(('http://', 'https://', '/'))): 162 return werkzeug.utils.redirect(name, 301) 163 164 response = werkzeug.wrappers.Response() 165 response.last_modified = wdate 166 167 response.set_etag(checksum) 168 response.make_conditional(request.httprequest) 169 170 if response.status_code == 304: 171 return response 172 173 response.mimetype = attach[0]['mimetype'] or 'application/octet-stream' 174 response.data = base64.b64decode(datas) 175 return response 176 177 @classmethod 178 def _serve_fallback(cls, exception): 179 # serve attachment 180 attach = cls._serve_attachment() 181 if attach: 182 return attach 183 return False 184 185 @classmethod 186 def _handle_exception(cls, exception): 187 # in case of Exception, e.g. 404, we don't step into _dispatch 188 cls._handle_debug() 189 190 # If handle_exception returns something different than None, it will be used as a response 191 192 # This is done first as the attachment path may 193 # not match any HTTP controller 194 if isinstance(exception, werkzeug.exceptions.HTTPException) and exception.code == 404: 195 serve = cls._serve_fallback(exception) 196 if serve: 197 return serve 198 199 # Don't handle exception but use werkzeug debugger if server in --dev mode 200 # Don't intercept JSON request to respect the JSON Spec and return exception as JSON 201 # "The Response is expressed as a single JSON Object, with the following members: 202 # jsonrpc, result, error, id" 203 if ('werkzeug' in tools.config['dev_mode'] 204 and not isinstance(exception, werkzeug.exceptions.NotFound) 205 and request._request_type != 'json'): 206 raise exception 207 208 try: 209 return request._handle_exception(exception) 210 except AccessDenied: 211 return werkzeug.exceptions.Forbidden() 212 213 @classmethod 214 def _dispatch(cls): 215 cls._handle_debug() 216 217 # locate the controller method 218 try: 219 rule, arguments = cls._match(request.httprequest.path) 220 func = rule.endpoint 221 except werkzeug.exceptions.NotFound as e: 222 return cls._handle_exception(e) 223 224 # check authentication level 225 try: 226 auth_method = cls._authenticate(func) 227 except Exception as e: 228 return cls._handle_exception(e) 229 230 processing = cls._postprocess_args(arguments, rule) 231 if processing: 232 return processing 233 234 # set and execute handler 235 try: 236 request.set_handler(func, arguments, auth_method) 237 result = request.dispatch() 238 if isinstance(result, Exception): 239 raise result 240 except Exception as e: 241 return cls._handle_exception(e) 242 243 return result 244 245 @classmethod 246 def _postprocess_args(cls, arguments, rule): 247 """ post process arg to set uid on browse records """ 248 for key, val in list(arguments.items()): 249 # Replace uid placeholder by the current request.uid 250 if isinstance(val, models.BaseModel) and isinstance(val._uid, RequestUID): 251 arguments[key] = val.with_user(request.uid) 252 253 @classmethod 254 def _generate_routing_rules(cls, modules, converters): 255 return http._generate_routing_rules(modules, False, converters) 256 257 @classmethod 258 def routing_map(cls, key=None): 259 260 if not hasattr(cls, '_routing_map'): 261 cls._routing_map = {} 262 cls._rewrite_len = {} 263 264 if key not in cls._routing_map: 265 _logger.info("Generating routing map for key %s" % str(key)) 266 installed = request.registry._init_modules | set(odoo.conf.server_wide_modules) 267 if tools.config['test_enable'] and odoo.modules.module.current_test: 268 installed.add(odoo.modules.module.current_test) 269 mods = sorted(installed) 270 # Note : when routing map is generated, we put it on the class `cls` 271 # to make it available for all instance. Since `env` create an new instance 272 # of the model, each instance will regenared its own routing map and thus 273 # regenerate its EndPoint. The routing map should be static. 274 routing_map = werkzeug.routing.Map(strict_slashes=False, converters=cls._get_converters()) 275 for url, endpoint, routing in cls._generate_routing_rules(mods, converters=cls._get_converters()): 276 xtra_keys = 'defaults subdomain build_only strict_slashes redirect_to alias host'.split() 277 kw = {k: routing[k] for k in xtra_keys if k in routing} 278 rule = werkzeug.routing.Rule(url, endpoint=endpoint, methods=routing['methods'], **kw) 279 rule.merge_slashes = False 280 routing_map.add(rule) 281 cls._routing_map[key] = routing_map 282 return cls._routing_map[key] 283 284 @classmethod 285 def _clear_routing_map(cls): 286 if hasattr(cls, '_routing_map'): 287 cls._routing_map = {} 288 _logger.debug("Clear routing map") 289 290 #------------------------------------------------------ 291 # Binary server 292 #------------------------------------------------------ 293 294 @classmethod 295 def _xmlid_to_obj(cls, env, xmlid): 296 return env.ref(xmlid, False) 297 298 def _get_record_and_check(self, xmlid=None, model=None, id=None, field='datas', access_token=None): 299 # get object and content 300 record = None 301 if xmlid: 302 record = self._xmlid_to_obj(self.env, xmlid) 303 elif id and model in self.env: 304 record = self.env[model].browse(int(id)) 305 306 # obj exists 307 if not record or field not in record: 308 return None, 404 309 310 try: 311 if model == 'ir.attachment': 312 record_sudo = record.sudo() 313 if access_token and not consteq(record_sudo.access_token or '', access_token): 314 return None, 403 315 elif (access_token and consteq(record_sudo.access_token or '', access_token)): 316 record = record_sudo 317 elif record_sudo.public: 318 record = record_sudo 319 elif self.env.user.has_group('base.group_portal'): 320 # Check the read access on the record linked to the attachment 321 # eg: Allow to download an attachment on a task from /my/task/task_id 322 record.check('read') 323 record = record_sudo 324 325 # check read access 326 try: 327 # We have prefetched some fields of record, among which the field 328 # 'write_date' used by '__last_update' below. In order to check 329 # access on record, we have to invalidate its cache first. 330 if not record.env.su: 331 record._cache.clear() 332 record['__last_update'] 333 except AccessError: 334 return None, 403 335 336 return record, 200 337 except MissingError: 338 return None, 404 339 340 @classmethod 341 def _binary_ir_attachment_redirect_content(cls, record, default_mimetype='application/octet-stream'): 342 # mainly used for theme images attachemnts 343 status = content = filename = filehash = None 344 mimetype = getattr(record, 'mimetype', False) 345 if record.type == 'url' and record.url: 346 # if url in in the form /somehint server locally 347 url_match = re.match("^/(\w+)/(.+)$", record.url) 348 if url_match: 349 module = url_match.group(1) 350 module_path = get_module_path(module) 351 module_resource_path = get_resource_path(module, url_match.group(2)) 352 353 if module_path and module_resource_path: 354 module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/' 355 module_resource_path = os.path.normpath(module_resource_path) 356 if module_resource_path.startswith(module_path): 357 with open(module_resource_path, 'rb') as f: 358 content = base64.b64encode(f.read()) 359 status = 200 360 filename = os.path.basename(module_resource_path) 361 mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype) 362 filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest() 363 364 if not content: 365 status = 301 366 content = record.url 367 368 return status, content, filename, mimetype, filehash 369 370 def _binary_record_content( 371 self, record, field='datas', filename=None, 372 filename_field='name', default_mimetype='application/octet-stream'): 373 374 model = record._name 375 mimetype = 'mimetype' in record and record.mimetype or False 376 content = None 377 filehash = 'checksum' in record and record['checksum'] or False 378 379 field_def = record._fields[field] 380 if field_def.type == 'binary' and field_def.attachment and not field_def.related: 381 if model != 'ir.attachment': 382 field_attachment = self.env['ir.attachment'].sudo().search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['datas', 'mimetype', 'checksum'], limit=1) 383 if field_attachment: 384 mimetype = field_attachment[0]['mimetype'] 385 content = field_attachment[0]['datas'] 386 filehash = field_attachment[0]['checksum'] 387 else: 388 mimetype = record['mimetype'] 389 content = record['datas'] 390 filehash = record['checksum'] 391 392 if not content: 393 content = record[field] or '' 394 395 # filename 396 default_filename = False 397 if not filename: 398 if filename_field in record: 399 filename = record[filename_field] 400 if not filename: 401 default_filename = True 402 filename = "%s-%s-%s" % (record._name, record.id, field) 403 404 if not mimetype: 405 try: 406 decoded_content = base64.b64decode(content) 407 except base64.binascii.Error: # if we could not decode it, no need to pass it down: it would crash elsewhere... 408 return (404, [], None) 409 mimetype = guess_mimetype(decoded_content, default=default_mimetype) 410 411 # extension 412 _, existing_extension = os.path.splitext(filename) 413 if not existing_extension or default_filename: 414 extension = mimetypes.guess_extension(mimetype) 415 if extension: 416 filename = "%s%s" % (filename, extension) 417 418 if not filehash: 419 filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest() 420 421 status = 200 if content else 404 422 return status, content, filename, mimetype, filehash 423 424 def _binary_set_headers(self, status, content, filename, mimetype, unique, filehash=None, download=False): 425 headers = [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff')] 426 # cache 427 etag = bool(request) and request.httprequest.headers.get('If-None-Match') 428 status = status or 200 429 if filehash: 430 headers.append(('ETag', filehash)) 431 if etag == filehash and status == 200: 432 status = 304 433 headers.append(('Cache-Control', 'max-age=%s' % (http.STATIC_CACHE_LONG if unique else 0))) 434 # content-disposition default name 435 if download: 436 headers.append(('Content-Disposition', content_disposition(filename))) 437 438 return (status, headers, content) 439 440 def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas', 441 unique=False, filename=None, filename_field='name', download=False, 442 mimetype=None, default_mimetype='application/octet-stream', 443 access_token=None): 444 """ Get file, attachment or downloadable content 445 446 If the ``xmlid`` and ``id`` parameter is omitted, fetches the default value for the 447 binary field (via ``default_get``), otherwise fetches the field for 448 that precise record. 449 450 :param str xmlid: xmlid of the record 451 :param str model: name of the model to fetch the binary from 452 :param int id: id of the record from which to fetch the binary 453 :param str field: binary field 454 :param bool unique: add a max-age for the cache control 455 :param str filename: choose a filename 456 :param str filename_field: if not create an filename with model-id-field 457 :param bool download: apply headers to download the file 458 :param str mimetype: mintype of the field (for headers) 459 :param str default_mimetype: default mintype if no mintype found 460 :param str access_token: optional token for unauthenticated access 461 only available for ir.attachment 462 :returns: (status, headers, content) 463 """ 464 record, status = self._get_record_and_check(xmlid=xmlid, model=model, id=id, field=field, access_token=access_token) 465 466 if not record: 467 return (status or 404, [], None) 468 469 content, headers, status = None, [], None 470 471 if record._name == 'ir.attachment': 472 status, content, filename, mimetype, filehash = self._binary_ir_attachment_redirect_content(record, default_mimetype=default_mimetype) 473 if not content: 474 status, content, filename, mimetype, filehash = self._binary_record_content( 475 record, field=field, filename=filename, filename_field=filename_field, 476 default_mimetype='application/octet-stream') 477 478 status, headers, content = self._binary_set_headers( 479 status, content, filename, mimetype, unique, filehash=filehash, download=download) 480 481 return status, headers, content 482 483 def _response_by_status(self, status, headers, content): 484 if status == 304: 485 return werkzeug.wrappers.Response(status=status, headers=headers) 486 elif status == 301: 487 return werkzeug.utils.redirect(content, code=301) 488 elif status != 200: 489 return request.not_found() 490