Skip to content

Commit bc8ddf7

Browse files
committed
Merge remote-tracking branch 'origin/develop' into integrate_last_sign_in
2 parents 72bdc7e + b52d8c8 commit bc8ddf7

File tree

13 files changed

+138
-137
lines changed

13 files changed

+138
-137
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/sync/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,6 @@ class Configuration(object):
7575
UPLOAD_CHUNKS_EXPIRATION = config(
7676
"UPLOAD_CHUNKS_EXPIRATION", default=86400, cast=int
7777
)
78+
EXCLUDED_CLONE_FILENAMES = config(
79+
"EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv()
80+
)

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
@@ -1136,9 +1136,12 @@ def clone_project(namespace, project_name): # noqa: E501
11361136
)
11371137
p.updated = datetime.utcnow()
11381138
db.session.add(p)
1139+
files_to_exclude = current_app.config.get("EXCLUDED_CLONE_FILENAMES", [])
11391140

11401141
try:
1141-
p.storage.initialize(template_project=cloned_project)
1142+
p.storage.initialize(
1143+
template_project=cloned_project, excluded_files=files_to_exclude
1144+
)
11421145
except InitializationError as e:
11431146
abort(400, f"Failed to clone project: {str(e)}")
11441147

@@ -1149,6 +1152,8 @@ def clone_project(namespace, project_name): # noqa: E501
11491152
# transform source files to new uploaded files
11501153
file_changes = []
11511154
for file in cloned_project.files:
1155+
if os.path.basename(file.path) in files_to_exclude:
1156+
continue
11521157
file_changes.append(
11531158
ProjectFileChange(
11541159
file.path,

server/mergin/sync/public_api_v2.yaml

Lines changed: 8 additions & 85 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
@@ -305,9 +304,7 @@ paths:
305304
default: false
306305
example: true
307306
version:
308-
type: string
309-
pattern: '^$|^v\d+$'
310-
example: v2
307+
$ref: "#/components/schemas/VersionName"
311308
changes:
312309
type: object
313310
required:
@@ -333,7 +330,7 @@ paths:
333330
content:
334331
application/json:
335332
schema:
336-
$ref: "#/components/schemas/Project"
333+
$ref: "#/components/schemas/ProjectDetail"
337334
"204":
338335
$ref: "#/components/responses/NoContent"
339336
"400":
@@ -592,7 +589,7 @@ components:
592589
description: List of files in the project
593590
items:
594591
allOf:
595-
- $ref: '#/components/schemas/File'
592+
- $ref: "#/components/schemas/File"
596593
- type: object
597594
properties:
598595
mtime:
@@ -774,81 +771,7 @@ components:
774771
type: string
775772
format: date-time
776773
example: 2019-02-26T08:47:58.636074Z
777-
Project:
778-
type: object
779-
required:
780-
- name
781-
properties:
782-
id:
783-
type: string
784-
example: f9ef87ac-1dae-48ab-85cb-062a4784fb83
785-
description: Project UUID
786-
name:
787-
type: string
788-
example: mergin
789-
namespace:
790-
type: string
791-
example: mergin
792-
creator:
793-
nullable: true
794-
type: integer
795-
example: 1
796-
description: Project creator ID
797-
created:
798-
type: string
799-
format: date-time
800-
example: 2018-11-30T08:47:58.636074Z
801-
updated:
802-
type: string
803-
nullable: true
804-
format: date-time
805-
example: 2018-11-30T08:47:58.636074Z
806-
description: Last modified
807-
version:
808-
type: string
809-
nullable: true
810-
example: v2
811-
description: Last project version
812-
disk_usage:
813-
type: integer
814-
example: 25324373
815-
description: Project size in bytes
816-
permissions:
817-
type: object
818-
properties:
819-
delete:
820-
type: boolean
821-
example: false
822-
update:
823-
type: boolean
824-
example: false
825-
upload:
826-
type: boolean
827-
example: true
828-
tags:
829-
type: array
830-
nullable: true
831-
items:
832-
$ref: "#/components/schemas/MerginTag"
833-
uploads:
834-
type: array
835-
nullable: true
836-
items:
837-
type: string
838-
example: 669b838e-a30b-4338-b2b6-3da144742a82
839-
description: UUID for ongoing upload
840-
access:
841-
$ref: "#/components/schemas/Access"
842-
files:
843-
type: array
844-
items:
845-
allOf:
846-
- $ref: "#/components/schemas/FileInfo"
847-
role:
848-
nullable: true
849-
type: string
850-
enum:
851-
- reader
852-
- editor
853-
- writer
854-
- owner
774+
VersionName:
775+
type: string
776+
pattern: '^$|^v\d+$'
777+
example: v2

server/mergin/sync/public_api_v2_controller.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
from .public_api_controller import catch_sync_failure
4545
from .schemas import (
4646
ProjectMemberSchema,
47-
ProjectVersionSchema,
4847
UploadChunkSchema,
4948
ProjectSchema,
5049
)
@@ -168,7 +167,7 @@ def remove_project_collaborator(id, user_id):
168167

169168
def get_project(id, files_at_version=None):
170169
"""Get project info. Include list of files at specific version if requested."""
171-
project = require_project_by_uuid(id, ProjectPermissions.Read)
170+
project = require_project_by_uuid(id, ProjectPermissions.Read, expose=False)
172171
data = ProjectSchemaV2().dump(project)
173172
if files_at_version:
174173
pv = ProjectVersion.query.filter_by(
@@ -362,7 +361,12 @@ def create_project_version(id):
362361
finally:
363362
# remove artifacts
364363
upload.clear()
365-
return ProjectSchema().dump(project), 201
364+
365+
result = ProjectSchemaV2().dump(project)
366+
result["files"] = ProjectFileSchema(
367+
only=("path", "mtime", "size", "checksum"), many=True
368+
).dump(project.files)
369+
return result, 201
366370

367371

368372
@auth_required

server/mergin/sync/storages/disk.py

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

181-
def initialize(self, template_project=None):
181+
def initialize(self, template_project=None, excluded_files=None):
182182
if os.path.exists(self.project_dir):
183183
raise InitializationError(
184184
"Project directory already exists: {}".format(self.project_dir)
@@ -193,8 +193,12 @@ def initialize(self, template_project=None):
193193
if ws.disk_usage() + template_project.disk_usage > ws.storage:
194194
self.delete()
195195
raise InitializationError("Disk quota reached")
196+
if excluded_files is None:
197+
excluded_files = []
196198

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

server/mergin/tests/test_middleware.py

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import psycogreen.gevent
77
import pytest
88
import sqlalchemy
9+
from unittest.mock import patch
910

1011
from ..app import create_simple_app, GeventTimeoutMiddleware, db
1112
from ..config import Configuration
@@ -14,58 +15,74 @@
1415
@pytest.mark.parametrize("use_middleware", [True, False])
1516
def test_use_middleware(use_middleware):
1617
"""Test using middleware"""
17-
Configuration.GEVENT_WORKER = use_middleware
18-
Configuration.GEVENT_REQUEST_TIMEOUT = 1
19-
application = create_simple_app()
18+
with patch.object(
19+
Configuration,
20+
"GEVENT_WORKER",
21+
use_middleware,
22+
), patch.object(
23+
Configuration,
24+
"GEVENT_REQUEST_TIMEOUT",
25+
1,
26+
):
27+
application = create_simple_app()
2028

21-
def ping():
22-
gevent.sleep(Configuration.GEVENT_REQUEST_TIMEOUT + 1)
23-
return "pong"
29+
def ping():
30+
gevent.sleep(Configuration.GEVENT_REQUEST_TIMEOUT + 1)
31+
return "pong"
2432

25-
application.add_url_rule("/test", "ping", ping)
26-
app_context = application.app_context()
27-
app_context.push()
33+
application.add_url_rule("/test", "ping", ping)
34+
app_context = application.app_context()
35+
app_context.push()
2836

29-
assert isinstance(application.wsgi_app, GeventTimeoutMiddleware) == use_middleware
30-
# in case of gevent, dummy endpoint it set to time out
31-
assert application.test_client().get("/test").status_code == (
32-
504 if use_middleware else 200
33-
)
37+
assert (
38+
isinstance(application.wsgi_app, GeventTimeoutMiddleware) == use_middleware
39+
)
40+
# in case of gevent, dummy endpoint it set to time out
41+
assert application.test_client().get("/test").status_code == (
42+
504 if use_middleware else 200
43+
)
3444

3545

3646
def test_catch_timeout():
3747
"""Test proper handling of gevent timeout with db.session.rollback"""
3848
psycogreen.gevent.patch_psycopg()
39-
Configuration.GEVENT_WORKER = True
40-
Configuration.GEVENT_REQUEST_TIMEOUT = 1
41-
application = create_simple_app()
49+
with patch.object(
50+
Configuration,
51+
"GEVENT_WORKER",
52+
True,
53+
), patch.object(
54+
Configuration,
55+
"GEVENT_REQUEST_TIMEOUT",
56+
1,
57+
):
58+
application = create_simple_app()
4259

43-
def unhandled():
44-
try:
45-
db.session.execute("SELECT pg_sleep(1.1);")
46-
finally:
47-
db.session.execute("SELECT 1;")
48-
return ""
60+
def unhandled():
61+
try:
62+
db.session.execute("SELECT pg_sleep(1.1);")
63+
finally:
64+
db.session.execute("SELECT 1;")
65+
return ""
4966

50-
def timeout():
51-
try:
52-
db.session.execute("SELECT pg_sleep(1.1);")
53-
except gevent.timeout.Timeout:
54-
db.session.rollback()
55-
raise
56-
finally:
57-
db.session.execute("SELECT 1;")
58-
return ""
67+
def timeout():
68+
try:
69+
db.session.execute("SELECT pg_sleep(1.1);")
70+
except gevent.timeout.Timeout:
71+
db.session.rollback()
72+
raise
73+
finally:
74+
db.session.execute("SELECT 1;")
75+
return ""
5976

60-
application.add_url_rule("/unhandled", "unhandled", unhandled)
61-
application.add_url_rule("/timeout", "timeout", timeout)
62-
app_context = application.app_context()
63-
app_context.push()
77+
application.add_url_rule("/unhandled", "unhandled", unhandled)
78+
application.add_url_rule("/timeout", "timeout", timeout)
79+
app_context = application.app_context()
80+
app_context.push()
6481

65-
assert application.test_client().get("/timeout").status_code == 504
82+
assert application.test_client().get("/timeout").status_code == 504
6683

67-
# in case of missing rollback sqlalchemy would raise error
68-
with pytest.raises(sqlalchemy.exc.PendingRollbackError):
69-
application.test_client().get("/unhandled")
84+
# in case of missing rollback sqlalchemy would raise error
85+
with pytest.raises(sqlalchemy.exc.PendingRollbackError):
86+
application.test_client().get("/unhandled")
7087

71-
db.session.rollback()
88+
db.session.rollback()

0 commit comments

Comments
 (0)