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 000000000..021762824
Binary files /dev/null and b/plinth/modules/kiwix/static/icons/kiwix.png differ
diff --git a/plinth/modules/kiwix/static/icons/kiwix.svg b/plinth/modules/kiwix/static/icons/kiwix.svg
new file mode 100644
index 000000000..566b1064b
--- /dev/null
+++ b/plinth/modules/kiwix/static/icons/kiwix.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/plinth/modules/kiwix/templates/add-content-package.html b/plinth/modules/kiwix/templates/add-content-package.html
new file mode 100644
index 000000000..53011c404
--- /dev/null
+++ b/plinth/modules/kiwix/templates/add-content-package.html
@@ -0,0 +1,58 @@
+{% extends "base.html" %}
+{% comment %}
+# SPDX-License-Identifier: AGPL-3.0-or-later
+{% endcomment %}
+
+{% load bootstrap %}
+{% load i18n %}
+
+{% block content %}
+
+
{{ title }}
+
+
+ {% blocktrans %}
+ You can download
+ content packages from the Kiwix project or
+ create your own.
+ {% endblocktrans %}
+
+
+
+ {% blocktrans %}
+ Content packages can be added in the following ways:
+