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