# SPDX-License-Identifier: AGPL-3.0-or-later """Configuration helper for Zoph server.""" import configparser import pathlib import re import subprocess from plinth import action_utils from plinth.actions import privileged from plinth.db import mariadb APACHE_CONF = '/etc/apache2/conf-available/zoph.conf' DB_CONF = pathlib.Path('/etc/zoph.ini') DB_BACKUP_FILE = '/var/lib/plinth/backups-data/zoph-database.sql' @privileged def pre_install(): """Preseed debconf values before packages are installed.""" action_utils.debconf_set_selections([ 'zoph zoph/dbconfig-install boolean true', 'zoph zoph/dbconfig-upgrade boolean true', 'zoph zoph/dbconfig-remove boolean true', 'zoph zoph/dbconfig-reinstall boolean true' 'zoph zoph/mysql/admin-user string root', 'zoph zoph/rm_images select yes', ]) @privileged def get_configuration() -> dict[str, str]: """Return the current configuration.""" configuration = {} try: process = action_utils.run(['zoph', '--dump-config'], check=True) except subprocess.CalledProcessError as exception: if exception.returncode != 96: raise _zoph_setup_cli_user() process = action_utils.run(['zoph', '--dump-config'], check=True) for line in process.stdout.decode().splitlines(): name, value = line.partition(':')[::2] configuration[name.strip()] = value[1:] return configuration def _zoph_setup_cli_user() -> None: """Ensure that Zoph cli user is not set to 'autodetect'. When set to 'autodetect', all command line commands will fail unless a user name 'root' exists in zoph database and is an admin user. """ query = ''' UPDATE zoph_conf SET value=( SELECT user_id FROM zoph_users WHERE user_class="0" ORDER BY user_id LIMIT 1) WHERE conf_id='interface.user.cli';''' database_name = _get_db_config()['db_name'] mariadb.run_query(database_name, query) def _zoph_configure(key, value): """Set a configure value in Zoph.""" try: action_utils.run(['zoph', '--config', key, value], check=True) except subprocess.CalledProcessError as exception: if exception.returncode != 96: raise _zoph_setup_cli_user() action_utils.run(['zoph', '--config', key, value], check=True) @privileged def setup(): """Setup Zoph configuration. May be called when app is disabled. """ with action_utils.service_ensure_running('mysql'): _zoph_configure('import.enable', 'true') _zoph_configure('import.upload', 'true') _zoph_configure('import.rotate', 'true') _zoph_configure('path.unzip', 'unzip') _zoph_configure('path.untar', 'tar xvf') _zoph_configure('path.ungz', 'gunzip') # Maps using OpenStreetMap is enabled by default. _zoph_configure('maps.provider', 'osm') def _get_db_config(): """Return the name of the database configured by dbconfig.""" config = configparser.ConfigParser() with DB_CONF.open('r', encoding='utf-8') as file_handle: config.read_file(file_handle) return { 'db_host': config['zoph']['db_host'].strip('"'), 'db_name': config['zoph']['db_name'].strip('"'), 'db_user': config['zoph']['db_user'].strip('"'), 'db_pass': config['zoph']['db_pass'].strip('"'), } @privileged def set_configuration(enable_osm: bool | None = None, admin_user: str | None = None): """Setup Zoph Apache configuration.""" _zoph_configure('interface.user.remote', 'true') # Note that using OpenSteetmap as a mapping provider is a very nice # feature, but some people may regard its use as a privacy issue if enable_osm is not None: value = 'osm' if enable_osm else '' _zoph_configure('maps.provider', value) if admin_user: # Edit the database to rename the admin user to FreedomBox admin user. if not re.match(r'^[\w.@][\w.@-]+\Z', admin_user, flags=re.ASCII): # Check to avoid SQL injection raise ValueError('Invalid username') query = f"UPDATE zoph_users SET user_name='{admin_user}' \ WHERE user_name='admin';" action_utils.run(['mysql', _get_db_config()['db_name']], input=query.encode(), check=True) @privileged def is_configured() -> bool | None: """Return whether zoph app is configured.""" try: process = action_utils.run( ['zoph', '--get-config', 'interface.user.remote'], check=True) return process.stdout.decode().strip() == 'true' except (FileNotFoundError, subprocess.CalledProcessError): return None @privileged def dump_database(): """Dump database to file. May be called when app is disabled. """ with action_utils.service_ensure_running('mysql'): db_name = _get_db_config()['db_name'] with open(DB_BACKUP_FILE, 'w', encoding='utf-8') as db_backup_file: action_utils.run(['mysqldump', db_name], stdout=db_backup_file, check=True) @privileged def restore_database(): """Restore database from file. May be called when app is disabled. """ with action_utils.service_ensure_running('mysql'): db_name = _get_db_config()['db_name'] db_user = _get_db_config()['db_user'] db_host = _get_db_config()['db_host'] db_pass = _get_db_config()['db_pass'] action_utils.run(['mysqladmin', '--force', 'drop', db_name], check=False) action_utils.run(['mysqladmin', 'create', db_name], check=True) with open(DB_BACKUP_FILE, 'r', encoding='utf-8') as db_restore_file: action_utils.run(['mysql', db_name], stdin=db_restore_file, check=True) # Set the password for user from restored configuration query = f'ALTER USER {db_user}@{db_host} IDENTIFIED BY "{db_pass}";' action_utils.run(['mysql'], input=query.encode(), check=True) @privileged def uninstall(): """Drop database, database user and database configuration. May be called when app is disabled. """ with action_utils.service_ensure_running('mysql'): try: config = _get_db_config() action_utils.run( ['mysqladmin', '--force', 'drop', config['db_name']], check=False) query = f'DROP USER IF EXISTS {config["db_user"]}@localhost;' action_utils.run(['mysql'], input=query.encode(), check=False) except FileNotFoundError: # Database configuration not found pass DB_CONF.unlink(missing_ok=True)