6 Commits

Author SHA1 Message Date
david 9a935261e6 Update metadata.txt 2026-06-03 13:42:46 +02:00
david a4e30bf334 hardcoded AND filtering when filtering by period AND activity area (#39) 2026-06-02 22:25:29 +02:00
david 56389e27d7 nosec update (#38) 2026-05-19 15:44:36 +02:00
David Spáčil c8d42e2459 metadata.txt version update 2026-05-19 15:42:25 +02:00
david a6ebbce4cf Update metadata.txt 2026-05-19 15:25:38 +02:00
david 88149fbb30 Hotfix/security (#37)
* vyměnit xml.etree.ElementTree za defusedxml.ElementTree

* zpátky xml.etree.ElementTree, ale přidáno # nosec; jde o false positive

* switch z ukládání hesel v registrech na používání QGIS Auth Manager
2026-05-19 15:22:35 +02:00
4 changed files with 206 additions and 37 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
import os
import csv
import requests
import xml.etree.ElementTree as ET
import xml.etree.ElementTree as ET # nosec
import time
from qgis.core import QgsMessageLog, Qgis
@@ -101,7 +101,7 @@ def fetch_set(internal_name, api_set, task=None):
try:
response = requests.get(BASE_URL, params=params, timeout=30)
response.raise_for_status()
root = ET.fromstring(response.content)
root = ET.fromstring(response.content) # nosec
records = root.findall('.//oai:record', NS)
for rec in records:
+168 -32
View File
@@ -1,12 +1,11 @@
# -*- coding: utf-8 -*-
import base64
from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout,
QLineEdit, QDialogButtonBox,
QCheckBox, QGroupBox, QPushButton,
QListWidget, QListWidgetItem, QHBoxLayout,
QMessageBox, QLabel, QFormLayout)
from qgis.PyQt.QtCore import Qt, QSettings
from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis
from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis, QgsAuthMethodConfig
from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE,
OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY,
DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST,
@@ -352,12 +351,24 @@ class AmcrFilterDialog(QDialog):
class LoginDialog(QDialog):
"""
Dialog pro uložení přihlašovacích údajů do AMČR.
Ukládá do QSettings (username plaintext, heslo base64).
Dialog for saving AMČR login credentials securely in the QGIS Authentication Manager.
Credentials are encrypted by the platform's native secret storage
(DPAPI on Windows, Keychain on macOS, encrypted SQLite on Linux).
The auth config ID is persisted in QSettings so the session can be
restored automatically after a QGIS restart.
Note on QgsAuthManager quirks (QGIS 4 / Python bindings):
- hasConfigId() is unreliable it checks an in-memory cache that may not
be populated yet. We never use it as a hard gate; we skip it and call
loadAuthenticationConfig() directly instead.
- storeAuthenticationConfig() and loadAuthenticationConfig() both have
SIP_INOUT on their config parameter, so Python bindings return a tuple
(bool, QgsAuthMethodConfig) rather than just bool. Always unpack both.
"""
KEY_USER = "amcr_viewer/username"
KEY_PASS = "amcr_viewer/password"
SETTINGS_KEY = "amcr_viewer/auth_config_id"
CONFIG_NAME = "AMČR Viewer"
def __init__(self, parent=None):
super().__init__(parent)
@@ -366,14 +377,19 @@ class LoginDialog(QDialog):
layout = QVBoxLayout()
settings = QSettings()
has_saved = bool(settings.value(self.KEY_USER, ""))
# Check whether a config ID is already stored from a previous session.
# We attempt a lightweight load (full=False) to confirm it is readable,
# since hasConfigId() may return False even for valid configs (cache lag).
existing_id = QSettings().value(self.SETTINGS_KEY, "")
self._has_saved = bool(existing_id) and bool(self._load_username_from_config(existing_id))
if has_saved:
info = QLabel("✔ Přihlašovací údaje jsou uloženy. Vyplňte pole níže pro jejich změnu.")
if self._has_saved:
info = QLabel("✔ Přihlašovací údaje jsou bezpečně uloženy ve správci autentizace QGIS.\n"
"Vyplňte pole níže pouze pokud je chcete změnit.")
info.setStyleSheet("color: green; font-style: italic;")
else:
info = QLabel("Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\nVarování: přihlašovací údaje budou nezašifrovaně uloženy v registrech systému.")
info = QLabel("Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\n"
"Budou zašifrovaně uloženy ve správci autentizace QGIS.")
info.setWordWrap(True)
layout.addWidget(info)
layout.addSpacing(8)
@@ -382,20 +398,23 @@ class LoginDialog(QDialog):
self.txt_user = QLineEdit()
self.txt_user.setPlaceholderText("např. jan.novak@email.cz")
# Předvyplnit uložené jméno pro pohodlí
self.txt_user.setText(settings.value(self.KEY_USER, ""))
# Pre-fill the stored username (not sensitive) for convenience
if self._has_saved:
self.txt_user.setText(self._load_username_from_config(existing_id))
form.addRow("E-mail:", self.txt_user)
self.txt_pass = QLineEdit()
self.txt_pass.setEchoMode(QLineEdit.EchoMode.Password)
self.txt_pass.setPlaceholderText("heslo")
self.txt_pass.setPlaceholderText(
"ponechte prázdné pro zachování stávajícího hesla" if self._has_saved else "heslo"
)
form.addRow("Heslo:", self.txt_pass)
layout.addLayout(form)
layout.addSpacing(8)
if has_saved:
btn_forget = QPushButton("Zapomenout uložené přihlašovací údaje")
if self._has_saved:
btn_forget = QPushButton("Odebrat uložené přihlašovací údaje")
btn_forget.setStyleSheet("color: #c0392b;")
btn_forget.clicked.connect(self._forget_credentials)
layout.addWidget(btn_forget)
@@ -411,35 +430,152 @@ class LoginDialog(QDialog):
self.setLayout(layout)
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
@staticmethod
def _load_config(config_id: str, full: bool = False):
"""
Attempt to load a QgsAuthMethodConfig by ID.
Returns (ok, cfg). Never raises; returns (False, empty cfg) on any error.
full=True decrypts and includes the password.
"""
try:
auth_mgr = QgsApplication.authManager()
cfg = QgsAuthMethodConfig()
result = auth_mgr.loadAuthenticationConfig(config_id, cfg, full)
# Python bindings return (bool, cfg) due to SIP_INOUT parameter
if isinstance(result, tuple):
return result
return result, cfg
except Exception:
return False, QgsAuthMethodConfig()
def _load_username_from_config(self, config_id: str) -> str:
"""Load just the username from a stored config (no password decryption)."""
ok, cfg = self._load_config(config_id, full=False)
return cfg.config("username", "") if ok else ""
def _ensure_master_password(self) -> bool:
"""
Ensure the Auth Manager is unlocked before writing.
Prompts the user to set or enter the master password if needed.
Returns True if the manager is ready, False if the user cancelled.
"""
auth_mgr = QgsApplication.authManager()
if auth_mgr.isDisabled():
QMessageBox.critical(
self, "Správce autentizace nedostupný",
"Správce autentizace QGIS je zakázán nebo poškozený.\n"
"Zkuste obnovit databázi: Nastavení → Možnosti → Autentizace → Pomůcky."
)
return False
# setMasterPassword(True) shows the QGIS master password dialog if needed
if not auth_mgr.setMasterPassword(True):
return False # User cancelled the master password dialog
return True
# ------------------------------------------------------------------
# Button actions
# ------------------------------------------------------------------
def _save_and_accept(self):
username = self.txt_user.text().strip()
password = self.txt_pass.text()
if not username or not password:
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím uživatelské jméno i heslo.")
if not username:
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím e-mailovou adresu.")
return
existing_id = QSettings().value(self.SETTINGS_KEY, "")
auth_mgr = QgsApplication.authManager()
# If a config already exists and the password field is blank,
# update only the username and keep the existing encrypted password.
if not password and existing_id:
ok, cfg = self._load_config(existing_id, full=True)
if ok:
if not self._ensure_master_password():
return
cfg.setConfig("username", username)
auth_mgr.updateAuthenticationConfig(cfg)
self.accept()
return
if not password:
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím heslo.")
return
if not self._ensure_master_password():
return
cfg = QgsAuthMethodConfig()
cfg.setName(self.CONFIG_NAME)
cfg.setMethod("Basic")
cfg.setConfig("username", username)
cfg.setConfig("password", password) # nosec B106
settings = QSettings()
settings.setValue(self.KEY_USER, username)
# base64 není šifrování, ale heslo aspoň neleží v plaintextu v registru
settings.setValue(self.KEY_PASS, base64.b64encode(password.encode()).decode())
# Try to update an existing config first; fall back to creating a new one.
# We skip hasConfigId() as it may return False despite the config existing
# (in-memory cache may not be populated yet in QGIS 4).
ok_load, existing_cfg = self._load_config(existing_id, full=False) if existing_id else (False, None)
if ok_load:
cfg.setId(existing_id)
ok, cfg = auth_mgr.updateAuthenticationConfig(cfg)
else:
ok, cfg = auth_mgr.storeAuthenticationConfig(cfg)
config_id = cfg.id() if cfg else ""
if not ok or not config_id:
QMessageBox.critical(
self, "Chyba uložení",
"Přihlašovací údaje se nepodařilo uložit do správce autentizace QGIS.\n"
"Zkuste restartovat QGIS a přihlásit se znovu."
)
return
settings.setValue(self.SETTINGS_KEY, config_id)
self.accept()
def _forget_credentials(self):
settings = QSettings()
settings.remove(self.KEY_USER)
settings.remove(self.KEY_PASS)
QMessageBox.information(self, "Hotovo", "Přihlašovací údaje byly odstraněny.")
existing_id = settings.value(self.SETTINGS_KEY, "")
if existing_id:
QgsApplication.authManager().removeAuthenticationConfig(existing_id)
settings.remove(self.SETTINGS_KEY)
QMessageBox.information(self, "Hotovo", "Uložené přihlašovací údaje byly odebrány.")
self.reject()
# ------------------------------------------------------------------
# Public static API call this anywhere in the plugin to get credentials
# ------------------------------------------------------------------
@staticmethod
def get_credentials() -> tuple[str, str]:
"""Vrátí (username, password) z QSettings, nebo ('', '') pokud nejsou uloženy."""
"""
Retrieve (username, password) from the QGIS Authentication Manager.
Returns ('', '') if no credentials are stored or the manager is locked.
Note: hasConfigId() is intentionally skipped here it checks an
in-memory cache that may lag behind the actual database contents,
causing false negatives (see class docstring). loadAuthenticationConfig()
is called directly and its return value is used as the authoritative result.
"""
settings = QSettings()
username = settings.value(LoginDialog.KEY_USER, "")
encoded = settings.value(LoginDialog.KEY_PASS, "")
try:
password = base64.b64decode(encoded.encode()).decode() if encoded else ""
except Exception:
password = ""
return username, password
config_id = settings.value(LoginDialog.SETTINGS_KEY, "")
if not config_id:
return "", ""
ok, cfg = LoginDialog._load_config(config_id, full=True)
if not ok:
return "", ""
return cfg.config("username", ""), cfg.config("password", "") # nosec B106
+25 -1
View File
@@ -142,6 +142,13 @@ def tr_code(code):
return ""
return TRANSLATIONS.get(code, code)
def komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
if filter_areal and komp.get('komponenta_areal', {}).get('id', "") not in filters.get('f_areal', []):
return False
if filter_datace and komp.get('komponenta_obdobi', {}).get('id', "") not in filters.get('f_obdobi', []):
return False
return True
def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false"):
"""
Main processing function:
@@ -203,6 +210,10 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
# Check if we should skip negative results based on filter
skip_negativni = filters.get('posevidence') == 'true' if filters else False
# Check whether we should filter results based on component filters
filter_areal = "f_areal" in filters if filters else False
filter_datace = "f_obdobi" in filters if filters else False
# --- API PAGINATION LOOP ---
while True:
@@ -339,6 +350,14 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
if skip_negativni and dj.get('dj_negativni_jednotka') is True:
continue
komps = dj.get('dj_komponenta', [])
if filter_areal or filter_datace:
if not komps:
continue
if not any(komp_projde_filtrem(komp, filter_areal, filter_datace, filters) for komp in komps):
continue
dj_id = dj.get('ident_cely')
dj_typ = dj.get('dj_typ')
@@ -361,9 +380,11 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
if komponenty == "true":
# One feature per component — all data on a single row, no relations needed
komps = dj.get('dj_komponenta', [])
if komps:
for komp in komps:
if not komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
continue
komp_meta = {
**dj_meta,
'komponenta_id': komp.get('ident_cely', ""),
@@ -374,6 +395,9 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
target_pian_ids_count += 1
else:
# DJ without components — still include with empty component fields
if filter_areal or filter_datace:
continue
empty_meta = {
**dj_meta,
'komponenta_id': "",
+11 -2
View File
@@ -8,7 +8,7 @@ name=AMČR Viewer
qgisMinimumVersion=3.4.0
qgisMaximumVersion=4.99.0
description=Viewing and downloading the AMČR data.
version=2.0.0-alpha.1
version=2.0.0-alpha.4
author=David Spáčil
email=spacil@arub.cz
@@ -22,7 +22,16 @@ repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer
hasProcessingProvider=no
# Uncomment the following line and add your changelog:
# changelog=
changelog=
v2.0.0-alpha.4 (2026-06-03)
* Backend filtering of the results based on the component-related filters improvement (plugin not only loads the results from API, it filters them further)
v2.0.0-alpha.23 (2026-05-19)
* Security vulnerabilities fix
v2.0.0-alpha.1 (2026-05-19):
* Attribute fields renamed to be ASCII compliant
* Codelist update; codelist can be recompiled from AMČR API
* Basic element changed from Documentation Unit to Component if user asks for components to simplify result filtering
* Plugin now supports logging with an AMČR account and enables the downloading of Events and Sites available to the roles Researcher and higher
# Tags are comma separated with spaces allowed
tags=python,AMCR,AIS CR,archaeology,PIAN,AMČR