From 34976ac4b0d30d6b1d648151e91a1eb35f6bf722 Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Tue, 25 Oct 2022 11:30:41 +0530 Subject: [PATCH] kiwix: Add app for Kiwix offline Wikipedia reader Signed-off-by: Joseph Nuthalapati Reviewed-by: Sunil Mohan Adapa --- HACKING.md | 8 +- debian/copyright | 6 + plinth/modules/kiwix/__init__.py | 121 ++++++++++++++ .../data/etc/plinth/modules-enabled/kiwix | 1 + .../system/kiwix-server-freedombox.service | 47 ++++++ .../conf-available/kiwix-freedombox.conf | 6 + .../share/freedombox/modules-enabled/kiwix | 1 + plinth/modules/kiwix/forms.py | 25 +++ plinth/modules/kiwix/manifest.py | 20 +++ plinth/modules/kiwix/privileged.py | 87 +++++++++++ plinth/modules/kiwix/static/icons/kiwix.png | Bin 0 -> 10665 bytes plinth/modules/kiwix/static/icons/kiwix.svg | 20 +++ .../kiwix/templates/add-content-package.html | 58 +++++++ .../templates/delete-content-package.html | 29 ++++ plinth/modules/kiwix/templates/kiwix.html | 48 ++++++ plinth/modules/kiwix/tests/__init__.py | 0 .../modules/kiwix/tests/data/FreedomBox.zim | Bin 0 -> 90984 bytes plinth/modules/kiwix/tests/data/invalid.zim | 1 + .../kiwix/tests/data/sample_library_zim.xml | 4 + plinth/modules/kiwix/tests/test_functional.py | 98 ++++++++++++ plinth/modules/kiwix/tests/test_privileged.py | 86 ++++++++++ .../modules/kiwix/tests/test_validations.py | 21 +++ plinth/modules/kiwix/tests/test_views.py | 147 ++++++++++++++++++ plinth/modules/kiwix/urls.py | 16 ++ plinth/modules/kiwix/views.py | 105 +++++++++++++ 25 files changed, 950 insertions(+), 5 deletions(-) create mode 100644 plinth/modules/kiwix/__init__.py create mode 100644 plinth/modules/kiwix/data/etc/plinth/modules-enabled/kiwix create mode 100644 plinth/modules/kiwix/data/usr/lib/systemd/system/kiwix-server-freedombox.service create mode 100644 plinth/modules/kiwix/data/usr/share/freedombox/etc/apache2/conf-available/kiwix-freedombox.conf create mode 100644 plinth/modules/kiwix/data/usr/share/freedombox/modules-enabled/kiwix create mode 100644 plinth/modules/kiwix/forms.py create mode 100644 plinth/modules/kiwix/manifest.py create mode 100644 plinth/modules/kiwix/privileged.py create mode 100644 plinth/modules/kiwix/static/icons/kiwix.png create mode 100644 plinth/modules/kiwix/static/icons/kiwix.svg create mode 100644 plinth/modules/kiwix/templates/add-content-package.html create mode 100644 plinth/modules/kiwix/templates/delete-content-package.html create mode 100644 plinth/modules/kiwix/templates/kiwix.html create mode 100644 plinth/modules/kiwix/tests/__init__.py create mode 100644 plinth/modules/kiwix/tests/data/FreedomBox.zim create mode 100644 plinth/modules/kiwix/tests/data/invalid.zim create mode 100644 plinth/modules/kiwix/tests/data/sample_library_zim.xml create mode 100644 plinth/modules/kiwix/tests/test_functional.py create mode 100644 plinth/modules/kiwix/tests/test_privileged.py create mode 100644 plinth/modules/kiwix/tests/test_validations.py create mode 100644 plinth/modules/kiwix/tests/test_views.py create mode 100644 plinth/modules/kiwix/urls.py create mode 100644 plinth/modules/kiwix/views.py 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: +
    +
  • Offline versions of websites: Wikimedia projects, Stack Exchange
  • +
  • Video content: Khan Academy, TED Talks, Crash Course
  • +
  • Educational materials: PHET, TED Ed, Vikidia
  • +
  • eBooks: Project Gutenberg
  • +
  • Magazines: Low-tech Magazine
  • +
''') +] + +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 0000000000000000000000000000000000000000..0217628241302b53b8fbb8a80f9c4f9298e18d12 GIT binary patch literal 10665 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 literal 0 HcmV?d00001 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: +

    +
  • upload a ZIM file
  • + + + + +
+ + + + {% 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" %}

+ + + +
+
+ {% 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 0000000000000000000000000000000000000000..fb8b09080c11ad6dca736031579673b09e57d6c8 GIT binary patch literal 90984 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 + + + 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 + })