diff --git a/debian/control b/debian/control
index 1a4c3a4a1..6801a41a5 100644
--- a/debian/control
+++ b/debian/control
@@ -41,6 +41,7 @@ Build-Depends:
python3-openssl,
python3-pampy,
python3-paramiko,
+ python3-pexpect,
python3-pip,
python3-psutil,
python3-pytest,
@@ -126,6 +127,7 @@ Depends:
python3-markupsafe,
python3-pampy,
python3-paramiko,
+ python3-pexpect,
python3-psutil,
python3-requests,
python3-ruamel.yaml,
diff --git a/debian/copyright b/debian/copyright
index dcf0cbb39..bb756b6ff 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -164,6 +164,12 @@ Copyright: 2015 Calinou, Nils Dagsson Moskopp
Comment: https://github.com/minetest/minetest/blob/master/misc/minetest.svg
License: CC-BY-SA-3.0
+Files: plinth/modules/miniflux/static/icons/miniflux.png
+ plinth/modules/miniflux/static/icons/miniflux.svg
+Copyright: 2018, 2019 Frédéric Guillot
+Comment: https://github.com/miniflux/logo
+License: CC-BY-SA-4.0
+
Files: plinth/modules/mumble/static/icons/mumble.png
Copyright: 2009 Martin Skilnand
Comment: https://commons.wikimedia.org/wiki/File:Icons_mumble.svg
diff --git a/plinth/modules/miniflux/__init__.py b/plinth/modules/miniflux/__init__.py
new file mode 100644
index 000000000..97d691c4f
--- /dev/null
+++ b/plinth/modules/miniflux/__init__.py
@@ -0,0 +1,115 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""FreedomBox app for Miniflux."""
+
+from django.utils.translation import gettext_lazy as _
+
+from plinth import app as app_module
+from plinth import frontpage, menu
+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.firewall.components import Firewall
+from plinth.package import Packages
+
+from . import manifest, privileged
+
+_description = [
+ _('Miniflux is a web-based tool that aggregates news and blog updates from'
+ ' various websites into one centralized, easy-to-read format. It has a '
+ 'simple interface and focuses on a distraction-free reading experience. '
+ 'You can can subscribe to your favorite sites and access full article '
+ 'contents within the reader itself.'),
+ _('Key features include keyboard shortcuts for quick navigation, full-text'
+ ' search, filtering articles, categories and favorites. Miniflux '
+ 'preserves user privacy by removing trackers. The primary interface is '
+ 'web-based. There are several third-party '
+ 'clients as well.'),
+]
+
+
+class MinifluxApp(app_module.App):
+ """FreedomBox app for Miniflux."""
+
+ app_id = 'miniflux'
+
+ _version = 1
+
+ def __init__(self):
+ """Create components for the app."""
+ super().__init__()
+
+ info = app_module.Info(self.app_id, self._version, name=_('Miniflux'),
+ icon_filename='miniflux',
+ short_description=_('News Feed Reader'),
+ description=_description,
+ manual_page='miniflux',
+ clients=manifest.clients,
+ donation_url='https://miniflux.app/#donations')
+ self.add(info)
+
+ menu_item = menu.Menu('menu-miniflux', info.name,
+ info.short_description, info.icon_filename,
+ 'miniflux:index', parent_url_name='apps')
+ self.add(menu_item)
+
+ shortcut = frontpage.Shortcut('shortcut-miniflux', info.name,
+ info.short_description,
+ info.icon_filename, url='/miniflux',
+ clients=manifest.clients,
+ login_required=True)
+ self.add(shortcut)
+
+ packages = Packages('packages-miniflux', [
+ 'miniflux',
+ 'postgresql',
+ 'postgresql-contrib',
+ ])
+ self.add(packages)
+
+ drop_in_configs = DropinConfigs(
+ 'dropin-configs-miniflux',
+ ['/etc/apache2/conf-available/miniflux-freedombox.conf'])
+ self.add(drop_in_configs)
+
+ firewall = Firewall('firewall-miniflux', info.name,
+ ports=['http', 'https'], is_external=True)
+ self.add(firewall)
+
+ webserver = Webserver('webserver-miniflux', 'miniflux-freedombox',
+ urls=['https://{host}/miniflux/'])
+ self.add(webserver)
+
+ daemon = Daemon('daemon-miniflux', 'miniflux',
+ listen_ports=[(8788, 'tcp4'), (8788, 'tcp6')])
+ self.add(daemon)
+
+ backup_restore = MinifluxBackupRestore('backup-restore-miniflux',
+ **manifest.backup)
+ self.add(backup_restore)
+
+ def setup(self, old_version=None):
+ """Install and configure the app."""
+ privileged.pre_setup()
+ super().setup(old_version)
+ if not old_version:
+ self.enable()
+
+ def uninstall(self):
+ """De-configure and uninstall the app."""
+ privileged.uninstall()
+ super().uninstall()
+
+
+class MinifluxBackupRestore(BackupRestore):
+ """Component to backup/restore Miniflux."""
+
+ def backup_pre(self, packet):
+ """Save database contents."""
+ super().backup_pre(packet)
+ privileged.dump_database()
+
+ def restore_post(self, packet):
+ """Restore database contents."""
+ super().restore_post(packet)
+ privileged.restore_database()
diff --git a/plinth/modules/miniflux/data/usr/lib/systemd/system/miniflux.service.d/freedombox.conf b/plinth/modules/miniflux/data/usr/lib/systemd/system/miniflux.service.d/freedombox.conf
new file mode 100644
index 000000000..053d7d6d5
--- /dev/null
+++ b/plinth/modules/miniflux/data/usr/lib/systemd/system/miniflux.service.d/freedombox.conf
@@ -0,0 +1,6 @@
+# FreedomBox configuration file stores both static settings and user
+# preferences. These settings are loaded as environment variables. Hence, they
+# take precedence, overriding the settings in miniflux.conf.
+
+[Service]
+EnvironmentFile=/etc/miniflux/freedombox.conf
diff --git a/plinth/modules/miniflux/data/usr/share/freedombox/etc/apache2/conf-available/miniflux-freedombox.conf b/plinth/modules/miniflux/data/usr/share/freedombox/etc/apache2/conf-available/miniflux-freedombox.conf
new file mode 100644
index 000000000..8c0ec733f
--- /dev/null
+++ b/plinth/modules/miniflux/data/usr/share/freedombox/etc/apache2/conf-available/miniflux-freedombox.conf
@@ -0,0 +1,20 @@
+##
+## On all sites, provide miniflux web interface on a path: /miniflux
+##
+
+# Redirect /miniflux to /miniflux/ as the miniflux server does not
+# work without a slash at the end.
+
+
+ {% blocktrans trimmed %} + Create an admin user to get started. Other users can be created from + within Miniflux. + {% endblocktrans %} +
+ + +{% endblock %} diff --git a/plinth/modules/miniflux/tests/__init__.py b/plinth/modules/miniflux/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/miniflux/tests/test_functional.py b/plinth/modules/miniflux/tests/test_functional.py new file mode 100644 index 000000000..db7eaaa09 --- /dev/null +++ b/plinth/modules/miniflux/tests/test_functional.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Functional, browser based tests for Miniflux app. +""" + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.miniflux] + +ADMIN_USERNAME = 'admin' +ADMIN_PASSWORD = 'str0ngp@$$word' +ADMIN_PASSWORD_NEW = 'str0ngERp@$$word' + +CREDENTIALS = {'username': 'admin', 'password': ADMIN_PASSWORD} + + +class TestMinifluxApp(functional.BaseAppTests): + """Class to customize basic app tests for Miniflux.""" + + app_name = 'miniflux' + has_service = True + has_web = True + + @pytest.fixture(name='create_admin_user') + def fixture_create_admin_user(self, session_browser): + """Create an admin user for Miniflux.""" + functional.app_enable(session_browser, self.app_name) + _create_admin_user(session_browser) + + def test_create_miniflux_admin_user(self, session_browser, + create_admin_user): + """Test creating an admin user.""" + _miniflux_login(session_browser) + # Verify that this user can see admin settings + with functional.wait_for_page_update(session_browser): + session_browser.links.find_by_href( + '/miniflux/settings').first.click() + + assert not session_browser.links.find_by_href( + '/miniflux/users').is_empty() + + def test_reset_miniflux_user_password(self, session_browser, + create_admin_user): + """Test Miniflux user password reset.""" + CREDENTIALS['password'] = ADMIN_PASSWORD_NEW + _reset_user_password(session_browser) + _miniflux_login(session_browser) + assert not session_browser.links.find_by_href( + '/miniflux/unread').is_empty() + + @pytest.mark.backups + def test_backup_restore(self, session_browser, create_admin_user): + """Test backup and restore of app data.""" + _subscribe(session_browser, 'https://planet.debian.org/atom.xml') + super().test_backup_restore(session_browser) + assert _is_subscribed(session_browser, 'Planet Debian') + + +def _fill_credentials_form(browser, href): + """Fill the user credentials form in Miniflux app.""" + functional.nav_to_module(browser, 'miniflux') + with functional.wait_for_page_update(browser): + browser.links.find_by_href( + f'/plinth/apps/miniflux/{href}/').first.click() + + browser.fill('miniflux-username', CREDENTIALS['username']) + browser.fill('miniflux-password', CREDENTIALS['password']) + browser.fill('miniflux-password_confirmation', CREDENTIALS['password']) + functional.submit(browser, form_class='form-miniflux') + + +def _create_admin_user(browser): + """Create Miniflux admin user.""" + _fill_credentials_form(browser, 'create-admin-user') + + +def _open_miniflux_app(browser): + """Load the web interface of Miniflux.""" + functional.visit(browser, '/miniflux/') + main = browser.find_by_id('main') + functional.eventually(lambda: main.visible) + + +def _miniflux_logout(browser): + """Attempt to log out of Miniflux app. Doesn't fail if not logged in.""" + _open_miniflux_app(browser) + maybe_logout_button = browser.links.find_by_href('/miniflux/logout') + if not maybe_logout_button.is_empty(): + with functional.wait_for_page_update(browser): + maybe_logout_button.first.click() + + +def _miniflux_submit(browser): + """Perform the Submit action in Miniflux forms.""" + functional.submit(browser, + element=browser.find_by_css('button[type="submit"]')) + + +def _miniflux_login(browser): + """Login to miniflux with the given credentials.""" + _open_miniflux_app(browser) + _miniflux_logout(browser) + browser.find_by_id('form-username').fill(CREDENTIALS['username']) + browser.find_by_id('form-password').fill(CREDENTIALS['password']) + _miniflux_submit(browser) + + +def _reset_user_password(browser): + """Reset a Miniflux user's password from FreedomBox web interface.""" + _fill_credentials_form(browser, 'reset-user-password') + + +def _subscribe(browser, feed_url): + """Subscribe to a feed in Miniflux.""" + _open_miniflux_app(browser) + _miniflux_login(browser) + with functional.wait_for_page_update(browser): + browser.links.find_by_href('/miniflux/subscribe').first.click() + + with functional.wait_for_page_update(browser): + browser.find_by_id('form-url').fill(feed_url) + _miniflux_submit(browser) + + +def _is_subscribed(browser, feed_name): + """Check if the user is subscribed to a feed.""" + _open_miniflux_app(browser) + _miniflux_login(browser) + with functional.wait_for_page_update(browser): + browser.links.find_by_href('/miniflux/feeds').first.click() + + return browser.is_text_present(feed_name) diff --git a/plinth/modules/miniflux/tests/test_views.py b/plinth/modules/miniflux/tests/test_views.py new file mode 100644 index 000000000..131f03e87 --- /dev/null +++ b/plinth/modules/miniflux/tests/test_views.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Tests for Miniflux views.""" + +from unittest.mock import patch + +import pytest +from django import urls +from django.contrib.messages.storage.fallback import FallbackStorage + +from plinth import module_loader +from plinth.modules.miniflux import views + +# For all tests, use plinth.urls instead of urls configured for testing +pytestmark = pytest.mark.urls('plinth.urls') + + +@pytest.fixture(autouse=True, scope='module') +def fixture_miniflux_urls(): + """Make sure Miniflux 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.miniflux') + 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 + + +########################## +# Create Admin User view # +########################## + + +def test_create_admin_user_view(rf): + """Test that the create admin user view loads successfully.""" + request = rf.get(urls.reverse('miniflux:create-admin-user')) + view = views.CreateAdminUserView.as_view() + response, _ = make_request(request, view) + + assert response.status_code == 200 + + +@patch('plinth.modules.miniflux.privileged.create_admin_user') +def test_create_admin_user_form_valid(create_admin_user, rf): + """Test that the create admin user form is valid and redirects.""" + form_data = { + 'miniflux-username': 'admin', + 'miniflux-password': 'strongpassword', + 'miniflux-password_confirmation': 'strongpassword' + } + request = rf.post(urls.reverse('miniflux:create-admin-user'), + data=form_data) + view = views.CreateAdminUserView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 302 + assert list(messages)[0].message == 'Created admin user: admin' + + +def test_passwords_do_not_match(rf): + """Test that the form shows an error when passwords do not match.""" + form_data = { + 'miniflux-username': 'admin', + 'miniflux-password': 'strongpassword', + 'miniflux-password_confirmation': 'weakpassword' + } + request = rf.post(urls.reverse('miniflux:create-admin-user'), + data=form_data) + view = views.CreateAdminUserView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 200 + assert response.context_data['form'].errors['password_confirmation'][ + 0] == 'Passwords do not match.' + + +def test_password_too_short(rf): + """Test that the form shows an error when the password is too short.""" + form_data = { + 'miniflux-username': 'demo', + 'miniflux-password': 'demo', + 'miniflux-password_confirmation': 'demo' + } + request = rf.post(urls.reverse('miniflux:create-admin-user'), + data=form_data) + view = views.CreateAdminUserView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 200 + assert response.context_data['form'].errors['password'][ + 0] == 'Ensure this value has at least 6 characters (it has 4).' + + +@patch('plinth.modules.miniflux.privileged.create_admin_user') +def test_recreate_existing_user(create_admin_user, rf): + """Test that trying to recreate an existing user fails.""" + create_admin_user.side_effect = Exception( + 'Skipping admin user creation because it already exists') + + form_data = { + 'miniflux-username': 'admin', + 'miniflux-password': 'strongpassword', + 'miniflux-password_confirmation': 'strongpassword' + } + request = rf.post(urls.reverse('miniflux:create-admin-user'), + data=form_data) + view = views.CreateAdminUserView.as_view() + response, messages = make_request(request, view) + + error_msg = ('An error occurred while creating the user: Skipping admin ' + 'user creation because it already exists.') + assert response.status_code == 302 + assert list(messages)[0].message == error_msg + + +############################ +# Reset User Password view # +############################ + + +@patch('plinth.modules.miniflux.privileged.reset_user_password') +def test_reset_user_password_form_valid(reset_user_password, rf): + """Test that the reset user password form is valid and redirects.""" + reset_user_password.return_value = 'Password changed!' + + form_data = { + 'miniflux-username': 'admin', + 'miniflux-password': 'strongpassword', + 'miniflux-password_confirmation': 'strongpassword' + } + request = rf.post(urls.reverse('miniflux:reset-user-password'), + data=form_data) + view = views.ResetUserPasswordView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 302 + assert list(messages)[0].message == 'Password reset for user: admin' + + +@patch('plinth.modules.miniflux.privileged.reset_user_password') +def test_reset_user_password_for_invalid_user(reset_user_password, rf): + """Test that the resetting user password for an invalid user fails.""" + reset_user_password.side_effect = Exception('user not found') + + form_data = { + 'miniflux-username': 'admin', + 'miniflux-password': 'strongpassword', + 'miniflux-password_confirmation': 'strongpassword' + } + request = rf.post(urls.reverse('miniflux:reset-user-password'), + data=form_data) + view = views.ResetUserPasswordView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 302 + assert list( + messages + )[0].message == 'An error occurred during password reset: user not found.' diff --git a/plinth/modules/miniflux/urls.py b/plinth/modules/miniflux/urls.py new file mode 100644 index 000000000..ac2017197 --- /dev/null +++ b/plinth/modules/miniflux/urls.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the Miniflux module.""" + +from django.urls import re_path + +from .views import CreateAdminUserView, MinifluxAppView, ResetUserPasswordView + +urlpatterns = [ + re_path(r'^apps/miniflux/$', MinifluxAppView.as_view(), name='index'), + re_path(r'^apps/miniflux/create-admin-user/$', + CreateAdminUserView.as_view(), name='create-admin-user'), + re_path(r'^apps/miniflux/reset-user-password/$', + ResetUserPasswordView.as_view(), name='reset-user-password'), +] diff --git a/plinth/modules/miniflux/views.py b/plinth/modules/miniflux/views.py new file mode 100644 index 000000000..76c507ef9 --- /dev/null +++ b/plinth/modules/miniflux/views.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Django views for Miniflux.""" + +import logging + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic.edit import FormView + +from plinth import views + +from . import privileged +from .forms import UserCredentialsForm + +logger = logging.getLogger(__name__) + + +class MinifluxAppView(views.AppView): + """Serve configuration page.""" + + app_id = 'miniflux' + template_name = 'miniflux.html' + + +class CreateAdminUserView(SuccessMessageMixin, FormView): + """View to create a new admin user.""" + + form_class = UserCredentialsForm + prefix = 'miniflux' + template_name = 'form.html' + success_url = reverse_lazy('miniflux:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Create Admin User') + return context + + def form_valid(self, form): + """Create the admin user on valid form submission.""" + username = form.cleaned_data['username'] + password = form.cleaned_data['password'] + + try: + privileged.create_admin_user(username, password) + self.success_message = _('Created admin user: {username}').format( + username=username) + except Exception as error: + messages.error( + self.request, + _('An error occurred while creating the user: {error}.'). + format(error=error)) + + return super().form_valid(form) + + +class ResetUserPasswordView(SuccessMessageMixin, FormView): + """View to reset a user password.""" + + form_class = UserCredentialsForm + prefix = 'miniflux' + template_name = 'form.html' + success_url = reverse_lazy('miniflux:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Reset User Password') + return context + + def form_valid(self, form): + """Reset password on valid form submission.""" + username = form.cleaned_data['username'] + password = form.cleaned_data['password'] + + try: + privileged.reset_user_password(username, password).strip() + self.success_message = _('Password reset for user: {username}' + ).format(username=username) + except Exception as error: + messages.error( + self.request, + _('An error occurred during password reset: {error}.').format( + error=error)) + + return super().form_valid(form)