From b7c3a06e85bd2e86efa5c78a98a1d7dd94b8c65b Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Fri, 3 May 2024 00:54:51 +0530 Subject: [PATCH] featherwiki: Add new app - Uninstall deletes wikis & extensions - Use Skylark (v1.8.0) - Add option to upload existing wiki - Open wiki links in new tab. Since Feather Wiki modifies browser history, it takes several clicks to go back and reach the FreedomBox app for Feather Wiki if the user wants to switch to another wiki file. Opening in a new tab also makes it easy for the user to move text between wikis (i.e. the Refile use case). - Improve HTML file path handling. Extract only the HTML file name from the URL. Return a 404 status if the file cannot be found - Place featherwiki_nest.cgi file in /usr/lib/cgi-bin. The file is installed as part of the FreedomBox package, rather than a step in the installation of Feather Wiki. [sunil] - Reorganized description to complete the introduction before talking about FreedomBox implementation. - Update description to say that only users of 'wiki' group can access. - Update description to talk about where the wiki is downloaded from how to upgrade it. - Update short description to 'Personal Notebooks'. - Add UsersAndGroups component and to reuse 'wiki' group properly. - Reorder component to resemble other apps (could prove useful in future). - Restrict frontpage shortcut to 'wiki' group users. - Minor styling updates. Run isort. - Use pathlib.Path object where possible instead of os.path. - Perform sanitization in privileged methods instead of callers. This leads better security if the service is compromised. - Perform duplicate checking in privileged methods instead of callers. - Check in privileged action that uploaded file originates from temporary directory. Otherwise, arbitrary files can moved into DAV directory. - Switch storage path to /var/lib/ which is an application data folder from /var/www which is a user data folder. - Add extra security to the DAV folder by explicitly rejecting .htaccess directives, forcing mime type and removing all options. - Update SVG/PNG logo icons to adhere to our guidelines. - Minor template updates. Add required attributes. Improve i18n. Avoid

inside

. - Refactor tests for more code reuse and fewer globals. Signed-off-by: Joseph Nuthalapati Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- debian/copyright | 6 + plinth/modules/featherwiki/__init__.py | 120 +++++++++++++ .../featherwiki-freedombox.conf | 25 +++ .../freedombox/modules-enabled/featherwiki | 1 + plinth/modules/featherwiki/forms.py | 37 ++++ plinth/modules/featherwiki/manifest.py | 16 ++ plinth/modules/featherwiki/privileged.py | 93 ++++++++++ .../featherwiki/static/icons/featherwiki.png | Bin 0 -> 24716 bytes .../featherwiki/static/icons/featherwiki.svg | 159 ++++++++++++++++++ .../templates/featherwiki_configure.html | 60 +++++++ .../templates/featherwiki_delete.html | 37 ++++ .../templates/featherwiki_upload_file.html | 23 +++ plinth/modules/featherwiki/tests/__init__.py | 0 .../featherwiki/tests/data/dummy_wiki.html | 9 + .../featherwiki/tests/test_functional.py | 105 ++++++++++++ plinth/modules/featherwiki/urls.py | 20 +++ plinth/modules/featherwiki/views.py | 157 +++++++++++++++++ 17 files changed, 868 insertions(+) create mode 100644 plinth/modules/featherwiki/__init__.py create mode 100644 plinth/modules/featherwiki/data/usr/share/freedombox/etc/apache2/conf-available/featherwiki-freedombox.conf create mode 100644 plinth/modules/featherwiki/data/usr/share/freedombox/modules-enabled/featherwiki create mode 100644 plinth/modules/featherwiki/forms.py create mode 100644 plinth/modules/featherwiki/manifest.py create mode 100644 plinth/modules/featherwiki/privileged.py create mode 100644 plinth/modules/featherwiki/static/icons/featherwiki.png create mode 100644 plinth/modules/featherwiki/static/icons/featherwiki.svg create mode 100644 plinth/modules/featherwiki/templates/featherwiki_configure.html create mode 100644 plinth/modules/featherwiki/templates/featherwiki_delete.html create mode 100644 plinth/modules/featherwiki/templates/featherwiki_upload_file.html create mode 100644 plinth/modules/featherwiki/tests/__init__.py create mode 100644 plinth/modules/featherwiki/tests/data/dummy_wiki.html create mode 100644 plinth/modules/featherwiki/tests/test_functional.py create mode 100644 plinth/modules/featherwiki/urls.py create mode 100644 plinth/modules/featherwiki/views.py diff --git a/debian/copyright b/debian/copyright index bb756b6ff..64b25d866 100644 --- a/debian/copyright +++ b/debian/copyright @@ -101,6 +101,12 @@ Copyright: 2012 William Theaker Comment: https://gitlab.com/fdroid/artwork/blob/master/fdroid-logo-2015/fdroid-logo.svg License: CC-BY-SA-3.0 or GPL-3+ +Files: plinth/modules/featherwiki/static/icons/featherwiki.png + plinth/modules/featherwiki/static/icons/featherwiki.svg +Copyright: 2022 Robbie Antenesse +Comment: https://codeberg.org/Alamantus/FeatherWiki/src/branch/main/logo.svg +License: AGPL-3+ + Files: plinth/modules/gitweb/static/icons/gitweb.png plinth/modules/gitweb/static/icons/gitweb.svg Copyright: 2010 Git Authors diff --git a/plinth/modules/featherwiki/__init__.py b/plinth/modules/featherwiki/__init__.py new file mode 100644 index 000000000..0a7120438 --- /dev/null +++ b/plinth/modules/featherwiki/__init__.py @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for Feather Wiki. + +This is a FreedomBox-native implementation of a Feather Wiki Nest. +This app doesn't install any Debian packages. +""" + +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.config import DropinConfigs +from plinth.modules.apache.components import Webserver +from plinth.modules.backups.components import BackupRestore +from plinth.modules.firewall.components import Firewall +from plinth.modules.users.components import UsersAndGroups +from plinth.utils import format_lazy + +from . import manifest, privileged + +_description = [ + format_lazy( + _('Feather Wiki is a tool to create simple self-contained wikis, each ' + 'stored in a single HTML file on your {box_name}. You can use it as ' + 'a personal wiki, as a web notebook, or for project documentation.'), + box_name=_(cfg.box_name)), + _('Each wiki is a small file. Create as many wikis as you like, such as ' + 'one wiki per topic. Customize each wiki to your liking with extensions ' + 'and other customization options.'), + _('Feather Wiki is downloaded from upstream website and not from Debian. ' + 'Wikis need to be upgraded to newer version manually.'), + format_lazy( + _('Wikis are not public by default, but they can be downloaded for ' + 'sharing or publishing. They can be edited by ' + 'any user on {box_name} belonging to the wiki group. ' + 'Simultaneous editing is not supported.'), box_name=_(cfg.box_name), + users_url=reverse_lazy('users:index')) +] + + +class FeatherWikiApp(app_module.App): + """FreedomBox app for Feather Wiki.""" + + app_id = 'featherwiki' + + _version = 1 + + def __init__(self): + """Create components for the app.""" + super().__init__() + + groups = {'wiki': _('View and edit wiki applications')} + + info = app_module.Info(self.app_id, self._version, + name=_('Feather Wiki'), + icon_filename='featherwiki', + short_description=_('Personal Notebooks'), + description=_description, + manual_page='FeatherWiki', + clients=manifest.clients) + self.add(info) + + menu_item = menu.Menu('menu-featherwiki', info.name, + info.short_description, info.icon_filename, + 'featherwiki:index', parent_url_name='apps') + self.add(menu_item) + + # The shortcut is a simple directory listing provided by Apache server. + # Expecting a large number of wiki files, so creating a shortcut for + # each file (like in ikiwiki's case) will crowd the front page. + shortcut = frontpage.Shortcut( + 'shortcut-featherwiki', info.name, + short_description=info.short_description, icon=info.icon_filename, + description=info.description, manual_page=info.manual_page, + url='/featherwiki/', clients=info.clients, login_required=True, + allowed_groups=list(groups)) + self.add(shortcut) + + dropin_configs = DropinConfigs('dropin-configs-featherwiki', [ + '/etc/apache2/conf-available/featherwiki-freedombox.conf', + ]) + self.add(dropin_configs) + + firewall = Firewall('firewall-featherwiki', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + webserver = Webserver('webserver-featherwiki', + 'featherwiki-freedombox') + self.add(webserver) + + users_and_groups = UsersAndGroups('users-and-groups-featherwiki', + groups=groups) + self.add(users_and_groups) + + backup_restore = BackupRestore('backup-restore-featherwiki', + **manifest.backup) + self.add(backup_restore) + + def setup(self, old_version=None): + """Install and configure the app.""" + super().setup(old_version) + privileged.setup() + if not old_version: + self.enable() + + def uninstall(self): + """Purge directory with all the wikis.""" + super().uninstall() + privileged.uninstall() + + +def get_wiki_list(): + """List all the Feather Wiki files.""" + return sorted([ + path.name for path in privileged.wiki_dir.iterdir() + if path.suffix == '.html' + ]) diff --git a/plinth/modules/featherwiki/data/usr/share/freedombox/etc/apache2/conf-available/featherwiki-freedombox.conf b/plinth/modules/featherwiki/data/usr/share/freedombox/etc/apache2/conf-available/featherwiki-freedombox.conf new file mode 100644 index 000000000..3781dedb8 --- /dev/null +++ b/plinth/modules/featherwiki/data/usr/share/freedombox/etc/apache2/conf-available/featherwiki-freedombox.conf @@ -0,0 +1,25 @@ +## +## On all sites, provide Feather Wiki files on a path: /featherwiki +## + +Alias /featherwiki /var/lib/featherwiki + + + Include includes/freedombox-single-sign-on.conf + + TKTAuthToken "admin" "wiki" + + + + + Dav On + + # Don't accept overrides in .htaccess + AllowOverride None + + # Disable following symlinks, show an index page + Options Indexes + + # Accept and serve only HTML files + ForceType text/html + diff --git a/plinth/modules/featherwiki/data/usr/share/freedombox/modules-enabled/featherwiki b/plinth/modules/featherwiki/data/usr/share/freedombox/modules-enabled/featherwiki new file mode 100644 index 000000000..9e2d74f56 --- /dev/null +++ b/plinth/modules/featherwiki/data/usr/share/freedombox/modules-enabled/featherwiki @@ -0,0 +1 @@ +plinth.modules.featherwiki diff --git a/plinth/modules/featherwiki/forms.py b/plinth/modules/featherwiki/forms.py new file mode 100644 index 000000000..7f2687264 --- /dev/null +++ b/plinth/modules/featherwiki/forms.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Django forms for configuring Feather Wiki.""" + +from django import forms +from django.core import validators +from django.utils.translation import gettext_lazy as _ + + +class CreateWikiForm(forms.Form): + """Form to create a new wiki file.""" + + name = forms.CharField( + label=_('Name of the wiki file, with file extension ".html"'), + strip=True, help_text=_( + 'Wiki title and description can be set from within the wiki. ' + 'This file name is independent of the wiki title.')) + + +class RenameWikiForm(forms.Form): + """Form to rename a wiki file.""" + + new_name = forms.CharField( + label=_('New name for the wiki file, with file extension ".html"'), + strip=True, help_text=_( + 'Renaming the file has no effect on the title of the wiki.')) + + +class UploadWikiForm(forms.Form): + """Form to upload a wiki file.""" + + file = forms.FileField( + label=_('A Feather Wiki file with .html file extension'), + required=True, validators=[ + validators.FileExtensionValidator( + ['html'], _('Feather Wiki files must be in HTML format')) + ], help_text=_( + 'Upload an existing Feather Wiki file from this computer.')) diff --git a/plinth/modules/featherwiki/manifest.py b/plinth/modules/featherwiki/manifest.py new file mode 100644 index 000000000..0469011c0 --- /dev/null +++ b/plinth/modules/featherwiki/manifest.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Application manifest for Feather Wiki.""" + +from django.utils.translation import gettext_lazy as _ + +from .privileged import wiki_dir + +clients = [{ + 'name': _('Feather Wiki'), + 'platforms': [{ + 'type': 'web', + 'url': '/featherwiki/' + }] +}] + +backup = {'data': {'directories': [str(wiki_dir)]}} diff --git a/plinth/modules/featherwiki/privileged.py b/plinth/modules/featherwiki/privileged.py new file mode 100644 index 000000000..d5066d17c --- /dev/null +++ b/plinth/modules/featherwiki/privileged.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure Feather Wiki.""" + +import pathlib +import re +import shutil +import tempfile +import urllib.request + +from plinth.actions import privileged + +# Needs to be changed on a new release +EMPTY_WIKI_FILE = 'https://feather.wiki/builds/v1.8.x/FeatherWiki_Skylark.html' + +wiki_dir = pathlib.Path('/var/lib/featherwiki') + + +def _set_ownership(path: pathlib.Path): + """Makes www-data:www-data the owner of the give path.""" + shutil.chown(path, user='www-data', group='www-data') + + +@privileged +def setup(): + """Setup wiki dir and CGI script.""" + wiki_dir.mkdir(parents=True, exist_ok=True) + _set_ownership(wiki_dir) + + +def _normalize_wiki_file_name(name): + """Return a normalized file name from a wiki name.""" + file_name = name.replace(' ', '_') + invalid_characters = r'[\/\\\:\*\?\"\'\<\>\|]' + file_name = re.sub(invalid_characters, '', file_name) + if not file_name.endswith('.html'): + return file_name + '.html' + + return file_name + + +@privileged +def create_wiki(file_name: str): + """Initialize wiki with the latest version of Feather Wiki.""" + file_name = _normalize_wiki_file_name(file_name) + response = urllib.request.urlopen(EMPTY_WIKI_FILE) + file_path = wiki_dir / file_name + if file_path.exists(): + raise ValueError('Wiki exists') + + file_path.write_bytes(response.read()) + _set_ownership(file_path) + + +@privileged +def add_wiki_file(upload_file_path: str): + """Add an uploaded wiki file.""" + upload_file_path = pathlib.Path(upload_file_path) + temp_dir = tempfile.gettempdir() + if not upload_file_path.is_relative_to(temp_dir): + raise Exception('Uploaded file is not in expected temp directory.') + + file_name = _normalize_wiki_file_name(upload_file_path.name) + file_path = wiki_dir / file_name + if file_path.exists(): + raise ValueError('Wiki exists') + + shutil.move(upload_file_path, file_path) + _set_ownership(file_path) + + +@privileged +def rename_wiki(old_name: str, new_name: str): + """Rename wiki file.""" + old_name = _normalize_wiki_file_name(old_name) + new_name = _normalize_wiki_file_name(new_name) + file_path = wiki_dir / new_name + if file_path.exists(): + raise ValueError('Wiki exists') + + (wiki_dir / old_name).rename(file_path) + + +@privileged +def delete_wiki(file_name: str): + """Delete one wiki file by name.""" + file_name = _normalize_wiki_file_name(file_name) + (wiki_dir / file_name).unlink(missing_ok=True) + + +@privileged +def uninstall(): + """Delete all the wiki content.""" + shutil.rmtree(wiki_dir) diff --git a/plinth/modules/featherwiki/static/icons/featherwiki.png b/plinth/modules/featherwiki/static/icons/featherwiki.png new file mode 100644 index 0000000000000000000000000000000000000000..35660077ee507eb523361b8bd34989a82694cde0 GIT binary patch literal 24716 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 + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + 2012-09-02T06:27:26 + a blue feather + https://openclipart.org/detail/172047/feather-by-t-i-n-a-172047 + + + T-i-n-a + + + + + bird + blue + feather + + + + + + + + + + + diff --git a/plinth/modules/featherwiki/templates/featherwiki_configure.html b/plinth/modules/featherwiki/templates/featherwiki_configure.html new file mode 100644 index 000000000..416d42f8f --- /dev/null +++ b/plinth/modules/featherwiki/templates/featherwiki_configure.html @@ -0,0 +1,60 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block configuration %} + {{ block.super }} + +

{% trans "Manage Wikis" %}

+ + + +
+
+ {% if not wikis %} +

{% trans 'No wikis available.' %}

+ {% else %} +
+ {% for wiki in wikis %} + + {% endfor %} +
+ {% endif %} +
+
+ +{% endblock %} diff --git a/plinth/modules/featherwiki/templates/featherwiki_delete.html b/plinth/modules/featherwiki/templates/featherwiki_delete.html new file mode 100644 index 000000000..714bff5bc --- /dev/null +++ b/plinth/modules/featherwiki/templates/featherwiki_delete.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

+ {% blocktrans trimmed %} + Delete wiki {{ name }} + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Hint: You can download a copy of this wiki from within + Feather Wiki before deleting it. + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Delete this wiki file permanently?

+ {% endblocktrans %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/plinth/modules/featherwiki/templates/featherwiki_upload_file.html b/plinth/modules/featherwiki/templates/featherwiki_upload_file.html new file mode 100644 index 000000000..99b274c4b --- /dev/null +++ b/plinth/modules/featherwiki/templates/featherwiki_upload_file.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ +{% endblock %} diff --git a/plinth/modules/featherwiki/tests/__init__.py b/plinth/modules/featherwiki/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/featherwiki/tests/data/dummy_wiki.html b/plinth/modules/featherwiki/tests/data/dummy_wiki.html new file mode 100644 index 000000000..fd092d709 --- /dev/null +++ b/plinth/modules/featherwiki/tests/data/dummy_wiki.html @@ -0,0 +1,9 @@ + + + + Dummy Feather Wiki File + + +

This is a not a real Feather Wiki file.

+ + diff --git a/plinth/modules/featherwiki/tests/test_functional.py b/plinth/modules/featherwiki/tests/test_functional.py new file mode 100644 index 000000000..812c54e97 --- /dev/null +++ b/plinth/modules/featherwiki/tests/test_functional.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Functional, browser based tests for Feather Wiki app.""" + +import pathlib + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.featherwiki] + +course_1 = 'Computer Organization and Architecture' +file_name_1 = 'Computer_Organization_and_Architecture.html' + + +class TestFeatherWikiApp(functional.BaseAppTests): + app_name = 'featherwiki' + has_service = False + has_web = True + + def _create_wiki_file(self, session_browser): + """Add a wiki to using the 'Create' functionality.""" + functional.nav_to_module(session_browser, 'featherwiki') + + wiki_link_1 = f'/featherwiki/{file_name_1}' + if self._get_links_in_app_page(session_browser, wiki_link_1): + return + + session_browser.links.find_by_href( + '/plinth/apps/featherwiki/create/').first.click() + session_browser.find_by_id('id_featherwiki-name').fill(course_1) + functional.submit(session_browser, form_class='form-featherwiki') + + def _get_links_in_app_page(self, session_browser, link): + """Return the links matching a href in the app page.""" + functional.nav_to_module(session_browser, 'featherwiki') + return session_browser.links.find_by_href(link) + + def _get_links_in_apache_listing(self, session_browser, link): + """Return the links matching a href in the index page.""" + default_url = functional.config['DEFAULT']['url'] + session_browser.visit(f'{default_url}/featherwiki') + return session_browser.links.find_by_href(link) + + def _assert_wiki_present(self, session_browser, file_name, present=True): + """Assert that a wiki is present.""" + wiki_link = f'/featherwiki/{file_name}' + assert bool(self._get_links_in_app_page(session_browser, + wiki_link)) == present + assert bool( + self._get_links_in_apache_listing(session_browser, + file_name)) == present + + def _assert_wiki_works(self, session_browser, file_name): + """Assert that wiki loads and run as expected.""" + wiki_link = f'/featherwiki/{file_name}' + default_url = functional.config['DEFAULT']['url'] + session_browser.visit(f'{default_url}{wiki_link}') + links = session_browser.links.find_by_href('https://feather.wiki') + assert len(links) == 1 + + def test_wiki_file_access(self, session_browser): + """Test creating a new wiki file.""" + self._create_wiki_file(session_browser) + + self._assert_wiki_present(session_browser, file_name_1) + self._assert_wiki_works(session_browser, file_name_1) + + def test_rename_wiki_file(self, session_browser): + """Test changing the name of a wiki file.""" + self._create_wiki_file(session_browser) + + new_course = 'A Midsummer Night\'s Dream' + new_file_name = 'A_Midsummer_Nights_Dream.html' + self._get_links_in_app_page( + session_browser, '/plinth/apps/featherwiki/' + file_name_1 + + '/rename/').first.click() + session_browser.find_by_id('id_featherwiki-new_name').fill(new_course) + functional.submit(session_browser, form_class='form-featherwiki') + + self._assert_wiki_present(session_browser, new_file_name) + self._assert_wiki_works(session_browser, new_file_name) + + def test_upload_wiki_file(self, session_browser): + """Test uploading an existing wiki file.""" + _test_data_dir = pathlib.Path(__file__).parent / 'data' + test_wiki_file = str(_test_data_dir / 'dummy_wiki.html') + + session_browser.links.find_by_href( + '/plinth/apps/featherwiki/upload/').first.click() + session_browser.attach_file('featherwiki-file', test_wiki_file) + functional.submit(session_browser, form_class='form-featherwiki') + + self._assert_wiki_present(session_browser, 'dummy_wiki.html') + + def test_delete_wiki_file(self, session_browser): + """Test deleting an existing wiki file""" + self._create_wiki_file(session_browser) + + self._get_links_in_app_page( + session_browser, '/plinth/apps/featherwiki/' + file_name_1 + + '/delete/').first.click() + functional.submit(session_browser, form_class='form-delete') + + self._assert_wiki_present(session_browser, file_name_1, present=False) diff --git a/plinth/modules/featherwiki/urls.py b/plinth/modules/featherwiki/urls.py new file mode 100644 index 000000000..87efd38a6 --- /dev/null +++ b/plinth/modules/featherwiki/urls.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the Feather Wiki app.""" + +from django.urls import re_path + +from .views import (CreateWikiView, FeatherWikiAppView, RenameWikiView, + UploadWikiView, delete) + +urlpatterns = [ + re_path(r'^apps/featherwiki/$', FeatherWikiAppView.as_view(), + name='index'), + re_path(r'^apps/featherwiki/create/$', CreateWikiView.as_view(), + name='create'), + re_path(r'^apps/featherwiki/upload/$', UploadWikiView.as_view(), + name='upload'), + re_path(r'^apps/featherwiki/(?P.+\.html)/rename/$', + RenameWikiView.as_view(), name='rename'), + re_path(r'^apps/featherwiki/(?P.+\.html)/delete/$', delete, + name='delete'), +] diff --git a/plinth/modules/featherwiki/views.py b/plinth/modules/featherwiki/views.py new file mode 100644 index 000000000..7692028c0 --- /dev/null +++ b/plinth/modules/featherwiki/views.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Django views for Feather Wiki.""" + +import tempfile + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView + +from plinth import app as app_module +from plinth import views +from plinth.modules import featherwiki + +from . import privileged +from .forms import CreateWikiForm, RenameWikiForm, UploadWikiForm + +DUPLICATE_FILE_ERROR = _('A wiki file with the given name already exists.') + + +class FeatherWikiAppView(views.AppView): + """Serve configuration page.""" + + app_id = 'featherwiki' + template_name = 'featherwiki_configure.html' + + def get_context_data(self, *args, **kwargs): + """Add wikis to the context data.""" + context = super().get_context_data(*args, **kwargs) + context['wikis'] = featherwiki.get_wiki_list() + return context + + +class CreateWikiView(SuccessMessageMixin, FormView): + """View to create a new repository.""" + + form_class = CreateWikiForm + prefix = 'featherwiki' + template_name = 'form.html' + success_url = reverse_lazy('featherwiki:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Create Wiki') + return context + + def form_valid(self, form): + """Create the repository on valid form submission.""" + try: + privileged.create_wiki(form.cleaned_data['name']) + self.success_message = _('Wiki created.') + except ValueError: + messages.error(self.request, DUPLICATE_FILE_ERROR) + except Exception as error: + messages.error( + self.request, "{0} {1}".format( + _('An error occurred while creating the wiki.'), error)) + + return super().form_valid(form) + + +class RenameWikiView(SuccessMessageMixin, FormView): + """View to edit an existing repository.""" + + form_class = RenameWikiForm + prefix = 'featherwiki' + template_name = 'form.html' + success_url = reverse_lazy('featherwiki:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Rename Wiki') + return context + + def form_valid(self, form): + """Rename the wiki on valid form submission.""" + try: + privileged.rename_wiki(self.kwargs['old_name'], + form.cleaned_data['new_name']) + self.success_message = _('Wiki renamed.') + except ValueError: + messages.error(self.request, DUPLICATE_FILE_ERROR) + except Exception as error: + messages.error( + self.request, "{0} {1}".format( + _('An error occurred while renaming the wiki.'), error)) + + return super().form_valid(form) + + +class UploadWikiView(SuccessMessageMixin, FormView): + """View to upload an existing wiki file.""" + + form_class = UploadWikiForm + prefix = 'featherwiki' + template_name = 'featherwiki_upload_file.html' + success_url = reverse_lazy('featherwiki:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Upload Wiki File') + return context + + def form_valid(self, form): + """Add the wiki file on valid form submission.""" + multipart_file = self.request.FILES['featherwiki-file'] + + try: + with tempfile.TemporaryDirectory() as temp_dir: + wiki_file_name = temp_dir + '/' + multipart_file.name + with open(wiki_file_name, 'wb+') as wiki_file: + for chunk in multipart_file.chunks(): + wiki_file.write(chunk) + + privileged.add_wiki_file(wiki_file_name) + + self.success_message = _('Wiki file added.') + except ValueError: + messages.error(self.request, DUPLICATE_FILE_ERROR) + except Exception as error: + messages.error( + self.request, "{0} {1}".format(_('Failed to add wiki file.'), + error)) + return redirect(reverse_lazy('featherwiki:index')) + + return super().form_valid(form) + + +def delete(request, name): + """Handle deleting wikis, showing a confirmation dialog first. + + On GET, display a confirmation page. + On POST, delete the wiki. + """ + app = app_module.App.get('featherwiki') + if request.method == 'POST': + try: + privileged.delete_wiki(name) + messages.success(request, _('{name} deleted.').format(name=name)) + except Exception as error: + messages.error( + request, + _('Could not delete {name}: {error}').format( + name=name, error=error)) + + return redirect(reverse_lazy('featherwiki:index')) + + return TemplateResponse(request, 'featherwiki_delete.html', { + 'title': app.info.name, + 'name': name + })