From 0b58a397588b21bdecfc0402ab111589f029a0c1 Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Thu, 4 Jul 2024 13:19:34 +0530 Subject: [PATCH] miniflux: Add new app [sunil's changes] - Add copyright information the logo. - Deluge: undo an unintended change. - Drop wrapper calls over privileged methods. The new privileged method decorators make is easy to avoid these. - Styling updates: docstrings, single quotes for strings, casing for UI strings. - Drop "DO NOT EDIT" comment for files located in /usr as they are not expected to be editable by the user. - Fix 'miniflux' to 'Miniflux' in web client name. - Overwrite FreedomBox settings onto the existing configuration file when setup is re-run. This is to ensure that FreedomBox settings take priority. - Use return value of the miniflux command to raise errors. - Use pathlib module where possible. - Move message parsing into the privileged module from views module. - Resize SVG and PNG logo files for consistency with icon styling. - Use hypens instead of underscores in URLs and Django URL names. - Rename miniflux_configure.html to miniflux.html. - Use base method for minor simplification in backup functional test. Ensure that the test can be run independently when other tests are not run. - Update tests to reflect code changes. - Avoid concatenating internationalized strings so that they can be translated properly. Signed-off-by: Joseph Nuthalapati Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- debian/control | 2 + debian/copyright | 6 + plinth/modules/miniflux/__init__.py | 115 ++++++++++++ .../system/miniflux.service.d/freedombox.conf | 6 + .../conf-available/miniflux-freedombox.conf | 20 +++ .../share/freedombox/modules-enabled/miniflux | 1 + plinth/modules/miniflux/forms.py | 33 ++++ plinth/modules/miniflux/manifest.py | 27 +++ plinth/modules/miniflux/privileged.py | 160 +++++++++++++++++ .../miniflux/static/icons/miniflux.png | Bin 0 -> 3886 bytes .../miniflux/static/icons/miniflux.svg | 57 ++++++ .../modules/miniflux/templates/miniflux.html | 32 ++++ plinth/modules/miniflux/tests/__init__.py | 0 .../modules/miniflux/tests/test_functional.py | 134 ++++++++++++++ plinth/modules/miniflux/tests/test_views.py | 166 ++++++++++++++++++ plinth/modules/miniflux/urls.py | 14 ++ plinth/modules/miniflux/views.py | 88 ++++++++++ 17 files changed, 861 insertions(+) create mode 100644 plinth/modules/miniflux/__init__.py create mode 100644 plinth/modules/miniflux/data/usr/lib/systemd/system/miniflux.service.d/freedombox.conf create mode 100644 plinth/modules/miniflux/data/usr/share/freedombox/etc/apache2/conf-available/miniflux-freedombox.conf create mode 100644 plinth/modules/miniflux/data/usr/share/freedombox/modules-enabled/miniflux create mode 100644 plinth/modules/miniflux/forms.py create mode 100644 plinth/modules/miniflux/manifest.py create mode 100644 plinth/modules/miniflux/privileged.py create mode 100644 plinth/modules/miniflux/static/icons/miniflux.png create mode 100644 plinth/modules/miniflux/static/icons/miniflux.svg create mode 100644 plinth/modules/miniflux/templates/miniflux.html create mode 100644 plinth/modules/miniflux/tests/__init__.py create mode 100644 plinth/modules/miniflux/tests/test_functional.py create mode 100644 plinth/modules/miniflux/tests/test_views.py create mode 100644 plinth/modules/miniflux/urls.py create mode 100644 plinth/modules/miniflux/views.py 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. + + + + RewriteEngine On + RewriteCond %{REQUEST_URI} ^/miniflux$ + RewriteRule .* /miniflux/ [R=301,L] + + + + + ProxyPreserveHost On + ProxyPass http://localhost:8788/miniflux/ + ProxyPassReverse http://localhost:8788/miniflux/ + diff --git a/plinth/modules/miniflux/data/usr/share/freedombox/modules-enabled/miniflux b/plinth/modules/miniflux/data/usr/share/freedombox/modules-enabled/miniflux new file mode 100644 index 000000000..e4c51f9d0 --- /dev/null +++ b/plinth/modules/miniflux/data/usr/share/freedombox/modules-enabled/miniflux @@ -0,0 +1 @@ +plinth.modules.miniflux diff --git a/plinth/modules/miniflux/forms.py b/plinth/modules/miniflux/forms.py new file mode 100644 index 000000000..0fc464d96 --- /dev/null +++ b/plinth/modules/miniflux/forms.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +class UserCredentialsForm(forms.Form): + """Form to create admin user or change a user's password.""" + + username = forms.CharField(label=_('Username'), + help_text=_('Enter a username for the user.')) + password = forms.CharField( + label=_('Password'), widget=forms.PasswordInput, min_length=6, + strip=False, + help_text=_('Enter a strong password with a minimum of 6 characters.')) + password_confirmation = forms.CharField( + label=_('Password confirmation'), widget=forms.PasswordInput, + min_length=6, strip=False, + help_text=_('Enter the same password for confirmation.')) + + def clean(self): + """Raise error if passwords don't match.""" + cleaned_data = super().clean() + password = self.cleaned_data.get('password') + password_confirmation = self.cleaned_data.get('password_confirmation') + + if password and password_confirmation and (password + != password_confirmation): + self.add_error('password_confirmation', + ValidationError(_('Passwords do not match.'))) + + return cleaned_data diff --git a/plinth/modules/miniflux/manifest.py b/plinth/modules/miniflux/manifest.py new file mode 100644 index 000000000..6769ea111 --- /dev/null +++ b/plinth/modules/miniflux/manifest.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Application manifest for miniflux.""" + +from django.utils.translation import gettext_lazy as _ + +clients = [{ + 'name': _('Miniflux'), + 'platforms': [{ + 'type': 'web', + 'url': '/miniflux/' + }] +}] + +backup = { + 'config': { + 'files': [ + '/etc/miniflux/freedombox.conf', + '/var/lib/plinth/backups-data/miniflux-database.sql', + ], + }, + 'secrets': { + 'files': [ + '/etc/miniflux/database', '/etc/dbconfig-common/miniflux.conf' + ] + }, + 'services': ['miniflux'] +} diff --git a/plinth/modules/miniflux/privileged.py b/plinth/modules/miniflux/privileged.py new file mode 100644 index 000000000..3b7f93426 --- /dev/null +++ b/plinth/modules/miniflux/privileged.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configuration helper for Miniflux feed reader.""" + +import json +import os +import pathlib +import subprocess +from typing import Dict +from urllib.parse import urlparse + +import pexpect + +from plinth import action_utils +from plinth.actions import privileged +from plinth.utils import is_non_empty_file + +STATIC_SETTINGS = { + 'BASE_URL': 'http://localhost/miniflux/', + 'RUN_MIGRATIONS': 1, + 'PORT': 8788 +} + +ENV_VARS_FILE = '/etc/miniflux/freedombox.conf' +DATABASE_FILE = '/etc/miniflux/database' +DB_BACKUP_FILE = '/var/lib/plinth/backups-data/miniflux-database.sql' + + +def _dict_to_env_file(dictionary: Dict) -> str: + """Write a dictionary into a systemd environment file format.""" + return "\n".join((f"{k}={v}" for k, v in dictionary.items())) + + +def _env_file_to_dict(env_vars: str) -> Dict: + """Return systemd environtment variables as a dictionary.""" + return { + line.split('=')[0]: line.split('=')[1].strip() + for line in env_vars.splitlines() + if line.strip() and not line.strip().startswith('#') + } + + +@privileged +def pre_setup(): + """Perform post-install actions for Miniflux.""" + vars_file = pathlib.Path(ENV_VARS_FILE) + vars_file.parent.mkdir(parents=True, exist_ok=True) + + existing_settings = {} + if is_non_empty_file(ENV_VARS_FILE): + # Any comments in the file will be dropped. + existing_settings = _env_file_to_dict(vars_file.read_text()) + + new_settings = existing_settings | STATIC_SETTINGS + vars_file.write_text(_dict_to_env_file(new_settings)) + + +def _run_miniflux_intreractively(command: str, username: str, + password: str) -> str: + """Fill interactive terminal prompt for username and password.""" + args = ['-c', '/etc/miniflux/miniflux.conf', command] + child = pexpect.spawn('miniflux', args, env={'LOG_FORMAT': 'json'}) + + # The CLI is in English only. + child.expect('Enter Username: ') + child.sendline(username) + + child.expect('Enter Password: ') + child.sendline(password) + + child.expect(pexpect.EOF) + status = child.before.decode() + + child.close() + if not os.WIFEXITED(child.exitstatus): + try: + status = json.loads(status)['msg'] + except (KeyError, json.JSONDecodeError): + pass + + raise Exception(status) + + +@privileged +def create_admin_user(username: str, password: str): + """Create a new admin user for Miniflux CLI. + + Raise exception if a user with the name already exists or otherwise fails. + """ + _run_miniflux_intreractively('--create-admin', username, password) + + +@privileged +def reset_user_password(username: str, password: str): + """Reset a user password using Miniflux CLI. + + Raise exception if the user does not exist or otherwise fails. + """ + _run_miniflux_intreractively('--reset-password', username, password) + + +@privileged +def uninstall(): + """Ensure that the database is removed.""" + action_utils.debconf_set_selections( + ['miniflux miniflux/purge boolean true']) + + +def _get_database_config(): + """Retrieve database credentials.""" + db_connection_string = pathlib.Path(DATABASE_FILE).read_text().strip() + parsed_url = urlparse(db_connection_string) + return { + 'user': parsed_url.username, + 'password': parsed_url.password, + 'database': parsed_url.path.lstrip('/'), + 'host': parsed_url.hostname, + } + + +# The following 3 methods are duplicated in tt-rss/privileged.py + + +def _run_as_postgres(command, stdin=None, stdout=None): + """Run a command as postgres user.""" + command = ['sudo', '--user', 'postgres'] + command + return subprocess.run(command, stdin=stdin, stdout=stdout, check=True) + + +@privileged +def dump_database(): + """Dump database to file.""" + config = _get_database_config() + os.makedirs(os.path.dirname(DB_BACKUP_FILE), exist_ok=True) + with open(DB_BACKUP_FILE, 'w', encoding='utf-8') as db_backup_file: + process = _run_as_postgres(['pg_dumpall', '--roles-only'], + stdout=subprocess.PIPE) + db_backup_file.write(f'DROP ROLE IF EXISTS {config["user"]};\n') + for line in process.stdout.decode().splitlines(): + if config['user'] in line: + db_backup_file.write(line + '\n') + + with open(DB_BACKUP_FILE, 'a', encoding='utf-8') as db_backup_file: + _run_as_postgres([ + 'pg_dump', '--create', '--clean', '--if-exists', config['database'] + ], stdout=db_backup_file) + + +@privileged +def restore_database(): + """Restore database from file.""" + config = _get_database_config() + + # This is needed for old backups only. New backups include 'DROP DATABASE + # IF EXISTS' and 'CREATE DATABASE' statements. + _run_as_postgres(['dropdb', config['database']]) + _run_as_postgres(['createdb', config['database']]) + + with open(DB_BACKUP_FILE, 'r', encoding='utf-8') as db_restore_file: + _run_as_postgres(['psql', '--dbname', config['database']], + stdin=db_restore_file) diff --git a/plinth/modules/miniflux/static/icons/miniflux.png b/plinth/modules/miniflux/static/icons/miniflux.png new file mode 100644 index 0000000000000000000000000000000000000000..6ce9ecfd0a1409084442e0cd4bfbd1e17b185997 GIT binary patch literal 3886 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 literal 0 HcmV?d00001 diff --git a/plinth/modules/miniflux/static/icons/miniflux.svg b/plinth/modules/miniflux/static/icons/miniflux.svg new file mode 100644 index 000000000..fd755f388 --- /dev/null +++ b/plinth/modules/miniflux/static/icons/miniflux.svg @@ -0,0 +1,57 @@ + + + + + icon + + + + + icon + + + + diff --git a/plinth/modules/miniflux/templates/miniflux.html b/plinth/modules/miniflux/templates/miniflux.html new file mode 100644 index 000000000..572d45035 --- /dev/null +++ b/plinth/modules/miniflux/templates/miniflux.html @@ -0,0 +1,32 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block configuration %} + {{ block.super }} + +

{% trans "Configuration" %}

+

+ {% 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)