Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

exclude: |
(?x)
# NOT INSTALLABLE ADDONS
Expand Down
54 changes: 54 additions & 0 deletions base_ir_binary/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
==============
Base Ir Binary
==============

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:f2d6522aca9e1ecdcac676df7c86a617e2b0a150f34e6ac92af3855bb71b7326
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-Escodoo%2Fserver--addons-lightgray.png?logo=github
:target: https://github.com/Escodoo/server-addons/tree/14.0/base_ir_binary
:alt: Escodoo/server-addons

|badge1| |badge2| |badge3|


**Table of contents**

.. contents::
:local:

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/Escodoo/server-addons/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/Escodoo/server-addons/issues/new?body=module:%20base_ir_binary%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
~~~~~~~

* Escodoo

Maintainers
~~~~~~~~~~~

This module is part of the `Escodoo/server-addons <https://github.com/Escodoo/server-addons/tree/14.0/base_ir_binary>`_ project on GitHub.

You are welcome to contribute.
3 changes: 3 additions & 0 deletions base_ir_binary/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import models

from . import http
15 changes: 15 additions & 0 deletions base_ir_binary/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2023 - TODAY, Escodoo
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Base Ir Binary",
"summary": """
Base ir.binary""",
"version": "14.0.1.0.0",
"license": "AGPL-3",
"author": "Escodoo",
"website": "https://github.com/Escodoo/server-addons",
"depends": [],
"data": [],
"demo": [],
}
213 changes: 213 additions & 0 deletions base_ir_binary/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import base64
import contextlib
import os
from io import BytesIO
from os.path import join as opj
from pathlib import Path
from zlib import adler32

import werkzeug.local
import werkzeug.utils

from odoo.http import STATIC_CACHE_LONG, Response, request, root
from odoo.tools import config

from .misc import file_path

try:
from werkzeug.utils import send_file as _send_file
except ImportError:
from odoo.http import send_file as _send_file


class Stream:
"""
Send the content of a file, an attachment or a binary field via HTTP

This utility is safe, cache-aware and uses the best available
streaming strategy. Works best with the --x-sendfile cli option.

Create a Stream via one of the constructors: :meth:`~from_path`:,
:meth:`~from_attachment`: or :meth:`~from_binary_field`:, generate
the corresponding HTTP response object via :meth:`~get_response`:.

Instantiating a Stream object manually without using one of the
dedicated constructors is discouraged.
"""

type: str = "" # 'data' or 'path' or 'url'
data = None
path = None
url = None

mimetype = None
as_attachment = False
download_name = None
conditional = True
etag = True
last_modified = None
max_age = None
immutable = False
size = None

def __init__(self, **kwargs):
self.__dict__.update(kwargs)

@classmethod
def from_path(cls, path, filter_ext=("",)):
"""Create a :class:`~Stream`: from an addon resource."""
path = file_path(path, filter_ext)
check = adler32(path.encode())
stat = os.stat(path)
return cls(
type="path",
path=path,
download_name=os.path.basename(path),
etag=f"{int(stat.st_mtime)}-{stat.st_size}-{check}",
last_modified=stat.st_mtime,
size=stat.st_size,
)

@classmethod
def from_attachment(cls, attachment):
"""Create a :class:`~Stream`: from an ir.attachment record."""
attachment.ensure_one()

self = cls(
mimetype=attachment.mimetype,
download_name=attachment.name,
conditional=True,
etag=attachment.checksum,
)

if attachment.store_fname:
self.type = "path"
self.path = werkzeug.security.safe_join(
os.path.abspath(config.filestore(request.db)), attachment.store_fname
)
stat = os.stat(self.path)
self.last_modified = stat.st_mtime
self.size = stat.st_size

elif attachment.db_datas:
self.type = "data"
self.data = attachment.raw
self.last_modified = attachment["__last_update"]
self.size = len(self.data)

elif attachment.url:
# When the URL targets a file located in an addon, assume it
# is a path to the resource. It saves an indirection and
# stream the file right away.
static_path = root.get_static_file(
attachment.url, host=request.httprequest.environ.get("HTTP_HOST", "")
)
if static_path:
self = cls.from_path(static_path)
else:
self.type = "url"
self.url = attachment.url

else:
self.type = "data"
self.data = b""
self.size = 0

return self

@classmethod
def from_binary_field(cls, record, field_name):
"""Create a :class:`~Stream`: from a binary field."""
data_b64 = record[field_name]
data = base64.b64decode(data_b64) if data_b64 else b""
return cls(
type="data",
data=data,
etag=request.env["ir.attachment"]._compute_checksum(data),
last_modified=record["__last_update"] if record._log_access else None,
size=len(data),
)

# pylint: disable=method-required-super
def read(self):
"""Get the stream content as bytes."""
if self.type == "url":
raise ValueError("Cannot read an URL")

if self.type == "data":
return self.data

with open(self.path, "rb") as file:
return file.read()

def get_response(self, as_attachment=None, immutable=None, **send_file_kwargs):
"""
Create the corresponding :class:`~Response` for the current stream.

:param bool as_attachment: Indicate to the browser that it
should offer to save the file instead of displaying it.
:param bool immutable: Add the ``immutable`` directive to the
``Cache-Control`` response header, allowing intermediary
proxies to aggressively cache the response. This option
also set the ``max-age`` directive to 1 year.
:param send_file_kwargs: Other keyword arguments to send to
:func:`odoo.tools._vendor.send_file.send_file` instead of
the stream sensitive values. Discouraged.
"""
assert self.type in (
"url",
"data",
"path",
), "Invalid type: {self.type!r}, should be 'url', 'data' or 'path'."
assert (
getattr(self, self.type) is not None
), "There is nothing to stream, missing {self.type!r} attribute."

if self.type == "url":
return request.redirect(self.url, code=301, local=False)

if as_attachment is None:
as_attachment = self.as_attachment
if immutable is None:
immutable = self.immutable

send_file_kwargs = {
"mimetype": self.mimetype,
"as_attachment": as_attachment,
"download_name": self.download_name,
"conditional": self.conditional,
"etag": self.etag,
"last_modified": self.last_modified,
"max_age": STATIC_CACHE_LONG if immutable else self.max_age,
"environ": request.httprequest.environ,
"response_class": Response,
**send_file_kwargs,
}

if self.type == "data":
return _send_file(BytesIO(self.data), **send_file_kwargs)

# self.type == 'path'
send_file_kwargs["use_x_sendfile"] = False
if config["x_sendfile"]:
with contextlib.suppress(ValueError): # outside of the filestore
fspath = Path(self.path).relative_to(
opj(config["data_dir"], "filestore")
)
x_accel_redirect = f"/web/filestore/{fspath}"
send_file_kwargs["use_x_sendfile"] = True

res = _send_file(self.path, **send_file_kwargs)

if immutable and res.cache_control:
res.cache_control["immutable"] = None # None sets the directive

if "X-Sendfile" in res.headers:
res.headers["X-Accel-Redirect"] = x_accel_redirect

# In case of X-Sendfile/X-Accel-Redirect, the body is empty,
# yet werkzeug gives the length of the file. This makes
# NGINX wait for content that'll never arrive.
res.headers["Content-Length"] = "0"

return res
104 changes: 104 additions & 0 deletions base_ir_binary/misc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import os
from contextlib import ContextDecorator

import odoo
from odoo.tools import config


# pylint: disable=class-camelcase
class replace_exceptions(ContextDecorator):
"""
Hide some exceptions behind another error. Can be used as a function
decorator or as a context manager.

.. code-block:

@route('/super/secret/route', auth='public')
@replace_exceptions(AccessError, by=NotFound())
def super_secret_route(self):
if not request.session.uid:
raise AccessError("Route hidden to non logged-in users")
...

def some_util():
...
with replace_exceptions(ValueError, by=UserError("Invalid argument")):
...
...

:param exceptions: the exception classes to catch and replace.
:param by: the exception to raise instead.
"""

def __init__(self, *exceptions, by):
if not exceptions:
raise ValueError("Missing exceptions")

wrong_exc = next(
(exc for exc in exceptions if not issubclass(exc, Exception)), None
)
if wrong_exc:
raise TypeError(f"{wrong_exc} is not an exception class.")

self.exceptions = exceptions
self.by = by

def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, traceback):
if exc_type is not None and issubclass(exc_type, self.exceptions):
raise self.by from exc_value


def file_path(file_path, filter_ext=("",), env=None):
"""Verify that a file exists under a known `addons_path` directory and return its full path.

Examples::

>>> file_path('hr')
>>> file_path('hr/static/description/icon.png')
>>> file_path('hr/static/description/icon.png', filter_ext=('.png', '.jpg'))

:param str file_path: absolute file path, or relative path within any `addons_path`
directory
:param list[str] filter_ext: optional list of supported extensions (lowercase, with
leading dot)
:param env: optional environment, required for a file path within a temporary
directory
created using `file_open_temporary_directory()`
:return: the absolute path to the file
:raise FileNotFoundError: if the file is not found under the known `addons_path`
directories
:raise ValueError: if the file doesn't have one of the supported extensions
(`filter_ext`)
"""
root_path = os.path.abspath(config["root_path"])
addons_paths = odoo.addons.__path__ + [root_path]
if env and hasattr(env.transaction, "__file_open_tmp_paths"):
addons_paths += env.transaction.__file_open_tmp_paths
is_abs = os.path.isabs(file_path)
normalized_path = os.path.normpath(os.path.normcase(file_path))

if filter_ext and not normalized_path.lower().endswith(filter_ext):
raise ValueError("Unsupported file: " + file_path)

# ignore leading 'addons/' if present, it's the final component of root_path, but
# may sometimes be included in relative paths
if normalized_path.startswith("addons" + os.sep):
normalized_path = normalized_path[7:]

for addons_dir in addons_paths:
# final path sep required to avoid partial match
parent_path = os.path.normpath(os.path.normcase(addons_dir)) + os.sep
fpath = (
normalized_path
if is_abs
else os.path.normpath(
os.path.normcase(os.path.join(parent_path, normalized_path))
)
)
if fpath.startswith(parent_path) and os.path.exists(fpath):
return fpath

raise FileNotFoundError("File not found: " + file_path)
Loading