From 889810bb96f8ce3025370f01768111390b0c4d42 Mon Sep 17 00:00:00 2001 From: Coucouf Date: Wed, 23 Sep 2020 22:25:11 +0000 Subject: [PATCH 01/46] Translated using Weblate (French) Currently translated at 100.0% (1321 of 1321 strings) --- plinth/locale/fr/LC_MESSAGES/django.po | 561 ++++++++++++------------- 1 file changed, 279 insertions(+), 282 deletions(-) diff --git a/plinth/locale/fr/LC_MESSAGES/django.po b/plinth/locale/fr/LC_MESSAGES/django.po index 10873041b..0d3483664 100644 --- a/plinth/locale/fr/LC_MESSAGES/django.po +++ b/plinth/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: FreedomBox UI\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-09-23 11:08+0000\n" +"PO-Revision-Date: 2020-09-24 22:41+0000\n" "Last-Translator: Coucouf \n" "Language-Team: French \n" @@ -83,12 +83,12 @@ msgstr "Application installée." #: plinth/middleware.py:63 #, python-brace-format msgid "Error installing application: {string} {details}" -msgstr "Erreur lors de l’installation de l'application : {string} {details}" +msgstr "Erreur lors de l’installation de l’application : {string} {details}" #: plinth/middleware.py:67 #, python-brace-format msgid "Error installing application: {error}" -msgstr "Erreur lors de l’installation de l'application : {error}" +msgstr "Erreur lors de l’installation de l’application : {error}" #: plinth/modules/apache/__init__.py:40 #: plinth/modules/monkeysphere/templates/monkeysphere.html:67 @@ -104,12 +104,12 @@ msgstr "Interface web de la {box_name} (Plinth)" #: plinth/modules/apache/components.py:120 #, python-brace-format msgid "Access URL {url} on tcp{kind}" -msgstr "Accès à l'URL {url} via tcp{kind}" +msgstr "Accès à l’URL {url} via tcp{kind}" #: plinth/modules/apache/components.py:124 #, python-brace-format msgid "Access URL {url}" -msgstr "Accès à l'URL {url}" +msgstr "Accès à l’URL {url}" #: plinth/modules/avahi/__init__.py:34 #, python-brace-format @@ -272,7 +272,7 @@ msgid "" "Path of a new or existing repository. Example: user@host:~/path/to/repo/" msgstr "" -"Chemin d'un dépôt, nouveau ou existant. Exemple : utilisateur@machine:~/" +"Chemin d’un dépôt, nouveau ou existant. Exemple : utilisateur@machine:~/" "chemin/vers/le/dépôt/" #: plinth/modules/backups/forms.py:193 @@ -284,7 +284,7 @@ msgid "" "Password of the SSH Server.
SSH key-based authentication is not yet " "possible." msgstr "" -"Mot de passe du serveur SSH.
L’authentification par clé SSH n'est pas " +"Mot de passe du serveur SSH.
L’authentification par clé SSH n’est pas " "encore supportée." #: plinth/modules/backups/forms.py:213 @@ -403,7 +403,7 @@ msgstr "Heure" #: plinth/modules/backups/templates/backups_delete.html:36 #, python-format msgid "Delete Archive %(name)s" -msgstr "Supprimer l'archive %(name)s" +msgstr "Supprimer l’archive %(name)s" #: plinth/modules/backups/templates/backups_form.html:20 #: plinth/modules/bepasty/templates/bepasty_add.html:20 @@ -414,7 +414,7 @@ msgstr "Supprimer l'archive %(name)s" #: plinth/modules/pagekite/templates/pagekite_custom_services.html:28 #: plinth/modules/sharing/templates/sharing_add_edit.html:20 msgid "Submit" -msgstr "Soumettre" +msgstr "Envoyer" #: plinth/modules/backups/templates/backups_repository.html:19 msgid "This repository is encrypted" @@ -422,16 +422,16 @@ msgstr "Ce dépôt est chiffré" #: plinth/modules/backups/templates/backups_repository.html:34 msgid "Unmount Location" -msgstr "Démonter l'emplacement" +msgstr "Démonter l’emplacement" #: plinth/modules/backups/templates/backups_repository.html:45 msgid "Mount Location" -msgstr "Monter l'emplacement" +msgstr "Monter l’emplacement" #: plinth/modules/backups/templates/backups_repository.html:56 msgid "Remove Backup Location. This will not delete the remote backup." msgstr "" -"Supprimer l'emplacement de sauvegarde. Cette action ne supprimera pas la " +"Supprimer l’emplacement de sauvegarde. Cette action ne supprimera pas la " "sauvegarde distante." #: plinth/modules/backups/templates/backups_repository.html:77 @@ -463,7 +463,7 @@ msgstr "" #: plinth/modules/backups/templates/backups_repository_remove.html:31 msgid "Remove Location" -msgstr "Enlever l'emplacement" +msgstr "Enlever l’emplacement" #: plinth/modules/backups/templates/backups_restore.html:15 msgid "Restore data from" @@ -549,7 +549,7 @@ msgstr "Archive créée." #: plinth/modules/backups/views.py:83 msgid "Delete Archive" -msgstr "Supprimer l'archive" +msgstr "Supprimer l’archive" #: plinth/modules/backups/views.py:95 msgid "Archive deleted." @@ -605,7 +605,7 @@ msgstr "La clé publique d’authenticité du serveur SSH n’a pu être vérifi #: plinth/modules/backups/views.py:366 msgid "Authentication to remote server failed." -msgstr "L'authentification sur le serveur distant a échoué." +msgstr "L’authentification sur le serveur distant a échoué." #: plinth/modules/backups/views.py:368 msgid "Error establishing connection to server: {}" @@ -621,7 +621,7 @@ msgstr "Supprimer ce dépôt" #: plinth/modules/backups/views.py:402 msgid "Repository removed. Backups were not deleted." -msgstr "Dépôt supprimé. Les sauvegardes n'ont pas été supprimées." +msgstr "Dépôt supprimé. Les sauvegardes n’ont pas été supprimées." #: plinth/modules/backups/views.py:412 msgid "Unmounting failed!" @@ -638,6 +638,11 @@ msgid "" "audio, video and PDF documents can be previewed in the browser. Shared files " "can be set to expire after a time period." msgstr "" +"bepasty est une application web permettant l’envoi et le partage de gros " +"fichiers. Du texte et des extraits de code source peuvent également être " +"collés et partagés. Les documents texte, images, audio, vidéo et PDF " +"pourront être visualisés directement dans le navigateur. Il est possible de " +"définir une date d’expiration pour les fichiers envoyés." #: plinth/modules/bepasty/__init__.py:29 msgid "" @@ -646,6 +651,11 @@ msgid "" "password, you can share it with the users who should have the associated " "permissions." msgstr "" +"bepasty n’utilise pas de noms d’utilisateurs comme identifiants mais " +"seulement des mots de passe. Un jeu de permissions peut être défini pour " +"chaque mot de passe. Une fois que vous avez créé un mot de passe et les " +"permissions associées, vous pouvez le partager aux utilisateurs qui " +"devraient y avoir accès." #: plinth/modules/bepasty/__init__.py:33 msgid "" @@ -654,56 +664,57 @@ msgid "" "revoke access for a single person or group, by removing their password from " "the list." msgstr "" +"Vous pouvez même créer plusieurs mots de passe avec le même jeu de " +"permissions pour les distribuer à différentes personnes ou groupes de " +"personnes. Cela vous permettra par la suite de révoquer les accès d’une " +"seule personne ou d’un seul groupe en supprimant leur mot de passe de la " +"liste." #: plinth/modules/bepasty/__init__.py:42 plinth/modules/bepasty/__init__.py:51 msgid "Read a file, if a web link to the file is available" -msgstr "" +msgstr "Accès en lecture seule aux fichiers, à partir de leur lien" #: plinth/modules/bepasty/__init__.py:43 -#, fuzzy -#| msgid "Restore from uploaded file" msgid "Create or upload files" -msgstr "Restaurer du fichier téléversé" +msgstr "Créer ou envoyer des fichiers" #: plinth/modules/bepasty/__init__.py:44 msgid "List all files and their web links" -msgstr "" +msgstr "Lister l’ensemble des fichiers et récupérer leurs liens" #: plinth/modules/bepasty/__init__.py:45 -#, fuzzy -#| msgid "Delete User" msgid "Delete files" -msgstr "Supprimer Utilisateur" +msgstr "Supprimer des fichiers" #: plinth/modules/bepasty/__init__.py:46 msgid "Administer files: lock/unlock files" -msgstr "" +msgstr "Administrer des fichiers : verrouiller / déverouiller des fichiers" #: plinth/modules/bepasty/__init__.py:50 msgid "None, password is always required" -msgstr "" +msgstr "Aucune, le mot de passe est toujours exigé" #: plinth/modules/bepasty/__init__.py:52 msgid "List and read all files" -msgstr "" +msgstr "Listing et lecture de l’ensemble des fichiers" #: plinth/modules/bepasty/__init__.py:65 plinth/modules/bepasty/manifest.py:9 msgid "bepasty" -msgstr "" +msgstr "bepasty" #: plinth/modules/bepasty/__init__.py:67 -#, fuzzy -#| msgid "File Sharing" msgid "File & Snippet Sharing" -msgstr "Partage de fichiers" +msgstr "Partage de fichiers et de bribes" #: plinth/modules/bepasty/forms.py:17 msgid "Public Access (default permissions)" -msgstr "" +msgstr "Accès public (permissions par défaut)" #: plinth/modules/bepasty/forms.py:18 msgid "Permissions for anonymous users, who have not provided a password." msgstr "" +"Permissions pour les utilisateurs anonymes, n’ayant pas fourni de mot de " +"passe." #: plinth/modules/bepasty/forms.py:27 #: plinth/modules/bepasty/templates/bepasty.html:30 @@ -715,34 +726,31 @@ msgstr "Permissions" msgid "" "Users that log in with this password will have the selected permissions." msgstr "" +"Les utilisateurs se connectant avec ce mot de passe auront les permissions " +"sélectionnées." #: plinth/modules/bepasty/forms.py:33 #: plinth/modules/bepasty/templates/bepasty.html:31 msgid "Comment" -msgstr "" +msgstr "Commentaire" #: plinth/modules/bepasty/forms.py:34 msgid "Any comment to help you remember the purpose of this password." msgstr "" +"Toute information utile pour vous rappeler de l’objet de ce mot de passe." #: plinth/modules/bepasty/templates/bepasty.html:12 -#, fuzzy -#| msgid "Change Password" msgid "Manage Passwords" -msgstr "Changer Mot de Passe" +msgstr "Gestion des mots de passe" #: plinth/modules/bepasty/templates/bepasty.html:16 #: plinth/modules/bepasty/templates/bepasty.html:18 -#, fuzzy -#| msgid "Show password" msgid "Add password" -msgstr "Afficher le mot de passe" +msgstr "Ajouter un mot de passe" #: plinth/modules/bepasty/templates/bepasty.html:23 -#, fuzzy -#| msgid "No shares currently configured." msgid "No passwords currently configured." -msgstr "Aucun partage actuellement configuré." +msgstr "Aucun mot de passe actuellement configuré." #: plinth/modules/bepasty/templates/bepasty.html:29 #: plinth/modules/dynamicdns/forms.py:106 plinth/modules/networks/forms.py:205 @@ -752,15 +760,15 @@ msgstr "Mot de passe" #: plinth/modules/bepasty/views.py:42 msgid "Read" -msgstr "" +msgstr "Lecture" #: plinth/modules/bepasty/views.py:43 msgid "Create" -msgstr "Créer" +msgstr "Création" #: plinth/modules/bepasty/views.py:44 msgid "List" -msgstr "" +msgstr "Listing" #: plinth/modules/bepasty/views.py:45 #: plinth/modules/letsencrypt/templates/letsencrypt.html:91 @@ -771,13 +779,11 @@ msgstr "" #: plinth/modules/wireguard/templates/wireguard_show_client.html:73 #: plinth/modules/wireguard/templates/wireguard_show_server.html:74 msgid "Delete" -msgstr "Supprimer" +msgstr "Suppression" #: plinth/modules/bepasty/views.py:46 -#, fuzzy -#| msgid "admin" msgid "Admin" -msgstr "admin" +msgstr "Admin" #: plinth/modules/bepasty/views.py:83 plinth/modules/searx/views.py:38 #: plinth/modules/searx/views.py:49 plinth/modules/tor/views.py:130 @@ -792,29 +798,23 @@ msgid "An error occurred during configuration." msgstr "Une erreur est survenue pendant la configuration." #: plinth/modules/bepasty/views.py:97 -#, fuzzy -#| msgid "Password updated" msgid "Password added." -msgstr "Mot de passe mis à jour" +msgstr "Mot de passe ajouté." #: plinth/modules/bepasty/views.py:102 -#, fuzzy -#| msgid "Password" msgid "Add Password" -msgstr "Mot de passe" +msgstr "Ajouter un mot de passe" #: plinth/modules/bepasty/views.py:119 -#, fuzzy -#| msgid "Password updated" msgid "Password deleted." -msgstr "Mot de passe mis à jour" +msgstr "Mot de passe supprimé." #: plinth/modules/bind/__init__.py:29 msgid "" "BIND enables you to publish your Domain Name System (DNS) information on the " "Internet, and to resolve DNS queries for your user devices on your network." msgstr "" -"BIND vous permet de publier sur l'internet vos informations DNS (Système de " +"BIND vous permet de publier sur Internet vos informations DNS (Système de " "Noms de Domaines) et de résoudre les requêtes DNS pour les appareils de " "votre réseau local." @@ -901,11 +901,11 @@ msgid "" "advanced functions that are not usually required. A web based terminal for " "console operations is also available." msgstr "" -"Cockpit est un gestionnaire de serveur qui simplifie l'administration de " -"serveurs GNU/Linux à l'aide d'un navigateur web. Sur une {box_name}, des " -"contrôles sont disponibles pour des fonctions avancées qui ne sont pas " -"nécessaires habituellement. Un terminal web est également disponible pour " -"des opérations depuis une console." +"Cockpit est un gestionnaire de serveur qui simplifie l’administration de " +"serveurs GNU/Linux à l’aide d'un navigateur web. Sur une {box_name}, des " +"contrôles sont disponibles pour de nombreuses fonctions avancées qui ne sont " +"pas nécessaires dans la plupart des cas. Un terminal web est également " +"fourni pour les opérations d’administration réalisables via une console." #: plinth/modules/cockpit/__init__.py:38 msgid "" @@ -914,6 +914,12 @@ msgid "" "firewall ports and advanced networking such as bonding, bridging and VLAN " "management." msgstr "" +"Cockpit peut être utilisé pour des opérations avancées sur le stockage " +"telles que le partitionnement de disques et la gestion de grappes de disques " +"RAID. Il peut aussi être utilisé pour l’ouverture de ports personnalisés sur " +"le pare-feu, ainsi que les opérations de réseau avancées telles que l’" +"agrégation de liens (« bonding »), la création de ponts (« bridging ») et la " +"gestion de réseaux virtuels VLAN." #: plinth/modules/cockpit/__init__.py:43 #, python-brace-format @@ -1002,8 +1008,8 @@ msgid "" msgstr "" "Le nom de machine est le nom local par lequel les autres appareils sur le " "réseau local joindront votre {box_name}. Le nom doit commencer et se " -"terminer par une lettre de l'alphabet ou un chiffre et contenir seulement " -"des lettres de l'alphabet, des chiffres ou des traits d’union « - ». Sa " +"terminer par une lettre de l’alphabet ou un chiffre et contenir seulement " +"des lettres de l’alphabet, des chiffres ou des traits d’union « - ». Sa " "longueur maximum est de 63 signes." #: plinth/modules/config/forms.py:57 @@ -1023,8 +1029,8 @@ msgstr "" "Le nom de domaine est le nom global par lequel les autres appareils sur " "Internet peuvent joindre votre {box_name}. Le nom ne doit contenir que des " "mots ou « labels » séparés par des points. Chaque label doit commencer et se " -"terminer par une lettre de l'alphabet ou un chiffre et contenir uniquement " -"des lettres de l'alphabet, des chiffres ou des traits d’union « - ». La " +"terminer par une lettre de l’alphabet ou un chiffre et contenir uniquement " +"des lettres de l’alphabet, des chiffres ou des traits d’union « - ». La " "longueur maximum d'un label est de 63 signes. La longueur totale du nom de " "domaine ne doit pas excéder 253 signes." @@ -1110,7 +1116,7 @@ msgstr "" "Coturn est un serveur facilitant les appels audios/vidéos ainsi que les " "conférences, en fournissant une implémentation des protocoles TURN et STUN. " "Les autres types de serveurs de communication, comme WebRTC et SIP, peuvent " -"l'utiliser afin d'établir un appel entre plusieurs parties qui n'auraient " +"l’utiliser afin d'établir un appel entre plusieurs parties qui n’auraient " "pas pu se connecter entre elles autrement." #: plinth/modules/coturn/__init__.py:35 @@ -1118,8 +1124,8 @@ msgid "" "It is not meant to be used directly by users. Servers such as matrix-synapse " "need to be configured with the details provided here." msgstr "" -"Ceci n'est pas sensé être utilisé directement par les utilisateurs. Les " -"serveurs du type matrix-synapse nécessitent d'être configurés avec les " +"Il n’est pas destiné à être utilisé directement par les utilisateurs finaux. " +"Les serveurs du type matrix-synapse nécessitent d’être configurés avec les " "détails fournis ici." #: plinth/modules/coturn/__init__.py:52 @@ -1145,20 +1151,20 @@ msgstr "" #: plinth/modules/coturn/templates/coturn.html:15 msgid "Use the following URLs to configure your communication server:" msgstr "" -"Veuillez utiliser l'URL suivante pour configurer votre serveur de " +"Veuillez utiliser l’URL suivante pour configurer votre serveur de " "communication :" #: plinth/modules/coturn/templates/coturn.html:26 msgid "Use the following shared authentication secret:" -msgstr "Veuillez utiliser les secrets d'authentification partagés suivants :" +msgstr "Veuillez utiliser les secrets d’authentification partagés suivants :" #: plinth/modules/datetime/__init__.py:25 msgid "" "Network time server is a program that maintains the system time in " "synchronization with servers on the Internet." msgstr "" -"Le serveur de temps réseau est un programme permettant de maintenir l'heure " -"du système synchronisée avec les serveurs sur Internet." +"Le serveur de temps réseau est un programme permettant de maintenir l’heure " +"du système synchronisée avec les serveurs Internet." #: plinth/modules/datetime/__init__.py:69 msgid "Date & Time" @@ -1242,20 +1248,16 @@ msgid "Diagnostics" msgstr "Diagnostiques" #: plinth/modules/diagnostics/__init__.py:102 -#, fuzzy -#| msgid "Quassel" msgid "passed" -msgstr "Quassel" +msgstr "réussi" #: plinth/modules/diagnostics/__init__.py:103 -#, fuzzy -#| msgid "Setup failed." msgid "failed" -msgstr "Échec de l’installation." +msgstr "échoué" #: plinth/modules/diagnostics/__init__.py:104 msgid "error" -msgstr "" +msgstr "erreur" #: plinth/modules/diagnostics/templates/diagnostics.html:17 #: plinth/modules/diagnostics/templates/diagnostics_button.html:13 @@ -1283,7 +1285,7 @@ msgstr "Résultats des diagnostics" #: plinth/modules/diagnostics/templates/diagnostics_app.html:17 msgid "This app does not support diagnostics" -msgstr "Cette application n'est pas compatible avec les tests de diagnostic." +msgstr "Cette application n’est pas compatible avec les tests de diagnostic." #: plinth/modules/diagnostics/templates/diagnostics_results.html:10 msgid "Test" @@ -1316,7 +1318,7 @@ msgstr "Réseau social fédéré" #: plinth/modules/diaspora/forms.py:13 msgid "Enable new user registrations" -msgstr "Activer l'inscription de nouveaux utilisateurs" +msgstr "Activer l’inscription de nouveaux utilisateurs" #: plinth/modules/diaspora/manifest.py:11 msgid "dandelion*" @@ -1372,7 +1374,7 @@ msgid "" "24h), it may be hard for others to find you on the Internet. This will " "prevent others from finding services which are provided by this {box_name}." msgstr "" -"Si votre fournisseur d'accès change régulièrement votre adresse IP (par ex. " +"Si votre fournisseur d’accès change régulièrement votre adresse IP (par ex. " "toutes les 24 h), il pourrait être difficile de vous retrouver sur Internet. " "Et ceci empêchera d’autres utilisateurs de découvrir les services proposés " "par cette {box_name}." @@ -1387,13 +1389,13 @@ msgid "" "Internet asks for your DNS name, they will get a response with your current " "IP address." msgstr "" -"La solution consiste à assigner un nom DNS à votre adresse IP et " -"d’actualiser le nom DNS à chaque fois que votre IP est modifiée par votre " -"fournisseur d'accès. Le système de DNS dynamique vous permet de publier " -"votre adresse IP courante vers un serveur GnuDIP. Le serveur se charge ensuite " -"d’assigner votre nom DNS à la nouvelle IP, de sorte que si quelqu’un sur " -"Internet demande votre nom DNS, il obtiendra bien votre adresse IP courante." +"La solution consiste à assigner un nom DNS à votre adresse IP et d’" +"actualiser le nom DNS à chaque fois que votre IP est modifiée par votre " +"fournisseur d’accès. Le système de DNS dynamique vous permet de publier " +"votre adresse IP courante vers un serveur GnuDIP. Le serveur se charge ensuite d’assigner " +"votre nom DNS à la nouvelle IP, de sorte que si quelqu’un sur Internet " +"demande votre nom DNS, il obtiendra bien votre adresse IP courante." #: plinth/modules/dynamicdns/__init__.py:55 msgid "Dynamic DNS Client" @@ -1450,7 +1452,7 @@ msgid "" "If this option is selected, your username and password will be used for HTTP " "basic authentication." msgstr "" -"Si ce choix est sélectionné, votre nom d'utilisateur et votre mot de passe " +"Si ce choix est sélectionné, votre nom d’utilisateur et votre mot de passe " "seront utilisés pour une authentification HTTP de base." #: plinth/modules/dynamicdns/forms.py:51 @@ -1467,8 +1469,8 @@ msgid "" msgstr "" "Paramètre optionnel. Entrer l’URL d’un service qui détecte automatiquement " "votre adresse IP publique réelle et la retourne en réponse (par exemple : " -"http://myip.datasystems24.de). Cette URL sera utilisée si votre {box_name} " -"n'est pas connectée directement à Internet (par exemple lorsqu’elle est " +"http://myip.datasystems24.de). Cette URL sera utilisée si votre {box_name} n’" +"est pas connectée directement à Internet (par exemple lorsqu’elle est " "connectée à un routeur à translation d’adresse réseau « NAT »)." #: plinth/modules/dynamicdns/forms.py:62 @@ -1478,17 +1480,15 @@ msgstr "" #: plinth/modules/dynamicdns/forms.py:65 msgid "GnuDIP" -msgstr "" +msgstr "GnuDIP" #: plinth/modules/dynamicdns/forms.py:68 -#, fuzzy -#| msgid "Update URL" msgid "other update URL" -msgstr "URL d’actualisation" +msgstr "autre URL d’actualisation" #: plinth/modules/dynamicdns/forms.py:70 msgid "Enable Dynamic DNS" -msgstr "Activer le DNS Dynamique" +msgstr "Activer le DNS dynamique" #: plinth/modules/dynamicdns/forms.py:73 msgid "Service Type" @@ -1496,7 +1496,7 @@ msgstr "Type de service" #: plinth/modules/dynamicdns/forms.py:78 msgid "GnuDIP Server Address" -msgstr "Adresse Serveur GnuDIP" +msgstr "Adresse du serveur GnuDIP" #: plinth/modules/dynamicdns/forms.py:81 msgid "Invalid server name" @@ -1553,10 +1553,10 @@ msgid "" "freedns.afraid.org." msgstr "" "Si vous cherchez un compte DNS dynamique gratuit, vous pourrez sans doute " -"trouver un service compatible GnuDIP sur gnudip.datasystems24.net ou un " -"service à base d'URL d’actualisation sur freedns.afraid.org." +"trouver un service compatible GnuDIP sur gnudip.datasystems24.net ou un " +"service à base d’URL d’actualisation sur freedns.afraid.org." #: plinth/modules/dynamicdns/templates/dynamicdns.html:23 #, python-format @@ -1565,10 +1565,10 @@ msgid "" "port forwarding for standard ports, including TCP port 80 (HTTP) and TCP " "port 443 (HTTPS)." msgstr "" -"Si votre %(box_name)s est connectée derrière un routeur à translation " -"d’adresse réseau (NAT), n'oubliez pas d'ajouter une redirection pour les " -"ports 80 (serveur http) et 443 (serveur sécurisé https) vers l'IP de votre " -"%(box_name)s. La redirection se configure sur l'interface web de votre " +"Si votre %(box_name)s est connectée derrière un routeur à translation d’" +"adresse réseau (NAT), n’oubliez pas d’ajouter une redirection pour les ports " +"80 (serveur http) et 443 (serveur sécurisé https) vers l’IP de votre " +"%(box_name)s. La redirection se configure sur l’interface web de votre " "routeur." #: plinth/modules/dynamicdns/templates/dynamicdns_configure.html:15 @@ -1577,7 +1577,7 @@ msgid "" "functions may not work (but the main functionality should work)." msgstr "" "Vous avez désactivé Javascript. Le mode formulaire dynamique est désactivé, " -"quelques fonctions d'aide pourraient ne pas marcher (mais la fonctionnalité " +"quelques fonctions d’aide pourraient ne pas marcher (mais la fonctionnalité " "principale devrait fonctionner)." #: plinth/modules/dynamicdns/templates/dynamicdns_status.html:9 @@ -1605,12 +1605,12 @@ msgid "" "for this, otherwise IP changes will not be detected). In case the WAN IP " "changes, it may take up to %(timer)s minutes until your DNS entry is updated." msgstr "" -"Derrière un NAT. Cela signifie que votre fournisseur d'URL pour DNS " -"dynamique sondera « l'URL pour rechercher l'IP publique » afin de détecter " -"d'éventuels changements (c'est pour cela que « l'URL pour rechercher l'IP " -"publique » est nécessaire, sinon les changements d'adresse IP ne seront pas " +"Derrière un NAT. Cela signifie que votre fournisseur d’URL pour DNS " +"dynamique sondera « l’URL pour rechercher l’IP publique » afin de détecter d’" +"éventuels changements (c’est pour cela que « l’URL pour rechercher l’IP " +"publique » est nécessaire, sinon les changements d’adresse IP ne seront pas " "détectés). Cela peut prendre %(timer)s minutes avant la mise à jour de votre " -"entrée DNS en cas de modification de l'IP publique." +"entrée DNS en cas de modification de l’IP publique." #: plinth/modules/dynamicdns/templates/dynamicdns_status.html:33 msgid "Last update" @@ -1860,6 +1860,9 @@ msgid "" "Advanced firewall operations such as opening custom ports are provided by " "the Cockpit app." msgstr "" +"Les opérations de pare-feu avancées telles que l’ouverture de ports " +"personnalisés sont accessibles dans l’appli Cockpit." #: plinth/modules/first_boot/forms.py:14 #, python-brace-format @@ -1930,8 +1933,8 @@ msgid "" "To learn more on how to use Git visit Git tutorial." msgstr "" -"Pour en apprendre plus sur l'utilisation de Git, consultez ce tutoriel Git." +"Pour en apprendre plus sur l’utilisation de Git, consultez ce tutoriel Git." #: plinth/modules/gitweb/__init__.py:51 msgid "Read-write access to Git repositories" @@ -1976,7 +1979,7 @@ msgstr "Dépôt privé" #: plinth/modules/gitweb/forms.py:92 msgid "Allow only authorized users to access this repository." -msgstr "Ne permet l'accès à ce dépôt qu'aux utilisateurs autorisés." +msgstr "Ne permet l’accès à ce dépôt qu’aux utilisateurs autorisés." #: plinth/modules/gitweb/forms.py:113 plinth/modules/gitweb/forms.py:155 msgid "A repository with this name already exists." @@ -1991,14 +1994,12 @@ msgid "An alpha-numeric string that uniquely identifies a repository." msgstr "Une chaîne alpha-numérique qui identifie de manière unique le dépôt." #: plinth/modules/gitweb/forms.py:134 -#, fuzzy -#| msgid "Default Skin" msgid "Default branch" -msgstr "Thème par défaut" +msgstr "Branche par défaut" #: plinth/modules/gitweb/forms.py:135 msgid "Gitweb displays this as a default branch." -msgstr "" +msgstr "Gitweb affiche cette branche comme branche par défaut." #: plinth/modules/gitweb/manifest.py:21 msgid "Git" @@ -2070,7 +2071,7 @@ msgstr "{name} supprimé." #: plinth/modules/gitweb/views.py:142 #, python-brace-format msgid "Could not delete {name}: {error}" -msgstr "La suppression de {name} n'a pas abouti : {error}" +msgstr "La suppression de {name} a échoué : {error}" #: plinth/modules/help/__init__.py:33 msgid "Documentation" @@ -2078,11 +2079,9 @@ msgstr "Documentation" #: plinth/modules/help/__init__.py:37 plinth/templates/help-menu.html:20 #: plinth/templates/help-menu.html:21 plinth/templates/index.html:128 -#, fuzzy -#| msgid "Manual" msgctxt "User guide" msgid "Manual" -msgstr "Manuel" +msgstr "Manuel utilisateur" #: plinth/modules/help/__init__.py:41 #: plinth/modules/help/templates/help_support.html:9 @@ -2096,7 +2095,7 @@ msgstr "Obtenir de l’aide" #: plinth/modules/help/views.py:36 plinth/templates/help-menu.html:33 #: plinth/templates/help-menu.html:34 msgid "Submit Feedback" -msgstr "Faire un retour d'utilisation" +msgstr "Partagez vos impressions" #: plinth/modules/help/__init__.py:49 #: plinth/modules/help/templates/help_contribute.html:9 @@ -2153,7 +2152,7 @@ msgid "" "package." msgstr "" "Il existe de nombreux projets dont le but est de développer une architecture " -"distribuée de l'Internet. %(box_name)s vise à intégrer ces différents " +"distribuée de l’Internet. %(box_name)s vise à intégrer ces différents " "projets dans un système unique et facile à utiliser." #: plinth/modules/help/templates/help_about.html:51 @@ -2162,7 +2161,7 @@ msgid "" "For more information about the %(box_name)s project, see the %(box_name)s Wiki." msgstr "" -"Pour plus d'informations sur le projet %(box_name)s, visitez le Wiki %(box_name)s." #: plinth/modules/help/templates/help_about.html:60 @@ -2249,9 +2248,9 @@ msgid "" "improve them on our discussion forum." msgstr "" -"Faites-nous part de fonctionnalités qui vous manquent, de vos applications " -"favorites et de vos idées pour les améliorer sur notre forum de discussions." +"Faites-nous part des fonctionnalités qui vous manquent, de vos applications " +"favorites et de vos idées pour les améliorer sur notre forum de discussions." #: plinth/modules/help/templates/help_feedback.html:26 msgid "" @@ -2263,9 +2262,9 @@ msgstr "" "Si vous rencontrez des bogues ou des problèmes, veuillez utiliser notre outil de suivi de tickets (en anglais) pour que nos " -"développeurs en soient informés. Avant cela, vérifiez que le problème n'a " -"pas déjà été remonté auparavant, et si ce n’est pas le cas cliquez sur le " -"bouton « Nouveau ticket »." +"développeurs en soient informés. Vérifiez tout d’abord que votre problème n’" +"a pas déjà été remonté, et si tel n’est pas le cas cliquez sur le bouton « " +"Nouveau ticket »." #: plinth/modules/help/templates/help_feedback.html:36 msgid "Thank you!" @@ -2292,7 +2291,7 @@ msgid "" "%(box_name)s project wiki contains further information." msgstr "" "Le wiki " -"du projet %(box_name)scontient davantage d'informations." +"du projet %(box_name)scontient davantage d’informations." #: plinth/modules/help/templates/help_index.html:30 #, python-format @@ -2302,7 +2301,7 @@ msgid "" "\"> mailing list. The list archives also contain information about " "problems faced by other users and possible solutions." msgstr "" -"Pour obtenir de l'aide de la communauté %(box_name)s, vous pouvez poser vos " +"Pour obtenir de l’aide de la communauté %(box_name)s, vous pouvez poser vos " "questions en anglais sur la liste de discussion du projet. " "Les archives de la liste contiennent aussi des informations sur les " @@ -2319,9 +2318,9 @@ msgid "" msgstr "" "De nombreux contributeurs et utilisateurs de %(box_name)s sont également " "présents sur le réseau IRC irc.oftc.net. Rejoignez le canal (en anglais) #freedombox en utilisant " -"l'interface web pour IRC et demandez de l'aide." +"href=\"https://webchat.oftc.net/?randomnick=1&channels=freedombox&prompt=1\"" +"> #freedombox en utilisant l’interface web pour IRC et demandez de " +"l’aide." #: plinth/modules/help/templates/help_manual.html:25 msgid "Download as PDF" @@ -2335,7 +2334,7 @@ msgid "" "contributors." msgstr "" "Si vous avez besoin d’aide pour réaliser une action ou si vous faites face à " -"un problème en utilisant la %(box_name)s, vous pouvez demander de l'aide à " +"un problème en utilisant la %(box_name)s, vous pouvez demander de l’aide à " "la communauté d’utilisateurs et de contributeurs." #: plinth/modules/help/templates/help_support.html:20 @@ -2362,7 +2361,7 @@ msgstr "" #: plinth/modules/help/templates/statuslog.html:10 msgid "Status Log" -msgstr "Journal d'état" +msgstr "Journal d’état" #: plinth/modules/help/templates/statuslog.html:13 #, python-format @@ -2430,7 +2429,7 @@ msgstr "" #: plinth/modules/i2p/__init__.py:56 msgid "Manage I2P application" -msgstr "Gérer l'application I2P" +msgstr "Gérer l’application I2P" #: plinth/modules/i2p/__init__.py:59 plinth/modules/i2p/manifest.py:16 msgid "I2P" @@ -2769,8 +2768,8 @@ msgid "" "Certificate successfully revoked for domain {domain}.This may take a few " "moments to take effect." msgstr "" -"Certificat révoqué avec succès pour le domaine {domain}. Cette modification " -"peut nécessiter quelques instants avant de prendre effet." +"Le certificat du domaine {domain} a été révoqué. Cette modification peut " +"nécessiter quelques instants avant de prendre effet." #: plinth/modules/letsencrypt/views.py:47 #, python-brace-format @@ -2782,7 +2781,7 @@ msgstr "" #: plinth/modules/letsencrypt/views.py:77 #, python-brace-format msgid "Certificate successfully obtained for domain {domain}" -msgstr "Certificat obtenu avec succès pour le domaine {domain}" +msgstr "Le certificat pour le domaine {domain} a été obtenu avec succès" #: plinth/modules/letsencrypt/views.py:65 #: plinth/modules/letsencrypt/views.py:82 @@ -2793,7 +2792,7 @@ msgstr "Échec de l'obtention du certificat pour le domaine {domain} : {error}" #: plinth/modules/letsencrypt/views.py:94 #, python-brace-format msgid "Certificate successfully deleted for domain {domain}" -msgstr "Certificat supprimé avec succès pour le domaine {domain}" +msgstr "Le certificat pour le domaine {domain} a été supprimé" #: plinth/modules/letsencrypt/views.py:99 #, python-brace-format @@ -2820,19 +2819,14 @@ msgstr "" "autres serveurs Matrix grâce la fédération." #: plinth/modules/matrixsynapse/__init__.py:40 -#, fuzzy -#| msgid "" -#| "To communicate, you can use the available clients for mobile, desktop and the web. Riot client is recommended." msgid "" "To communicate, you can use the available clients for mobile, desktop and the web. Element client is recommended." msgstr "" -"Pour communiquer, vous pouvez utiliser les clients disponibles pour mobile, ordinateur de bureau et " -"web. Le client Riot est recommandé." +"Pour communiquer, vous pouvez utiliser un des clients disponibles pour mobile, ordinateur de bureau " +"et web. Le client Element est recommandé." #: plinth/modules/matrixsynapse/__init__.py:67 msgid "Matrix Synapse" @@ -2854,7 +2848,7 @@ msgstr "" #: plinth/modules/matrixsynapse/manifest.py:13 msgid "Element" -msgstr "" +msgstr "Element" #: plinth/modules/matrixsynapse/templates/matrix-synapse-pre-setup.html:15 #: plinth/modules/snapshot/templates/snapshot.html:12 @@ -3171,7 +3165,7 @@ msgstr "Serveur de streaming de médias" #: plinth/modules/minidlna/__init__.py:47 msgid "MiniDLNA" -msgstr "" +msgstr "MiniDLNA" #: plinth/modules/minidlna/__init__.py:48 msgid "Simple Media Server" @@ -3453,9 +3447,10 @@ msgid "" "href=\"http://mumble.info\">Clients to connect to Mumble from your " "desktop and Android devices are available." msgstr "" -"Vous pouvez vous connecter au serveur Mumble sur un port normal Mumble " -"64738. Il existe des clients permettant " -"de se connecter à Mumble depuis un ordinateur ou un appareil Android." +"Vous pouvez vous connecter au serveur Mumble sur le port Mumble habituel " +"64738. Utilisez l’un des clients " +"permettant de se connecter à Mumble depuis votre ordinateur ou votre " +"appareil Android." #: plinth/modules/mumble/__init__.py:43 plinth/modules/mumble/manifest.py:12 msgid "Mumble" @@ -3492,7 +3487,7 @@ msgstr "Mumla" #: plinth/modules/mumble/views.py:27 msgid "SuperUser password successfully updated." -msgstr "Mot de passe du super utilisateur mis à jour avec succès." +msgstr "Le mot de passe du super utilisateur a été mis à jour." #: plinth/modules/names/__init__.py:26 #, python-brace-format @@ -3521,10 +3516,8 @@ msgid "All web apps" msgstr "Toutes les applications web" #: plinth/modules/names/templates/names.html:16 -#, fuzzy -#| msgid "Service" msgid "Services" -msgstr "Service" +msgstr "Services" #: plinth/modules/networks/__init__.py:40 msgid "" @@ -3615,11 +3608,9 @@ msgid "Shared" msgstr "Partagée" #: plinth/modules/networks/forms.py:48 plinth/modules/networks/forms.py:79 -#, fuzzy -#| msgid "Manual" msgctxt "Not automatically" msgid "Manual" -msgstr "Manuel" +msgstr "Manuelle" #: plinth/modules/networks/forms.py:54 msgid "Netmask" @@ -3874,15 +3865,7 @@ msgstr "" "choisir cette option.

" #: plinth/modules/networks/forms.py:360 -#, fuzzy, python-brace-format -#| msgid "" -#| "I have a public IP address that does not change overtime (recommended)

This means that devices on the Internet can reach " -#| "you when you are connected to the Internet. Every time you connect to the " -#| "Internet with your Internet Service Provider (ISP), you always get the " -#| "same IP address. This is the most trouble-free setup for many {box_name} " -#| "services but very few ISPs offer this. You may be able to get this " -#| "service from your ISP by making an additional payment.

" +#, python-brace-format msgid "" "I have a public IP address that does not change over time (recommended)

This means that devices on the Internet can reach you " @@ -3892,13 +3875,13 @@ msgid "" "but very few ISPs offer this. You may be able to get this service from your " "ISP by making an additional payment.

" msgstr "" -"J’ai une adresse IP publique ne changeant pas avec le temps (recommandé)

Cela signifie que les appareils sur Internet peuvent " -"vous joindre quand vous êtes connecté à Internet. À chaque connexion avec " -"votre fournisseur d’accès à Internet (FAI), vous avez toujours la même " -"adresse IP. Il s’agit de la situation la moins problématique pour beaucoup " -"des services de la {box_name} mais très peu de FAI proposent cela. Il est " -"possible que votre FAI propose cette option moyennant un surcoût.

" +"J’ai toujours la même adresse IP publique statique (recommandé)

Cela signifie que les appareils sur Internet peuvent vous " +"joindre quand vous êtes connecté à Internet. À chaque connexion avec votre " +"fournisseur d’accès à Internet (FAI), vous avez toujours la même adresse IP. " +"Il s’agit de la situation la moins problématique pour beaucoup des services " +"de la {box_name} mais très peu de FAI le proposent. Il se peut que votre FAI " +"propose cette option moyennant un surcoût.

" #: plinth/modules/networks/forms.py:373 #, python-brace-format @@ -3978,10 +3961,10 @@ msgid "" "have not configured or are unable to configure the router currently and wish " "to be reminded later. Some of the other configuration steps may fail.

" msgstr "" -"Le routeur n’est pas configuré actuellement

Choisissez cette option si vous n’avez pas encore pu configurer le " -"routeur et souhaitez vous faire rappeler de le configurer plus tard. Il se " -"peut que d’autres étapes de configuration échouent.

" +"Le routeur n’est pas configuré actuellement

Choisissez cette option si vous n’avez pas encore pu configurer le routeur " +"et souhaitez vous faire rappeler de le configurer ultérieurement. Il se peut " +"que d’autres étapes de configuration échouent.

" #: plinth/modules/networks/templates/connection_show.html:28 msgid "Edit connection" @@ -4132,7 +4115,7 @@ msgstr "Sécurité" #: plinth/modules/networks/templates/connection_show.html:276 #: plinth/modules/networks/templates/connection_show.html:295 msgid "Firewall zone" -msgstr "Zone pare-feu" +msgstr "Zone de pare-feu" #: plinth/modules/networks/templates/connection_show.html:265 msgid "" @@ -4162,6 +4145,8 @@ msgid "" "This interface is not maintained by %(box_name)s. For security, it is " "automatically assigned to the external zone." msgstr "" +"Cette interface n’est pas gérée par la %(box_name)s. Pour des raisons de " +"sécurité elle est assignée automatiquement à la zone externe." #: plinth/modules/networks/templates/connections_create.html:19 msgid "Create Connection" @@ -4314,7 +4299,7 @@ msgstr "" #: plinth/modules/networks/templates/internet_connectivity_main.html:41 #: plinth/modules/networks/templates/network_topology_main.html:41 msgid "Update..." -msgstr "Actualiser…" +msgstr "Modifier…" #: plinth/modules/networks/templates/network_topology_content.html:10 #, python-format @@ -4365,7 +4350,7 @@ msgid "" "connect to %(box_name)s for their Internet connectivity." msgstr "" "Votre %(box_name)s est directement connectée à Internet et tous vos " -"appareils se connectent à la {box_name}s pour leur connectivité Internet." +"appareils passent par la %(box_name)s pour leur connectivité Internet." #: plinth/modules/networks/templates/network_topology_main.html:34 #, python-format @@ -4381,6 +4366,10 @@ msgid "" "Advanced networking operations such as bonding, bridging and VLAN management " "are provided by the Cockpit app." msgstr "" +"Les opérations de réseau avancées telles que l’agrégation de liens (« " +"bonding »), la création de ponts (« bridging ») et la gestion de réseaux " +"virtuels VLAN sont accessibles dans l’appli Cockpit." #: plinth/modules/networks/templates/router_configuration_content.html:10 #, python-format @@ -4604,11 +4593,12 @@ msgid "" "available for most platforms. Click \"Learn more...\" above for recommended " "clients and instructions on how to configure them." msgstr "" -"Pour vous connecter au réseau VPN de la %(box_name)s, vous devez télécharger " -"un profil et le fournir à un client OpenVPN sur votre mobile ou sur votre " -"ordinateur. Les clients OpenVPN sont disponibles pour de nombreuses plate-" -"formes. Cliquez sur « Application clientes » ci-dessus pour connaître les " -"clients recommandés et leurs instructions de configuration." +"Pour vous connecter au réseau privé virtuel VPN de la %(box_name)s, vous " +"devez télécharger un profil et le fournir à un client OpenVPN sur votre " +"mobile ou sur votre ordinateur. Les clients OpenVPN sont disponibles pour " +"de nombreuses plate-formes. Cliquez sur « Application clientes » ci-dessus " +"pour connaître les clients recommandés et leurs instructions de " +"configuration." #: plinth/modules/openvpn/templates/openvpn.html:74 #, python-format @@ -4853,16 +4843,22 @@ msgid "" "utilization of the hardware. This can give you basic insights into usage " "patterns and whether the hardware is overloaded by users and services." msgstr "" +"L’appli Performance vous permet de collecter, stocker et visualiser les " +"statistiques d’utilisation de la machine. Elle peut vous donner un aperçu " +"des tendances d’utilisation et vérifier si la machine est surchargée par " +"certains utilisateurs ou certains services." #: plinth/modules/performance/__init__.py:29 msgid "" "Performance metrics are collected by Performance Co-Pilot and can be viewed " "using the Cockpit app." msgstr "" +"Les métriques de performance sont collectées par le Co-Pilote « Performance »" +" et visualisables dans l’appli Cockpit." #: plinth/modules/performance/__init__.py:46 msgid "System Monitoring" -msgstr "Contrôle Système" +msgstr "Surveillance du système" #: plinth/modules/power/__init__.py:16 msgid "Restart or shut down the system." @@ -5016,12 +5012,7 @@ msgid "Quasseldroid" msgstr "Quasseldroid" #: plinth/modules/radicale/__init__.py:29 -#, fuzzy, python-brace-format -#| msgid "" -#| "Radicale is a CalDAV and CardDAV server. It allows synchronization and " -#| "sharing of scheduling and contact data. To use Radicale, a supported client application is " -#| "needed. Radicale can be accessed by any user with a {box_name} login." +#, python-brace-format msgid "" "Radicale is a CalDAV and CardDAV server. It allows synchronization and " "sharing of scheduling and contact data. To use Radicale, a is needed. Radicale can be accessed by any user with a " "{box_name} login." msgstr "" -"Radicale est un serveur pour les protocoles d’édition de calendrier en ligne " -"CalDAV et de carnet d'adresses CardDAV. Il permet de synchroniser et de " -"partager ses rendez-vous et ses contacts. Pour utiliser Radicale, une application client compatible est " -"nécessaire. N’importe quel utilisateur disposant d’un compte sur la " -"{box_name} peut accéder à Radicale." +"Radicale est un serveur pour les protocoles de gestion de calendriers en " +"ligne CalDAV et de carnets d’adresses CardDAV. Il permet de synchroniser ses " +"rendez-vous et ses contacts entre plusieurs appareils et de les partager. " +"Pour utiliser Radicale, vous devrez passer par une application cliente " +"compatible. Tous les utilisateur disposant d’un compte sur la {box_name} " +"ont accès à Radicale." #: plinth/modules/radicale/__init__.py:35 msgid "" @@ -5082,10 +5074,8 @@ msgstr "" "visualiser et modifier n’importe quel agenda/carnet d’adresses." #: plinth/modules/radicale/forms.py:30 -#, fuzzy -#| msgid "Access Point" msgid "Access rights" -msgstr "Point d’accès" +msgstr "Droits d’accès" #: plinth/modules/radicale/manifest.py:10 msgid "DAVx5" @@ -5251,7 +5241,7 @@ msgstr "Client Android Samba" #: plinth/modules/samba/manifest.py:28 msgid "Ghost Commander - Samba plugin" -msgstr "" +msgstr "Module Samba pour Ghost Commander" #: plinth/modules/samba/manifest.py:42 msgid "VLC media player" @@ -5275,8 +5265,8 @@ msgid "" "Note: Only specially created directories will be shared on selected disks, " "not the whole disk." msgstr "" -"Note : seuls les répertoires créés spécialement pour seront partagés sur les " -"disques sélectionnés, et non pas les disques entiers." +"Remarque : seuls les répertoires créés spécialement pour seront partagés sur " +"les disques sélectionnés, et non pas les disques entiers." #: plinth/modules/samba/templates/samba.html:34 msgid "Disk Name" @@ -5460,12 +5450,12 @@ msgstr "Afficher le rapport de sécurité" #: plinth/modules/upgrades/templates/backports-firstboot.html:11 #: plinth/modules/upgrades/templates/upgrades_configure.html:11 msgid "Frequent Feature Updates" -msgstr "" +msgstr "Mise à jour régulière des fonctionnalités" #: plinth/modules/security/templates/security.html:19 #: plinth/modules/upgrades/templates/upgrades_configure.html:19 msgid "Frequent feature updates are activated." -msgstr "" +msgstr "La mise à jour régulière des fonctionnalités est activée." #: plinth/modules/security/templates/security.html:24 #: plinth/modules/upgrades/templates/backports-firstboot.html:14 @@ -5480,6 +5470,14 @@ msgid "" "Instead, they are maintained by contributors to Debian and the %(box_name)s " "community." msgstr "" +"En activant cette option, le système %(box_name)s et un nombre limité de " +"logiciels recevront des mises à jours plus fréquentes (via le dépôt de " +"rétroportage « backports ») pouvant inclure de nouvelles fonctionnalités. " +"Ceci vous permet de bénéficier de nouvelles fonctionnalités au bout de " +"quelques semaines plutôt que tous les 2 ans environ. Veuillez noter que les " +"logiciels bénéficiant de ces mises à jours fréquentes ne sont pas supportés " +"par l’équipe Debian en charge de la sécurité. Ils sont simplement maintenus " +"par les contributeurs de Debian et la communauté %(box_name)s." #: plinth/modules/security/templates/security_report.html:10 #: plinth/modules/security/views.py:74 @@ -5880,16 +5878,12 @@ msgid "Yearly Snapshots Limit" msgstr "Instantanés annuels à conserver" #: plinth/modules/snapshot/forms.py:49 -#, fuzzy -#| msgid "" -#| "Keep a maximum of this many yearly snapshots. The default value is 0 " -#| "(disabled)." msgid "" "Keep a maximum of this many yearly snapshots. The default value is 0 (keep " "no yearly snapshot)." msgstr "" "Conserver au maximum ce nombre d’instantanés pris tous les ans. La valeur " -"par défaut est de 0 (désactivé)." +"par défaut est de 0 (ne pas conserver d’instantané annuel)." #: plinth/modules/snapshot/templates/snapshot_delete_selected.html:12 msgid "Delete the following snapshots permanently?" @@ -6264,6 +6258,9 @@ msgid "" "Advanced storage operations such as disk partitioning and RAID management " "are provided by the Cockpit app." msgstr "" +"Les opérations avancées sur le stockage telles que le partitionnement de " +"disques et la gestion de grappes de disques RAID sont accessible dans l’" +"appli Cockpit." #: plinth/modules/storage/templates/storage_expand.html:14 #, python-format @@ -6283,7 +6280,7 @@ msgstr "Erreur lors de l’extension de la partition : {exception}" #: plinth/modules/storage/views.py:73 msgid "Partition expanded successfully." -msgstr "Partition étendue avec succès." +msgstr "Extension de la partition réalisée avec succès." #: plinth/modules/storage/views.py:91 #, python-brace-format @@ -6694,11 +6691,12 @@ msgid "" "to be unavailable briefly. If system reboot is deemed necessary, it is done " "automatically at 02:00 causing all apps to be unavailable briefly." msgstr "" -"Les mises à jour sont exécutées à 6h00 tous les jours, selon votre heure " -"locale. Veuillez chosir votre fuseau horaire dans l'application Date & Time. " -"Les applications redémarrent après la mise à jour, causant brièvement leurs " -"interruptions. Si le redémarrage du système est nécessaire, il sera effectué " -"à 2h00, causant brièvement l'interruption de toutes les applications." +"Les mises à jour sont exécutées à 6h00 heure locale tous les jours. Vous " +"pouvez définir votre fuseau horaire dans l’application Date et heure. Les " +"applications sont redémarrées après leur mise à jour ce qui les rend " +"brièvement indisponibles. Lorsque qu’un redémarrage du système est " +"nécessaire, il est effectué à 2h00, rendant indisponible l’ensemble des " +"applications pour une courte période." #: plinth/modules/upgrades/__init__.py:64 plinth/templates/setup.html:62 msgid "Update" @@ -6721,19 +6719,24 @@ msgstr "" #: plinth/modules/upgrades/forms.py:20 #: plinth/modules/upgrades/templates/upgrades_configure.html:56 msgid "Activate frequent feature updates (recommended)" -msgstr "" +msgstr "Activer la mise à jour régulière des fonctionnalités (recommandé)" #: plinth/modules/upgrades/templates/backports-firstboot.html:26 msgid "" "It is strongly recommended to activate frequent feature updates. If not " "activated now, they can be activated later." msgstr "" +"Il est fortement recommandé d’activer la mise à jour régulière des " +"fonctionnalités. Si vous choisissez de ne pas l’activez maintenant, vous " +"pourrez toujours le faire par la suite." #: plinth/modules/upgrades/templates/backports-firstboot.html:33 msgid "" "Note: Once frequent feature updates are activated, they " "cannot be deactivated." msgstr "" +"Remarque : Une fois activée, la mise à jour régulière des " +"fonctionnalités ne peut plus être désactivée." #: plinth/modules/upgrades/templates/upgrades-new-release.html:9 #, python-format @@ -6758,12 +6761,16 @@ msgstr "Fermer" msgid "" "Frequent feature updates can be activated. Activating them is recommended." msgstr "" +"Vous pouvez activer la mise à jour régulière des fonctionnalités. Cette " +"option est recommandée." #: plinth/modules/upgrades/templates/upgrades_configure.html:23 msgid "" "Frequent feature updates cannot be activated. They may not be necessary on " "your distribution." msgstr "" +"La mise à jour régulière des fonctionnalités n’est pas disponibles. Elle n’" +"est sans doute pas nécessaire pour votre distribution." #: plinth/modules/upgrades/templates/upgrades_configure.html:45 #, python-format @@ -6772,10 +6779,12 @@ msgid "" "cannot be deactivated. You may wish to take a snapshot using Storage Snapshots before continuing." msgstr "" +"Attention ! Une fois activée, la mise à jour régulière des " +"fonctionnalités ne peut plus être désactivée. Il est recommandé de prendre " +"un instantané du système via la fonction d’Instantanés de disque avant de poursuivre." #: plinth/modules/upgrades/templates/upgrades_configure.html:61 -#, fuzzy -#| msgid "Manual update" msgid "Manual Update" msgstr "Mise à jour manuelle" @@ -6785,29 +6794,22 @@ msgstr "Mise à jour en cours…" #: plinth/modules/upgrades/templates/upgrades_configure.html:75 msgid "Update now" -msgstr "Mettre à jour immédiatement" +msgstr "Lancer la mise à jour" #: plinth/modules/upgrades/templates/upgrades_configure.html:81 -#, fuzzy -#| msgid "" -#| "This may take a long time to complete. During an update, " -#| "you cannot install apps. Also, this web interface may be temporarily " -#| "unavailable and show an error. In that case, refresh the page to continue." msgid "" "This may take a long time to complete. During an update, " "you cannot install apps. Also, this web interface may be temporarily " "unavailable and show an error. In that case, refresh the page to continue." msgstr "" "Cette opération peut prendre du temps. Pendant la durée de " -"la mise à jour, vous ne pouvez pas installer d’autre appli. Cette interface " -"web pourrait aussi être temporairement indisponible ou afficher une erreur. " -"Si cela se produit, rafraîchissez la page pour continuer." +"la mise à jour, vous ne pourrez pas installer d’autre appli. Cette interface " +"web pourrait aussi devenir temporairement indisponible ou afficher une " +"erreur. Si cela se produit, rafraîchissez la page pour continuer." #: plinth/modules/upgrades/templates/upgrades_configure.html:95 -#, fuzzy -#| msgid "Toggle recent update logs" msgid "Show recent update logs" -msgstr "Basculer les journaux de modification récents" +msgstr "Afficher les derniers journaux de mises à jour" #: plinth/modules/upgrades/views.py:57 #, python-brace-format @@ -6826,7 +6828,7 @@ msgstr "Mises à niveau automatiques désactivées" #: plinth/modules/upgrades/views.py:79 msgid "Upgrade process started." -msgstr "Mise à niveau initiée." +msgstr "Mise à jour lancée." #: plinth/modules/upgrades/views.py:81 msgid "Starting upgrade failed." @@ -6834,7 +6836,7 @@ msgstr "Le lancement de la mise à niveau a échoué." #: plinth/modules/upgrades/views.py:91 msgid "Frequent feature updates activated." -msgstr "" +msgstr "Mise à jour régulière des fonctionnalités activée." #: plinth/modules/users/__init__.py:39 msgid "" @@ -7074,7 +7076,7 @@ msgstr "Changer Mot de Passe" #: plinth/modules/users/views.py:147 msgid "Password changed successfully." -msgstr "Mot de passe changé avec succès." +msgstr "Le mot de passe a été changé." #: plinth/modules/wireguard/__init__.py:24 msgid "WireGuard is a fast, modern, secure VPN tunnel." @@ -7086,9 +7088,9 @@ msgid "" "It can be used to connect to a VPN provider which supports WireGuard, and to " "route all outgoing traffic from {box_name} through the VPN." msgstr "" -"Il peut être utilisé pour se connecter à un fournisseur de VPN proposant un " -"service WireGuard, pour router tout le trafic sortant de la {box_name} à " -"travers ce VPN." +"Il peut être utilisé pour se connecter à un fournisseur de réseau privé " +"virtuel VPN proposant un service WireGuard, pour router tout le trafic " +"sortant de la {box_name} à travers ce VPN." #: plinth/modules/wireguard/__init__.py:30 #, python-brace-format @@ -7160,9 +7162,9 @@ msgid "" "endpoint. This value is usually provided by the server operator. Example: " "192.168.0.10." msgstr "" -"Adresse IP attribuée à cette machine sur le VPN une fois connecté au serveur " -"distant. Cette valeur est généralement fournie par le gestionnaire du " -"serveur. Par exemple : 192.18.0.10." +"Adresse IP attribuée à cette machine sur le réseau privé virtuel VPN une " +"fois connecté au serveur distant. Cette valeur est généralement fournie par " +"le gestionnaire du serveur. Par exemple : 192.18.0.10." #: plinth/modules/wireguard/forms.py:89 msgid "Private key of this machine" @@ -7203,8 +7205,8 @@ msgstr "Utiliser cette connexion pour y envoyer tout le trafic sortant" #: plinth/modules/wireguard/forms.py:107 msgid "Typically checked for a VPN service though which all traffic is sent." msgstr "" -"Activé la plupart du temps pour un service VPN à travers lequel tout le " -"trafic est envoyé." +"À activer en général pour un service de réseau privé virtuel VPN à travers " +"lequel tout le trafic est envoyé." #: plinth/modules/wireguard/templates/wireguard.html:10 msgid "As a Server" @@ -7554,7 +7556,7 @@ msgstr "S'identifier" #: plinth/templates/clients-button.html:16 msgid "Launch web client" -msgstr "Lancer le client web" +msgstr "Ouvrir le client web" #: plinth/templates/clients-button.html:25 msgid "Client Apps" @@ -7641,10 +7643,10 @@ msgid "" "server to deploy social applications on small machines. It provides online " "communication tools respecting your privacy and data ownership." msgstr "" -"%(box_name)s, un pur produit de Debian, est un serveur web d’auto-" -"hébergement 100%% à base de logiciel libre conçu pour déployer des " -"applications sociales sur de petites machines. Les outils de communication " -"proposés sont respectueux de votre vie privée et de vos données personnelles." +"Pur produit de Debian, %(box_name)s est un serveur web d’auto-hébergement " +"composé à 100%% de logiciels libres, conçu pour déployer des applications " +"sociales sur de petites machines. Les outils de communication proposés sont " +"respectueux de votre vie privée et de vos données personnelles." #: plinth/templates/index.html:117 #, python-format @@ -7653,7 +7655,7 @@ msgid "" "free software, distributed under the GNU Affero General Public License, " "Version 3 or later." msgstr "" -"Ce portail fait partie de l'interface web de la %(box_name)s. %(box_name)s " +"Ce portail fait partie de l’interface web de la %(box_name)s. %(box_name)s " "est un logiciel libre distribué sous licence GNU Affero General Public " "License, Version 3 ou ultérieure." @@ -7682,13 +7684,14 @@ msgid "Mailing list" msgstr "Liste de discussion du projet" #: plinth/templates/internal-zone.html:11 -#, fuzzy, python-format -#| msgid "%(service_name)s is available only on internal networks." +#, python-format msgid "" "%(service_name)s is available only on internal networks or when the " "client is connected to %(box_name)s through VPN." msgstr "" -"%(service_name)s n'est disponible que sur les réseaux internes." +"%(service_name)s n’est disponible que sur les réseaux internes où " +"lorsque le client est connecté à la %(box_name)s via un réseau privé virtuel " +"VPN." #: plinth/templates/internal-zone.html:17 msgid "Currently there are no network interfaces configured as internal." @@ -7713,16 +7716,13 @@ msgid "Port Forwarding" msgstr "Redirection de port" #: plinth/templates/port-forwarding-info.html:13 -#, fuzzy, python-format -#| msgid "" -#| "You may want to check the network setup " -#| "and modify it if necessary." +#, python-format msgid "" "Your FreedomBox is not behind a router. No " "action is necessary." msgstr "" -"Vous pouvez vérifier la configuration du " -"réseau et la modifier si nécessaire." +"Votre FreedomBox n’est pas derrière un routeur. Aucune action n’est nécessaire." #: plinth/templates/port-forwarding-info.html:19 #, python-format @@ -7731,37 +7731,34 @@ msgid "" "are using the DMZ feature to forward all ports. No further router " "configuration is necessary." msgstr "" +"Votre FreedomBox est derrière un routeur " +"et vous utilisez la fonction de zone démilitarisée DMZ pour rediriger tous " +"les ports. Aucune configuration supplémentaire du routeur n’est nécessaire." #: plinth/templates/port-forwarding-info.html:26 -#, fuzzy, python-format -#| msgid "" -#| "If your FreedomBox is behind a router, you will need to set up port " -#| "forwarding on your router. You should forward the following ports for " -#| "%(service_name)s:" +#, python-format msgid "" "Your FreedomBox is behind a router and you " "are not using the DMZ feature. You will need to set up port forwarding on " "your router. You should forward the following ports for %(service_name)s:" msgstr "" -"Si votre FreedomBox se situe derrière un routeur, vous aurez besoin de " -"mettre en place une redirection de port sur votre routeur. Vous devriez " +"Votre FreedomBox est derrière un routeur et " +"vous n’utilisez pas la fonction de zone démilitarisée DMZ. Vous allez devoir " +"mettre en place une redirection de ports sur votre routeur. Vous devriez " "rediriger les ports suivants pour %(service_name)s :" #: plinth/templates/port-forwarding-info.html:36 -#, fuzzy -#| msgid "protocol" msgid "Protocol" -msgstr "protocole" +msgstr "Protocole" #: plinth/templates/port-forwarding-info.html:37 msgid "From Router/WAN Ports" -msgstr "" +msgstr "Depuis les ports routeur/Internet (WAN)" #: plinth/templates/port-forwarding-info.html:38 -#, fuzzy, python-format -#| msgid "%(box_name)s Setup" +#, python-format msgid "To %(box_name)s Ports" -msgstr "Configuration initiale de la %(box_name)s" +msgstr "Vers les ports %(box_name)s" #: plinth/templates/setup.html:24 msgid "Install this application?" From f69d8289f64961c85a6dab2f8baf8f2c7d64bfd5 Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Sat, 19 Sep 2020 15:38:41 +0530 Subject: [PATCH 02/46] bepasty: Change default permissions to 'read' Allow read access by URL by default. Tests: - Installing bepasty fresh show the default permissions as read. - Upgrading bepasty from older version when default permissions are none sets the default permissions to read. - Upgrading bepasty from older version when default permissions are not none retrains the permissions. Signed-off-by: Joseph Nuthalapati [sunil: Don't relocate setup() method] Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa Tested-by: Sunil Mohan Adapa --- actions/bepasty | 2 +- plinth/modules/bepasty/__init__.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/actions/bepasty b/actions/bepasty index f6bc5eb46..b8bf179a0 100755 --- a/actions/bepasty +++ b/actions/bepasty @@ -148,7 +148,7 @@ def subcommand_setup(arguments): passwords[1]: 'editor', passwords[2]: 'viewer', }, - 'DEFAULT_PERMISSIONS': '', + 'DEFAULT_PERMISSIONS': 'read', } conf_file_write(conf) CONF_FILE.chmod(0o640) diff --git a/plinth/modules/bepasty/__init__.py b/plinth/modules/bepasty/__init__.py index 597d87513..0fa082e8c 100644 --- a/plinth/modules/bepasty/__init__.py +++ b/plinth/modules/bepasty/__init__.py @@ -15,7 +15,7 @@ from plinth.modules.firewall.components import Firewall from .manifest import backup, clients # noqa, pylint: disable=unused-import -version = 1 +version = 2 managed_packages = ['bepasty', 'uwsgi', 'uwsgi-plugin-python3'] @@ -98,6 +98,9 @@ def setup(helper, old_version=None): helper.call('post', actions.superuser_run, 'bepasty', ['setup', '--domain-name', 'freedombox.local']) helper.call('post', app.enable) + if old_version == 1 and not get_configuration().get('DEFAULT_PERMISSIONS'): + # Upgrade to a better default only if user hasn't changed the value. + set_default_permissions('read') def get_configuration(): From d588e7fa25c1575aeec78c78436edd9aa140c15c Mon Sep 17 00:00:00 2001 From: Fioddor Superconcentrado Date: Mon, 14 Sep 2020 19:09:47 +0000 Subject: [PATCH 03/46] HACKING: Add extra development requirements [sunil: Reword the extra requirements to explain when they are needed] Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- HACKING.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/HACKING.md b/HACKING.md index 12431e4e4..d4c1a9152 100644 --- a/HACKING.md +++ b/HACKING.md @@ -4,13 +4,23 @@ FreedomBox is built as part of Debian GNU/Linux. However, you don't need to install Debian to do development for FreedomBox. FreedomBox development is -typically done on a container or a Virtual Machine. +typically done with a container or a Virtual Machine. -* For running a container, you need systemd containers, Git, Python and a +* For running a container, you need systemd containers, Git, Python3 and a sudo-enabled user. This approach is recommended. * For running a VM, you can work on any operating system that can install latest versions of Git, Vagrant and VirtualBox. +In addition: + +- To run code quality checks you need flake8 and yapf. These may be installed + inside the container with Python3 and pip but installing them on host leads to + smoother development experience. +- To update translation strings on the host machine, you need Django and gettext + to be installed on host machine. These can be installed with Python3 and pip. +- You need Mumble voice chat software to participate in bi-weekly live + development discussions. + ## Using Containers The `./container` script shipped with FreedomBox source code can manage the @@ -422,7 +432,7 @@ For more information on translations: https://wiki.debian.org/FreedomBox/Transla ## Application Icons When adding a new App into FreedomBox, an icon is needed to represent the app in -the application view and for shortcuts in the front page. The following the +the application view and for shortcuts in the front page. Follow these guidelines for creating an app icon: - Use SVG format. From b527b003a3517578e0cb8feaed7583b367929c1a Mon Sep 17 00:00:00 2001 From: Fioddor Superconcentrado Date: Wed, 16 Sep 2020 13:59:09 +0000 Subject: [PATCH 04/46] CONTRIBUTING: Require flake8 compliance Reviewed-by: Sunil Mohan Adapa --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf0cb8465..9b74d4070 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Naming conventions: repository. * Before committing your changes ensure that your code conforms to base code hygiene by running tests (see HACKING.md) and the automatic code formatting - tools `yapf` and `isort`. + tools `yapf` and `isort`. Please check also with `flake8`. * Please include one single feature per merge request, to keep the review simple and focused on one topic. (This might still mean hundreds of lines of code.) Use a branch other than `master`, so you can create multiple merge requests From 75a46962f054d4f5739f11ecc5751c2d1669f7bf Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 16:57:08 -0700 Subject: [PATCH 05/46] module_loader, web_framework: Update console log messages - Drop message for initialization of each app. This was removed earlier but was reintroduced during init() refactoring. There is not much use for these messages and the noise they generate hide some important messages relating to domains added. - Print lists in a better way for humans. - Add a log message after all the initialization is completed. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/module_loader.py | 5 ++--- plinth/web_framework.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/plinth/module_loader.py b/plinth/module_loader.py index a8c0621db..420c4a0e7 100644 --- a/plinth/module_loader.py +++ b/plinth/module_loader.py @@ -66,12 +66,13 @@ def load_modules(): except KeyError: logger.error('Unsatified dependency for module - %s', module_name) - logger.info('Module load order - %s', ordered_modules) + logger.info('Initializing apps - %s', ', '.join(ordered_modules)) for module_name in ordered_modules: _initialize_module(module_name, modules[module_name]) loaded_modules[module_name] = modules[module_name] + logger.debug('App initialization completed.') post_module_loading.send_robust(sender="module_loader") @@ -134,8 +135,6 @@ def _initialize_module(module_name, module): if module.setup_helper.get_state( ) != 'needs-setup' and module.app.is_enabled(): module.app.set_enabled(True) - - logger.debug("Initialized %s", module.__name__) except Exception as exception: logger.exception('Exception while running init for %s: %s', module, exception) diff --git a/plinth/web_framework.py b/plinth/web_framework.py index 481e2bbf5..22689c963 100644 --- a/plinth/web_framework.py +++ b/plinth/web_framework.py @@ -51,7 +51,7 @@ def init(read_only=False): django.setup(set_prefix=True) logger.debug('Configured Django with applications - %s', - settings.INSTALLED_APPS) + ', '.join(settings.INSTALLED_APPS)) def post_init(): From e9559b2d0dc10042e6ef1fda5d4f17cacb9b953b Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 17:02:02 -0700 Subject: [PATCH 06/46] dynamicdns: Drop unnecessary code to set app as enabled This is already handled by module loader. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/dynamicdns/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plinth/modules/dynamicdns/__init__.py b/plinth/modules/dynamicdns/__init__.py index bcf20774f..dc87de12c 100644 --- a/plinth/modules/dynamicdns/__init__.py +++ b/plinth/modules/dynamicdns/__init__.py @@ -9,9 +9,9 @@ from plinth import actions from plinth import app as app_module from plinth import cfg, menu from plinth.modules.names.components import DomainType +from plinth.modules.users.components import UsersAndGroups from plinth.signals import domain_added from plinth.utils import format_lazy -from plinth.modules.users.components import UsersAndGroups from .manifest import backup # noqa, pylint: disable=unused-import @@ -76,7 +76,6 @@ class DynamicDNSApp(app_module.App): domain_type='domain-type-dynamic', name=current_status['dynamicdns_domain'], services='__all__') - self.set_enabled(True) def is_enabled(self): """Return whether all the leader components are enabled. From ec5f99406879baffca3e99844617c36d293c106a Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 17:13:39 -0700 Subject: [PATCH 07/46] pagekite: Don't announce unconfigured kite as a valid domain Closes: #1891. As soon as the app is installed, the default configuration has NAME.pagekite.me. This is incorrectly announced as a valid domain to the system. Avoid this behavior by never announcing this default configured kite name. Tests: - Install Pagekite. Observe that the default kite name is not announced as a domain (confirmed in Name Services). With the patch, it will. - Change the domain to anything other than default. It is announced. LE tries to obtain cert. Name Services list the domain. - Change the domain back to default kite name. It is not announced. Old domain is removed. - Start FreedomBox with Pagekite configured with default domain. It is not announced. - Start FreedomBox with Pagekite configured with non-default domain. It is announced. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/pagekite/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plinth/modules/pagekite/utils.py b/plinth/modules/pagekite/utils.py index d2a7d4572..7c3cf266d 100644 --- a/plinth/modules/pagekite/utils.py +++ b/plinth/modules/pagekite/utils.py @@ -20,6 +20,9 @@ KITE_SECRET = '@kitesecret' # Augeas base path for Pagekite configuration files CONF_PATH = '/files/etc/pagekite.d' +# Kite name used by default in config. Should be treated as unconfigured. +UNCONFIGURED_KITE = 'NAME.pagekite.me' + # Parameters that get stored in configuration service_on entries SERVICE_PARAMS = [ 'protocol', 'kitename', 'backend_host', 'backend_port', 'secret' @@ -178,7 +181,7 @@ def update_names_module(is_enabled=None): service for service, value in config['predefined_services'].items() if value ] - if config['kite_name']: + if config['kite_name'] and config['kite_name'] != UNCONFIGURED_KITE: domain_added.send_robust(sender='pagekite', domain_type='domain-type-pagekite', name=config['kite_name'], From 9b9238188a32b7c9350f1db0b7e89c0de043a36d Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 17:29:46 -0700 Subject: [PATCH 08/46] pagekite: Don't update names module if not installed - Due to a minor regression with init() method refactoring: - Pagekite tries to check if it is enabled even when it is not installed. This is an unnecessary check. - Pagekite tries to remove domains from name services even when it is not installed. This could have unnecessary consequences. Fix this by checking if Pagekite is installed and perform name services updating only if service is enabled. Tests: - Enable pagekite and configure it. When FreedomBox is restarted, Pagekite kite is announced as domain and shown in Name Services. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/pagekite/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plinth/modules/pagekite/__init__.py b/plinth/modules/pagekite/__init__.py index eff0ab899..3a305ed76 100644 --- a/plinth/modules/pagekite/__init__.py +++ b/plinth/modules/pagekite/__init__.py @@ -81,7 +81,9 @@ class PagekiteApp(app_module.App): self.add(daemon) # Register kite name with Name Services module. - utils.update_names_module(is_enabled=self.is_enabled()) + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() != 'needs-setup' and self.is_enabled(): + utils.update_names_module(is_enabled=True) def enable(self): """Send domain signals after enabling the app.""" From 7b7d42fe331575b397346ff18875f64c3185ea4b Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 17:45:02 -0700 Subject: [PATCH 09/46] tor: Don't check if enabled when not installed Tests: - Install Tor and enable onion service. Restart FreedomBox. During initialization the onion service domain is added. Check in Name Services. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/tor/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plinth/modules/tor/__init__.py b/plinth/modules/tor/__init__.py index 7cd98bfc9..b8ae723f5 100644 --- a/plinth/modules/tor/__init__.py +++ b/plinth/modules/tor/__init__.py @@ -87,7 +87,9 @@ class TorApp(app_module.App): self.add(users_and_groups) # Register hidden service name with Name Services module. - if self.is_enabled() and app_is_running(self): + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() != 'needs-setup' and \ + self.is_enabled() and app_is_running(self): status = utils.get_status(initialized=False) hostname = status['hs_hostname'] services = [int(port['virtport']) for port in status['hs_ports']] From 68475668ea1ed80b981b4658331285dd3eac9b2e Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Sun, 20 Sep 2020 20:33:15 -0700 Subject: [PATCH 10/46] tests: functional: Simplify calling the login helper Signed-off-by: Sunil Mohan Adapa Reviewed-by: Joseph Nuthalapati --- plinth/tests/functional/__init__.py | 8 +++++++- plinth/tests/functional/step_definitions.py | 4 +--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plinth/tests/functional/__init__.py b/plinth/tests/functional/__init__.py index 9987eed8d..ec73a0be9 100644 --- a/plinth/tests/functional/__init__.py +++ b/plinth/tests/functional/__init__.py @@ -234,7 +234,13 @@ def _create_admin_account(browser, username, password): submit(browser) -def login(browser, url, username, password): +def login(browser): + """Login to the interface.""" + login_with_account(browser, base_url, config['DEFAULT']['username'], + config['DEFAULT']['password']) + + +def login_with_account(browser, url, username, password): # XXX: Find a way to remove the hardcoded jsxc URL if '/plinth/' not in browser.url or '/jsxc/jsxc' in browser.url: diff --git a/plinth/tests/functional/step_definitions.py b/plinth/tests/functional/step_definitions.py index 034c002af..76fc56984 100644 --- a/plinth/tests/functional/step_definitions.py +++ b/plinth/tests/functional/step_definitions.py @@ -13,9 +13,7 @@ from plinth.tests import functional @given("I'm a logged in user") def logged_in_user(session_browser): - functional.login(session_browser, functional.base_url, - functional.config['DEFAULT']['username'], - functional.config['DEFAULT']['password']) + functional.login(session_browser) @given("I'm a logged out user") From 6bfffeee138ea88ab3c9e84d0965ca193c2c4647 Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Sun, 9 Dec 2018 21:34:18 +0530 Subject: [PATCH 11/46] calibre: Add new e-book library app [joseph: initial code for the app] Signed-off-by: Joseph Nuthalapati [sunil: use the modified framework API] [sunil: simplify setup logic, move to service file] [sunil: strict security for service file, dynamic users] [sunil: interface for managing libraries] [sunil: implement backup/restore] [sunil: add functional, action, and view tests] [sunil: use svg icon] [sunil: update description] [sunil: fix apache configuration] Signed-off-by: Sunil Mohan Adapa Reviewed-by: Joseph Nuthalapati --- actions/calibre | 76 ++ plinth/modules/calibre/__init__.py | 124 ++ .../conf-available/calibre-freedombox.conf | 10 + .../data/etc/plinth/modules-enabled/calibre | 1 + .../system/calibre-server-freedombox.service | 47 + plinth/modules/calibre/forms.py | 29 + plinth/modules/calibre/manifest.py | 21 + .../templates/calibre-delete-library.html | 30 + plinth/modules/calibre/templates/calibre.html | 59 + plinth/modules/calibre/tests/__init__.py | 0 plinth/modules/calibre/tests/data/sample.txt | 1 + plinth/modules/calibre/tests/test_actions.py | 84 ++ .../modules/calibre/tests/test_functional.py | 180 +++ plinth/modules/calibre/tests/test_views.py | 146 +++ plinth/modules/calibre/urls.py | 16 + plinth/modules/calibre/views.py | 75 ++ plinth/templates/form.html | 22 + pytest.ini | 1 + static/themes/default/icons/calibre.png | Bin 0 -> 56951 bytes static/themes/default/icons/calibre.svg | 1139 +++++++++++++++++ 20 files changed, 2061 insertions(+) create mode 100755 actions/calibre create mode 100644 plinth/modules/calibre/__init__.py create mode 100644 plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf create mode 100644 plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre create mode 100644 plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service create mode 100644 plinth/modules/calibre/forms.py create mode 100644 plinth/modules/calibre/manifest.py create mode 100644 plinth/modules/calibre/templates/calibre-delete-library.html create mode 100644 plinth/modules/calibre/templates/calibre.html create mode 100644 plinth/modules/calibre/tests/__init__.py create mode 100644 plinth/modules/calibre/tests/data/sample.txt create mode 100644 plinth/modules/calibre/tests/test_actions.py create mode 100644 plinth/modules/calibre/tests/test_functional.py create mode 100644 plinth/modules/calibre/tests/test_views.py create mode 100644 plinth/modules/calibre/urls.py create mode 100644 plinth/modules/calibre/views.py create mode 100644 plinth/templates/form.html create mode 100644 static/themes/default/icons/calibre.png create mode 100644 static/themes/default/icons/calibre.svg diff --git a/actions/calibre b/actions/calibre new file mode 100755 index 000000000..bc8d51cd7 --- /dev/null +++ b/actions/calibre @@ -0,0 +1,76 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Configuration helper for calibre. +""" + +import argparse +import json +import pathlib +import shutil +import subprocess + +from plinth.modules import calibre + +LIBRARIES_PATH = pathlib.Path('/var/lib/calibre-server-freedombox/libraries') + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparsers.add_parser('list-libraries', + help='Return the list of libraries setup') + subparser = subparsers.add_parser('create-library', + help='Create an empty library') + subparser.add_argument('name', help='Name of the new library') + subparser = subparsers.add_parser('delete-library', + help='Delete a library and its contents') + subparser.add_argument('name', help='Name of the library to delete') + + subparsers.required = True + return parser.parse_args() + + +def subcommand_list_libraries(_): + """Return the list of libraries setup.""" + libraries = [] + for library in LIBRARIES_PATH.glob('*/metadata.db'): + libraries.append(str(library.parent.name)) + + print(json.dumps({'libraries': libraries})) + + +def subcommand_create_library(arguments): + """Create an empty library.""" + calibre.validate_library_name(arguments.name) + library = LIBRARIES_PATH / arguments.name + library.mkdir(mode=0o755) # Raise exception if already exists + subprocess.call( + ['calibredb', '--with-library', library, 'list_categories'], + stdout=subprocess.DEVNULL) + + # Force systemd StateDirectory= logic to assign proper ownership to the + # DynamicUser= + shutil.chown(LIBRARIES_PATH.parent, 'root', 'root') + + +def subcommand_delete_library(arguments): + """Delete a library and its contents.""" + calibre.validate_library_name(arguments.name) + library = LIBRARIES_PATH / arguments.name + shutil.rmtree(library) + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +if __name__ == '__main__': + main() diff --git a/plinth/modules/calibre/__init__.py b/plinth/modules/calibre/__init__.py new file mode 100644 index 000000000..e3786812c --- /dev/null +++ b/plinth/modules/calibre/__init__.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for calibre e-book library. +""" + +import json +import re + +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.daemon import Daemon +from plinth.modules.apache.components import Webserver +from plinth.modules.firewall.components import Firewall +from plinth.modules.users.components import UsersAndGroups +from plinth.utils import format_lazy + +from .manifest import backup, clients # noqa, pylint: disable=unused-import + +version = 1 + +managed_services = ['calibre-server-freedombox'] + +managed_packages = ['calibre'] + +_description = [ + format_lazy( + _('calibre server provides online access to your e-book collection. ' + 'You can store your e-books on your {box_name}, read them online or ' + 'from any of your devices.'), box_name=_(cfg.box_name)), + _('You can organize your e-books, extract and edit their metadata, and ' + 'perform advanced search. It can import, export, or convert across a ' + 'wide range of formats to make e-books ready for reading on any ' + 'device. You can read books on your browser with the builtin web ' + 'reader. It remembers your last read location, bookmarks, and ' + 'highlighted text.'), + _('Only users belonging to calibre group will be able to access ' + 'the app. Content distribution with OPDS is currently not supported.') +] + +app = None + +LIBRARY_NAME_PATTERN = r'[a-zA-Z0-9 _-]+' + + +class CalibreApp(app_module.App): + """FreedomBox app for calibre.""" + + app_id = 'calibre' + + def __init__(self): + """Create components for the app.""" + super().__init__() + + groups = {'calibre': _('Administer calibre application')} + + info = app_module.Info(app_id=self.app_id, version=version, + name=_('calibre'), icon_filename='calibre', + short_description=_('E-book Library'), + description=_description, manual_page='calibre', + clients=clients) + self.add(info) + + menu_item = menu.Menu('menu-calibre', info.name, + info.short_description, info.icon_filename, + 'calibre:index', parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-calibre', info.name, + short_description=info.short_description, + icon=info.icon_filename, url='/calibre', + clients=info.clients, + login_required=True, + allowed_groups=list(groups)) + self.add(shortcut) + + firewall = Firewall('firewall-calibre', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + webserver = Webserver('webserver-calibre', 'calibre-freedombox', + urls=['https://{host}/calibre']) + self.add(webserver) + + daemon = Daemon('daemon-calibre', managed_services[0], + listen_ports=[(8844, 'tcp4')]) + self.add(daemon) + + users_and_groups = UsersAndGroups('users-and-groups-calibre', + reserved_usernames=['calibre'], + groups=groups) + self.add(users_and_groups) + + +def setup(helper, old_version=None): + """Install and configure the module.""" + helper.install(managed_packages) + helper.call('post', app.enable) + + +def validate_library_name(library_name): + """Raise exception if library name does not fit the accepted pattern.""" + if not re.fullmatch(r'[A-Za-z0-9_.-]+', library_name): + raise Exception('Invalid library name') + + +def list_libraries(): + """Return a list of libraries.""" + output = actions.superuser_run('calibre', ['list-libraries']) + return json.loads(output)['libraries'] + + +def create_library(name): + """Create an empty library.""" + actions.superuser_run('calibre', ['create-library', name]) + actions.superuser_run('service', ['try-restart', managed_services[0]]) + + +def delete_library(name): + """Delete a library and its contents.""" + actions.superuser_run('calibre', ['delete-library', name]) + actions.superuser_run('service', ['try-restart', managed_services[0]]) diff --git a/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf b/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf new file mode 100644 index 000000000..e423f2134 --- /dev/null +++ b/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf @@ -0,0 +1,10 @@ +## +## On all sites, provide calibre web interface on a path: /calibre +## + + ProxyPass http://localhost:8844/calibre + Include includes/freedombox-single-sign-on.conf + + TKTAuthToken "admin" "calibre" + + diff --git a/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre b/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre new file mode 100644 index 000000000..bef75a6fd --- /dev/null +++ b/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre @@ -0,0 +1 @@ +plinth.modules.calibre diff --git a/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service new file mode 100644 index 000000000..29941bc85 --- /dev/null +++ b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +[Unit] +Description=calibre Content Server +Documentation=man:calibre-server(1) +After=network.target + +[Service] +CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER CAP_NET_ADMIN CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE CAP_KILL CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_LINUX_IMMUTABLE CAP_IPC_LOCK CAP_SYS_CHROOT CAP_BLOCK_SUSPEND CAP_LEASE CAP_SYS_PACCT CAP_SYS_TTY_CONFIG CAP_SYS_BOOT CAP_MAC_ADMIN CAP_MAC_OVERRIDE CAP_SYS_NICE CAP_SYS_RESOURCE +DevicePolicy=closed +Environment=HOME="/var/lib/calibre-server-freedombox" +Environment=DEFAULT_LIBRARY="/var/lib/calibre-server-freedombox/libraries/Library" +Environment=ARGS="--listen-on 127.0.0.1 --url-prefix /calibre --port 8844 --enable-local-write --disable-auth" +ExecStartPre=sh -e -c "files=$$(ls ${HOME}/libraries/*/metadata.db 2>/dev/null || true); [ \"x$${files}\" = \"x\" ] && (mkdir -p \"${DEFAULT_LIBRARY}\" && calibredb --with-library=\"${DEFAULT_LIBRARY}\" list_categories > /dev/null) || true" +ExecStart=sh -e -c "files=${HOME}/libraries/*/metadata.db; libraries=$$(dirname $${files}) ; exec /usr/bin/calibre-server $ARGS $${libraries}" +Restart=on-failure +ExecReload=/bin/kill -HUP $MAINPID +DynamicUser=yes +LockPersonality=yes +NoNewPrivileges=yes +PrivateDevices=yes +PrivateMounts=yes +PrivateTmp=yes +PrivateUsers=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +StateDirectory=calibre-server-freedombox +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@resources +SystemCallFilter=~@privileged +SystemCallErrorNumber=EPERM +Type=simple + + +[Install] +WantedBy=multi-user.target diff --git a/plinth/modules/calibre/forms.py b/plinth/modules/calibre/forms.py new file mode 100644 index 000000000..61fe67d61 --- /dev/null +++ b/plinth/modules/calibre/forms.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Django form for configuring calibre. +""" + +from django import forms +from django.core import validators +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from plinth.modules import calibre + + +class CreateLibraryForm(forms.Form): + """Form to create an empty library.""" + + name = forms.CharField( + label=_('Name of the new library'), strip=True, + validators=[validators.RegexValidator(r'^[A-Za-z0-9_.-]+$')]) + + def clean_name(self): + """Check if the library name is valid.""" + name = self.cleaned_data['name'] + + if name in calibre.list_libraries(): + raise ValidationError( + _('A library with this name already exists.')) + + return name diff --git a/plinth/modules/calibre/manifest.py b/plinth/modules/calibre/manifest.py new file mode 100644 index 000000000..2b6ce7317 --- /dev/null +++ b/plinth/modules/calibre/manifest.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.utils.translation import ugettext_lazy as _ + +from plinth.clients import validate +from plinth.modules.backups.api import validate as validate_backup + +clients = validate([{ + 'name': _('calibre'), + 'platforms': [{ + 'type': 'web', + 'url': '/calibre/' + }] +}]) + +backup = validate_backup({ + 'data': { + 'directories': ['/var/lib/private/calibre-server-freedombox/'] + }, + 'services': ['calibre-server-freedombox'] +}) diff --git a/plinth/modules/calibre/templates/calibre-delete-library.html b/plinth/modules/calibre/templates/calibre-delete-library.html new file mode 100644 index 000000000..93946e251 --- /dev/null +++ b/plinth/modules/calibre/templates/calibre-delete-library.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block content %} + +

+ {% blocktrans trimmed %} + Delete calibre Library {{ name }} + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Delete this library permanently? All stored e-books and saved data will be + lost. + {% endblocktrans %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/plinth/modules/calibre/templates/calibre.html b/plinth/modules/calibre/templates/calibre.html new file mode 100644 index 000000000..9e4e854a8 --- /dev/null +++ b/plinth/modules/calibre/templates/calibre.html @@ -0,0 +1,59 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block page_head %} + +{% endblock %} + +{% block configuration %} + {{ block.super }} + +

{% trans "Manage Libraries" %}

+ + + +
+
+ {% if not libraries %} +

{% trans 'No libraries available.' %}

+ {% else %} +
+ {% for library in libraries %} + + {% endfor %} +
+ {% endif %} +
+
+ +{% endblock %} diff --git a/plinth/modules/calibre/tests/__init__.py b/plinth/modules/calibre/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/calibre/tests/data/sample.txt b/plinth/modules/calibre/tests/data/sample.txt new file mode 100644 index 000000000..73709ba68 --- /dev/null +++ b/plinth/modules/calibre/tests/data/sample.txt @@ -0,0 +1 @@ +Testing diff --git a/plinth/modules/calibre/tests/test_actions.py b/plinth/modules/calibre/tests/test_actions.py new file mode 100644 index 000000000..ff61ece9c --- /dev/null +++ b/plinth/modules/calibre/tests/test_actions.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test module for calibre actions. +""" + +import imp +import json +import pathlib +from unittest.mock import call, patch + +import pytest + + +def _action_file(): + """Return the path to the 'calibre' actions file.""" + current_directory = pathlib.Path(__file__).parent + return str(current_directory / '..' / '..' / '..' / '..' / 'actions' / + 'calibre') + + +actions = imp.load_source('calibre', _action_file()) + + +@pytest.fixture(name='call_action') +def fixture_call_action(tmpdir, capsys): + """Run actions with custom root path.""" + + def _call_action(args, **kwargs): + actions.LIBRARIES_PATH = pathlib.Path(str(tmpdir)) + with patch('argparse._sys.argv', ['calibre'] + args): + actions.main() + captured = capsys.readouterr() + return captured.out + + return _call_action + + +@pytest.fixture(autouse=True) +def fixture_patch(): + """Patch some underlying methods.""" + + def side_effect(*args, **_kwargs): + if args[0][0] != 'calibredb': + return + + path = pathlib.Path(args[0][2]) / 'metadata.db' + path.touch() + + with patch('subprocess.call') as subprocess_call, \ + patch('shutil.chown'): + subprocess_call.side_effect = side_effect + yield + + +def test_list_libraries(call_action): + """Test listing libraries.""" + assert json.loads(call_action(['list-libraries'])) == {'libraries': []} + call_action(['create-library', 'TestLibrary']) + expected_output = {'libraries': ['TestLibrary']} + assert json.loads(call_action(['list-libraries'])) == expected_output + + +@patch('shutil.chown') +def test_create_library(chown, call_action): + """Test creating a library.""" + call_action(['create-library', 'TestLibrary']) + library = actions.LIBRARIES_PATH / 'TestLibrary' + assert (library / 'metadata.db').exists() + assert library.stat().st_mode == 0o40755 + expected_output = {'libraries': ['TestLibrary']} + assert json.loads(call_action(['list-libraries'])) == expected_output + + chown.assert_has_calls([call(library.parent.parent, 'root', 'root')]) + + +def test_delete_library(call_action): + """Test deleting a library.""" + call_action(['create-library', 'TestLibrary']) + + call_action(['delete-library', 'TestLibrary']) + assert json.loads(call_action(['list-libraries'])) == {'libraries': []} + + with pytest.raises(FileNotFoundError): + call_action(['delete-library', 'TestLibrary']) diff --git a/plinth/modules/calibre/tests/test_functional.py b/plinth/modules/calibre/tests/test_functional.py new file mode 100644 index 000000000..9f7ca97e6 --- /dev/null +++ b/plinth/modules/calibre/tests/test_functional.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Functional, browser based tests for calibre app. +""" + +import pathlib +import time + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.sso, pytest.mark.calibre] + + +@pytest.fixture(scope='module', autouse=True) +def fixture_background(session_browser): + """Login and install the app.""" + functional.login(session_browser) + functional.install(session_browser, 'calibre') + yield + functional.app_disable(session_browser, 'calibre') + + +def test_enable_disable(session_browser): + """Test enabling the app.""" + functional.app_disable(session_browser, 'calibre') + + functional.app_enable(session_browser, 'calibre') + assert functional.service_is_running(session_browser, 'calibre') + assert functional.is_available(session_browser, 'calibre') + + functional.app_disable(session_browser, 'calibre') + assert not functional.service_is_running(session_browser, 'calibre') + assert not functional.is_available(session_browser, 'calibre') + + +def test_add_delete_library(session_browser): + """Test adding/deleting a new library.""" + functional.app_enable(session_browser, 'calibre') + _delete_library(session_browser, 'FunctionalTest', True) + + _add_library(session_browser, 'FunctionalTest') + assert _is_library_available(session_browser, 'FunctionalTest') + + _delete_library(session_browser, 'FunctionalTest') + assert not _is_library_available(session_browser, 'FunctionalTest') + + +def test_add_delete_book(session_browser): + """Test adding/delete book in the library.""" + functional.app_enable(session_browser, 'calibre') + _add_library(session_browser, 'FunctionalTest') + _delete_book(session_browser, 'FunctionalTest', 'sample.txt', True) + + _add_book(session_browser, 'FunctionalTest', 'sample.txt') + assert _is_book_available(session_browser, 'FunctionalTest', 'sample.txt') + + _delete_book(session_browser, 'FunctionalTest', 'sample.txt') + assert not _is_book_available(session_browser, 'FunctionalTest', + 'sample.txt') + + +@pytest.mark.backups +def test_backup(session_browser): + """Test backing up and restoring.""" + functional.app_enable(session_browser, 'calibre') + _add_library(session_browser, 'FunctionalTest') + functional.backup_create(session_browser, 'calibre', 'test_calibre') + _delete_library(session_browser, 'FunctionalTest') + functional.backup_restore(session_browser, 'calibre', 'test_calibre') + assert _is_library_available(session_browser, 'FunctionalTest') + + +def _add_library(browser, name): + """Add a new library.""" + if _is_library_available(browser, name): + return + + browser.find_link_by_href( + '/plinth/apps/calibre/library/create/').first.click() + browser.find_by_id('id_calibre-name').fill(name) + functional.submit(browser) + + +def _delete_library(browser, name, ignore_missing=False): + """Delete a library.""" + functional.nav_to_module(browser, 'calibre') + link = browser.find_link_by_href( + f'/plinth/apps/calibre/library/{name}/delete/') + if not link: + if ignore_missing: + return + + raise ValueError('Library not found') + + link.first.click() + functional.submit(browser) + + +def _is_library_available(browser, name): + """Return whether a library is present in the list of libraries.""" + functional.nav_to_module(browser, 'calibre') + link = browser.find_link_by_href( + f'/plinth/apps/calibre/library/{name}/delete/') + return bool(link) + + +def _visit_library(browser, name): + """Open the page for the library.""" + functional.visit(browser, '/calibre/') + + # Calibre interface will be available a short time after restarting the + # service. + def _service_available(): + unavailable_xpath = '//h1[contains(text(), "Service Unavailable")]' + available = not browser.find_by_xpath(unavailable_xpath) + if not available: + time.sleep(0.5) + functional.visit(browser, '/calibre/') + + return available + + functional.eventually(_service_available) + + functional.eventually(browser.find_by_css, + args=[f'.calibre-push-button[data-lid="{name}"]']) + link = browser.find_by_css(f'.calibre-push-button[data-lid="{name}"]') + if not link: + raise ValueError('Library not found') + + link.first.click() + functional.eventually(browser.find_by_css, [f'.book-list-cover-grid']) + + +def _add_book(browser, library_name, book_name): + """Add a book to the library through Calibre interface.""" + _visit_library(browser, library_name) + add_button = browser.find_by_css(f'a[data-button-icon="plus"]') + add_button.first.click() + + functional.eventually(browser.find_by_xpath, + ['//span[contains(text(), "Add books")]']) + browser.execute_script( + '''document.querySelector('input[type="file"]').setAttribute( + 'name', 'test-book-upload');''') + + file_path = pathlib.Path(__file__).parent / f'data/{book_name}' + browser.attach_file('test-book-upload', [str(file_path)]) + functional.eventually(browser.find_by_xpath, + ['//span[contains(text(), "Added successfully")]']) + + +def _delete_book(browser, library_name, book_name, ignore_missing=False): + """Delete a book from the library through Calibre interface.""" + _visit_library(browser, library_name) + book_name = book_name.partition('.')[0] + book = browser.find_by_xpath(f'//a[contains(@title, "{book_name}")]') + if not book: + if ignore_missing: + return + + raise Exception('Book not found') + + book.first.click() + delete_button = browser.find_by_css(f'a[data-button-icon="trash"]') + delete_button.first.click() + + dialog = browser.find_by_id('modal-container').first + functional.eventually(lambda: dialog.visible) + ok_button = browser.find_by_xpath('//span[contains(text(), "OK")]') + ok_button.first.click() + + +def _is_book_available(browser, library_name, book_name): + """Return whether a book is present in Calibre interface.""" + _visit_library(browser, library_name) + book_name = book_name.partition('.')[0] + book = browser.find_by_xpath(f'//a[contains(@title, "{book_name}")]') + return bool(book) diff --git a/plinth/modules/calibre/tests/test_views.py b/plinth/modules/calibre/tests/test_views.py new file mode 100644 index 000000000..24ede010d --- /dev/null +++ b/plinth/modules/calibre/tests/test_views.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Tests for calibre views. +""" + +from unittest.mock import call, patch + +import pytest +from django import urls +from django.contrib.messages.storage.fallback import FallbackStorage +from django.http.response import Http404 + +from plinth import actions, module_loader +from plinth.modules.calibre 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_calibre_urls(): + """Make sure calibre 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.calibre') + module_loader.include_urls() + yield + + +@pytest.fixture(autouse=True) +def calibre_patch(): + """Patch calibre methods.""" + with patch('plinth.modules.calibre.list_libraries') as list_libraries: + list_libraries.return_value = ['TestExistingLibrary'] + + 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 + + +@patch('plinth.modules.calibre.create_library') +def test_create_library(create_library, rf): + """Test that create library view works.""" + form_data = {'calibre-name': 'TestLibrary'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == 'Library created.' + create_library.assert_has_calls([call('TestLibrary')]) + + +@patch('plinth.modules.calibre.create_library') +def test_create_library_failed(create_library, rf): + """Test that create library fails as expected.""" + create_library.side_effect = actions.ActionError('calibre', 'TestOutput', + 'TestError') + form_data = {'calibre-name': 'TestLibrary'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, messages = make_request(request, view) + + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == \ + 'An error occurred while creating the library. TestError' + + +def test_create_library_existing_library(rf): + """Test that create library errors out for an existing library name.""" + form_data = {'calibre-name': 'TestExistingLibrary'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, _ = make_request(request, view) + + assert response.context_data['form'].errors['name'][ + 0] == 'A library with this name already exists.' + assert response.status_code == 200 + + +def test_create_library_invalid_name(rf): + """Test that create library errors out for invalid name.""" + form_data = {'calibre-name': 'Invalid Library'} + request = rf.post(urls.reverse('calibre:create-library'), data=form_data) + view = views.CreateLibraryView.as_view() + response, _ = make_request(request, view) + + assert response.context_data['form'].errors['name'][ + 0] == 'Enter a valid value.' + assert response.status_code == 200 + + +@patch('plinth.modules.calibre.app') +def test_delete_library_confirmation_view(_app, rf): + """Test that deleting library confirmation shows correct name.""" + response, _ = make_request(rf.get(''), views.delete_library, + name='TestExistingLibrary') + assert response.status_code == 200 + assert response.context_data['name'] == 'TestExistingLibrary' + + +@patch('plinth.modules.calibre.delete_library') +@patch('plinth.modules.calibre.app') +def test_delete_library(_app, delete_library, rf): + """Test that deleting a library works.""" + response, messages = make_request(rf.post(''), views.delete_library, + name='TestExistingLibrary') + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == 'TestExistingLibrary deleted.' + delete_library.assert_has_calls([call('TestExistingLibrary')]) + + +@patch('plinth.modules.calibre.delete_library') +def test_delete_library_error(delete_library, rf): + """Test that deleting a library shows error when operation fails.""" + delete_library.side_effect = actions.ActionError('calibre', 'TestInput', + 'TestError') + response, messages = make_request(rf.post(''), views.delete_library, + name='TestExistingLibrary') + assert response.status_code == 302 + assert response.url == urls.reverse('calibre:index') + assert list(messages)[0].message == \ + 'Could not delete TestExistingLibrary: '\ + "('calibre', 'TestInput', 'TestError')" + + +def test_delete_library_non_existing(rf): + """Test that deleting a library shows error when operation fails.""" + with pytest.raises(Http404): + make_request(rf.post(''), views.delete_library, + name='TestNonExistingLibrary') + + with pytest.raises(Http404): + make_request(rf.get(''), views.delete_library, + name='TestNonExistingLibrary') diff --git a/plinth/modules/calibre/urls.py b/plinth/modules/calibre/urls.py new file mode 100644 index 000000000..a4cb694e3 --- /dev/null +++ b/plinth/modules/calibre/urls.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +URLs for the calibre module. +""" + +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^apps/calibre/$', views.CalibreAppView.as_view(), name='index'), + url(r'^apps/calibre/library/create/$', views.CreateLibraryView.as_view(), + name='create-library'), + url(r'^apps/calibre/library/(?P[a-zA-Z0-9_.-]+)/delete/$', + views.delete_library, name='delete-library'), +] diff --git a/plinth/modules/calibre/views.py b/plinth/modules/calibre/views.py new file mode 100644 index 000000000..f969de9d5 --- /dev/null +++ b/plinth/modules/calibre/views.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Views for the calibre module. +""" + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404 +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic.edit import FormView + +from plinth import actions, views +from plinth.modules import calibre + +from . import forms + + +class CalibreAppView(views.AppView): + """Serve configuration form.""" + app_id = 'calibre' + template_name = 'calibre.html' + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['libraries'] = calibre.list_libraries() + return context + + +class CreateLibraryView(SuccessMessageMixin, FormView): + """View to create an empty library.""" + form_class = forms.CreateLibraryForm + prefix = 'calibre' + template_name = 'form.html' + success_url = reverse_lazy('calibre:index') + success_message = _('Library created.') + + def form_valid(self, form): + """Create the library on valid form submission.""" + try: + calibre.create_library(form.cleaned_data['name']) + except actions.ActionError as error: + self.success_message = '' + error_text = error.args[2].split('\n')[0] + messages.error( + self.request, "{0} {1}".format( + _('An error occurred while creating the library.'), + error_text)) + + return super().form_valid(form) + + +def delete_library(request, name): + """View to delete a library.""" + if name not in calibre.list_libraries(): + raise Http404 + + if request.method == 'POST': + try: + calibre.delete_library(name) + messages.success(request, _('{name} deleted.').format(name=name)) + except actions.ActionError as error: + messages.error( + request, + _('Could not delete {name}: {error}').format( + name=name, error=error)) + return redirect(reverse_lazy('calibre:index')) + + return TemplateResponse(request, 'calibre-delete-library.html', { + 'title': calibre.app.info.name, + 'name': name + }) diff --git a/plinth/templates/form.html b/plinth/templates/form.html new file mode 100644 index 000000000..ae21f928a --- /dev/null +++ b/plinth/templates/form.html @@ -0,0 +1,22 @@ +{% 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/pytest.ini b/pytest.ini index e95adc2ab..cad6c2306 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,6 +4,7 @@ markers = functional apps backups bind + calibre configuration date_and_time deluge diff --git a/static/themes/default/icons/calibre.png b/static/themes/default/icons/calibre.png new file mode 100644 index 0000000000000000000000000000000000000000..12e7f1067eecf35e6d66b9de0fab02d980e6ab93 GIT binary patch literal 56951 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Kovid Goyal + + + + + CC BY-SA + + + + + Kovid Goyal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +   + + + + + + + + + + + + + + + From 5a04f6d309f34fdbe196db94da1d41a076d74673 Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Sun, 27 Sep 2020 22:14:04 +0530 Subject: [PATCH 12/46] calibre: Minor changes to app description Signed-off-by: Joseph Nuthalapati --- plinth/modules/calibre/__init__.py | 12 ++++++------ .../systemd/system/calibre-server-freedombox.service | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/plinth/modules/calibre/__init__.py b/plinth/modules/calibre/__init__.py index e3786812c..5608b0cdc 100644 --- a/plinth/modules/calibre/__init__.py +++ b/plinth/modules/calibre/__init__.py @@ -31,13 +31,13 @@ _description = [ 'You can store your e-books on your {box_name}, read them online or ' 'from any of your devices.'), box_name=_(cfg.box_name)), _('You can organize your e-books, extract and edit their metadata, and ' - 'perform advanced search. It can import, export, or convert across a ' - 'wide range of formats to make e-books ready for reading on any ' - 'device. You can read books on your browser with the builtin web ' - 'reader. It remembers your last read location, bookmarks, and ' - 'highlighted text.'), + 'perform advanced search. calibre can import, export, or convert across ' + 'a wide range of formats to make e-books ready for reading on any ' + 'device. It also provides an online web reader. It remembers your ' + 'last read location, bookmarks, and highlighted text. Content ' + 'distribution using OPDS is currently not supported.'), _('Only users belonging to calibre group will be able to access ' - 'the app. Content distribution with OPDS is currently not supported.') + 'the app. All users with access can use all the libraries.') ] app = None diff --git a/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service index 29941bc85..8133549ef 100644 --- a/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service +++ b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service @@ -42,6 +42,5 @@ SystemCallFilter=~@privileged SystemCallErrorNumber=EPERM Type=simple - [Install] WantedBy=multi-user.target From e421e0fde96768b444f4975e261cc3a160a10330 Mon Sep 17 00:00:00 2001 From: Michael Breidenbach Date: Sun, 27 Sep 2020 11:53:45 +0000 Subject: [PATCH 13/46] Translated using Weblate (Swedish) Currently translated at 100.0% (1321 of 1321 strings) --- plinth/locale/sv/LC_MESSAGES/django.po | 154 ++++++++++--------------- 1 file changed, 60 insertions(+), 94 deletions(-) diff --git a/plinth/locale/sv/LC_MESSAGES/django.po b/plinth/locale/sv/LC_MESSAGES/django.po index 8075d67cc..59fc4998d 100644 --- a/plinth/locale/sv/LC_MESSAGES/django.po +++ b/plinth/locale/sv/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-08-24 14:45+0000\n" +"PO-Revision-Date: 2020-09-28 12:41+0000\n" "Last-Translator: Michael Breidenbach \n" "Language-Team: Swedish \n" @@ -17,7 +17,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.2.1-dev\n" +"X-Generator: Weblate 4.3-dev\n" #: doc/dev/_templates/layout.html:11 msgid "Page source" @@ -150,10 +150,8 @@ msgid "{app} (No data to backup)" msgstr "{app} (Inga data att säkerhetskopiera)" #: plinth/modules/backups/forms.py:50 -#, fuzzy -#| msgid "Create Repository" msgid "Repository" -msgstr "Skapa respository" +msgstr "Databasen" #: plinth/modules/backups/forms.py:52 #: plinth/modules/backups/templates/backups_delete.html:18 @@ -223,10 +221,8 @@ msgstr "" "säkerhetskopian." #: plinth/modules/backups/forms.py:122 -#, fuzzy -#| msgid "Create Repository" msgid "Key in Repository" -msgstr "Skapa respository" +msgstr "Key i databasen" #: plinth/modules/backups/forms.py:122 plinth/modules/searx/forms.py:15 msgid "None" @@ -434,7 +430,7 @@ msgstr "" #: plinth/modules/backups/templates/backups_repository.html:77 msgid "Download" -msgstr "Ladda ner" +msgstr "Hämta" #: plinth/modules/backups/templates/backups_repository.html:81 #: plinth/modules/backups/templates/backups_restore.html:27 @@ -794,7 +790,7 @@ msgstr "Ett fel inträffade under konfiguration." #: plinth/modules/bepasty/views.py:97 msgid "Password added." -msgstr "Lösenord tillagt" +msgstr "Lösenord tillagt." #: plinth/modules/bepasty/views.py:102 msgid "Add Password" @@ -802,7 +798,7 @@ msgstr "Lägg till lösenord" #: plinth/modules/bepasty/views.py:119 msgid "Password deleted." -msgstr "Lösenord raderat" +msgstr "Lösenord raderat." #: plinth/modules/bind/__init__.py:29 msgid "" @@ -1229,20 +1225,16 @@ msgid "Diagnostics" msgstr "Diagnostik" #: plinth/modules/diagnostics/__init__.py:102 -#, fuzzy -#| msgid "Quassel" msgid "passed" -msgstr "Quassel" +msgstr "passerade" #: plinth/modules/diagnostics/__init__.py:103 -#, fuzzy -#| msgid "Setup failed." msgid "failed" -msgstr "Installationen misslyckades." +msgstr "misslyckades" #: plinth/modules/diagnostics/__init__.py:104 msgid "error" -msgstr "" +msgstr "fel" #: plinth/modules/diagnostics/templates/diagnostics.html:17 #: plinth/modules/diagnostics/templates/diagnostics_button.html:13 @@ -1262,7 +1254,7 @@ msgstr "Resultat" #: plinth/modules/diagnostics/templates/diagnostics_app.html:12 #, python-format msgid "App: %(app_id)s" -msgstr "App: %(app_id)s" +msgstr "App:%(app_id)s" #: plinth/modules/diagnostics/templates/diagnostics_app.html:10 msgid "Diagnostic Results" @@ -1457,13 +1449,11 @@ msgstr "Användarnamnet som användes när konton skapades." #: plinth/modules/dynamicdns/forms.py:65 msgid "GnuDIP" -msgstr "" +msgstr "GnuDIP" #: plinth/modules/dynamicdns/forms.py:68 -#, fuzzy -#| msgid "Update URL" msgid "other update URL" -msgstr "Adress för uppdateringar" +msgstr "annan uppdaterings-URL" #: plinth/modules/dynamicdns/forms.py:70 msgid "Enable Dynamic DNS" @@ -1961,14 +1951,12 @@ msgid "An alpha-numeric string that uniquely identifies a repository." msgstr "En alfanumerisk sträng som identifierar ett respository unikt." #: plinth/modules/gitweb/forms.py:134 -#, fuzzy -#| msgid "Default Skin" msgid "Default branch" -msgstr "Standardskal" +msgstr "Standardgren" #: plinth/modules/gitweb/forms.py:135 msgid "Gitweb displays this as a default branch." -msgstr "" +msgstr "Gitweb visar detta som en standardgren." #: plinth/modules/gitweb/manifest.py:21 msgid "Git" @@ -2057,7 +2045,7 @@ msgstr "Handbok" #: plinth/modules/help/views.py:42 plinth/templates/help-menu.html:27 #: plinth/templates/help-menu.html:28 msgid "Get Support" -msgstr "Få stöd" +msgstr "Få support" #: plinth/modules/help/__init__.py:45 #: plinth/modules/help/templates/help_feedback.html:9 @@ -2071,7 +2059,7 @@ msgstr "Skicka feedback" #: plinth/modules/help/views.py:30 plinth/templates/help-menu.html:39 #: plinth/templates/help-menu.html:40 msgid "Contribute" -msgstr "Bidra" +msgstr "Bidrar" #: plinth/modules/help/templates/help_about.html:17 #, python-format @@ -2404,7 +2392,7 @@ msgstr "I2P Proxies och tunnlar" #: plinth/modules/i2p/templates/i2p.html:21 #: plinth/modules/i2p/templates/i2p.html:34 plinth/templates/clients.html:28 msgid "Launch" -msgstr "Lansera" +msgstr "Starta" #: plinth/modules/i2p/templates/i2p.html:25 msgid "Anonymous Torrents" @@ -3442,10 +3430,8 @@ msgid "All web apps" msgstr "Alla webbappar" #: plinth/modules/names/templates/names.html:16 -#, fuzzy -#| msgid "Service" msgid "Services" -msgstr "Tjänst" +msgstr "Tjänster" #: plinth/modules/networks/__init__.py:40 msgid "" @@ -4747,12 +4733,18 @@ msgid "" "utilization of the hardware. This can give you basic insights into usage " "patterns and whether the hardware is overloaded by users and services." msgstr "" +"Performance app låter dig samla in, lagra och visa information om hur " +"hårdvaran används. Detta kan ge dig grundläggande insikter om " +"användningsmönster och om hårdvaran är överbelastad av användare och " +"tjänster." #: plinth/modules/performance/__init__.py:29 msgid "" "Performance metrics are collected by Performance Co-Pilot and can be viewed " "using the Cockpit app." msgstr "" +"Prestandamätvärden samlas in av Performance Co-Pilot och kan visas med " +"Cockpit-appen." #: plinth/modules/performance/__init__.py:46 msgid "System Monitoring" @@ -4961,10 +4953,8 @@ msgstr "" "en kalender/adressbok." #: plinth/modules/radicale/forms.py:30 -#, fuzzy -#| msgid "Access Point" msgid "Access rights" -msgstr "Kopplingspunkt" +msgstr "Tillträdesrätt" #: plinth/modules/radicale/manifest.py:10 msgid "DAVx5" @@ -5331,21 +5321,13 @@ msgstr "Frekventa funktionsuppdateringar" #: plinth/modules/security/templates/security.html:19 #: plinth/modules/upgrades/templates/upgrades_configure.html:19 -#, fuzzy -#| msgid "Frequent feature updates are enabled." msgid "Frequent feature updates are activated." msgstr "Frekventa funktionsuppdateringar är aktiverade." #: plinth/modules/security/templates/security.html:24 #: plinth/modules/upgrades/templates/backports-firstboot.html:14 #: plinth/modules/upgrades/templates/upgrades_configure.html:31 -#, fuzzy, python-format -#| msgid "" -#| "This will allow a very limited set of software, including FreedomBox " -#| "service, to be updated to receive newer features regularly instead of " -#| "once every 2 years or so. Note that packages with frequent feature " -#| "updates do not have support from Debian Security Team. They are instead " -#| "maintained by contributors to Debian and the FreedomBox community." +#, python-format msgid "" "Frequent feature updates allow the %(box_name)s Service, plus a very limited " "set of software, to receive new features more frequently (from the backports " @@ -5355,12 +5337,13 @@ msgid "" "Instead, they are maintained by contributors to Debian and the %(box_name)s " "community." msgstr "" -"Detta kommer att möjliggöra en mycket begränsad uppsättning av programvara, " -"inklusive FreedomBox service, att vara uppdaterad för att få nyare " -"funktioner regelbundet istället för en gång per 2 år eller så. Observera att " -"paket med täta uppdateringar inte har stöd från Debians säkerhetsgrupp. De " -"är i stället upprätthålls av bidragsgivarna till Debian och FreedomBox " -"gemenskapen." +"Frekventa funktionsuppdateringar gör att %(box_name)s Tjänsten, plus en " +"mycket begränsad uppsättning programvaror, kan ta emot nya funktioner oftare " +"(från backports-förvaret). Detta resulterar i att få några nya funktioner " +"inom veckor, i stället för bara en gång vart 2 år eller så. Observera att " +"programvara med frekventa funktionsuppdateringar inte har stöd från Debians " +"säkerhetsgrupp. Istället underhålls de av bidragsgivare till Debian och " +"%(box_name)s community." #: plinth/modules/security/templates/security_report.html:10 #: plinth/modules/security/views.py:74 @@ -5747,16 +5730,12 @@ msgid "Yearly Snapshots Limit" msgstr "Årlig ögonblicksbild gräns" #: plinth/modules/snapshot/forms.py:49 -#, fuzzy -#| msgid "" -#| "Keep a maximum of this many yearly snapshots. The default value is 0 " -#| "(disabled)." msgid "" "Keep a maximum of this many yearly snapshots. The default value is 0 (keep " "no yearly snapshot)." msgstr "" -"Behåll högst detta många årliga ögonblicksbilder. Standardvärdet är 0 " -"(inaktiverat)." +"Behåll maximalt så här många årsögonblicksbilder. Standardvärdet är 0 (" +"behåll ingen årsögonblicksbild)." #: plinth/modules/snapshot/templates/snapshot_delete_selected.html:12 msgid "Delete the following snapshots permanently?" @@ -6516,7 +6495,7 @@ msgid "" "href=\"/tt-rss-app/\">/tt-rss-app for connecting." msgstr "" "När du använder en mobil eller stationär applikation för Tiny Tiny RSS, " -"Använd URL/tt-rss-app/\"> för att ansluta." +"Använd URL/tt-rss-app/\" för att ansluta." #: plinth/modules/ttrss/__init__.py:53 msgid "Read and subscribe to news feeds" @@ -6579,20 +6558,16 @@ msgid "" "It is strongly recommended to activate frequent feature updates. If not " "activated now, they can be activated later." msgstr "" +"Det rekommenderas starkt att aktivera frekventa funktionsuppdateringar. Om " +"den inte aktiveras nu, kan de aktiveras senare." #: plinth/modules/upgrades/templates/backports-firstboot.html:33 -#, fuzzy -#| msgid "" -#| "Warning! Once frequent feature updates are activated, " -#| "they cannot be deactivated. You may wish to take a snapshot using Storage Snapshots before continuing." msgid "" "Note: Once frequent feature updates are activated, they " "cannot be deactivated." msgstr "" -"Varning! När täta uppdateringar är aktiverade, de kan inte " -"stängas av. Du kanske vill ta en snapshot med hjälp av Ögonblicksbilder av lagring innan du fortsätter." +"Obs: När frekventa funktionsuppdateringar har aktiverats " +"kan de inte avaktiveras." #: plinth/modules/upgrades/templates/upgrades-new-release.html:9 #, python-format @@ -6620,14 +6595,12 @@ msgstr "" "Frekventa funktionsuppdateringar kan aktiveras. Aktivera dem rekommenderas." #: plinth/modules/upgrades/templates/upgrades_configure.html:23 -#, fuzzy -#| msgid "" -#| "Frequent feature updates can be activated. Activating them is recommended." msgid "" "Frequent feature updates cannot be activated. They may not be necessary on " "your distribution." msgstr "" -"Frekventa funktionsuppdateringar kan aktiveras. Aktivera dem rekommenderas." +"Frekventa funktionsuppdateringar kan inte aktiveras. De kanske inte är " +"nödvändiga för din distribution." #: plinth/modules/upgrades/templates/upgrades_configure.html:45 #, python-format @@ -6689,10 +6662,8 @@ msgid "Starting upgrade failed." msgstr "Det gick inte att starta uppgraderingen." #: plinth/modules/upgrades/views.py:91 -#, fuzzy -#| msgid "Frequent feature updates are enabled." msgid "Frequent feature updates activated." -msgstr "Frekventa funktionsuppdateringar är aktiverade." +msgstr "Frekventa funktionsuppdateringar aktiverade." #: plinth/modules/users/__init__.py:39 msgid "" @@ -7509,7 +7480,7 @@ msgstr "Hemsida" #: plinth/templates/index.html:140 msgid "Source Code" -msgstr "Källkoden" +msgstr "Källkod" #: plinth/templates/index.html:143 msgid "Donate" @@ -7560,16 +7531,13 @@ msgid "Port Forwarding" msgstr "Port Forwarding" #: plinth/templates/port-forwarding-info.html:13 -#, fuzzy, python-format -#| msgid "" -#| "You may want to check the network setup " -#| "and modify it if necessary." +#, python-format msgid "" "Your FreedomBox is not behind a router. No " "action is necessary." msgstr "" -"Du kanske vill kontrollera nätverksinställningar och ändra den vid behov." +"Din FreedomBox ligger inte bakom en router. " +"Ingen åtgärd är nödvändig." #: plinth/templates/port-forwarding-info.html:19 #, python-format @@ -7578,36 +7546,34 @@ msgid "" "are using the DMZ feature to forward all ports. No further router " "configuration is necessary." msgstr "" +"Din FreedomBox ligger bakom en router och " +"du använder DMZ-funktionen för att vidarebefordra alla portar. Ingen " +"ytterligare routerkonfiguration är nödvändig." #: plinth/templates/port-forwarding-info.html:26 -#, fuzzy, python-format -#| msgid "" -#| "If your FreedomBox is behind a router, you will need to set up port " -#| "forwarding on your router. You should forward the following ports for " -#| "%(service_name)s:" +#, python-format msgid "" "Your FreedomBox is behind a router and you " "are not using the DMZ feature. You will need to set up port forwarding on " "your router. You should forward the following ports for %(service_name)s:" msgstr "" -"Om din FreedomBox är bakom en router, måste du ställa in Port Forwarding på " -"routern. Du bör vidarebefordra följande portar för %(service_name)s:" +"Din FreedomBox ligger bakom en router och " +"du använder inte DMZ-funktionen. Du kommer att behöva ställa in port " +"forwarding på din router. Du bör vidarebefordra följande portar för " +"%(service_name)s:" #: plinth/templates/port-forwarding-info.html:36 -#, fuzzy -#| msgid "protocol" msgid "Protocol" -msgstr "Protokollet" +msgstr "Protokoll" #: plinth/templates/port-forwarding-info.html:37 msgid "From Router/WAN Ports" -msgstr "" +msgstr "Från Router/WAN-portar" #: plinth/templates/port-forwarding-info.html:38 -#, fuzzy, python-format -#| msgid "%(box_name)s Setup" +#, python-format msgid "To %(box_name)s Ports" -msgstr "%(box_name)s Installation" +msgstr "Till %(box_name)s Portar" #: plinth/templates/setup.html:24 msgid "Install this application?" From 514a04d64363f0b678074c19cc528227942eede1 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Mon, 14 Sep 2020 23:28:53 -0700 Subject: [PATCH 14/46] doc: Before fetching, drop all old to cleanup deleted pages/images Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- doc/Makefile | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/doc/Makefile b/doc/Makefile index 94c0ee0e6..4e63015cb 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -60,13 +60,21 @@ install: all # require further reviews. fetch-main-list:=$(foreach lang,$(MANUAL_LANGUAGES),fetch-main-$(lang)) fetch-pages-list:=$(foreach lang,$(MANUAL_LANGUAGES),fetch-pages-$(lang)) +fetch-cleanup-list:=$(foreach lang,$(MANUAL_LANGUAGES),fetch-cleanup-$(lang)) -.PHONY: fetch $(fetch-main-list) $(fetch-pages-list) +.PHONY: fetch $(fetch-main-list) $(fetch-pages-list) $(fetch-cleanup-list) fetch: $(fetch-main-list) $(fetch-pages-list) +# Remove raw wiki pages and images before fetching to drop items deleted +# upstream. +featch-cleanup-%: lang = $* +$(fetch-cleanup-list): fetch-cleanup-%: + rm -f manual/$(lang)/*.raw.wiki + rm -f manual/$(lang)/images/*.png manual/$(lang)/images/*.jpg + fetch-main-%: lang = $* fetch-main-%: lang-fragment = $(subst en/,,$*/) -$(fetch-main-list): fetch-main-%: +$(fetch-main-list): fetch-main-%: fetch-cleanup-% MANUAL_URL_RAW_LANG=$(subst {lang-fragment},$(lang-fragment),$(MANUAL_URL_RAW)) ; \ wget --quiet --user-agent=Firefox \ -O manual/$(lang)/freedombox-manual.raw.wiki $${MANUAL_URL_RAW_LANG} @@ -82,7 +90,7 @@ $(fetch-main-list): fetch-main-%: fetch-pages-%: lang = $* fetch-pages-%: lang-fragment = $(subst en/,,$*/) -$(fetch-pages-list): fetch-pages-%: +$(fetch-pages-list): fetch-pages-%: fetch-cleanup-% MANUAL_URL_LANG=$(subst {lang-fragment},$(lang-fragment),$(MANUAL_URL_RAW)) ; \ PAGES=$$(wget --quiet --user-agent=Firefox -O - $${MANUAL_URL_LANG} | \ sed -n -e "s|.*< Date: Thu, 10 Sep 2020 21:45:28 -0700 Subject: [PATCH 15/46] coturn: Don't handle certificates if not installed Tests: - Without these changes, with Coturn not-installed, change the domain name. Notice that certificate events for Coturn fails due to missing domain information. - With these changes, with Coturn not-installed, change the domain name. Notice that certificate events for Coturn don't result in any actions. - With these changes, with Coturn installed, change the domain name. Notice that certificate events for Coturn succeed. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/coturn/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plinth/modules/coturn/__init__.py b/plinth/modules/coturn/__init__.py index d5309d8f6..4138dff5e 100644 --- a/plinth/modules/coturn/__init__.py +++ b/plinth/modules/coturn/__init__.py @@ -115,6 +115,10 @@ def get_domain(): def get_domains(): """Return a list with the configured domains.""" + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() == 'needs-setup': + return [] + domain = get_domain() if domain: return [domain] From 5f4e2673f3f7e853a8301e5835f0b47d8eb85fa2 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 21:48:45 -0700 Subject: [PATCH 16/46] quassel: Don't handle certificates if not installed Tests: - Without these changes, with Quassel not-installed, change the domain name. Notice that certificate events for Quassel fails due to missing domain information. - With these changes, with Quassel not-installed, change the domain name. Notice that certificate events for Quassel don't result in any actions. - With these changes, with Quassel installed, change the domain name. Notice that certificate events for Quassel succeed. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/quassel/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plinth/modules/quassel/__init__.py b/plinth/modules/quassel/__init__.py index bee065c40..8b39c6668 100644 --- a/plinth/modules/quassel/__init__.py +++ b/plinth/modules/quassel/__init__.py @@ -134,6 +134,10 @@ def get_domain(): def get_domains(): """Return a list with the configured domain for quassel.""" + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() == 'needs-setup': + return [] + domain = get_domain() if domain: return [domain] From 69d66927ac48a8baa57a838600879b7363c58c65 Mon Sep 17 00:00:00 2001 From: Coucouf Date: Mon, 28 Sep 2020 20:41:43 +0000 Subject: [PATCH 17/46] Translated using Weblate (French) Currently translated at 100.0% (1321 of 1321 strings) --- plinth/locale/fr/LC_MESSAGES/django.po | 251 +++++++++++++------------ 1 file changed, 126 insertions(+), 125 deletions(-) diff --git a/plinth/locale/fr/LC_MESSAGES/django.po b/plinth/locale/fr/LC_MESSAGES/django.po index 0d3483664..2631a3dd0 100644 --- a/plinth/locale/fr/LC_MESSAGES/django.po +++ b/plinth/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: FreedomBox UI\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-09-24 22:41+0000\n" +"PO-Revision-Date: 2020-09-29 09:39+0000\n" "Last-Translator: Coucouf \n" "Language-Team: French \n" @@ -61,7 +61,7 @@ msgid "" "Warning! The application may not work properly if domain name is changed " "later." msgstr "" -"Attention ! Cette application peut ne peut pas fonctionner correctement si " +"Attention ! Cette application risque de ne pas fonctionner correctement si " "le nom de domaine est modifié ultérieurement." #: plinth/forms.py:48 @@ -74,7 +74,7 @@ msgstr "Langue de cette interface web" #: plinth/forms.py:56 msgid "Use the language preference set in the browser" -msgstr "Utiliser les préférences de langue du navigateur" +msgstr "Utiliser la langue du navigateur" #: plinth/middleware.py:57 plinth/templates/setup.html:18 msgid "Application installed." @@ -481,11 +481,11 @@ msgid "" " " msgstr "" "\n" -" Téléverser un fichier de sauvegarde téléchargé d’une autre " -"%(box_name)s pour restaurer\n" -" son contenu. Vous pourrez choisir les applis que vous souhaitez " -"restaurer après\n" -" avoir téléversé un fichier de sauvegarde.\n" +" Téléverser un fichier de sauvegarde récupéré d’une autre %(box_name)s " +"pour restaurer\n" +" son contenu. Vous pourrez choisir les applis à restaurer une fois le " +"fichier de sauvegarde\n" +" téléversé.\n" " " #: plinth/modules/backups/templates/backups_upload.html:27 @@ -680,7 +680,7 @@ msgstr "Créer ou envoyer des fichiers" #: plinth/modules/bepasty/__init__.py:44 msgid "List all files and their web links" -msgstr "Lister l’ensemble des fichiers et récupérer leurs liens" +msgstr "Lister l’ensemble des fichiers et récupérer leurs liens hypertexte" #: plinth/modules/bepasty/__init__.py:45 msgid "Delete files" @@ -902,7 +902,7 @@ msgid "" "console operations is also available." msgstr "" "Cockpit est un gestionnaire de serveur qui simplifie l’administration de " -"serveurs GNU/Linux à l’aide d'un navigateur web. Sur une {box_name}, des " +"serveurs GNU/Linux à l’aide d’un navigateur web. Sur une {box_name}, des " "contrôles sont disponibles pour de nombreuses fonctions avancées qui ne sont " "pas nécessaires dans la plupart des cas. Un terminal web est également " "fourni pour les opérations d’administration réalisables via une console." @@ -960,8 +960,8 @@ msgid "" "Here you can set some general configuration options like hostname, domain " "name, webserver home page etc." msgstr "" -"Vous pouvez ici configurer certains paramètres globaux comme le nom de " -"machine, le nom de domaine, la page d’accueil du serveur web, etc." +"Cette page vous permet de modifier certains paramètres généraux comme le nom " +"de machine, le nom de domaine, la page d’accueil du serveur web, etc." #: plinth/modules/config/__init__.py:52 msgid "General Configuration" @@ -1048,12 +1048,12 @@ msgid "" "explicitly type /plinth or /freedombox to reach {box_name} Service (Plinth)." msgstr "" "Sélectionnez la page qui sera affichée par défaut lorsque quelqu’un visite " -"votre {box_name} sur le Web. Une utilisation classique est de mettre votre " -"blogue ou votre wiki en page d’accueil lorsque quelqu’un visite le nom de " -"domaine. Veuillez noter qu’en sélectionnant une page d’accueil différente de " -"« Gestion de la {box_name} (Plinth) », vos utilisateurs devront " +"votre {box_name} sur le Web. Vous pouvez typiquement utiliser cette option " +"pour mettre votre blogue ou votre wiki en page d’accueil lorsque quelqu’un " +"visite votre nom de domaine. Veuillez noter qu’avec une page d’accueil autre " +"que « Gestion de la {box_name} (Plinth) », vos utilisateurs devront " "explicitement ajouter le chemin /plinth ou /freedombox à l’adresse de la " -"{box_name} pour accéder à cette interface." +"{box_name} pour accéder à la présente interface." #: plinth/modules/config/forms.py:91 msgid "Show advanced apps and features" @@ -1329,8 +1329,8 @@ msgid "" "It is an unofficial webview based client for the community-run, distributed " "social network diaspora*" msgstr "" -"Il s’agit d’un client basé sur une vue web non officielle pour le réseau " -"social distribué et communautaire diaspora*" +"Il s’agit d’un client non officiel pour le réseau social distribué et " +"communautaire diaspora*, basé sur une vue web" #: plinth/modules/diaspora/templates/diaspora-post-setup.html:16 #, python-format @@ -1341,13 +1341,13 @@ msgid "" "podname wouldn't be accessible.
You can access the diaspora* pod at diaspora.%(domain_name)s " msgstr "" -"Le domaine du nœud diaspora* (pod) est défini comme %(domain_name)s. " -"Les identifiants utilisateur seront du type username@diaspora." -"%(domain_name)s
Si le nom de domaine de la FreedomBox est modifié, " -"toutes les données des utilisateurs enregistrés auprès du nœud avec l’ancien " -"nom seront rendues inaccessibles.
Vous pouvez accéder au nœud diaspora* " -"à l’adresse diaspora." -"%(domain_name)s" +"Le nœud diaspora* (pod) utilise le domaine %(domain_name)s. Les " +"identifiants utilisateur seront du type " +"username@diaspora.%(domain_name)s
Si le nom de domaine de la " +"FreedomBox est modifié, toutes les données des utilisateurs enregistrés " +"auprès du nœud avec l’ancien nom seront rendues inaccessibles.
Vous " +"pouvez accéder au nœud diaspora* à l’adresse diaspora.%(domain_name)s" #: plinth/modules/diaspora/templates/diaspora-pre-setup.html:43 #: plinth/modules/dynamicdns/templates/dynamicdns_configure.html:25 @@ -1609,8 +1609,8 @@ msgstr "" "dynamique sondera « l’URL pour rechercher l’IP publique » afin de détecter d’" "éventuels changements (c’est pour cela que « l’URL pour rechercher l’IP " "publique » est nécessaire, sinon les changements d’adresse IP ne seront pas " -"détectés). Cela peut prendre %(timer)s minutes avant la mise à jour de votre " -"entrée DNS en cas de modification de l’IP publique." +"détectés). La mise à jour de votre entrée DNS peut prendre jusqu’à %(timer)s " +"minutes en cas de modification de l’IP publique." #: plinth/modules/dynamicdns/templates/dynamicdns_status.html:33 msgid "Last update" @@ -1744,10 +1744,10 @@ msgid "" "like username@%(domainname)s. You can setup your domain on the system " "Configure page." msgstr "" -"Le domaine de votre serveur XMPP est défini comme %(domainname)s. Les " +"Votre serveur XMPP utilise le domaine %(domainname)s. Les " "identifiants utilisateur seront du type : username@%(domainname)s. " -"Vous pouvez configurer le domaine de votre système sur la page Configurer." +"Vous pouvez configurer le domaine de votre système sur la page Configurer." #: plinth/modules/ejabberd/views.py:44 msgid "Message Archive Management enabled" @@ -1919,14 +1919,14 @@ msgid "" "available graphical clients. And you can share your code with people around " "the world." msgstr "" -"Git est un système distribué de gestion de versions afin de suivre les " +"Git est un système distribué de gestion de versions destiné à suivre les " "changements du code source pendant le développement de logiciels. Gitweb " -"offre une interface Web aux dépôts Git. Vous pouvez parcourir l’historique " -"et le contenu du code source, utiliser la recherche pour trouver des " -"archivages et du code spécifiques. Vous pouvez aussi cloner des dépôts et " -"téléverser des changements de code avec un client Git en ligne de commande " -"ou avec de nombreux clients graphiques existants. Vous pouvez ainsi partager " -"votre code source avec d’autres, tout autour du monde." +"offre une interface web pour gérer des dépôts Git. Il vous permet de " +"parcourir l’historique et le contenu du code source, d’effectuer des " +"recherches de versions ou d’éléments de code spécifiques. Vous pourrez aussi " +"cloner ces dépôts et téléverser des changements de code depuis un client Git " +"en ligne de commande ou l’un des nombreux clients graphiques existants. " +"Partagez ainsi votre code source avec d’autres, tout autour du monde." #: plinth/modules/gitweb/__init__.py:35 msgid "" @@ -2122,8 +2122,8 @@ msgstr "" "protégeant votre vie privée et vos données. %(box_name)s intègre des " "applications permettant d’héberger par exemple un blogue, un wiki, un site " "web, un réseau social, de gérer des courriels, un serveur mandataire web ou " -"un relais Tor, sur une machine qui peut également remplacer votre routeur Wi-" -"Fi. De cette manière vos données restent sous votre contrôle." +"un relais Tor, le tout sur une machine qui peut également remplacer votre " +"routeur Wi-Fi. De cette manière vos données restent sous votre contrôle." #: plinth/modules/help/templates/help_about.html:30 msgid "" @@ -2317,10 +2317,10 @@ msgid "" "channel using the IRC web interface." msgstr "" "De nombreux contributeurs et utilisateurs de %(box_name)s sont également " -"présents sur le réseau IRC irc.oftc.net. Rejoignez le canal (en anglais) #freedombox en utilisant l’interface web pour IRC et demandez de " -"l’aide." +"> #freedombox (en anglais) pour utiliser l’interface web IRC et demander " +"de l’aide." #: plinth/modules/help/templates/help_manual.html:25 msgid "Download as PDF" @@ -2372,10 +2372,10 @@ msgid "" "attach this status log to the bug report." msgstr "" "Voici les %(num_lines)s dernières lignes du journal d’état de cette " -"interface web. Si vous voulez nous faire parvenir un rapport d’erreur, " -"veuillez utiliser l’outil de suivi de tickets et attacher ce journal " -"d’état au rapport d’erreur." +"interface web. Pour nous faire parvenir un rapport d’erreur, veuillez " +"utiliser l’outil de suivi de tickets et attacher ce journal d’état au " +"rapport d’erreur." #: plinth/modules/help/templates/statuslog.html:24 msgid "" @@ -2424,8 +2424,8 @@ msgid "" "The first visit to the provided web interface will initiate the " "configuration process." msgstr "" -"La configuration aura lieu lors de la première visite à l’interface web mise " -"à disposition." +"L’interface web fournie vous guidera dans les étapes de configuration lors " +"de votre première visite." #: plinth/modules/i2p/__init__.py:56 msgid "Manage I2P application" @@ -2644,7 +2644,7 @@ msgid "" "JSXC is a web client for XMPP. Typically it is used with an XMPP server " "running locally." msgstr "" -"JSXC est un client web pour XMPP. Il s'utilise typiquement avec un serveur " +"JSXC est un client web pour XMPP. Il s’utilise typiquement avec un serveur " "XMPP tournant sur la même machine." #: plinth/modules/jsxc/__init__.py:43 plinth/modules/jsxc/manifest.py:10 @@ -2875,9 +2875,8 @@ msgid "" " " msgstr "" "\n" -" Attention ! Le changement du nom de domaine après " -"l’installation initiale\n" -" n’est actuellement pas supporté.\n" +" Attention ! Le changement de nom de domaine après\n" +" l’installation initiale n’est actuellement pas supporté.\n" " " #: plinth/modules/matrixsynapse/templates/matrix-synapse-pre-setup.html:35 @@ -2896,10 +2895,10 @@ msgid "" "look like @username:%(domain_name)s. Changing the domain name after " "the initial setup is currently not supported." msgstr "" -"Le domaine de votre serveur Matrix est défini comme %(domain_name)s. Les identifiants utilisateur seront du type @identifiant:" -"%(domain_name)s. Le changement du nom de domaine après l’installation " -"initiale n’est actuellement pas supporté." +"Votre serveur Matrix utilise le domaine %(domain_name)s. Les " +"identifiants utilisateur seront du type @identifiant:%(domain_name)s. Le changement du nom de domaine après l’installation initiale n’est " +"actuellement pas supporté." #: plinth/modules/matrixsynapse/templates/matrix-synapse.html:21 msgid "" @@ -3231,7 +3230,7 @@ msgid "" msgstr "" "Les utilisateurs membres des groupes admin ou ed2k peuvent l’utiliser au " "travers de l’interface web. Les utilisateurs du groupe admin peuvent " -"également l’utiliser depuis n'importe quel client sur téléphone ou " +"également l’utiliser depuis n’importe quel client sur téléphone ou " "ordinateur, ou depuis une interface telnet. Consultez le manuel." #: plinth/modules/mldonkey/__init__.py:35 @@ -3274,15 +3273,16 @@ msgid "" "monkeysphere.info/getting-started-ssh/\"> Monkeysphere SSH documentation " "for more details." msgstr "" -"Avec Monkeysphere, une clef OpenPGP peut être générée pour chaque domaine " -"configuré pour les connexions SSH. La clef publique OpenPGP peut alors être " -"téléversée sur les serveurs de clefs OpenPGP. Les utilisateurs se connectant " -"à cette machine via SSH pourront ainsi vérifier qu’ils se connectent au bon " -"serveur. Pour que les utilisateurs puissent faire confiance à la clef, au " -"moins une personne (habituellement le propriétaire de la machine) doit la " -"signer en utilisant le processus habituel de signature de clef OpenPGP. Pour " -"plus de détails, voir la documentation SSH de Monkeysphere." +"Monkeysphere peut être utilisé pour générer une clef OpenPGP pour chaque " +"domaine acceptant les connexions SSH. La clef publique OpenPGP peut alors " +"être téléversée sur les serveurs de clefs OpenPGP. Les utilisateurs se " +"connectant à cette machine via SSH pourront ainsi s’assurer que le serveur " +"auquel ils se connectent est réellement le serveur demandé. Pour que les " +"utilisateurs puissent faire confiance à la clef, au moins une personne (" +"habituellement le propriétaire de la machine) doit la signer en utilisant le " +"processus habituel de signature de clef OpenPGP. Pour plus de détails, " +"référez-vous à la documentation SSH de Monkeysphere." #: plinth/modules/monkeysphere/__init__.py:27 msgid "" @@ -3294,14 +3294,14 @@ msgid "" "is available on the " "Monkeysphere website." msgstr "" -"Monkeysphere peut également générer une clé OpenPGP pour chaque certificat " -"de serveur web sécurisé (HTTPS) installé sur cette machine. La clé publique " -"OpenPGP peut ensuite être téléversée sur les serveurs de clés OpenPGP. Les " -"utilisateurs accédant au serveur web avec une adresse HTTPS pourront ainsi " -"s'assurer qu'ils se connectent bien au bon serveur. Pour valider le " -"certificat, l'utilisateur aura besoin d'installer une série de programmes " -"depuis le site " -"Monkeysphere." +"Monkeysphere peut également générer une clef OpenPGP pour chaque certificat " +"de serveur web sécurisé (HTTPS) installé sur cette machine. La clef publique " +"OpenPGP peut ensuite être téléversée sur les serveurs de clefs OpenPGP. Les " +"utilisateurs accédant au serveur web avec une adresse HTTPS pourront ainsi s’" +"assurer que le serveur auquel ils se connectent est réellement le serveur " +"demandé. Pour valider le certificat, l’utilisateur a besoin d’installer une " +"série de logiciels comme expliqué sur le site Monkeysphere." #: plinth/modules/monkeysphere/__init__.py:49 #: plinth/modules/monkeysphere/templates/monkeysphere_details.html:11 @@ -3401,7 +3401,7 @@ msgid "" "After this key is published to the keyservers, it can be signed using GnuPG with the following commands:" msgstr "" -"Après la publication de la clé sur les serveurs de clés, cette clé peut être " +"Après la publication de la clef sur les serveurs de clefs, elle peut être " "signée en utilisant GnuPG grâce aux " "commandes suivantes :" @@ -3859,10 +3859,10 @@ msgstr "" "block\">Cela signifie que les appareils sur Internet peuvent vous joindre " "quand vous êtes connecté à Internet. À chaque connexion avec votre " "fournisseur d’accès à Internet (FAI), il se peut que vous ayez une adresse " -"IP différente, en particulier après un certain temps hors ligne. De nombreux " -"FAI proposent ce type de connectivité. Si vous avez une adresse IP publique " -"mais ne savez pas si celle-ci peut changer dans le temps, il est plus sûr de " -"choisir cette option.

" +"IP différente, en particulier après une certaine durée sans connexion. De " +"nombreux FAI proposent ce type de connectivité. Si vous avez une adresse IP " +"publique mais ne savez pas si celle-ci peut changer dans le temps, il est " +"plus sûr de choisir cette option.

" #: plinth/modules/networks/forms.py:360 #, python-brace-format @@ -4889,9 +4889,8 @@ msgid "" "Are you sure you want to restart? You will not be able to access this web " "interface for a few minutes until the system is restarted." msgstr "" -"Voulez-vous vraiment redémarrer ? Vous ne pourrez plus accéder à cette " -"interface web durant quelques minutes, jusqu'à ce que le système ait " -"redémarré." +"Voulez-vous vraiment redémarrer ? Cette interface web sera inaccessible " +"durant quelques minutes, le temps que le système ait redémarré." #: plinth/modules/power/templates/power_restart.html:34 msgid "" @@ -4911,8 +4910,8 @@ msgid "" "Are you sure you want to shut down? You will not be able to access this web " "interface after shut down." msgstr "" -"Voulez-vous vraiment éteindre le système ? Vous ne pourrez plus accéder à " -"cette interface web après l’extinction du système." +"Voulez-vous vraiment éteindre le système ? Cette interface web sera " +"inaccessible un fois le système éteint." #: plinth/modules/power/templates/power_shutdown.html:33 msgid "" @@ -5035,9 +5034,9 @@ msgid "" "which must be done using a separate client." msgstr "" "Radicale inclut une interface web très sommaire, qui ne permet que la " -"création de nouveaux calendriers ou carnets d’adresses. Elle ne permet pas " -"l’ajout d'événements ou de contacts, qui doivent être réalisés avec un " -"client dédié." +"création de nouveaux calendriers ou carnets d’adresses. Elle ne permet pas l’" +"ajout d’événements ou de contacts, opérations qui doivent être réalisées " +"avec un client dédié." #: plinth/modules/radicale/__init__.py:56 #: plinth/modules/radicale/manifest.py:75 @@ -5120,8 +5119,9 @@ msgid "" msgstr "" "Dans Evolution, ajoutez un nouvel agenda ou un nouveau carnet d’adresses " "avec WebDAV. Entrez l’URL du serveur Radicale (par exemple https://) et votre compte-utilisateur. Cliquer sur le bouton de " -"recherche affichera la liste de vos agendas et carnets d’adresses existants." +"de.votre.freedombox>) et votre compte-utilisateur. Cliquez sur le bouton de " +"recherche pour afficher la liste de vos agendas et carnets d’adresses " +"existants." #: plinth/modules/radicale/views.py:35 msgid "Access rights configuration updated" @@ -5134,12 +5134,12 @@ msgid "" "from an email client, including MIME support, address book, folder " "manipulation, message searching and spell checking." msgstr "" -"La messagerie web Roundcube est un client IMAP multilingue fonctionnant sur " -"un navigateur grâce à une interface similaire à celle d'une application. " -"Roundcube propose la panoplie complète des fonctionnalités attendues pour un " -"client de courriel, telles que la compatibilité MIME, un carnet d’adresses, " -"une gestion des dossiers, un outil de recherche dans les messages et un " -"correcteur orthographique." +"La messagerie web Roundcube est un client IMAP multilingue fonctionnant dans " +"un navigateur et présentant une interface similaire à celle d’une " +"application. Roundcube propose la panoplie complète des fonctionnalités " +"attendues d’un client de courriel, telles que la compatibilité MIME, un " +"carnet d’adresses, une gestion des dossiers, un outil de recherche dans les " +"messages et un correcteur orthographique." #: plinth/modules/roundcube/__init__.py:26 msgid "" @@ -5472,7 +5472,7 @@ msgid "" msgstr "" "En activant cette option, le système %(box_name)s et un nombre limité de " "logiciels recevront des mises à jours plus fréquentes (via le dépôt de " -"rétroportage « backports ») pouvant inclure de nouvelles fonctionnalités. " +"rétroportages « backports ») pouvant inclure de nouvelles fonctionnalités. " "Ceci vous permet de bénéficier de nouvelles fonctionnalités au bout de " "quelques semaines plutôt que tous les 2 ans environ. Veuillez noter que les " "logiciels bénéficiant de ces mises à jours fréquentes ne sont pas supportés " @@ -5665,7 +5665,7 @@ msgid "" "web with chosen groups of users." msgstr "" "« Partages » vous permet de partager des fichiers et répertoires de votre " -"{box_name} sur Internet avec des groupes d’utilisateurs déterminés." +"{box_name} sur Internet avec des groupes d’utilisateurs bien définis." #: plinth/modules/sharing/__init__.py:38 msgid "Sharing" @@ -5787,8 +5787,8 @@ msgid "" msgstr "" "Le système prend périodiquement des instantanés dits « instantanés " "programmés », ainsi qu’avant et après chaque installation de logiciels. Les " -"instantanés plus anciens peuvent être supprimés automatiquement en fonction " -"du paramétrage qui suit." +"instantanés plus anciens seront supprimés automatiquement en fonction du " +"paramétrage qui suit." #: plinth/modules/snapshot/__init__.py:32 msgid "" @@ -5838,7 +5838,7 @@ msgstr "Instantanés d’installation de logiciels" #: plinth/modules/snapshot/forms.py:27 msgid "Enable or disable snapshots before and after software installation" msgstr "" -"Active ou désactive la prise d'instantanés avant et après l’installation de " +"Active ou désactive la prise d’instantanés avant et après l’installation de " "logiciels" #: plinth/modules/snapshot/forms.py:32 @@ -6243,9 +6243,10 @@ msgid "" "root partition. Root partition can be expanded to use this space. This " "will provide you additional free space to store your files." msgstr "" -"Il y a %(expandable_root_size)s d’espace disponible non alloué après votre " -"partition racine. La partition racine peut être étendue pour l’utiliser. " -"Cela vous donnera de l’espace supplémentaire pour stocker vos fichiers." +"Il y a %(expandable_root_size)s d’espace disponible non alloué à la suite de " +"votre partition racine. La partition racine peut être étendue pour l’" +"utiliser. Cela vous donnera de l’espace supplémentaire pour stocker vos " +"fichiers." #: plinth/modules/storage/templates/storage.html:90 #: plinth/modules/storage/templates/storage_expand.html:24 @@ -6320,14 +6321,14 @@ msgid "" "{box_name} is only available for users belonging to the \"admin\" or " "\"syncthing\" group." msgstr "" -"En faisant tourner Syncthing sur votre {box_name}, vous ajoutez un nœud de " -"synchronisation à forte disponiblilité qui permettra à vos appareils de se " -"synchroniser plus régulièrement que simplement entre eux. Sur la {box_name} " -"une seule instance de Syncthing s’exécute, qui peut servir à plusieurs " -"utilisateurs. Chaque utilisateur peut choisir un ensemble de dossiers qui " -"lui est propre, à synchroniser entre ses appareils. L’interface web sur la " -"{box_name} est accessible uniquement aux utilisateurs membres des groupes " -"« admin » ou « syncthing »." +"En activant Syncthing sur votre {box_name}, vous ajoutez un nœud de " +"synchronisation disponible en permanence qui permettra à vos appareils de se " +"synchroniser plus régulièrement que juste entre eux. La {box_name} exécute " +"une unique instance de Syncthing qui peut servir à plusieurs utilisateurs. " +"Chaque utilisateur peut choisir un ensemble de dossiers qui lui est propre, " +"à synchroniser entre ses appareils. L’interface web sur la {box_name} est " +"accessible uniquement aux utilisateurs membres des groupes « admin » ou « " +"syncthing »." #: plinth/modules/syncthing/__init__.py:56 msgid "Administer Syncthing application" @@ -6383,11 +6384,11 @@ msgid "" "DATA. You can access Tahoe-LAFS at https://%(domain_name)s:5678." msgstr "" -"Le domaine du serveur Tahoe-LAFS est défini comme %(domain_name)s. Le " +"Lu serveur Tahoe-LAFS utilise le domaine %(domain_name)s. Le " "changement du nom de domaine de la FreedomBox nécessitera la réinstallation " "de Tahoe-LAFS et vous PERDREZ LES DONNÉES. Vous pouvez accéder à Tahoe-LAFS " -"à l’adresse https://" -"%(domain_name)s:5678." +"à l’adresse https://%(domain_name)s:5678." #: plinth/modules/tahoe/templates/tahoe-post-setup.html:29 msgid "Local introducer" @@ -6692,7 +6693,7 @@ msgid "" "automatically at 02:00 causing all apps to be unavailable briefly." msgstr "" "Les mises à jour sont exécutées à 6h00 heure locale tous les jours. Vous " -"pouvez définir votre fuseau horaire dans l’application Date et heure. Les " +"pouvez régler votre fuseau horaire dans l’application Date et heure. Les " "applications sont redémarrées après leur mise à jour ce qui les rend " "brièvement indisponibles. Lorsque qu’un redémarrage du système est " "nécessaire, il est effectué à 2h00, rendant indisponible l’ensemble des " @@ -6804,7 +6805,7 @@ msgid "" msgstr "" "Cette opération peut prendre du temps. Pendant la durée de " "la mise à jour, vous ne pourrez pas installer d’autre appli. Cette interface " -"web pourrait aussi devenir temporairement indisponible ou afficher une " +"web risque aussi d’être temporairement indisponible ou d’afficher une " "erreur. Si cela se produit, rafraîchissez la page pour continuer." #: plinth/modules/upgrades/templates/upgrades_configure.html:95 @@ -6857,9 +6858,9 @@ msgid "" "group may alter apps or system settings." msgstr "" "N’importe quel utilisateur peut se connecter à l’interface web de la " -"{box_name} pour consulter la liste d’applications disponibles depuis la page " -"principale. En revanche, seuls les utilisateurs du groupe admin " -"pourront modifier ces applications ou les paramètres système." +"{box_name} pour consulter la liste d’applications installées depuis la page " +"principale. En revanche, seuls les utilisateurs membres du groupe admin peuvent modifier les applications ou changer les paramètres système." #: plinth/modules/users/__init__.py:65 msgid "Users and Groups" @@ -6946,7 +6947,7 @@ msgstr "Échec du paramétrage des clefs SSH." #: plinth/modules/users/forms.py:282 msgid "Failed to change user status." -msgstr "Échec du changement de statut de l'utilisateur." +msgstr "Échec du changement de statut de l’utilisateur." #: plinth/modules/users/forms.py:290 msgid "Cannot delete the only administrator in the system." @@ -7009,10 +7010,10 @@ msgid "" "can be changed later. This user will be granted administrative privileges. " "Other users can be added later." msgstr "" -"Choisir un nom d’utilisateur et un mot de passe pour accéder à l’interface " -"web. Le mot de passe peut être modifié par la suite. Cet utilisateur " -"disposera de privilèges administrateur. D’autres utilisateurs pourront être " -"créés ultérieurement." +"Choisissez un nom d’utilisateur et un mot de passe pour accéder à cette " +"interface web. Le mot de passe peut être modifié par la suite. Cet " +"utilisateur disposera des privilèges d’administrateur. D’autres utilisateurs " +"pourront être créés ultérieurement." #: plinth/modules/users/templates/users_firstboot.html:27 msgid "Create Account" From 9ea579230674c605f41816ebd3eb2e6891cfa2f1 Mon Sep 17 00:00:00 2001 From: ssantos Date: Mon, 28 Sep 2020 13:48:26 +0000 Subject: [PATCH 18/46] Translated using Weblate (Portuguese) Currently translated at 12.3% (163 of 1321 strings) --- plinth/locale/pt/LC_MESSAGES/django.po | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plinth/locale/pt/LC_MESSAGES/django.po b/plinth/locale/pt/LC_MESSAGES/django.po index 57099f25f..295a45e55 100644 --- a/plinth/locale/pt/LC_MESSAGES/django.po +++ b/plinth/locale/pt/LC_MESSAGES/django.po @@ -8,16 +8,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-04-08 13:55+0000\n" -"Last-Translator: Manuela Silva \n" +"PO-Revision-Date: 2020-09-29 09:39+0000\n" +"Last-Translator: ssantos \n" "Language-Team: Portuguese \n" +"freedombox/pt/>\n" "Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.0-dev\n" +"X-Generator: Weblate 4.3-dev\n" #: doc/dev/_templates/layout.html:11 msgid "Page source" @@ -61,7 +61,7 @@ msgid "" "Warning! The application may not work properly if domain name is changed " "later." msgstr "" -"Atenção! O aplicativo pode não responder corretamente se o nome de domínio " +"Atenção! A aplicação pode não responder corretamente se o nome de domínio " "for alterado mais tarde." #: plinth/forms.py:48 From c2a9592de12e76f122eff71922f9696be63aa4ee Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 22:49:50 -0700 Subject: [PATCH 19/46] quassel: Fix minor typo Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- actions/quassel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/quassel b/actions/quassel index e93b90cd7..8da6c8b81 100755 --- a/actions/quassel +++ b/actions/quassel @@ -14,7 +14,7 @@ def parse_arguments(): subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') subparser = subparsers.add_parser('set-domain', - help='Setup Cockpit configuration') + help='Setup Quassel configuration') subparser.add_argument('domain_name', help='Domain name to be allowed') subparsers.required = True From 215371a877fa64ca283d4545aa176ad1a9062211 Mon Sep 17 00:00:00 2001 From: Phil Morrell Date: Sun, 24 May 2020 02:42:22 +0100 Subject: [PATCH 20/46] mumble: configure letsencrypt component Fixes: #701 Signed-off-by: Phil Morrell Reviewed-by: James Valleroy --- actions/mumble | 26 +++++++++++++++++++++++++- plinth/modules/mumble/__init__.py | 14 ++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/actions/mumble b/actions/mumble index 4a1c3f3c2..2e1d01283 100755 --- a/actions/mumble +++ b/actions/mumble @@ -7,9 +7,13 @@ Configure Mumble server. """ import argparse +import augeas import sys from subprocess import Popen, PIPE +CONFIG_FILE = '/etc/mumble-server.ini' +DATA_DIR = '/var/lib/mumble-server' + def parse_arguments(): """Return parsed command line arguments as dictionary.""" @@ -17,12 +21,21 @@ def parse_arguments(): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + subparsers.add_parser('setup', help='Setup Mumble server') subparsers.add_parser('create-password', help='Setup mumble superuser password') return parser.parse_args() +def subcommand_setup(_): + """Setup Mumble server.""" + aug = load_augeas() + aug.set('.anon/sslCert', DATA_DIR + '/fullchain.pem') + aug.set('.anon/sslKey', DATA_DIR + '/privkey.pem') + aug.save() + + def read_from_stdin(): """Read password from stdin""" @@ -34,7 +47,7 @@ def subcommand_create_password(arguments): password = read_from_stdin() - cmd = ['murmurd', '-ini', '/etc/mumble-server.ini', '-readsupw'] + cmd = ['murmurd', '-ini', CONFIG_FILE, '-readsupw'] proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, shell=False) # The exit code of the command above seems to be 1 when successful! @@ -51,6 +64,17 @@ def subcommand_create_password(arguments): sys.exit(1) +def load_augeas(): + """Initialize Augeas.""" + aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + + augeas.Augeas.NO_MODL_AUTOLOAD) + aug.transform('Php', CONFIG_FILE) + aug.set('/augeas/context', '/files' + CONFIG_FILE) + aug.load() + + return aug + + def main(): """Parse arguments and perform all duties.""" arguments = parse_arguments() diff --git a/plinth/modules/mumble/__init__.py b/plinth/modules/mumble/__init__.py index 2f71b3766..1e414dfe1 100644 --- a/plinth/modules/mumble/__init__.py +++ b/plinth/modules/mumble/__init__.py @@ -3,6 +3,8 @@ FreedomBox app to configure Mumble server. """ +import pathlib + from django.urls import reverse_lazy from django.utils.translation import ugettext_lazy as _ @@ -10,6 +12,7 @@ from plinth import app as app_module from plinth import frontpage, menu from plinth.daemon import Daemon from plinth.modules.firewall.components import Firewall +from plinth.modules.letsencrypt.components import LetsEncrypt from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import @@ -20,6 +23,8 @@ managed_services = ['mumble-server'] managed_packages = ['mumble-server'] +managed_paths = [pathlib.Path('/var/lib/mumble-server')] + _description = [ _('Mumble is an open source, low-latency, encrypted, high quality ' 'voice chat software.'), @@ -61,6 +66,15 @@ class MumbleApp(app_module.App): ports=['mumble-plinth'], is_external=True) self.add(firewall) + letsencrypt = LetsEncrypt( + 'letsencrypt-mumble', domains='*', + daemons=managed_services, should_copy_certificates=True, + private_key_path='/var/lib/mumble-server/privkey.pem', + certificate_path='/var/lib/mumble-server/fullchain.pem', + user_owner='mumble-server', group_owner='mumble-server', + managing_app='mumble') + self.add(letsencrypt) + daemon = Daemon( 'daemon-mumble', managed_services[0], listen_ports=[(64738, 'tcp4'), (64738, 'tcp6'), (64738, 'udp4'), From 956530ab8a10df0cb9f07ccf28b2987eae150352 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 22:51:01 -0700 Subject: [PATCH 21/46] mumble: Store and use a single domain for TLS certificate setup Certificate can be setup for a single domain at a time in Mumble. So, allow the user to choose the domain purely for this propose even though Mumble can work with multiple domains. Tell Let's Encrypt to work with this domain. Tests: - Without Mumble installed, change the domain name. Notice the mumble related certificate events are ignored. - Install Mumble, a TLS domain is automatically selected. Certificate is setup for that domain. - Ensure at least two domains are setup in the system. See the list in the Mumble app page. Choose a non-default domain. Domain should change and cert should be setup for that domain. - Go to config app and change the domain. Mumble domain should get set to a different domain and cert should get updated. - Install mumble without these changes. Apply the changes and start FreedomBox. Mumble app should get upgraded and certificate should get setup for a domain. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- actions/mumble | 33 +++++++++++++++++----- plinth/modules/mumble/__init__.py | 46 +++++++++++++++++++++++++++++-- plinth/modules/mumble/forms.py | 16 +++++++++++ plinth/modules/mumble/views.py | 12 ++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/actions/mumble b/actions/mumble index 2e1d01283..e2ec6fba2 100755 --- a/actions/mumble +++ b/actions/mumble @@ -1,15 +1,16 @@ #!/usr/bin/python3 # SPDX-License-Identifier: AGPL-3.0-or-later - """ Configure Mumble server. """ import argparse -import augeas +import pathlib import sys -from subprocess import Popen, PIPE +from subprocess import PIPE, Popen + +import augeas CONFIG_FILE = '/etc/mumble-server.ini' DATA_DIR = '/var/lib/mumble-server' @@ -25,6 +26,11 @@ def parse_arguments(): subparsers.add_parser('create-password', help='Setup mumble superuser password') + subparsers.add_parser('get-domain', help='Print Mumble domain') + subparser = subparsers.add_parser('set-domain', help='Setup Mumble domain') + subparser.add_argument('domain_name', help='Domain name to be allowed') + + subparsers.required = True return parser.parse_args() @@ -42,7 +48,7 @@ def read_from_stdin(): return (''.join(sys.stdin)).strip() -def subcommand_create_password(arguments): +def subcommand_create_password(_): """Save superuser password with murmurd command""" password = read_from_stdin() @@ -58,12 +64,25 @@ def subcommand_create_password(arguments): phrase = "Superuser password set on server" if phrase not in err: - print( - "Error occured while saving password: %s" % err - ) + print("Error occured while saving password: %s" % err) sys.exit(1) +def subcommand_get_domain(_): + """Print the file containing domain name or empty string.""" + domain_file = pathlib.Path('/var/lib/mumble-server/domain-freedombox') + try: + print(domain_file.read_text()) + except FileNotFoundError: + pass + + +def subcommand_set_domain(arguments): + """Write a file containing domain name.""" + domain_file = pathlib.Path('/var/lib/mumble-server/domain-freedombox') + domain_file.write_text(arguments.domain_name) + + def load_augeas(): """Initialize Augeas.""" aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + diff --git a/plinth/modules/mumble/__init__.py b/plinth/modules/mumble/__init__.py index 1e414dfe1..61fabdaba 100644 --- a/plinth/modules/mumble/__init__.py +++ b/plinth/modules/mumble/__init__.py @@ -8,16 +8,18 @@ import pathlib from django.urls import reverse_lazy from django.utils.translation import ugettext_lazy as _ +from plinth import actions from plinth import app as app_module from plinth import frontpage, menu from plinth.daemon import Daemon +from plinth.modules import names from plinth.modules.firewall.components import Firewall from plinth.modules.letsencrypt.components import LetsEncrypt from plinth.modules.users.components import UsersAndGroups from .manifest import backup, clients # noqa, pylint: disable=unused-import -version = 1 +version = 2 managed_services = ['mumble-server'] @@ -67,7 +69,7 @@ class MumbleApp(app_module.App): self.add(firewall) letsencrypt = LetsEncrypt( - 'letsencrypt-mumble', domains='*', + 'letsencrypt-mumble', domains=get_domains, daemons=managed_services, should_copy_certificates=True, private_key_path='/var/lib/mumble-server/privkey.pem', certificate_path='/var/lib/mumble-server/fullchain.pem', @@ -89,4 +91,42 @@ class MumbleApp(app_module.App): def setup(helper, old_version=None): """Install and configure the module.""" helper.install(managed_packages) - helper.call('post', app.enable) + helper.call('post', actions.superuser_run, 'mumble', ['setup']) + if not old_version: + helper.call('post', app.enable) + + app.get_component('letsencrypt-mumble').setup_certificates() + + +def get_available_domains(): + """Return an iterator with all domains able to have a certificate.""" + return (domain.name for domain in names.components.DomainName.list() + if domain.domain_type.can_have_certificate) + + +def set_domain(domain): + """Set the TLS domain by writing a file to data directory.""" + if domain: + actions.superuser_run('mumble', ['set-domain', domain]) + + +def get_domain(): + """Read TLS domain from config file select first available if none.""" + domain = actions.superuser_run('mumble', ['get-domain']).strip() + if not domain: + domain = next(get_available_domains(), None) + set_domain(domain) + + return domain + + +def get_domains(): + """Return a list with the configured domains.""" + if not pathlib.Path('/var/lib/mumble-server/').exists(): + return [] + + domain = get_domain() + if domain: + return [domain] + + return [] diff --git a/plinth/modules/mumble/forms.py b/plinth/modules/mumble/forms.py index fa05b25b5..a3df53a23 100644 --- a/plinth/modules/mumble/forms.py +++ b/plinth/modules/mumble/forms.py @@ -6,9 +6,25 @@ Mumble server configuration form from django import forms from django.utils.translation import ugettext_lazy as _ +from plinth.modules import mumble + + +def get_domain_choices(): + """Double domain entries for inclusion in the choice field.""" + return ((domain, domain) for domain in mumble.get_available_domains()) + class MumbleForm(forms.Form): """Mumble server configuration""" + domain = forms.ChoiceField( + choices=get_domain_choices, + label=_('TLS domain'), + help_text=_( + 'Select a domain to use TLS with. If the list is empty, please ' + 'configure at least one domain with certificates.'), + required=False, + ) + super_user_password = forms.CharField( max_length=20, label=_('Set SuperUser Password'), diff --git a/plinth/modules/mumble/views.py b/plinth/modules/mumble/views.py index a12ba76ce..4180f7b59 100644 --- a/plinth/modules/mumble/views.py +++ b/plinth/modules/mumble/views.py @@ -3,6 +3,7 @@ from django.contrib import messages from django.utils.translation import ugettext_lazy as _ from plinth import actions +from plinth.modules import mumble from plinth.modules.mumble.forms import MumbleForm from plinth.views import AppView @@ -11,10 +12,21 @@ class MumbleAppView(AppView): app_id = 'mumble' form_class = MumbleForm + def get_initial(self): + """Return the values to fill in the form.""" + initial = super().get_initial() + initial['domain'] = mumble.get_domain() + return initial + def form_valid(self, form): """Apply new superuser password if it exists""" new_config = form.cleaned_data + if mumble.get_domain() != new_config['domain']: + mumble.set_domain(new_config['domain']) + mumble.app.get_component('letsencrypt-mumble').setup_certificates() + messages.success(self.request, _('Configuration updated')) + password = new_config.get('super_user_password') if password: actions.run_as_user( From 3f4bd9416d92d939d63ccf182761d4ee21c820ba Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 24 Sep 2020 00:02:22 -0700 Subject: [PATCH 22/46] doc: dev: Link to list of potential apps from tutorial Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- doc/dev/tutorial/beginning.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/dev/tutorial/beginning.rst b/doc/dev/tutorial/beginning.rst index 34f7818d9..42eff6eb7 100644 --- a/doc/dev/tutorial/beginning.rst +++ b/doc/dev/tutorial/beginning.rst @@ -18,7 +18,9 @@ be built. Picking an app ^^^^^^^^^^^^^^ -We must first, of course, pick an application to add to FreedomBox. For the +We must first, of course, pick an application to add to FreedomBox. Many +potential applications that can be added to FreedomBox have been listed on the +`FreedomBox wiki `. For the purpose of this tutorial, let us pick Transmission. Transmission daemon handles Bitorrent file sharing. BitTorrent is a peer-to-peer file sharing protocol. From 2416d8739870a45ddb9ac671d6c7f30bbcf0638d Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Sun, 27 Sep 2020 22:25:09 +0530 Subject: [PATCH 23/46] container: Handle edge cases with container update Fix update command failing when image files do not exist. Signed-off-by: Joseph Nuthalapati Reviewed-by: Veiko Aasa --- container | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/container b/container index b82276da2..72d565845 100755 --- a/container +++ b/container @@ -286,11 +286,11 @@ def _get_systemd_nspawn_version(): def _download_file(url, target_file, force=False): """Download a file from remote URL.""" - if target_file.exists() and not force: - return - - if force: - os.remove(target_file) + if target_file.exists(): + if force: + os.remove(target_file) + else: + return partial_file = target_file.with_suffix(target_file.suffix + '.partial') @@ -805,8 +805,12 @@ def _get_latest_image_timestamp(distribution): def _is_update_required(distribution): """Compare local image timestamp against the latest image timestamp.""" - filename = URLS[distribution].split('/')[-1] - local_image_timestamp = os.path.getmtime(work_directory / filename) + file_path = work_directory / URLS[distribution].split('/')[-1] + + if not file_path.exists(): + return True + + local_image_timestamp = os.path.getmtime(file_path) one_day = datetime.timedelta(days=1).total_seconds() latest_image_timestamp = _get_latest_image_timestamp(distribution) return latest_image_timestamp - local_image_timestamp > one_day From 94c3ddefd1ae495c82fbefe83e9d7cf76c4069d3 Mon Sep 17 00:00:00 2001 From: Fioddor Superconcentrado Date: Tue, 29 Sep 2020 20:24:51 +0000 Subject: [PATCH 24/46] Translated using Weblate (Spanish) Currently translated at 100.0% (1321 of 1321 strings) --- plinth/locale/es/LC_MESSAGES/django.po | 189 +++++++++---------------- 1 file changed, 65 insertions(+), 124 deletions(-) diff --git a/plinth/locale/es/LC_MESSAGES/django.po b/plinth/locale/es/LC_MESSAGES/django.po index d6675a941..08dc3a79e 100644 --- a/plinth/locale/es/LC_MESSAGES/django.po +++ b/plinth/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-08-30 18:23+0000\n" +"PO-Revision-Date: 2020-10-01 21:41+0000\n" "Last-Translator: Fioddor Superconcentrado \n" "Language-Team: Spanish \n" @@ -17,7 +17,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.2.1-dev\n" +"X-Generator: Weblate 4.3-dev\n" #: doc/dev/_templates/layout.html:11 msgid "Page source" @@ -152,10 +152,8 @@ msgid "{app} (No data to backup)" msgstr "{app} (Sin datos que respaldar)" #: plinth/modules/backups/forms.py:50 -#, fuzzy -#| msgid "Create Repository" msgid "Repository" -msgstr "Crear repositorio" +msgstr "Repositorio" #: plinth/modules/backups/forms.py:52 #: plinth/modules/backups/templates/backups_delete.html:18 @@ -226,10 +224,8 @@ msgstr "" "está almacenada con la copia de seguridad." #: plinth/modules/backups/forms.py:122 -#, fuzzy -#| msgid "Create Repository" msgid "Key in Repository" -msgstr "Crear repositorio" +msgstr "Clave en repositorio" #: plinth/modules/backups/forms.py:122 plinth/modules/searx/forms.py:15 msgid "None" @@ -723,34 +719,31 @@ msgstr "Permisos" msgid "" "Users that log in with this password will have the selected permissions." msgstr "" +"Los usuarios que ingresen con esta contraseña tendrán los permisos " +"seleccionados." #: plinth/modules/bepasty/forms.py:33 #: plinth/modules/bepasty/templates/bepasty.html:31 msgid "Comment" -msgstr "" +msgstr "Comentario" #: plinth/modules/bepasty/forms.py:34 msgid "Any comment to help you remember the purpose of this password." msgstr "" +"Cualquier comentario que te ayude a recordar el propósito de esta contraseña." #: plinth/modules/bepasty/templates/bepasty.html:12 -#, fuzzy -#| msgid "Change Password" msgid "Manage Passwords" -msgstr "Cambiar clave de acceso" +msgstr "Administrar contraseñas" #: plinth/modules/bepasty/templates/bepasty.html:16 #: plinth/modules/bepasty/templates/bepasty.html:18 -#, fuzzy -#| msgid "Show password" msgid "Add password" -msgstr "Mostrar clave de acceso" +msgstr "Añadir contraseña" #: plinth/modules/bepasty/templates/bepasty.html:23 -#, fuzzy -#| msgid "No shares currently configured." msgid "No passwords currently configured." -msgstr "Actualmente no hay comparticiones configuradas." +msgstr "Actualmente no hay contraseñas configuradas." #: plinth/modules/bepasty/templates/bepasty.html:29 #: plinth/modules/dynamicdns/forms.py:106 plinth/modules/networks/forms.py:205 @@ -760,7 +753,7 @@ msgstr "Clave de acceso" #: plinth/modules/bepasty/views.py:42 msgid "Read" -msgstr "" +msgstr "Leer" #: plinth/modules/bepasty/views.py:43 msgid "Create" @@ -768,7 +761,7 @@ msgstr "Crear" #: plinth/modules/bepasty/views.py:44 msgid "List" -msgstr "" +msgstr "Listar" #: plinth/modules/bepasty/views.py:45 #: plinth/modules/letsencrypt/templates/letsencrypt.html:91 @@ -782,10 +775,8 @@ msgid "Delete" msgstr "Eliminar" #: plinth/modules/bepasty/views.py:46 -#, fuzzy -#| msgid "admin" msgid "Admin" -msgstr "admin" +msgstr "Admin" #: plinth/modules/bepasty/views.py:83 plinth/modules/searx/views.py:38 #: plinth/modules/searx/views.py:49 plinth/modules/tor/views.py:130 @@ -800,22 +791,16 @@ msgid "An error occurred during configuration." msgstr "Ha habido un error en la configuración." #: plinth/modules/bepasty/views.py:97 -#, fuzzy -#| msgid "Password updated" msgid "Password added." -msgstr "Clave actualizada" +msgstr "Contraseña actualizada." #: plinth/modules/bepasty/views.py:102 -#, fuzzy -#| msgid "Password" msgid "Add Password" -msgstr "Clave de acceso" +msgstr "Añadir contraseña" #: plinth/modules/bepasty/views.py:119 -#, fuzzy -#| msgid "Password updated" msgid "Password deleted." -msgstr "Clave actualizada" +msgstr "Contraseña eliminada." #: plinth/modules/bind/__init__.py:29 msgid "" @@ -1244,20 +1229,16 @@ msgid "Diagnostics" msgstr "Diagnósticos" #: plinth/modules/diagnostics/__init__.py:102 -#, fuzzy -#| msgid "Quassel" msgid "passed" -msgstr "Quassel" +msgstr "ok." #: plinth/modules/diagnostics/__init__.py:103 -#, fuzzy -#| msgid "Setup failed." msgid "failed" -msgstr "Ha fallado la configuración." +msgstr "Falló" #: plinth/modules/diagnostics/__init__.py:104 msgid "error" -msgstr "" +msgstr "error" #: plinth/modules/diagnostics/templates/diagnostics.html:17 #: plinth/modules/diagnostics/templates/diagnostics_button.html:13 @@ -1471,13 +1452,11 @@ msgstr "El nombre de usuaria/o que empleó al crear la cuenta." #: plinth/modules/dynamicdns/forms.py:65 msgid "GnuDIP" -msgstr "" +msgstr "GnuDIP" #: plinth/modules/dynamicdns/forms.py:68 -#, fuzzy -#| msgid "Update URL" msgid "other update URL" -msgstr "URL de actualización" +msgstr "Otra URL de actualización" #: plinth/modules/dynamicdns/forms.py:70 msgid "Enable Dynamic DNS" @@ -1979,14 +1958,12 @@ msgid "An alpha-numeric string that uniquely identifies a repository." msgstr "Una cadena alfanumérica que identifica unívocamente un repositorio." #: plinth/modules/gitweb/forms.py:134 -#, fuzzy -#| msgid "Default Skin" msgid "Default branch" -msgstr "Tema por defecto" +msgstr "Rama por omisión" #: plinth/modules/gitweb/forms.py:135 msgid "Gitweb displays this as a default branch." -msgstr "" +msgstr "Gitweb muestra esta rama por omisión." #: plinth/modules/gitweb/manifest.py:21 msgid "Git" @@ -2066,8 +2043,6 @@ msgstr "Documentación" #: plinth/modules/help/__init__.py:37 plinth/templates/help-menu.html:20 #: plinth/templates/help-menu.html:21 plinth/templates/index.html:128 -#, fuzzy -#| msgid "Manual" msgctxt "User guide" msgid "Manual" msgstr "Manual" @@ -2787,19 +2762,14 @@ msgstr "" "un servidor Matrix contacten con los de otro servidor." #: plinth/modules/matrixsynapse/__init__.py:40 -#, fuzzy -#| msgid "" -#| "To communicate, you can use the available clients for mobile, desktop and the web. Riot client is recommended." msgid "" "To communicate, you can use the available clients for mobile, desktop and the web. Element client is recommended." msgstr "" -"Para comunicarse puede usar los clientes disponibles para móvil, escritorio y la web. Se recomienda " -"el cliente Riot." +"Para comunicarse puede usar los clientes disponibles para móvil, escritorio y la web. Se recomienda el " +"cliente Element." #: plinth/modules/matrixsynapse/__init__.py:67 msgid "Matrix Synapse" @@ -2821,7 +2791,7 @@ msgstr "" #: plinth/modules/matrixsynapse/manifest.py:13 msgid "Element" -msgstr "" +msgstr "Element" #: plinth/modules/matrixsynapse/templates/matrix-synapse-pre-setup.html:15 #: plinth/modules/snapshot/templates/snapshot.html:12 @@ -3475,10 +3445,8 @@ msgid "All web apps" msgstr "Todas las apps web" #: plinth/modules/names/templates/names.html:16 -#, fuzzy -#| msgid "Service" msgid "Services" -msgstr "Servicio" +msgstr "Servicios" #: plinth/modules/networks/__init__.py:40 msgid "" @@ -3568,8 +3536,6 @@ msgid "Shared" msgstr "Compartido" #: plinth/modules/networks/forms.py:48 plinth/modules/networks/forms.py:79 -#, fuzzy -#| msgid "Manual" msgctxt "Not automatically" msgid "Manual" msgstr "Manual" @@ -4774,12 +4740,17 @@ msgid "" "utilization of the hardware. This can give you basic insights into usage " "patterns and whether the hardware is overloaded by users and services." msgstr "" +"La app Rendimiento te permite recolectar, almacenar y visualizar información " +"acerca del uso del hardware e identificar patrones de uso o si el hardware " +"se sobrecarga de usuarios o servicios." #: plinth/modules/performance/__init__.py:29 msgid "" "Performance metrics are collected by Performance Co-Pilot and can be viewed " "using the Cockpit app." msgstr "" +"Las métricas de rendimiento las recolecta Performance Co-Pilot y se pueden " +"ver empleando la app Cockpit." #: plinth/modules/performance/__init__.py:46 msgid "System Monitoring" @@ -4989,10 +4960,8 @@ msgstr "" "cualquier calendario/agenda." #: plinth/modules/radicale/forms.py:30 -#, fuzzy -#| msgid "Access Point" msgid "Access rights" -msgstr "Punto de acceso" +msgstr "Permisos de acceso" #: plinth/modules/radicale/manifest.py:10 msgid "DAVx5" @@ -5365,21 +5334,13 @@ msgstr "Actualizaciones funcionales frecuentes" #: plinth/modules/security/templates/security.html:19 #: plinth/modules/upgrades/templates/upgrades_configure.html:19 -#, fuzzy -#| msgid "Frequent feature updates are enabled." msgid "Frequent feature updates are activated." -msgstr "Las actualizaciones funcionales frecuentes están habilitadas." +msgstr "Las actualizaciones funcionales frecuentes están activadas." #: plinth/modules/security/templates/security.html:24 #: plinth/modules/upgrades/templates/backports-firstboot.html:14 #: plinth/modules/upgrades/templates/upgrades_configure.html:31 -#, fuzzy, python-format -#| msgid "" -#| "This will allow a very limited set of software, including FreedomBox " -#| "service, to be updated to receive newer features regularly instead of " -#| "once every 2 years or so. Note that packages with frequent feature " -#| "updates do not have support from Debian Security Team. They are instead " -#| "maintained by contributors to Debian and the FreedomBox community." +#, python-format msgid "" "Frequent feature updates allow the %(box_name)s Service, plus a very limited " "set of software, to receive new features more frequently (from the backports " @@ -5389,11 +5350,12 @@ msgid "" "Instead, they are maintained by contributors to Debian and the %(box_name)s " "community." msgstr "" -"Esto permitirá que un conjunto limitado de software, incluído el Servicio " -"FreedomBox se actualize para recibir nuevas funcionalidades regularmente en " -"vez de cada 2 años más o menos. Sepa que los paquetes con actualizaciones " -"funcionales frequentes no tienen soporte del Equipo de Seguridad de Debian, " -"sino de contribuyentes a Debian y de la comunidad de FreedomBox." +"Las actualizaciones funcionales frecuentes permiten al servicio %(box_name)s " +"y a un conjunto limitado de software recibir nuevas funcionalidades (desde " +"el repositorio backports). Así las reciben en cosa de semanas en vez de cada " +"2 años más o menos. Sepa que los paquetes con actualizaciones funcionales " +"frequentes no tienen soporte del Equipo de Seguridad de Debian, sino de " +"contribuyentes a Debian y de la comunidad de %(box_name)s." #: plinth/modules/security/templates/security_report.html:10 #: plinth/modules/security/views.py:74 @@ -5786,16 +5748,12 @@ msgid "Yearly Snapshots Limit" msgstr "Límite anual de instantáneas" #: plinth/modules/snapshot/forms.py:49 -#, fuzzy -#| msgid "" -#| "Keep a maximum of this many yearly snapshots. The default value is 0 " -#| "(disabled)." msgid "" "Keep a maximum of this many yearly snapshots. The default value is 0 (keep " "no yearly snapshot)." msgstr "" -"Conservar el máximo número de instantáneas anuales. El valor predeterminado " -"es 0 (desactivado)." +"Conservar esta cantidad de instantáneas anuales como mucho. El valor " +"predeterminado es 0 (sin instantáneas anuales)." #: plinth/modules/snapshot/templates/snapshot_delete_selected.html:12 msgid "Delete the following snapshots permanently?" @@ -6626,21 +6584,16 @@ msgid "" "It is strongly recommended to activate frequent feature updates. If not " "activated now, they can be activated later." msgstr "" +"Se recomienda encarecidamente activar las actualizaciones funcionales " +"frecuentes. Si no se activan ahora se pueden activar más tarde." #: plinth/modules/upgrades/templates/backports-firstboot.html:33 -#, fuzzy -#| msgid "" -#| "Warning! Once frequent feature updates are activated, " -#| "they cannot be deactivated. You may wish to take a snapshot using Storage Snapshots before continuing." msgid "" "Note: Once frequent feature updates are activated, they " "cannot be deactivated." msgstr "" -"¡Advertencia! Una vez que se activan las actualizaciones " -"funcionales frecuentes no se pueden desactivar. Antes de continuar quizá " -"quiera Ud. hacer una copia de respaldo usando Instantáneas ." +"Nota: Una vez que se activan las actualizaciones " +"funcionales frecuentes no se pueden desactivar." #: plinth/modules/upgrades/templates/upgrades-new-release.html:9 #, python-format @@ -6669,15 +6622,12 @@ msgstr "" "activarlas." #: plinth/modules/upgrades/templates/upgrades_configure.html:23 -#, fuzzy -#| msgid "" -#| "Frequent feature updates can be activated. Activating them is recommended." msgid "" "Frequent feature updates cannot be activated. They may not be necessary on " "your distribution." msgstr "" -"Se pueden activar las actualizaciones funcionales frecuentes. Se recomienda " -"activarlas." +"No se pueden activar las actualizaciones funcionales frecuentes. Quizá no " +"sean necesarias en tu distribución." #: plinth/modules/upgrades/templates/upgrades_configure.html:45 #, python-format @@ -6740,10 +6690,8 @@ msgid "Starting upgrade failed." msgstr "No se ha podido iniciar la actualización." #: plinth/modules/upgrades/views.py:91 -#, fuzzy -#| msgid "Frequent feature updates are enabled." msgid "Frequent feature updates activated." -msgstr "Las actualizaciones funcionales frecuentes están habilitadas." +msgstr "Las actualizaciones funcionales frecuentes están activadas." #: plinth/modules/users/__init__.py:39 msgid "" @@ -7610,16 +7558,13 @@ msgid "Port Forwarding" msgstr "Redirección de Puertos" #: plinth/templates/port-forwarding-info.html:13 -#, fuzzy, python-format -#| msgid "" -#| "You may want to check the network setup " -#| "and modify it if necessary." +#, python-format msgid "" "Your FreedomBox is not behind a router. No " "action is necessary." msgstr "" -"Puede que quiera comprobar la configuración de " -"red y modificarla si es necesario." +"Tu FreedomBox no está tras un router. No " +"hace falta hacer nada." #: plinth/templates/port-forwarding-info.html:19 #, python-format @@ -7628,37 +7573,33 @@ msgid "" "are using the DMZ feature to forward all ports. No further router " "configuration is necessary." msgstr "" +"Su FreedomBox está tras un router en la " +"zona desmilitarizada (DMZ) con todos los puertos expuestos. No hace falta " +"configurar nada más." #: plinth/templates/port-forwarding-info.html:26 -#, fuzzy, python-format -#| msgid "" -#| "If your FreedomBox is behind a router, you will need to set up port " -#| "forwarding on your router. You should forward the following ports for " -#| "%(service_name)s:" +#, python-format msgid "" "Your FreedomBox is behind a router and you " "are not using the DMZ feature. You will need to set up port forwarding on " "your router. You should forward the following ports for %(service_name)s:" msgstr "" -"Si tu FreedomBox está detrás de un router, deberás configurar la redirección " -"de puertos de tu router. Deberás redireccionar los siguientes puertos para " +"Tu FreedomBox está detrás de un router en " +"la zona militarizada. Deberás redireccionar los siguientes puertos para " "%(service_name)s:" #: plinth/templates/port-forwarding-info.html:36 -#, fuzzy -#| msgid "protocol" msgid "Protocol" -msgstr "protocolo" +msgstr "Protocolo" #: plinth/templates/port-forwarding-info.html:37 msgid "From Router/WAN Ports" -msgstr "" +msgstr "Desde los puertos externos" #: plinth/templates/port-forwarding-info.html:38 -#, fuzzy, python-format -#| msgid "%(box_name)s Setup" +#, python-format msgid "To %(box_name)s Ports" -msgstr "Configuración de %(box_name)s" +msgstr "A los puertos de %(box_name)s" #: plinth/templates/setup.html:24 msgid "Install this application?" From d60c00235c73f0bd5ddc61bcefbb1dec1397b81f Mon Sep 17 00:00:00 2001 From: Coucouf Date: Wed, 30 Sep 2020 20:06:12 +0000 Subject: [PATCH 25/46] Translated using Weblate (French) Currently translated at 100.0% (1321 of 1321 strings) --- plinth/locale/fr/LC_MESSAGES/django.po | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plinth/locale/fr/LC_MESSAGES/django.po b/plinth/locale/fr/LC_MESSAGES/django.po index 2631a3dd0..7bd2f887b 100644 --- a/plinth/locale/fr/LC_MESSAGES/django.po +++ b/plinth/locale/fr/LC_MESSAGES/django.po @@ -8,8 +8,8 @@ msgstr "" "Project-Id-Version: FreedomBox UI\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-09-29 09:39+0000\n" -"Last-Translator: Coucouf \n" +"PO-Revision-Date: 2020-10-01 21:41+0000\n" +"Last-Translator: Coucouf \n" "Language-Team: French \n" "Language: fr\n" @@ -176,7 +176,7 @@ msgstr "Applications à inclure dans la sauvegarde" #: plinth/modules/backups/forms.py:70 msgid "Select the apps you want to restore" -msgstr "Sélectionnez les applications que vous souhaitez restaurer." +msgstr "Sélectionnez les applications que vous souhaitez restaurer" #: plinth/modules/backups/forms.py:83 msgid "Upload File" @@ -1285,7 +1285,7 @@ msgstr "Résultats des diagnostics" #: plinth/modules/diagnostics/templates/diagnostics_app.html:17 msgid "This app does not support diagnostics" -msgstr "Cette application n’est pas compatible avec les tests de diagnostic." +msgstr "Cette application n’est pas compatible avec les tests de diagnostic" #: plinth/modules/diagnostics/templates/diagnostics_results.html:10 msgid "Test" @@ -1873,7 +1873,7 @@ msgid "" msgstr "" "Entrez le code secret généré durant l’installation de la FreedomBox. Il peut " "également être obtenu en lançant la commande « sudo cat /var/lib/plinth/" -"firstboot-wizard-secret » sur votre {box_name}." +"firstboot-wizard-secret » sur votre {box_name}" #: plinth/modules/first_boot/forms.py:19 msgid "Firstboot Wizard Secret" @@ -2628,7 +2628,7 @@ msgstr "Gobby" #: plinth/modules/infinoted/manifest.py:14 msgid "Gobby is a collaborative text editor" -msgstr "Gobby est un éditeur de texte collaboratif." +msgstr "Gobby est un éditeur de texte collaboratif" #: plinth/modules/infinoted/manifest.py:17 #, python-brace-format @@ -6967,7 +6967,7 @@ msgstr "Échec de la restriction de l'accès à la console." #: plinth/modules/users/forms.py:385 msgid "User account created, you are now logged in" -msgstr "Compte utilisateur créé, vous êtes maintenant connecté." +msgstr "Compte utilisateur créé, vous êtes maintenant connecté" #: plinth/modules/users/templates/users_change_password.html:11 #, python-format @@ -7553,7 +7553,7 @@ msgstr "Choisir la langue" #: plinth/templates/base.html:205 plinth/templates/base.html:207 msgid "Log in" -msgstr "S'identifier" +msgstr "S’identifier" #: plinth/templates/clients-button.html:16 msgid "Launch web client" From c76241510b0569ac1ee866b0f5e53fbe6344a230 Mon Sep 17 00:00:00 2001 From: Burak Yavuz Date: Wed, 30 Sep 2020 21:39:07 +0000 Subject: [PATCH 26/46] Translated using Weblate (Turkish) Currently translated at 100.0% (1321 of 1321 strings) --- plinth/locale/tr/LC_MESSAGES/django.po | 40 +++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/plinth/locale/tr/LC_MESSAGES/django.po b/plinth/locale/tr/LC_MESSAGES/django.po index 9e0a73ad2..fc44ece4c 100644 --- a/plinth/locale/tr/LC_MESSAGES/django.po +++ b/plinth/locale/tr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-09-17 09:36+0000\n" +"PO-Revision-Date: 2020-10-01 21:41+0000\n" "Last-Translator: Burak Yavuz \n" "Language-Team: Turkish \n" @@ -217,12 +217,12 @@ msgid "" "\"Key in Repository\" means that a password-protected key is stored with the " "backup." msgstr "" -"\"Key in Repository\", parola korumalı bir anahtarın yedek ile saklanacağı " +"\"Anahtar Depoda\", parola korumalı bir anahtarın yedek ile saklanacağı " "anlamına gelir." #: plinth/modules/backups/forms.py:122 msgid "Key in Repository" -msgstr "Depodaki Anahtar" +msgstr "Anahtar Depoda" #: plinth/modules/backups/forms.py:122 plinth/modules/searx/forms.py:15 msgid "None" @@ -238,7 +238,7 @@ msgstr "Parola; Yalnızca şifreleme kullanırken gerekli." #: plinth/modules/backups/forms.py:128 msgid "Confirm Passphrase" -msgstr "Parolayı Onayla" +msgstr "Parolayı Onaylayın" #: plinth/modules/backups/forms.py:128 msgid "Repeat the passphrase." @@ -273,7 +273,7 @@ msgstr "" #: plinth/modules/backups/forms.py:193 msgid "SSH server password" -msgstr "SSH sunucu parolası" +msgstr "SSH Sunucu Parolası" #: plinth/modules/backups/forms.py:194 msgid "" @@ -391,7 +391,7 @@ msgstr "Bu arşiv kalıcı olarak silinsin mi?" #: plinth/modules/backups/templates/backups_delete.html:19 msgid "Time" -msgstr "Saat" +msgstr "Zaman" #: plinth/modules/backups/templates/backups_delete.html:36 #, python-format @@ -1343,8 +1343,8 @@ msgid "" "24h), it may be hard for others to find you on the Internet. This will " "prevent others from finding services which are provided by this {box_name}." msgstr "" -"Internet sağlayıcınız IP adresinizi düzenli olarak değiştirirse (yani her 24 " -"saatte bir), başkalarının sizi Internet'te bulması zor olabilir. Bu, " +"Eğer Internet sağlayıcınız IP adresinizi düzenli olarak değiştiriyorsa (yani " +"her 24 saatte bir), başkalarının sizi Internet'te bulması zor olabilir. Bu, " "başkalarının bu {box_name} tarafından sağlanan hizmetleri bulmasını " "engelleyecektir." @@ -2403,7 +2403,7 @@ msgid "" "For this, your browser, preferably a Tor Browser, needs to be configured for " "a proxy." msgstr "" -"I2P, Interne'te ve gizli hizmetlerde (eep siteleri) isimsiz olarak " +"I2P, Internet'te ve gizli hizmetlerde (eep siteleri) isimsiz olarak " "gezinmenize izin verir. Bunun için tarayıcınızın, tercihen bir Tor " "Tarayıcı'nın bir proksi için yapılandırılması gerekir." @@ -5711,7 +5711,7 @@ msgstr "Yazılım Kurulum Anlık Görüntüleri" msgid "Enable or disable snapshots before and after software installation" msgstr "" "Yazılım kurulumundan önce ve sonra anlık görüntüleri etkinleştirin veya " -"etkisizleştirin" +"etkisizleştirin." #: plinth/modules/snapshot/forms.py:32 msgid "Hourly Snapshots Limit" @@ -5719,7 +5719,7 @@ msgstr "Saatlik Anlık Görüntü Sınırı" #: plinth/modules/snapshot/forms.py:33 msgid "Keep a maximum of this many hourly snapshots." -msgstr "Bu birçok saatlik anlık görüntüyü en fazlada tutun." +msgstr "En fazla bu kadar tutulacak saatlik anlık görüntü." #: plinth/modules/snapshot/forms.py:36 msgid "Daily Snapshots Limit" @@ -5727,7 +5727,7 @@ msgstr "Günlük Anlık Görüntü Sınırı" #: plinth/modules/snapshot/forms.py:37 msgid "Keep a maximum of this many daily snapshots." -msgstr "Bu birçok günlük anlık görüntüyü en fazlada tutun." +msgstr "En fazla bu kadar tutulacak günlük anlık görüntü." #: plinth/modules/snapshot/forms.py:40 msgid "Weekly Snapshots Limit" @@ -5735,7 +5735,7 @@ msgstr "Haftalık Anlık Görüntü Sınırı" #: plinth/modules/snapshot/forms.py:41 msgid "Keep a maximum of this many weekly snapshots." -msgstr "Bu birçok haftalık anlık görüntüyü en fazlada tutun." +msgstr "En fazla bu kadar tutulacak haftalık anlık görüntü." #: plinth/modules/snapshot/forms.py:44 msgid "Monthly Snapshots Limit" @@ -5743,7 +5743,7 @@ msgstr "Aylık Anlık Görüntü Sınırı" #: plinth/modules/snapshot/forms.py:45 msgid "Keep a maximum of this many monthly snapshots." -msgstr "Bu birçok aylık anlık görüntüyü en fazlada tutun." +msgstr "En fazla bu kadar tutulacak aylık anlık görüntü." #: plinth/modules/snapshot/forms.py:48 msgid "Yearly Snapshots Limit" @@ -5754,8 +5754,8 @@ msgid "" "Keep a maximum of this many yearly snapshots. The default value is 0 (keep " "no yearly snapshot)." msgstr "" -"Bu birçok yıllık anlık görüntüyü en fazlada tutun. Varsayılan değer 0'dır (" -"yıllık anlık görüntü tutmayın)." +"En fazla bu kadar tutulacak yıllık anlık görüntü. Varsayılan değer 0'dır (" +"yıllık anlık görüntü tutma)." #: plinth/modules/snapshot/templates/snapshot_delete_selected.html:12 msgid "Delete the following snapshots permanently?" @@ -6308,7 +6308,7 @@ msgstr "Tor Köprüsü Aktarımı" #: plinth/modules/tor/__init__.py:110 msgid "Tor relay port available" -msgstr "Tor aktarımı bağlantı noktası mevcut" +msgstr "Tor aktarımı bağlantı noktası kullanılabilir" #: plinth/modules/tor/__init__.py:120 msgid "Obfs3 transport registered" @@ -6321,12 +6321,12 @@ msgstr "Obfs4 taşıma kayıtlı" #: plinth/modules/tor/__init__.py:199 #, python-brace-format msgid "Access URL {url} on tcp{kind} via Tor" -msgstr "Tor aracılığıyla tcp{kind} üzerindeki erişim URL'si {url}" +msgstr "Tor aracılığıyla tcp{kind} üzerinde erişim URL'si {url}" #: plinth/modules/tor/__init__.py:210 #, python-brace-format msgid "Confirm Tor usage at {url} on tcp{kind}" -msgstr "Tcp{kind} üzerinde {url} adresinde Tor kullanımını onayla" +msgstr "Tcp{kind} üzerinde {url} adresinde Tor kullanımını onaylama" #: plinth/modules/tor/forms.py:31 msgid "" @@ -6474,7 +6474,7 @@ msgstr "SOCKS" msgid "A Tor SOCKS port is available on your %(box_name)s on TCP port 9050." msgstr "" "9050 nolu TCP bağlantı noktası üzerindeki %(box_name)s cihazınızda bir Tor " -"SOCKS bağlantı noktası mevcuttur." +"SOCKS bağlantı noktası kullanılabilir." #: plinth/modules/tor/views.py:137 plinth/views.py:216 msgid "Setting unchanged" From 3aca199aab198c99cf3a605f65665973a13f3c64 Mon Sep 17 00:00:00 2001 From: Petter Reinholdtsen Date: Fri, 2 Oct 2020 00:23:20 +0000 Subject: [PATCH 27/46] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegian?= =?UTF-8?q?=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 82.8% (1094 of 1321 strings) --- plinth/locale/nb/LC_MESSAGES/django.po | 42 +++++++++++--------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/plinth/locale/nb/LC_MESSAGES/django.po b/plinth/locale/nb/LC_MESSAGES/django.po index e81d8a45f..773d92688 100644 --- a/plinth/locale/nb/LC_MESSAGES/django.po +++ b/plinth/locale/nb/LC_MESSAGES/django.po @@ -16,7 +16,7 @@ msgstr "" "Project-Id-Version: FreedomBox UI\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-15 16:55-0400\n" -"PO-Revision-Date: 2020-09-22 09:39+0000\n" +"PO-Revision-Date: 2020-10-03 00:41+0000\n" "Last-Translator: Petter Reinholdtsen \n" "Language-Team: Norwegian Bokmål \n" @@ -158,10 +158,8 @@ msgid "{app} (No data to backup)" msgstr "{app} (Ingen data å sikkerhetskopiere)" #: plinth/modules/backups/forms.py:50 -#, fuzzy -#| msgid "Create Repository" msgid "Repository" -msgstr "Opprett depot" +msgstr "Depot" #: plinth/modules/backups/forms.py:52 #: plinth/modules/backups/templates/backups_delete.html:18 @@ -172,10 +170,8 @@ msgid "Name" msgstr "Navn" #: plinth/modules/backups/forms.py:53 -#, fuzzy -#| msgid "Name for new backup archive." msgid "(Optional) Set a name for this backup archive" -msgstr "Navn på nytt sikkerhetskopiarkiv." +msgstr "(Valgfritt) Sett navn på dette sikkerhetskopiarkivet" #: plinth/modules/backups/forms.py:56 msgid "Included apps" @@ -233,10 +229,8 @@ msgstr "" "sikkerhetskopien." #: plinth/modules/backups/forms.py:122 -#, fuzzy -#| msgid "Create Repository" msgid "Key in Repository" -msgstr "Opprett depot" +msgstr "Nøkkel i depot" #: plinth/modules/backups/forms.py:122 plinth/modules/searx/forms.py:15 msgid "None" @@ -479,15 +473,7 @@ msgid "Restore data from" msgstr "Gjenopprett data fra" #: plinth/modules/backups/templates/backups_upload.html:17 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Upload a backup file downloaded from another %(box_name)s to " -#| "restore is\n" -#| " contents. You can choose the apps you wish to restore after " -#| "uploading a\n" -#| " backup file.\n" -#| " " +#, python-format msgid "" "\n" " Upload a backup file downloaded from another %(box_name)s to restore " @@ -499,7 +485,7 @@ msgid "" msgstr "" "\n" " Last opp en sikkerhetskopi hentet fra en annen %(box_name)s for å " -"tilbakeføre innholdet.\n" +"tilbakeføre dens innhold.\n" " Du kan velge programmene du ønsker å importere etter å ha lastet opp " "en sikkerhetskopifil.\n" " " @@ -587,9 +573,8 @@ msgid "Restore from uploaded file" msgstr "Gjenopprett fra opplastet fil" #: plinth/modules/backups/views.py:238 -#, fuzzy msgid "No additional disks available to add a repository." -msgstr "Ingen ytterligere disker å legge til i pakkebrønn." +msgstr "Ingen ytterligere disker tilgjengelig for å opprette et depot." #: plinth/modules/backups/views.py:246 msgid "Create backup repository" @@ -666,6 +651,10 @@ msgid "" "password, you can share it with the users who should have the associated " "permissions." msgstr "" +"bepastery har ikke brukernavn for å logge in. Den bruker kun passord. Et " +"sett med tillatelser kan velges for hvert passord. Når du har laget et " +"passord så kan du dele det med brukerne som skal ha de tilhørende " +"rettighetene." #: plinth/modules/bepasty/__init__.py:33 msgid "" @@ -674,6 +663,10 @@ msgid "" "revoke access for a single person or group, by removing their password from " "the list." msgstr "" +"Du kan også opprette flere passord med de samme rettighetene, og spre dem " +"til ulike folk eller grupper. Dette vil la deg trekke tilbake tilgangen " +"senere for en enkelt person eller gruppe, ved å fjerne passordet deres fra " +"listen." #: plinth/modules/bepasty/__init__.py:42 plinth/modules/bepasty/__init__.py:51 msgid "Read a file, if a web link to the file is available" @@ -7716,10 +7709,9 @@ msgid "From Router/WAN Ports" msgstr "Fra ruter/WAN-porter" #: plinth/templates/port-forwarding-info.html:38 -#, fuzzy, python-format -#| msgid "%(box_name)s Setup" +#, python-format msgid "To %(box_name)s Ports" -msgstr "Oppsett av %(box_name)s" +msgstr "Til portene for %(box_name)s" #: plinth/templates/setup.html:24 msgid "Install this application?" From 00683762c8612b5e89156b53db936f9557b0fc04 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 21:45:28 -0700 Subject: [PATCH 28/46] coturn: Don't handle certificates if not installed Tests: - Without these changes, with Coturn not-installed, change the domain name. Notice that certificate events for Coturn fails due to missing domain information. - With these changes, with Coturn not-installed, change the domain name. Notice that certificate events for Coturn don't result in any actions. - With these changes, when Coturn is installed, certificate is properly setup for a domain. - With these changes, with Coturn installed, change the domain name. Notice that certificate events for Coturn succeed. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/coturn/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plinth/modules/coturn/__init__.py b/plinth/modules/coturn/__init__.py index 4138dff5e..5e74ee7e0 100644 --- a/plinth/modules/coturn/__init__.py +++ b/plinth/modules/coturn/__init__.py @@ -115,8 +115,8 @@ def get_domain(): def get_domains(): """Return a list with the configured domains.""" - setup_helper = globals()['setup_helper'] - if setup_helper.get_state() == 'needs-setup': + # If not installed, return empty. But work while installing too. + if not pathlib.Path('/etc/coturn/freedombox.conf').exists(): return [] domain = get_domain() From 98f9d59ef1a3ed5804847b258b96b70f6721d712 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 10 Sep 2020 21:48:45 -0700 Subject: [PATCH 29/46] quassel: Don't handle certificates if not installed Tests: - Without these changes, with Quassel not-installed, change the domain name. Notice that certificate events for Quassel fails due to missing domain information. - With these changes, with Quassel not-installed, change the domain name. Notice that certificate events for Quassel don't result in any actions. - With these changes, when Quassel is installed, certificate is properly setup for a domain. - With these changes, with Quassel installed, change the domain name. Notice that certificate events for Quassel succeed. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/quassel/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plinth/modules/quassel/__init__.py b/plinth/modules/quassel/__init__.py index 8b39c6668..43555e679 100644 --- a/plinth/modules/quassel/__init__.py +++ b/plinth/modules/quassel/__init__.py @@ -134,8 +134,8 @@ def get_domain(): def get_domains(): """Return a list with the configured domain for quassel.""" - setup_helper = globals()['setup_helper'] - if setup_helper.get_state() == 'needs-setup': + # If not installed, return empty. But work while installing too. + if not pathlib.Path('/var/lib/quassel/').exists(): return [] domain = get_domain() From 6b61ca2f188b7b660c8e3bb243a70435862ed2f1 Mon Sep 17 00:00:00 2001 From: Veiko Aasa Date: Fri, 28 Aug 2020 11:47:04 +0300 Subject: [PATCH 30/46] ssh: action script: Require user credentials when editing ssh keys This change prevents the plinth user to set the ssh-keys without knowing the user password. - Debian: added new dependency python3-pampy to authenticate users. - Added additional required parameter --auth-user to the 'actions/ssh set-keys' command. A password should be provided through STDIN. Tests performed: - running 'actions/ssh set-keys' with empty or wrong admin credentials fails. - running 'actions/ssh set-keys' with correct admin credentials succeeds. - running 'actions/ssh set-keys' with correct non-admin credentials succeeds if the --username is the same user. - running 'actions/ssh set-keys' with correct non-admin credentials fails if the --username is a different user. Signed-off-by: Veiko Aasa Reviewed-by: Sunil Mohan Adapa --- actions/ssh | 31 ++++++++++++++++++++++++++++++- debian/control | 2 ++ plinth/utils.py | 7 +++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/actions/ssh b/actions/ssh index d2aee9319..cc777170d 100755 --- a/actions/ssh +++ b/actions/ssh @@ -5,6 +5,7 @@ Configuration helper for SSH server. """ import argparse +import grp import os import pwd import shutil @@ -14,7 +15,7 @@ import sys import augeas -from plinth import action_utils +from plinth import action_utils, utils def parse_arguments(): @@ -32,6 +33,7 @@ def parse_arguments(): help='Set SSH authorized keys') set_keys.add_argument('--username', required=True, type=_managed_user) set_keys.add_argument('--keys', required=True) + set_keys.add_argument('--auth-user', required=True) subparsers.add_parser('get-password-config', help='Get SSH password auth configuration') @@ -44,6 +46,24 @@ def parse_arguments(): return parser.parse_args() +def _validate_user(username, must_be_admin=True): + """Validate a user.""" + if must_be_admin: + try: + admins = grp.getgrnam('admin').gr_mem + except KeyError: + admins = [] + + if username not in admins: + msg = '"{}" is not authorized to perform this action'.format( + username) + raise argparse.ArgumentTypeError(msg) + + password = _read_password() + if not utils.is_authenticated_user(username, password): + raise argparse.ArgumentTypeError("Invalid credentials") + + def _managed_user(username): """Raise an error if the user is root.""" if pwd.getpwnam(username).pw_gid == 0: @@ -52,6 +72,11 @@ def _managed_user(username): return username +def _read_password(): + """Read the password from stdin.""" + return ''.join(sys.stdin) + + def subcommand_setup(arguments): """Setup Open SSH server. @@ -91,6 +116,10 @@ def subcommand_get_keys(arguments): def subcommand_set_keys(arguments): """Set SSH authorized keys.""" user = arguments.username + auth_user = arguments.auth_user + + must_be_admin = user != auth_user + _validate_user(auth_user, must_be_admin=must_be_admin) ssh_folder = os.path.join(get_user_homedir(user), '.ssh') key_file_path = os.path.join(ssh_folder, 'authorized_keys') diff --git a/debian/control b/debian/control index c3c10670d..f90820a40 100644 --- a/debian/control +++ b/debian/control @@ -32,6 +32,7 @@ Build-Depends: python3-flake8, python3-gi, python3-markupsafe, + python3-pampy, python3-paramiko, python3-psutil, python3-pytest, @@ -104,6 +105,7 @@ Depends: python3-django-stronghold, python3-gi, python3-markupsafe, + python3-pampy, python3-paramiko, python3-psutil, python3-requests, diff --git a/plinth/utils.py b/plinth/utils.py index 80ca0e336..a2dfcb6be 100644 --- a/plinth/utils.py +++ b/plinth/utils.py @@ -12,6 +12,7 @@ import string from distutils.version import LooseVersion import markupsafe +import pam import ruamel.yaml from django.utils.functional import lazy @@ -156,3 +157,9 @@ def is_axes_old(): return False return LooseVersion(version) < LooseVersion('5.0') + + +def is_authenticated_user(username, password): + """Return true if the user authentication succeeds.""" + pam_authenticator = pam.pam() + return bool(pam_authenticator.authenticate(username, password)) From dfaf009d3ce01ec14ac693c3a247268c85118e90 Mon Sep 17 00:00:00 2001 From: Veiko Aasa Date: Fri, 28 Aug 2020 11:48:30 +0300 Subject: [PATCH 31/46] users: Require admin credentials when creating or editing a user This change prevents the plinth user to become a superuser without knowing an admin password. Users module and action script: - User credentials are now required for the subcommands: create-user, set-user-password, add-user-to-group (if the group is admin), remove-user-from-group (if the group is admin), set-user-status, remove-user (if the removed user is the last admin user. Note: the web UI doesn't allow to delete last admin user). - subcommand remove-users requires authentication if the user is last admin user. Password must be provided through standard input. - subcommand remove-group: do not allow to remove group 'admin' - User credentials must be provided using the argument --auth-user and a passsword must be provided through standard input. - If there are no users in the admin group, no admin password is required and if the --auth-user argument is required, it can be an empty string. Users web UI: - An admin needs to enter current password to create and edit a user and to change user's password. - Show more detailed error text on exceptions when submitting forms. - Show page title on the edit and create user pages. Users unit and functional tests: - Added a configuration parameters to the pytest configuration file to set current admin user/password. - Added a configuration parameter 'ssh_port' to the functional tests. You can overwrite this with the FREEDOMBOX_SSH_PORT environment variable. Modified HACKING.md accordingly. - Added an unit test: - test changing the password as a non-admin user. - test invalid admin password input. - test that removing the admin group fails. - Capture stdout and stderr in the unit tests when calling an action script to be able to see more info on exceptions. - Added functional tests for setting ssh keys and changing passwords for admin and non-admin users. - Added a functional test for setting a user as active/inactive. Changes during review [sunil]: - Move uncommon functional step definitions to users module from global. This is keep the common functional step definitions to minimal level and promote when needed. - Minor styling changes, flake8 fixes. - Don't require pampy module when running non-admin tests. This allows tests to be run from outside the container on the host machine without python3-pam installed. - Call the confirm password field 'Authorization Password'. This avoid confusion with a very common field 'Confirm Password' which essentially means retype your password to ensure you didn't get it wrong. Add label explaining why the field exists. - Don't hard-code /tmp path in test_actions.py. Use tmp_path_factory fixture provided by pytest. - Remove unused _get_password_hash() from actions/users. - Undo splitting ldapgid output before parsing. It does not seem correct and could introduce problems when field values contain spaces. Tests performed: - No failed unit tests (run with and without sudo). - All 'users' functional tests pass. - Creating an admin user during the first boot wizard succeeds. - Creating a user using the web UI with an empty or wrong admin password fails and with the correct admin password succeeds. - Editing a user using the web UI with an empty or wrong admin password fails and with the correct admin password succeeds. - Changing user's password using the web UI with an empty or wrong admin password fails and with the correct admin password succeeds. - Above mentioned user action script commands can't be run without correct credentials. - Adding the daemon user to the freedombox-share group succeeds when installing certain apps (deluge, mldonkey, syncthing, transmission). Signed-off-by: Veiko Aasa [sunil: Move uncommon functional step definitions to users module from global] [sunil: Minor styling changes, flake8 fixes] [sunil: Don't require pampy module when running non-admin tests] [sunil: Call the confirm password field 'Authorization Password'] [sunil: Don't hard-code /tmp path in test_actions.py] [sunil: Remove unused _get_password_hash() from actions/users] [sunil: Undo splitting ldapgid output before parsing] Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- HACKING.md | 2 +- actions/users | 176 ++++++++++-- plinth/modules/users/forms.py | 142 +++++++--- .../modules/users/templates/users_create.html | 2 + .../modules/users/templates/users_update.html | 6 +- plinth/modules/users/tests/test_actions.py | 129 +++++++-- plinth/modules/users/tests/test_functional.py | 268 +++++++++++++++++- plinth/modules/users/tests/test_views.py | 100 ++++++- plinth/modules/users/tests/users.feature | 67 ++++- plinth/modules/users/views.py | 1 + plinth/templates/base.html | 8 +- plinth/tests/config.py | 6 + plinth/tests/functional/__init__.py | 12 +- plinth/tests/functional/config.ini | 1 + plinth/utils.py | 3 +- 15 files changed, 796 insertions(+), 127 deletions(-) diff --git a/HACKING.md b/HACKING.md index d4c1a9152..5db6eb580 100644 --- a/HACKING.md +++ b/HACKING.md @@ -349,7 +349,7 @@ tests will create the required user using FreedomBox's first boot process. **When inside a container/VM you will need to target the guest** ```bash -guest$ export FREEDOMBOX_URL=https://localhost FREEDOMBOX_SAMBA_PORT=445 +guest$ export FREEDOMBOX_URL=https://localhost FREEDOMBOX_SSH_PORT=22 FREEDOMBOX_SAMBA_PORT=445 ``` You will be running `py.test-3`. diff --git a/actions/users b/actions/users index bbc26b136..fb91987ef 100755 --- a/actions/users +++ b/actions/users @@ -14,8 +14,9 @@ import sys import augeas -from plinth import action_utils +from plinth import action_utils, utils +INPUT_LINES = None ACCESS_CONF = '/etc/security/access.conf' LDAPSCRIPTS_CONF = '/etc/ldapscripts/freedombox-ldapscripts.conf' @@ -31,10 +32,13 @@ def parse_arguments(): subparser = subparsers.add_parser('create-user', help='Create an LDAP user') subparser.add_argument('username', help='Name of the LDAP user to create') + subparser.add_argument('--auth-user', required=True) subparser = subparsers.add_parser('remove-user', help='Delete an LDAP user') - subparser.add_argument('username', help='Name of the LDAP user to delete') + subparser.add_argument( + 'username', help='Name of the LDAP user to delete. If the username is ' + 'the last admin user, a password should be provided through STDIN.') subparser = subparsers.add_parser('rename-user', help='Rename an LDAP user') @@ -45,6 +49,7 @@ def parse_arguments(): help='Set the password of an LDAP user') subparser.add_argument( 'username', help='Name of the LDAP user to set the password for') + subparser.add_argument('--auth-user', required=True) subparser = subparsers.add_parser('create-group', help='Create an LDAP group') @@ -65,12 +70,14 @@ def parse_arguments(): help='Add an LDAP user to an LDAP group') subparser.add_argument('username', help='LDAP user to add to group') subparser.add_argument('groupname', help='LDAP group to add the user to') + subparser.add_argument('--auth-user', required=False) subparser = subparsers.add_parser('set-user-status', help='Set user as active or inactive') subparser.add_argument('username', help='User to change status') subparser.add_argument('status', choices=['active', 'inactive'], help='New status of the user') + subparser.add_argument('--auth-user', required=True) subparser = subparsers.add_parser( 'remove-user-from-group', @@ -78,6 +85,7 @@ def parse_arguments(): subparser.add_argument('username', help='LDAP user to remove from group') subparser.add_argument('groupname', help='LDAP group to remove the user from') + subparser.add_argument('--auth-user', required=False) help_get_group_users = 'Get the list of all users in an LDAP group' subparser = subparsers.add_parser('get-group-users', @@ -90,6 +98,38 @@ def parse_arguments(): return parser.parse_args() +def validate_user(username, must_be_admin=True): + """Validate a user.""" + if must_be_admin: + admins = get_admin_users() + + if not admins: + # any user is valid + return + + if not username: + msg = 'Argument --auth-user is required' + raise argparse.ArgumentTypeError(msg) + + if username not in admins: + msg = '"{}" is not authorized to perform this action'.format( + username) + raise argparse.ArgumentTypeError(msg) + + if not username: + msg = 'Argument --auth-user is required' + raise argparse.ArgumentTypeError(msg) + + validate_password(username) + + +def validate_password(username): + """Raise an error if the user password is invalid.""" + password = read_password(last=True) + if not utils.is_authenticated_user(username, password): + raise argparse.ArgumentTypeError("Invalid credentials") + + def subcommand_first_setup(_): """Perform initial setup of LDAP.""" # Avoid reconfiguration of slapd during module upgrades, because @@ -243,18 +283,36 @@ def disconnect_samba_user(username): raise -def read_password(): - """Read the password from stdin.""" - return ''.join(sys.stdin) +def get_input_lines(): + """Return list of input lines from stdin.""" + global INPUT_LINES + if INPUT_LINES is None: + INPUT_LINES = [line.strip() for line in sys.stdin] + return INPUT_LINES + + +def read_password(last=False): + """Return the password. + + Set last=True to read password from last line of the input. + + """ + line = -1 if last else 0 + return get_input_lines()[line] def subcommand_create_user(arguments): """Create an LDAP user, set password and flush cache.""" - _run(['ldapadduser', arguments.username, 'users']) + username = arguments.username + auth_user = arguments.auth_user + + validate_user(auth_user) + + _run(['ldapadduser', username, 'users']) password = read_password() - set_user_password(arguments.username, password) + set_user_password(username, password) flush_cache() - set_samba_user(arguments.username, password) + set_samba_user(username, password) def subcommand_remove_user(arguments): @@ -262,6 +320,10 @@ def subcommand_remove_user(arguments): username = arguments.username groups = get_user_groups(username) + # require authentication if the user is last admin user + if get_group_users('admin') == [username]: + validate_password(username) + delete_samba_user(username) for group in groups: @@ -314,9 +376,58 @@ def set_samba_user(username, password): def subcommand_set_user_password(arguments): """Set a user's password.""" + username = arguments.username + auth_user = arguments.auth_user + + must_be_admin = username != auth_user + validate_user(auth_user, must_be_admin=must_be_admin) + password = read_password() - set_user_password(arguments.username, password) - set_samba_user(arguments.username, password) + set_user_password(username, password) + set_samba_user(username, password) + + +def get_admin_users(): + """Returns list of members in the admin group. + + Raises an error if the slapd service is not running. + + """ + admin_users = [] + + try: + output = subprocess.check_output([ + 'ldapsearch', '-LLL', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///', + '-o', 'ldif-wrap=no', '-s', 'base', '-b', + 'cn=admin,ou=groups,dc=thisbox', 'memberUid' + ]).decode() + except subprocess.CalledProcessError as error: + if error.returncode == 32: + # no entries found + return [] + raise + + for line in output.splitlines(): + if line.startswith('memberUid: '): + user = line.split('memberUid: ', 1)[1].strip() + admin_users.append(user) + + return admin_users + + +def get_group_users(groupname): + """Returns list of members in the group.""" + try: + process = _run(['ldapgid', '-P', groupname], stdout=subprocess.PIPE) + except subprocess.CalledProcessError: + return [] # Group does not exist + + output = process.stdout.decode() + # extract users from output, example: 'admin:*:10001:user1,user2' + users = output.rsplit(':')[-1].strip().split(',') + if users == ['']: + return [] + return users def get_user_groups(username): @@ -371,10 +482,14 @@ def subcommand_create_group(arguments): def subcommand_remove_group(arguments): """Remove an LDAP group.""" - if group_exists(arguments.groupname): - _run(['ldapdeletegroup', arguments.groupname]) + groupname = arguments.groupname - flush_cache() + if groupname == 'admin': + raise argparse.ArgumentTypeError("Can't remove the group 'admin'") + + if group_exists(groupname): + _run(['ldapdeletegroup', groupname]) + flush_cache() def add_user_to_group(username, groupname): @@ -385,7 +500,12 @@ def add_user_to_group(username, groupname): def subcommand_add_user_to_group(arguments): """Add an LDAP user to an LDAP group.""" - add_user_to_group(arguments.username, arguments.groupname) + groupname = arguments.groupname + + if groupname == 'admin': + validate_user(arguments.auth_user) + + add_user_to_group(arguments.username, groupname) flush_cache() @@ -396,31 +516,31 @@ def remove_user_from_group(username, groupname): def subcommand_remove_user_from_group(arguments): """Remove an LDAP user from an LDAP group.""" - remove_user_from_group(arguments.username, arguments.groupname) + username = arguments.username + groupname = arguments.groupname + + if groupname == 'admin': + validate_user(arguments.auth_user) + + remove_user_from_group(username, groupname) flush_cache() - if arguments.groupname == 'freedombox-share': - disconnect_samba_user(arguments.username) + if groupname == 'freedombox-share': + disconnect_samba_user(username) def subcommand_get_group_users(arguments): """Get the list of users of an LDAP group.""" - try: - process = _run(['ldapgid', '-P', arguments.groupname], - stdout=subprocess.PIPE) - except subprocess.CalledProcessError: - return # Group does not exist, return empty list - - output = process.stdout.decode() - users = output.rsplit(':')[-1] - if users: - for user in users.strip().split(','): - print(user) + for user in get_group_users(arguments.groupname): + print(user) def subcommand_set_user_status(arguments): """Set the status of the user.""" username = arguments.username status = arguments.status + auth_user = arguments.auth_user + + validate_user(auth_user) if status == 'active': flag = '-e' diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py index bebb77b74..7f6d51065 100644 --- a/plinth/modules/users/forms.py +++ b/plinth/modules/users/forms.py @@ -6,6 +6,7 @@ import re from django import forms from django.contrib import auth, messages from django.contrib.auth.forms import SetPasswordForm, UserCreationForm +from django.contrib.auth.hashers import check_password from django.contrib.auth.models import Group, User from django.core import validators from django.core.exceptions import ValidationError @@ -71,9 +72,27 @@ USERNAME_FIELD = forms.CharField( 'letters, digits and @/./-/_ only.')) +class PasswordConfirmForm(forms.Form): + """Password confirmation form.""" + confirm_password = forms.CharField( + widget=forms.PasswordInput, + label=ugettext_lazy('Authorization Password'), help_text=ugettext_lazy( + 'Enter your current password to authorize account modifications.')) + + def clean_confirm_password(self): + """Check that current user's password matches.""" + confirm_password = self.cleaned_data['confirm_password'] + password_matches = check_password(confirm_password, + self.request.user.password) + if not password_matches: + raise ValidationError(_('Invalid password.'), code='invalid') + + return confirm_password + + class CreateUserForm(ValidNewUsernameCheckMixin, plinth.forms.LanguageSelectionFormMixin, - UserCreationForm): + PasswordConfirmForm, UserCreationForm): """Custom user create form. Include options to add user to groups. @@ -95,7 +114,8 @@ class CreateUserForm(ValidNewUsernameCheckMixin, class Meta(UserCreationForm.Meta): """Metadata to control automatic form building.""" - fields = ('username', 'password1', 'password2', 'groups', 'language') + fields = ('username', 'password1', 'password2', 'groups', 'language', + 'confirm_password') def __init__(self, request, *args, **kwargs): """Initialize the form with extra request argument.""" @@ -104,7 +124,7 @@ class CreateUserForm(ValidNewUsernameCheckMixin, self.fields['username'].widget.attrs.update({ 'autofocus': 'autofocus', 'autocapitalize': 'none', - 'autocomplete': 'username' + 'autocomplete': 'username', }) def save(self, commit=True): @@ -114,26 +134,34 @@ class CreateUserForm(ValidNewUsernameCheckMixin, if commit: user.userprofile.language = self.cleaned_data['language'] user.userprofile.save() + auth_username = self.request.user.username + confirm_password = self.cleaned_data['confirm_password'] + process_input = '{0}\n{1}'.format(self.cleaned_data['password1'], + confirm_password).encode() try: - actions.superuser_run( - 'users', - ['create-user', user.get_username()], - input=self.cleaned_data['password1'].encode()) - except ActionError: - messages.error(self.request, _('Creating LDAP user failed.')) + actions.superuser_run('users', [ + 'create-user', + user.get_username(), '--auth-user', auth_username + ], input=process_input) + except ActionError as error: + messages.error( + self.request, + _('Creating LDAP user failed: {error}'.format( + error=error))) for group in self.cleaned_data['groups']: try: - actions.superuser_run( - 'users', - ['add-user-to-group', - user.get_username(), group]) - except ActionError: + actions.superuser_run('users', [ + 'add-user-to-group', + user.get_username(), group, '--auth-user', + auth_username + ], input=confirm_password.encode()) + except ActionError as error: messages.error( self.request, - _('Failed to add new user to {group} group.').format( - group=group)) + _('Failed to add new user to {group} group: {error}'). + format(group=group, error=error)) group_object, created = Group.objects.get_or_create(name=group) group_object.user_set.add(user) @@ -141,7 +169,7 @@ class CreateUserForm(ValidNewUsernameCheckMixin, return user -class UserUpdateForm(ValidNewUsernameCheckMixin, +class UserUpdateForm(ValidNewUsernameCheckMixin, PasswordConfirmForm, plinth.forms.LanguageSelectionFormMixin, forms.ModelForm): """When user info is changed, also updates LDAP user.""" username = USERNAME_FIELD @@ -158,7 +186,8 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, class Meta: """Metadata to control automatic form building.""" - fields = ('username', 'groups', 'ssh_keys', 'language', 'is_active') + fields = ('username', 'groups', 'ssh_keys', 'language', 'is_active', + 'confirm_password') model = User widgets = { 'groups': plinth.forms.CheckboxSelectMultipleWithReadOnly(), @@ -210,9 +239,11 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, user = super(UserUpdateForm, self).save(commit=False) # Profile is auto saved with user object user.userprofile.language = self.cleaned_data['language'] + auth_username = self.request.user.username + confirm_password = self.cleaned_data['confirm_password'] # If user is updating their own profile then only translate the pages - if self.username == self.request.user.username: + if self.username == auth_username: set_language(self.request, None, user.userprofile.language) if commit: @@ -240,8 +271,9 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, try: actions.superuser_run('users', [ 'remove-user-from-group', - user.get_username(), old_group - ]) + user.get_username(), old_group, '--auth-user', + auth_username + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Failed to remove user from group.')) @@ -251,18 +283,23 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, try: actions.superuser_run('users', [ 'add-user-to-group', - user.get_username(), new_group - ]) + user.get_username(), new_group, '--auth-user', + auth_username + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Failed to add user to group.')) try: actions.superuser_run('ssh', [ - 'set-keys', '--username', - user.get_username(), '--keys', - self.cleaned_data['ssh_keys'].strip() - ]) + 'set-keys', + '--username', + user.get_username(), + '--keys', + self.cleaned_data['ssh_keys'].strip(), + '--auth-user', + auth_username, + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Unable to set SSH keys.')) @@ -273,10 +310,13 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, else: status = 'inactive' try: - actions.superuser_run( - 'users', - ['set-user-status', - user.get_username(), status]) + actions.superuser_run('users', [ + 'set-user-status', + user.get_username(), + status, + '--auth-user', + auth_username, + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Failed to change user status.')) @@ -297,7 +337,7 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, return cleaned_data -class UserChangePasswordForm(SetPasswordForm): +class UserChangePasswordForm(PasswordConfirmForm, SetPasswordForm): """Custom form that also updates password for LDAP users.""" def __init__(self, request, *args, **kwargs): @@ -310,12 +350,16 @@ class UserChangePasswordForm(SetPasswordForm): def save(self, commit=True): """Save the user model and change LDAP password as well.""" user = super(UserChangePasswordForm, self).save(commit) + auth_username = self.request.user.username if commit: + process_input = '{0}\n{1}'.format( + self.cleaned_data['new_password1'], + self.cleaned_data['confirm_password']).encode() try: - actions.superuser_run( - 'users', ['set-user-password', - user.get_username()], - input=self.cleaned_data['new_password1'].encode()) + actions.superuser_run('users', [ + 'set-user-password', + user.get_username(), '--auth-user', auth_username + ], input=process_input) except ActionError: messages.error(self.request, _('Changing LDAP user password failed.')) @@ -341,19 +385,25 @@ class FirstBootForm(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm): try: actions.superuser_run( 'users', - ['create-user', user.get_username()], + ['create-user', + user.get_username(), '--auth-user', ''], input=self.cleaned_data['password1'].encode()) - except ActionError: - messages.error(self.request, _('Creating LDAP user failed.')) + except ActionError as error: + messages.error( + self.request, + _('Creating LDAP user failed: {error}'.format( + error=error))) try: actions.superuser_run( 'users', ['add-user-to-group', user.get_username(), 'admin']) - except ActionError: - messages.error(self.request, - _('Failed to add new user to admin group.')) + except ActionError as error: + messages.error( + self.request, + _('Failed to add new user to admin group: {error}'.format( + error=error))) # Create initial Django groups for group_choice in UsersAndGroups.get_group_choices(): @@ -368,9 +418,11 @@ class FirstBootForm(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm): # Restrict console login to users in admin or sudo group try: set_restricted_access(True) - except Exception: - messages.error(self.request, - _('Failed to restrict console access.')) + except Exception as error: + messages.error( + self.request, + _('Failed to restrict console access: {error}'.format( + error=error))) return user diff --git a/plinth/modules/users/templates/users_create.html b/plinth/modules/users/templates/users_create.html index 96a5204bf..53e2dc4da 100644 --- a/plinth/modules/users/templates/users_create.html +++ b/plinth/modules/users/templates/users_create.html @@ -8,6 +8,8 @@ {% block content %} +

{%trans "Create User" %}

+
{% csrf_token %} diff --git a/plinth/modules/users/templates/users_update.html b/plinth/modules/users/templates/users_update.html index f33bb30f6..ecf4ecfbd 100644 --- a/plinth/modules/users/templates/users_update.html +++ b/plinth/modules/users/templates/users_update.html @@ -7,7 +7,11 @@ {% load i18n %} {% block content %} -

{{ object.username }}

+

+ {% blocktrans trimmed with username=object.username %} + Edit User {{ username }} + {% endblocktrans %} +

{% url 'users:change_password' object.username as change_password_url %} diff --git a/plinth/modules/users/tests/test_actions.py b/plinth/modules/users/tests/test_actions.py index 6a1d54645..b88404476 100644 --- a/plinth/modules/users/tests/test_actions.py +++ b/plinth/modules/users/tests/test_actions.py @@ -16,6 +16,7 @@ import pytest from plinth import action_utils from plinth.modules import security +from plinth.tests import config as test_config _cleanup_users = None _cleanup_groups = None @@ -74,8 +75,8 @@ def _try_login_to_ssh(username, password, returncode=0): def _action_file(): """Return the path to the 'users' actions file.""" current_directory = pathlib.Path(__file__).parent - return str( - current_directory / '..' / '..' / '..' / '..' / 'actions' / 'users') + return str(current_directory / '..' / '..' / '..' / '..' / 'actions' / + 'users') @pytest.fixture(name='disable_restricted_access', autouse=True) @@ -111,8 +112,8 @@ def fixture_auto_cleanup_users_groups(needs_root, load_cfg): def _call_action(arguments, **kwargs): """Call the action script.""" - kwargs['stdout'] = kwargs.get('stdout', subprocess.DEVNULL) - kwargs['stderr'] = kwargs.get('stderr', subprocess.DEVNULL) + kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) + kwargs['stderr'] = kwargs.get('stderr', subprocess.PIPE) kwargs['check'] = kwargs.get('check', True) return subprocess.run([_action_file()] + arguments, **kwargs) @@ -120,13 +121,19 @@ def _call_action(arguments, **kwargs): def _create_user(username=None, groups=None): """Call the action script for creating a new user.""" username = username or _random_string() - password = _random_string() + password = username + '_passwd' + admin_user, admin_password = _get_admin_user_password() - _call_action(['create-user', username], input=password.encode()) + process_input = "{0}\n{1}".format(password, admin_password).encode() + _call_action(['create-user', '--auth-user', admin_user, username], + input=process_input) if groups: for group in groups: - _call_action(['add-user-to-group', username, group]) + admin_user, admin_password = _get_admin_user_password() + _call_action([ + 'add-user-to-group', '--auth-user', admin_user, username, group + ], input=admin_password.encode()) if group != 'admin': _cleanup_groups.add(group) @@ -136,29 +143,54 @@ def _create_user(username=None, groups=None): def _delete_user(username): """Utility to delete an LDAP and Samba user""" - _call_action(['remove-user', username]) + process_input = None + if _get_group_users('admin') == [username]: + _, admin_password = _get_admin_user_password() + process_input = admin_password.encode() + _call_action(['remove-user', username], input=process_input) + + +def _get_admin_user_password(): + """Return an admin username and password.""" + + admin_users = _get_group_users('admin') + + if not admin_users: + return ('', '') + + if test_config.admin_username in admin_users: + return (test_config.admin_username, test_config.admin_password) + + return (admin_users[0], admin_users[0] + '_passwd') def _rename_user(old_username, new_username=None): """Rename a user.""" new_username = new_username or _random_string() + _call_action(['rename-user', old_username, new_username]) _cleanup_users.remove(old_username) _cleanup_users.add(new_username) return new_username +def _get_group_users(group): + """Return the list of members in a group.""" + process = _call_action(['get-group-users', group]) + return process.stdout.decode().split() + + def _get_user_groups(username): """Return the list of groups for a user.""" - process = _call_action(['get-user-groups', username], - stdout=subprocess.PIPE) + process = _call_action(['get-user-groups', username]) return process.stdout.decode().split() def _create_group(groupname=None): groupname = groupname or _random_string() _call_action(['create-group', groupname]) - _cleanup_groups.add(groupname) + if groupname != 'admin': + _cleanup_groups.add(groupname) return groupname @@ -181,7 +213,13 @@ def test_change_user_password(): username, old_password = _create_user(groups=['admin']) old_password_hash = _get_password_hash(username) new_password = 'pass $123' - _call_action(['set-user-password', username], input=new_password.encode()) + + admin_user, admin_password = _get_admin_user_password() + + process_input = "{0}\n{1}".format(new_password, admin_password).encode() + _call_action(['set-user-password', username, '--auth-user', admin_user], + input=process_input) + new_password_hash = _get_password_hash(username) assert old_password_hash != new_password_hash @@ -191,6 +229,38 @@ def test_change_user_password(): assert _try_login_to_ssh(username, new_password) +def test_change_password_as_non_admin_user(): + """Test changing user password as a non-admin user.""" + username, old_password = _create_user() + old_password_hash = _get_password_hash(username) + new_password = 'pass $123' + + process_input = "{0}\n{1}".format(new_password, old_password).encode() + _call_action(['set-user-password', username, '--auth-user', username], + input=process_input) + + new_password_hash = _get_password_hash(username) + assert old_password_hash != new_password_hash + + # User can login to ssh using new password but not the old password. + # sshpass gives a return code of 5 if the password is incorrect. + assert _try_login_to_ssh(username, old_password, returncode=5) + assert _try_login_to_ssh(username, new_password) + + +def test_change_other_users_password_as_non_admin(): + """Test that changing other user's password as a non-admin user fails.""" + username1, password1 = _create_user() + username2, _ = _create_user() + new_password = 'pass $123' + + process_input = "{0}\n{1}".format(new_password, password1).encode() + with pytest.raises(subprocess.CalledProcessError): + _call_action( + ['set-user-password', username2, '--auth-user', username1], + input=process_input) + + def test_set_password_for_non_existent_user(): """Test setting password for a non-existent user.""" non_existent_user = _random_string() @@ -202,6 +272,9 @@ def test_set_password_for_non_existent_user(): def test_rename_user(): """Test whether renaming a user works.""" + # create an admin user to create other users with + _create_user(groups=['admin']) + old_username, password = _create_user(groups=['admin', _random_string()]) old_groups = _get_user_groups(old_username) @@ -269,24 +342,40 @@ def test_groups(): assert _is_exit_zero([_action_file(), 'remove-group', groupname]) +def test_delete_admin_group_fails(): + """Test that deleting the admin group fails.""" + groupname = 'admin' + _create_group('admin') + + assert not _is_exit_zero([_action_file(), 'remove-group', groupname]) + + def test_user_group_interactions(): """Test adding/removing user from a groups.""" group1 = _random_string() user1, _ = _create_user(groups=[group1]) assert [group1] == _get_user_groups(user1) + admin_user, admin_password = _get_admin_user_password() + # add-user-to-group is not idempotent with pytest.raises(subprocess.CalledProcessError): - _call_action(['add-user-to-group', user1, group1]) + _call_action( + ['add-user-to-group', '--auth-user', admin_user, user1, group1], + input=admin_password.encode()) # The same user can be added to other new groups group2 = _random_string() _create_group(group2) - _call_action(['add-user-to-group', user1, group2]) + _call_action( + ['add-user-to-group', '--auth-user', admin_user, user1, group2], + input=admin_password.encode()) # Adding a user to a non-existent group creates the group group3 = _random_string() - _call_action(['add-user-to-group', user1, group3]) + _call_action( + ['add-user-to-group', '--auth-user', admin_user, user1, group3], + input=admin_password.encode()) _cleanup_groups.add(group3) # The expected groups got created and the user is part of them. @@ -295,7 +384,10 @@ def test_user_group_interactions(): # Remove user from group group_to_remove_from = random.choice(expected_groups) - _call_action(['remove-user-from-group', user1, group_to_remove_from]) + _call_action([ + 'remove-user-from-group', '--auth-user', admin_user, user1, + group_to_remove_from + ], input=admin_password.encode()) # User is no longer in the group that they're removed from expected_groups.remove(group_to_remove_from) @@ -305,4 +397,7 @@ def test_user_group_interactions(): random_group = _random_string() _create_group(random_group) with pytest.raises(subprocess.CalledProcessError): - _call_action(['remove-user-from-group', user1, random_group]) + _call_action([ + 'remove-user-from-group', '--auth-user', admin_user, user1, + random_group + ], input=admin_password.encode()) diff --git a/plinth/modules/users/tests/test_functional.py b/plinth/modules/users/tests/test_functional.py index fc642dbab..7481297de 100644 --- a/plinth/modules/users/tests/test_functional.py +++ b/plinth/modules/users/tests/test_functional.py @@ -3,10 +3,18 @@ Functional, browser based tests for users app. """ +import random +import string +import subprocess +import urllib + +import pytest from pytest_bdd import given, parsers, scenarios, then, when from plinth.tests import functional +_admin_password = functional.config['DEFAULT']['password'] + scenarios('users.feature') _language_codes = { @@ -51,11 +59,63 @@ def new_user_does_not_exist(session_browser, name): @given(parsers.parse('the user {name:w} exists')) def test_user_exists(session_browser, name): + functional.nav_to_module(session_browser, 'users') + user_link = session_browser.find_link_by_href( + '/plinth/sys/users/{}/edit/'.format(name)) + + if user_link: + _delete_user(session_browser, name) + + _create_user(session_browser, name, _random_string()) + + +@given( + parsers.parse('the admin user {name:w} with password {password:w} exists')) +def test_admin_user_exists(session_browser, name, password): functional.nav_to_module(session_browser, 'users') user_link = session_browser.find_link_by_href('/plinth/sys/users/' + name + '/edit/') - if not user_link: - create_user(session_browser, name, 'secret123') + if user_link: + _delete_user(session_browser, name) + + _create_user(session_browser, name, password, is_admin=True) + + +@given(parsers.parse('the user {name:w} with password {password:w} exists')) +def user_exists(session_browser, name, password): + functional.nav_to_module(session_browser, 'users') + user_link = session_browser.find_link_by_href('/plinth/sys/users/' + name + + '/edit/') + if user_link: + _delete_user(session_browser, name) + + _create_user(session_browser, name, password) + + +@given( + parsers.parse( + "I'm logged in as the user {username:w} with password {password:w}")) +def logged_in_user_with_account(session_browser, username, password): + functional.login_with_account(session_browser, functional.base_url, + username, password) + + +@given(parsers.parse('the ssh keys are {ssh_keys:w}')) +def ssh_keys(session_browser, ssh_keys): + _set_ssh_keys(session_browser, ssh_keys) + + +@given('the client has a ssh key') +def generate_ssh_keys(session_browser, tmp_path_factory): + key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key' + try: + key_file.unlink() + except FileNotFoundError: + pass + + subprocess.check_call( + ['ssh-keygen', '-t', 'ed25519', '-N', '', '-q', '-f', + str(key_file)]) @when( @@ -74,6 +134,123 @@ def delete_user(session_browser, name): _delete_user(session_browser, name) +@when('I change the language to ') +def change_language(session_browser, language): + _set_language(session_browser, _language_codes[language]) + + +@when(parsers.parse('I change the ssh keys to {ssh_keys:w}')) +def change_ssh_keys(session_browser, ssh_keys): + _set_ssh_keys(session_browser, ssh_keys) + + +@when('I remove the ssh keys') +def remove_ssh_keys(session_browser): + _set_ssh_keys(session_browser, '') + + +@when( + parsers.parse( + 'I change the ssh keys to {ssh_keys:w} for the user {username:w}')) +def change_user_ssh_keys(session_browser, ssh_keys, username): + _set_ssh_keys(session_browser, ssh_keys, username=username) + + +@when( + parsers.parse( + 'I change my ssh keys to {ssh_keys:w} with password {password:w}')) +def change_my_ssh_keys(session_browser, ssh_keys, password): + _set_ssh_keys(session_browser, ssh_keys, password=password) + + +@when(parsers.parse('I set the user {username:w} as inactive')) +def set_user_inactive(session_browser, username): + _set_user_inactive(session_browser, username) + + +@when( + parsers.parse( + 'I change my password from {current_password} to {new_password:w}')) +def change_my_password(session_browser, current_password, new_password): + _change_password(session_browser, new_password, + current_password=current_password) + + +@when( + parsers.parse( + 'I change the user {username:w} password to {new_password:w}')) +def change_other_user_password(session_browser, username, new_password): + _change_password(session_browser, new_password, username=username) + + +@when('I configure the ssh keys') +def configure_ssh_keys(session_browser, tmp_path_factory): + public_key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key.pub' + public_key = public_key_file.read_text() + _set_ssh_keys(session_browser, public_key) + + +@then( + parsers.parse( + 'I can log in as the user {username:w} with password {password:w}')) +def can_log_in(session_browser, username, password): + functional.visit(session_browser, '/plinth/accounts/logout/') + functional.login_with_account(session_browser, functional.base_url, + username, password) + assert len(session_browser.find_by_id('id_user_menu')) > 0 + + +@then( + parsers.parse( + "I can't log in as the user {username:w} with password {password:w}")) +def cannot_log_in(session_browser, username, password): + functional.visit(session_browser, '/plinth/accounts/logout/') + functional.login_with_account(session_browser, functional.base_url, + username, password) + assert len(session_browser.find_by_id('id_user_menu')) == 0 + + +@then('Plinth language should be ') +def plinth_language_should_be(session_browser, language): + assert _check_language(session_browser, _language_codes[language]) + + +@then(parsers.parse('the ssh keys should be {ssh_keys:w}')) +def ssh_keys_match(session_browser, ssh_keys): + assert _get_ssh_keys(session_browser) == ssh_keys + + +@then('the ssh keys should be removed') +def ssh_keys_should_be_removed(session_browser, ssh_keys): + assert _get_ssh_keys(session_browser) == '' + + +@then( + parsers.parse( + 'the ssh keys should be {ssh_keys:w} for the user {username:w}')) +def ssh_keys_match_for_user(session_browser, ssh_keys, username): + assert _get_ssh_keys(session_browser, username=username) == ssh_keys + + +@then(parsers.parse('my ssh keys should be {ssh_keys:w}')) +def my_ssh_keys_match(session_browser, ssh_keys): + assert _get_ssh_keys(session_browser) == ssh_keys + + +@then('the client should be able to connect passwordless over ssh') +def should_connect_passwordless_over_ssh(session_browser, tmp_path_factory): + key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key' + _try_login_to_ssh(key_file=key_file) + + +@then("the client shouldn't be able to connect passwordless over ssh") +def should_not_connect_passwordless_over_ssh(session_browser, + tmp_path_factory): + key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key' + with pytest.raises(subprocess.CalledProcessError): + _try_login_to_ssh(key_file=key_file) + + @then(parsers.parse('{name:w} should be listed as a user')) def new_user_is_listed(session_browser, name): assert _is_user(session_browser, name) @@ -84,23 +261,16 @@ def new_user_is_not_listed(session_browser, name): assert not _is_user(session_browser, name) -@when('I change the language to ') -def change_language(session_browser, language): - _set_language(session_browser, _language_codes[language]) - - -@then('Plinth language should be ') -def plinth_language_should_be(session_browser, language): - assert _check_language(session_browser, _language_codes[language]) - - -def _create_user(browser, name, password): +def _create_user(browser, name, password, is_admin=False): functional.nav_to_module(browser, 'users') with functional.wait_for_page_update(browser): browser.find_link_by_href('/plinth/sys/users/create/').first.click() browser.find_by_id('id_username').fill(name) browser.find_by_id('id_password1').fill(password) browser.find_by_id('id_password2').fill(password) + if is_admin: + browser.find_by_id('id_groups_0').check() + browser.find_by_id('id_confirm_password').fill(_admin_password) functional.submit(browser) @@ -110,6 +280,7 @@ def _rename_user(browser, old_name, new_name): browser.find_link_by_href('/plinth/sys/users/' + old_name + '/edit/').first.click() browser.find_by_id('id_username').fill(new_name) + browser.find_by_id('id_confirm_password').fill(_admin_password) functional.submit(browser) @@ -135,6 +306,7 @@ def _set_language(browser, language_code): functional.visit(browser, '/plinth/sys/users/{}/edit/'.format(username)) browser.find_by_xpath('//select[@id="id_language"]//option[@value="' + language_code + '"]').first.click() + browser.find_by_id('id_confirm_password').fill(_admin_password) functional.submit(browser) @@ -142,3 +314,73 @@ def _check_language(browser, language_code): functional.nav_to_module(browser, 'config') return browser.find_by_css('.app-titles').first.find_by_tag( 'h2').first.value == _config_page_title_language_map[language_code] + + +def _get_ssh_keys(browser, username=None): + functional.visit(browser, '/plinth/') + if username is None: + browser.find_by_id('id_user_menu').click() + browser.find_by_id('id_user_edit_menu').click() + else: + functional.visit(browser, + '/plinth/sys/users/{}/edit/'.format(username)) + return browser.find_by_id('id_ssh_keys').text + + +def _random_string(length=8): + """Return a random string created from lower case ascii.""" + return ''.join( + [random.choice(string.ascii_lowercase) for _ in range(length)]) + + +def _set_ssh_keys(browser, ssh_keys, username=None, password=None): + if username is None: + browser.find_by_id('id_user_menu').click() + browser.find_by_id('id_user_edit_menu').click() + else: + functional.visit(browser, + '/plinth/sys/users/{}/edit/'.format(username)) + + password = password or _admin_password + + browser.find_by_id('id_ssh_keys').fill(ssh_keys) + browser.find_by_id('id_confirm_password').fill(password) + + functional.submit(browser) + + +def _set_user_inactive(browser, username): + functional.visit(browser, '/plinth/sys/users/{}/edit/'.format(username)) + browser.find_by_id('id_is_active').uncheck() + browser.find_by_id('id_confirm_password').fill(_admin_password) + functional.submit(browser) + + +def _change_password(browser, new_password, current_password=None, + username=None): + current_password = current_password or _admin_password + + if username is None: + browser.find_by_id('id_user_menu').click() + browser.find_by_id('id_change_password_menu').click() + else: + functional.visit( + browser, '/plinth/sys/users/{}/change_password/'.format(username)) + + browser.find_by_id('id_new_password1').fill(new_password) + browser.find_by_id('id_new_password2').fill(new_password) + browser.find_by_id('id_confirm_password').fill(current_password) + functional.submit(browser) + + +def _try_login_to_ssh(key_file=None): + user = functional.config['DEFAULT']['username'] + hostname = urllib.parse.urlparse( + functional.config['DEFAULT']['url']).hostname + port = functional.config['DEFAULT']['ssh_port'] + + subprocess.check_call([ + 'ssh', '-p', port, '-i', key_file, '-q', '-o', + 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', + 'BatchMode=yes', '{0}@{1}'.format(user, hostname), '/bin/true' + ]) diff --git a/plinth/modules/users/tests/test_views.py b/plinth/modules/users/tests/test_views.py index 97d717908..467604990 100644 --- a/plinth/modules/users/tests/test_views.py +++ b/plinth/modules/users/tests/test_views.py @@ -67,6 +67,7 @@ def make_request(request, view, as_admin=True, **kwargs): user = User.objects.create(username='tester') admin_user = User.objects.create(username='admin') + admin_user.set_password('adminpassword') request.user = admin_user if as_admin else user @@ -99,7 +100,8 @@ def test_create_user_view(rf, username): form_data = { 'username': username, 'password1': password, - 'password2': password + 'password2': password, + 'confirm_password': 'adminpassword', } request = rf.post(urls.reverse('users:create'), data=form_data) @@ -111,6 +113,31 @@ def test_create_user_view(rf, username): assert response.url == urls.reverse('users:index') +@pytest.mark.parametrize('password,error', [ + ('', { + 'confirm_password': ['This field is required.'] + }), + ('wrong_password', { + 'confirm_password': ['Invalid password.'] + }), +]) +def test_create_user_invalid_admin_view(rf, password, error): + """Test that user creation with an invalid admin password fails.""" + user_password = 'testingtesting' + form_data = { + 'username': 'test-new', + 'password1': user_password, + 'password2': user_password, + 'confirm_password': password, + } + + request = rf.post(urls.reverse('users:create'), data=form_data) + view = views.UserCreate.as_view() + response, messages = make_request(request, view) + + assert response.context_data['form'].errors == error + + def test_create_user_form_view(rf): """Test that a username field on create form has correct attributes.""" request = rf.get(urls.reverse('users:create')) @@ -133,7 +160,8 @@ def test_create_user_invalid_username_view(rf, username): form_data = { 'username': username, 'password1': 'testingtesting', - 'password2': 'testingtesting' + 'password2': 'testingtesting', + 'confirm_password': 'adminpassword', } request = rf.post(urls.reverse('users:create'), data=form_data) @@ -157,7 +185,8 @@ def test_create_user_taken_or_reserved_username_view(rf, username, error): form_data = { 'username': username, 'password1': 'testingtesting', - 'password2': 'testingtesting' + 'password2': 'testingtesting', + 'confirm_password': 'adminpassword', } request = rf.post(urls.reverse('users:create'), data=form_data) @@ -175,7 +204,8 @@ def test_update_user_view(rf): form_data = { 'username': new_username, 'password1': 'testingtesting', - 'password2': 'testingtesting' + 'password2': 'testingtesting', + 'confirm_password': 'adminpassword', } url = urls.reverse('users:edit', kwargs={'slug': user}) @@ -189,12 +219,40 @@ def test_update_user_view(rf): kwargs={'slug': new_username}) +@pytest.mark.parametrize('password,error', [ + ('', { + 'confirm_password': ['This field is required.'] + }), + ('wrong_password', { + 'confirm_password': ['Invalid password.'] + }), +]) +def test_update_user_invalid_admin_view(rf, password, error): + """Test that updating username with an invalid admin password fails.""" + user = 'tester' + new_username = 'tester-renamed' + form_data = { + 'username': new_username, + 'password1': 'testingtesting', + 'password2': 'testingtesting', + 'confirm_password': password, + } + + url = urls.reverse('users:edit', kwargs={'slug': user}) + request = rf.post(url, data=form_data) + view = views.UserUpdate.as_view() + response, messages = make_request(request, view, as_admin=True, slug=user) + + assert response.context_data['form'].errors == error + + def test_update_user_without_permissions_view(rf): """Test that updating other user as non-admin user raises exception.""" form_data = { 'username': 'admin-renamed', 'password1': 'testingtesting', - 'password2': 'testingtesting' + 'password2': 'testingtesting', + 'confirm_password': 'adminpassword', } url = urls.reverse('users:edit', kwargs={'slug': 'admin'}) @@ -224,7 +282,8 @@ def test_user_change_password_view(rf): user = 'admin' form_data = { 'new_password1': 'testingtesting2', - 'new_password2': 'testingtesting2' + 'new_password2': 'testingtesting2', + 'confirm_password': 'adminpassword', } url = urls.reverse('users:change_password', kwargs={'slug': user}) @@ -237,6 +296,32 @@ def test_user_change_password_view(rf): assert response.url == urls.reverse('users:edit', kwargs={'slug': user}) +@pytest.mark.parametrize('password,error', [ + ('', { + 'confirm_password': ['This field is required.'] + }), + ('wrong_password', { + 'confirm_password': ['Invalid password.'] + }), +]) +def test_user_change_password_invalid_admin_view(rf, password, error): + """Test that changing password with an invalid admin password fails.""" + user = 'admin' + form_data = { + 'new_password1': 'testingtesting2', + 'new_password2': 'testingtesting2', + 'confirm_password': password, + } + + url = urls.reverse('users:change_password', kwargs={'slug': user}) + request = rf.post(url, data=form_data) + view = views.UserChangePassword.as_view() + response, messages = make_request(request, view, as_admin=True, slug=user) + + assert response.context_data['form'].errors == error + assert response.status_code == 200 + + def test_user_change_password_without_permissions_view(rf): """ Test that changing other user password as a non-admin user raises @@ -245,7 +330,8 @@ def test_user_change_password_without_permissions_view(rf): user = 'admin' form_data = { 'new_password1': 'adminadmin2', - 'new_password2': 'adminadmin2' + 'new_password2': 'adminadmin2', + 'confirm_password': 'adminpassword', } url = urls.reverse('users:change_password', kwargs={'slug': user}) diff --git a/plinth/modules/users/tests/users.feature b/plinth/modules/users/tests/users.feature index ddb70ab14..4e1a5eea2 100644 --- a/plinth/modules/users/tests/users.feature +++ b/plinth/modules/users/tests/users.feature @@ -2,11 +2,6 @@ # TODO Scenario: Add user to wiki group # TODO Scenario: Remove user from wiki group -# TODO Scenario: Set user SSH key -# TODO Scenario: Clear user SSH key -# TODO Scenario: Make user inactive -# TODO Scenario: Make user active -# TODO Scenario: Change user password @system @essential @users Feature: Users and Groups @@ -27,10 +22,39 @@ Scenario: Rename user Then alice should not be listed as a user Then bob should be listed as a user -Scenario: Delete user +Scenario: Admin users can change their own ssh keys + When I change the ssh keys to somekey123 + Then the ssh keys should be somekey123 + +Scenario: Non-admin users can change their own ssh keys + Given the user alice with password secret123secret123 exists + And I'm logged in as the user alice with password secret123secret123 + When I change my ssh keys to somekey456 with password secret123secret123 + Then my ssh keys should be somekey456 + +Scenario: Admin users can change other user's ssh keys Given the user alice exists - When I delete the user alice - Then alice should not be listed as a user + When I change the ssh keys to alicesomekey123 for the user alice + Then the ssh keys should be alicesomekey123 for the user alice + +Scenario: Users can remove ssh keys + Given the ssh keys are somekey123 + When I remove the ssh keys + Then the ssh keys should be removed + +Scenario: Users can connect passwordless over ssh if the keys are set + Given the ssh application is enabled + And the client has a ssh key + When I configure the ssh keys + Then the client should be able to connect passwordless over ssh + +Scenario: Users can't connect passwordless over ssh if the keys aren't set + Given the ssh application is enabled + And the client has a ssh key + And the ssh keys are configured + When I remove the ssh keys + Then the client shouldn't be able to connect passwordless over ssh + Scenario Outline: Change language When I change the language to @@ -52,3 +76,30 @@ Scenario Outline: Change language | Türkçe | | 简体中文 | | None | + +Scenario: Admin users can set other users an inactive + Given the user alice with password secret789secret789 exists + When I set the user alice as inactive + Then I can't log in as the user alice with password secret789secret789 + +Scenario: Admin users can change their own password + Given the admin user testadmin with password testingtesting123 exists + And I'm logged in as the user testadmin with password testingtesting123 + When I change my password from testingtesting123 to testingtesting456 + Then I can log in as the user testadmin with password testingtesting456 + +Scenario: Admin user can change other user's password + Given the user alice exists + When I change the user alice password to secretsecret567 + Then I can log in as the user alice with password secretsecret567 + +Scenario: Non-admin users can change their own password + Given the user alice with password secret123secret123 exists + And I'm logged in as the user alice with password secret123secret123 + When I change my password from secret123secret123 to secret456secret456 + Then I can log in as the user alice with password secret456secret456 + +Scenario: Delete user + Given the user alice exists + When I delete the user alice + Then alice should not be listed as a user diff --git a/plinth/modules/users/views.py b/plinth/modules/users/views.py index d51014a1c..3cb9a365c 100644 --- a/plinth/modules/users/views.py +++ b/plinth/modules/users/views.py @@ -25,6 +25,7 @@ from .forms import (CreateUserForm, FirstBootForm, UserChangePasswordForm, class ContextMixin(object): """Mixin to add 'title' to the template context.""" + def get_context_data(self, **kwargs): """Add self.title to template context.""" context = super(ContextMixin, self).get_context_data(**kwargs) diff --git a/plinth/templates/base.html b/plinth/templates/base.html index 6934f4b55..0252d30f8 100644 --- a/plinth/templates/base.html +++ b/plinth/templates/base.html @@ -146,7 +146,7 @@ {% include "help-menu.html" %} -