Skip to content

Commit 88a3fe8

Browse files
authored
Merge pull request #874 from MerginMaps/dev-2026.2.0
Release 2026.2.0
2 parents 5c33894 + ffeeb6c commit 88a3fe8

5 files changed

Lines changed: 167 additions & 18 deletions

File tree

.github/workflows/packages.yml

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ on:
1010
type: string
1111
REQUESTED_GEODIFF_VER:
1212
description: 'Geodiff version'
13-
default: '2.1.2'
13+
default: '2.2.0'
1414
type: string
1515
env:
1616
PYTHON_API_CLIENT_VER: ${{ inputs.REQUESTED_PYTHON_API_CLIENT_VER || '0.12.2' }}
17-
GEODIFF_VER: ${{ inputs.REQUESTED_GEODIFF_VER || '2.1.2' }}
17+
GEODIFF_VER: ${{ inputs.REQUESTED_GEODIFF_VER || '2.2.0' }}
1818
PYTHON_VER: "38"
1919
jobs:
2020
build_linux_binary:
@@ -61,17 +61,11 @@ jobs:
6161
run: |
6262
choco install unzip
6363
64-
- name: Download pygeodiff 32 binaries
65-
run: |
66-
pip3 download --only-binary=:all: --no-deps --platform "win32" --python-version $env:PYTHON_VER pygeodiff==$env:GEODIFF_VER
67-
unzip -o pygeodiff-$env:GEODIFF_VER-cp$env:PYTHON_VER-cp$env:PYTHON_VER-win32.whl -d tmp32
68-
mkdir pygeodiff-binaries
69-
copy tmp32\pygeodiff\*.pyd pygeodiff-binaries\
70-
7164
- name: Download pygeodiff 64 binaries
7265
run: |
7366
pip3 download --only-binary=:all: --no-deps --platform "win_amd64" --python-version $env:PYTHON_VER pygeodiff==$env:GEODIFF_VER
7467
unzip -o pygeodiff-$env:GEODIFF_VER-cp$env:PYTHON_VER-cp$env:PYTHON_VER-win_amd64.whl -d tmp64
68+
mkdir pygeodiff-binaries
7569
copy tmp64\pygeodiff\*.pyd pygeodiff-binaries\
7670
7771
- uses: actions/upload-artifact@v4

Mergin/plugin.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
icon_path,
4444
mm_symbol_path,
4545
mergin_project_local_path,
46-
PROJS_PER_PAGE,
4746
remove_project_variables,
4847
set_qgis_project_mergin_variables,
4948
unsaved_project_check,
@@ -55,6 +54,7 @@
5554
AuthTokenExpiredError,
5655
set_qgsexpressionscontext,
5756
get_authcfg,
57+
AuthSync,
5858
)
5959

6060
from .mergin.merginproject import MerginProject
@@ -478,6 +478,7 @@ def create_new_project(self):
478478

479479
def current_project_sync(self):
480480
"""Synchronise current Mergin Maps project."""
481+
AuthSync().export_auth(self.mc)
481482
self.manager.project_status(self.mergin_proj_dir)
482483

483484
def find_project(self):
@@ -551,6 +552,8 @@ def add_context_menu_actions(self, layers):
551552
self.iface.addCustomActionForLayer(self.action_export_mbtiles, l)
552553

553554
def unload(self):
555+
from .utils import pygeodiff
556+
554557
if self.iface is not None:
555558
# Disconnect Mergin related signals
556559
self.iface.projectRead.disconnect(self.on_qgis_project_changed)
@@ -576,6 +579,8 @@ def unload(self):
576579
QgsExpressionContextUtils.removeGlobalVariable("mm_user_email")
577580
QgsApplication.instance().dataItemProviderRegistry().removeProvider(self.data_item_provider)
578581
self.data_item_provider = None
582+
# unload pygeodiff to avoid .pyd to be write-protected and thus impossible to delete on Windows
583+
pygeodiff.shutdown()
579584
# this is crashing qgis on exit
580585
# self.iface.browserModel().reload()
581586

Mergin/projects_manager.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@
3636
UnsavedChangesStrategy,
3737
write_project_variables,
3838
bytes_to_human_size,
39+
is_file_changed,
3940
get_push_changes_batch,
4041
SYNC_ATTEMPTS,
4142
SYNC_ATTEMPT_WAIT,
4243
push_error_message,
4344
)
44-
from .utils_auth import get_stored_mergin_server_url
45+
from .utils_auth import AuthSync, AUTH_CONFIG_FILENAME
4546

4647
from .mergin.merginproject import MerginProject
4748
from .project_status_dialog import ProjectStatusDialog
@@ -73,6 +74,9 @@ def open_project(self, project_dir):
7374

7475
qgis_files = find_qgis_files(project_dir)
7576
if len(qgis_files) == 1:
77+
# singleton project object is not in the interface yet, we need to pass the qgis file to retrieve the project id
78+
AuthSync(qgis_files[0]).import_auth()
79+
7680
iface.addProject(qgis_files[0])
7781
if self.mc.has_unfinished_pull(project_dir):
7882
widget = iface.messageBar().createMessage(
@@ -438,12 +442,17 @@ def sync_project(self, project_dir, project_name=None):
438442

439443
if dlg.pull_conflicts:
440444
self.report_conflicts(dlg.pull_conflicts)
445+
if is_file_changed(pull_changes, AUTH_CONFIG_FILENAME):
446+
AuthSync().import_auth()
441447
return
442448

443449
if not dlg.is_complete:
444450
# we were cancelled
445451
return
446452

453+
if is_file_changed(pull_changes, AUTH_CONFIG_FILENAME):
454+
AuthSync().import_auth()
455+
447456
dlg = SyncDialog()
448457
dlg.labelStatus.setText("Preparing project upload...")
449458
dlg.push_start(self.mc, project_dir, project_name)
@@ -493,6 +502,7 @@ def sync_project(self, project_dir, project_name=None):
493502
if not dlg.is_complete:
494503
# we were cancelled
495504
return
505+
496506
_, has_push_changes = get_push_changes_batch(self.mc, project_dir)
497507
error_retries_attempts = 0
498508
if not has_push_changes:

Mergin/utils.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import shutil
55
from datetime import datetime, timezone
66
from enum import Enum
7-
from typing import Any
7+
from typing import Any, Dict, List
88
from urllib.error import URLError, HTTPError
99
import configparser
1010
import os
@@ -1063,11 +1063,8 @@ def mm_symbol_path():
10631063

10641064
def check_mergin_subdirs(directory):
10651065
"""Check if the directory has a Mergin Maps project subdir (.mergin)."""
1066-
for root, dirs, files in os.walk(directory):
1067-
for name in dirs:
1068-
if name == ".mergin":
1069-
return os.path.join(root, name)
1070-
return False
1066+
mergin_dir = os.path.join(directory, ".mergin")
1067+
return mergin_dir if os.path.isdir(mergin_dir) else False
10711068

10721069

10731070
def is_number(s):
@@ -1728,6 +1725,11 @@ def escape_html_minimal(s: str) -> str:
17281725
return s
17291726

17301727

1728+
def is_file_changed(changes: Dict[str, List[dict]], filename: str) -> bool:
1729+
"""Check whether a file is added or updated"""
1730+
return any(f.get("path") == filename for key in ["added", "updated"] for f in changes.get(key, []))
1731+
1732+
17311733
def sanitize_path(expr: str) -> str:
17321734
if not expr:
17331735
return expr

Mergin/utils_auth.py

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import datetime
1+
# GPLv3 license
2+
# Copyright Lutra Consulting Limited
3+
4+
import hashlib
5+
import os
6+
import re
27
import typing
38
import uuid
49
import json
@@ -14,16 +19,23 @@
1419
QgsNetworkAccessManager,
1520
QgsExpressionContextUtils,
1621
Qgis,
22+
QgsProject,
23+
QgsProviderRegistry,
1724
)
1825
from qgis.PyQt.QtCore import QSettings, QUrl
1926
from qgis.PyQt.QtNetwork import QNetworkRequest
27+
from qgis.PyQt.QtWidgets import QMessageBox
2028

2129
from .mergin.client import MerginClient, ServerType, AuthTokenExpiredError
2230
from .mergin.common import ClientError, LoginError
31+
from .mergin.merginproject import MerginProject
2332

2433
from .utils import MERGIN_URL, get_qgis_proxy_config, get_plugin_version
2534

2635

36+
AUTH_CONFIG_FILENAME = "qgis_cfg.xml"
37+
38+
2739
class LoginType(Enum):
2840
"""Types of login supported by Mergin Maps."""
2941

@@ -552,3 +564,129 @@ def qgis_support_sso() -> bool:
552564
"""
553565
# QGIS 3.40+ supports SSO
554566
return Qgis.versionInt() >= 34000
567+
568+
569+
class AuthSync:
570+
def __init__(self, qgis_file=None):
571+
if qgis_file is None:
572+
self.project = QgsProject.instance()
573+
else:
574+
self.project = QgsProject()
575+
self.project.read(qgis_file)
576+
self.project_path = self.project.homePath()
577+
self.auth_file = os.path.join(self.project_path, AUTH_CONFIG_FILENAME)
578+
self.mp = MerginProject(self.project_path)
579+
self.project_id = self.mp.project_id()
580+
self.auth_mngr = QgsApplication.authManager()
581+
582+
def get_layers_auth_ids(self) -> typing.List[str]:
583+
"""Get the auth config IDs of the protected layers in the current project."""
584+
auth_ids = set()
585+
reg = QgsProviderRegistry.instance()
586+
for layer in self.project.mapLayers().values():
587+
source = layer.source()
588+
prov_type = layer.providerType()
589+
decoded_uri = reg.decodeUri(prov_type, source)
590+
auth_id = decoded_uri.get("authcfg")
591+
if auth_id:
592+
auth_ids.add(auth_id)
593+
return list(auth_ids)
594+
595+
def get_auth_config_hash(self, auth_ids: typing.List[str]) -> str:
596+
"""
597+
Generates a stable hash from the decrypted content of the given auth IDs.
598+
This allows us to detect config changes regardless of random encryption salts in the encrypted XML file.
599+
"""
600+
sorted_ids = sorted(auth_ids)
601+
602+
hasher = hashlib.sha256()
603+
604+
for auth_id in sorted_ids:
605+
config = QgsAuthMethodConfig()
606+
if not self.auth_mngr.loadAuthenticationConfig(auth_id, config, True): # True to decrypt full details
607+
self.mp.log.error(f"Failed to load the authentication config for the auth ID: {auth_id}")
608+
continue
609+
610+
header_data = f"{config.id()}|{config.method()}|{config.uri()}"
611+
hasher.update(header_data.encode("utf-8"))
612+
613+
config_map = config.configMap()
614+
for key in sorted(config_map.keys()):
615+
entry = f"|{key}={config_map[key]}"
616+
hasher.update(entry.encode("utf-8"))
617+
618+
return hasher.hexdigest()
619+
620+
def export_auth(self, client) -> None:
621+
"""Export auth DB credentials for protected layers if they have changed"""
622+
623+
# permission check - auth config .xml file can be modified from the writer role above
624+
project_info = client.project_info(self.mp.project_full_name())
625+
role = project_info.get("role")
626+
if not (role and role in ("writer", "owner")):
627+
return
628+
629+
referenced_ids = self.get_layers_auth_ids()
630+
available_ids = self.auth_mngr.configIds()
631+
auth_ids = [aid for aid in referenced_ids if aid in available_ids]
632+
if not auth_ids:
633+
if os.path.exists(self.auth_file):
634+
os.remove(self.auth_file)
635+
return
636+
637+
if not self.auth_mngr.masterPasswordIsSet():
638+
self.mp.log.warning("Master Password not set. Cannot export auth configs.")
639+
msg = "Failed to export authentication configuration. If you want to share the credentials of the protected layer(s), set the master password please."
640+
QMessageBox.warning(
641+
None, "Cannot export configuration for protected layer", msg, QMessageBox.StandardButton.Close
642+
)
643+
return
644+
645+
current_hash = self.get_auth_config_hash(auth_ids)
646+
647+
# Compare current hash with the hash in the existing file
648+
file_exists = os.path.exists(self.auth_file)
649+
if file_exists:
650+
with open(self.auth_file, "r", encoding="utf-8") as f:
651+
content = f.read()
652+
pattern = r"<!--\s*HASH:\s*([A-Za-z0-9]+)\s*-->"
653+
match = re.search(pattern, content)
654+
if match:
655+
existing_hash = match.group(1)
656+
if existing_hash == current_hash:
657+
self.mp.log.info("No change in auth config. No update needed.")
658+
return
659+
else:
660+
self.mp.log.info("Auth config file change detected. Updating file...")
661+
else:
662+
self.mp.log.warning("No hash found in existing config file. Creating one...")
663+
664+
# Export and inject hash
665+
temp_file = os.path.join(self.project_path, f"temp_{AUTH_CONFIG_FILENAME}")
666+
667+
ok = self.auth_mngr.exportAuthenticationConfigsToXml(temp_file, list(auth_ids), self.project_id)
668+
669+
if ok:
670+
with open(temp_file, "r", encoding="utf-8") as f:
671+
xml_content = f.read()
672+
673+
hashed_content = xml_content + f"\n<!-- HASH: {current_hash} -->"
674+
675+
with open(self.auth_file, "w", encoding="utf-8") as f:
676+
f.write(hashed_content)
677+
678+
if os.path.exists(temp_file):
679+
os.remove(temp_file)
680+
681+
def import_auth(self) -> None:
682+
"""Import credentials for protected layers"""
683+
684+
if os.path.isfile(self.auth_file):
685+
if not self.auth_mngr.masterPasswordIsSet():
686+
self.mp.log.warning("Master password is not set. Could not import auth config.")
687+
user_msg = "Could not import authentication configuration for the protected layer(s). Set the master password and reload the project if you want to access the protected layer(s)."
688+
QMessageBox.warning(None, "Could not load protected layer", user_msg, QMessageBox.StandardButton.Close)
689+
return
690+
691+
ok = self.auth_mngr.importAuthenticationConfigsFromXml(self.auth_file, self.project_id, overwrite=True)
692+
self.mp.log.info(f"QGIS auth imported: {ok}")

0 commit comments

Comments
 (0)