client/securedrop_client/app.py (173 lines of code) (raw):

""" SecureDrop client - an easy to use interface for SecureDrop in Qubes. Copyright (C) 2018 The Freedom of the Press Foundation. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. """ import gettext import locale import logging import os import platform import signal import socket import sys from argparse import ArgumentParser from contextlib import contextmanager from gettext import gettext as _ from logging.handlers import SysLogHandler, TimedRotatingFileHandler from pathlib import Path from typing import Any, NewType, NoReturn from PyQt5.QtCore import Qt, QThread, QTimer from PyQt5.QtWidgets import QApplication, QMessageBox from securedrop_client import __version__, state from securedrop_client.database import Database from securedrop_client.db import make_session_maker from securedrop_client.gui.main import Window from securedrop_client.logic import Controller from securedrop_client.utils import safe_mkdir LanguageCode = NewType("LanguageCode", str) DEFAULT_LANGUAGE = LanguageCode("en") DEFAULT_SDC_HOME = "~/.securedrop_client" DESKTOP_FILE_NAME = "press.freedom.SecureDropClient.desktop" ENCODING = "utf-8" GETTEXT_DOMAIN = "messages" LOGLEVEL = os.environ.get("LOGLEVEL", "info").upper() SDC_NAME = "SecureDrop Client" def init(sdc_home: Path) -> None: safe_mkdir(sdc_home) safe_mkdir(sdc_home, "data") def excepthook(*exc_args): # type: ignore[no-untyped-def] """ This function is called in the event of a catastrophic failure. Log exception and exit cleanly. """ logging.error("Unrecoverable error", exc_info=(exc_args)) sys.__excepthook__(*exc_args) print("") # force terminal prompt on to a new line sys.exit(1) def configure_locale_and_language() -> LanguageCode: """Configure locale, language and define location of translation assets.""" localedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "locale")) try: # Use the operating system's locale. current_locale, encoding = locale.getdefaultlocale() # Get the language code. if current_locale is None: code = DEFAULT_LANGUAGE else: code = LanguageCode(current_locale[:2]) except ValueError: # pragma: no cover code = DEFAULT_LANGUAGE # pragma: no cover gettext.bindtextdomain(GETTEXT_DOMAIN, localedir=localedir) gettext.textdomain(GETTEXT_DOMAIN) return code def configure_logging(sdc_home: Path) -> None: """ Set up all logging. """ safe_mkdir(sdc_home, "logs") log_file = os.path.join(sdc_home, "logs", "client.log") # set logging format log_fmt = "%(asctime)s - %(name)s:%(lineno)d(%(funcName)s) " "%(levelname)s: %(message)s" formatter = logging.Formatter(log_fmt) # define log handlers such as for rotating log files handler = TimedRotatingFileHandler( log_file, when="midnight", backupCount=5, delay=False, encoding=ENCODING ) handler.setFormatter(formatter) # For syslog handler if platform.system() != "Linux": # pragma: no cover syslog_file = "/var/run/syslog" else: syslog_file = "/dev/log" sysloghandler = SysLogHandler(address=syslog_file) sysloghandler.setFormatter(formatter) # set up primary log log = logging.getLogger() log.setLevel(LOGLEVEL) log.addHandler(handler) # add the secondary logger log.addHandler(sysloghandler) # override excepthook to capture a log of catastrophic failures. sys.excepthook = excepthook def configure_signal_handlers(app: QApplication) -> None: def signal_handler(*nargs) -> None: # type: ignore[no-untyped-def] app.quit() for sig in [signal.SIGINT, signal.SIGTERM]: signal.signal(sig, signal_handler) def expand_to_absolute(value: str) -> str: """ Expands a path to the absolute path so users can provide arguments in the form ``~/my/dir/``. """ return os.path.abspath(os.path.expanduser(value)) def arg_parser() -> ArgumentParser: parser = ArgumentParser("securedrop-client", description="SecureDrop Journalist GUI") parser.add_argument( "-H", "--sdc-home", default=DEFAULT_SDC_HOME, type=expand_to_absolute, help=( f"{SDC_NAME} home directory for storing files and state. " f"(Default {DEFAULT_SDC_HOME})" ), ) parser.add_argument( "--no-proxy", action="store_true", help="Use proxy AppVM name to connect to server." ) parser.add_argument( "--no-qubes", action="store_true", help="Disable opening submissions in DispVMs" ) return parser def prevent_second_instance(app: QApplication, unique_name: str) -> None: # This function is only necessary on Qubes, so we can skip it on other platforms to help devs if platform.system() != "Linux": # pragma: no cover return # Null byte triggers abstract namespace IDENTIFIER = "\0" + app.applicationName() + unique_name ALREADY_BOUND_ERRNO = 98 app.instance_binding = socket.socket( # type: ignore[attr-defined] socket.AF_UNIX, socket.SOCK_DGRAM ) try: app.instance_binding.bind(IDENTIFIER) # type: ignore[attr-defined] except OSError as e: if e.errno == ALREADY_BOUND_ERRNO: err_dialog = QMessageBox() err_dialog.setText( _("{application_name} is already running").format( application_name=app.applicationName() ) ) err_dialog.exec() sys.exit() else: raise @contextmanager def threads(count: int) -> Any: """Ensures that the thread is properly closed before its reference is dropped.""" threads = [] for i in range(count): threads.append(QThread()) yield threads for thread in threads: thread.exit() # Wait until the thread has finished, or the deadline expires. TWO_SECONDS_IN_MILLISECONDS = 2000 thread.wait(TWO_SECONDS_IN_MILLISECONDS) def start_app(args, qt_args) -> NoReturn: # type: ignore[no-untyped-def] """ Create all the top-level assets for the application, set things up and run the application. Specific tasks include: - set up locale and language. - set up logging. - create an application object. - create a window for the app. - create an API connection to the SecureDrop proxy. - create a SqlAlchemy session to local storage. - configure the client (logic) object. - ensure the application is setup in the default safe starting state. """ os.umask(0o077) configure_locale_and_language() init(args.sdc_home) configure_logging(args.sdc_home) logging.info(f"Starting {SDC_NAME} {__version__}") app = QApplication(qt_args) app.setApplicationName(SDC_NAME) app.setDesktopFileName(DESKTOP_FILE_NAME) app.setApplicationVersion(__version__) app.setAttribute(Qt.AA_UseHighDpiPixmaps) prevent_second_instance(app, args.sdc_home) session_maker = make_session_maker(args.sdc_home) session = session_maker() database = Database(session) app_state = state.State(database) with threads(3) as [ sync_thread, main_queue_thread, file_download_queue_thread, ]: gui = Window(app_state) controller = Controller( "http://localhost:8081/", gui, session_maker, args.sdc_home, app_state, not args.no_proxy, not args.no_qubes, sync_thread, main_queue_thread, file_download_queue_thread, ) controller.setup() configure_signal_handlers(app) timer = QTimer() timer.start(500) timer.timeout.connect(lambda: None) sys.exit(app.exec_()) def run() -> NoReturn: args, qt_args = arg_parser().parse_known_args() # reinsert the program's name qt_args.insert(0, "securedrop-client") start_app(args, qt_args)