Skip to content

Commit 2183811

Browse files
committed
Merge remote-tracking branch 'origin/develop' into dev-r85-v2-pull
2 parents ab7aeda + 08e99c0 commit 2183811

File tree

11 files changed

+68
-15
lines changed

11 files changed

+68
-15
lines changed

server/mergin/sync/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,6 @@ class Configuration(object):
7777
)
7878
# whether client can pull using v2 apis
7979
V2_PULL_ENABLED = config("V2_PULL_ENABLED", default=True, cast=bool)
80+
EXCLUDED_CLONE_FILENAMES = config(
81+
"EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv()
82+
)

server/mergin/sync/permissions.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,21 @@ def require_project(ws, project_name, permission) -> Project:
209209
return project
210210

211211

212-
def require_project_by_uuid(uuid: str, permission: ProjectPermissions, scheduled=False):
212+
def require_project_by_uuid(
213+
uuid: str, permission: ProjectPermissions, scheduled=False, expose=True
214+
) -> Project:
215+
"""
216+
Retrieves a project by UUID after validating existence, workspace status, and permissions.
217+
218+
Args:
219+
uuid (str): The unique identifier of the project.
220+
permission (ProjectPermissions): The permission level required to access the project.
221+
scheduled (bool, optional): If ``True``, bypasses the check for projects marked for deletion.
222+
expose (bool, optional): Controls security disclosure behavior on permission failure.
223+
- If `True`: Returns 403 Forbidden (reveals project exists but access is denied).
224+
- If `False`: Returns 404 Not Found (hides project existence for security).
225+
Standard is that reading results in 404, while writing results in 403
226+
"""
213227
if not is_valid_uuid(uuid):
214228
abort(404)
215229

@@ -219,13 +233,18 @@ def require_project_by_uuid(uuid: str, permission: ProjectPermissions, scheduled
219233
if not scheduled:
220234
project = project.filter(Project.removed_at.is_(None))
221235
project = project.first_or_404()
236+
if not expose and current_user.is_anonymous and not project.public:
237+
# we don't want to tell anonymous user if a private project exists
238+
abort(404)
239+
222240
workspace = project.workspace
223241
if not workspace:
224242
abort(404)
225243
if not is_active_workspace(workspace):
226244
abort(404, "Workspace doesn't exist")
227245
if not permission.check(project, current_user):
228246
abort(403, "You do not have permissions for this project")
247+
229248
return project
230249

231250

server/mergin/sync/public_api_controller.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1109,9 +1109,12 @@ def clone_project(namespace, project_name): # noqa: E501
11091109
)
11101110
p.updated = datetime.utcnow()
11111111
db.session.add(p)
1112+
files_to_exclude = current_app.config.get("EXCLUDED_CLONE_FILENAMES", [])
11121113

11131114
try:
1114-
p.storage.initialize(template_project=cloned_project)
1115+
p.storage.initialize(
1116+
template_project=cloned_project, excluded_files=files_to_exclude
1117+
)
11151118
except InitializationError as e:
11161119
abort(400, f"Failed to clone project: {str(e)}")
11171120

@@ -1122,6 +1125,8 @@ def clone_project(namespace, project_name): # noqa: E501
11221125
# transform source files to new uploaded files
11231126
file_changes = []
11241127
for file in cloned_project.files:
1128+
if os.path.basename(file.path) in files_to_exclude:
1129+
continue
11251130
file_changes.append(
11261131
ProjectFileChange(
11271132
file.path,

server/mergin/sync/public_api_v2.yaml

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ paths:
8787
description: Include list of files at specific version
8888
required: false
8989
schema:
90-
type: string
91-
example: v3
90+
$ref: "#/components/schemas/VersionName"
9291
responses:
9392
"200":
9493
description: Success
@@ -345,9 +344,7 @@ paths:
345344
default: false
346345
example: true
347346
version:
348-
type: string
349-
pattern: '^$|^v\d+$'
350-
example: v2
347+
$ref: "#/components/schemas/VersionName"
351348
changes:
352349
type: object
353350
required:
@@ -975,5 +972,5 @@ components:
975972
$ref: "#/components/schemas/ProjectDeltaChange"
976973
VersionName:
977974
type: string
978-
pattern: '^v\d+$'
975+
pattern: '^$|^v\d+$'
979976
example: v2

server/mergin/sync/public_api_v2_controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def download_diff_file(id: str, file: str):
193193

194194
def get_project(id, files_at_version=None):
195195
"""Get project info. Include list of files at specific version if requested."""
196-
project = require_project_by_uuid(id, ProjectPermissions.Read)
196+
project = require_project_by_uuid(id, ProjectPermissions.Read, expose=False)
197197
data = ProjectSchemaV2().dump(project)
198198
if files_at_version:
199199
pv = ProjectVersion.query.filter_by(

server/mergin/sync/storages/disk.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def _project_dir(self):
179179
)
180180
return project_dir
181181

182-
def initialize(self, template_project=None):
182+
def initialize(self, template_project=None, excluded_files=None):
183183
if os.path.exists(self.project_dir):
184184
raise InitializationError(
185185
"Project directory already exists: {}".format(self.project_dir)
@@ -194,8 +194,12 @@ def initialize(self, template_project=None):
194194
if ws.disk_usage() + template_project.disk_usage > ws.storage:
195195
self.delete()
196196
raise InitializationError("Disk quota reached")
197+
if excluded_files is None:
198+
excluded_files = []
197199

198200
for file in template_project.files:
201+
if os.path.basename(file.path) in excluded_files:
202+
continue
199203
src = os.path.join(template_project.storage.project_dir, file.location)
200204
dest = os.path.join(
201205
self.project_dir,

server/mergin/tests/test_project_controller.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,16 +1731,21 @@ def test_clone_project(client, data, username, expected):
17311731
assert resp.json["code"] == "StorageLimitHit"
17321732
assert resp.json["detail"] == "You have reached a data limit (StorageLimitHit)"
17331733
if expected == 200:
1734+
excluded_filenames = current_app.config.get("EXCLUDED_CLONE_FILENAMES")
1735+
17341736
proj = data.get("project", test_project).strip()
17351737
template = Project.query.filter_by(
17361738
name=test_project, workspace_id=test_workspace_id
17371739
).first()
17381740
project = Project.query.filter_by(
17391741
name=proj, workspace_id=test_workspace_id
17401742
).first()
1743+
template_files_filtered = [
1744+
f for f in template.files if f.path not in excluded_filenames
1745+
]
17411746
assert not any(
17421747
x.checksum != y.checksum and x.path != y.path
1743-
for x, y in zip(project.files, template.files)
1748+
for x, y in zip(project.files, template_files_filtered)
17441749
)
17451750
assert os.path.exists(
17461751
os.path.join(project.storage.project_dir, project.files[0].location)
@@ -1758,6 +1763,12 @@ def test_clone_project(client, data, username, expected):
17581763
item for item in changes if item.change == PushChangeType.UPDATE.value
17591764
]
17601765
assert pv.device_id == json_headers["X-Device-Id"]
1766+
1767+
assert not any(f.path == excluded_filenames[0] for f in project.files)
1768+
assert not os.path.exists(
1769+
os.path.join(project.storage.project_dir, excluded_filenames[0])
1770+
)
1771+
assert len(project.files) == len(template.files) - 1
17611772
# cleanup
17621773
shutil.rmtree(project.storage.project_dir)
17631774

@@ -1988,7 +1999,7 @@ def test_get_projects_by_uuids(client):
19881999
{"page": 1, "per_page": 5, "desc": False},
19892000
200,
19902001
"v1",
1991-
{"added": 12, "removed": 0, "updated": 0, "updated_diff": 0},
2002+
{"added": 13, "removed": 0, "updated": 0, "updated_diff": 0},
19922003
),
19932004
(
19942005
{"page": 2, "per_page": 3, "desc": True},
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<!DOCTYPE qgis_authentication>
2+
<qgis_authentication salt="852cd01b5ba209804a5d068f5b28c448" hash="cab305f8ee1500ff912f9d68375b49e8" civ="d5472f8b9ad315bdd250e44a3419a2b8b3c9fd01956c002220520097bfbcb099">0737fe398eb9f26bd847fb9da2407646a2e8c89dc2f93eba5a059f19eedd8017e50c557d2c7d435a2701d881cdaf4fbbd3a892e4367053b5bfc348b556ae252314d9b06fc70a4f184362d064023c1ed6c4dd7ee14dce10ea91595e8548f7ba3d3eaca1d41063f50d1ccc12bfb90c059271254dca780e0d60e68bd234844fda81a0781977907485b397aa1263aef81863625eb439dc349fca0dbc641b4a606657f17e55d2c02fbc95388bf9f96977c65fcf7b723689d5fcdaf73190a5597425b3d33c858c2c4ef8c334b5f601c98db05557c8f690cdb9f73c725bf7ee420fffb6037cc9e80c7374a55ab55baf4aaa7f1c957fd40bb69b9fcb41ec42b063330bbddcd73f4de69e47772309167cb20ef4fe3250db96c29b71772edd18c7e73c501d569f4f8deda15fb0bcf6701d81902a6fc6c722db9e0d766d18a45297232224738c07a1a4f8fe490954efcae05fc1e43eac4eb5efc8b9008dcff4cf3688db4b7e268c9adf75d88d4d1d3648232c6fc2ac98b49b3bbb19368ce460b4a7a9828558d473eb0f1ba34c09f1ba9ce0170ac6d6c656176760e4012c56daef3f5f05320d5f84260d2e6b5a0b15620c33802d1c8c2f28084eef63b32f8130edd4789972b25960e12eb79351d11316f78aea3a941b9e7f1f4042f708d873ac7807ce0652819b2e2f77f9aac1c50cf72d2341118c41419f5f4c31474d8dbe56558dab9cb4b8e4fe8df9c8b4a057d9c6fe6b098b78e150aadd2a45cf1ea15e02f8f1f8b1b46d1a5513c26a63ea08788675feaa912e884ceee57adc120393c8a5bc42988f7b210195f6eff5de3e332d0d67321d05b907f836eb0f0f9e97388f89b699638804978639ad8b4c889f1a56952f949242679506cba5cc35538ea01b1621dd6a154f92b721b5247e294a5394df9c87765675b737dcb28346fc4032b68f87f46150aa4aa136378903036aff61fd41cf0cbcdd0865660f26d7f1d49f29ff5962adc209b9db71d12bf49bf67950496f18ca1de0a5cd7186e1bc0fcf826ffd1bb91ab36412c43730db5ff9ec57990fee27c5158446294bf0d8e61e12ee53e80b606b541c754ed45b2289079df8b647a8ca12fb1706e371523a581af50d333adfe5e84bcce2a60e84e24bdc1eb74610bc28b279b15c4f2020b045d2e4a7f846e488d74e761d98c05f105452235f602b3fe8beccf4b11d35ba6042dcc97f68090f40edbd6e8497434c193343cca98ebeabdd8620ec7eec642efda7cd45f0a9547ea821ac193eb1a8fb8c9c71d2e607b4651de5b8b613bc38aa4ba06bdb65a3d6b6e92546f1a4113e0bbce99aadbab3bbb07f31d6f90b3ff58b4494815e97a265c1c5e8a826bf14177427e03247395a18941753c0e580c42661a9c959ad57b93b97fb4adeca49927f3bec95eff361e95c324623f1c7c4d39e71250938d4189461cd6c1e978a5445f88eaf47670f23145cb7c8faf42ac83158743004fefb17a37a25edcf2425d530dd12ca52fdcfc399542cd288773c06931ce9aaac94df69dc6514fa3b1b8629dcfe725c0dcd77b5db967c5620dbc2444f4b78fb247e33a54ed2cbcaba3b92833b6d75b4900697da646f04da9a04d6353556b0ab70f8dd952eed9bd9cee1d53e760b292080862a74f625eb402662aadd94efaa6cce0727d3ccab5b6e112f25562effadfcf70307800e0d28976327576e99380facea2828ddb6a85addb4d4c0cfdb73cd848a9f707f8f978caf5de82756c80f42d53719987d5b4826397de8674d75dc1308dd3e96af37e9b3e42175dca1a5ff58a4aa4881a344113711a93340ee6515e5b9d03d1f4979531c84ec187b9303ea763b2641f530144cf52a81812349511219fc92bb038ec62d438c3beaf723</qgis_authentication>
3+
4+
<!-- HASH: c60949a4387b0efb717777df1cd9e3da459574109674fb0f483e59869e11fe1b -->

server/mergin/tests/test_public_api_v2.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
create_workspace,
1212
create_project,
1313
upload_file_to_project,
14+
login,
15+
file_info,
1416
)
1517

1618
from ..auth.models import User
@@ -76,7 +78,6 @@
7678
_get_changes_with_diff_0_size,
7779
_get_changes_without_added,
7880
)
79-
from .utils import add_user, file_info
8081

8182

8283
def test_schedule_delete_project(client):
@@ -195,6 +196,7 @@ def test_project_members(client):
195196
# access provided by workspace role cannot be removed directly
196197
response = client.delete(url + f"/{user.id}")
197198
assert response.status_code == 404
199+
Configuration.GLOBAL_READ = 0
198200

199201

200202
def test_file_diff_download(client, diff_project):
@@ -644,7 +646,12 @@ def test_get_project(client):
644646
test_workspace = create_workspace()
645647
project = create_project("new_project", test_workspace, admin)
646648
logout(client)
649+
# anonymous user cannot access the private resource
650+
response = client.get(f"v2/projects/{project.id}")
651+
assert response.status_code == 404
647652
# lack of permissions
653+
user = add_user("tests", "tests")
654+
login(client, user.username, "tests")
648655
response = client.get(f"v2/projects/{project.id}")
649656
assert response.status_code == 403
650657
# access public project
@@ -703,6 +710,9 @@ def test_get_project(client):
703710
)
704711
assert len(response.json["files"]) == 3
705712
assert {f["path"] for f in response.json["files"]} == set(files)
713+
# invalid version format parameter
714+
response = client.get(f"v2/projects/{project.id}?files_at_version=3")
715+
assert response.status_code == 400
706716

707717

708718
push_data = [

server/mergin/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55

66
def get_version():
7-
return "2025.7.3"
7+
return "2025.8.2"

0 commit comments

Comments
 (0)