Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
254a9b8
Update batch API endpoint, openapi documentation and add batch test
xkello Jan 26, 2026
235f1b1
Make changes to code to better represent specs, split test logic for …
xkello Jan 30, 2026
aa1eb6d
add name to CLA-signed-list
xkello Jan 30, 2026
d44dfee
Add test fix where user was getting Project object instead of an error
xkello Jan 30, 2026
1914dc3
Fix 2 on test for permissions function
xkello Jan 30, 2026
2024bc7
Hide sorting options on FE for public projects
harminius Feb 3, 2026
85f6e30
Merge pull request #575 from MerginMaps/hide_public_projects_sorting
MarcelGeo Feb 3, 2026
a4ad4a3
Fix controller and permissions functions for better reusability
xkello Feb 3, 2026
541c724
Remove auth for public api endpoint, add for invalid UUID
xkello Feb 3, 2026
ddbabe5
Fix test where it expected auth required and added some casses for an…
xkello Feb 3, 2026
1c71d12
Mock config
harminius Feb 5, 2026
8d073b8
Create project handler method to allow EE to check workspaces state
harminius Feb 5, 2026
65d95c3
black .
harminius Feb 5, 2026
69641d6
Add test to check batch size, add custom error class
xkello Feb 5, 2026
3e03452
Add test to check batch size, add custom error class
xkello Feb 5, 2026
ef37a44
Fix setting global configs for permissions test
xkello Feb 5, 2026
595a554
Move passing IDs to function args
xkello Feb 6, 2026
6964521
Fix bad connexion binding
xkello Feb 6, 2026
e3cab08
Merge pull request #567 from MerginMaps/api-update-batch-endpoint
MarcelGeo Feb 6, 2026
26a3f6d
shadow for invalid input
harminius Feb 10, 2026
8385e14
Small tweaks
harminius Feb 10, 2026
585e82a
revert - permission is not role
harminius Feb 10, 2026
0def7e6
Merge branch 'develop' into per_seat_pricing
harminius Feb 11, 2026
3eb8dd5
Introduce UPLOAD_FILES_WHITELIST
MarcelGeo Feb 16, 2026
099506a
Merge pull request #580 from MerginMaps/per_seat_pricing
MarcelGeo Feb 23, 2026
5c31a3d
Update server/mergin/sync/config.py
MarcelGeo Feb 24, 2026
fa1fc84
fix tests for mimetype
MarcelGeo Feb 24, 2026
e16b154
Merge branch 'develop' into upload-allowed-files
MarcelGeo Feb 26, 2026
ba69f4f
Merge pull request #581 from MerginMaps/upload-allowed-files
MarcelGeo Feb 26, 2026
47a5e73
add variables and custom classes
harminius Mar 1, 2026
4b2d1ad
Merge pull request #586 from MerginMaps/per_seat_pricing
MarcelGeo Mar 2, 2026
8c755a3
Merge remote-tracking branch 'origin/master' into integrate-master
MarcelGeo Mar 9, 2026
2d89c38
Merge pull request #590 from MerginMaps/integrate-master
MarcelGeo Mar 9, 2026
78e8696
Add basename for checking validation
MarcelGeo Mar 9, 2026
a7aea9c
Merge pull request #591 from MerginMaps/basename-skipping-validation
MarcelGeo Mar 11, 2026
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 LICENSES/CLA-signed-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ C/ My company has custom contribution contract with Lutra Consulting Ltd. or I a
* jozef-budac, 30th January 2024
* fernandinand, 13th March 2025
* wonder-sk, 9th February 2026
* xkello, 26th January 2026
4 changes: 4 additions & 0 deletions server/mergin/sync/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,7 @@ class Configuration(object):
EXCLUDED_CLONE_FILENAMES = config(
"EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv()
)
# files that should be ignored during extension and MIME type checks
UPLOAD_FILES_WHITELIST = config("UPLOAD_FILES_WHITELIST", default="", cast=Csv())
# max batch size for fetch projects in batch endpoint
MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int)
5 changes: 5 additions & 0 deletions server/mergin/sync/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ class BigChunkError(ResponseError):
detail = f"Chunk size exceeds maximum allowed size {MAX_CHUNK_SIZE} MB"


class BatchLimitError(ResponseError):
code = "BatchLimitExceeded"
detail = f"Batch size exceeds maximum allowed size {Configuration.MAX_BATCH_SIZE}"


class DiffDownloadError(ResponseError):
code = "DiffDownloadError"
detail = (
Expand Down
23 changes: 23 additions & 0 deletions server/mergin/sync/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,29 @@ def require_project_by_uuid(
return project


def check_project_permissions(
project: Project, permission: ProjectPermissions
) -> int | None:
"""Check project permissions and return appropriate HTTP error code if check fails.
:param project: project
:type project: Project
:param permission: permission to check
:type permission: ProjectPermissions
:return: HTTP error code if permission check fails, None otherwise
:rtype: int | None
"""

if not permission.check(project, current_user):
# logged in - NO, have acccess - NONE, public project - NO
if current_user.is_anonymous:
# we don't want to tell anonymous user if a private project exists
return 404
# logged in - YES, have access - NO, public project - NO
return 403

return None


def get_upload(transaction_id):
upload = Upload.query.get_or_404(transaction_id)
# upload to 'removed' projects is forbidden
Expand Down
10 changes: 10 additions & 0 deletions server/mergin/sync/project_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ def get_email_receivers(self, project: Project) -> List[User]:
)
.all()
)

@staticmethod
def get_projects_by_uuids(uuids: List[str]) -> [Project]:
"""Gets non-deleted projects"""
return (
Project.query.filter(Project.id.in_(uuids))
.filter(Project.storage_params.isnot(None))
.filter(Project.removed_at.is_(None))
.all()
)
5 changes: 3 additions & 2 deletions server/mergin/sync/public_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,8 +582,9 @@ def get_paginated_projects(
public,
only_public,
)
result = projects.paginate(page=page, per_page=per_page).items
total = projects.paginate().total
pagination = projects.paginate(page=page, per_page=per_page)
result = pagination.items
total = pagination.total

# create user map id:username passed to project schema to minimize queries to db
projects_ids = [p.id for p in result]
Expand Down
66 changes: 63 additions & 3 deletions server/mergin/sync/public_api_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,53 @@ paths:
$ref: "#/components/schemas/ProjectLocked"

x-openapi-router-controller: mergin.sync.public_api_v2_controller

/projects/batch:
post:
tags:
- project
summary: Get multiple projects by UUIDs
operationId: list_batch_projects
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
description: List of project UUIDs to fetch
items:
$ref: "#/components/schemas/ProjectId"
responses:
"200":
description: Projects returned as a list of simple project objects and/or error objects.
content:
application/json:
schema:
type: object
required: [projects]
properties:
projects:
type: array
items:
oneOf:
- $ref: "#/components/schemas/Project"
- $ref: "#/components/schemas/BatchItemError"
"400":
description: Batch limit exceeded or one or more UUIDs were invalid
content:
application/problem+json:
schema:
$ref: "#/components/schemas/CustomError"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
x-openapi-router-controller: mergin.sync.public_api_v2_controller

/projects/{id}/delta:
get:
tags:
Expand Down Expand Up @@ -526,9 +573,7 @@ components:
description: UUID of the project
required: true
schema:
type: string
format: uuid
pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
$ref: "#/components/schemas/ProjectId"
WorkspaceId:
name: workspace_id
in: path
Expand All @@ -537,6 +582,10 @@ components:
schema:
type: integer
schemas:
ProjectId:
type: string
format: uuid
pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
# Errors
CustomError:
type: object
Expand Down Expand Up @@ -616,6 +665,17 @@ components:
example:
code: UploadError
detail: "Project version could not be created (UploadError)"
BatchItemError:
type: object
properties:
id:
$ref: "#/components/schemas/ProjectId"
error:
type: integer
example: 404
required:
- id
- error
DiffDownloadError:
allOf:
- $ref: "#/components/schemas/CustomError"
Expand Down
46 changes: 45 additions & 1 deletion server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import ObjectDeletedError

from .schemas_v2 import BatchErrorSchema, ProjectSchema as ProjectSchemaV2
from ..app import db
from ..auth import auth_required
from ..auth.models import User
from .errors import (
AnotherUploadRunning,
BatchLimitError,
BigChunkError,
DataSyncError,
DiffDownloadError,
Expand All @@ -43,7 +45,12 @@
project_version_created,
push_finished,
)
from .permissions import ProjectPermissions, require_project_by_uuid, projects_query
from .permissions import (
ProjectPermissions,
check_project_permissions,
require_project_by_uuid,
projects_query,
)
from .public_api_controller import catch_sync_failure
from .schemas import (
ProjectMemberSchema,
Expand Down Expand Up @@ -529,3 +536,40 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N

data = ProjectSchemaV2(many=True).dump(result)
return jsonify(projects=data, count=total, page=page, per_page=per_page), 200


def list_batch_projects(body):
"""List projects by given list of UUIDs. Limit to 100 projects per request.

:param ids: List of project UUIDs
:type ids: List[str]
:rtype: Dict[str: List[Project]]
"""
ids = list(dict.fromkeys(body.get("ids", [])))
# remove duplicates while preserving the order
max_batch = current_app.config.get("MAX_BATCH_SIZE", 100)
if len(ids) > max_batch:
return BatchLimitError().response(400)

projects = current_app.project_handler.get_projects_by_uuids(ids)
by_id = {str(project.id): project for project in projects}

filtered_projects = []
for uuid in ids:
project = by_id.get(uuid)

if not project:
filtered_projects.append(
BatchErrorSchema().dump({"id": uuid, "error": 404})
)
continue

err = check_project_permissions(project, ProjectPermissions.Read)
if err is not None:
filtered_projects.append(
BatchErrorSchema().dump({"id": uuid, "error": err})
)
else:
filtered_projects.append(ProjectSchemaV2().dump(project))

return jsonify(projects=filtered_projects), 200
5 changes: 5 additions & 0 deletions server/mergin/sync/schemas_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ class Meta:
"workspace",
"role",
)


class BatchErrorSchema(ma.Schema):
id = fields.UUID(required=True)
error = fields.Integer(required=True)
16 changes: 16 additions & 0 deletions server/mergin/sync/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
from flask import current_app
from pathlib import Path

from .config import Configuration

# log base for caching strategy, diff checkpoints, etc.
LOG_BASE = 4

Expand Down Expand Up @@ -357,6 +359,8 @@ def has_trailing_space(filepath: str) -> bool:

def is_supported_extension(filepath) -> bool:
"""Check whether file's extension is supported."""
if check_skip_validation(filepath):
return True
ext = os.path.splitext(filepath)[1].lower()
return ext and ext not in FORBIDDEN_EXTENSIONS

Expand Down Expand Up @@ -499,6 +503,16 @@ def is_supported_extension(filepath) -> bool:
".xnk",
}


def check_skip_validation(file_path: str) -> bool:
"""
Check if we can skip validation for this file path.
Some files are allowed even if they have forbidden extension or mime type.
"""
file_name = os.path.basename(file_path)
return file_name in Configuration.UPLOAD_FILES_WHITELIST


FORBIDDEN_MIME_TYPES = {
"application/x-msdownload",
"application/x-sh",
Expand All @@ -523,6 +537,8 @@ def is_supported_extension(filepath) -> bool:

def is_supported_type(filepath) -> bool:
"""Check whether the file mimetype is supported."""
if check_skip_validation(filepath):
return True
mime_type = get_mimetype(filepath)
return mime_type.startswith("image/") or mime_type not in FORBIDDEN_MIME_TYPES

Expand Down
64 changes: 61 additions & 3 deletions server/mergin/tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import pytest
from unittest.mock import patch
import datetime
from flask_login import AnonymousUserMixin

from ..sync.permissions import require_project, ProjectPermissions
from ..sync.models import ProjectRole
from mergin.tests import DEFAULT_USER

from ..sync.permissions import (
require_project,
check_project_permissions,
ProjectPermissions,
)
from ..sync.models import Project, ProjectRole
from ..auth.models import User
from ..app import db
from ..config import Configuration
from .utils import add_user, create_project, create_workspace
from .utils import (
add_user,
create_project,
create_workspace,
login,
logout,
)


def test_project_permissions(client):
Expand Down Expand Up @@ -116,3 +130,47 @@ def test_project_permissions(client):
assert ProjectPermissions.All.check(project, user)
assert ProjectPermissions.Edit.check(project, user)
assert ProjectPermissions.get_user_project_role(project, user) == ProjectRole.OWNER


def test_check_project_permissions(client):
"""Test check_project_permissions with various permission scenarios."""
admin = User.query.filter_by(username=DEFAULT_USER[0]).first()
test_workspace = create_workspace()

private_proj = create_project("batch_private", test_workspace, admin)
public_proj = create_project("batch_public", test_workspace, admin)

p = Project.query.get(public_proj.id)
p.public = True
db.session.commit()

priv_proj = Project.query.get(private_proj.id)
pub_proj = Project.query.get(public_proj.id)

# First user with access to both projects
login(client, DEFAULT_USER[0], DEFAULT_USER[1])

with client:
client.get("/")
assert check_project_permissions(priv_proj, ProjectPermissions.Read) is None
assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None

# Second user with no access to private project (ensure global perms disabled)
with patch.object(Configuration, "GLOBAL_READ", False), patch.object(
Configuration, "GLOBAL_WRITE", False
), patch.object(Configuration, "GLOBAL_ADMIN", False):
user2 = add_user("user_batch", "password")
login(client, user2.username, "password")

with client:
client.get("/")
assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None
assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 403

# Logged-out (anonymous) user
logout(client)

with client:
client.get("/")
assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 404
assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None
Loading
Loading