kiwix: Fix various issues after review

- Fix icon paths in copyright file.

- Minor refactoring.

- Add Kiwix library link to app page as well as users may want to see the
  content available before installing the app.

- Consolidate terminology to 'content package' for UI and just 'package'
internally.

- Drop unused SYSTEM_USER constant.

- Simplify the ExecStart= in systemd service file.

- Fix incorrect i18n caused by non-lazy formatting of strings.

- Confirm that xml parsing is not vulnerable as expat library of required
version is used in Debian bookworm.

- Don't start the kiwix daemon when managing library if app is disabled.

- Ignore errors when removing files during uninstallation.

- Handle failures more gracefully when library XML file does not have required
attributes.

- Update SVG/PNG icons to adhere to FreedomBox guidelines.

- Trim block translations in templates.

- Drop comments/deadcode inside translation strings.

- Drop a comment inside add content page that only makes sense with multiple
methods for adding content.

- tests: Don't use pkg_resources library as it is deprecated. We can use
importlib.resources library in future if we run tests on zip installations.

- Fix potential security issues while writing file to tmp directory.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
Sunil Mohan Adapa 2023-10-17 13:21:04 -07:00
parent 34976ac4b0
commit cfdf92cf0d
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
19 changed files with 301 additions and 289 deletions

4
debian/copyright vendored
View File

@ -132,8 +132,8 @@ 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
Files: plinth/modules/kiwix/static/icons/kiwix.png
plinth/modules/kiwix/static/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

View File

@ -5,13 +5,14 @@ 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 import app as app_module
from plinth import 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.firewall.components import (Firewall,
FirewallLocalProtection)
from plinth.modules.users.components import UsersAndGroups
from . import manifest, privileged
@ -28,11 +29,13 @@ _description = [
<li>Educational materials: PHET, TED Ed, Vikidia</li>
<li>eBooks: Project Gutenberg</li>
<li>Magazines: Low-tech Magazine</li>
</ul>''')
</ul>'''),
_('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.'),
]
SYSTEM_USER = 'kiwix'
class KiwixApp(app_module.App):
"""FreedomBox app for Kiwix."""
@ -116,6 +119,6 @@ class KiwixApp(app_module.App):
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}")
"""Check if the content package file has a valid extension."""
if not file_name.endswith('.zim'):
raise ValueError(f'Expected a ZIM file. Found {file_name}')

View File

@ -13,7 +13,7 @@ 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"
ExecStart=/usr/bin/kiwix-serve $ARGS $LIBRARY_PATH
Restart=on-failure
ExecReload=/bin/kill -HUP $MAINPID
DynamicUser=yes

View File

@ -8,18 +8,21 @@ from django.core import validators
from django.utils.translation import gettext_lazy as _
from plinth import cfg
from plinth.utils import format_lazy
from .privileged import KIWIX_HOME
class AddContentForm(forms.Form):
"""Form to create an empty library."""
class AddPackageForm(forms.Form):
"""Form to upload a content package to a 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.'''))
], help_text=format_lazy(
_('Uploaded ZIM files will be stored under {kiwix_home}/content '
'on your {box_name}. If Kiwix fails to add the file, it will be '
'deleted immediately to save disk space.'),
box_name=_(cfg.box_name), kiwix_home=KIWIX_HOME))

View File

@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
from plinth.clients import validate
clients = validate([{
'name': _('kiwix'),
'name': _('Kiwix'),
'platforms': [{
'type': 'web',
'url': '/kiwix'

View File

@ -3,10 +3,11 @@
Privileged actions for Kiwix content server.
"""
import subprocess
import os
import pathlib
import shutil
import xml.etree.ElementTree as ET
import subprocess
from xml.etree import ElementTree
from plinth import action_utils
from plinth.actions import privileged
@ -19,19 +20,19 @@ CONTENT_DIR = KIWIX_HOME / 'content'
@privileged
def add_content(file_name: str):
def add_package(file_name: str):
"""Adds a content package to Kiwix.
Adding packages is idempotent.
Users can add content to Kiwix in multiple ways:
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.
"""
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
@ -39,6 +40,8 @@ def add_content(file_name: str):
zim_file_name = pathlib.Path(file_name).name
CONTENT_DIR.mkdir(exist_ok=True)
zim_file_dest = str(CONTENT_DIR / zim_file_name)
shutil.chown(file_name, 'root', 'root')
os.chmod(file_name, 0o644)
shutil.move(file_name, zim_file_dest)
_kiwix_manage_add(zim_file_dest)
@ -48,40 +51,51 @@ 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')
action_utils.service_try_restart('kiwix-server-freedombox')
@privileged
def uninstall():
def uninstall() -> None:
"""Remove all content during uninstall."""
shutil.rmtree(str(CONTENT_DIR))
LIBRARY_FILE.unlink()
shutil.rmtree(str(CONTENT_DIR), ignore_errors=True)
LIBRARY_FILE.unlink(missing_ok=True)
@privileged
def list_content_packages() -> dict[str, dict]:
library = ET.parse(LIBRARY_FILE).getroot()
def list_packages() -> dict[str, dict[str, str]]:
"""Return the list of content packages configured in library file."""
library = ElementTree.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
}
books = {}
for book in library:
path = book.attrib['path'].split('/')[-1]
path = path.removesuffix('.zim').lower() # Strip '.zim' from the path
try:
books[book.attrib['id']] = {
'title': book.attrib['title'],
'description': book.attrib['description'],
'path': path
}
except KeyError:
pass # Ignore entries that don't have expected properties
return books
@privileged
def delete_content_package(zim_id: str):
library = ET.parse(LIBRARY_FILE).getroot()
def delete_package(zim_id: str):
"""Remove a content package from the library file."""
library = ElementTree.parse(LIBRARY_FILE).getroot()
for book in library:
if book.attrib['id'] == zim_id:
try:
if book.attrib['id'] != zim_id:
continue
subprocess.check_call(
['kiwix-manage', LIBRARY_FILE, 'remove', zim_id])
(KIWIX_HOME / book.attrib['path']).unlink()
action_utils.service_restart('kiwix-server-freedombox')
action_utils.service_try_restart('kiwix-server-freedombox')
return
except KeyError: # Expected properties not found on elements
pass

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,20 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- 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">
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 512 512"
xml:space="preserve"
sodipodi:docname="kiwix.svg"
width="512"
height="512"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs9" /><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="0.59282121"
inkscape:cx="448.70189"
inkscape:cy="609.79599"
inkscape:window-width="1504"
inkscape:window-height="1282"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="Layer_1" />
<style
type="text/css"
id="style2">
.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"/>
<path
class="st0"
d="m 511.31939,320.69463 c -3.94623,-17.30636 -32.56828,-67.181 -91.09611,-111.44534 -10.69761,-8.13018 -20.34923,-14.88156 -28.38433,-20.25414 11.69606,-50.06482 -49.11392,-110.446904 -108.45,-82.01502 C 240.78868,66.899736 193.29129,50.544276 149.59749,51.209906 33.920647,52.826436 -55.321204,173.35286 40.672028,288.36407 c 0.04754,0.0476 0.09509,0.0951 0.19018,0.19018 1.473893,1.75916 2.995331,3.51833 4.516769,5.27749 6.228389,7.46456 10.364799,14.0733 13.883124,25.72181 l 28.622054,93.80617 H 77.757083 c -9.318809,0 -16.925999,7.55964 -16.925999,16.926 h 38.416311 l 64.566025,30.52385 c 3.99378,-8.463 0.38036,-18.54253 -8.03509,-22.48876 l -16.926,-7.98755 h 48.68602 c 0,-9.31881 -7.55965,-16.926 -16.926,-16.926 h -44.1217 c -7.70228,0 -14.54876,-5.03976 -16.78337,-12.40923 L 87.028346,326.49511 c -5.657848,-19.58851 7.321921,-32.3781 19.540974,-33.89954 11.12551,-1.3788 16.73581,5.80048 21.96576,23.20193 l 20.15905,66.08747 h -10.12707 c -9.31881,0 -16.926,7.55964 -16.926,16.926 h 38.41631 l 64.56603,30.52385 c 3.99378,-8.463 0.38036,-18.54253 -8.03509,-22.48876 l -16.926,-7.98755 h 35.70625 c 3.61341,6.1333 8.03509,11.93378 13.31258,17.16373 33.28146,33.28145 87.34006,33.28145 120.62151,0 33.28146,-33.28146 33.28146,-87.34006 0,-120.62152 -33.28145,-33.28145 -87.34005,-33.28145 -120.62151,0 -23.39211,23.39211 -30.38122,57.05393 -20.87223,86.53179 h -40.4132 c -7.70228,0 -14.54875,-5.03976 -16.78336,-12.40923 l -33.47164,-110.01899 5.7054,-3.04287 c 79.44759,-33.329 164.4104,26.19726 223.5563,-30.9993 0.14264,-0.14264 0.28527,-0.28527 0.38036,-0.38036 7.32192,-6.65629 14.64384,-13.45522 36.27679,0.0951 23.29702,14.59629 74.69311,52.67979 97.99013,117.81636 0.0475,0 14.16839,-5.03977 10.2697,-22.29858 z M 347.85989,176.39574 c -0.19018,-8.8909 -7.41702,-16.02265 -16.40301,-16.02265 -9.03354,0 -16.403,7.32193 -16.403,16.40301 0,4.94467 2.18706,9.31881 5.6103,12.31414 -11.88623,-2.28216 -20.82468,-12.64696 -20.82468,-25.15127 0,-14.1684 11.45833,-25.62673 25.62672,-25.62673 14.16839,0 25.62672,11.45833 25.62672,25.62673 0,4.51676 -1.18862,8.74826 -3.23305,12.45677 z"
id="path4"
style="stroke-width:0.475449" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,58 +0,0 @@
{% 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,39 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
<p>
{% blocktrans trimmed %}
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>
{% if max_filesize %}
<div class="alert alert-warning" role="alert">
{% 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 ZIM file" %}"/>
</form>
{% endblock %}

View File

@ -15,7 +15,8 @@
<p>
{% blocktrans trimmed %}
Delete this package permanently? You may add it back later if you have a copy of the ZIM file.
Delete this package permanently? You may add it back later if you have a
copy of the ZIM file.
{% endblocktrans %}
</p>

View File

@ -8,32 +8,31 @@
{% block configuration %}
{{ block.super }}
<h3>{% trans "Manage Content" %}</h3>
<h3>{% trans "Manage Content Packages" %}</h3>
<div class="btn-toolbar">
<a href="{% url 'kiwix:add-content' %}" class="btn btn-default"
<a href="{% url 'kiwix:add-package' %}" class="btn btn-default"
role="button" title="{% trans 'Add a content package' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Add' %}
{% trans 'Add Package' %}
</a>
</div>
<div class="row">
<div class="col-md-6">
{% if not packages %}
<p>{% trans 'No content available.' %}</p>
<p>{% trans 'No content packages 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"
<a id="{{ id }}" class="primary"
href="/kiwix/viewer#{{ package.path }}"
title="{{ package.description }}">
title="{{ package.description }}">
{{ package.title }}
</a>
<a href="{% url 'kiwix:delete-content' id %}"
<a href="{% url 'kiwix:delete-package' 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>

View File

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

View File

@ -3,94 +3,97 @@
Functional, browser based tests for Kiwix app.
"""
import pkg_resources
import pathlib
from time import sleep
import pytest
from time import sleep
from plinth.modules.kiwix.tests.test_privileged import ZIM_ID
from plinth.tests import functional
from .test_privileged import ZIM_ID
pytestmark = [pytest.mark.apps, pytest.mark.sso, pytest.mark.kiwix]
_default_url = functional.config['DEFAULT']['url']
ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea'
_data_dir = pathlib.Path(__file__).parent / 'data'
class TestKiwixApp(functional.BaseAppTests):
"""Basic functional tests for Kiwix app."""
app_name = 'kiwix'
has_service = True
has_web = True
def test_add_delete_content_package(self, session_browser):
def test_add_delete_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')
zim_file = _data_dir / 'FreedomBox.zim'
_add_package(session_browser, str(zim_file))
assert _is_package_listed(session_browser, 'freedombox')
assert _is_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')
_delete_package(session_browser, ZIM_ID)
assert not _is_package_listed(session_browser, 'freedombox')
assert not _is_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)
zim_file = _data_dir / 'FreedomBox.zim'
_add_package(session_browser, str(zim_file))
functional.backup_create(session_browser, 'kiwix', 'test_kiwix')
_delete_content_package(session_browser, ZIM_ID)
_delete_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')
assert _is_package_listed(session_browser, 'freedombox')
assert _is_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)
zim_file = _data_dir / 'invalid.zim'
_add_package(session_browser, str(zim_file))
assert not _is_content_package_listed(session_browser, 'invalid')
assert not _is_package_listed(session_browser, 'invalid')
def _add_content_package(browser, file_name):
browser.links.find_by_href('/plinth/apps/kiwix/content/add/').first.click()
def _add_package(browser, file_name):
"""Add a package by uploading the ZIM file in kiwix app page."""
browser.links.find_by_href('/plinth/apps/kiwix/package/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:
def _is_package_available(browser, title) -> bool:
"""Check whether a ZIM file is available in Kiwix web interface."""
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))
return any(element.value == title for element in titles)
def _is_content_package_listed(browser, name) -> bool:
def _is_package_listed(browser, name) -> bool:
"""Return whether a content package is list in kiwix app page."""
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):
def _delete_package(browser, zim_id):
"""Delete a content package from the kiwix app page."""
functional.nav_to_module(browser, 'kiwix')
link = browser.links.find_by_href(
f'/plinth/apps/kiwix/content/{zim_id}/delete/')
f'/plinth/apps/kiwix/package/{zim_id}/delete/')
if not link:
raise ValueError('ZIM file missing!')
link.first.click()
functional.submit(browser, form_class='form-delete')

View File

@ -4,7 +4,7 @@ Test module for Kiwix actions.
"""
import pathlib
import pkg_resources
import shutil
from unittest.mock import patch
import pytest
@ -22,65 +22,57 @@ 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'))
def fixture_kiwix_home(tmp_path):
"""Create a new Kiwix home in a new temporary directory.
Initialize with a sample, valid library file.
"""
privileged.KIWIX_HOME = tmp_path / '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)
source_file = pathlib.Path(__file__).parent / 'data/sample_library_zim.xml'
shutil.copy(source_file, privileged.LIBRARY_FILE)
@pytest.fixture(autouse=True)
def fixture_patch():
"""Patch some underlying methods."""
with patch('subprocess.check_call'), patch('subprocess.run'):
with patch('subprocess.check_call'), patch('subprocess.run'), patch(
'os.chown'):
yield
def test_add_content(tmpdir):
def test_add_package(tmp_path):
"""Test adding a content package to Kiwix."""
some_dir = tmpdir / 'some' / 'dir'
pathlib.Path(some_dir).mkdir(parents=True, exist_ok=True)
some_dir = tmp_path / 'some' / 'dir'
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()
orig_file.touch()
privileged.add_content(str(orig_file))
privileged.add_package(str(orig_file))
assert (privileged.KIWIX_HOME / 'content' / zim_file_name).exists()
assert not orig_file.exists()
def test_list_content_packages():
def test_list_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] == {
content = privileged.list_packages()
assert content[ZIM_ID] == {
'title': 'FreedomBox',
'description': 'A sample content archive',
'path': 'freedombox'
}
def test_delete_content_package():
def test_delete_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)
privileged.delete_package(ZIM_ID)
assert not zim_file.exists()
# Cannot check that the book is removed from library_zim.xml

View File

@ -3,19 +3,18 @@
Test module for Kiwix validations.
"""
import unittest
import pytest
from plinth.modules import kiwix
class TestValidations(unittest.TestCase):
def test_add_file_with_invalid_extension():
"""Test that adding a file with invalid fails as expected."""
with pytest.raises(ValueError):
kiwix.validate_file_name('wikipedia.zip')
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.
with pytest.raises(ValueError):
kiwix.validate_file_name('wikipedia_en_all_maxi_2022-05.zima')
# 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')
kiwix.validate_file_name('wikipedia_en_all_maxi_2022-05.zim')

View File

@ -3,15 +3,15 @@
Test module for Kiwix views.
"""
from plinth import module_loader
from django import urls
import pathlib
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 django import urls
from django.contrib.messages.storage.fallback import FallbackStorage
from django.http.response import Http404
from plinth import module_loader
from plinth.modules.kiwix import views
# For all tests, use plinth.urls instead of urls configured for testing
@ -19,6 +19,8 @@ pytestmark = pytest.mark.urls('plinth.urls')
ZIM_ID = 'bc4f8cdf-5626-2b13-3860-0033deddfbea'
_data_dir = pathlib.Path(__file__).parent / 'data'
@pytest.fixture(autouse=True, scope='module')
def fixture_kiwix_urls():
@ -41,107 +43,88 @@ def make_request(request, view, **kwargs):
@pytest.fixture(autouse=True)
def kiwix_patch():
def fiture_kiwix_patch():
"""Patch kiwix methods."""
with patch('plinth.modules.kiwix.privileged.list_content_packages'
) as list_libraries:
with patch(
'plinth.modules.kiwix.privileged.list_packages') as list_libraries:
list_libraries.return_value = {
ZIM_ID: {
'title': 'TestExistingContentPackage',
'title': 'TestExistingPackage',
'description': 'A sample content package',
'path': 'test_existing_content_package'
'path': 'test_existing_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):
@patch('tempfile.TemporaryDirectory')
@patch('plinth.modules.kiwix.privileged.add_package')
def test_add_package(add_package, temp_dir_class, rf, tmp_path):
"""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')
temp_dir_class.return_value.__enter__.return_value = str(tmp_path)
with open(_data_dir / 'FreedomBox.zim', 'rb') as zim_file:
post_data = {'kiwix-file': zim_file}
request = rf.post('', data=post_data)
response, messages = make_request(request,
views.AddContentView.as_view())
views.AddPackageView.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')])
add_package.assert_has_calls([call(f'{tmp_path}/FreedomBox.zim')])
@patch('plinth.modules.kiwix.privileged.add_content')
def test_add_content_package_failed(add_content, rf):
@patch('plinth.modules.kiwix.privileged.add_package')
def test_add_package_failed(add_package, 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')
add_package.side_effect = RuntimeError('TestError')
with open(_data_dir / 'FreedomBox.zim', 'rb') as zim_file:
post_data = {'kiwix-file': zim_file}
request = rf.post('', data=post_data)
response, messages = make_request(request,
views.AddContentView.as_view())
views.AddPackageView.as_view())
assert response.status_code == 302
assert response.url == urls.reverse('kiwix:index')
assert list(messages)[0].message == \
'Failed to add content package.'
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)
"""Test that deleting content confirmation shows correct title."""
response, _ = make_request(rf.get(''), views.delete_package, zim_id=ZIM_ID)
assert response.status_code == 200
assert response.context_data['name'] == 'TestExistingContentPackage'
assert response.context_data['name'] == 'TestExistingPackage'
@patch('plinth.modules.kiwix.privileged.delete_content_package')
@patch('plinth.modules.kiwix.privileged.delete_package')
@patch('plinth.app.App.get')
def test_delete_content_package(_app, delete_content_package, rf):
def test_delete_package(_app, delete_package, rf):
"""Test that deleting a content package works."""
response, messages = make_request(rf.post(''), views.delete_content,
response, messages = make_request(rf.post(''), views.delete_package,
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)])
assert list(messages)[0].message == 'TestExistingPackage deleted.'
delete_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,
@patch('plinth.modules.kiwix.privileged.delete_package')
def test_delete_package_error(delete_package, rf):
"""Test that deleting content shows an error when operation fails."""
delete_package.side_effect = ValueError('TestError')
response, messages = make_request(rf.post(''), views.delete_package,
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'
'Could not delete TestExistingPackage: TestError'
def test_delete_content_package_non_existing(rf):
"""Test that deleting a content package shows error when operation fails."""
def test_delete_package_non_existing(rf):
"""Test that deleting content shows error when operation fails."""
with pytest.raises(Http404):
make_request(rf.post(''), views.delete_content,
make_request(rf.post(''), views.delete_package,
zim_id='NonExistentZimId')
with pytest.raises(Http404):
make_request(rf.get(''), views.delete_content,
make_request(rf.get(''), views.delete_package,
zim_id='NonExistentZimId')

View File

@ -9,8 +9,8 @@ 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'),
re_path(r'^apps/kiwix/package/add/$', views.AddPackageView.as_view(),
name='add-package'),
re_path(r'^apps/kiwix/package/(?P<zim_id>[a-zA-Z0-9-]+)/delete/$',
views.delete_package, name='delete-package'),
]

View File

@ -4,6 +4,7 @@ Views for the Kiwix module.
"""
import logging
import tempfile
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
@ -27,21 +28,23 @@ 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()
context['packages'] = privileged.list_packages()
return context
class AddContentView(SuccessMessageMixin, FormView):
"""View to add content in the form of ZIM files."""
form_class = forms.AddContentForm
class AddPackageView(SuccessMessageMixin, FormView):
"""View to add content package in the form of ZIM files."""
form_class = forms.AddPackageForm
prefix = 'kiwix'
template_name = 'add-content-package.html'
template_name = 'kiwix-add-package.html'
success_url = reverse_lazy('kiwix:index')
success_message = _('Content package added.')
@ -66,23 +69,26 @@ class AddContentView(SuccessMessageMixin, FormView):
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'))
with tempfile.TemporaryDirectory() as temp_dir:
zim_file_name = temp_dir + '/' + 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_package(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):
def delete_package(request, zim_id):
"""View to delete a library."""
packages = privileged.list_content_packages()
packages = privileged.list_packages()
if zim_id not in packages:
raise Http404
@ -90,8 +96,8 @@ def delete_content(request, zim_id):
if request.method == 'POST':
try:
privileged.delete_content_package(zim_id)
messages.success(request, _(f'{name} deleted.'))
privileged.delete_package(zim_id)
messages.success(request, _('{name} deleted.').format(name=name))
except Exception as error:
messages.error(
request,
@ -99,7 +105,7 @@ def delete_content(request, zim_id):
name=name, error=error))
return redirect(reverse_lazy('kiwix:index'))
return TemplateResponse(request, 'delete-content-package.html', {
return TemplateResponse(request, 'kiwix-delete-package.html', {
'title': app_module.App.get('kiwix').info.name,
'name': name
})