kiwix: Add app for Kiwix offline Wikipedia reader

Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
Joseph Nuthalapati 2022-10-25 11:30:41 +05:30 committed by Sunil Mohan Adapa
parent 16993e0b34
commit 34976ac4b0
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
25 changed files with 950 additions and 5 deletions

View File

@ -9,7 +9,7 @@ This document provides reference information for FreedomBox **contribution** hac
1. [Submitting your changes](#submitting-your-changes) 1. [Submitting your changes](#submitting-your-changes)
1. [Other related stuff](#miscelanea) 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 ## 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 Source code for FreedomBox Service is available from
[salsa.debian.org](https://salsa.debian.org/freedombox-team/freedombox). [salsa.debian.org](https://salsa.debian.org/freedombox-team/freedombox).
## Development environments: setting up and their usage ## Development environments: setting up and their usage
### Requirements for Development OS ### 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 install Debian to do development for FreedomBox. FreedomBox development is
typically done with a container or a Virtual Machine. 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. 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. versions of Git, Vagrant and VirtualBox.
In addition: In addition:

6
debian/copyright vendored
View File

@ -132,6 +132,12 @@ Files: plinth/modules/janus/static/icons/janus.png
Copyright: 2014-2022 Meetecho Copyright: 2014-2022 Meetecho
License: GPL-3 with OpenSSL exception 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 Files: static/themes/default/icons/macos.png
static/themes/default/icons/macos.svg static/themes/default/icons/macos.svg
Copyright: Vectors Market (https://thenounproject.com/vectorsmarket/) Copyright: Vectors Market (https://thenounproject.com/vectorsmarket/)

View File

@ -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:
<ul>
<li>Offline versions of websites: Wikimedia projects, Stack Exchange</li>
<li>Video content: Khan Academy, TED Talks, Crash Course</li>
<li>Educational materials: PHET, TED Ed, Vikidia</li>
<li>eBooks: Project Gutenberg</li>
<li>Magazines: Low-tech Magazine</li>
</ul>''')
]
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}")

View File

@ -0,0 +1 @@
plinth.modules.kiwix

View File

@ -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 '<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<library version=\"20110515\">\n</library>' > \"${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

View File

@ -0,0 +1,6 @@
##
## On all sites, provide kiwix web interface on a path: /kiwix
##
<Location /kiwix>
ProxyPass http://localhost:4201/kiwix
</Location>

View File

@ -0,0 +1 @@
plinth.modules.kiwix

View File

@ -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.'''))

View File

@ -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']
}

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="Layer_1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1256 1256"
style="enable-background:new 0 0 1256 1256;" xml:space="preserve">
<style type="text/css">
.st0{fill:#010101;}
</style>
<path class="st0" d="M1165,764.1c-8.3-36.4-68.5-141.3-191.6-234.4c-22.5-17.1-42.8-31.3-59.7-42.6
c24.6-105.3-103.3-232.3-228.1-172.5C596,230.3,496.1,195.9,404.2,197.3c-243.3,3.4-431,256.9-229.1,498.8c0.1,0.1,0.2,0.2,0.4,0.4
c3.1,3.7,6.3,7.4,9.5,11.1c13.1,15.7,21.8,29.6,29.2,54.1L274.4,959h-21.3c-19.6,0-35.6,15.9-35.6,35.6h80.8l135.8,64.2
c8.4-17.8,0.8-39-16.9-47.3l-35.6-16.8H484c0-19.6-15.9-35.6-35.6-35.6h-92.8c-16.2,0-30.6-10.6-35.3-26.1l-47.7-156.7
c-11.9-41.2,15.4-68.1,41.1-71.3c23.4-2.9,35.2,12.2,46.2,48.8l42.4,139h-21.3c-19.6,0-35.6,15.9-35.6,35.6h80.8l135.8,64.2
c8.4-17.8,0.8-39-16.9-47.3l-35.6-16.8h75.1c7.6,12.9,16.9,25.1,28,36.1c70,70,183.7,70,253.7,0s70-183.7,0-253.7s-183.7-70-253.7,0
c-49.2,49.2-63.9,120-43.9,182h-85c-16.2,0-30.6-10.6-35.3-26.1L378,635.4l12-6.4c167.1-70.1,345.8,55.1,470.2-65.2
c0.3-0.3,0.6-0.6,0.8-0.8c15.4-14,30.8-28.3,76.3,0.2c49,30.7,157.1,110.8,206.1,247.8C1143.5,811,1173.2,800.4,1165,764.1z
M821.2,460.6c-0.4-18.7-15.6-33.7-34.5-33.7c-19,0-34.5,15.4-34.5,34.5c0,10.4,4.6,19.6,11.8,25.9c-25-4.8-43.8-26.6-43.8-52.9
c0-29.8,24.1-53.9,53.9-53.9c29.8,0,53.9,24.1,53.9,53.9C828,443.9,825.5,452.8,821.2,460.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
<p>
{% blocktrans %}
You can <a href="https://library.kiwix.org"
target="_blank" rel="noopener noreferrer">download</a>
content packages from the Kiwix project or
<a href="https://openzim.org/wiki/Build_your_ZIM_file"
target="_blank" rel="noopener noreferrer">create</a> your own.
{% endblocktrans %}
</p>
<p>
{% blocktrans %}
Content packages can be added in the following ways:
<ul>
<li>upload a ZIM file</li>
<!-- <li>upload a BitTorrent file to download a ZIM file</li> -->
<!-- <li>provide a download link to a ZIM file</li> -->
<!-- <li>provide a magnet link to download a ZIM file</li> -->
<!-- <li>provide a file path to a ZIM file</li> -->
</ul>
<!-- TODO Add this somewhere -->
<!-- The Kiwix project recommends using BitTorrent for downloads. -->
{% endblocktrans %}
</p>
{% if max_filesize %}
<div class="alert alert-warning" role="alert">
<!-- <span class="fa fa-exclamation-triangle" aria-hidden="true"></span> -->
<!-- <span class="sr-only">{% trans "Caution:" %}</span> -->
{% blocktrans trimmed %}
You have {{ max_filesize }} of free disk space available.
{% endblocktrans %}
</div>
{% endif %}
<form class="form form-kiwix" enctype="multipart/form-data" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Upload file" %}"/>
</form>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<h3>
{% blocktrans trimmed %}
Delete content package <em>{{ name }}</em>
{% endblocktrans %}
</h3>
<p>
{% blocktrans trimmed %}
Delete this package permanently? You may add it back later if you have a copy of the ZIM file.
{% endblocktrans %}
</p>
<form class="form form-delete" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-md btn-danger"
value="{% blocktrans %}Delete {{ name }}{% endblocktrans %}"/>
</form>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends "app.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block configuration %}
{{ block.super }}
<h3>{% trans "Manage Content" %}</h3>
<div class="btn-toolbar">
<a href="{% url 'kiwix:add-content' %}" class="btn btn-default"
role="button" title="{% trans 'Add a content package' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Add' %}
</a>
</div>
<div class="row">
<div class="col-md-6">
{% if not packages %}
<p>{% trans 'No content available.' %}</p>
{% else %}
<div id="kiwix-packages" class="list-group list-group-two-column">
{% for id, package in packages.items %}
<div class="list-group-item">
<a id="{{ id }}"
class="primary"
href="/kiwix/viewer#{{ package.path }}"
title="{{ package.description }}">
{{ package.title }}
</a>
<a href="{% url 'kiwix:delete-content' id %}"
class="btn btn-default btn-sm secondary" role="button"
title="{% blocktrans with title=package.title %}Delete package {{ title }}{% endblocktrans %}">
<span class="fa fa-trash-o" aria-hidden="true"></span>
</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

Binary file not shown.

View File

@ -0,0 +1 @@
Nothing to see here.

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" ?>
<library version="20110515">
<book id="bc4f8cdf-5626-2b13-3860-0033deddfbea" path="content/FreedomBox.zim" title="FreedomBox" description="A sample content archive" language="eng" creator="FreedomBox" publisher="FreedomBox" tags="_ftindex:yes;_ftindex:yes;_pictures:yes;_videos:yes;_details:yes" date="2022-11-29" articleCount="1" mediaCount="1" size="88" />
</library>

View File

@ -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.

View File

@ -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 = '''<?xml version="1.0" encoding="UTF-8"?>
<library version="20110515">
</library>'''
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

View File

@ -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')

View File

@ -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')

View File

@ -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<zim_id>[a-zA-Z0-9-]+)/delete/$',
views.delete_content, name='delete-content'),
]

View File

@ -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
})