Skip to content

Commit 6762f82

Browse files
authored
Merge pull request #542 from MerginMaps/develop
Release 2025.8.2
2 parents 537429e + c34ac6f commit 6762f82

19 files changed

+440
-100
lines changed

server/.test.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ SECURITY_BEARER_SALT='bearer'
2424
SECURITY_EMAIL_SALT='email'
2525
SECURITY_PASSWORD_SALT='password'
2626
DIAGNOSTIC_LOGS_DIR=/tmp/diagnostic_logs
27+
GEVENT_WORKER=0

server/mergin/auth/forms.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (C) Lutra Consulting Limited
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
45
import re
56
import safe
67
from flask_wtf import FlaskForm
@@ -48,18 +49,22 @@ class ExtendedEmail(Email):
4849
1. spaces,
4950
2. special characters ,:;()<>[]\"
5051
3, multiple @ symbols,
51-
4, leading, trailing, or consecutive dots in the local part
52-
5, invalid domain part - missing top level domain (user@example), consecutive dots
53-
Custom check for additional invalid characters disallows |'— because they make our email sending service to fail
52+
4, leading, trailing, or consecutive dots in the local part,
53+
5, invalid domain part - missing top level domain (user@example), consecutive dots,
54+
The extended validation checks email addresses using the regex provided by Brevo,
55+
so that we stay consistent with their validation rules and avoid API failures.
5456
"""
5557

5658
def __call__(self, form, field):
5759
super().__call__(form, field)
5860

59-
if re.search(r"[|'—]", field.data):
60-
raise ValidationError(
61-
f"Email address '{field.data}' contains an invalid character."
62-
)
61+
email = field.data.strip()
62+
63+
pattern = r"^[\x60#&*\/=?^{!}~'+\w-]+(\.[\x60#&*\/=?^{!}~'+\w-]+)*\.?@([_a-zA-Z0-9-]+(\.[_a-zA-Z0-9-]+)*\.)[a-zA-Z0-9-]*[a-zA-Z0-9]{2,}$"
64+
email_regexp = re.compile(pattern, re.IGNORECASE)
65+
66+
if not email_regexp.match(email):
67+
raise ValidationError(f"Email address '{email}' is invalid.")
6368

6469

6570
class PasswordValidator:

server/mergin/sync/files.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313

1414
from .utils import (
1515
is_file_name_blacklisted,
16-
is_qgis,
1716
is_supported_extension,
1817
is_valid_path,
1918
is_versioned_file,
19+
has_trailing_space,
2020
)
2121
from ..app import DateTimeWithZ, ma
2222

@@ -212,14 +212,21 @@ def validate(self, data, **kwargs):
212212

213213
if not is_valid_path(file_path):
214214
raise ValidationError(
215-
f"Unsupported file name detected: {file_path}. Please remove the invalid characters."
215+
f"Unsupported file name detected: '{file_path}'. Please remove the invalid characters."
216216
)
217217

218218
if not is_supported_extension(file_path):
219219
raise ValidationError(
220-
f"Unsupported file type detected: {file_path}. "
220+
f"Unsupported file type detected: '{file_path}'. "
221221
f"Please remove the file or try compressing it into a ZIP file before uploading.",
222222
)
223+
# new checks must restrict only new files not to block existing projects
224+
for file in data["added"]:
225+
file_path = file["path"]
226+
if has_trailing_space(file_path):
227+
raise ValidationError(
228+
f"Folder name contains a trailing space. Please remove the space in: '{file_path}'."
229+
)
223230

224231

225232
class ProjectFileSchema(FileSchema):
@@ -230,5 +237,5 @@ class ProjectFileSchema(FileSchema):
230237
def patch_field(self, data, **kwargs):
231238
# drop 'diff' key entirely if empty or None as clients would expect
232239
if not data.get("diff"):
233-
data.pop("diff")
240+
data.pop("diff", None)
234241
return data

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: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,7 @@
5555
from .files import (
5656
ProjectFileChange,
5757
ChangesSchema,
58-
UploadFileSchema,
5958
ProjectFileSchema,
60-
FileSchema,
6159
files_changes_from_upload,
6260
mergin_secure_filename,
6361
)
@@ -83,17 +81,11 @@
8381
generate_checksum,
8482
Toucher,
8583
get_x_accel_uri,
86-
is_file_name_blacklisted,
8784
get_ip,
8885
get_user_agent,
8986
generate_location,
9087
is_valid_uuid,
91-
is_versioned_file,
92-
get_project_path,
9388
get_device_id,
94-
is_valid_path,
95-
is_supported_type,
96-
is_supported_extension,
9789
get_mimetype,
9890
wkb2wkt,
9991
)
@@ -980,7 +972,7 @@ def push_finish(transaction_id):
980972
if len(unsupported_files):
981973
abort(
982974
400,
983-
f"Unsupported file type detected: {unsupported_files[0]}. "
975+
f"Unsupported file type detected: '{unsupported_files[0]}'. "
984976
f"Please remove the file or try compressing it into a ZIP file before uploading.",
985977
)
986978

@@ -1036,14 +1028,6 @@ def push_finish(transaction_id):
10361028
# let's move uploaded files where they are expected to be
10371029
os.renames(files_dir, version_dir)
10381030

1039-
# remove used chunks
1040-
for file in upload.changes["added"] + upload.changes["updated"]:
1041-
file_chunks = file.get("chunks", [])
1042-
for chunk_id in file_chunks:
1043-
chunk_file = os.path.join(upload.upload_dir, "chunks", chunk_id)
1044-
if os.path.exists(chunk_file):
1045-
move_to_tmp(chunk_file)
1046-
10471031
logging.info(
10481032
f"Push finished for project: {project.id}, project version: {v_next_version}, transaction id: {transaction_id}."
10491033
)

server/mergin/sync/public_api_v2.yaml

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,34 @@ paths:
7676
"409":
7777
$ref: "#/components/responses/Conflict"
7878
x-openapi-router-controller: mergin.sync.public_api_v2_controller
79+
get:
80+
tags:
81+
- project
82+
summary: Get project info
83+
operationId: get_project
84+
parameters:
85+
- name: files_at_version
86+
in: query
87+
description: Include list of files at specific version
88+
required: false
89+
schema:
90+
$ref: "#/components/schemas/VersionName"
91+
responses:
92+
"200":
93+
description: Success
94+
content:
95+
application/json:
96+
schema:
97+
$ref: "#/components/schemas/ProjectDetail"
98+
"400":
99+
$ref: "#/components/responses/BadRequest"
100+
"401":
101+
$ref: "#/components/responses/Unauthorized"
102+
"403":
103+
$ref: "#/components/responses/Forbidden"
104+
"404":
105+
$ref: "#/components/responses/NotFound"
106+
x-openapi-router-controller: mergin.sync.public_api_v2_controller
79107
/projects/{id}/scheduleDelete:
80108
post:
81109
tags:
@@ -276,9 +304,7 @@ paths:
276304
default: false
277305
example: true
278306
version:
279-
type: string
280-
pattern: '^$|^v\d+$'
281-
example: v2
307+
$ref: "#/components/schemas/VersionName"
282308
changes:
283309
type: object
284310
required:
@@ -502,6 +528,72 @@ components:
502528
$ref: "#/components/schemas/ProjectRole"
503529
role:
504530
$ref: "#/components/schemas/Role"
531+
ProjectDetail:
532+
type: object
533+
required:
534+
- id
535+
- name
536+
- workspace
537+
- role
538+
- version
539+
- created_at
540+
- updated_at
541+
- public
542+
- size
543+
properties:
544+
id:
545+
type: string
546+
description: project uuid
547+
example: c1ae6439-0056-42df-a06d-79cc430dd7df
548+
name:
549+
type: string
550+
example: survey
551+
workspace:
552+
type: object
553+
properties:
554+
id:
555+
type: integer
556+
example: 123
557+
name:
558+
type: string
559+
example: mergin
560+
role:
561+
$ref: "#/components/schemas/ProjectRole"
562+
version:
563+
type: string
564+
description: latest project version
565+
example: v2
566+
created_at:
567+
type: string
568+
format: date-time
569+
description: project creation timestamp
570+
example: 2025-10-24T08:27:56Z
571+
updated_at:
572+
type: string
573+
format: date-time
574+
description: last project update timestamp
575+
example: 2025-10-24T08:28:00.279699Z
576+
public:
577+
type: boolean
578+
description: whether the project is public
579+
example: false
580+
size:
581+
type: integer
582+
description: project size in bytes for this version
583+
example: 17092380
584+
files:
585+
type: array
586+
description: List of files in the project
587+
items:
588+
allOf:
589+
- $ref: '#/components/schemas/File'
590+
- type: object
591+
properties:
592+
mtime:
593+
type: string
594+
format: date-time
595+
description: File modification timestamp
596+
example: 2024-11-19T13:50:00Z
505597
File:
506598
type: object
507599
description: Project file metadata
@@ -754,3 +846,7 @@ components:
754846
- editor
755847
- writer
756848
- owner
849+
VersionName:
850+
type: string
851+
pattern: '^$|^v\d+$'
852+
example: v2

server/mergin/sync/public_api_v2_controller.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
from marshmallow import ValidationError
1515
from sqlalchemy.exc import IntegrityError
1616

17+
from mergin.sync.tasks import remove_transaction_chunks
18+
19+
from .schemas_v2 import ProjectSchema as ProjectSchemaV2
1720
from ..app import db
1821
from ..auth import auth_required
1922
from ..auth.models import User
@@ -26,7 +29,7 @@
2629
StorageLimitHit,
2730
UploadError,
2831
)
29-
from .files import ChangesSchema
32+
from .files import ChangesSchema, ProjectFileSchema
3033
from .forms import project_name_validation
3134
from .models import (
3235
Project,
@@ -41,7 +44,6 @@
4144
from .public_api_controller import catch_sync_failure
4245
from .schemas import (
4346
ProjectMemberSchema,
44-
ProjectVersionSchema,
4547
UploadChunkSchema,
4648
ProjectSchema,
4749
)
@@ -162,6 +164,22 @@ def remove_project_collaborator(id, user_id):
162164
return NoContent, 204
163165

164166

167+
def get_project(id, files_at_version=None):
168+
"""Get project info. Include list of files at specific version if requested."""
169+
project = require_project_by_uuid(id, ProjectPermissions.Read, expose=False)
170+
data = ProjectSchemaV2().dump(project)
171+
if files_at_version:
172+
pv = ProjectVersion.query.filter_by(
173+
project_id=project.id, name=ProjectVersion.from_v_name(files_at_version)
174+
).first()
175+
if pv:
176+
data["files"] = ProjectFileSchema(
177+
only=("path", "mtime", "size", "checksum"), many=True
178+
).dump(pv.files)
179+
180+
return data, 200
181+
182+
165183
@auth_required
166184
@catch_sync_failure
167185
def create_project_version(id):
@@ -302,12 +320,12 @@ def create_project_version(id):
302320
os.renames(temp_files_dir, version_dir)
303321

304322
# remove used chunks
323+
# get chunks from added and updated files
324+
chunks_ids = []
305325
for file in to_be_added_files + to_be_updated_files:
306326
file_chunks = file.get("chunks", [])
307-
for chunk_id in file_chunks:
308-
chunk_file = get_chunk_location(chunk_id)
309-
if os.path.exists(chunk_file):
310-
move_to_tmp(chunk_file)
327+
chunks_ids.extend(file_chunks)
328+
remove_transaction_chunks.delay(chunks_ids)
311329

312330
logging.info(
313331
f"Push finished for project: {project.id}, project version: {v_next_version}, upload id: {upload.id}."
@@ -360,7 +378,6 @@ def upload_chunk(id: str):
360378
# we could have used request.data here, but it could eventually cause OOM issue
361379
save_to_file(request.stream, dest_file, current_app.config["MAX_CHUNK_SIZE"])
362380
except IOError:
363-
move_to_tmp(dest_file, chunk_id)
364381
return BigChunkError().response(413)
365382
except Exception as e:
366383
return UploadError(error="Error saving chunk").response(400)

0 commit comments

Comments
 (0)