Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/mswms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,10 @@ This runs the wms server on port 5000. If you use a certificate and proxy by e.g

PYTHONPATH=~/INSTANCE/wsgi/ waitress-serve --host 127.0.0.1 --port 5000 --url-scheme=http wsgi_setup:app

The capabilities name the address of the server for the maps, built from the scheme and host of the request.
Behind an https proxy without --url-scheme=https, this address starts with http. msui then warns, and it
doesn't send the login of a protected server with the map requests, so they fail.

Further documentations:

- `Waitress <https://docs.pylonsproject.org/projects/waitress/en/stable/index.html>`_
Expand Down
6 changes: 6 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ All passwords are stored by using an OS-provided keyring after entering them
the first time. Also the token required for accessing the MSColab server will
be stored there. You can also use an OS-provided keyring app to set, change and delete passwords.

The login of a WMS is only sent to the server it was entered for. The capabilities of a WMS name the
address used for the maps (GetMap). msui sends the login with the map requests only when this address has
the same scheme, host and port as the address of the capabilities. Otherwise the maps are requested without
the login, and msui warns once per session. It also warns when the capabilities were loaded with https and
the maps are to be requested with http.


MSUI Flight track import/export plugins
.......................................
Expand Down
9 changes: 9 additions & 0 deletions mslib/msui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ consumed over HTTP.
- Config access via `mslib.utils.config.config_loader`; per-widget Qt state
via `save_settings_qsettings`/`load_settings_qsettings`.
- Message boxes in tests are mocked; an unhandled QMessageBox fails the test.
- Texts from servers or other users (WMS capabilities and errors, MSColab names,
exception messages) never reach Qt as rich text: `QLabel.setTextFormat(Qt.PlainText)`,
`html.escape` inside own HTML, and for tooltips and message boxes
`mslib.utils.qt.plain_text_as_html`, `show_popup` or `plain_text_message_box`.
Qt loads `<img src="file://host/share/x">` from rich text (NTLM leak).
`tests/_test_utils/test_qt.py` fails for a message box or tooltip whose text is
not a constant and doesn't go through `plain_text_as_html`.
- Passwords and tokens go only to their own server: check with
`mslib.utils.auth.same_origin` before sending them to a URL a server named.

## Verify

Expand Down
4 changes: 2 additions & 2 deletions mslib/msui/autoplot_dockwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from mslib.autoplot import main as cli_tool
from mslib.msui.qt5.ui_mss_autoplot import Ui_AutoplotDockWidget
from mslib.utils import constants as const
from mslib.utils.qt import get_save_filename
from mslib.utils.qt import get_save_filename, plain_text_as_html


class AutoplotDockWidget(QWidget, Ui_AutoplotDockWidget):
Expand Down Expand Up @@ -220,7 +220,7 @@ def download_plots_cli(self, config_settings):
QMessageBox.information(
self,
"Error",
ex.args[0]
plain_text_as_html(ex.args[0])
)
ctx.obj = None
return
Expand Down
6 changes: 3 additions & 3 deletions mslib/msui/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import json
from pathlib import Path

from mslib.utils.qt import get_open_filename, get_save_filename, show_popup
from mslib.utils.qt import get_open_filename, get_save_filename, show_popup, plain_text_as_html
from mslib.msui.qt5 import ui_configuration_editor_window as ui_conf
from PyQt5 import QtWidgets, QtCore, QtGui
from mslib.utils.constants import MSUI_SETTINGS
Expand Down Expand Up @@ -703,7 +703,7 @@ def export_config(self):
msg = """Since the current configuration matches the default configuration, \
only an empty json file would be exported.\nDo you still want to continue?"""
ret = QtWidgets.QMessageBox.warning(
self, self.tr("Mission Support System"), self.tr(msg),
self, self.tr("Mission Support System"), plain_text_as_html(self.tr(msg)),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No)
if ret == QtWidgets.QMessageBox.No:
Expand All @@ -725,7 +725,7 @@ def closeEvent(self, event):
"Save Changes to default msui_settings.json?\nYou need to restart the gui for changes to take effect.")
if msg != "":
ret = QtWidgets.QMessageBox.warning(
self, self.tr("Mission Support System"), self.tr(msg),
self, self.tr("Mission Support System"), plain_text_as_html(self.tr(msg)),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No)
if ret == QtWidgets.QMessageBox.Yes:
Expand Down
5 changes: 3 additions & 2 deletions mslib/msui/hexagon_dockwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from mslib.msui import flighttrack as ft
from mslib.utils.coordinate import rotate_point
from mslib.utils.config import config_loader
from mslib.utils.qt import plain_text_as_html


class HexagonException(Exception):
Expand Down Expand Up @@ -152,7 +153,7 @@ def _remove_hexagon(self):
else:
sel = QtWidgets.QMessageBox.question(
table_view, "Remove hexagon",
f"This will remove waypoints {row_min:d}-{row_max:d}. Continue?",
plain_text_as_html(f"This will remove waypoints {row_min:d}-{row_max:d}. Continue?"),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.Yes)
if sel == QtWidgets.QMessageBox.Yes:
Expand All @@ -161,4 +162,4 @@ def _remove_hexagon(self):
raise HexagonException("Cannot remove hexagon, please select a hexagon "
"waypoint ('Hexagon x' in comments field)")
except HexagonException as ex:
QtWidgets.QMessageBox.warning(self, "Remove hexagon", str(ex))
QtWidgets.QMessageBox.warning(self, "Remove hexagon", plain_text_as_html(str(ex)))
8 changes: 4 additions & 4 deletions mslib/msui/kmloverlay_dockwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from pathlib import Path
from matplotlib import patheffects

from mslib.utils.qt import get_open_filenames, get_save_filename
from mslib.utils.qt import get_open_filenames, get_save_filename, plain_text_as_html
from mslib.utils.colordialog import CustomColorDialog
from mslib.msui.qt5 import ui_kmloverlay_dockwidget as ui
from PyQt5 import QtGui, QtWidgets, QtCore
Expand Down Expand Up @@ -582,7 +582,7 @@ def load_file(self):
del self.dict_files[self.listWidget.item(index).text()] # del the checked files from dictionary
self.listWidget.takeItem(index) # remove file item from ListWidget
QtWidgets.QMessageBox.critical(
self, self.tr("KML Overlay"), self.tr(f"ERROR:\n{type(ex)}\n{ex}"))
self, self.tr("KML Overlay"), plain_text_as_html(self.tr(f"ERROR:\n{type(ex)}\n{ex}")))
logging.debug(self.dict_files)

def merge_file(self):
Expand Down Expand Up @@ -639,10 +639,10 @@ def merge_file(self):
except (OSError, IOError) as ex:
QtWidgets.QMessageBox.critical(
self, self.tr("Problem while merging KML Files:"),
self.tr(f"ERROR: {type(ex)} {ex}"))
plain_text_as_html(self.tr(f"ERROR: {type(ex)} {ex}")))
else:
QtWidgets.QMessageBox.warning(self, "Merge KML Files",
f"File extension is not '.kml'!\n{filename:}")
plain_text_as_html(f"File extension is not '.kml'!\n{filename:}"))

def remove_ns(self, root):
"""
Expand Down
4 changes: 3 additions & 1 deletion mslib/msui/mpl_pathinteractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from mslib.utils.thermolib import pressure2flightlevel
from mslib.msui import flighttrack as ft
from mslib.utils.loggerdef import configure_mpl_logger
from mslib.utils.qt import plain_text_as_html


mpl_logger = configure_mpl_logger()
Expand Down Expand Up @@ -935,7 +936,8 @@ def confirm_delete_waypoint(self, row):
wp = wps[row]
return QtWidgets.QMessageBox.question(
None, "Remove waypoint",
f"Remove waypoint no.{row:d} at {wp.lat:.2f}/{wp.lon:.2f}, flightlevel {wp.flightlevel:.2f}?",
plain_text_as_html(
f"Remove waypoint no.{row:d} at {wp.lat:.2f}/{wp.lon:.2f}, flightlevel {wp.flightlevel:.2f}?"),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes

Expand Down
89 changes: 68 additions & 21 deletions mslib/msui/mscolab.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@

from mslib.utils.auth import del_password_from_keyring
from mslib.utils.verify_waypoint_data import verify_waypoint_data
from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup
from mslib.utils.qt import (get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup,
plain_text_as_html)
from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog
from mslib.msui.qt5 import ui_mscolab_add_operation_dialog as msc_add_operation_ui
from mslib.msui.qt5 import ui_mscolab_merge_waypoints_dialog as merge_wp_ui
Expand All @@ -89,6 +90,31 @@
from mslib.utils.config import config_loader


# characters that can't be in a file or directory name on Windows, macOS or Linux, and control characters
_UNSAFE_NAME_CHARACTERS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]')
# names Windows reserves for devices, also with an extension, e.g. "con.txt"
_WINDOWS_DEVICE_NAMES = re.compile(r"(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?", re.IGNORECASE)


def _safe_dir_name(name):
"""
name as the name of a single directory, on Windows, macOS and Linux

Unsafe characters are replaced by "_", e.g. "team/a" -> "team_a", "a\\b" -> "a_b", "x:y" -> "x_y". So are
trailing dots and spaces, which Windows drops, and a name of only dots ("." and ".."). A Windows device name
gets a "_" appended. Other names, e.g. every name msui and the MSColab server allow for operations, stay as they
are, so existing local copies keep their directory.
"""
name = _UNSAFE_NAME_CHARACTERS.sub("_", str(name))
stripped = name.rstrip(". ")
name = stripped + "_" * (len(name) - len(stripped))
if name == "":
return "_"
if _WINDOWS_DEVICE_NAMES.fullmatch(name):
return name + "_"
return name


class MSUIMscolab(QtCore.QObject):
"""
Class for implementing MSColab functionalities
Expand Down Expand Up @@ -499,18 +525,19 @@ def upload_image(self, _=None):
QMessageBox.information(self.prof_diag, "Success", "Image uploaded successfully")
self.fetch_profile_image(refresh=True)
else:
QMessageBox.critical(self.prof_diag, "Error", "Failed to upload image: "
f"{sc.response_message(response, response.text)}")
# the answer comes from the MSColab server
QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html(
f"Failed to upload image: {sc.response_message(response, response.text)}"))

except requests.exceptions.RequestException as e:
QMessageBox.critical(self.prof_diag, "Error", f"Error occurred: {e}")
QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html(f"Error occurred: {e}"))

except UnidentifiedImageError as e:
QMessageBox.critical(self.prof_diag, "Error",
f'Cannot identify image file. Please check the file format. Error : {e}')
QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html(
f'Cannot identify image file. Please check the file format. Error : {e}'))
except OSError as e:
QMessageBox.critical(self.prof_diag, "Error",
f'Cannot identify image file. Please check the file format. Error: {e}')
QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html(
f'Cannot identify image file. Please check the file format. Error: {e}'))

def delete_own_account(self, _=None):
reply = QMessageBox.question(
Expand Down Expand Up @@ -943,18 +970,39 @@ def rename_operation_handler(self, _=None):
"Operation is renamed successfully.",
)

def local_operation_dir(self):
"""
Directory of the local copy of the active operation, None if it would not be in the data directory

The username and the operation name come from the MSColab server. A server could otherwise make msui write
the local copy anywhere, e.g. with the operation name ".." or an absolute path, so unsafe characters are
replaced, see _safe_dir_name. The containment check catches what is left, e.g. a symbolic link.
"""
local_data_dir = Path(self.data_dir) / "local_colabdata"
local_dir = local_data_dir / _safe_dir_name(self.user["username"]) / _safe_dir_name(self.active_operation_name)
if not local_dir.resolve().is_relative_to(local_data_dir.resolve()):
return None
return local_dir

def handle_work_locally_toggle(self, _=None):
if self.ui.workLocallyCheckbox.isChecked():
local_dir = self.local_operation_dir()
if local_dir is None:
logging.error("no local copy for user %r and operation %r, not a directory in %s",
self.user["username"], self.active_operation_name, self.data_dir)
QMessageBox.critical(
self.ui, "Work Asynchronously",
plain_text_as_html(
"The operation can't be stored locally, its directory would not be in the data directory:"
f"\n\nOperation: {self.active_operation_name}\nUsername: {self.user['username']}"))
self.ui.workLocallyCheckbox.blockSignals(True)
self.ui.workLocallyCheckbox.setChecked(False)
self.ui.workLocallyCheckbox.blockSignals(False)
return
if self.version_window is not None:
self.version_window.close()
self.create_local_operation_file()
self.local_ftml_file = str(
Path(self.data_dir) /
"local_colabdata" /
self.user["username"] /
self.active_operation_name /
"mscolab_operation.ftml"
)
self.create_local_operation_file(local_dir)
self.local_ftml_file = str(local_dir / "mscolab_operation.ftml")
self.ui.workingStatusLabel.setText(
self.ui.tr(
"Working Asynchronously.\nYour changes are only available to you. "
Expand All @@ -975,14 +1023,13 @@ def handle_work_locally_toggle(self, _=None):
self.show_operation_options()
self.reload_view_windows()

def create_local_operation_file(self):
local_data_dir = self.data_dir / "local_colabdata" / self.user['username'] / self.active_operation_name
ftml_file = local_data_dir / "mscolab_operation.ftml"
def create_local_operation_file(self, local_dir):
ftml_file = local_dir / "mscolab_operation.ftml"

if ftml_file.exists():
return

local_data_dir.mkdir(parents=True, exist_ok=True)
local_dir.mkdir(parents=True, exist_ok=True)
server_data = self.waypoints_model.get_xml_content()

with open(ftml_file, "w") as f:
Expand Down Expand Up @@ -1331,7 +1378,7 @@ def archive_operation(self, _):
logging.debug("handle_archive_operation")
ret = QMessageBox.warning(
self.ui, self.tr("Mission Support System"),
self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"),
plain_text_as_html(self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?")),
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if ret == QMessageBox.Yes:
req = UpdateOperationRequest(op_id=self.active_op_id, attribute="active", value="False")
Expand Down
32 changes: 6 additions & 26 deletions mslib/msui/mscolab_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,16 @@
from markdown import Markdown
from markdown.extensions import Extension
from markdown.treeprocessors import Treeprocessor
from urllib.parse import urljoin, urlsplit
from urllib.parse import urljoin

from mslib.mscolab.api.attachments import DEFAULT_ATTACHMENT_EXTENSIONS, IMAGE_EXTENSIONS, file_extension
from mslib.mscolab.api.message_type import MessageType
from mslib.msui.socket_control import response_message
from PyQt5 import QtCore, QtGui, QtWidgets
from mslib.utils.qt import get_open_filename, get_save_filename, show_popup
from mslib.utils.qt import get_open_filename, get_save_filename, plain_text_message_box, show_popup
from mslib.msui.qt5 import ui_mscolab_operation_window as ui
from mslib.utils.config import config_loader
from mslib.utils.auth import same_origin
from mslib.mscolab.api import endpoints
from mslib.mscolab.api.schemas import (
FetchProfileImageRequest,
Expand Down Expand Up @@ -639,20 +640,6 @@ def closeEvent(self, event):
self.viewCloses.emit()


def _same_origin(url, base_url):
"""
True if url has the scheme, host and port of base_url
"""
default_ports = {"http": 80, "https": 443}
try:
a, b = urlsplit(url), urlsplit(base_url)
return (a.scheme.lower() == b.scheme.lower() and a.hostname == b.hostname and
(a.port or default_ports.get(a.scheme.lower())) == (b.port or default_ports.get(b.scheme.lower())))
except ValueError:
# e.g. an invalid port
return False


class MessageItem(QtWidgets.QWidget):
def __init__(self, message, chat_window):
super().__init__()
Expand Down Expand Up @@ -691,7 +678,7 @@ def attachment_url(self):
path = path.replace('\\', '/').split('colabdata')[-1]
server_url = self.chat_window.mscolab_server_url
url = urljoin(server_url, path)
if not _same_origin(url, server_url):
if not same_origin(url, server_url):
logging.warning("attachment %r is not on the MSColab server %s, ignored", path, server_url)
return None
return url
Expand Down Expand Up @@ -975,27 +962,20 @@ def chat_link_target(link):
return url


def _plain_text_box(icon, title, text, buttons, parent):
# the text contains the URL chosen by the author of the message, it must not be interpreted as markup
box = QtWidgets.QMessageBox(icon, title, text, buttons, parent)
box.setTextFormat(QtCore.Qt.PlainText)
return box


def open_chat_link(parent, url):
"""Open a link of a chat message after the user has seen and confirmed its real target.

The label of a link is chosen by its author, so the full URL is shown, as plain text.
"""
target = chat_link_target(url)
if target is None:
_plain_text_box(
plain_text_message_box(
QtWidgets.QMessageBox.Warning, "Link not opened",
"Only http and https links to a host can be opened from the chat. This link was not opened:\n\n"
f"{url.toString(QtCore.QUrl.FullyEncoded)}",
QtWidgets.QMessageBox.Ok, parent).exec_()
return
box = _plain_text_box(
box = plain_text_message_box(
QtWidgets.QMessageBox.Question, "Open link?",
f"Do you want to open this link in your browser?\n\nHost: {target.host(QtCore.QUrl.FullyEncoded)}\n\n"
f"{target.toString(QtCore.QUrl.FullyEncoded)}",
Expand Down
3 changes: 2 additions & 1 deletion mslib/msui/msui.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from mslib.utils import setup_logging
from mslib.msui.icons import icons
from mslib.utils.config import read_config_file
from mslib.utils.qt import plain_text_as_html
from PyQt5 import QtGui, QtCore, QtWidgets

# Add config path to PYTHONPATH so plugins located there may be found
Expand Down Expand Up @@ -117,7 +118,7 @@ def notify(QObject, QEvent):
"""

ret = QtWidgets.QMessageBox.question(mainwindow, 'Update of msui_settings.json file',
text,
plain_text_as_html(text),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No)
if ret == QtWidgets.QMessageBox.Yes:
Expand Down
Loading
Loading