Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
168b5f0
Disallow non-ASCII in email domain
harminius Oct 27, 2025
c118ca9
Create endpoint
harminius Oct 30, 2025
6e9f177
add tests
harminius Oct 31, 2025
39129f2
Fix test helpers order to give time to manifest
harminius Nov 3, 2025
8635e5e
use email_validator and isascii
harminius Nov 3, 2025
889a73c
improve tests
harminius Nov 4, 2025
e8acc1d
fix json encoding
harminius Nov 4, 2025
178020c
use supplied regex
harminius Nov 4, 2025
5c9b768
Revert "fix json encoding"
harminius Nov 5, 2025
2223ded
Merge branch 'develop' into project_detail_v2
harminius Nov 5, 2025
5635a89
Merge branch 'develop' into validate_email_domain
harminius Nov 5, 2025
40ce2d8
fix auth tests
harminius Nov 5, 2025
d4f2673
Revert "use supplied regex"
harminius Nov 6, 2025
ea71902
rm get_email_domain in auth utils
harminius Nov 7, 2025
d4ac7ed
use re lib to validate emails
harminius Nov 7, 2025
830ef0f
address review - survive non-existing project version
harminius Nov 7, 2025
caed4df
rm regex from pipfile
harminius Nov 7, 2025
6adb3f2
fix test
harminius Nov 10, 2025
0958fe7
Accept locale for currency calculation
MarcelGeo Nov 13, 2025
3f66041
Simplify condition & improve readbility
harminius Nov 14, 2025
14f51a2
Merge pull request #525 from MerginMaps/validate_email_domain
MarcelGeo Nov 14, 2025
75c2da7
Don't allow trailing space for new files
harminius Nov 14, 2025
d92cf79
rm unused imports
harminius Nov 14, 2025
b402d72
fix logic
harminius Nov 14, 2025
2e80f34
Merge branch 'develop' into project_detail_v2
harminius Nov 14, 2025
072e4bb
fix schema import
harminius Nov 14, 2025
88885a3
Merge pull request #531 from MerginMaps/project_detail_v2
MarcelGeo Nov 17, 2025
f8f718c
Merge pull request #536 from MerginMaps/currency-locale-accept
MarcelGeo Nov 18, 2025
df29847
add negative test case
harminius Nov 19, 2025
43b560c
cleanup
harminius Nov 20, 2025
99ded71
add windows tests
harminius Nov 20, 2025
6dc9bb7
fix tests
harminius Nov 21, 2025
58fca7c
Merge pull request #534 from MerginMaps/refuse_trailing_whitespace_in…
MarcelGeo Nov 21, 2025
cac45a8
remove bottlnech with removing chunks
MarcelGeo Nov 24, 2025
85a5834
update tests for chunks - they exist
MarcelGeo Nov 24, 2025
88e5490
update tests
MarcelGeo Nov 24, 2025
2149c0c
call remove unused chunks async job
MarcelGeo Nov 24, 2025
05fb028
type for transaction chunks
MarcelGeo Nov 25, 2025
d19d5d1
Merge pull request #537 from MerginMaps/sync-remove-chunks
MarcelGeo Nov 25, 2025
1a46828
return 404 to anonymous user requesting private project info
harminius Nov 28, 2025
e8b1ba7
failing test
harminius Nov 28, 2025
1152229
failing test 2
harminius Nov 28, 2025
b350cd5
DEBUG failing test 3
harminius Nov 28, 2025
05c88d8
DEBUG try to print to stderr - 4
harminius Nov 28, 2025
be212c2
DEBUG 5: reset Global read
harminius Dec 2, 2025
19798f5
validate version param schema
harminius Dec 2, 2025
99e4414
rm prints
harminius Dec 2, 2025
e1fab33
add flag for v1 compatibility
harminius Dec 2, 2025
d80900b
actions should expose
harminius Dec 2, 2025
e845142
GET project never exposes
harminius Dec 2, 2025
b1e895c
cleanup
harminius Dec 2, 2025
0007fa2
return 403 for authenticated without permission
harminius Dec 2, 2025
31518fb
Fix failing tests with random 504
varmar05 Nov 25, 2025
4a206c8
Merge pull request #541 from MerginMaps/dont_reveal_resource_to_anony…
MarcelGeo Dec 4, 2025
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
1 change: 1 addition & 0 deletions server/.test.env
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ SECURITY_BEARER_SALT='bearer'
SECURITY_EMAIL_SALT='email'
SECURITY_PASSWORD_SALT='password'
DIAGNOSTIC_LOGS_DIR=/tmp/diagnostic_logs
GEVENT_WORKER=0
19 changes: 12 additions & 7 deletions server/mergin/auth/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import re
import safe
from flask_wtf import FlaskForm
Expand Down Expand Up @@ -48,18 +49,22 @@ class ExtendedEmail(Email):
1. spaces,
2. special characters ,:;()<>[]\"
3, multiple @ symbols,
4, leading, trailing, or consecutive dots in the local part
5, invalid domain part - missing top level domain (user@example), consecutive dots
Custom check for additional invalid characters disallows |'— because they make our email sending service to fail
4, leading, trailing, or consecutive dots in the local part,
5, invalid domain part - missing top level domain (user@example), consecutive dots,
The extended validation checks email addresses using the regex provided by Brevo,
so that we stay consistent with their validation rules and avoid API failures.
"""

def __call__(self, form, field):
super().__call__(form, field)

if re.search(r"[|'—]", field.data):
raise ValidationError(
f"Email address '{field.data}' contains an invalid character."
)
email = field.data.strip()

pattern = r"^[\x60#&*\/=?^{!}~'+\w-]+(\.[\x60#&*\/=?^{!}~'+\w-]+)*\.?@([_a-zA-Z0-9-]+(\.[_a-zA-Z0-9-]+)*\.)[a-zA-Z0-9-]*[a-zA-Z0-9]{2,}$"
email_regexp = re.compile(pattern, re.IGNORECASE)

if not email_regexp.match(email):
raise ValidationError(f"Email address '{email}' is invalid.")


class PasswordValidator:
Expand Down
15 changes: 11 additions & 4 deletions server/mergin/sync/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

from .utils import (
is_file_name_blacklisted,
is_qgis,
is_supported_extension,
is_valid_path,
is_versioned_file,
has_trailing_space,
)
from ..app import DateTimeWithZ, ma

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

if not is_valid_path(file_path):
raise ValidationError(
f"Unsupported file name detected: {file_path}. Please remove the invalid characters."
f"Unsupported file name detected: '{file_path}'. Please remove the invalid characters."
)

if not is_supported_extension(file_path):
raise ValidationError(
f"Unsupported file type detected: {file_path}. "
f"Unsupported file type detected: '{file_path}'. "
f"Please remove the file or try compressing it into a ZIP file before uploading.",
)
# new checks must restrict only new files not to block existing projects
for file in data["added"]:
file_path = file["path"]
if has_trailing_space(file_path):
raise ValidationError(
f"Folder name contains a trailing space. Please remove the space in: '{file_path}'."
)


class ProjectFileSchema(FileSchema):
Expand All @@ -230,5 +237,5 @@ class ProjectFileSchema(FileSchema):
def patch_field(self, data, **kwargs):
# drop 'diff' key entirely if empty or None as clients would expect
if not data.get("diff"):
data.pop("diff")
data.pop("diff", None)
return data
21 changes: 20 additions & 1 deletion server/mergin/sync/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,21 @@ def require_project(ws, project_name, permission) -> Project:
return project


def require_project_by_uuid(uuid: str, permission: ProjectPermissions, scheduled=False):
def require_project_by_uuid(
uuid: str, permission: ProjectPermissions, scheduled=False, expose=True
) -> Project:
"""
Retrieves a project by UUID after validating existence, workspace status, and permissions.

Args:
uuid (str): The unique identifier of the project.
permission (ProjectPermissions): The permission level required to access the project.
scheduled (bool, optional): If ``True``, bypasses the check for projects marked for deletion.
expose (bool, optional): Controls security disclosure behavior on permission failure.
- If `True`: Returns 403 Forbidden (reveals project exists but access is denied).
- If `False`: Returns 404 Not Found (hides project existence for security).
Standard is that reading results in 404, while writing results in 403
"""
if not is_valid_uuid(uuid):
abort(404)

Expand All @@ -219,13 +233,18 @@ def require_project_by_uuid(uuid: str, permission: ProjectPermissions, scheduled
if not scheduled:
project = project.filter(Project.removed_at.is_(None))
project = project.first_or_404()
if not expose and current_user.is_anonymous and not project.public:
# we don't want to tell anonymous user if a private project exists
abort(404)

workspace = project.workspace
if not workspace:
abort(404)
if not is_active_workspace(workspace):
abort(404, "Workspace doesn't exist")
if not permission.check(project, current_user):
abort(403, "You do not have permissions for this project")

return project


Expand Down
18 changes: 1 addition & 17 deletions server/mergin/sync/public_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@
from .files import (
ProjectFileChange,
ChangesSchema,
UploadFileSchema,
ProjectFileSchema,
FileSchema,
files_changes_from_upload,
mergin_secure_filename,
)
Expand All @@ -83,17 +81,11 @@
generate_checksum,
Toucher,
get_x_accel_uri,
is_file_name_blacklisted,
get_ip,
get_user_agent,
generate_location,
is_valid_uuid,
is_versioned_file,
get_project_path,
get_device_id,
is_valid_path,
is_supported_type,
is_supported_extension,
get_mimetype,
wkb2wkt,
)
Expand Down Expand Up @@ -980,7 +972,7 @@ def push_finish(transaction_id):
if len(unsupported_files):
abort(
400,
f"Unsupported file type detected: {unsupported_files[0]}. "
f"Unsupported file type detected: '{unsupported_files[0]}'. "
f"Please remove the file or try compressing it into a ZIP file before uploading.",
)

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

# remove used chunks
for file in upload.changes["added"] + upload.changes["updated"]:
file_chunks = file.get("chunks", [])
for chunk_id in file_chunks:
chunk_file = os.path.join(upload.upload_dir, "chunks", chunk_id)
if os.path.exists(chunk_file):
move_to_tmp(chunk_file)

logging.info(
f"Push finished for project: {project.id}, project version: {v_next_version}, transaction id: {transaction_id}."
)
Expand Down
102 changes: 99 additions & 3 deletions server/mergin/sync/public_api_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ paths:
"409":
$ref: "#/components/responses/Conflict"
x-openapi-router-controller: mergin.sync.public_api_v2_controller
get:
tags:
- project
summary: Get project info
operationId: get_project
parameters:
- name: files_at_version
in: query
description: Include list of files at specific version
required: false
schema:
$ref: "#/components/schemas/VersionName"
responses:
"200":
description: Success
content:
application/json:
schema:
$ref: "#/components/schemas/ProjectDetail"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
x-openapi-router-controller: mergin.sync.public_api_v2_controller
/projects/{id}/scheduleDelete:
post:
tags:
Expand Down Expand Up @@ -276,9 +304,7 @@ paths:
default: false
example: true
version:
type: string
pattern: '^$|^v\d+$'
example: v2
$ref: "#/components/schemas/VersionName"
changes:
type: object
required:
Expand Down Expand Up @@ -502,6 +528,72 @@ components:
$ref: "#/components/schemas/ProjectRole"
role:
$ref: "#/components/schemas/Role"
ProjectDetail:
type: object
required:
- id
- name
- workspace
- role
- version
- created_at
- updated_at
- public
- size
properties:
id:
type: string
description: project uuid
example: c1ae6439-0056-42df-a06d-79cc430dd7df
name:
type: string
example: survey
workspace:
type: object
properties:
id:
type: integer
example: 123
name:
type: string
example: mergin
role:
$ref: "#/components/schemas/ProjectRole"
version:
type: string
description: latest project version
example: v2
created_at:
type: string
format: date-time
description: project creation timestamp
example: 2025-10-24T08:27:56Z
updated_at:
type: string
format: date-time
description: last project update timestamp
example: 2025-10-24T08:28:00.279699Z
public:
type: boolean
description: whether the project is public
example: false
size:
type: integer
description: project size in bytes for this version
example: 17092380
files:
type: array
description: List of files in the project
items:
allOf:
- $ref: '#/components/schemas/File'
- type: object
properties:
mtime:
type: string
format: date-time
description: File modification timestamp
example: 2024-11-19T13:50:00Z
File:
type: object
description: Project file metadata
Expand Down Expand Up @@ -754,3 +846,7 @@ components:
- editor
- writer
- owner
VersionName:
type: string
pattern: '^$|^v\d+$'
example: v2
31 changes: 24 additions & 7 deletions server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
from marshmallow import ValidationError
from sqlalchemy.exc import IntegrityError

from mergin.sync.tasks import remove_transaction_chunks

from .schemas_v2 import ProjectSchema as ProjectSchemaV2
from ..app import db
from ..auth import auth_required
from ..auth.models import User
Expand All @@ -26,7 +29,7 @@
StorageLimitHit,
UploadError,
)
from .files import ChangesSchema
from .files import ChangesSchema, ProjectFileSchema
from .forms import project_name_validation
from .models import (
Project,
Expand All @@ -41,7 +44,6 @@
from .public_api_controller import catch_sync_failure
from .schemas import (
ProjectMemberSchema,
ProjectVersionSchema,
UploadChunkSchema,
ProjectSchema,
)
Expand Down Expand Up @@ -162,6 +164,22 @@ def remove_project_collaborator(id, user_id):
return NoContent, 204


def get_project(id, files_at_version=None):
"""Get project info. Include list of files at specific version if requested."""
project = require_project_by_uuid(id, ProjectPermissions.Read, expose=False)
data = ProjectSchemaV2().dump(project)
if files_at_version:
pv = ProjectVersion.query.filter_by(
project_id=project.id, name=ProjectVersion.from_v_name(files_at_version)
).first()
if pv:
data["files"] = ProjectFileSchema(
only=("path", "mtime", "size", "checksum"), many=True
).dump(pv.files)

return data, 200


@auth_required
@catch_sync_failure
def create_project_version(id):
Expand Down Expand Up @@ -302,12 +320,12 @@ def create_project_version(id):
os.renames(temp_files_dir, version_dir)

# remove used chunks
# get chunks from added and updated files
chunks_ids = []
for file in to_be_added_files + to_be_updated_files:
file_chunks = file.get("chunks", [])
for chunk_id in file_chunks:
chunk_file = get_chunk_location(chunk_id)
if os.path.exists(chunk_file):
move_to_tmp(chunk_file)
chunks_ids.extend(file_chunks)
remove_transaction_chunks.delay(chunks_ids)

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