diff --git a/HACKING.md b/HACKING.md index cd4332a09..4cec539ec 100644 --- a/HACKING.md +++ b/HACKING.md @@ -9,7 +9,7 @@ This document provides reference information for FreedomBox **contribution** hac 1. [Submitting your changes](#submitting-your-changes) 1. [Other related stuff](#miscelanea) -It doesn't cover arquitecture, design choices, or other product internals. +It doesn't cover architecture, design choices or other product internals. ## Picking a task to work on @@ -20,8 +20,6 @@ Newcomers will find easy, self-contained tasks tagged as "beginner". Source code for FreedomBox Service is available from [salsa.debian.org](https://salsa.debian.org/freedombox-team/freedombox). - - ## Development environments: setting up and their usage ### Requirements for Development OS @@ -30,9 +28,9 @@ FreedomBox is built as part of Debian GNU/Linux. However, you don't need to install Debian to do development for FreedomBox. FreedomBox development is typically done with a container or a Virtual Machine. -* For running a container, you need systemd containers, Git, Python3 and a +* To run a container, you need systemd containers, Git, Python3 and a sudo-enabled user. This approach is recommended. -* For running a VM, you can work on any operating system that can install latest +* To run a VM, you can work on any operating system that can install latest versions of Git, Vagrant and VirtualBox. In addition: diff --git a/debian/copyright b/debian/copyright index 3cf95666f..6e8cf2899 100644 --- a/debian/copyright +++ b/debian/copyright @@ -132,6 +132,12 @@ Files: plinth/modules/janus/static/icons/janus.png Copyright: 2014-2022 Meetecho License: GPL-3 with OpenSSL exception +Files: static/themes/default/icons/kiwix.png + static/themes/default/icons/kiwix.svg +Copyright: 2020 The other Kiwix guy +Comment: https://commons.wikimedia.org/wiki/File:Kiwix_logo_v3.svg +License: CC-BY-SA-4.0 + Files: static/themes/default/icons/macos.png static/themes/default/icons/macos.svg Copyright: Vectors Market (https://thenounproject.com/vectorsmarket/) diff --git a/plinth/modules/kiwix/__init__.py b/plinth/modules/kiwix/__init__.py new file mode 100644 index 000000000..7243ddcb2 --- /dev/null +++ b/plinth/modules/kiwix/__init__.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for Kiwix content server. +""" + +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module, frontpage, menu, package +from plinth.config import DropinConfigs +from plinth.daemon import Daemon +from plinth.modules.apache.components import Webserver +from plinth.modules.backups.components import BackupRestore +from plinth.modules.kiwix import manifest +from plinth.modules.firewall.components import Firewall, FirewallLocalProtection +from plinth.modules.users.components import UsersAndGroups + +from . import manifest, privileged + +_description = [ + _('Kiwix is an offline reader for web content. It is software intended ' + 'to make Wikipedia available without using the internet, but it is ' + 'potentially suitable for all HTML content. Kiwix packages are in the ' + 'ZIM file format.'), + _('''Kiwix can host various kinds of content: + ''') +] + +SYSTEM_USER = 'kiwix' + + +class KiwixApp(app_module.App): + """FreedomBox app for Kiwix.""" + + app_id = 'kiwix' + + _version = 1 + + DAEMON = 'kiwix-server-freedombox' + + def __init__(self): + """Create components for the app.""" + super().__init__() + + groups = {'kiwix': _('Manage Kiwix content server')} + + info = app_module.Info( + app_id=self.app_id, version=self._version, name=_('Kiwix'), + icon_filename='kiwix', short_description=_('Offline Wikipedia'), + description=_description, manual_page='Kiwix', + clients=manifest.clients, + donation_url='https://www.kiwix.org/en/support-us/') + self.add(info) + + menu_item = menu.Menu('menu-kiwix', info.name, info.short_description, + info.icon_filename, 'kiwix:index', + parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-kiwix', info.name, + short_description=info.short_description, + icon=info.icon_filename, url='/kiwix', + clients=info.clients, + login_required=True, + allowed_groups=list(groups)) + self.add(shortcut) + + packages = package.Packages('packages-kiwix', ['kiwix-tools']) + self.add(packages) + + dropin_configs = DropinConfigs('dropin-configs-kiwix', [ + '/etc/apache2/conf-available/kiwix-freedombox.conf', + ]) + self.add(dropin_configs) + + firewall = Firewall('firewall-kiwix', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + firewall_local_protection = FirewallLocalProtection( + 'firewall-local-protection-kiwix', ['4201']) + self.add(firewall_local_protection) + + webserver = Webserver('webserver-kiwix', 'kiwix-freedombox', + urls=['https://{host}/kiwix']) + self.add(webserver) + + daemon = Daemon('daemon-kiwix', self.DAEMON, + listen_ports=[(4201, 'tcp4')]) + self.add(daemon) + + users_and_groups = UsersAndGroups('users-and-groups-kiwix', + reserved_usernames=['kiwix'], + groups=groups) + self.add(users_and_groups) + + backup_restore = BackupRestore('backup-restore-kiwix', + **manifest.backup) + self.add(backup_restore) + + def setup(self, old_version=None): + """Install and configure the app.""" + super().setup(old_version) + if not old_version: + self.enable() + + def uninstall(self): + """De-configure and uninstall the app.""" + super().uninstall() + privileged.uninstall() + + +def validate_file_name(file_name: str): + """Check if the content archive file has a valid extension.""" + if not file_name.endswith(".zim"): + raise ValueError(f"Expected a ZIM file. Found {file_name}") diff --git a/plinth/modules/kiwix/data/etc/plinth/modules-enabled/kiwix b/plinth/modules/kiwix/data/etc/plinth/modules-enabled/kiwix new file mode 100644 index 000000000..83bc06248 --- /dev/null +++ b/plinth/modules/kiwix/data/etc/plinth/modules-enabled/kiwix @@ -0,0 +1 @@ +plinth.modules.kiwix diff --git a/plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service b/plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service new file mode 100644 index 000000000..7abe49c60 --- /dev/null +++ b/plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +[Unit] +Description=Kiwix Content Server +Documentation=man:kiwix-serve(1) +After=network.target +ConditionPathExists=/usr/bin/kiwix-serve + +[Service] +CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER CAP_NET_ADMIN CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE CAP_KILL CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_LINUX_IMMUTABLE CAP_IPC_LOCK CAP_SYS_CHROOT CAP_BLOCK_SUSPEND CAP_LEASE CAP_SYS_PACCT CAP_SYS_TTY_CONFIG CAP_SYS_BOOT CAP_MAC_ADMIN CAP_MAC_OVERRIDE CAP_SYS_NICE CAP_SYS_RESOURCE +DevicePolicy=closed +Environment=HOME="/var/lib/kiwix-server-freedombox" +Environment=LIBRARY_PATH="/var/lib/kiwix-server-freedombox/library_zim.xml" +Environment=ARGS="--library --port=4201 --urlRootLocation=kiwix" +ExecStartPre=sh -e -c "mkdir -p $HOME/content; library=$$(ls ${LIBRARY_PATH} 2>/dev/null || true); [ \"x$${library}\" = \"x\" ] && (mkdir -p \"${HOME}\" && echo '\n\n' > \"${LIBRARY_PATH}\") || true" +ExecStart=sh -e -c "exec /usr/bin/kiwix-serve $ARGS $LIBRARY_PATH" +Restart=on-failure +ExecReload=/bin/kill -HUP $MAINPID +DynamicUser=yes +LockPersonality=yes +NoNewPrivileges=yes +PrivateDevices=yes +PrivateMounts=yes +PrivateTmp=yes +PrivateUsers=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +StateDirectory=kiwix-server-freedombox +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@resources +SystemCallFilter=~@privileged +SystemCallErrorNumber=EPERM +Type=simple + +[Install] +WantedBy=multi-user.target diff --git a/plinth/modules/kiwix/data/usr/share/freedombox/etc/apache2/conf-available/kiwix-freedombox.conf b/plinth/modules/kiwix/data/usr/share/freedombox/etc/apache2/conf-available/kiwix-freedombox.conf new file mode 100644 index 000000000..e88eb58cc --- /dev/null +++ b/plinth/modules/kiwix/data/usr/share/freedombox/etc/apache2/conf-available/kiwix-freedombox.conf @@ -0,0 +1,6 @@ +## +## On all sites, provide kiwix web interface on a path: /kiwix +## + + ProxyPass http://localhost:4201/kiwix + diff --git a/plinth/modules/kiwix/data/usr/share/freedombox/modules-enabled/kiwix b/plinth/modules/kiwix/data/usr/share/freedombox/modules-enabled/kiwix new file mode 100644 index 000000000..83bc06248 --- /dev/null +++ b/plinth/modules/kiwix/data/usr/share/freedombox/modules-enabled/kiwix @@ -0,0 +1 @@ +plinth.modules.kiwix diff --git a/plinth/modules/kiwix/forms.py b/plinth/modules/kiwix/forms.py new file mode 100644 index 000000000..c926c36ca --- /dev/null +++ b/plinth/modules/kiwix/forms.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Forms for the Kiwix module. +""" + +from django import forms +from django.core import validators +from django.utils.translation import gettext_lazy as _ + +from plinth import cfg + +from .privileged import KIWIX_HOME + + +class AddContentForm(forms.Form): + """Form to create an empty library.""" + + # Would be nice to have a progress bar when uploading large files. + file = forms.FileField( + label=_('Upload File'), required=True, validators=[ + validators.FileExtensionValidator( + ['zim'], _('Content packages have to be in .zim format')) + ], help_text=_(f'''Uploaded ZIM files will be stored under + {KIWIX_HOME}/content on your {cfg.box_name}. If Kiwix fails to add the file, + it will be deleted immediately to save disk space.''')) diff --git a/plinth/modules/kiwix/manifest.py b/plinth/modules/kiwix/manifest.py new file mode 100644 index 000000000..e7e3bc3d8 --- /dev/null +++ b/plinth/modules/kiwix/manifest.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.utils.translation import gettext_lazy as _ + +from plinth.clients import validate + +clients = validate([{ + 'name': _('kiwix'), + 'platforms': [{ + 'type': 'web', + 'url': '/kiwix' + }] +}]) + +backup = { + 'data': { + 'directories': ['/var/lib/private/kiwix-server-freedombox/'] + }, + 'services': ['kiwix-server-freedombox'] +} diff --git a/plinth/modules/kiwix/privileged.py b/plinth/modules/kiwix/privileged.py new file mode 100644 index 000000000..5a47db19b --- /dev/null +++ b/plinth/modules/kiwix/privileged.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Privileged actions for Kiwix content server. +""" + +import subprocess +import pathlib +import shutil +import xml.etree.ElementTree as ET + +from plinth import action_utils +from plinth.actions import privileged +from plinth.modules import kiwix + +# Only one central library is supported. +KIWIX_HOME = pathlib.Path('/var/lib/kiwix-server-freedombox') +LIBRARY_FILE = KIWIX_HOME / 'library_zim.xml' +CONTENT_DIR = KIWIX_HOME / 'content' + + +@privileged +def add_content(file_name: str): + """Adds a content package to Kiwix. + + Adding packages is idempotent. + + Users can add content to Kiwix in multiple ways: + - Upload a ZIM file + - Provide a link to the ZIM file + - Provide a magnet link to the ZIM file + + The commandline download manager aria2c is a dependency of kiwix-tools. + aria2c is used for both HTTP and Magnet downloads. + """ + kiwix.validate_file_name(file_name) + + # Moving files to the Kiwix library path ensures that + # they can't be removed by other apps or users. + zim_file_name = pathlib.Path(file_name).name + CONTENT_DIR.mkdir(exist_ok=True) + zim_file_dest = str(CONTENT_DIR / zim_file_name) + shutil.move(file_name, zim_file_dest) + + _kiwix_manage_add(zim_file_dest) + + +def _kiwix_manage_add(zim_file: str): + subprocess.check_call(['kiwix-manage', LIBRARY_FILE, 'add', zim_file]) + + # kiwix-serve doesn't read the library file unless it is restarted. + action_utils.service_restart('kiwix-server-freedombox') + + +@privileged +def uninstall(): + """Remove all content during uninstall.""" + shutil.rmtree(str(CONTENT_DIR)) + LIBRARY_FILE.unlink() + + +@privileged +def list_content_packages() -> dict[str, dict]: + library = ET.parse(LIBRARY_FILE).getroot() + + # Relying on the fact that Python dictionaries maintain order of insertion. + return { + book.attrib['id']: { + 'title': book.attrib['title'], + 'description': book.attrib['description'], + # strip '.zim' from the path + 'path': book.attrib['path'].split('/')[-1][:-4].lower() + } + for book in library + } + + +@privileged +def delete_content_package(zim_id: str): + library = ET.parse(LIBRARY_FILE).getroot() + + for book in library: + if book.attrib['id'] == zim_id: + subprocess.check_call( + ['kiwix-manage', LIBRARY_FILE, 'remove', zim_id]) + (KIWIX_HOME / book.attrib['path']).unlink() + action_utils.service_restart('kiwix-server-freedombox') + return diff --git a/plinth/modules/kiwix/static/icons/kiwix.png b/plinth/modules/kiwix/static/icons/kiwix.png new file mode 100644 index 000000000..021762824 Binary files /dev/null and b/plinth/modules/kiwix/static/icons/kiwix.png differ diff --git a/plinth/modules/kiwix/static/icons/kiwix.svg b/plinth/modules/kiwix/static/icons/kiwix.svg new file mode 100644 index 000000000..566b1064b --- /dev/null +++ b/plinth/modules/kiwix/static/icons/kiwix.svg @@ -0,0 +1,20 @@ + + + + + + diff --git a/plinth/modules/kiwix/templates/add-content-package.html b/plinth/modules/kiwix/templates/add-content-package.html new file mode 100644 index 000000000..53011c404 --- /dev/null +++ b/plinth/modules/kiwix/templates/add-content-package.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +

+ {% blocktrans %} + You can download + content packages from the Kiwix project or + create your own. + {% endblocktrans %} +

+ +

+ {% blocktrans %} + Content packages can be added in the following ways: +

+ + + + {% endblocktrans %} +

+ + {% if max_filesize %} + + {% endif %} + +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ +{% endblock %} diff --git a/plinth/modules/kiwix/templates/delete-content-package.html b/plinth/modules/kiwix/templates/delete-content-package.html new file mode 100644 index 000000000..70f209af7 --- /dev/null +++ b/plinth/modules/kiwix/templates/delete-content-package.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block content %} + +

+ {% blocktrans trimmed %} + Delete content package {{ name }} + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Delete this package permanently? You may add it back later if you have a copy of the ZIM file. + {% endblocktrans %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/plinth/modules/kiwix/templates/kiwix.html b/plinth/modules/kiwix/templates/kiwix.html new file mode 100644 index 000000000..45fb03c7d --- /dev/null +++ b/plinth/modules/kiwix/templates/kiwix.html @@ -0,0 +1,48 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block configuration %} + {{ block.super }} + +

{% trans "Manage Content" %}

+ +
+ + + {% trans 'Add' %} + +
+ +
+
+ {% if not packages %} +

{% trans 'No content available.' %}

+ {% else %} +
+ {% for id, package in packages.items %} + + {% endfor %} +
+ {% endif %} +
+
+ +{% endblock %} diff --git a/plinth/modules/kiwix/tests/__init__.py b/plinth/modules/kiwix/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/kiwix/tests/data/FreedomBox.zim b/plinth/modules/kiwix/tests/data/FreedomBox.zim new file mode 100644 index 000000000..fb8b09080 Binary files /dev/null and b/plinth/modules/kiwix/tests/data/FreedomBox.zim differ diff --git a/plinth/modules/kiwix/tests/data/invalid.zim b/plinth/modules/kiwix/tests/data/invalid.zim new file mode 100644 index 000000000..bd7fab739 --- /dev/null +++ b/plinth/modules/kiwix/tests/data/invalid.zim @@ -0,0 +1 @@ +Nothing to see here. \ No newline at end of file diff --git a/plinth/modules/kiwix/tests/data/sample_library_zim.xml b/plinth/modules/kiwix/tests/data/sample_library_zim.xml new file mode 100644 index 000000000..791cc6988 --- /dev/null +++ b/plinth/modules/kiwix/tests/data/sample_library_zim.xml @@ -0,0 +1,4 @@ + + + + diff --git a/plinth/modules/kiwix/tests/test_functional.py b/plinth/modules/kiwix/tests/test_functional.py new file mode 100644 index 000000000..02d364939 --- /dev/null +++ b/plinth/modules/kiwix/tests/test_functional.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Functional, browser based tests for Kiwix app. +""" + +import pkg_resources +import pytest + +from time import sleep +from plinth.modules.kiwix.tests.test_privileged import ZIM_ID + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.sso, pytest.mark.kiwix] + +_default_url = functional.config['DEFAULT']['url'] + +ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea' + + +class TestKiwixApp(functional.BaseAppTests): + app_name = 'kiwix' + has_service = True + has_web = True + + def test_add_delete_content_package(self, session_browser): + """Test adding/deleting content package to the library.""" + functional.app_enable(session_browser, 'kiwix') + + zim_file = pkg_resources.resource_filename( + 'plinth.modules.kiwix.tests', 'data/FreedomBox.zim') + _add_content_package(session_browser, zim_file) + assert _is_content_package_listed(session_browser, 'freedombox') + assert _is_content_package_available(session_browser, 'FreedomBox') + + _delete_content_package(session_browser, ZIM_ID) + assert not _is_content_package_listed(session_browser, 'freedombox') + assert not _is_content_package_available(session_browser, 'FreedomBox') + + @pytest.mark.backups + def test_backup_restore(self, session_browser): + """Test backing up and restoring.""" + functional.app_enable(session_browser, 'kiwix') + + zim_file = pkg_resources.resource_filename( + 'plinth.modules.kiwix.tests', 'data/FreedomBox.zim') + _add_content_package(session_browser, zim_file) + functional.backup_create(session_browser, 'kiwix', 'test_kiwix') + + _delete_content_package(session_browser, ZIM_ID) + functional.backup_restore(session_browser, 'kiwix', 'test_kiwix') + + assert _is_content_package_listed(session_browser, 'freedombox') + assert _is_content_package_available(session_browser, 'FreedomBox') + + def test_add_invalid_zim_file(self, session_browser): + """Test handling of invalid zim files.""" + functional.app_enable(session_browser, 'kiwix') + + zim_file = pkg_resources.resource_filename( + 'plinth.modules.kiwix.tests', 'data/invalid.zim') + _add_content_package(session_browser, zim_file) + + assert not _is_content_package_listed(session_browser, 'invalid') + + +def _add_content_package(browser, file_name): + browser.links.find_by_href('/plinth/apps/kiwix/content/add/').first.click() + browser.attach_file('kiwix-file', file_name) + functional.submit(browser, form_class='form-kiwix') + + +def _is_content_package_available(browser, title) -> bool: + browser.visit(f'{_default_url}/kiwix') + sleep(1) # Allow time for the books to appear + titles = browser.find_by_id('book__title') + print(len(titles)) + print([title.value for title in titles]) + return any(map(lambda e: e.value == title, titles)) + + +def _is_content_package_listed(browser, name) -> bool: + functional.nav_to_module(browser, 'kiwix') + links_found = browser.links.find_by_partial_href(f'/kiwix/viewer#{name}') + return len(links_found) == 1 + + +def _delete_content_package(browser, zim_id): + functional.nav_to_module(browser, 'kiwix') + link = browser.links.find_by_href( + f'/plinth/apps/kiwix/content/{zim_id}/delete/') + if not link: + raise ValueError('ZIM file missing!') + link.first.click() + functional.submit(browser, form_class='form-delete') + + +# TODO Add test to check that Kiwix can be viewed without logging in. diff --git a/plinth/modules/kiwix/tests/test_privileged.py b/plinth/modules/kiwix/tests/test_privileged.py new file mode 100644 index 000000000..3050c70b3 --- /dev/null +++ b/plinth/modules/kiwix/tests/test_privileged.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test module for Kiwix actions. +""" + +import pathlib +import pkg_resources +from unittest.mock import patch + +import pytest + +from plinth.modules.kiwix import privileged + +pytestmark = pytest.mark.usefixtures('mock_privileged') +privileged_modules_to_mock = ['plinth.modules.kiwix.privileged'] + +EMPTY_LIBRARY_CONTENTS = ''' + +''' + +ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea' + + +@pytest.fixture(autouse=True) +def fixture_kiwix_home(tmpdir): + """Set Kiwix home to a new temporary directory + initialized with an empty library file.""" + privileged.KIWIX_HOME = pathlib.Path(str(tmpdir / 'kiwix')) + privileged.KIWIX_HOME.mkdir() + privileged.CONTENT_DIR = privileged.KIWIX_HOME / 'content' + privileged.CONTENT_DIR.mkdir() + privileged.LIBRARY_FILE = privileged.KIWIX_HOME / 'library_zim.xml' + with open(privileged.LIBRARY_FILE, 'w', encoding='utf_8') as library_file: + library_file.write(EMPTY_LIBRARY_CONTENTS) + + +@pytest.fixture(autouse=True) +def fixture_patch(): + """Patch some underlying methods.""" + with patch('subprocess.check_call'), patch('subprocess.run'): + yield + + +def test_add_content(tmpdir): + """Test adding a content package to Kiwix.""" + some_dir = tmpdir / 'some' / 'dir' + pathlib.Path(some_dir).mkdir(parents=True, exist_ok=True) + zim_file_name = 'wikipedia_en_all_maxi_2022-05.zim' + orig_file = some_dir / zim_file_name + pathlib.Path(orig_file).touch() + + privileged.add_content(str(orig_file)) + assert (privileged.KIWIX_HOME / 'content' / zim_file_name).exists() + assert not orig_file.exists() + + +def test_list_content_packages(): + """Test listing the content packages from a library file.""" + privileged.LIBRARY_FILE = pkg_resources.resource_filename( + 'plinth.modules.kiwix.tests', 'data/sample_library_zim.xml') + content_packages = privileged.list_content_packages() + assert content_packages[ZIM_ID] == { + 'title': 'FreedomBox', + 'description': 'A sample content archive', + 'path': 'freedombox' + } + + +def test_delete_content_package(): + """Test deleting one content package.""" + sample_library_file = pkg_resources.resource_filename( + 'plinth.modules.kiwix.tests', 'data/sample_library_zim.xml') + + with open(sample_library_file, 'r', + encoding='utf_8') as sample_library_file: + with open(privileged.LIBRARY_FILE, 'w', + encoding='utf_8') as library_file: + library_file.write(sample_library_file.read()) + + zim_file = privileged.CONTENT_DIR / 'FreedomBox.zim' + zim_file.touch() + + privileged.delete_content_package(ZIM_ID) + + assert not zim_file.exists() + # Cannot check that the book is removed from library_zim.xml diff --git a/plinth/modules/kiwix/tests/test_validations.py b/plinth/modules/kiwix/tests/test_validations.py new file mode 100644 index 000000000..f4113e131 --- /dev/null +++ b/plinth/modules/kiwix/tests/test_validations.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test module for Kiwix validations. +""" + +import unittest +from plinth.modules import kiwix + + +class TestValidations(unittest.TestCase): + + def test_add_file_with_invalid_extension(self): + self.assertRaises(ValueError, + lambda: kiwix.validate_file_name('wikipedia.zip')) + + # We don't support the legacy format of split zim files. + self.assertRaises( + ValueError, lambda: kiwix.validate_file_name( + 'wikipedia_en_all_maxi_2022-05.zima')) + + kiwix.validate_file_name('wikipedia_en_all_maxi_2022-05.zim') diff --git a/plinth/modules/kiwix/tests/test_views.py b/plinth/modules/kiwix/tests/test_views.py new file mode 100644 index 000000000..099ccf290 --- /dev/null +++ b/plinth/modules/kiwix/tests/test_views.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test module for Kiwix views. +""" + +from plinth import module_loader +from django import urls +from unittest.mock import call, patch +from django.contrib.messages.storage.fallback import FallbackStorage +from django.http.response import Http404 +from django.test.client import encode_multipart, RequestFactory + +import pytest + +from plinth.modules.kiwix import views + +# For all tests, use plinth.urls instead of urls configured for testing +pytestmark = pytest.mark.urls('plinth.urls') + +ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea' + + +@pytest.fixture(autouse=True, scope='module') +def fixture_kiwix_urls(): + """Make sure kiwix app's URLs are part of plinth.urls.""" + with patch('plinth.module_loader._modules_to_load', new=[]) as modules, \ + patch('plinth.urls.urlpatterns', new=[]): + modules.append('plinth.modules.kiwix') + module_loader.include_urls() + yield + + +def make_request(request, view, **kwargs): + """Make request with a message storage.""" + setattr(request, 'session', 'session') + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = view(request, **kwargs) + + return response, messages + + +@pytest.fixture(autouse=True) +def kiwix_patch(): + """Patch kiwix methods.""" + with patch('plinth.modules.kiwix.privileged.list_content_packages' + ) as list_libraries: + list_libraries.return_value = { + ZIM_ID: { + 'title': 'TestExistingContentPackage', + 'description': 'A sample content package', + 'path': 'test_existing_content_package' + } + } + yield + + +@pytest.fixture() +def storage_info_patch(): + """Patch storage info method.""" + with patch('plinth.modules.storage.get_mount_info') as get_mount_info: + get_mount_info.return_value = {'free_bytes': 1000000000000} + yield + + +@patch('plinth.modules.kiwix.privileged.add_content') +def test_add_content_package(add_content, rf): + """Test that adding content view works.""" + with open('plinth/modules/kiwix/tests/data/FreedomBox.zim', + 'rb') as zim_file: + post_data = { + 'kiwix-file': zim_file, + } + post_data = encode_multipart('BoUnDaRyStRiNg', post_data) + request = rf.post( + '', data=post_data, content_type='multipart/form-data; ' + 'boundary=BoUnDaRyStRiNg') + response, messages = make_request(request, + views.AddContentView.as_view()) + assert response.status_code == 302 + assert response.url == urls.reverse('kiwix:index') + assert list(messages)[0].message == 'Content package added.' + add_content.assert_has_calls([call('/tmp/FreedomBox.zim')]) + + +@patch('plinth.modules.kiwix.privileged.add_content') +def test_add_content_package_failed(add_content, rf): + """Test that adding content package fails in case of an error.""" + add_content.side_effect = RuntimeError('TestError') + with open('plinth/modules/kiwix/tests/data/FreedomBox.zim', + 'rb') as zim_file: + post_data = { + 'kiwix-file': zim_file, + } + post_data = encode_multipart('BoUnDaRyStRiNg', post_data) + request = rf.post( + '', data=post_data, content_type='multipart/form-data; ' + 'boundary=BoUnDaRyStRiNg') + response, messages = make_request(request, + views.AddContentView.as_view()) + assert response.status_code == 302 + assert response.url == urls.reverse('kiwix:index') + assert list(messages)[0].message == \ + 'Failed to add content package.' + + +@patch('plinth.app.App.get') +def test_delete_package_confirmation_view(_app, rf): + """Test that deleting package confirmation shows correct title.""" + response, _ = make_request(rf.get(''), views.delete_content, zim_id=ZIM_ID) + assert response.status_code == 200 + assert response.context_data['name'] == 'TestExistingContentPackage' + + +@patch('plinth.modules.kiwix.privileged.delete_content_package') +@patch('plinth.app.App.get') +def test_delete_content_package(_app, delete_content_package, rf): + """Test that deleting a content package works.""" + response, messages = make_request(rf.post(''), views.delete_content, + zim_id=ZIM_ID) + assert response.status_code == 302 + assert response.url == urls.reverse('kiwix:index') + assert list(messages)[0].message == 'TestExistingContentPackage deleted.' + delete_content_package.assert_has_calls([call(ZIM_ID)]) + + +@patch('plinth.modules.kiwix.privileged.delete_content_package') +def test_delete_content_package_error(delete_content_package, rf): + """Test that deleting a content package shows an error when operation fails.""" + delete_content_package.side_effect = ValueError('TestError') + response, messages = make_request(rf.post(''), views.delete_content, + zim_id=ZIM_ID) + assert response.status_code == 302 + assert response.url == urls.reverse('kiwix:index') + assert list(messages)[0].message == \ + 'Could not delete TestExistingContentPackage: TestError' + + +def test_delete_content_package_non_existing(rf): + """Test that deleting a content package shows error when operation fails.""" + with pytest.raises(Http404): + make_request(rf.post(''), views.delete_content, + zim_id='NonExistentZimId') + + with pytest.raises(Http404): + make_request(rf.get(''), views.delete_content, + zim_id='NonExistentZimId') diff --git a/plinth/modules/kiwix/urls.py b/plinth/modules/kiwix/urls.py new file mode 100644 index 000000000..1170d12b5 --- /dev/null +++ b/plinth/modules/kiwix/urls.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +URLs for the Kiwix module. +""" + +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path(r'^apps/kiwix/$', views.KiwixAppView.as_view(), name='index'), + re_path(r'^apps/kiwix/content/add/$', views.AddContentView.as_view(), + name='add-content'), + re_path(r'^apps/kiwix/content/(?P[a-zA-Z0-9-]+)/delete/$', + views.delete_content, name='delete-content'), +] diff --git a/plinth/modules/kiwix/views.py b/plinth/modules/kiwix/views.py new file mode 100644 index 000000000..470a6f6f5 --- /dev/null +++ b/plinth/modules/kiwix/views.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Views for the Kiwix module. +""" + +import logging + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404 +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic.edit import FormView + +from plinth import app as app_module +from plinth import views +from plinth.errors import PlinthError +from plinth.modules import storage +from plinth.modules.kiwix import privileged + +from . import forms + +logger = logging.getLogger(__name__) + + +class KiwixAppView(views.AppView): + """Serve configuration form.""" + app_id = 'kiwix' + template_name = 'kiwix.html' + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['packages'] = privileged.list_content_packages() + return context + + +class AddContentView(SuccessMessageMixin, FormView): + """View to add content in the form of ZIM files.""" + form_class = forms.AddContentForm + prefix = 'kiwix' + template_name = 'add-content-package.html' + success_url = reverse_lazy('kiwix:index') + success_message = _('Content package added.') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Add a new content package') + + # TODO The following is almost duplicated in backups/views.py + try: + mount_info = storage.get_mount_info('/') + except PlinthError as exception: + logger.exception( + 'Error getting information about root partition: %s', + exception) + else: + context['max_filesize'] = storage.format_bytes( + mount_info['free_bytes']) + + return context + + def form_valid(self, form): + """Store the uploaded file.""" + multipart_file = self.request.FILES['kiwix-file'] + zim_file_name = '/tmp/' + multipart_file.name + with open(zim_file_name, 'wb+') as zim_file: + for chunk in multipart_file.chunks(): + zim_file.write(chunk) + + try: + privileged.add_content(zim_file_name) + except Exception: + messages.error(self.request, _('Failed to add content package.')) + return redirect(reverse_lazy('kiwix:index')) + + return super().form_valid(form) + + +def delete_content(request, zim_id): + """View to delete a library.""" + packages = privileged.list_content_packages() + if zim_id not in packages: + raise Http404 + + name = packages[zim_id]['title'] + + if request.method == 'POST': + try: + privileged.delete_content_package(zim_id) + messages.success(request, _(f'{name} deleted.')) + except Exception as error: + messages.error( + request, + _('Could not delete {name}: {error}').format( + name=name, error=error)) + return redirect(reverse_lazy('kiwix:index')) + + return TemplateResponse(request, 'delete-content-package.html', { + 'title': app_module.App.get('kiwix').info.name, + 'name': name + })