diff --git a/requirements.txt b/requirements.txt index 275c9dfb40..1ec18d7bcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ fsspec>=2024.5.0 fsspec>=2025.3.0 fsspec[s3] paramiko +pyftpdlib python_slugify diff --git a/setup/storage_backend_ftp/odoo/addons/storage_backend_ftp b/setup/storage_backend_ftp/odoo/addons/storage_backend_ftp new file mode 120000 index 0000000000..18973453c2 --- /dev/null +++ b/setup/storage_backend_ftp/odoo/addons/storage_backend_ftp @@ -0,0 +1 @@ +../../../../storage_backend_ftp \ No newline at end of file diff --git a/setup/storage_backend_ftp/setup.py b/setup/storage_backend_ftp/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/storage_backend_ftp/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/storage_backend_ftp/README.rst b/storage_backend_ftp/README.rst new file mode 100644 index 0000000000..783956a9af --- /dev/null +++ b/storage_backend_ftp/README.rst @@ -0,0 +1,3 @@ +# storage_backend_ftp + +This is the README for storage_backend_ftp. \ No newline at end of file diff --git a/storage_backend_ftp/__init__.py b/storage_backend_ftp/__init__.py new file mode 100644 index 0000000000..0f00a6730d --- /dev/null +++ b/storage_backend_ftp/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import components diff --git a/storage_backend_ftp/__manifest__.py b/storage_backend_ftp/__manifest__.py new file mode 100644 index 0000000000..db72bf4b58 --- /dev/null +++ b/storage_backend_ftp/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Storage Backend FTP", + "summary": "Implement FTP Storage", + "version": "16.0.1.0.0", + "category": "Storage", + "website": "https://github.com/OCA/storage", + "author": " Acsone SA/NV,Odoo Community Association (OCA)", + "license": "LGPL-3", + "external_dependencies": {"python": ["pyftpdlib"]}, + "depends": ["storage_backend"], + "data": ["views/backend_storage_view.xml"], +} diff --git a/storage_backend_ftp/components/__init__.py b/storage_backend_ftp/components/__init__.py new file mode 100644 index 0000000000..72dbc3a308 --- /dev/null +++ b/storage_backend_ftp/components/__init__.py @@ -0,0 +1 @@ +from . import ftp_adapter diff --git a/storage_backend_ftp/components/ftp_adapter.py b/storage_backend_ftp/components/ftp_adapter.py new file mode 100644 index 0000000000..9e7669f74e --- /dev/null +++ b/storage_backend_ftp/components/ftp_adapter.py @@ -0,0 +1,167 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import errno +import io +import logging +import os +import ssl +from contextlib import contextmanager +from io import BytesIO + +from odoo.exceptions import UserError + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + +try: + import ftplib +except ImportError as err: # pragma: no cover + _logger.debug(err) + +FTP_SECURITY_TO_PROTOCOL = { + "tls": ssl.PROTOCOL_TLS, + "tlsv1": ssl.PROTOCOL_TLSv1, + "tlsv1_1": ssl.PROTOCOL_TLSv1_1, + "tlsv1_2": ssl.PROTOCOL_TLSv1_2, + "sslv2": "sslv2 has been deprecated due to security issues", + "sslv23": ssl.PROTOCOL_SSLv23, + "sslv3": "sslv3 has been deprecated due to security issues", +} + + +def ftp_mkdirs(client, path): + try: + client.mkd(path) + except IOError as e: + if e.errno == errno.ENOENT and path: + ftp_mkdirs(client, os.path.dirname(path)) + client.mkd(path) + else: + raise # pragma: no cover + + +class ImplicitFTPTLS(ftplib.FTP_TLS): + """FTP_TLS subclass that automatically wraps sockets in SSL to support implicit FTPS.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._sock = None + + @property + def sock(self): + """Return the socket.""" + return self._sock + + @sock.setter + def sock(self, value): + """When modifying the socket, ensure that it is ssl wrapped.""" + if value is not None and not isinstance(value, ssl.SSLSocket): + value = self.context.wrap_socket(value) + self._sock = value + + +@contextmanager +def ftp(backend): + security = None + prot_p = False + if backend.ftp_encryption in ["ftp", "tls", "tls_explicit"]: + if backend.ftp_encryption == "ftp": + ftp = ftplib.FTP(timeout=False) + elif backend.ftp_encryption == "tls": + ftp = ImplicitFTPTLS() + # Due to a bug into between ftplib and ssl, this part (about ssl) might not work! + # https://bugs.python.org/issue31727 + security = FTP_SECURITY_TO_PROTOCOL.get(backend.ftp_security, None) + prot_p = True + if isinstance(security, str): + raise UserError(security) + elif backend.ftp_encryption == "tls_explicit": + ftp = ftplib.FTP_TLS() + prot_p = True + with ftp as client: + if security: + client.ssl_version = security + client.connect(host=backend.ftp_server, port=backend.ftp_port) + client.login(backend.ftp_login, backend.ftp_password) + if prot_p: + client.prot_p() + if backend.ftp_passive: + client.set_pasv(True) + yield client + + +class FTPStorageBackendAdapter(Component): + _name = "ftp.adapter" + _inherit = "base.storage.adapter" + _usage = "ftp" + + def add(self, relative_path, data, **kwargs): + with ftp(self.collection) as client: + full_path = self._fullpath(relative_path) + dirname = os.path.dirname(full_path) + if dirname: + try: + client.cwd(dirname) + except IOError as e: + if e.errno == errno.ENOENT: + ftp_mkdirs(client, dirname) + else: + raise # pragma: no cover + with io.BytesIO(data) as tmp_file: + try: + client.storbinary("STOR " + full_path, tmp_file) + except ftplib.Error as e: + raise ValueError(repr(e)) from None + except OSError as e: + raise ValueError(repr(e)) from None + + def get(self, relative_path, **kwargs): + full_path = self._fullpath(relative_path) + with ftp(self.collection) as client, BytesIO() as buff: + try: + client.retrbinary("RETR " + full_path, buff.write) + data = buff.getvalue() + except ftplib.Error as e: + raise FileNotFoundError(repr(e)) from None + return data + + def list(self, relative_path): + full_path = self._fullpath(relative_path) + with ftp(self.collection) as client: + try: + return client.nlst(full_path) + except IOError as e: + if e.errno == errno.ENOENT: + # The path do not exist return an empty list + return [] + else: + raise # pragma: no cover + + def move_files(self, files, destination_path): + _logger.debug("mv %s %s", files, destination_path) + with ftp(self.collection) as client: + for ftp_file in files: + dest_file_path = os.path.join( + destination_path, os.path.basename(ftp_file) + ) + # Remove existing file at the destination path (an error is raised + # otherwise) + result = [] + try: + result = client.nlst(dest_file_path) + except ftplib.Error: + _logger.debug("destination %s is free", dest_file_path) + if result: + client.delete(dest_file_path) + # Move the file + client.rename(ftp_file, dest_file_path) + + def delete(self, relative_path): + full_path = self._fullpath(relative_path) + with ftp(self.collection) as client: + return client.delete(full_path) + + def validate_config(self): + with ftp(self.collection) as client: + client.getwelcome() diff --git a/storage_backend_ftp/i18n/de.po b/storage_backend_ftp/i18n/de.po new file mode 100644 index 0000000000..667542fc7b --- /dev/null +++ b/storage_backend_ftp/i18n/de.po @@ -0,0 +1,64 @@ +msgid "" +msgstr "" +"Project-Id-Version: ametras-xsolutions\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: ametras-xsolutions\n" +"X-Crowdin-Project-ID: 530964\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[AmetrasIntelligence.oca-migrated] 16.0/storage_backend_ftp/i18n/storage_backend_ftp.pot\n" +"X-Crowdin-File-ID: 21438\n" +"Language: de_DE\n" +"PO-Revision-Date: 2025-02-18 16:30\n" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__ftp +#: model_terms:ir.ui.view,arch_db:storage_backend_ftp.storage_backend_view_form +msgid "FTP" +msgstr "FTP" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__tls +msgid "Implicit FTP over TLS" +msgstr "Implizites FTP über TLS" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__none +msgid "None" +msgstr "Ohne" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv2 +msgid "SSLv2" +msgstr "SSLv2" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv23 +msgid "SSLv23" +msgstr "SSLv23" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv3 +msgid "SSLv3" +msgstr "SSLv3" + +#. module: storage_backend_ftp +#: model:ir.model,name:storage_backend_ftp.model_storage_backend +msgid "Storage Backend" +msgstr "Ablage-Backend" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1 +msgid "TLS" +msgstr "TLS" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1_1 +msgid "TLSv1_1" +msgstr "TLSv1_1" + diff --git a/storage_backend_ftp/i18n/fr.po b/storage_backend_ftp/i18n/fr.po new file mode 100644 index 0000000000..a724b08793 --- /dev/null +++ b/storage_backend_ftp/i18n/fr.po @@ -0,0 +1,64 @@ +msgid "" +msgstr "" +"Project-Id-Version: ametras-xsolutions\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: ametras-xsolutions\n" +"X-Crowdin-Project-ID: 530964\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[AmetrasIntelligence.oca-migrated] 16.0/storage_backend_ftp/i18n/storage_backend_ftp.pot\n" +"X-Crowdin-File-ID: 21438\n" +"Language: fr_FR\n" +"PO-Revision-Date: 2024-09-25 12:08\n" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__ftp +#: model_terms:ir.ui.view,arch_db:storage_backend_ftp.storage_backend_view_form +msgid "FTP" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__tls +msgid "Implicit FTP over TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__none +msgid "None" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv2 +msgid "SSLv2" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv23 +msgid "SSLv23" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv3 +msgid "SSLv3" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model,name:storage_backend_ftp.model_storage_backend +msgid "Storage Backend" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1 +msgid "TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1_1 +msgid "TLSv1_1" +msgstr "" + diff --git a/storage_backend_ftp/i18n/storage_backend_ftp.pot b/storage_backend_ftp/i18n/storage_backend_ftp.pot new file mode 100644 index 0000000000..fe59650c60 --- /dev/null +++ b/storage_backend_ftp/i18n/storage_backend_ftp.pot @@ -0,0 +1,60 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_backend_ftp +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__ftp +#: model_terms:ir.ui.view,arch_db:storage_backend_ftp.storage_backend_view_form +msgid "FTP" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__tls +msgid "Implicit FTP over TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__none +msgid "None" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv2 +msgid "SSLv2" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv23 +msgid "SSLv23" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv3 +msgid "SSLv3" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model,name:storage_backend_ftp.model_storage_backend +msgid "Storage Backend" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1 +msgid "TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1_1 +msgid "TLSv1_1" +msgstr "" diff --git a/storage_backend_ftp/models/__init__.py b/storage_backend_ftp/models/__init__.py new file mode 100644 index 0000000000..f45f402268 --- /dev/null +++ b/storage_backend_ftp/models/__init__.py @@ -0,0 +1 @@ +from . import storage_backend diff --git a/storage_backend_ftp/models/storage_backend.py b/storage_backend_ftp/models/storage_backend.py new file mode 100644 index 0000000000..0abcc8e024 --- /dev/null +++ b/storage_backend_ftp/models/storage_backend.py @@ -0,0 +1,58 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class StorageBackend(models.Model): + _inherit = "storage.backend" + + backend_type = fields.Selection( + selection_add=[("ftp", "FTP")], + ondelete={ + "ftp": "set default", + }, + ) + ftp_server = fields.Char(string="FTP Host") + ftp_port = fields.Integer(string="FTP Port", default=21) + ftp_encryption = fields.Selection( + string="FTP Encryption method", + selection=[ + ("ftp", "FTP"), + ("tls", "Implicit FTP over TLS"), + ("tls_explicit", "Explicit FTP over TLS"), + ], + default="ftp", + required=True, + ) + ftp_security = fields.Selection( + string="FTP security option", + selection=[ + ("none", "None"), + ("tlsv1", "TLS"), + ("tlsv1_1", "TLSv1_1"), + ("tlsv1_2", "TLSv1_2"), + ("sslv2", "SSLv2"), + ("sslv23", "SSLv23"), + ("sslv3", "SSLv3"), + ], + required=True, + ) + ftp_login = fields.Char(string="FTP Login", help="Login to connect to ftp server") + ftp_password = fields.Char(string="FTP Password") + ftp_passive = fields.Boolean(string="FTP Passive", default=False) + + @property + def _server_env_fields(self): + env_fields = super()._server_env_fields + env_fields.update( + { + "ftp_password": {}, + "ftp_login": {}, + "ftp_server": {}, + "ftp_port": {}, + "ftp_encryption": {}, + "ftp_security": {}, + "ftp_passive": {}, + } + ) + return env_fields diff --git a/storage_backend_ftp/static/description/icon.png b/storage_backend_ftp/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/storage_backend_ftp/static/description/icon.png differ diff --git a/storage_backend_ftp/static/description/index.html b/storage_backend_ftp/static/description/index.html new file mode 100644 index 0000000000..7b349c3ce1 --- /dev/null +++ b/storage_backend_ftp/static/description/index.html @@ -0,0 +1,445 @@ + + + + + + + Storage Backend FTP + + + +
+

Storage Backend FTP

+ + +

Beta License: LGPL-3 OCA/storage + Translate me on Weblate + Try me on Runboat

+

Add FTP as storage backend

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub + 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. +

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Acsone SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose + mission is to support the collaborative development of Odoo features and + promote its widespread use.

+

This module is part of the OCA/storage + project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. +

+
+
+
+ + diff --git a/storage_backend_ftp/views/backend_storage_view.xml b/storage_backend_ftp/views/backend_storage_view.xml new file mode 100644 index 0000000000..3d9de66113 --- /dev/null +++ b/storage_backend_ftp/views/backend_storage_view.xml @@ -0,0 +1,28 @@ + + + + storage.backend + + + + + + + + + + + + + + + + +