Skip to content

Commit d467006

Browse files
authored
Merge pull request #595 from MerginMaps/develop
2026.3.0
2 parents ec68e6d + a7aea9c commit d467006

19 files changed

Lines changed: 408 additions & 13 deletions

LICENSES/CLA-signed-list.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ C/ My company has custom contribution contract with Lutra Consulting Ltd. or I a
2222
* jozef-budac, 30th January 2024
2323
* fernandinand, 13th March 2025
2424
* wonder-sk, 9th February 2026
25+
* xkello, 26th January 2026

server/mergin/sync/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,7 @@ class Configuration(object):
8080
EXCLUDED_CLONE_FILENAMES = config(
8181
"EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv()
8282
)
83+
# files that should be ignored during extension and MIME type checks
84+
UPLOAD_FILES_WHITELIST = config("UPLOAD_FILES_WHITELIST", default="", cast=Csv())
85+
# max batch size for fetch projects in batch endpoint
86+
MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int)

server/mergin/sync/errors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ class BigChunkError(ResponseError):
9797
detail = f"Chunk size exceeds maximum allowed size {MAX_CHUNK_SIZE} MB"
9898

9999

100+
class BatchLimitError(ResponseError):
101+
code = "BatchLimitExceeded"
102+
detail = f"Batch size exceeds maximum allowed size {Configuration.MAX_BATCH_SIZE}"
103+
104+
100105
class DiffDownloadError(ResponseError):
101106
code = "DiffDownloadError"
102107
detail = (

server/mergin/sync/permissions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,29 @@ def require_project_by_uuid(
248248
return project
249249

250250

251+
def check_project_permissions(
252+
project: Project, permission: ProjectPermissions
253+
) -> int | None:
254+
"""Check project permissions and return appropriate HTTP error code if check fails.
255+
:param project: project
256+
:type project: Project
257+
:param permission: permission to check
258+
:type permission: ProjectPermissions
259+
:return: HTTP error code if permission check fails, None otherwise
260+
:rtype: int | None
261+
"""
262+
263+
if not permission.check(project, current_user):
264+
# logged in - NO, have acccess - NONE, public project - NO
265+
if current_user.is_anonymous:
266+
# we don't want to tell anonymous user if a private project exists
267+
return 404
268+
# logged in - YES, have access - NO, public project - NO
269+
return 403
270+
271+
return None
272+
273+
251274
def get_upload(transaction_id):
252275
upload = Upload.query.get_or_404(transaction_id)
253276
# upload to 'removed' projects is forbidden

server/mergin/sync/project_handler.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ def get_email_receivers(self, project: Project) -> List[User]:
2828
)
2929
.all()
3030
)
31+
32+
@staticmethod
33+
def get_projects_by_uuids(uuids: List[str]) -> [Project]:
34+
"""Gets non-deleted projects"""
35+
return (
36+
Project.query.filter(Project.id.in_(uuids))
37+
.filter(Project.storage_params.isnot(None))
38+
.filter(Project.removed_at.is_(None))
39+
.all()
40+
)

server/mergin/sync/public_api_controller.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -582,8 +582,9 @@ def get_paginated_projects(
582582
public,
583583
only_public,
584584
)
585-
result = projects.paginate(page=page, per_page=per_page).items
586-
total = projects.paginate().total
585+
pagination = projects.paginate(page=page, per_page=per_page)
586+
result = pagination.items
587+
total = pagination.total
587588

588589
# create user map id:username passed to project schema to minimize queries to db
589590
projects_ids = [p.id for p in result]

server/mergin/sync/public_api_v2.yaml

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,53 @@ paths:
407407
$ref: "#/components/schemas/ProjectLocked"
408408

409409
x-openapi-router-controller: mergin.sync.public_api_v2_controller
410+
411+
/projects/batch:
412+
post:
413+
tags:
414+
- project
415+
summary: Get multiple projects by UUIDs
416+
operationId: list_batch_projects
417+
requestBody:
418+
required: true
419+
content:
420+
application/json:
421+
schema:
422+
type: object
423+
required: [ids]
424+
properties:
425+
ids:
426+
type: array
427+
description: List of project UUIDs to fetch
428+
items:
429+
$ref: "#/components/schemas/ProjectId"
430+
responses:
431+
"200":
432+
description: Projects returned as a list of simple project objects and/or error objects.
433+
content:
434+
application/json:
435+
schema:
436+
type: object
437+
required: [projects]
438+
properties:
439+
projects:
440+
type: array
441+
items:
442+
oneOf:
443+
- $ref: "#/components/schemas/Project"
444+
- $ref: "#/components/schemas/BatchItemError"
445+
"400":
446+
description: Batch limit exceeded or one or more UUIDs were invalid
447+
content:
448+
application/problem+json:
449+
schema:
450+
$ref: "#/components/schemas/CustomError"
451+
"401":
452+
$ref: "#/components/responses/Unauthorized"
453+
"404":
454+
$ref: "#/components/responses/NotFound"
455+
x-openapi-router-controller: mergin.sync.public_api_v2_controller
456+
410457
/projects/{id}/delta:
411458
get:
412459
tags:
@@ -526,9 +573,7 @@ components:
526573
description: UUID of the project
527574
required: true
528575
schema:
529-
type: string
530-
format: uuid
531-
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
576+
$ref: "#/components/schemas/ProjectId"
532577
WorkspaceId:
533578
name: workspace_id
534579
in: path
@@ -537,6 +582,10 @@ components:
537582
schema:
538583
type: integer
539584
schemas:
585+
ProjectId:
586+
type: string
587+
format: uuid
588+
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
540589
# Errors
541590
CustomError:
542591
type: object
@@ -616,6 +665,17 @@ components:
616665
example:
617666
code: UploadError
618667
detail: "Project version could not be created (UploadError)"
668+
BatchItemError:
669+
type: object
670+
properties:
671+
id:
672+
$ref: "#/components/schemas/ProjectId"
673+
error:
674+
type: integer
675+
example: 404
676+
required:
677+
- id
678+
- error
619679
DiffDownloadError:
620680
allOf:
621681
- $ref: "#/components/schemas/CustomError"

server/mergin/sync/public_api_v2_controller.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
from sqlalchemy.exc import IntegrityError
1919
from sqlalchemy.orm.exc import ObjectDeletedError
2020

21+
from .schemas_v2 import BatchErrorSchema, ProjectSchema as ProjectSchemaV2
2122
from ..app import db
2223
from ..auth import auth_required
2324
from ..auth.models import User
2425
from .errors import (
2526
AnotherUploadRunning,
27+
BatchLimitError,
2628
BigChunkError,
2729
DataSyncError,
2830
DiffDownloadError,
@@ -43,7 +45,12 @@
4345
project_version_created,
4446
push_finished,
4547
)
46-
from .permissions import ProjectPermissions, require_project_by_uuid, projects_query
48+
from .permissions import (
49+
ProjectPermissions,
50+
check_project_permissions,
51+
require_project_by_uuid,
52+
projects_query,
53+
)
4754
from .public_api_controller import catch_sync_failure
4855
from .schemas import (
4956
ProjectMemberSchema,
@@ -529,3 +536,40 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N
529536

530537
data = ProjectSchemaV2(many=True).dump(result)
531538
return jsonify(projects=data, count=total, page=page, per_page=per_page), 200
539+
540+
541+
def list_batch_projects(body):
542+
"""List projects by given list of UUIDs. Limit to 100 projects per request.
543+
544+
:param ids: List of project UUIDs
545+
:type ids: List[str]
546+
:rtype: Dict[str: List[Project]]
547+
"""
548+
ids = list(dict.fromkeys(body.get("ids", [])))
549+
# remove duplicates while preserving the order
550+
max_batch = current_app.config.get("MAX_BATCH_SIZE", 100)
551+
if len(ids) > max_batch:
552+
return BatchLimitError().response(400)
553+
554+
projects = current_app.project_handler.get_projects_by_uuids(ids)
555+
by_id = {str(project.id): project for project in projects}
556+
557+
filtered_projects = []
558+
for uuid in ids:
559+
project = by_id.get(uuid)
560+
561+
if not project:
562+
filtered_projects.append(
563+
BatchErrorSchema().dump({"id": uuid, "error": 404})
564+
)
565+
continue
566+
567+
err = check_project_permissions(project, ProjectPermissions.Read)
568+
if err is not None:
569+
filtered_projects.append(
570+
BatchErrorSchema().dump({"id": uuid, "error": err})
571+
)
572+
else:
573+
filtered_projects.append(ProjectSchemaV2().dump(project))
574+
575+
return jsonify(projects=filtered_projects), 200

server/mergin/sync/schemas_v2.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,8 @@ class Meta:
4646
"workspace",
4747
"role",
4848
)
49+
50+
51+
class BatchErrorSchema(ma.Schema):
52+
id = fields.UUID(required=True)
53+
error = fields.Integer(required=True)

server/mergin/sync/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
from flask import current_app
3434
from pathlib import Path
3535

36+
from .config import Configuration
37+
3638
# log base for caching strategy, diff checkpoints, etc.
3739
LOG_BASE = 4
3840

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

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

@@ -499,6 +503,16 @@ def is_supported_extension(filepath) -> bool:
499503
".xnk",
500504
}
501505

506+
507+
def check_skip_validation(file_path: str) -> bool:
508+
"""
509+
Check if we can skip validation for this file path.
510+
Some files are allowed even if they have forbidden extension or mime type.
511+
"""
512+
file_name = os.path.basename(file_path)
513+
return file_name in Configuration.UPLOAD_FILES_WHITELIST
514+
515+
502516
FORBIDDEN_MIME_TYPES = {
503517
"application/x-msdownload",
504518
"application/x-sh",
@@ -523,6 +537,8 @@ def is_supported_extension(filepath) -> bool:
523537

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

0 commit comments

Comments
 (0)