mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-08-26 12:46:08 +00:00
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 <njoseph@riseup.net> Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
parent
ccbd5d7d20
commit
0b58a39758
2
debian/control
vendored
2
debian/control
vendored
@ -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,
|
||||
|
||||
6
debian/copyright
vendored
6
debian/copyright
vendored
@ -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
|
||||
|
||||
115
plinth/modules/miniflux/__init__.py
Normal file
115
plinth/modules/miniflux/__init__.py
Normal file
@ -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 '
|
||||
'<a href="https://miniflux.app/docs/apps.html">clients</a> 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()
|
||||
@ -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
|
||||
@ -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.
|
||||
|
||||
<Location ~ ^/miniflux$>
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_URI} ^/miniflux$
|
||||
RewriteRule .* /miniflux/ [R=301,L]
|
||||
</IfModule>
|
||||
</Location>
|
||||
|
||||
<Location /miniflux/>
|
||||
ProxyPreserveHost On
|
||||
ProxyPass http://localhost:8788/miniflux/
|
||||
ProxyPassReverse http://localhost:8788/miniflux/
|
||||
</Location>
|
||||
@ -0,0 +1 @@
|
||||
plinth.modules.miniflux
|
||||
33
plinth/modules/miniflux/forms.py
Normal file
33
plinth/modules/miniflux/forms.py
Normal file
@ -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
|
||||
27
plinth/modules/miniflux/manifest.py
Normal file
27
plinth/modules/miniflux/manifest.py
Normal file
@ -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']
|
||||
}
|
||||
160
plinth/modules/miniflux/privileged.py
Normal file
160
plinth/modules/miniflux/privileged.py
Normal file
@ -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)
|
||||
BIN
plinth/modules/miniflux/static/icons/miniflux.png
Normal file
BIN
plinth/modules/miniflux/static/icons/miniflux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
57
plinth/modules/miniflux/static/icons/miniflux.svg
Normal file
57
plinth/modules/miniflux/static/icons/miniflux.svg
Normal file
@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
viewBox="0 0 512.00001 511.99999"
|
||||
version="1.1"
|
||||
sodipodi:docname="miniflux.svg"
|
||||
width="512"
|
||||
height="512"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
inkscape:export-filename="miniflux.png"
|
||||
inkscape:export-xdpi="48"
|
||||
inkscape:export-ydpi="48"
|
||||
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"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs83" />
|
||||
<sodipodi:namedview
|
||||
id="namedview81"
|
||||
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.54434451"
|
||||
inkscape:cx="279.23493"
|
||||
inkscape:cy="868.01647"
|
||||
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" />
|
||||
<title
|
||||
id="title76">icon</title>
|
||||
<path
|
||||
d="M 166.81225,96.150624 A 113.67357,113.67357 0 0 1 220.80571,83.09152 q 59.78696,0 76.1702,51.99898 a 165.13831,165.13831 0 0 1 44.60278,-36.886035 q 26.27254,-15.112945 58.57602,-15.112945 44.84021,0 64.10833,27.40038 19.26811,27.40037 19.24437,82.27235 v 206.57129 c 0,5.16429 0.72419,8.6665 2.17256,10.54226 1.44837,1.87576 4.58256,3.56158 9.40255,4.91497 L 512,420.42006 v 12.64359 H 411.71795 q -13.05911,0 -18.79324,-9.84182 -5.73413,-9.84181 -5.79349,-29.51357 V 180.12067 q 0,-31.62678 -6.99256,-44.97081 -6.99256,-13.34403 -23.3758,-13.3559 -26.0351,0 -55.44183,30.86697 a 249.31018,249.31018 0 0 1 2.92049,40.10332 v 206.57129 c 0,5.16429 0.72419,8.6665 2.17256,10.54226 1.44837,1.87576 4.58256,3.56158 9.40255,4.91497 l 16.86999,5.62729 v 12.64359 H 232.38083 q -13.05911,0 -18.80511,-9.84182 -5.74601,-9.84181 -5.7935,-29.51357 V 180.12067 q 0,-31.62678 -6.98068,-44.97081 -6.98069,-13.34403 -23.38767,-13.3559 -25.56023,0 -52.54509,28.11269 v 249.42889 c 0,5.16429 0.71232,8.78522 2.16069,10.88655 1.44837,2.10133 4.41635,3.87024 8.92768,5.27113 l 16.38324,4.92684 v 12.64359 H 0 v -12.64359 l 16.869989,-5.62729 q 7.229995,-2.10132 9.402555,-4.91497 c 1.448373,-1.87576 2.17256,-5.37797 2.17256,-10.54226 V 133.74897 q 0,-7.72861 -2.17256,-10.54226 -2.17256,-2.81364 -9.402555,-4.92684 L 0,112.66446 V 100.02087 L 115.70367,78.93635 h 8.19162 v 49.18534 a 169.21038,169.21038 0 0 1 42.91696,-31.971066 z"
|
||||
id="path78"
|
||||
style="stroke-width:1.18719" />
|
||||
<metadata
|
||||
id="metadata85">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:title>icon</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
32
plinth/modules/miniflux/templates/miniflux.html
Normal file
32
plinth/modules/miniflux/templates/miniflux.html
Normal file
@ -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 }}
|
||||
|
||||
<h3>{% trans "Configuration" %}</h3>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Create an admin user to get started. Other users can be created from
|
||||
within Miniflux.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<div class="btn-toolbar">
|
||||
<a href="{% url 'miniflux:create-admin-user' %}" class="btn btn-default"
|
||||
role="button" title="{% trans 'Create admin user' %}">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
{% trans 'Create admin user' %}
|
||||
</a>
|
||||
<a href="{% url 'miniflux:reset-user-password' %}" class="btn btn-default"
|
||||
role="button" title="{% trans 'Reset user password' %}">
|
||||
<span class="fa fa-key" aria-hidden="true"></span>
|
||||
{% trans 'Reset user password' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
plinth/modules/miniflux/tests/__init__.py
Normal file
0
plinth/modules/miniflux/tests/__init__.py
Normal file
134
plinth/modules/miniflux/tests/test_functional.py
Normal file
134
plinth/modules/miniflux/tests/test_functional.py
Normal file
@ -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)
|
||||
166
plinth/modules/miniflux/tests/test_views.py
Normal file
166
plinth/modules/miniflux/tests/test_views.py
Normal file
@ -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.'
|
||||
14
plinth/modules/miniflux/urls.py
Normal file
14
plinth/modules/miniflux/urls.py
Normal file
@ -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'),
|
||||
]
|
||||
88
plinth/modules/miniflux/views.py
Normal file
88
plinth/modules/miniflux/views.py
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user