From 49fca1569f88f3adcadc0a4251301dc62838dd22 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 2 Apr 2025 10:27:06 +0200 Subject: [PATCH 01/43] Don't allow SSO users update their profile (#416) * Show 'Change password' and 'Edit profile' only when user created account through our registration form * Backend blocks update profile for SSO users * Allow creating mock of SSO user * Add test for admin * black . * Add checks for others methods, address reviews * decorate can_edit_profile * black . * Do not duplicate can_edit_profile check --- server/mergin/auth/api.yaml | 12 ++++++++++-- server/mergin/auth/app.py | 18 +++++++++++++++++- server/mergin/auth/controller.py | 12 ++++++++++++ server/mergin/auth/models.py | 14 ++++++++++++-- server/mergin/auth/schemas.py | 1 + server/mergin/tests/test_auth.py | 13 +++++++++++++ web-app/packages/lib/src/modules/user/types.ts | 1 + .../modules/user/views/ProfileViewTemplate.vue | 5 ++++- 8 files changed, 70 insertions(+), 6 deletions(-) diff --git a/server/mergin/auth/api.yaml b/server/mergin/auth/api.yaml index 095b0d26..e0771689 100644 --- a/server/mergin/auth/api.yaml +++ b/server/mergin/auth/api.yaml @@ -68,8 +68,8 @@ paths: post: tags: - user - summary: Update profile of user in sesssion - description: Update profile of user in sesssion + summary: Update profile of user in session + description: Update profile of user in session operationId: mergin.auth.controller.update_user_profile requestBody: description: Updated profile @@ -101,6 +101,8 @@ paths: $ref: "#/components/responses/BadStatusResp" "401": $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/Forbidden" /app/auth/refresh/csrf: get: summary: Get refreshed csrf token @@ -426,6 +428,8 @@ paths: description: OK "400": $ref: "#/components/responses/BadStatusResp" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFoundResp" /app/auth/reset-password/{token}: @@ -462,6 +466,8 @@ paths: description: OK "400": $ref: "#/components/responses/BadStatusResp" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFoundResp" /app/auth/confirm-email/{token}: @@ -895,6 +901,8 @@ components: example: my-workspace role: $ref: "#/components/schemas/WorkspaceRole" + can_edit_profile: + type: boolean LoginResponse: allOf: - $ref: "#/components/schemas/UserDetail" diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index 7d3d8e29..acfccf43 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -11,12 +11,14 @@ from .commands import add_commands from .config import Configuration -from .models import User, UserProfile +from .models import User # signal for other versions to listen to user_account_closed = signal("user_account_closed") user_created = signal("user_created") +CANNOT_EDIT_PROFILE_MSG = "You cannot edit profile of this user" + def register(app): """Register mergin auth module in Flask app @@ -70,6 +72,20 @@ def wrapped_func(*args, **kwargs): return wrapped_func +def edit_profile_enabled(f): + """Decorator to check if user can edit their profile (it is not allowed for SSO users)""" + + @functools.wraps(f) + def wrapped_func(*args, **kwargs): + if not current_user or not current_user.is_authenticated: + return "Authentication information is missing or invalid.", 401 + if not current_user.can_edit_profile: + return CANNOT_EDIT_PROFILE_MSG, 403 + return f(*args, **kwargs) + + return wrapped_func + + def authenticate(login, password): if "@" in login: query = func.lower(User.email) == func.lower(login) diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 980bc14c..3e00ce16 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -20,6 +20,8 @@ generate_confirmation_token, user_created, user_account_closed, + edit_profile_enabled, + CANNOT_EDIT_PROFILE_MSG, ) from .bearer import encode_token from .models import User, LoginHistory, UserProfile @@ -254,6 +256,7 @@ def logout(): # pylint: disable=W0613,W0612 @auth_required +@edit_profile_enabled def change_password(): # pylint: disable=W0613,W0612 form = UserChangePasswordForm() if form.validate_on_submit(): @@ -268,6 +271,7 @@ def change_password(): # pylint: disable=W0613,W0612 @auth_required +@edit_profile_enabled def resend_confirm_email(): # pylint: disable=W0613,W0612 send_confirmation_email( current_app, @@ -292,6 +296,9 @@ def password_reset(): # pylint: disable=W0613,W0612 if not user.active: # user should confirm email first return jsonify({"email": ["Account is not active"]}), 400 + if not user.can_edit_profile: + # using SSO + abort(403, CANNOT_EDIT_PROFILE_MSG) send_confirmation_email( current_app, @@ -311,6 +318,8 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 user = User.query.filter_by(email=email).first_or_404() if not user.active: abort(400, "Account is not active") + if not user.can_edit_profile: + abort(403, CANNOT_EDIT_PROFILE_MSG) form = UserPasswordForm.from_json(request.json) if form.validate(): @@ -331,6 +340,8 @@ def confirm_email(token): # pylint: disable=W0613,W0612 abort(400, "Invalid token") user = User.query.filter_by(email=email).first_or_404() + if not user.can_edit_profile: + abort(403, CANNOT_EDIT_PROFILE_MSG) if user.verified_email: return "", 200 @@ -343,6 +354,7 @@ def confirm_email(token): # pylint: disable=W0613,W0612 @auth_required +@edit_profile_enabled def update_user_profile(): # pylint: disable=W0613,W0612 form = UserProfileDataForm.from_json(request.json) email_changed = current_user.email != form.email.data.strip() diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 31499ad3..7587a622 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -41,7 +41,7 @@ class User(db.Model): db.Index("ix_user_email", func.lower(email), unique=True), ) - def __init__(self, username, email, passwd, is_admin=False): + def __init__(self, username, email, passwd=None, is_admin=False): self.username = username self.email = email self.assign_password(passwd) @@ -58,7 +58,11 @@ def check_password(self, password): def assign_password(self, password): if isinstance(password, str): password = password.encode("utf-8") - self.passwd = bcrypt.hashpw(password, bcrypt.gensalt()).decode("utf-8") + self.passwd = ( + bcrypt.hashpw(password, bcrypt.gensalt()).decode("utf-8") + if password + else None + ) @property def is_authenticated(self): @@ -236,6 +240,12 @@ def create( db.session.commit() return user + @property + def can_edit_profile(self) -> bool: + """Flag if we allow user to edit their email and name""" + # False when user is created by SSO login + return self.passwd is not None and self.active + class UserProfile(db.Model): user_id = db.Column( diff --git a/server/mergin/auth/schemas.py b/server/mergin/auth/schemas.py index 62d0bd1e..3f614ae1 100644 --- a/server/mergin/auth/schemas.py +++ b/server/mergin/auth/schemas.py @@ -101,6 +101,7 @@ class UserInfoSchema(ma.SQLAlchemyAutoSchema): receive_notifications = fields.Boolean(attribute="profile.receive_notifications") registration_date = DateTimeWithZ(attribute="registration_date") name = fields.Function(lambda obj: obj.profile.name()) + can_edit_profile = fields.Boolean(attribute="can_edit_profile") class Meta: model = User diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index a5217f6a..cb4de697 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -502,6 +502,19 @@ def test_update_user_profile(client): assert not user.verified_email assert user.email == "changed_email@mergin.co.uk" + # do not allow to update sso user + sso_user = add_user("sso_user", "sso") + login(client, sso_user.username, "sso") + sso_user.passwd = None + db.session.add(sso_user) + db.session.commit() + resp = client.post( + url_for("/.mergin_auth_controller_update_user_profile"), + data=json.dumps({"email": "changed_email@sso.co.uk"}), + headers=json_headers, + ) + assert resp.status_code == 403 + def test_search_user(client): user = User.query.filter_by(username="mergin").first() diff --git a/web-app/packages/lib/src/modules/user/types.ts b/web-app/packages/lib/src/modules/user/types.ts index 2305c053..bb1f0b29 100644 --- a/web-app/packages/lib/src/modules/user/types.ts +++ b/web-app/packages/lib/src/modules/user/types.ts @@ -66,6 +66,7 @@ export interface UserDetailResponse extends UserProfileResponse { receive_notifications: boolean registration_date: string workspaces: UserWorkspace[] + can_edit_profile: boolean } export interface WorkspaceResponse extends UserWorkspace { diff --git a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue index 4c6632a3..7326f055 100644 --- a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue +++ b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue @@ -12,7 +12,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial >

Account details

-
+
Date: Wed, 2 Apr 2025 15:37:06 +0200 Subject: [PATCH 02/43] Send SSO connections for server in statistics --- server/mergin/stats/models.py | 1 + server/mergin/stats/tasks.py | 1 + server/mergin/sync/workspace.py | 5 +++++ server/mergin/tests/test_statistics.py | 2 ++ 4 files changed, 9 insertions(+) diff --git a/server/mergin/stats/models.py b/server/mergin/stats/models.py index 3c32e887..cd67d01f 100644 --- a/server/mergin/stats/models.py +++ b/server/mergin/stats/models.py @@ -24,6 +24,7 @@ class ServerCallhomeData: server_version: Optional[str] monthly_contributors: Optional[int] editors: Optional[int] + sso_connections: Optional[int] class MerginInfo(db.Model): diff --git a/server/mergin/stats/tasks.py b/server/mergin/stats/tasks.py index 9812340d..a98365db 100644 --- a/server/mergin/stats/tasks.py +++ b/server/mergin/stats/tasks.py @@ -39,6 +39,7 @@ def get_callhome_data(info: MerginInfo | None = None) -> ServerCallhomeData: server_version=current_app.config["VERSION"], monthly_contributors=current_app.ws_handler.monthly_contributors_count(), editors=current_app.ws_handler.server_editors_count(), + sso_connections=current_app.ws_handler.sso_connections_count(), ) return data diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index 79aef2b9..a9818003 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -373,3 +373,8 @@ def server_editors_count(self) -> int: .group_by(ProjectUser.user_id) .count() ) + + @staticmethod + def sso_connections_count() -> int: + """Number of SSO connections for the server""" + return 0 diff --git a/server/mergin/tests/test_statistics.py b/server/mergin/tests/test_statistics.py index a87ba08c..6f339505 100644 --- a/server/mergin/tests/test_statistics.py +++ b/server/mergin/tests/test_statistics.py @@ -63,6 +63,7 @@ def test_send_statistics(app, caplog): "server_version", "monthly_contributors", "editors", + "sso_connections", } assert data["workspaces_count"] == 1 assert data["service_uuid"] == app.config["SERVICE_ID"] @@ -72,6 +73,7 @@ def test_send_statistics(app, caplog): assert data["projects_count"] == 2 assert data["contact_email"] == "test@example.com" assert data["editors"] == 2 + assert data["sso_connections"] == 0 # repeated action does not do anything task = send_statistics.s().apply() From 46f95c7f1927a6ced1ce2578433c30942cee0186 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Tue, 8 Apr 2025 17:27:57 +0200 Subject: [PATCH 03/43] Add service id to `server check` command --- server/mergin/commands.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/mergin/commands.py b/server/mergin/commands.py index 3b2b16d4..50d5ae0b 100644 --- a/server/mergin/commands.py +++ b/server/mergin/commands.py @@ -84,6 +84,7 @@ def _send_email(email: str): def _check_server(): # pylint: disable=W0612 """Check server configuration.""" + from .stats.models import MerginInfo _echo_title("Server health check") edition_map = { @@ -111,6 +112,14 @@ def _check_server(): # pylint: disable=W0612 else: click.secho(f"Your contact email is {contact_email}.", fg="green") + info = MerginInfo.query.first() + service_id = app.config.get("SERVICE_ID") or (info.service_id if info else None) + + if service_id: + click.secho(f"Service ID is {service_id}.", fg="green") + else: + _echo_error("No service ID set.") + tables = db.engine.table_names() if not tables: _echo_error("Database not initialized. Run flask init-db command") From bfd28c35b69d32af50665c138cd33bad584858b5 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 10 Apr 2025 11:21:48 +0200 Subject: [PATCH 04/43] Add possibility to lock a project - add column to project table - block push - do not count to workspace storage and project number --- server/mergin/sync/models.py | 3 +- server/mergin/sync/private_api_controller.py | 4 +-- server/mergin/sync/public_api_controller.py | 10 +++++++ server/mergin/sync/workspace.py | 11 ++++--- .../mergin/tests/test_project_controller.py | 30 +++++++++++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index ba13b947..d557188c 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -16,7 +16,6 @@ from pygeodiff import GeoDiff from sqlalchemy import text, null, desc, nullslast from sqlalchemy.dialects.postgresql import ARRAY, BIGINT, UUID, JSONB, ENUM -from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.types import String from sqlalchemy.ext.hybrid import hybrid_property from pygeodiff.geodifflib import GeoDiffLibError @@ -68,6 +67,7 @@ class Project(db.Model): db.Integer, db.ForeignKey("user.id"), nullable=True, index=True ) public = db.Column(db.Boolean, default=False, index=True, nullable=False) + locked_until = db.Column(db.DateTime, index=True) creator = db.relationship( "User", uselist=False, backref=db.backref("projects"), foreign_keys=[creator_id] @@ -88,6 +88,7 @@ def __init__( self.creator = creator self.latest_version = 0 self.public = kwargs.get("public", False) + self.locked_until = None latest_files = LatestProjectFiles(project=self) db.session.add(latest_files) diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 643c274c..073f8275 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -6,11 +6,10 @@ from flask import render_template, request, current_app, jsonify, abort from flask_login import current_user from sqlalchemy.orm import defer -from sqlalchemy import text, and_, desc, asc +from sqlalchemy import text from ..app import db from ..auth import auth_required -from ..auth.models import User, UserProfile from .forms import AccessPermissionForm from .models import ( Project, @@ -22,7 +21,6 @@ ProjectListSchema, ProjectAccessRequestSchema, AdminProjectSchema, - ProjectAccessSchema, ProjectAccessDetailSchema, ) from .permissions import ( diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 2a4829b9..b6857305 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -785,6 +785,11 @@ def project_push(namespace, project_name): changes = request.json["changes"] project_permission = current_app.project_handler.get_push_permission(changes) project = require_project(namespace, project_name, project_permission) + if project.locked_until: + abort( + 423, + f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + ) # pass full project object to request for later use request.view_args["project"] = project ws = project.workspace @@ -1027,6 +1032,11 @@ def push_finish(transaction_id): upload.changes ) project = upload.project + if project.locked_until: + abort( + 423, + f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + ) project_path = get_project_path(project) corrupted_files = [] diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index 79aef2b9..2994d104 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from typing import Dict, Tuple, Optional, Set, List from flask_login import current_user -from sqlalchemy import Column, literal, extract +from sqlalchemy import Column, literal, extract, and_ from sqlalchemy.sql.operators import is_ from .errors import UpdateProjectAccessError @@ -66,7 +66,7 @@ def disk_usage(self): projects_usage_list = ( db.session.query(Project.disk_usage) - .filter(Project.removed_at.is_(None)) + .filter(Project.removed_at.is_(None), Project.locked_until.is_(None)) .all() ) return sum(p.disk_usage for p in projects_usage_list) @@ -107,8 +107,11 @@ def project_count(self): return ( db.session.query(Project.disk_usage) - .filter(Project.workspace_id == self.id) - .filter(Project.removed_at.is_(None)) + .filter( + Project.workspace_id == self.id, + Project.removed_at.is_(None), + Project.locked_until.is_(None), + ) .count() ) diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 652932f5..5f8df253 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2621,3 +2621,33 @@ def test_supported_file_upload(client): resp.json["detail"] == f"Unsupported file type detected: {spoof_name}. Please remove the file or try compressing it into a ZIP file before uploading." ) + + +def test_locked_project(client, diff_project): + """Users cannot push to the locked project. Moreover, it does not count to the storage and project count67""" + # before project is locked + orig_p_count = diff_project.workspace.project_count() + orig_storage = diff_project.workspace.disk_usage() + # after locking the project + diff_project.locked_until = datetime.datetime.utcnow() + datetime.timedelta( + weeks=26 + ) + db.session.commit() + assert diff_project.workspace.project_count() == orig_p_count - 1 + assert diff_project.workspace.disk_usage() == orig_storage - diff_project.disk_usage + # push is not possible to the locked project + changes = _get_changes_without_added(test_project_dir) + project_path = get_project_path(diff_project) + data = {"version": "v1", "changes": changes} + resp = client.post( + f"/v1/project/push/{project_path}", + data=json.dumps(data, cls=DateTimeEncoder).encode("utf-8"), + headers=json_headers, + ) + assert resp.status_code == 423 + # to play safe push finish is also blocked + upload, upload_dir = create_transaction("mergin", changes) + url = "/v1/project/push/finish/{}".format(upload.id) + + resp = client.post(url, headers=json_headers) + assert resp.status_code == 423 From d9197992ca0e8537886496989e6ee5df71f5ce1d Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 11 Apr 2025 13:05:31 +0200 Subject: [PATCH 05/43] Adjust error type and message --- server/mergin/sync/models.py | 1 - server/mergin/sync/public_api_controller.py | 8 +++--- .../mergin/tests/test_project_controller.py | 10 +++++-- .../6cb54659c1de_add_locked_until.py | 28 +++++++++++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 server/migrations/community/6cb54659c1de_add_locked_until.py diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index d557188c..b7f9dc64 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -88,7 +88,6 @@ def __init__( self.creator = creator self.latest_version = 0 self.public = kwargs.get("public", False) - self.locked_until = None latest_files = LatestProjectFiles(project=self) db.session.add(latest_files) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index b6857305..f2f45e33 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -787,8 +787,8 @@ def project_push(namespace, project_name): project = require_project(namespace, project_name, project_permission) if project.locked_until: abort( - 423, - f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + 400, + f"This project is currently locked and you cannot make changes to it.", ) # pass full project object to request for later use request.view_args["project"] = project @@ -1034,8 +1034,8 @@ def push_finish(transaction_id): project = upload.project if project.locked_until: abort( - 423, - f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + 400, + f"This project is currently locked and you cannot make changes to it.", ) project_path = get_project_path(project) corrupted_files = [] diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 5f8df253..68261553 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2624,7 +2624,7 @@ def test_supported_file_upload(client): def test_locked_project(client, diff_project): - """Users cannot push to the locked project. Moreover, it does not count to the storage and project count67""" + """Users cannot push to the locked project. Moreover, it does not count to the storage and project count""" # before project is locked orig_p_count = diff_project.workspace.project_count() orig_storage = diff_project.workspace.disk_usage() @@ -2644,10 +2644,14 @@ def test_locked_project(client, diff_project): data=json.dumps(data, cls=DateTimeEncoder).encode("utf-8"), headers=json_headers, ) - assert resp.status_code == 423 + assert resp.status_code == 400 + assert ( + resp.json.get("detail") + == "This project is currently locked and you cannot make changes to it." + ) # to play safe push finish is also blocked upload, upload_dir = create_transaction("mergin", changes) url = "/v1/project/push/finish/{}".format(upload.id) resp = client.post(url, headers=json_headers) - assert resp.status_code == 423 + assert resp.status_code == 400 diff --git a/server/migrations/community/6cb54659c1de_add_locked_until.py b/server/migrations/community/6cb54659c1de_add_locked_until.py new file mode 100644 index 00000000..ff82a22d --- /dev/null +++ b/server/migrations/community/6cb54659c1de_add_locked_until.py @@ -0,0 +1,28 @@ +"""Add locked_until to project + +Revision ID: 6cb54659c1de +Revises: 5ad13be6f7ef +Create Date: 2025-04-10 11:11:09.277522 + +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "6cb54659c1de" +down_revision = "5ad13be6f7ef" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("project", sa.Column("locked_until", sa.DateTime(), nullable=True)) + op.create_index( + op.f("ix_project_locked_until"), "project", ["locked_until"], unique=False + ) + + +def downgrade(): + op.drop_index(op.f("ix_project_locked_until"), table_name="project") + op.drop_column("project", "locked_until") From 28ca99632d78bbc4590ad09daf1d75099cb0a7cb Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Fri, 11 Apr 2025 14:19:41 +0200 Subject: [PATCH 06/43] Initial version for zip download rework --- .prod.env | 2 + server/application.py | 11 ++++- server/mergin/sync/config.py | 7 +++ server/mergin/sync/models.py | 7 +++ server/mergin/sync/public_api_controller.py | 40 ++++++++++------ server/mergin/sync/tasks.py | 51 +++++++++++++++++++++ 6 files changed, 104 insertions(+), 14 deletions(-) diff --git a/.prod.env b/.prod.env index b6b3dc2a..96231c46 100644 --- a/.prod.env +++ b/.prod.env @@ -14,6 +14,8 @@ FLASK_DEBUG=0 #LOCAL_PROJECTS=os.path.join(config_dir, os.pardir, os.pardir, 'projects') # for local storage type LOCAL_PROJECTS=/data +PROJECTS_ARCHIVES_DIR=/data/projects_archives + #MAINTENANCE_FILE=os.path.join(LOCAL_PROJECTS, 'MAINTENANCE') # locking file when backups are created MAINTENANCE_FILE=/data/MAINTENANCE diff --git a/server/application.py b/server/application.py index 2a73f2ef..5cb08b69 100644 --- a/server/application.py +++ b/server/application.py @@ -23,7 +23,11 @@ from celery.schedules import crontab from mergin.app import create_app from mergin.auth.tasks import anonymize_removed_users -from mergin.sync.tasks import remove_temp_files, remove_projects_backups +from mergin.sync.tasks import ( + remove_projects_archives, + remove_temp_files, + remove_projects_backups, +) from mergin.celery import celery, configure_celery from mergin.stats.config import Configuration from mergin.stats.tasks import save_statistics, send_statistics @@ -76,3 +80,8 @@ def setup_periodic_tasks(sender, **kwargs): send_statistics, name="send usage statistics", ) + sender.add_periodic_task( + crontab(hour=3, minute=0), + remove_projects_archives, + name="remove old project archives", + ) diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index 0f19deb4..cece6080 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -14,6 +14,13 @@ class Configuration(object): "LOCAL_PROJECTS", default=os.path.join(config_dir, os.pardir, os.pardir, os.pardir, "projects"), ) + PROJECTS_ARCHIVES_DIR = config( + "PROJECTS_ARCHIVES_DIR", + default=os.path.join(LOCAL_PROJECTS, "projects_archives"), + ) + PROJECTS_ARCHIVES_EXPIRATION = config( + "PROJECTS_ARCHIVES_EXPIRATION", cast=int, default=7 + ) # locking file when backups are created MAINTENANCE_FILE = config( "MAINTENANCE_FILE", default=os.path.join(LOCAL_PROJECTS, "MAINTENANCE") diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index ba13b947..67dcd836 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -988,6 +988,13 @@ def changes_count(self) -> Dict: result = db.session.execute(query, params).fetchall() return {row[0]: row[1] for row in result} + @property + def zip_path(self): + return os.path.join( + current_app.config["PROJECTS_ARCHIVES_DIR"], + f"{self.project_id}-{self.to_v_name(self.name)}.zip", + ) + class Upload(db.Model): id = db.Column(db.String, primary_key=True) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 2a4829b9..66e849db 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -20,6 +20,7 @@ from flask import ( abort, current_app, + send_file, send_from_directory, jsonify, make_response, @@ -35,7 +36,6 @@ from werkzeug.exceptions import HTTPException from mergin.sync.forms import project_name_validation - from .interfaces import WorkspaceRole from ..app import db from ..auth import auth_required @@ -94,6 +94,8 @@ ) from .errors import StorageLimitHit from ..utils import format_time_delta +from .tasks import create_project_version_zip + push_finished = signal("push_finished") # TODO: Move to database events to handle all commits to project versions @@ -291,18 +293,16 @@ def delete_project(namespace, project_name): # noqa: E501 def download_project( - namespace, project_name, format=None, version=None + namespace, project_name, version=None ): # noqa: E501 # pylint: disable=W0622 - """Download full project + """Download full project in any version - Download whole project folder as zip file or multipart stream # noqa: E501 + Download whole project folder as zip file :param project_name: Name of project to download. :type project_name: str :param namespace: Workspace for project to look into. :type namespace: str - :param format: Output format (only zip available). - :type format: str :param version: Particular version to download :type version: str @@ -322,14 +322,28 @@ def download_project( "The total size of requested files is too large to download as a single zip, " "please use different method/client for download", ) - try: - return project.storage.download_files( - project_version.files, - format, - version=ProjectVersion.from_v_name(version) if version else None, + + # check zip is already created + if os.path.exists(project_version.zip_path): + if current_app.config["USE_X_ACCEL"]: + resp = make_response() + resp.headers["X-Accel-Redirect"] = f"/download/{project_version.zip_path}" + resp.headers["X-Accel-Buffering"] = True + resp.headers["X-Accel-Expires"] = "off" + resp.headers["Content-Type"] = "application/zip" + else: + resp = send_file(project_version.zip_path, mimetype="application/zip") + + resp.headers["Content-Disposition"] = ( + f"attachment; filename={project.id}-{lookup_version}.zip" ) - except FileNotFound as e: - abort(404, str(e)) + return resp + + temp_zip_path = project_version.zip_path + ".partial" + if not os.path.exists(temp_zip_path): + create_project_version_zip.delay(project_version.id) + + return "Project zip being prepared, please try again later", 202 def download_project_file( diff --git a/server/mergin/sync/tasks.py b/server/mergin/sync/tasks.py index ac9fdb1c..021744e9 100644 --- a/server/mergin/sync/tasks.py +++ b/server/mergin/sync/tasks.py @@ -7,6 +7,7 @@ import os import time from datetime import datetime, timedelta +from zipfile import ZIP_DEFLATED, ZipFile from flask import current_app from .models import Project, ProjectVersion, FileHistory @@ -102,3 +103,53 @@ def optimize_storage(project_id): age = time.time() - os.path.getmtime(item.abs_path) if age > Configuration.FILE_EXPIRATION: move_to_tmp(item.abs_path) + + +@celery.task +def create_project_version_zip(version_id: int): + """Create zip file for project version.""" + db.session.info = {"msg": "create_project_version_zip"} + project_version = ProjectVersion.query.get(version_id) + if not project_version: + return + + zip_path = project_version.zip_path + ".partial" + # already running job + if os.path.exists(zip_path): + return + + os.makedirs(os.path.dirname(zip_path), exist_ok=True) + try: + with ZipFile( + zip_path, + "w", + compression=ZIP_DEFLATED, + compresslevel=1, + ) as archive: + for f in project_version.files: + project_version.project.storage.restore_versioned_file( + f.path, project_version.name + ) + archive.write( + project_version.project.storage.file_path(f.location), f.path + ) + # move zip file to final location + os.rename(zip_path, project_version.zip_path) + finally: + # remove partial zip file if exists + if os.path.exists(zip_path): + os.remove(zip_path) + + +@celery.task +def remove_projects_archives(): + """Remove created zip files for project versions if they were not accessed for certain time""" + for file in os.listdir(current_app.config["PROJECT_ARCHIVES_DIR"]): + path = os.path.join(current_app.config["PROJECT_ARCHIVES_DIR"], file) + if datetime.fromtimestamp(os.path.getatime(path)) < datetime.now( + datetime.timezone.utc + ) - timedelta(days=current_app.config["PROJECT_ARCHIVES_EXPIRATION"]): + try: + shutil.rmtree(path) + except OSError as e: + print(f"Unable to remove {path}: {str(e)}") From a5b7290d07ceb98ebb10f16f9150af6fe1b9c918 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 11 Apr 2025 12:37:17 +0200 Subject: [PATCH 07/43] Make prpoerty optional for an external link --- web-app/packages/lib/src/modules/layout/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/packages/lib/src/modules/layout/types.ts b/web-app/packages/lib/src/modules/layout/types.ts index 38b3e873..51ebe39f 100644 --- a/web-app/packages/lib/src/modules/layout/types.ts +++ b/web-app/packages/lib/src/modules/layout/types.ts @@ -7,5 +7,5 @@ export interface SideBarItemModel { to?: string href?: string icon: string - active: boolean + active?: boolean } From 6e07388a44cc63fe249f8f63b381aa484b856b5f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 14 Apr 2025 11:17:06 +0200 Subject: [PATCH 08/43] Add custom error for blocking push to locked project --- server/mergin/sync/errors.py | 5 +++++ server/mergin/sync/public_api.yaml | 13 +++++++++++++ server/mergin/sync/public_api_controller.py | 12 +++--------- server/mergin/tests/test_project_controller.py | 12 ++++++------ 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/server/mergin/sync/errors.py b/server/mergin/sync/errors.py index fa22d858..d253ef4c 100644 --- a/server/mergin/sync/errors.py +++ b/server/mergin/sync/errors.py @@ -34,3 +34,8 @@ def to_dict(self) -> Dict: data["current_usage"] = self.current_usage data["storage_limit"] = self.storage_limit return data + + +class ProjectLocked(ResponseError): + code = "ProjectLocked" + detail = "The project is currently locked and you cannot make changes to it" diff --git a/server/mergin/sync/public_api.yaml b/server/mergin/sync/public_api.yaml index c8072ad6..2eeb8a46 100644 --- a/server/mergin/sync/public_api.yaml +++ b/server/mergin/sync/public_api.yaml @@ -652,6 +652,7 @@ paths: - $ref: "#/components/schemas/UnprocessableEntityError" - $ref: "#/components/schemas/TrialExpired" - $ref: "#/components/schemas/StorageLimitHit" + - $ref: "#/components/schemas/ProjectLocked" x-openapi-router-controller: mergin.sync.public_api_controller #not implemented in connexion, going directly to flask endpoint # /project/push/chunk/{transaction_id}/{chunk_id}: @@ -760,6 +761,12 @@ paths: $ref: "#/components/responses/NotFoundResp" "409": $ref: "#/components/responses/ConflictResp" + "422": + description: Push is not allowed + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProjectLocked' x-openapi-router-controller: mergin.sync.public_api_controller /project/push/cancel/{transaction_id}: post: @@ -1493,3 +1500,9 @@ components: size: type: integer example: 512 + ProjectLocked: + allOf: + - $ref: '#/components/schemas/CustomError' + example: + code: ProjectLocked + detail: The project is currently locked and you cannot make changes to it diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index f2f45e33..2763ca54 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -92,7 +92,7 @@ is_supported_extension, get_mimetype, ) -from .errors import StorageLimitHit +from .errors import StorageLimitHit, ProjectLocked from ..utils import format_time_delta push_finished = signal("push_finished") @@ -786,10 +786,7 @@ def project_push(namespace, project_name): project_permission = current_app.project_handler.get_push_permission(changes) project = require_project(namespace, project_name, project_permission) if project.locked_until: - abort( - 400, - f"This project is currently locked and you cannot make changes to it.", - ) + abort(make_response(jsonify(ProjectLocked().to_dict()), 422)) # pass full project object to request for later use request.view_args["project"] = project ws = project.workspace @@ -1033,10 +1030,7 @@ def push_finish(transaction_id): ) project = upload.project if project.locked_until: - abort( - 400, - f"This project is currently locked and you cannot make changes to it.", - ) + abort(make_response(jsonify(ProjectLocked().to_dict()), 422)) project_path = get_project_path(project) corrupted_files = [] diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 68261553..975377ed 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2644,14 +2644,14 @@ def test_locked_project(client, diff_project): data=json.dumps(data, cls=DateTimeEncoder).encode("utf-8"), headers=json_headers, ) - assert resp.status_code == 400 - assert ( - resp.json.get("detail") - == "This project is currently locked and you cannot make changes to it." - ) + assert resp.status_code == 422 + assert resp.headers["Content-Type"] == "application/problem+json" + assert resp.json["code"] == "ProjectLocked" # to play safe push finish is also blocked upload, upload_dir = create_transaction("mergin", changes) url = "/v1/project/push/finish/{}".format(upload.id) resp = client.post(url, headers=json_headers) - assert resp.status_code == 400 + assert resp.status_code == 422 + assert resp.headers["Content-Type"] == "application/problem+json" + assert resp.json["code"] == "ProjectLocked" From 7ce05d6074542801c44208db800e697fbfd85354 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 16 Apr 2025 15:48:03 +0200 Subject: [PATCH 09/43] Move download() function to private api --- server/mergin/sync/private_api.yaml | 31 ++++++++++ server/mergin/sync/private_api_controller.py | 61 +++++++++++++++++++- server/mergin/sync/public_api.yaml | 39 ------------- server/mergin/sync/public_api_controller.py | 54 ----------------- 4 files changed, 91 insertions(+), 94 deletions(-) diff --git a/server/mergin/sync/private_api.yaml b/server/mergin/sync/private_api.yaml index 5db24675..4413d7a5 100644 --- a/server/mergin/sync/private_api.yaml +++ b/server/mergin/sync/private_api.yaml @@ -383,6 +383,30 @@ paths: "404": $ref: "#/components/responses/NotFoundResp" x-openapi-router-controller: mergin.sync.private_api_controller + /projects/{id}/download: + get: + tags: + - project + summary: Download full project + description: Download whole project folder as zip file or multipart stream + operationId: download_project + parameters: + - $ref: "#/components/parameters/project_id" + responses: + "200": + description: Zip file or stream + content: + application/octet-stream: + schema: + type: string + format: binary + "400": + $ref: "#/components/responses/BadStatusResp" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFoundResp" + x-openapi-router-controller: mergin.sync.private_api_controller components: responses: UnauthorizedError: @@ -436,6 +460,13 @@ components: schema: type: string example: project_1 + project_id: + name: project_id + in: query + description: Project uuid + required: true + schema: + type: string schemas: CustomError: type: object diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 073f8275..56191197 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -1,9 +1,19 @@ # Copyright (C) Lutra Consulting Limited # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial +import os +from datetime import datetime, timedelta from blinker import signal from connexion import NoContent -from flask import render_template, request, current_app, jsonify, abort +from flask import ( + render_template, + request, + current_app, + jsonify, + abort, + make_response, + send_file, +) from flask_login import current_user from sqlalchemy.orm import defer from sqlalchemy import text @@ -16,6 +26,7 @@ AccessRequest, ProjectRole, RequestStatus, + ProjectVersion, ) from .schemas import ( ProjectListSchema, @@ -29,8 +40,11 @@ check_workspace_permissions, ) from ..utils import parse_order_params, split_order_param, get_order_param +from .tasks import create_project_version_zip +from .storages.disk import move_to_tmp project_access_granted = signal("project_access_granted") +PARTIAL_ZIP_EXPIRATION = 5 # minutes @auth_required @@ -309,3 +323,48 @@ def get_project_access(id: str): result = current_app.ws_handler.project_access(project) data = ProjectAccessDetailSchema(many=True).dump(result) return data, 200 + + +@auth_required +def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W0622 + """Download whole project folder as zip file in any version + + :rtype: file - zip archive or multipart stream with project files + """ + project = require_project_by_uuid(id, ProjectPermissions.Read) + lookup_version = ( + ProjectVersion.from_v_name(version) if version else project.latest_version + ) + project_version = ProjectVersion.query.filter_by( + project_id=project.id, name=lookup_version + ).first_or_404("Project version does not exist") + + if project_version.project_size > current_app.config["MAX_DOWNLOAD_ARCHIVE_SIZE"]: + abort( + 400, + "The total size of requested files is too large to download as a single zip, " + "please use different method/client for download", + ) + + # check zip is already created + if os.path.exists(project_version.zip_path): + if current_app.config["USE_X_ACCEL"]: + resp = make_response() + resp.headers["X-Accel-Redirect"] = f"/download/{project_version.zip_path}" + resp.headers["X-Accel-Buffering"] = True + resp.headers["X-Accel-Expires"] = "off" + resp.headers["Content-Type"] = "application/zip" + else: + resp = send_file(project_version.zip_path, mimetype="application/zip") + + resp.headers["Content-Disposition"] = ( + f"attachment; filename={project.id}-{lookup_version}.zip" + ) + return resp + + temp_zip_path = project_version.zip_path + ".partial" + + if not os.path.exists(temp_zip_path): + create_project_version_zip.delay(project_version.id) + + return "Project zip being prepared, please try again later", 202 diff --git a/server/mergin/sync/public_api.yaml b/server/mergin/sync/public_api.yaml index 2eeb8a46..5227b562 100644 --- a/server/mergin/sync/public_api.yaml +++ b/server/mergin/sync/public_api.yaml @@ -494,45 +494,6 @@ paths: '404': $ref: '#/components/responses/NotFoundResp' x-openapi-router-controller: mergin.sync.public_api_controller - /project/download/{namespace}/{project_name}: - get: - tags: - - project - summary: Download full project - description: Download whole project folder as zip file or multipart stream - operationId: download_project - parameters: - - $ref: "#/components/parameters/projectName" - - $ref: "#/components/parameters/namespace" - - name: format - in: query - description: Output format (only zip available). - required: false - schema: - type: string - enum: - - zip - - name: version - in: query - description: Particular version to download - required: false - schema: - $ref: "#/components/schemas/VersionName" - responses: - "200": - description: Zip file or stream - content: - application/octet-stream: - schema: - type: string - format: binary - "400": - $ref: "#/components/responses/BadStatusResp" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFoundResp" - x-openapi-router-controller: mergin.sync.public_api_controller /project/raw/{namespace}/{project_name}: get: tags: diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 3ed35e95..94423f17 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -292,60 +292,6 @@ def delete_project(namespace, project_name): # noqa: E501 return NoContent, 200 -def download_project( - namespace, project_name, version=None -): # noqa: E501 # pylint: disable=W0622 - """Download full project in any version - - Download whole project folder as zip file - - :param project_name: Name of project to download. - :type project_name: str - :param namespace: Workspace for project to look into. - :type namespace: str - :param version: Particular version to download - :type version: str - - :rtype: file - zip archive or multipart stream with project files - """ - project = require_project(namespace, project_name, ProjectPermissions.Read) - lookup_version = ( - ProjectVersion.from_v_name(version) if version else project.latest_version - ) - project_version = ProjectVersion.query.filter_by( - project_id=project.id, name=lookup_version - ).first_or_404("Project version does not exist") - - if project_version.project_size > current_app.config["MAX_DOWNLOAD_ARCHIVE_SIZE"]: - abort( - 400, - "The total size of requested files is too large to download as a single zip, " - "please use different method/client for download", - ) - - # check zip is already created - if os.path.exists(project_version.zip_path): - if current_app.config["USE_X_ACCEL"]: - resp = make_response() - resp.headers["X-Accel-Redirect"] = f"/download/{project_version.zip_path}" - resp.headers["X-Accel-Buffering"] = True - resp.headers["X-Accel-Expires"] = "off" - resp.headers["Content-Type"] = "application/zip" - else: - resp = send_file(project_version.zip_path, mimetype="application/zip") - - resp.headers["Content-Disposition"] = ( - f"attachment; filename={project.id}-{lookup_version}.zip" - ) - return resp - - temp_zip_path = project_version.zip_path + ".partial" - if not os.path.exists(temp_zip_path): - create_project_version_zip.delay(project_version.id) - - return "Project zip being prepared, please try again later", 202 - - def download_project_file( project_name, namespace, file, version=None, diff=None ): # noqa: E501 From e09fe4997058bb4e1595c5d822030a9892997c7c Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 16 Apr 2025 15:49:38 +0200 Subject: [PATCH 10/43] Add safecheck for inactive partials --- server/mergin/sync/private_api_controller.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 56191197..e8409b67 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -363,6 +363,13 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06 return resp temp_zip_path = project_version.zip_path + ".partial" + # to be safe we are not in vicious circle remove inactive partial zip + if os.path.exists(temp_zip_path) and datetime.fromtimestamp( + os.path.getmtime(temp_zip_path) + ) < datetime.now(datetime.timezone.utc) - timedelta( + minutes=current_app.config["PARTIAL_ZIP_EXPIRATION"] + ): + move_to_tmp(temp_zip_path) if not os.path.exists(temp_zip_path): create_project_version_zip.delay(project_version.id) From 0b9fd2028f7deca3f6cbe62f010e517050a6a867 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 17 Apr 2025 09:24:54 +0200 Subject: [PATCH 11/43] Tests --- server/mergin/sync/private_api.yaml | 25 ++++----- server/mergin/sync/tasks.py | 18 ++++--- server/mergin/tests/test_celery.py | 25 ++++++++- .../mergin/tests/test_private_project_api.py | 51 ++++++++++++++++++- .../mergin/tests/test_project_controller.py | 26 +++++----- server/mergin/tests/utils.py | 16 ++++++ 6 files changed, 126 insertions(+), 35 deletions(-) diff --git a/server/mergin/sync/private_api.yaml b/server/mergin/sync/private_api.yaml index 4413d7a5..3365f13a 100644 --- a/server/mergin/sync/private_api.yaml +++ b/server/mergin/sync/private_api.yaml @@ -388,18 +388,19 @@ paths: tags: - project summary: Download full project - description: Download whole project folder as zip file or multipart stream + description: Download whole project folder as zip file operationId: download_project parameters: - - $ref: "#/components/parameters/project_id" + - $ref: "#/components/parameters/ProjectId" + - name: version + in: query + description: Particular version to download + required: false + schema: + $ref: "#/components/schemas/VersionName" responses: - "200": - description: Zip file or stream - content: - application/octet-stream: - schema: - type: string - format: binary + "202": + description: Accepted "400": $ref: "#/components/responses/BadStatusResp" "403": @@ -460,9 +461,9 @@ components: schema: type: string example: project_1 - project_id: - name: project_id - in: query + ProjectId: + name: id + in: path description: Project uuid required: true schema: diff --git a/server/mergin/sync/tasks.py b/server/mergin/sync/tasks.py index 021744e9..f56fb273 100644 --- a/server/mergin/sync/tasks.py +++ b/server/mergin/sync/tasks.py @@ -6,7 +6,7 @@ import shutil import os import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from zipfile import ZIP_DEFLATED, ZipFile from flask import current_app @@ -144,12 +144,14 @@ def create_project_version_zip(version_id: int): @celery.task def remove_projects_archives(): """Remove created zip files for project versions if they were not accessed for certain time""" - for file in os.listdir(current_app.config["PROJECT_ARCHIVES_DIR"]): - path = os.path.join(current_app.config["PROJECT_ARCHIVES_DIR"], file) - if datetime.fromtimestamp(os.path.getatime(path)) < datetime.now( - datetime.timezone.utc - ) - timedelta(days=current_app.config["PROJECT_ARCHIVES_EXPIRATION"]): + for file in os.listdir(current_app.config["PROJECTS_ARCHIVES_DIR"]): + path = os.path.join(current_app.config["PROJECTS_ARCHIVES_DIR"], file) + if datetime.fromtimestamp( + os.path.getatime(path), tz=timezone.utc + ) < datetime.now(timezone.utc) - timedelta( + days=current_app.config["PROJECTS_ARCHIVES_EXPIRATION"] + ): try: - shutil.rmtree(path) + os.remove(path) except OSError as e: - print(f"Unable to remove {path}: {str(e)}") + logging.error(f"Unable to remove {path}: {str(e)}") diff --git a/server/mergin/tests/test_celery.py b/server/mergin/tests/test_celery.py index 672ae60b..a5d07f47 100644 --- a/server/mergin/tests/test_celery.py +++ b/server/mergin/tests/test_celery.py @@ -12,10 +12,15 @@ from ..config import Configuration from ..sync.models import Project, AccessRequest, ProjectRole, ProjectVersion from ..celery import send_email_async -from ..sync.tasks import remove_temp_files, remove_projects_backups +from ..sync.tasks import ( + remove_temp_files, + remove_projects_backups, + create_project_version_zip, + remove_projects_archives, +) from ..sync.storages.disk import move_to_tmp from . import test_project, test_workspace_name, test_workspace_id -from .utils import add_user, create_workspace, create_project, login +from .utils import add_user, create_workspace, create_project, login, modify_file_times from ..auth.models import User from . import json_headers @@ -136,3 +141,19 @@ def test_remove_deleted_project_backups(client): ) assert ProjectVersion.query.filter_by(project_id=rm_project.id).count() != 0 assert str(rm_project.id) in rm_project.name + + +def test_create_project_version_zip(diff_project): + latest_version = diff_project.get_latest_version() + assert not os.path.exists(latest_version.zip_path) + create_project_version_zip(diff_project.latest_version) + assert os.path.exists(latest_version.zip_path) + assert not os.path.exists(latest_version.zip_path + ".partial") + remove_projects_archives() + assert os.path.exists(latest_version.zip_path) + new_time = datetime.now() - timedelta( + days=current_app.config["PROJECTS_ARCHIVES_EXPIRATION"] + 1 + ) + modify_file_times(latest_version.zip_path, new_time) + remove_projects_archives() + assert not os.path.exists(latest_version.zip_path) diff --git a/server/mergin/tests/test_private_project_api.py b/server/mergin/tests/test_private_project_api.py index 5d5ec518..abebc59e 100644 --- a/server/mergin/tests/test_private_project_api.py +++ b/server/mergin/tests/test_private_project_api.py @@ -5,14 +5,16 @@ import datetime import json import os +from unittest.mock import patch +import pytest from flask import url_for from ..app import db from ..sync.models import AccessRequest, Project, ProjectRole, RequestStatus from ..auth.models import User from ..config import Configuration -from . import json_headers +from . import json_headers, test_project, test_workspace_name from .utils import ( add_user, login, @@ -418,3 +420,50 @@ def test_admin_project_list(client): p.delete() resp = client.get("/app/admin/projects?page=1&per_page=15&like=mergin") assert len(resp.json["items"]) == 14 + + +test_download_proj_data = [ + (None, 202), + ("v1", 202), + ("v100", 404), +] + + +@pytest.mark.parametrize("version,expected", test_download_proj_data) +@patch("mergin.sync.tasks.create_project_version_zip.delay") +def test_download_project(mock_create_zip, client, version, expected, diff_project): + resp = client.get( + url_for( + "/app.mergin_sync_private_api_controller_download_project", + id=diff_project.id, + version=version if version else "", + ) + ) + assert resp.status_code == expected + assert mock_create_zip.called if expected == 202 else not mock_create_zip.called + if not version: + call_args, _ = mock_create_zip.call_args + args = call_args[0] + assert args == diff_project.latest_version + + +def test_large_project_download_fail(client, diff_project): + resp = client.get( + url_for( + "/app.mergin_sync_private_api_controller_download_project", + id=diff_project.id, + version="v1", + ) + ) + assert resp.status_code == 202 + # pretend testing project to be too large by lowering limit + client.application.config["MAX_DOWNLOAD_ARCHIVE_SIZE"] = 10 + resp = client.get( + url_for( + "/app.mergin_sync_private_api_controller_download_project", + id=diff_project.id, + version="v1", + ) + ) + assert resp.status_code == 400 + assert "The total size of requested files is too large" in resp.json["detail"] diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 975377ed..d9cf0e78 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -786,18 +786,20 @@ def test_download_project(client, proj_name, out_format, expected, version): assert resp.status_code == expected -def test_large_project_download_fail(client, diff_project): - resp = client.get( - f"/v1/project/download/{test_workspace_name}/{diff_project.name}?v1format=zip" - ) - assert resp.status_code == 200 - # pretend testing project to be too large by lowering limit - client.application.config["MAX_DOWNLOAD_ARCHIVE_SIZE"] = 10 - resp = client.get( - f"/v1/project/download/{test_workspace_name}/{diff_project.name}?v1format=zip" - ) - assert resp.status_code == 400 - assert "The total size of requested files is too large" in resp.json["detail"] +# +# +# def test_large_project_download_fail(client, diff_project): +# resp = client.get( +# f"/v1/project/download/{test_workspace_name}/{diff_project.name}?v1format=zip" +# ) +# assert resp.status_code == 200 +# # pretend testing project to be too large by lowering limit +# client.application.config["MAX_DOWNLOAD_ARCHIVE_SIZE"] = 10 +# resp = client.get( +# f"/v1/project/download/{test_workspace_name}/{diff_project.name}?v1format=zip" +# ) +# assert resp.status_code == 400 +# assert "The total size of requested files is too large" in resp.json["detail"] test_download_file_data = [ diff --git a/server/mergin/tests/utils.py b/server/mergin/tests/utils.py index 0f768c6e..94fc033f 100644 --- a/server/mergin/tests/utils.py +++ b/server/mergin/tests/utils.py @@ -371,3 +371,19 @@ def push_change(project, action, path, src_dir): db.session.add(project) db.session.commit() return pv + + +def modify_file_times(path, time: datetime, accessed=True, modified=True): + """Modifies files access and modification time + + :param path: path to file to be modified + :param time: new time - seconds since the epoch + :param accessed: modify access time + :param modified: modify modification time + """ + file_stat = os.stat(path) + epoch_time = time.timestamp() + atime = epoch_time if accessed else file_stat.st_atime + mtime = epoch_time if modified else file_stat.st_mtime + + os.utime(path, (atime, mtime)) From c8bc4b6f2e00bcc7b9a9d7e0dac7b0c3f2c97bf3 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 17 Apr 2025 09:36:01 +0200 Subject: [PATCH 12/43] Rm legacy tests --- .../mergin/tests/test_project_controller.py | 64 ------------------- 1 file changed, 64 deletions(-) diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index d9cf0e78..b1f60a8f 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -738,70 +738,6 @@ def test_update_project(client): assert resp.json["role"] == "owner" -test_download_proj_data = [ - (test_project, None, 200, None), - (test_project, "zip", 200, None), - (test_project, "foo", 400, None), - ("bar", None, 404, None), - (test_project, None, 200, "v1"), - (test_project, "zip", 200, "v1"), - (test_project, "foo", 400, "v1"), - ("bar", None, 404, "v99"), - (test_project, None, 404, "v100"), - (test_project, "zip", 404, "v100"), - (test_project, "foo", 400, "v100"), - ("bar", None, 404, "v100"), -] - - -@pytest.mark.parametrize( - "proj_name,out_format,expected,version", test_download_proj_data -) -def test_download_project(client, proj_name, out_format, expected, version): - if out_format: - resp = client.get( - "/v1/project/download/{}/{}?{}format={}".format( - test_workspace_name, - proj_name, - "version={}&".format(version) if version else "", - out_format, - ) - ) - if expected == 200: - header = "attachment; filename={}{}.zip".format( - proj_name, "-" + version if version is not None else "" - ) - assert header in resp.headers[1][1] - else: - resp = client.get( - "/v1/project/download/{}/{}{}".format( - test_workspace_name, - proj_name, - "?version={}".format(version) if version else "", - ) - ) - if expected == 200: - assert "multipart/form-data" in resp.headers[0][1] - - assert resp.status_code == expected - - -# -# -# def test_large_project_download_fail(client, diff_project): -# resp = client.get( -# f"/v1/project/download/{test_workspace_name}/{diff_project.name}?v1format=zip" -# ) -# assert resp.status_code == 200 -# # pretend testing project to be too large by lowering limit -# client.application.config["MAX_DOWNLOAD_ARCHIVE_SIZE"] = 10 -# resp = client.get( -# f"/v1/project/download/{test_workspace_name}/{diff_project.name}?v1format=zip" -# ) -# assert resp.status_code == 400 -# assert "The total size of requested files is too large" in resp.json["detail"] - - test_download_file_data = [ (test_project, "test.txt", "text/plain", 200), (test_project, "logo.pdf", "application/pdf", 200), From 75e52557bbb857aa6b81998a08f5efd3df4e3343 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 17 Apr 2025 10:22:17 +0200 Subject: [PATCH 13/43] Rm unused deps --- LICENSES/EE-used-libs.json | 10 +- server/Pipfile | 2 - server/Pipfile.lock | 620 ++++++++++---------- server/mergin/sync/public_api_controller.py | 4 +- server/mergin/sync/storages/storage.py | 40 -- server/setup.py | 1 - 6 files changed, 327 insertions(+), 350 deletions(-) diff --git a/LICENSES/EE-used-libs.json b/LICENSES/EE-used-libs.json index 9ce9e385..b1b02c87 100644 --- a/LICENSES/EE-used-libs.json +++ b/LICENSES/EE-used-libs.json @@ -255,10 +255,6 @@ "library": "requests", "version": "2.31.0" }, - { - "library": "requests-toolbelt", - "version": "0.9.1" - }, { "library": "result", "version": "0.5.0" @@ -335,10 +331,6 @@ "library": "WTForms-JSON", "version": "0.3.5" }, - { - "library": "zipfly", - "version": "6.0.3" - }, { "library": "zipp", "version": "3.17.0" @@ -623,4 +615,4 @@ "library": "xml-utils", "version": "1.7.0" } -] \ No newline at end of file +] diff --git a/server/Pipfile b/server/Pipfile index de19b958..c6033818 100644 --- a/server/Pipfile +++ b/server/Pipfile @@ -11,10 +11,8 @@ flask-marshmallow = "==0.14.0" marshmallow-sqlalchemy = "==1.1.0" psycopg2-binary = "==2.9.9" itsdangerous = "==2.2.0" -requests-toolbelt = "==1.0.0" Flask-SQLAlchemy = "==2.5.1" sqlalchemy = "==1.4.53" -zipfly = "==6.0.3" gunicorn = {extras = ["gevent"],version = "==19.9"} python-dotenv = "==0.20.0" flask-login = "==0.6.2" diff --git a/server/Pipfile.lock b/server/Pipfile.lock index c67fc14b..de1686d3 100644 --- a/server/Pipfile.lock +++ b/server/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "80c85ba248721f99cfc035138c19117256307ec2a7e939a8f4bce2b6b7a63b0f" + "sha256": "92d9947af3f62750b41615f35ea94f219e587df704420325f3b13a3e09d0086c" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "alembic": { "hashes": [ - "sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe", - "sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49" + "sha256:1c72391bbdeffccfe317eefba686cb9a3c078005478885413b95c3b26c57a8a7", + "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53" ], "markers": "python_version >= '3.9'", - "version": "==1.15.1" + "version": "==1.15.2" }, "amqp": { "hashes": [ @@ -48,6 +48,59 @@ "markers": "python_version >= '3.8'", "version": "==25.3.0" }, + "backports-datetime-fromisoformat": { + "hashes": [ + "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491", + "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234", + "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", + "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8", + "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", + "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4", + "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", + "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce", + "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", + "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", + "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", + "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", + "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", + "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575", + "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c", + "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5", + "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", + "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2", + "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", + "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", + "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d", + "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", + "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b", + "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", + "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", + "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", + "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", + "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824", + "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", + "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", + "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", + "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876", + "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", + "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f", + "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", + "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd", + "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", + "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", + "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", + "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10", + "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", + "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62", + "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", + "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef", + "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42", + "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d", + "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.3" + }, "bcrypt": { "hashes": [ "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb", @@ -416,82 +469,64 @@ }, "greenlet": { "hashes": [ - "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", - "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7", - "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", - "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", - "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", - "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563", - "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83", - "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", - "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", - "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa", - "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", - "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", - "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", - "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22", - "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9", - "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0", - "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba", - "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3", - "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", - "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", - "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291", - "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", - "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", - "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", - "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", - "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef", - "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c", - "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", - "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c", - "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", - "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", - "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8", - "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d", - "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", - "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145", - "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80", - "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", - "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e", - "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", - "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1", - "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef", - "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc", - "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", - "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120", - "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437", - "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd", - "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981", - "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", - "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", - "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798", - "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7", - "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", - "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", - "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", - "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af", - "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", - "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", - "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42", - "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e", - "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81", - "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", - "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617", - "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc", - "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de", - "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111", - "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", - "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", - "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6", - "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", - "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", - "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803", - "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", - "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f" + "sha256:0010e928e1901d36625f21d008618273f9dda26b516dbdecf873937d39c9dff0", + "sha256:04e781447a4722e30b4861af728cb878d73a3df79509dc19ea498090cea5d204", + "sha256:0e14541f9024a280adb9645143d6a0a51fda6f7c5695fd96cb4d542bb563442f", + "sha256:144283ad88ed77f3ebd74710dd419b55dd15d18704b0ae05935766a93f5671c5", + "sha256:17fd241c0d50bacb7ce8ff77a30f94a2d0ca69434ba2e0187cf95a5414aeb7e1", + "sha256:18adc14ab154ca6e53eecc9dc50ff17aeb7ba70b7e14779b26e16d71efa90038", + "sha256:199453d64b02d0c9d139e36d29681efd0e407ed8e2c0bf89d88878d6a787c28f", + "sha256:1cf89e2d92bae0d7e2d6093ce0bed26feeaf59a5d588e3984e35fcd46fc41090", + "sha256:1d2d43bd711a43db8d9b9187500e6432ddb4fafe112d082ffabca8660a9e01a7", + "sha256:1dcb1108449b55ff6bc0edac9616468f71db261a4571f27c47ccf3530a7f8b97", + "sha256:211a9721f540e454a02e62db7956263e9a28a6cf776d4b9a7213844e36426333", + "sha256:23f56a0103deb5570c8d6a0bb4ddf8a7a28931973ad7ed7a883460a67e599b32", + "sha256:2688b3bd3198cc4bad7a79648a95fee088c24a0f6abd05d3639e6c3040ded015", + "sha256:2919b126eeb63ca5fa971501cd20cd6cdb5522369a8e39548bbc73a3e10b8b41", + "sha256:29449a2b82ed7ce11f8668c31ef20d31e9d88cd8329eb933098fab5a8608a93a", + "sha256:2b986f1a6467710e7ffeeeac1777da0318c95bbfcc467acbd0bd35abc775f558", + "sha256:33ea7e7269d6f7275ce31f593d6dcfedd97539c01f63fbdc8d84e493e20b1b2c", + "sha256:397b6bbda06f8fe895893d96218cd6f6d855a6701dc45012ebe12262423cec8b", + "sha256:39801e633a978c3f829f21022501e7b0c3872683d7495c1850558d1a6fb95ed0", + "sha256:4174fa6fa214e8924cedf332b6f2395ba2b9879f250dacd3c361b2fca86f58af", + "sha256:430cba962c85e339767235a93450a6aaffed6f9c567e73874ea2075f5aae51e1", + "sha256:47aeadd1e8fbdef8fdceb8fb4edc0cbb398a57568d56fd68f2bc00d0d809e6b6", + "sha256:58ef3d637c54e2f079064ca936556c4af3989144e4154d80cfd4e2a59fc3769c", + "sha256:598da3bd464c2cc411b723e3d4afc27b13c219ac077ba897bac88443ae45f5ec", + "sha256:5be69cd50994b8465c3ad1467f9e63001f76e53a89440ad4440d1b6d52591280", + "sha256:5e57ff52315bfc0c5493917f328b8ba3ae0c0515d94524453c4d24e7638cbb53", + "sha256:6005f7a86de836a1dc4b8d824a2339cdd5a1ca7cb1af55ea92575401f9952f4c", + "sha256:6017a4d430fad5229e397ad464db504ae70cb7b903757c4688cee6c25d6ce8d8", + "sha256:60e77242e38e99ecaede853755bbd8165e0b20a2f1f3abcaa6f0dceb826a7411", + "sha256:6fad8a9ca98b37951a053d7d2d2553569b151cd8c4ede744806b94d50d7f8f73", + "sha256:7154b13ef87a8b62fc05419f12d75532d7783586ad016c57b5de8a1c6feeb517", + "sha256:78b721dfadc60e3639141c0e1f19d23953c5b4b98bfcaf04ce40f79e4f01751c", + "sha256:7b162de2fb61b4c7f4b5d749408bf3280cae65db9b5a6aaf7f922ac829faa67c", + "sha256:7b17a26abc6a1890bf77d5d6b71c0999705386b00060d15c10b8182679ff2790", + "sha256:7d08b88ee8d506ca1f5b2a58744e934d33c6a1686dd83b81e7999dfc704a912f", + "sha256:7f163d04f777e7bd229a50b937ecc1ae2a5b25296e6001445e5433e4f51f5191", + "sha256:7fee6f518868e8206c617f4084a83ad4d7a3750b541bf04e692dfa02e52e805d", + "sha256:82a68a25a08f51fc8b66b113d1d9863ee123cdb0e8f1439aed9fc795cd6f85cf", + "sha256:844acfd479ee380f3810415e682c9ee941725fb90b45e139bb7fd6f85c6c9a30", + "sha256:8a8940a8d301828acd8b9f3f85db23069a692ff2933358861b19936e29946b95", + "sha256:8b3538711e7c0efd5f7a8fc1096c4db9598d6ed99dc87286b31e4ce9f8a8da67", + "sha256:8fd2583024ff6cd5d4f842d446d001de4c4fe1264fdb5f28ddea28f6488866df", + "sha256:a0bc5776ac2831c022e029839bf1b9d3052332dcf5f431bb88c8503e27398e31", + "sha256:b2392cc41eeed4055978c6b52549ccd9effd263bb780ffd639c0e1e7e2055ab0", + "sha256:b7a7b7f2bad3ca72eb2fa14643f1c4ca11d115614047299d89bc24a3b11ddd09", + "sha256:b86a3ccc865ae601f446af042707b749eebc297928ea7bd0c5f60c56525850be", + "sha256:b99de16560097b9984409ded0032f101f9555e1ab029440fc6a8b5e76dbba7ac", + "sha256:cd37273dc7ca1d5da149b58c8b3ce0711181672ba1b09969663905a765affe21", + "sha256:ce531d7c424ef327a391de7a9777a6c93a38e1f89e18efa903a1c4ba11f85905", + "sha256:d3f32d7c70b1c26844fd0e4e56a1da852b493e4e1c30df7b07274a1e5a9b599e", + "sha256:d97bc1be4bad83b70d8b8627ada6724091af41139616696e59b7088f358583b9", + "sha256:e61d426969b68b2170a9f853cc36d5318030494576e9ec0bfe2dc2e2afa15a68", + "sha256:e8622b33d8694ec373ad55050c3d4e49818132b44852158442e1931bb02af336", + "sha256:e8ac9a2c20fbff3d0b853e9ef705cdedb70d9276af977d1ec1cde86a87a4c821", + "sha256:ee59db626760f1ca8da697a086454210d36a19f7abecc9922a2374c04b47735b" ], "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", - "version": "==3.1.1" + "version": "==3.2.0" }, "gunicorn": { "extras": [ @@ -573,19 +608,19 @@ }, "kombu": { "hashes": [ - "sha256:526c6cf038c986b998639109a1eb762502f831e8da148cc928f1f95cd91eb874", - "sha256:72e65c062e903ee1b4e8b68d348f63c02afc172eda409e3aca85867752e79c0b" + "sha256:021a0e11fcfcd9b0260ef1fb64088c0e92beb976eb59c1dfca7ddd4ad4562ea2", + "sha256:5b0dbceb4edee50aa464f59469d34b97864be09111338cfb224a10b6a163909b" ], "markers": "python_version >= '3.8'", - "version": "==5.5.0" + "version": "==5.5.3" }, "mako": { "hashes": [ - "sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1", - "sha256:b5d65ff3462870feec922dbccf38f6efb44e5714d7b593a656be86663d8600ac" + "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", + "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59" ], "markers": "python_version >= '3.8'", - "version": "==1.3.9" + "version": "==1.3.10" }, "markupsafe": { "hashes": [ @@ -656,11 +691,11 @@ }, "marshmallow": { "hashes": [ - "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", - "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6" + "sha256:3b6e80aac299a7935cfb97ed01d1854fb90b5079430969af92118ea1b12a8d55", + "sha256:e7b0528337e9990fd64950f8a6b3a1baabed09ad17a0dfb844d701151f92d203" ], "markers": "python_version >= '3.9'", - "version": "==3.26.1" + "version": "==4.0.0" }, "marshmallow-sqlalchemy": { "hashes": [ @@ -751,11 +786,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", - "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198" + "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", + "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed" ], - "markers": "python_full_version >= '3.8.0'", - "version": "==3.0.50" + "markers": "python_version >= '3.8'", + "version": "==3.0.51" }, "psycogreen": { "hashes": [ @@ -1018,15 +1053,6 @@ "markers": "python_version >= '3.8'", "version": "==2.32.3" }, - "requests-toolbelt": { - "hashes": [ - "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", - "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, "result": { "hashes": [ "sha256:46f039a2d17e47709c13e29af359c3fa91fd5cacddba2a8109fdcb514e6ff471", @@ -1037,112 +1063,123 @@ }, "rpds-py": { "hashes": [ - "sha256:09cd7dbcb673eb60518231e02874df66ec1296c01a4fcd733875755c02014b19", - "sha256:0f3288930b947cbebe767f84cf618d2cbe0b13be476e749da0e6a009f986248c", - "sha256:0fced9fd4a07a1ded1bac7e961ddd9753dd5d8b755ba8e05acba54a21f5f1522", - "sha256:112b8774b0b4ee22368fec42749b94366bd9b536f8f74c3d4175d4395f5cbd31", - "sha256:11dd60b2ffddba85715d8a66bb39b95ddbe389ad2cfcf42c833f1bcde0878eaf", - "sha256:178f8a60fc24511c0eb756af741c476b87b610dba83270fce1e5a430204566a4", - "sha256:1b08027489ba8fedde72ddd233a5ea411b85a6ed78175f40285bd401bde7466d", - "sha256:1bf5be5ba34e19be579ae873da515a2836a2166d8d7ee43be6ff909eda42b72b", - "sha256:1ed7de3c86721b4e83ac440751329ec6a1102229aa18163f84c75b06b525ad7e", - "sha256:1eedaaccc9bb66581d4ae7c50e15856e335e57ef2734dbc5fd8ba3e2a4ab3cb6", - "sha256:243241c95174b5fb7204c04595852fe3943cc41f47aa14c3828bc18cd9d3b2d6", - "sha256:26bb3e8de93443d55e2e748e9fd87deb5f8075ca7bc0502cfc8be8687d69a2ec", - "sha256:271fa2184cf28bdded86bb6217c8e08d3a169fe0bbe9be5e8d96e8476b707122", - "sha256:28358c54fffadf0ae893f6c1050e8f8853e45df22483b7fff2f6ab6152f5d8bf", - "sha256:285019078537949cecd0190f3690a0b0125ff743d6a53dfeb7a4e6787af154f5", - "sha256:2893d778d4671ee627bac4037a075168b2673c57186fb1a57e993465dbd79a93", - "sha256:2a54027554ce9b129fc3d633c92fa33b30de9f08bc61b32c053dc9b537266fed", - "sha256:2c6ae11e6e93728d86aafc51ced98b1658a0080a7dd9417d24bfb955bb09c3c2", - "sha256:2cfa07c346a7ad07019c33fb9a63cf3acb1f5363c33bc73014e20d9fe8b01cdd", - "sha256:35d5631ce0af26318dba0ae0ac941c534453e42f569011585cb323b7774502a5", - "sha256:3614d280bf7aab0d3721b5ce0e73434acb90a2c993121b6e81a1c15c665298ac", - "sha256:3902df19540e9af4cc0c3ae75974c65d2c156b9257e91f5101a51f99136d834c", - "sha256:3aaf141d39f45322e44fc2c742e4b8b4098ead5317e5f884770c8df0c332da70", - "sha256:3d8abf7896a91fb97e7977d1aadfcc2c80415d6dc2f1d0fca5b8d0df247248f3", - "sha256:3e77febf227a1dc3220159355dba68faa13f8dca9335d97504abf428469fb18b", - "sha256:3e9212f52074fc9d72cf242a84063787ab8e21e0950d4d6709886fb62bcb91d5", - "sha256:3ee9d6f0b38efb22ad94c3b68ffebe4c47865cdf4b17f6806d6c674e1feb4246", - "sha256:4233df01a250b3984465faed12ad472f035b7cd5240ea3f7c76b7a7016084495", - "sha256:4263320ed887ed843f85beba67f8b2d1483b5947f2dc73a8b068924558bfeace", - "sha256:4ab923167cfd945abb9b51a407407cf19f5bee35001221f2911dc85ffd35ff4f", - "sha256:4caafd1a22e5eaa3732acb7672a497123354bef79a9d7ceed43387d25025e935", - "sha256:50fb62f8d8364978478b12d5f03bf028c6bc2af04082479299139dc26edf4c64", - "sha256:55ff4151cfd4bc635e51cfb1c59ac9f7196b256b12e3a57deb9e5742e65941ad", - "sha256:5b98b6c953e5c2bda51ab4d5b4f172617d462eebc7f4bfdc7c7e6b423f6da957", - "sha256:5c9ff044eb07c8468594d12602291c635da292308c8c619244e30698e7fc455a", - "sha256:5e9c206a1abc27e0588cf8b7c8246e51f1a16a103734f7750830a1ccb63f557a", - "sha256:5fb89edee2fa237584e532fbf78f0ddd1e49a47c7c8cfa153ab4849dc72a35e6", - "sha256:633462ef7e61d839171bf206551d5ab42b30b71cac8f10a64a662536e057fdef", - "sha256:66f8d2a17e5838dd6fb9be6baaba8e75ae2f5fa6b6b755d597184bfcd3cb0eba", - "sha256:6959bb9928c5c999aba4a3f5a6799d571ddc2c59ff49917ecf55be2bbb4e3722", - "sha256:698a79d295626ee292d1730bc2ef6e70a3ab135b1d79ada8fde3ed0047b65a10", - "sha256:721f9c4011b443b6e84505fc00cc7aadc9d1743f1c988e4c89353e19c4a968ee", - "sha256:72e680c1518733b73c994361e4b06441b92e973ef7d9449feec72e8ee4f713da", - "sha256:75307599f0d25bf6937248e5ac4e3bde5ea72ae6618623b86146ccc7845ed00b", - "sha256:754fba3084b70162a6b91efceee8a3f06b19e43dac3f71841662053c0584209a", - "sha256:759462b2d0aa5a04be5b3e37fb8183615f47014ae6b116e17036b131985cb731", - "sha256:7938c7b0599a05246d704b3f5e01be91a93b411d0d6cc62275f025293b8a11ce", - "sha256:7b77e07233925bd33fc0022b8537774423e4c6680b6436316c5075e79b6384f4", - "sha256:7e5413d2e2d86025e73f05510ad23dad5950ab8417b7fc6beaad99be8077138b", - "sha256:7f3240dcfa14d198dba24b8b9cb3b108c06b68d45b7babd9eefc1038fdf7e707", - "sha256:7f9682a8f71acdf59fd554b82b1c12f517118ee72c0f3944eda461606dfe7eb9", - "sha256:8d67beb6002441faef8251c45e24994de32c4c8686f7356a1f601ad7c466f7c3", - "sha256:9441af1d25aed96901f97ad83d5c3e35e6cd21a25ca5e4916c82d7dd0490a4fa", - "sha256:98b257ae1e83f81fb947a363a274c4eb66640212516becaff7bef09a5dceacaa", - "sha256:9e9f3a3ac919406bc0414bbbd76c6af99253c507150191ea79fab42fdb35982a", - "sha256:a1c66e71ecfd2a4acf0e4bd75e7a3605afa8f9b28a3b497e4ba962719df2be57", - "sha256:a1e17d8dc8e57d8e0fd21f8f0f0a5211b3fa258b2e444c2053471ef93fe25a00", - "sha256:a20cb698c4a59c534c6701b1c24a968ff2768b18ea2991f886bd8985ce17a89f", - "sha256:a970bfaf130c29a679b1d0a6e0f867483cea455ab1535fb427566a475078f27f", - "sha256:a98f510d86f689fcb486dc59e6e363af04151e5260ad1bdddb5625c10f1e95f8", - "sha256:a9d3b728f5a5873d84cba997b9d617c6090ca5721caaa691f3b1a78c60adc057", - "sha256:ad76f44f70aac3a54ceb1813ca630c53415da3a24fd93c570b2dfb4856591017", - "sha256:ae28144c1daa61366205d32abd8c90372790ff79fc60c1a8ad7fd3c8553a600e", - "sha256:b03a8d50b137ee758e4c73638b10747b7c39988eb8e6cd11abb7084266455165", - "sha256:b5a96fcac2f18e5a0a23a75cd27ce2656c66c11c127b0318e508aab436b77428", - "sha256:b5ef909a37e9738d146519657a1aab4584018746a18f71c692f2f22168ece40c", - "sha256:b79f5ced71efd70414a9a80bbbfaa7160da307723166f09b69773153bf17c590", - "sha256:b91cceb5add79ee563bd1f70b30896bd63bc5f78a11c1f00a1e931729ca4f1f4", - "sha256:b92f5654157de1379c509b15acec9d12ecf6e3bc1996571b6cb82a4302060447", - "sha256:c04ca91dda8a61584165825907f5c967ca09e9c65fe8966ee753a3f2b019fe1e", - "sha256:c1f8afa346ccd59e4e5630d5abb67aba6a9812fddf764fd7eb11f382a345f8cc", - "sha256:c5334a71f7dc1160382d45997e29f2637c02f8a26af41073189d79b95d3321f1", - "sha256:c617d7453a80e29d9973b926983b1e700a9377dbe021faa36041c78537d7b08c", - "sha256:c632419c3870507ca20a37c8f8f5352317aca097639e524ad129f58c125c61c6", - "sha256:c6760211eee3a76316cf328f5a8bd695b47b1626d21c8a27fb3b2473a884d597", - "sha256:c698d123ce5d8f2d0cd17f73336615f6a2e3bdcedac07a1291bb4d8e7d82a05a", - "sha256:c76b32eb2ab650a29e423525e84eb197c45504b1c1e6e17b6cc91fcfeb1a4b1d", - "sha256:c8f7e90b948dc9dcfff8003f1ea3af08b29c062f681c05fd798e36daa3f7e3e8", - "sha256:c9e799dac1ffbe7b10c1fd42fe4cd51371a549c6e108249bde9cd1200e8f59b4", - "sha256:cafa48f2133d4daa028473ede7d81cd1b9f9e6925e9e4003ebdf77010ee02f35", - "sha256:ce473a2351c018b06dd8d30d5da8ab5a0831056cc53b2006e2a8028172c37ce5", - "sha256:d31ed4987d72aabdf521eddfb6a72988703c091cfc0064330b9e5f8d6a042ff5", - "sha256:d550d7e9e7d8676b183b37d65b5cd8de13676a738973d330b59dc8312df9c5dc", - "sha256:d6adb81564af0cd428910f83fa7da46ce9ad47c56c0b22b50872bc4515d91966", - "sha256:d6f6512a90bd5cd9030a6237f5346f046c6f0e40af98657568fa45695d4de59d", - "sha256:d7031d493c4465dbc8d40bd6cafefef4bd472b17db0ab94c53e7909ee781b9ef", - "sha256:d9f75a06ecc68f159d5d7603b734e1ff6daa9497a929150f794013aa9f6e3f12", - "sha256:db7707dde9143a67b8812c7e66aeb2d843fe33cc8e374170f4d2c50bd8f2472d", - "sha256:e0397dd0b3955c61ef9b22838144aa4bef6f0796ba5cc8edfc64d468b93798b4", - "sha256:e0df046f2266e8586cf09d00588302a32923eb6386ced0ca5c9deade6af9a149", - "sha256:e14f86b871ea74c3fddc9a40e947d6a5d09def5adc2076ee61fb910a9014fb35", - "sha256:e5963ea87f88bddf7edd59644a35a0feecf75f8985430124c253612d4f7d27ae", - "sha256:e768267cbe051dd8d1c5305ba690bb153204a09bf2e3de3ae530de955f5b5580", - "sha256:e9cb79ecedfc156c0692257ac7ed415243b6c35dd969baa461a6888fc79f2f07", - "sha256:ed6f011bedca8585787e5082cce081bac3d30f54520097b2411351b3574e1219", - "sha256:f3429fb8e15b20961efca8c8b21432623d85db2228cc73fe22756c6637aa39e7", - "sha256:f35eff113ad430b5272bbfc18ba111c66ff525828f24898b4e146eb479a2cdda", - "sha256:f3a6cb95074777f1ecda2ca4fa7717caa9ee6e534f42b7575a8f0d4cb0c24013", - "sha256:f7356a6da0562190558c4fcc14f0281db191cdf4cb96e7604c06acfcee96df15", - "sha256:f88626e3f5e57432e6191cd0c5d6d6b319b635e70b40be2ffba713053e5147dd", - "sha256:fad784a31869747df4ac968a351e070c06ca377549e4ace94775aaa3ab33ee06", - "sha256:fc869af5cba24d45fb0399b0cfdbcefcf6910bf4dee5d74036a57cf5264b3ff4", - "sha256:fee513135b5a58f3bb6d89e48326cd5aa308e4bcdf2f7d59f67c861ada482bf8" + "sha256:0047638c3aa0dbcd0ab99ed1e549bbf0e142c9ecc173b6492868432d8989a046", + "sha256:006f4342fe729a368c6df36578d7a348c7c716be1da0a1a0f86e3021f8e98724", + "sha256:041f00419e1da7a03c46042453598479f45be3d787eb837af382bfc169c0db33", + "sha256:04ecf5c1ff4d589987b4d9882872f80ba13da7d42427234fce8f22efb43133bc", + "sha256:04f2b712a2206e13800a8136b07aaedc23af3facab84918e7aa89e4be0260032", + "sha256:0aeb3329c1721c43c58cae274d7d2ca85c1690d89485d9c63a006cb79a85771a", + "sha256:0e374c0ce0ca82e5b67cd61fb964077d40ec177dd2c4eda67dba130de09085c7", + "sha256:0f00c16e089282ad68a3820fd0c831c35d3194b7cdc31d6e469511d9bffc535c", + "sha256:174e46569968ddbbeb8a806d9922f17cd2b524aa753b468f35b97ff9c19cb718", + "sha256:1b221c2457d92a1fb3c97bee9095c874144d196f47c038462ae6e4a14436f7bc", + "sha256:208b3a70a98cf3710e97cabdc308a51cd4f28aa6e7bb11de3d56cd8b74bab98d", + "sha256:20f2712bd1cc26a3cc16c5a1bfee9ed1abc33d4cdf1aabd297fe0eb724df4272", + "sha256:24795c099453e3721fda5d8ddd45f5dfcc8e5a547ce7b8e9da06fecc3832e26f", + "sha256:2a0f156e9509cee987283abd2296ec816225145a13ed0391df8f71bf1d789e2d", + "sha256:2b2356688e5d958c4d5cb964af865bea84db29971d3e563fb78e46e20fe1848b", + "sha256:2c13777ecdbbba2077670285dd1fe50828c8742f6a4119dbef6f83ea13ad10fb", + "sha256:2d3ee4615df36ab8eb16c2507b11e764dcc11fd350bbf4da16d09cda11fcedef", + "sha256:2d53747da70a4e4b17f559569d5f9506420966083a31c5fbd84e764461c4444b", + "sha256:32bab0a56eac685828e00cc2f5d1200c548f8bc11f2e44abf311d6b548ce2e45", + "sha256:34d90ad8c045df9a4259c47d2e16a3f21fdb396665c94520dbfe8766e62187a4", + "sha256:369d9c6d4c714e36d4a03957b4783217a3ccd1e222cdd67d464a3a479fc17796", + "sha256:3a55fc10fdcbf1a4bd3c018eea422c52cf08700cf99c28b5cb10fe97ab77a0d3", + "sha256:3d2d8e4508e15fc05b31285c4b00ddf2e0eb94259c2dc896771966a163122a0c", + "sha256:3fab5f4a2c64a8fb64fc13b3d139848817a64d467dd6ed60dcdd6b479e7febc9", + "sha256:43dba99f00f1d37b2a0265a259592d05fcc8e7c19d140fe51c6e6f16faabeb1f", + "sha256:44d51febb7a114293ffd56c6cf4736cb31cd68c0fddd6aa303ed09ea5a48e029", + "sha256:493fe54318bed7d124ce272fc36adbf59d46729659b2c792e87c3b95649cdee9", + "sha256:4b28e5122829181de1898c2c97f81c0b3246d49f585f22743a1246420bb8d399", + "sha256:4cd031e63bc5f05bdcda120646a0d32f6d729486d0067f09d79c8db5368f4586", + "sha256:528927e63a70b4d5f3f5ccc1fa988a35456eb5d15f804d276709c33fc2f19bda", + "sha256:564c96b6076a98215af52f55efa90d8419cc2ef45d99e314fddefe816bc24f91", + "sha256:5db385bacd0c43f24be92b60c857cf760b7f10d8234f4bd4be67b5b20a7c0b6b", + "sha256:5ef877fa3bbfb40b388a5ae1cb00636a624690dcb9a29a65267054c9ea86d88a", + "sha256:5f6e3cec44ba05ee5cbdebe92d052f69b63ae792e7d05f1020ac5e964394080c", + "sha256:5fc13b44de6419d1e7a7e592a4885b323fbc2f46e1f22151e3a8ed3b8b920405", + "sha256:60748789e028d2a46fc1c70750454f83c6bdd0d05db50f5ae83e2db500b34da5", + "sha256:60d9b630c8025b9458a9d114e3af579a2c54bd32df601c4581bd054e85258143", + "sha256:619ca56a5468f933d940e1bf431c6f4e13bef8e688698b067ae68eb4f9b30e3a", + "sha256:630d3d8ea77eabd6cbcd2ea712e1c5cecb5b558d39547ac988351195db433f6c", + "sha256:63981feca3f110ed132fd217bf7768ee8ed738a55549883628ee3da75bb9cb78", + "sha256:66420986c9afff67ef0c5d1e4cdc2d0e5262f53ad11e4f90e5e22448df485bf0", + "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350", + "sha256:6a4a535013aeeef13c5532f802708cecae8d66c282babb5cd916379b72110cf7", + "sha256:6a727fd083009bc83eb83d6950f0c32b3c94c8b80a9b667c87f4bd1274ca30ba", + "sha256:6e1daf5bf6c2be39654beae83ee6b9a12347cb5aced9a29eecf12a2d25fff664", + "sha256:6eea559077d29486c68218178ea946263b87f1c41ae7f996b1f30a983c476a5a", + "sha256:75a810b7664c17f24bf2ffd7f92416c00ec84b49bb68e6a0d93e542406336b56", + "sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e", + "sha256:78884d155fd15d9f64f5d6124b486f3d3f7fd7cd71a78e9670a0f6f6ca06fb2d", + "sha256:79e8d804c2ccd618417e96720ad5cd076a86fa3f8cb310ea386a3e6229bae7d1", + "sha256:7e80d375134ddb04231a53800503752093dbb65dad8dabacce2c84cccc78e964", + "sha256:8097b3422d020ff1c44effc40ae58e67d93e60d540a65649d2cdaf9466030791", + "sha256:8205ee14463248d3349131bb8099efe15cd3ce83b8ef3ace63c7e976998e7124", + "sha256:8212ff58ac6dfde49946bea57474a386cca3f7706fc72c25b772b9ca4af6b79e", + "sha256:823e74ab6fbaa028ec89615ff6acb409e90ff45580c45920d4dfdddb069f2120", + "sha256:84e0566f15cf4d769dade9b366b7b87c959be472c92dffb70462dd0844d7cbad", + "sha256:896c41007931217a343eff197c34513c154267636c8056fb409eafd494c3dcdc", + "sha256:8aa362811ccdc1f8dadcc916c6d47e554169ab79559319ae9fae7d7752d0d60c", + "sha256:8b3b397eefecec8e8e39fa65c630ef70a24b09141a6f9fc17b3c3a50bed6b50e", + "sha256:8ebc7e65ca4b111d928b669713865f021b7773350eeac4a31d3e70144297baba", + "sha256:9168764133fd919f8dcca2ead66de0105f4ef5659cbb4fa044f7014bed9a1797", + "sha256:921ae54f9ecba3b6325df425cf72c074cd469dea843fb5743a26ca7fb2ccb149", + "sha256:92558d37d872e808944c3c96d0423b8604879a3d1c86fdad508d7ed91ea547d5", + "sha256:951cc481c0c395c4a08639a469d53b7d4afa252529a085418b82a6b43c45c240", + "sha256:998c01b8e71cf051c28f5d6f1187abbdf5cf45fc0efce5da6c06447cba997034", + "sha256:9abc80fe8c1f87218db116016de575a7998ab1629078c90840e8d11ab423ee25", + "sha256:9be4f99bee42ac107870c61dfdb294d912bf81c3c6d45538aad7aecab468b6b7", + "sha256:9c39438c55983d48f4bb3487734d040e22dad200dab22c41e331cee145e7a50d", + "sha256:9d7e8ce990ae17dda686f7e82fd41a055c668e13ddcf058e7fb5e9da20b57793", + "sha256:9ea7f4174d2e4194289cb0c4e172d83e79a6404297ff95f2875cf9ac9bced8ba", + "sha256:a18fc371e900a21d7392517c6f60fe859e802547309e94313cd8181ad9db004d", + "sha256:a36b452abbf29f68527cf52e181fced56685731c86b52e852053e38d8b60bc8d", + "sha256:a5b66d1b201cc71bc3081bc2f1fc36b0c1f268b773e03bbc39066651b9e18391", + "sha256:a824d2c7a703ba6daaca848f9c3d5cb93af0505be505de70e7e66829affd676e", + "sha256:a88c0d17d039333a41d9bf4616bd062f0bd7aa0edeb6cafe00a2fc2a804e944f", + "sha256:aa6800adc8204ce898c8a424303969b7aa6a5e4ad2789c13f8648739830323b7", + "sha256:aad911555286884be1e427ef0dc0ba3929e6821cbeca2194b13dc415a462c7fd", + "sha256:afc6e35f344490faa8276b5f2f7cbf71f88bc2cda4328e00553bd451728c571f", + "sha256:b9a4df06c35465ef4d81799999bba810c68d29972bf1c31db61bfdb81dd9d5bb", + "sha256:bb2954155bb8f63bb19d56d80e5e5320b61d71084617ed89efedb861a684baea", + "sha256:bbc4362e06f950c62cad3d4abf1191021b2ffaf0b31ac230fbf0526453eee75e", + "sha256:c0145295ca415668420ad142ee42189f78d27af806fcf1f32a18e51d47dd2052", + "sha256:c30ff468163a48535ee7e9bf21bd14c7a81147c0e58a36c1078289a8ca7af0bd", + "sha256:c347a20d79cedc0a7bd51c4d4b7dbc613ca4e65a756b5c3e57ec84bd43505b47", + "sha256:c43583ea8517ed2e780a345dd9960896afc1327e8cf3ac8239c167530397440d", + "sha256:c61a2cb0085c8783906b2f8b1f16a7e65777823c7f4d0a6aaffe26dc0d358dd9", + "sha256:c9ca89938dff18828a328af41ffdf3902405a19f4131c88e22e776a8e228c5a8", + "sha256:cc31e13ce212e14a539d430428cd365e74f8b2d534f8bc22dd4c9c55b277b875", + "sha256:cdabcd3beb2a6dca7027007473d8ef1c3b053347c76f685f5f060a00327b8b65", + "sha256:cf86f72d705fc2ef776bb7dd9e5fbba79d7e1f3e258bf9377f8204ad0fc1c51e", + "sha256:d09dc82af2d3c17e7dd17120b202a79b578d79f2b5424bda209d9966efeed114", + "sha256:d3aa13bdf38630da298f2e0d77aca967b200b8cc1473ea05248f6c5e9c9bdb44", + "sha256:d69d003296df4840bd445a5d15fa5b6ff6ac40496f956a221c4d1f6f7b4bc4d9", + "sha256:d6e109a454412ab82979c5b1b3aee0604eca4bbf9a02693bb9df027af2bfa91a", + "sha256:d8551e733626afec514b5d15befabea0dd70a343a9f23322860c4f16a9430205", + "sha256:d8754d872a5dfc3c5bf9c0e059e8107451364a30d9fd50f1f1a85c4fb9481164", + "sha256:d8f9a6e7fd5434817526815f09ea27f2746c4a51ee11bb3439065f5fc754db58", + "sha256:dbcbb6db5582ea33ce46a5d20a5793134b5365110d84df4e30b9d37c6fd40ad3", + "sha256:e0f3ef95795efcd3b2ec3fe0a5bcfb5dadf5e3996ea2117427e524d4fbf309c6", + "sha256:e13ae74a8a3a0c2f22f450f773e35f893484fcfacb00bb4344a7e0f4f48e1f97", + "sha256:e274f62cbd274359eff63e5c7e7274c913e8e09620f6a57aae66744b3df046d6", + "sha256:e838bf2bb0b91ee67bf2b889a1a841e5ecac06dd7a2b1ef4e6151e2ce155c7ae", + "sha256:e8acd55bd5b071156bae57b555f5d33697998752673b9de554dd82f5b5352727", + "sha256:e8e5ab32cf9eb3647450bc74eb201b27c185d3857276162c101c0f8c6374e098", + "sha256:ebcb786b9ff30b994d5969213a8430cbb984cdd7ea9fd6df06663194bd3c450c", + "sha256:ebea2821cdb5f9fef44933617be76185b80150632736f3d76e54829ab4a3b4d1", + "sha256:ed0ef550042a8dbcd657dfb284a8ee00f0ba269d3f2286b0493b15a5694f9fe8", + "sha256:eda5c1e2a715a4cbbca2d6d304988460942551e4e5e3b7457b50943cd741626d", + "sha256:f5c0ed12926dec1dfe7d645333ea59cf93f4d07750986a586f511c0bc61fe103", + "sha256:f6016bd950be4dcd047b7475fdf55fb1e1f59fc7403f387be0e8123e4a576d30", + "sha256:f9e0057a509e096e47c87f753136c9b10d7a91842d8042c2ee6866899a717c0d", + "sha256:fc1c892b1ec1f8cbd5da8de287577b455e388d9c328ad592eabbdcb6fc93bee5", + "sha256:fc2c1e1b00f88317d9de6b2c2b39b012ebbfe35fe5e7bef980fd2a91f6100a07", + "sha256:fd822f019ccccd75c832deb7aa040bb02d70a92eb15a2f16c7987b7ad4ee8d83" ], "markers": "python_version >= '3.9'", - "version": "==0.23.1" + "version": "==0.24.0" }, "safe": { "hashes": [ @@ -1163,11 +1200,11 @@ }, "setuptools": { "hashes": [ - "sha256:34750dcb17d046929f545dec9b8349fe42bf4ba13ddffee78428aec422dbfb73", - "sha256:4959b9ad482ada2ba2320c8f1a8d8481d4d8d668908a7a1b84d987375cd7f5bd" + "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54", + "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8" ], "markers": "python_version >= '3.9'", - "version": "==76.1.0" + "version": "==78.1.0" }, "shapely": { "hashes": [ @@ -1339,11 +1376,11 @@ }, "tzdata": { "hashes": [ - "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", - "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639" + "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", + "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9" ], "markers": "python_version >= '2'", - "version": "==2025.1" + "version": "==2025.2" }, "urllib3": { "hashes": [ @@ -1403,13 +1440,6 @@ "index": "pypi", "version": "==0.3.5" }, - "zipfly": { - "hashes": [ - "sha256:ac78c6feb76313548b8e0256df5bbb13611ab7a9983b64ef3d1d76570d11ad71" - ], - "index": "pypi", - "version": "==6.0.3" - }, "zipp": { "hashes": [ "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", @@ -1644,80 +1674,80 @@ "toml" ], "hashes": [ - "sha256:056d3017ed67e7ddf266e6f57378ece543755a4c9231e997789ab3bd11392c94", - "sha256:0ce8cf59e09d31a4915ff4c3b94c6514af4c84b22c4cc8ad7c3c546a86150a92", - "sha256:104bf640f408f4e115b85110047c7f27377e1a8b7ba86f7db4fa47aa49dc9a8e", - "sha256:1393e5aa9441dafb0162c36c8506c648b89aea9565b31f6bfa351e66c11bcd82", - "sha256:1586ad158523f4133499a4f322b230e2cfef9cc724820dbd58595a5a236186f4", - "sha256:180e3fc68ee4dc5af8b33b6ca4e3bb8aa1abe25eedcb958ba5cff7123071af68", - "sha256:1b336d06af14f8da5b1f391e8dec03634daf54dfcb4d1c4fb6d04c09d83cef90", - "sha256:1c8fbce80b2b8bf135d105aa8f5b36eae0c57d702a1cc3ebdea2a6f03f6cdde5", - "sha256:2d673e3add00048215c2cc507f1228a7523fd8bf34f279ac98334c9b07bd2656", - "sha256:316f29cc3392fa3912493ee4c83afa4a0e2db04ff69600711f8c03997c39baaa", - "sha256:33c1394d8407e2771547583b66a85d07ed441ff8fae5a4adb4237ad39ece60db", - "sha256:37cbc7b0d93dfd133e33c7ec01123fbb90401dce174c3b6661d8d36fb1e30608", - "sha256:39abcacd1ed54e2c33c54bdc488b310e8ef6705833f7148b6eb9a547199d375d", - "sha256:3ab7090f04b12dc6469882ce81244572779d3a4b67eea1c96fb9ecc8c607ef39", - "sha256:3b0e6e54591ae0d7427def8a4d40fca99df6b899d10354bab73cd5609807261c", - "sha256:416e2a8845eaff288f97eaf76ab40367deafb9073ffc47bf2a583f26b05e5265", - "sha256:4545485fef7a8a2d8f30e6f79ce719eb154aab7e44217eb444c1d38239af2072", - "sha256:4c124025430249118d018dcedc8b7426f39373527c845093132196f2a483b6dd", - "sha256:4fbb7a0c3c21908520149d7751cf5b74eb9b38b54d62997b1e9b3ac19a8ee2fe", - "sha256:52fc89602cde411a4196c8c6894afb384f2125f34c031774f82a4f2608c59d7d", - "sha256:55143aa13c49491f5606f05b49ed88663446dce3a4d3c5d77baa4e36a16d3573", - "sha256:57f3bd0d29bf2bd9325c0ff9cc532a175110c4bf8f412c05b2405fd35745266d", - "sha256:5b2f144444879363ea8834cd7b6869d79ac796cb8f864b0cfdde50296cd95816", - "sha256:5efdeff5f353ed3352c04e6b318ab05c6ce9249c25ed3c2090c6e9cadda1e3b2", - "sha256:60e6347d1ed882b1159ffea172cb8466ee46c665af4ca397edbf10ff53e9ffaf", - "sha256:693d921621a0c8043bfdc61f7d4df5ea6d22165fe8b807cac21eb80dd94e4bbd", - "sha256:708f0a1105ef2b11c79ed54ed31f17e6325ac936501fc373f24be3e6a578146a", - "sha256:70f0925c4e2bfc965369f417e7cc72538fd1ba91639cf1e4ef4b1a6b50439b3b", - "sha256:7789e700f33f2b133adae582c9f437523cd5db8de845774988a58c360fc88253", - "sha256:7b6c96d69928a3a6767fab8dc1ce8a02cf0156836ccb1e820c7f45a423570d98", - "sha256:7d2a65876274acf544703e943c010b60bd79404e3623a1e5d52b64a6e2728de5", - "sha256:7f18d47641282664276977c604b5a261e51fefc2980f5271d547d706b06a837f", - "sha256:89078312f06237417adda7c021c33f80f7a6d2db8572a5f6c330d89b080061ce", - "sha256:8c938c6ae59be67ac19a7204e079efc94b38222cd7d0269f96e45e18cddeaa59", - "sha256:8e336b56301774ace6be0017ff85c3566c556d938359b61b840796a0202f805c", - "sha256:a0a207c87a9f743c8072d059b4711f8d13c456eb42dac778a7d2e5d4f3c253a7", - "sha256:a2454b12a3f12cc4698f3508912e6225ec63682e2ca5a96f80a2b93cef9e63f3", - "sha256:a538a23119d1e2e2ce077e902d02ea3d8e0641786ef6e0faf11ce82324743944", - "sha256:aa4dff57fc21a575672176d5ab0ef15a927199e775c5e8a3d75162ab2b0c7705", - "sha256:ad0edaa97cb983d9f2ff48cadddc3e1fb09f24aa558abeb4dc9a0dbacd12cbb4", - "sha256:ae8006772c6b0fa53c33747913473e064985dac4d65f77fd2fdc6474e7cd54e4", - "sha256:b0fac2088ec4aaeb5468b814bd3ff5e5978364bfbce5e567c44c9e2854469f6c", - "sha256:b3e212a894d8ae07fde2ca8b43d666a6d49bbbddb10da0f6a74ca7bd31f20054", - "sha256:b54a1ee4c6f1905a436cbaa04b26626d27925a41cbc3a337e2d3ff7038187f07", - "sha256:b667b91f4f714b17af2a18e220015c941d1cf8b07c17f2160033dbe1e64149f0", - "sha256:b8c36093aca722db73633cf2359026ed7782a239eb1c6db2abcff876012dc4cf", - "sha256:bb356e7ae7c2da13f404bf8f75be90f743c6df8d4607022e759f5d7d89fe83f8", - "sha256:bce730d484038e97f27ea2dbe5d392ec5c2261f28c319a3bb266f6b213650135", - "sha256:c075d167a6ec99b798c1fdf6e391a1d5a2d054caffe9593ba0f97e3df2c04f0e", - "sha256:c4e09534037933bf6eb31d804e72c52ec23219b32c1730f9152feabbd7499463", - "sha256:c5f8a5364fc37b2f172c26a038bc7ec4885f429de4a05fc10fdcb53fb5834c5c", - "sha256:cb203c0afffaf1a8f5b9659a013f8f16a1b2cad3a80a8733ceedc968c0cf4c57", - "sha256:cc41374d2f27d81d6558f8a24e5c114580ffefc197fd43eabd7058182f743322", - "sha256:cd879d4646055a573775a1cec863d00c9ff8c55860f8b17f6d8eee9140c06166", - "sha256:d013c07061751ae81861cae6ec3a4fe04e84781b11fd4b6b4201590234b25c7b", - "sha256:d8c7524779003d59948c51b4fcbf1ca4e27c26a7d75984f63488f3625c328b9b", - "sha256:d9710521f07f526de30ccdead67e6b236fe996d214e1a7fba8b36e2ba2cd8261", - "sha256:e1ffde1d6bc2a92f9c9207d1ad808550873748ac2d4d923c815b866baa343b3f", - "sha256:e7f559c36d5cdc448ee13e7e56ed7b6b5d44a40a511d584d388a0f5d940977ba", - "sha256:f2a1e18a85bd066c7c556d85277a7adf4651f259b2579113844835ba1a74aafd", - "sha256:f32b165bf6dfea0846a9c9c38b7e1d68f313956d60a15cde5d1709fddcaf3bee", - "sha256:f5a2f71d6a91238e7628f23538c26aa464d390cbdedf12ee2a7a0fb92a24482a", - "sha256:f81fe93dc1b8e5673f33443c0786c14b77e36f1025973b85e07c70353e46882b" + "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", + "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", + "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", + "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", + "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", + "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", + "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", + "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", + "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", + "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", + "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", + "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", + "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", + "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", + "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", + "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", + "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", + "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", + "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82", + "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", + "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", + "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", + "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", + "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", + "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814", + "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd", + "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", + "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", + "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3", + "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c", + "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", + "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a", + "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", + "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", + "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", + "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", + "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", + "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", + "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", + "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", + "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", + "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", + "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f", + "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", + "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899", + "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", + "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", + "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", + "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", + "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", + "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", + "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", + "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", + "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", + "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", + "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", + "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", + "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4", + "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", + "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", + "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", + "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", + "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f" ], "markers": "python_version >= '3.9'", - "version": "==7.7.0" + "version": "==7.8.0" }, "dill": { "hashes": [ - "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", - "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c" + "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", + "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" ], "markers": "python_version < '3.11'", - "version": "==0.3.9" + "version": "==0.4.0" }, "distlib": { "hashes": [ @@ -1760,11 +1790,11 @@ }, "iniconfig": { "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" + "markers": "python_version >= '3.8'", + "version": "==2.1.0" }, "isort": { "hashes": [ @@ -1816,11 +1846,11 @@ }, "platformdirs": { "hashes": [ - "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", - "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" + "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", + "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351" ], - "markers": "python_version >= '3.8'", - "version": "==4.3.6" + "markers": "python_version >= '3.9'", + "version": "==4.3.7" }, "pluggy": { "hashes": [ @@ -2038,11 +2068,11 @@ }, "virtualenv": { "hashes": [ - "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170", - "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac" + "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", + "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6" ], "markers": "python_version >= '3.8'", - "version": "==20.29.3" + "version": "==20.30.0" } } } diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 94423f17..e5d22f9c 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -20,7 +20,6 @@ from flask import ( abort, current_app, - send_file, send_from_directory, jsonify, make_response, @@ -66,7 +65,7 @@ FileHistorySchema, ProjectVersionListSchema, ) -from .storages.storage import FileNotFound, DataSyncError, InitializationError +from .storages.storage import DataSyncError, InitializationError from .storages.disk import save_to_file, move_to_tmp from .permissions import ( require_project, @@ -94,7 +93,6 @@ ) from .errors import StorageLimitHit, ProjectLocked from ..utils import format_time_delta -from .tasks import create_project_version_zip push_finished = signal("push_finished") diff --git a/server/mergin/sync/storages/storage.py b/server/mergin/sync/storages/storage.py index 0fc427a6..fd4c1e81 100644 --- a/server/mergin/sync/storages/storage.py +++ b/server/mergin/sync/storages/storage.py @@ -2,12 +2,6 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -from urllib.parse import quote -from flask import Response -from requests_toolbelt import MultipartEncoder -from gevent import sleep -import zipfly - class InvalidProject(Exception): pass @@ -76,37 +70,3 @@ def file_path(self, file): def restore_versioned_file(self, file, version): raise NotImplementedError - - def download_files(self, files, files_format: str = None, version: int = None): - """Download files""" - if version: - for f in files: - sleep(0) - self.restore_versioned_file(f.path, version) - if files_format == "zip": - paths = [{"fs": self.file_path(f.location), "n": f.path} for f in files] - z = zipfly.ZipFly(mode="w", paths=paths) - response = Response(z.generator(), mimetype="application/zip") - archive_name = quote(self.project.name.encode("utf-8")) - if version is not None: - archive_name += f"-v{version}" - response.headers["Content-Disposition"] = ( - f"attachment; filename={archive_name}.zip" - ) - return response - files_dict = {} - for f in files: - path = f.path - files_dict[path] = (path, StorageFile(self, f.location)) - encoder = MultipartEncoder(files_dict) - - def _generator(): - while True: - data = encoder.read(4096) - sleep(0) - if data: - yield data - else: - break - - return Response(_generator(), mimetype=encoder.content_type) diff --git a/server/setup.py b/server/setup.py index fb67ae9a..383eee2b 100644 --- a/server/setup.py +++ b/server/setup.py @@ -25,7 +25,6 @@ "psycopg2-binary", "itsdangerous", "Flask-SQLAlchemy", - "zipfly", "python-dotenv", "flask-login", "bcrypt", From 4b6a74310b0d4c5286a7615dc2b4c5a7eab15e3f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 17 Apr 2025 22:41:00 +0200 Subject: [PATCH 14/43] Fix marshmallow version --- server/Pipfile.lock | 81 +++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/server/Pipfile.lock b/server/Pipfile.lock index de1686d3..1f4f539a 100644 --- a/server/Pipfile.lock +++ b/server/Pipfile.lock @@ -426,46 +426,47 @@ }, "gevent": { "hashes": [ - "sha256:1c3443b0ed23dcb7c36a748d42587168672953d368f2956b17fad36d43b58836", - "sha256:1d4fadc319b13ef0a3c44d2792f7918cf1bca27cacd4d41431c22e6b46668026", - "sha256:1ea50009ecb7f1327347c37e9eb6561bdbc7de290769ee1404107b9a9cba7cf1", - "sha256:2142704c2adce9cd92f6600f371afb2860a446bfd0be5bd86cca5b3e12130766", - "sha256:351d1c0e4ef2b618ace74c91b9b28b3eaa0dd45141878a964e03c7873af09f62", - "sha256:356b73d52a227d3313f8f828025b665deada57a43d02b1cf54e5d39028dbcf8d", - "sha256:3d882faa24f347f761f934786dde6c73aa6c9187ee710189f12dcc3a63ed4a50", - "sha256:58851f23c4bdb70390f10fc020c973ffcf409eb1664086792c8b1e20f25eef43", - "sha256:68bee86b6e1c041a187347ef84cf03a792f0b6c7238378bf6ba4118af11feaae", - "sha256:7398c629d43b1b6fd785db8ebd46c0a353880a6fab03d1cf9b6788e7240ee32e", - "sha256:816b3883fa6842c1cf9d2786722014a0fd31b6312cca1f749890b9803000bad6", - "sha256:81d918e952954675f93fb39001da02113ec4d5f4921bf5a0cc29719af6824e5d", - "sha256:85329d556aaedced90a993226d7d1186a539c843100d393f2349b28c55131c85", - "sha256:8619d5c888cb7aebf9aec6703e410620ef5ad48cdc2d813dd606f8aa7ace675f", - "sha256:8bd1419114e9e4a3ed33a5bad766afff9a3cf765cb440a582a1b3a9bc80c1aca", - "sha256:92e0d7759de2450a501effd99374256b26359e801b2d8bf3eedd3751973e87f5", - "sha256:92fe5dfee4e671c74ffaa431fd7ffd0ebb4b339363d24d0d944de532409b935e", - "sha256:97e2f3999a5c0656f42065d02939d64fffaf55861f7d62b0107a08f52c984897", - "sha256:9d3b249e4e1f40c598ab8393fc01ae6a3b4d51fc1adae56d9ba5b315f6b2d758", - "sha256:a3d75fa387b69c751a3d7c5c3ce7092a171555126e136c1d21ecd8b50c7a6e46", - "sha256:a5f1701ce0f7832f333dd2faf624484cbac99e60656bfbb72504decd42970f0f", - "sha256:b24d800328c39456534e3bc3e1684a28747729082684634789c2f5a8febe7671", - "sha256:b5efe72e99b7243e222ba0c2c2ce9618d7d36644c166d63373af239da1036bab", - "sha256:b7bfcfe08d038e1fa6de458891bca65c1ada6d145474274285822896a858c870", - "sha256:beede1d1cff0c6fafae3ab58a0c470d7526196ef4cd6cc18e7769f207f2ea4eb", - "sha256:c6b775381f805ff5faf250e3a07c0819529571d19bb2a9d474bee8c3f90d66af", - "sha256:c9c935b83d40c748b6421625465b7308d87c7b3717275acd587eef2bd1c39546", - "sha256:ca845138965c8c56d1550499d6b923eb1a2331acfa9e13b817ad8305dde83d11", - "sha256:d618e118fdb7af1d6c1a96597a5cd6ac84a9f3732b5be8515c6a66e098d498b6", - "sha256:d6c0a065e31ef04658f799215dddae8752d636de2bed61365c358f9c91e7af61", - "sha256:d740206e69dfdfdcd34510c20adcb9777ce2cc18973b3441ab9767cd8948ca8a", - "sha256:d7886b63ebfb865178ab28784accd32f287d5349b3ed71094c86e4d3ca738af5", - "sha256:d9347690f4e53de2c4af74e62d6fabc940b6d4a6cad555b5a379f61e7d3f2a8e", - "sha256:d9ca80711e6553880974898d99357fb649e062f9058418a92120ca06c18c3c59", - "sha256:e24181d172f50097ac8fc272c8c5b030149b630df02d1c639ee9f878a470ba2b", - "sha256:ec68e270543ecd532c4c1d70fca020f90aa5486ad49c4f3b8b2e64a66f5c9274", - "sha256:f43f47e702d0c8e1b8b997c00f1601486f9f976f84ab704f8f11536e3fa144c9", - "sha256:ff96c5739834c9a594db0e12bf59cb3fa0e5102fc7b893972118a3166733d61c" - ], - "version": "==24.11.1" + "sha256:036cfaf14f90bd8aa57fce4a875693d3712c446371f3812eb113860f7393f670", + "sha256:1bdd383c77a47e041844a17cb43c15c2e1e0b2749fc85d4a2cff14307044b78a", + "sha256:1cb0ec13cace36c38ec4648a78ad93192f45981d1e79f029d456f8305a2ca2bb", + "sha256:29d05fa5e566d04ca509d2c25b15f5e9b123cac4f2f96bb85e110740860e107f", + "sha256:2f31e40ecf11e19150c11bf6b2977050360020ae6086d4c21f94455e3d0222ee", + "sha256:347c60704fa8b6b3110a376facc69ae405ab4d8a71cb1012f2de24e8059a080b", + "sha256:43800850e138913557c96e4a45f86e017b52e5bc63d14395ffd73ebff26e0031", + "sha256:450cdc9a99c665049eaea2257d3b9c7e1cf9a5a699901b1dfc85f1061e6d6cf2", + "sha256:4e0ad8db3711e56c40d37a1b80c61a27f7523de33c5338498ceb453f837a3368", + "sha256:5a8e5660925285b7befb1ecbecc8d44a0e91cdec2b1eebc24006cec564ab8ec5", + "sha256:5f8c584b946f1b476f23bf03b64c4e21f8e6273faa8cf8c91e199491dc1a2b2e", + "sha256:61cbe95833751648fae1aa62d50d386fac71658f57ffa76b3140056f5b305086", + "sha256:63b906c23cc27343d74e605f3ce6ee60947b912737e50b5e1b1a56e6adb54ad6", + "sha256:6cafc4b2e2f55f84acd52c909a2cfda6375e08c1c294536ced473947869f84e8", + "sha256:702ca9a4b75f549c235da6ab9d43fc5261ee7aeaccc6de40f92c43917593ec7f", + "sha256:7052c0daeb260730291a58c6e5cf3789735f8c937557e6a0231d4f46027771b1", + "sha256:7135b10f82864e40d25e877419fa1f90f770dc568917513af0e5ba63910e0067", + "sha256:7c82179a3b1783d0ecf2a11e2040f4f12d7e1925c31e888fe69217cc4e2e21ba", + "sha256:82449386e57237756b10f284cd51172edf4b3866ace143f60d196459aa411c32", + "sha256:832dd696e7bd50c7f141c3af0f9470bc67128152df040580d39c22084d9bb3a5", + "sha256:88cada934c24de811df209d4d5d0cbb78f88a9c3f06ba491ff08c19f6625f9fa", + "sha256:8e5ad1016f7699b6b22186509c64d1b1fcf4df179420062adfebf22fc48bad32", + "sha256:9c59dd3aab086092510860a5c9d237429181cca96e684ba052d27e6a45c78345", + "sha256:b3154899cc60ae1814fb232b6055f8286d7825250d9e3fb06c4f52be126e46cd", + "sha256:b43cbe919a22c1b640152ba828dbb72b3b0b600714a481a0240884afe680ae28", + "sha256:b59c8e7c19b12d04be133b0cea311239069e816e5a7aefc871f3984ba3bcbfb0", + "sha256:b661ead80588352ba94fd55f64432e1eb9a55a259c3a68872377e3c90b087939", + "sha256:cd8762bae8633da18884074a3b7ea1384c1c64131ae6e36ecc927675df258e1f", + "sha256:cfcbd6cc35449d9346234227d90253bfc2c015027e6b076b9c205633ca7e7ad9", + "sha256:e810fc35e4bb8e90fb6771b7f4694e0baf6ce28d1e4740afc4133834eca8b219", + "sha256:e940d31f737925d084bcf2b593e0ba04ade04c9cd21a6230b79081af0cd8fb23", + "sha256:e98cc6262b20471285f8d81e14155d0d4c2a186d31d49b48b71ab5ba6f24d687", + "sha256:ec53625dc995086f27bf49be425f3111a8105e04385830266c021c541bd23998", + "sha256:ee11cd11ee7fbe5215b6d37a9eb32a49fd210540d757648dd991128995e73575", + "sha256:f3de1700459dc16cabaca4609ad83f7cd1371db445d30da1d4aee2e7674ea123", + "sha256:f5f51f6fb68e8c47a02b9c16634bbd9b43b2ff6a66c52c71e6a406dd6b07c80c", + "sha256:fa9bb5133bd50e46d3a8445eb8da829150212d6addfac6c89a2298281e5b884d", + "sha256:fb2701b751f36c39d1dd4dbcc4702089f198673ebe3bcafe3497af6026dbcdfe", + "sha256:ff03881d6b30efb33c110188d6d5c1c10fb09ab299e5ef78db57ae5fc057ea9a" + ], + "version": "==25.4.1" }, "greenlet": { "hashes": [ From d32fb67f57d727bb45d3401367f3c1c01c8d5b7a Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 17 Apr 2025 22:50:44 +0200 Subject: [PATCH 15/43] Fix marshmallow version II --- server/Pipfile | 1 + server/Pipfile.lock | 64 +++++---------------------------------------- 2 files changed, 7 insertions(+), 58 deletions(-) diff --git a/server/Pipfile b/server/Pipfile index c6033818..9859c84e 100644 --- a/server/Pipfile +++ b/server/Pipfile @@ -7,6 +7,7 @@ name = "pypi" connexion = {extras = ["swagger-ui"],version = "==2.14.1"} flask = "==2.2.5" python-dateutil = "==2.8.2" +marshmallow = "==3.20.1" flask-marshmallow = "==0.14.0" marshmallow-sqlalchemy = "==1.1.0" psycopg2-binary = "==2.9.9" diff --git a/server/Pipfile.lock b/server/Pipfile.lock index 1f4f539a..438912f8 100644 --- a/server/Pipfile.lock +++ b/server/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "92d9947af3f62750b41615f35ea94f219e587df704420325f3b13a3e09d0086c" + "sha256": "5d7a107dfa425254eebbe86700e139290dc6986e54bd18e1538a2d87a60282f3" }, "pipfile-spec": 6, "requires": { @@ -48,59 +48,6 @@ "markers": "python_version >= '3.8'", "version": "==25.3.0" }, - "backports-datetime-fromisoformat": { - "hashes": [ - "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491", - "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234", - "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", - "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8", - "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", - "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4", - "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", - "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce", - "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", - "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", - "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", - "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", - "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", - "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575", - "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c", - "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5", - "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", - "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2", - "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", - "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", - "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d", - "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", - "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b", - "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", - "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", - "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", - "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", - "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824", - "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", - "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", - "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", - "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876", - "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", - "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f", - "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", - "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd", - "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", - "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", - "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", - "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10", - "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", - "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62", - "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", - "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef", - "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42", - "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d", - "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.3" - }, "bcrypt": { "hashes": [ "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb", @@ -692,11 +639,12 @@ }, "marshmallow": { "hashes": [ - "sha256:3b6e80aac299a7935cfb97ed01d1854fb90b5079430969af92118ea1b12a8d55", - "sha256:e7b0528337e9990fd64950f8a6b3a1baabed09ad17a0dfb844d701151f92d203" + "sha256:5d2371bbe42000f2b3fb5eaa065224df7d8f8597bc19a1bbfa5bfe7fba8da889", + "sha256:684939db93e80ad3561392f47be0230743131560a41c5110684c16e21ade0a5c" ], - "markers": "python_version >= '3.9'", - "version": "==4.0.0" + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==3.20.1" }, "marshmallow-sqlalchemy": { "hashes": [ From a30aac242faaec82a9dd8caebbe9055b0e1dab91 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Tue, 22 Apr 2025 17:20:43 +0200 Subject: [PATCH 16/43] initial version of fe - timeut for downloadArchive - admin, project dashboard and project version download --- .gitignore | 2 +- docker-compose.yml | 6 +- .../src/modules/admin/views/ProjectView.vue | 12 ++-- .../lib/src/modules/notification/store.ts | 3 +- .../lib/src/modules/notification/types.ts | 2 + .../project/components/DownloadProgress.vue | 54 +++++++++++++++ .../components/ProjectVersionsTable.vue | 15 ++++- .../project/components/UploadProgress.vue | 7 +- .../src/modules/project/components/index.ts | 1 + .../lib/src/modules/project/projectApi.ts | 17 ++--- .../packages/lib/src/modules/project/store.ts | 67 ++++++++++++------- .../packages/lib/src/modules/project/types.ts | 1 + .../project/views/ProjectViewTemplate.vue | 19 ++++-- 13 files changed, 147 insertions(+), 59 deletions(-) create mode 100644 web-app/packages/lib/src/modules/project/components/DownloadProgress.vue diff --git a/.gitignore b/.gitignore index 4b7c63da..968b4e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # demo data -projects/ +projects*/ mergin_db logs diff --git a/docker-compose.yml b/docker-compose.yml index fd19df78..9180ee58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres volumes: - - ./mergin_db:/var/lib/postgresql/data + - ./mergin_db_ce_compose:/var/lib/postgresql/data redis: image: redis:6.2.17 container_name: merginmaps-redis @@ -26,7 +26,7 @@ services: restart: always user: 901:999 volumes: - - ./projects:/data + - ./projects_compose:/data - ./server/entrypoint.sh:/app/entrypoint.sh env_file: - .prod.env @@ -64,7 +64,7 @@ services: - GEVENT_WORKER=0 - NO_MONKEY_PATCH=1 volumes: - - ./projects:/data + - ./projects_compose:/data - ./server/entrypoint.sh:/app/entrypoint.sh depends_on: - redis diff --git a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue index c76e1cbf..dbcefe7e 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue @@ -13,6 +13,7 @@ icon="ti ti-download" class="mr-2" label="Download" + :disabled="projectStore.projectDownloading" /> + @@ -107,7 +109,8 @@ import { AppSection, AppContainer, useProjectStore, - ProjectApi + ProjectApi, + DownloadProgress, } from '@mergin/lib' import { computed, watch, defineProps } from 'vue' import { useRouter, useRoute } from 'vue-router' @@ -195,11 +198,8 @@ function tabClick(index: number) { } function downloadArchive() { - const url = ProjectApi.constructDownloadProjectUrl( - routeWorkspaceName.value, - routeProjectName.value - ) - projectStore.downloadArchive({ url }) + const url = ProjectApi.constructDownloadProjectUrl(project.value?.id) + projectStore.downloadArchive({ url, fileName: routeProjectName.value }) } function openDashboard() { diff --git a/web-app/packages/lib/src/modules/notification/store.ts b/web-app/packages/lib/src/modules/notification/store.ts index 4c85d101..7181f64c 100644 --- a/web-app/packages/lib/src/modules/notification/store.ts +++ b/web-app/packages/lib/src/modules/notification/store.ts @@ -33,7 +33,8 @@ export const useNotificationStore = defineStore('notificationModule', { severity: payload.severity ?? 'success', summary: payload.text, detail: payload.detail, - life: payload.life ?? 3000 + life: payload.sticky ? undefined : payload.life ?? 3000, + group: payload.group }) }, warn(payload: NotificationPayload) { diff --git a/web-app/packages/lib/src/modules/notification/types.ts b/web-app/packages/lib/src/modules/notification/types.ts index 44da8d31..b84199d2 100644 --- a/web-app/packages/lib/src/modules/notification/types.ts +++ b/web-app/packages/lib/src/modules/notification/types.ts @@ -13,6 +13,8 @@ export interface NotificationPayload { text: string detail?: string life?: number + sticky?: boolean + group?: string | undefined } export type NotificationShowPayload = NotificationPayload & { diff --git a/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue b/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue new file mode 100644 index 00000000..c8b6012c --- /dev/null +++ b/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue @@ -0,0 +1,54 @@ + + + + + + + diff --git a/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue b/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue index 5fc5fac4..2c1338d0 100644 --- a/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue +++ b/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue @@ -80,7 +80,11 @@ import { DataViewWrapperColumnItem, DataViewWrapperOptions } from '@/common/components/data-view/types' -import { FetchProjectVersionsParams, ProjectVersionsTableItem } from '@/modules' +import { + FetchProjectVersionsParams, + ProjectApi, + ProjectVersionsTableItem +} from '@/modules' import { useProjectStore } from '@/modules/project/store' const props = withDefaults( @@ -100,7 +104,8 @@ const emit = defineEmits<{ }>() const projectStore = useProjectStore() -const { versions, versionsLoading, versionsCount } = storeToRefs(projectStore) +const { versions, versionsLoading, versionsCount, project } = + storeToRefs(projectStore) const columns = ref([ { @@ -151,7 +156,11 @@ const updateOptions = (newOptions: DataViewWrapperOptions) => { const downloadClick = (item: ProjectVersionsTableItem) => { projectStore.downloadArchive({ - url: `/v1/project/download/${props.namespace}/${props.projectName}?version=${item.name}&format=zip` + url: ProjectApi.constructDownloadProjectVersionUrl( + project.value.id, + item.name + ), + fileName: `${item.project_name}-${item.name}.zip` }) } diff --git a/web-app/packages/lib/src/modules/project/components/UploadProgress.vue b/web-app/packages/lib/src/modules/project/components/UploadProgress.vue index 8f951c28..05f71d00 100644 --- a/web-app/packages/lib/src/modules/project/components/UploadProgress.vue +++ b/web-app/packages/lib/src/modules/project/components/UploadProgress.vue @@ -35,11 +35,6 @@ import { useProjectStore } from '@/modules/project/store' export default defineComponent({ name: 'upload-progress', - data() { - return { - visible: false - } - }, computed: { ...mapState(useProjectStore, ['uploads']), list() { @@ -57,7 +52,7 @@ export default defineComponent({ this.$toast.add({ group: 'upload-progress', severity: 'info', - summary: 'Uploading data to projects' + summary: 'Uploading data to project.' }) }, close() { diff --git a/web-app/packages/lib/src/modules/project/components/index.ts b/web-app/packages/lib/src/modules/project/components/index.ts index e3ccc527..2255fc00 100644 --- a/web-app/packages/lib/src/modules/project/components/index.ts +++ b/web-app/packages/lib/src/modules/project/components/index.ts @@ -25,3 +25,4 @@ export { default as ProjectMembersTable } from './ProjectMembersTable.vue' export { default as FilesTable } from './FilesTable.vue' export { default as ProjectVersionsTable } from './ProjectVersionsTable.vue' export { default as ProjectVersionChanges } from './ProjectVersionChanges.vue' +export { default as DownloadProgress } from './DownloadProgress.vue' diff --git a/web-app/packages/lib/src/modules/project/projectApi.ts b/web-app/packages/lib/src/modules/project/projectApi.ts index b12666f6..91b6edbb 100644 --- a/web-app/packages/lib/src/modules/project/projectApi.ts +++ b/web-app/packages/lib/src/modules/project/projectApi.ts @@ -272,19 +272,15 @@ export const ProjectApi = { ) }, - constructDownloadProjectUrl(namespace: string, projectName: string) { + constructDownloadProjectUrl(projectId: string) { return ProjectModule.httpService.absUrl( - `/v1/project/download/${namespace}/${projectName}?format=zip` + `/app/projects/${projectId}/download` ) }, - constructDownloadProjectVersionUrl( - namespace: string, - projectName: string, - versionId: string - ) { + constructDownloadProjectVersionUrl(projectId: string, versionId: string) { return ProjectModule.httpService.absUrl( - `/v1/project/download/${namespace}/${projectName}?version=${versionId}&format=zip` + `/app/projects/${projectId}/download?version=${versionId}` ) }, @@ -315,6 +311,11 @@ export const ProjectApi = { return ProjectModule.httpService.get(url, { responseType: 'blob' }) }, + /** Request head of file download */ + async getHeadDownloadFile(url: string): Promise> { + return ProjectModule.httpService.head(url) + }, + // Kept for EE (collaborators + invitation) access, TODO: remove when a separate invitation endpoint is implemented async getProjectAccess( projectId: string diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index 7366c4f7..25f0f107 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -57,6 +57,8 @@ export interface UploadFilesPayload { files: File[] } +let downloadArchiveTimeout = null + export interface ProjectState { accessRequests: AccessRequest[] accessRequestsCount: number @@ -77,6 +79,7 @@ export interface ProjectState { availableRoles: DropdownOption[] versionsChangesetLoading: boolean collaborators: ProjectCollaborator[] + projectDownloading: boolean } export const useProjectStore = defineStore('projectModule', { @@ -102,7 +105,8 @@ export const useProjectStore = defineStore('projectModule', { availablePermissions: permissionUtils.getProjectPermissionsValues(), availableRoles: permissionUtils.getProjectRoleNameValues(), versionsChangesetLoading: false, - collaborators: [] + collaborators: [], + projectDownloading: false }), getters: { @@ -702,33 +706,48 @@ export const useProjectStore = defineStore('projectModule', { async downloadArchive(payload: DownloadPayload) { const notificationStore = useNotificationStore() - - try { - const resp = await ProjectApi.downloadFile(payload.url) - const fileName = - resp.headers['content-disposition'].split('filename=')[1] - FileSaver.saveAs(resp.data, fileName) - } catch (e) { - // parse error details from blob - if (axios.isAxiosError(e)) { - let resp - const blob = new Blob([e.response.data], { type: 'text/plain' }) - blob.text().then((text) => { - resp = JSON.parse(text) - notificationStore.error({ text: resp.detail }) - }) + this.cancelDownloadArchive() + this.projectDownloading = true + + const delays = [...Array(3).fill(1000), ...Array(3).fill(3000), 5000] + let retryCount = 0 + const pollDownloadArchive = async () => { + try { + const head = await ProjectApi.getHeadDownloadFile(payload.url) + const polling = head.status == 202 + + if (polling) { + const delay = delays[Math.min(retryCount, delays.length - 1)] // Select delay based on retry count + retryCount++ // Increment retry count + downloadArchiveTimeout = setTimeout(async () => { + await pollDownloadArchive() + }, delay) + return + } + // Use browser download instead of playing around with the blob + FileSaver.saveAs(payload.url, payload.fileName) + notificationStore.closeNotification() + clearTimeout(downloadArchiveTimeout) + downloadArchiveTimeout = null + this.projectDownloading = false + } catch { + notificationStore.error({ text: 'Failed to download project' }) + this.cancelDownloadArchive() } } + pollDownloadArchive() }, - constructDownloadProjectUrl(payload: { - namespace: string - projectName: string - }) { - return ProjectApi.constructDownloadProjectUrl( - payload.namespace, - payload.projectName - ) + cancelDownloadArchive() { + if (downloadArchiveTimeout) { + clearTimeout(downloadArchiveTimeout) + downloadArchiveTimeout = null + } + this.projectDownloading = false + }, + + constructDownloadProjectUrl(payload: { projectId: string }) { + return ProjectApi.constructDownloadProjectUrl(payload.projectId) }, setProjectsSorting(payload: SortingParams) { diff --git a/web-app/packages/lib/src/modules/project/types.ts b/web-app/packages/lib/src/modules/project/types.ts index 2e3ca0f2..5a612117 100644 --- a/web-app/packages/lib/src/modules/project/types.ts +++ b/web-app/packages/lib/src/modules/project/types.ts @@ -308,6 +308,7 @@ export interface UpdatePublicFlagParams { export interface DownloadPayload { url: string + fileName: string } export interface FetchProjectVersionsPayload { diff --git a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue index 57b5eee6..731f30e6 100644 --- a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue +++ b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue @@ -14,11 +14,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
+
@@ -118,6 +122,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial import { mapActions, mapState } from 'pinia' import { defineComponent, PropType } from 'vue' +import DownloadProgress from '../components/DownloadProgress.vue' + import { AppContainer, AppSection } from '@/common' import { waitCursor } from '@/common/html_utils' import { @@ -144,7 +150,8 @@ export default defineComponent({ components: { UploadDialog, AppContainer, - AppSection + AppSection, + DownloadProgress }, props: { namespace: String, @@ -168,7 +175,7 @@ export default defineComponent({ ...mapState(useLayoutStore, ['drawer']), ...mapState(useProjectStore, ['project', 'uploads']), ...mapState(useUserStore, ['loggedUser']), - ...mapState(useProjectStore, ['isProjectOwner']), + ...mapState(useProjectStore, ['isProjectOwner', 'projectDownloading']), ...mapState(useUserStore, ['currentWorkspace', 'isLoggedIn']), tabs(): TabItem[] { @@ -237,14 +244,12 @@ export default defineComponent({ downloadUrl() { if (this.$route.name === 'project-versions-detail') { return ProjectApi.constructDownloadProjectVersionUrl( - this.namespace, - this.projectName, + this.project.id, this.$route.params.version_id as string ) } else { return this.constructDownloadProjectUrl({ - namespace: this.namespace, - projectName: this.projectName + projectId: this.project.id }) } } From a8dcfc187a5fc65b2cafe92ff87e506775b34d07 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Thu, 24 Apr 2025 12:56:04 +0200 Subject: [PATCH 17/43] cleanup docker-compose --- docker-compose.yml | 6 +++--- server/mergin/sync/private_api_controller.py | 2 +- web-app/packages/lib/src/modules/project/store.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9180ee58..fd19df78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres volumes: - - ./mergin_db_ce_compose:/var/lib/postgresql/data + - ./mergin_db:/var/lib/postgresql/data redis: image: redis:6.2.17 container_name: merginmaps-redis @@ -26,7 +26,7 @@ services: restart: always user: 901:999 volumes: - - ./projects_compose:/data + - ./projects:/data - ./server/entrypoint.sh:/app/entrypoint.sh env_file: - .prod.env @@ -64,7 +64,7 @@ services: - GEVENT_WORKER=0 - NO_MONKEY_PATCH=1 volumes: - - ./projects_compose:/data + - ./projects:/data - ./server/entrypoint.sh:/app/entrypoint.sh depends_on: - redis diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index e8409b67..2b04151f 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -358,7 +358,7 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06 resp = send_file(project_version.zip_path, mimetype="application/zip") resp.headers["Content-Disposition"] = ( - f"attachment; filename={project.id}-{lookup_version}.zip" + f"attachment; filename={project.name}-v{lookup_version}.zip" ) return resp diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index 25f0f107..d6029265 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -715,7 +715,6 @@ export const useProjectStore = defineStore('projectModule', { try { const head = await ProjectApi.getHeadDownloadFile(payload.url) const polling = head.status == 202 - if (polling) { const delay = delays[Math.min(retryCount, delays.length - 1)] // Select delay based on retry count retryCount++ // Increment retry count @@ -724,8 +723,9 @@ export const useProjectStore = defineStore('projectModule', { }, delay) return } + // Use browser download instead of playing around with the blob - FileSaver.saveAs(payload.url, payload.fileName) + FileSaver.saveAs(payload.url) notificationStore.closeNotification() clearTimeout(downloadArchiveTimeout) downloadArchiveTimeout = null From 9661ed2110a72a6f2c717b258f1c8a2508191ebf Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Thu, 24 Apr 2025 15:57:26 +0200 Subject: [PATCH 18/43] Cleanup of version and more detailed description about /download endpoint --- deployment/common/nginx.conf | 4 +++- deployment/community/.env.template | 7 ------- deployment/enterprise/.env.template | 2 -- development.md | 4 +++- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/deployment/common/nginx.conf b/deployment/common/nginx.conf index 2371e8e4..834ea30a 100644 --- a/deployment/common/nginx.conf +++ b/deployment/common/nginx.conf @@ -54,6 +54,8 @@ server { location /download/ { internal; - alias /data/; # we need to mount data from mergin server here + # We need to mount data from mergin server here. + # This should have the same path as LOCAL_PROJECTS env variable with slash at the end + alias /data/; } } diff --git a/deployment/community/.env.template b/deployment/community/.env.template index dfe380d4..b7def629 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -1,7 +1,5 @@ # This file should contain a set of Mergin Maps configuration definitions along with their default values -# Mind that any major change to this file MUST BE reflected in docs - FLASK_APP=application @@ -14,8 +12,6 @@ FLASK_DEBUG=0 #LOCAL_PROJECTS=os.path.join(config_dir, os.pardir, os.pardir, 'projects') # for local storage type LOCAL_PROJECTS=/data -PROJECTS_ARCHIVES_DIR=/data/projects_archives - #MAINTENANCE_FILE=os.path.join(LOCAL_PROJECTS, 'MAINTENANCE') # locking file when backups are created MAINTENANCE_FILE=/data/MAINTENANCE @@ -24,9 +20,6 @@ MAINTENANCE_FILE=/data/MAINTENANCE #TEMP_DIR=gettempdir() # trash dir for temp files being cleaned regularly TEMP_DIR=/data/tmp -#VERSION=get_version() - - # Mergin DB related #DB_APPLICATION_NAME=mergin diff --git a/deployment/enterprise/.env.template b/deployment/enterprise/.env.template index 1a4b4764..62553a19 100644 --- a/deployment/enterprise/.env.template +++ b/deployment/enterprise/.env.template @@ -30,8 +30,6 @@ TEMP_DIR=/data/tmp #USER_SELF_REGISTRATION=True -#VERSION=get_version() - # Mergin DB related diff --git a/development.md b/development.md index b51c83d4..42ad83d3 100644 --- a/development.md +++ b/development.md @@ -68,12 +68,14 @@ If you want to run the whole stack locally, you can use the docker. Docker will # Enter community edition deployment folder cd deployment/community/ +# Create .prod.env file from .env.template +cp .env.template .prod.env + # Run the docker composition with the current Dockerfiles docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d # Give ownership of the ./projects folder to user that is running the gunicorn container sudo chown 901:999 projects -sudo chown 101:999 logs # init db and create user docker exec -it merginmaps-server flask init-db From fbb56afc48179d57848d011b2b738f4633a2968b Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Thu, 24 Apr 2025 16:04:08 +0200 Subject: [PATCH 19/43] Introduce get_x_accel method to create valid accel url for nginx --- server/mergin/sync/private_api_controller.py | 3 ++- server/mergin/sync/public_api_controller.py | 5 +++-- server/mergin/sync/utils.py | 19 +++++++++++++++++++ server/mergin/tests/test_utils.py | 17 +++++++++++++++++ .../admin-lib/src/modules/admin/store.ts | 4 ++-- .../src/modules/admin/views/ProjectView.vue | 2 +- .../components/ProjectVersionsTable.vue | 3 +-- .../packages/lib/src/modules/project/types.ts | 1 - .../project/views/ProjectViewTemplate.vue | 4 +--- 9 files changed, 46 insertions(+), 12 deletions(-) diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 2b04151f..dc798f38 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -42,6 +42,7 @@ from ..utils import parse_order_params, split_order_param, get_order_param from .tasks import create_project_version_zip from .storages.disk import move_to_tmp +from .utils import get_x_accel_uri project_access_granted = signal("project_access_granted") PARTIAL_ZIP_EXPIRATION = 5 # minutes @@ -350,7 +351,7 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06 if os.path.exists(project_version.zip_path): if current_app.config["USE_X_ACCEL"]: resp = make_response() - resp.headers["X-Accel-Redirect"] = f"/download/{project_version.zip_path}" + resp.headers["X-Accel-Redirect"] = get_x_accel_uri(project_version.zip_path) resp.headers["X-Accel-Buffering"] = True resp.headers["X-Accel-Expires"] = "off" resp.headers["Content-Type"] = "application/zip" diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index e5d22f9c..9fd229a1 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -77,6 +77,7 @@ from .utils import ( generate_checksum, Toucher, + get_x_accel_uri, is_file_name_blacklisted, get_ip, get_user_agent, @@ -355,8 +356,8 @@ def download_project_file( # encoding for nginx to be able to download file with non-ascii chars encoded_file_path = quote(file_path.encode("utf-8")) resp = make_response() - resp.headers["X-Accel-Redirect"] = ( - f"/download/{project.storage_params['location']}/{encoded_file_path}" + resp.headers["X-Accel-Redirect"] = get_x_accel_uri( + project.storage_params["location"], encoded_file_path ) resp.headers["X-Accel-Buffering"] = True resp.headers["X-Accel-Expires"] = "off" diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index 8ee0f480..50d29070 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -22,6 +22,7 @@ is_valid_filename, ) import magic +from flask import current_app def generate_checksum(file, chunk_size=4096): @@ -546,3 +547,21 @@ def is_supported_type(filepath) -> bool: def get_mimetype(filepath: str) -> str: """Identifies file types by checking their headers""" return magic.from_file(filepath, mime=True) + + +def get_x_accel_uri(*url_parts): + """ + Get the accell uri for the given url parts + """ + download_accell_uri = "/download" + if not url_parts: + return download_accell_uri + + local_projects = current_app.config.get("LOCAL_PROJECTS") + print(local_projects) + url = os.path.join(*url_parts) + # if the path parts_join starts with local_projects, remove it + if url.startswith(local_projects): + url = os.path.relpath(url, local_projects) + result = os.path.join(download_accell_uri, url) + return result diff --git a/server/mergin/tests/test_utils.py b/server/mergin/tests/test_utils.py index 2a70a29e..f3249daa 100644 --- a/server/mergin/tests/test_utils.py +++ b/server/mergin/tests/test_utils.py @@ -19,6 +19,7 @@ has_valid_first_character, check_filename, is_valid_path, + get_x_accel_uri, ) from ..auth.models import LoginHistory, User from . import json_headers @@ -253,3 +254,19 @@ def test_is_name_allowed(): @pytest.mark.parametrize("filepath,allow", filepaths) def test_is_valid_path(client, filepath, allow): assert is_valid_path(filepath) == allow + + +def test_get_x_accell_uri(client): + """Test get_x_accell_uri""" + client.application.config["LOCAL_PROJECTS"] = "/data/" + # Input URL parts + url_parts = ("/data", "archive", "project1", "file.txt") + # Expected result + expected = "/download/archive/project1/file.txt" + assert get_x_accel_uri(*url_parts) == expected + + url_parts = ("archive", "project1", "file.txt") + assert get_x_accel_uri(*url_parts) == expected + + url_parts = () + assert get_x_accel_uri(*url_parts) == "/download" diff --git a/web-app/packages/admin-lib/src/modules/admin/store.ts b/web-app/packages/admin-lib/src/modules/admin/store.ts index 1f76d4b1..a0544bbd 100644 --- a/web-app/packages/admin-lib/src/modules/admin/store.ts +++ b/web-app/packages/admin-lib/src/modules/admin/store.ts @@ -13,6 +13,7 @@ import { useNotificationStore, UserResponse } from '@mergin/lib' +import axios from 'axios' import FileSaver from 'file-saver' import { defineStore, getActivePinia } from 'pinia' import Cookies from 'universal-cookie' @@ -28,7 +29,6 @@ import { UpdateUserPayload, UsersResponse } from '@/modules/admin/types' -import axios from 'axios' export interface AdminState { loading: boolean @@ -350,7 +350,7 @@ export const useAdminStore = defineStore('adminModule', { const fileName = resp.headers['content-disposition'].split('filename=')[1] const extension = fileName.split('.')[1] - new FileSaver.saveAs( + FileSaver.saveAs( resp.data, `usage-report-${date.toISOString().split('T')[0]}.${extension}` ) diff --git a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue index dbcefe7e..5da36817 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue @@ -199,7 +199,7 @@ function tabClick(index: number) { function downloadArchive() { const url = ProjectApi.constructDownloadProjectUrl(project.value?.id) - projectStore.downloadArchive({ url, fileName: routeProjectName.value }) + projectStore.downloadArchive({ url }) } function openDashboard() { diff --git a/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue b/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue index 2c1338d0..abac33cd 100644 --- a/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue +++ b/web-app/packages/lib/src/modules/project/components/ProjectVersionsTable.vue @@ -159,8 +159,7 @@ const downloadClick = (item: ProjectVersionsTableItem) => { url: ProjectApi.constructDownloadProjectVersionUrl( project.value.id, item.name - ), - fileName: `${item.project_name}-${item.name}.zip` + ) }) } diff --git a/web-app/packages/lib/src/modules/project/types.ts b/web-app/packages/lib/src/modules/project/types.ts index 5a612117..2e3ca0f2 100644 --- a/web-app/packages/lib/src/modules/project/types.ts +++ b/web-app/packages/lib/src/modules/project/types.ts @@ -308,7 +308,6 @@ export interface UpdatePublicFlagParams { export interface DownloadPayload { url: string - fileName: string } export interface FetchProjectVersionsPayload { diff --git a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue index 731f30e6..9ce0b3f5 100644 --- a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue +++ b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue @@ -14,9 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
Date: Thu, 24 Apr 2025 16:17:14 +0200 Subject: [PATCH 20/43] add max 100 retries for download --- web-app/packages/lib/src/modules/project/store.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index d6029265..b73af656 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -724,6 +724,14 @@ export const useProjectStore = defineStore('projectModule', { return } + if (retryCount > 100) { + notificationStore.error({ + text: 'Failed to download project. Please try again.' + }) + this.cancelDownloadArchive() + return + } + // Use browser download instead of playing around with the blob FileSaver.saveAs(payload.url) notificationStore.closeNotification() From 900902ef577ff3ab484b7d67591bb2108f8808d7 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Thu, 24 Apr 2025 16:47:11 +0200 Subject: [PATCH 21/43] cancel using method + make retries maximum working properly --- .../packages/lib/src/modules/project/store.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index b73af656..0fc201d2 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -713,6 +713,14 @@ export const useProjectStore = defineStore('projectModule', { let retryCount = 0 const pollDownloadArchive = async () => { try { + if (retryCount > 100) { + notificationStore.error({ + text: 'Failed to download project. Please try again.' + }) + this.cancelDownloadArchive() + return + } + const head = await ProjectApi.getHeadDownloadFile(payload.url) const polling = head.status == 202 if (polling) { @@ -724,20 +732,10 @@ export const useProjectStore = defineStore('projectModule', { return } - if (retryCount > 100) { - notificationStore.error({ - text: 'Failed to download project. Please try again.' - }) - this.cancelDownloadArchive() - return - } - // Use browser download instead of playing around with the blob FileSaver.saveAs(payload.url) notificationStore.closeNotification() - clearTimeout(downloadArchiveTimeout) - downloadArchiveTimeout = null - this.projectDownloading = false + this.cancelDownloadArchive() } catch { notificationStore.error({ text: 'Failed to download project' }) this.cancelDownloadArchive() From ede4e3c201c9d4022e76af595aa72226b8b188b7 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Thu, 24 Apr 2025 17:04:41 +0200 Subject: [PATCH 22/43] Cleanup of download progress --- .../project/components/DownloadProgress.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue b/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue index c8b6012c..ee9c60d8 100644 --- a/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue +++ b/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue @@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + + From 28f664fb49b3bbcc081bbc4c59e691f0b3f59502 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Wed, 30 Apr 2025 12:33:53 +0200 Subject: [PATCH 34/43] tweaks for default values for download - introduce new varibles for download to .env.templates --- deployment/community/.env.template | 14 +++++++++++++- deployment/enterprise/.env.template | 16 ++++++++++++++-- server/mergin/sync/config.py | 6 +++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/deployment/community/.env.template b/deployment/community/.env.template index b7def629..741e0e9c 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -112,11 +112,23 @@ MAIL_SUPPRESS_SEND=0 #MAX_CHUNK_SIZE=10 * 1024 * 1024 # 10485760 in bytes -#MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 # max total files size for archive download +# data download + +#MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 * 10 # max total files size in bytes for archive download - 10 GB #USE_X_ACCEL=False # use nginx (in front of gunicorn) to serve files (https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/) USE_X_ACCEL=1 +#PARTIAL_ZIP_EXPIRATION=600 # in seconds + +#PROJECTS_ARCHIVES_DIR=LOCAL_PROJECTS/projects_archives # where to store archives for download + +# days for which archive is ready to download +# PROJECTS_ARCHIVES_EXPIRATION=7 # in days + +# If use x-accel buffering by download (no/yes) +# PROJECTS_ARCHIVES_X_ACCEL_BUFFERING="no" + # geodif related # where geodiff lib copies working files diff --git a/deployment/enterprise/.env.template b/deployment/enterprise/.env.template index 62553a19..f6bda021 100644 --- a/deployment/enterprise/.env.template +++ b/deployment/enterprise/.env.template @@ -106,10 +106,22 @@ MAIL_USERNAME=fix-me #MAX_CHUNK_SIZE=10 * 1024 * 1024 # 10485760 in bytes -#MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 # max total files size for archive download +# data download + +#MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 # max total files size in bytes for archive download #USE_X_ACCEL=False # use nginx (in front of gunicorn) to serve files (https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/) -USE_X_ACCEL=True +USE_X_ACCEL=1 + +#PARTIAL_ZIP_EXPIRATION=600 # in seconds + +#PROJECTS_ARCHIVES_DIR=LOCAL_PROJECTS/projects_archives # where to store archives for download + +# days for which archive is ready to download +# PROJECTS_ARCHIVES_EXPIRATION=7 # in days + +# If use x-accel buffering by download (no/yes) +# PROJECTS_ARCHIVES_X_ACCEL_BUFFERING="no" # celery diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index 26a9d14c..b182da6d 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -43,8 +43,8 @@ class Configuration(object): ) # max total files size for archive download MAX_DOWNLOAD_ARCHIVE_SIZE = config( - "MAX_DOWNLOAD_ARCHIVE_SIZE", default=1024 * 1024 * 1024 * 20, cast=int - ) # 20 GB + "MAX_DOWNLOAD_ARCHIVE_SIZE", default=1024 * 1024 * 1024 * 10, cast=int + ) # 10 GB PROJECT_ACCESS_REQUEST = config( "PROJECT_ACCESS_REQUEST", default=7 * 24 * 3600, cast=int ) @@ -63,4 +63,4 @@ class Configuration(object): default=os.path.join(LOCAL_PROJECTS, "geodiff_tmp"), ) # in seconds, older unfinished zips are moved to temp - PARTIAL_ZIP_EXPIRATION = config("PARTIAL_ZIP_EXPIRATION", default=300, cast=int) + PARTIAL_ZIP_EXPIRATION = config("PARTIAL_ZIP_EXPIRATION", default=600, cast=int) From 04dec0095362cda8da981f2cdd3171be87fc0e09 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Wed, 30 Apr 2025 12:34:37 +0200 Subject: [PATCH 35/43] Update texts: - in case of preparing archive - in case of big file - introduce 413 status code for file is too large archive --- server/mergin/sync/private_api.yaml | 4 +++ server/mergin/sync/private_api_controller.py | 6 +--- .../project/components/DownloadProgress.vue | 4 +-- .../packages/lib/src/modules/project/store.ts | 28 +++++++++++++++---- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/server/mergin/sync/private_api.yaml b/server/mergin/sync/private_api.yaml index 389a6d7b..8c7c8491 100644 --- a/server/mergin/sync/private_api.yaml +++ b/server/mergin/sync/private_api.yaml @@ -410,6 +410,8 @@ paths: description: Accepted "400": $ref: "#/components/responses/BadStatusResp" + "413": + $ref: "#/components/responses/FileTooLargeResp" "403": $ref: "#/components/responses/Forbidden" "404": @@ -423,6 +425,8 @@ components: description: Project not found. BadStatusResp: description: Invalid request. + FileTooLargeResp: + description: File is too large. InvalidDataResp: description: Invalid/unprocessable data. Success: diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 88f84c35..528629cf 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -337,11 +337,7 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06 ).first_or_404("Project version does not exist") if project_version.project_size > current_app.config["MAX_DOWNLOAD_ARCHIVE_SIZE"]: - abort( - 400, - "The total size of requested files is too large to download as a single zip, " - "please use different method/client for download", - ) + abort(413) # check zip is already created if os.path.exists(project_version.zip_path): diff --git a/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue b/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue index ee9c60d8..61f99d48 100644 --- a/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue +++ b/web-app/packages/lib/src/modules/project/components/DownloadProgress.vue @@ -30,8 +30,8 @@ export default defineComponent({ this.$toast.add({ group: 'download-progress', severity: 'info', - summary: `Downloading ${this.project?.name}`, - detail: 'Please wait while your project is being downloaded.', + summary: `Preparing archive`, + detail: `Your project ${this.project?.name} is being prepared for download.`, life: undefined }) }, diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index 0fc201d2..a0c850f7 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -8,7 +8,7 @@ import keyBy from 'lodash/keyBy' import omit from 'lodash/omit' import { defineStore, getActivePinia } from 'pinia' -import { DropdownOption, permissionUtils } from '@/common' +import { DropdownOption, errorUtils, permissionUtils } from '@/common' import { getErrorMessage } from '@/common/error_utils' import { waitCursor } from '@/common/html_utils' import { filesDiff } from '@/common/mergin_utils' @@ -708,14 +708,21 @@ export const useProjectStore = defineStore('projectModule', { const notificationStore = useNotificationStore() this.cancelDownloadArchive() this.projectDownloading = true + const errorMessage = + 'Failed to download project archive. Please try again later.' + const exceedMessage = + 'It seems like preparing your ZIP file is taking longer than expected. Please try again in a little while to download your file.' + const fileTooLargeMessage = + 'The requested archive is too large to download. Please use direct download with python client or plugin instead.' const delays = [...Array(3).fill(1000), ...Array(3).fill(3000), 5000] let retryCount = 0 const pollDownloadArchive = async () => { try { - if (retryCount > 100) { - notificationStore.error({ - text: 'Failed to download project. Please try again.' + if (retryCount > 125) { + notificationStore.warn({ + text: exceedMessage, + life: 6000 }) this.cancelDownloadArchive() return @@ -736,8 +743,17 @@ export const useProjectStore = defineStore('projectModule', { FileSaver.saveAs(payload.url) notificationStore.closeNotification() this.cancelDownloadArchive() - } catch { - notificationStore.error({ text: 'Failed to download project' }) + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 413) { + notificationStore.error({ + text: fileTooLargeMessage, + life: 6000 + }) + } else { + notificationStore.error({ + text: errorMessage + }) + } this.cancelDownloadArchive() } } From d01812d2be1d65763358c739926602bd4c1cad77 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Wed, 30 Apr 2025 13:33:14 +0200 Subject: [PATCH 36/43] Return 400 instad 413 of large files - upgrades for large files error --- server/mergin/sync/private_api_controller.py | 2 +- .../mergin/tests/test_private_project_api.py | 1 - .../project/components/DownloadFileLarge.vue | 33 +++++++++++++++++++ .../packages/lib/src/modules/project/store.ts | 5 +-- .../project/views/ProjectViewTemplate.vue | 5 ++- 5 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 web-app/packages/lib/src/modules/project/components/DownloadFileLarge.vue diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 528629cf..8e0180d4 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -337,7 +337,7 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06 ).first_or_404("Project version does not exist") if project_version.project_size > current_app.config["MAX_DOWNLOAD_ARCHIVE_SIZE"]: - abort(413) + abort(400) # check zip is already created if os.path.exists(project_version.zip_path): diff --git a/server/mergin/tests/test_private_project_api.py b/server/mergin/tests/test_private_project_api.py index 0b72952c..27021ecb 100644 --- a/server/mergin/tests/test_private_project_api.py +++ b/server/mergin/tests/test_private_project_api.py @@ -472,7 +472,6 @@ def test_large_project_download_fail(client, diff_project): ) ) assert resp.status_code == 400 - assert "The total size of requested files is too large" in resp.json["detail"] @patch("mergin.sync.tasks.create_project_version_zip.delay") diff --git a/web-app/packages/lib/src/modules/project/components/DownloadFileLarge.vue b/web-app/packages/lib/src/modules/project/components/DownloadFileLarge.vue new file mode 100644 index 00000000..4865dc66 --- /dev/null +++ b/web-app/packages/lib/src/modules/project/components/DownloadFileLarge.vue @@ -0,0 +1,33 @@ + + + + + + + diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index a0c850f7..401c264d 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -744,9 +744,10 @@ export const useProjectStore = defineStore('projectModule', { notificationStore.closeNotification() this.cancelDownloadArchive() } catch (e) { - if (axios.isAxiosError(e) && e.response?.status === 413) { + if (axios.isAxiosError(e) && e.response?.status === 400) { notificationStore.error({ - text: fileTooLargeMessage, + group: 'download-large-error', + text: '', life: 6000 }) } else { diff --git a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue index 9ce0b3f5..16535528 100644 --- a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue +++ b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue @@ -113,6 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial +
@@ -120,6 +121,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial import { mapActions, mapState } from 'pinia' import { defineComponent, PropType } from 'vue' +import DownloadFileLarge from '../components/DownloadFileLarge.vue' import DownloadProgress from '../components/DownloadProgress.vue' import { AppContainer, AppSection } from '@/common' @@ -149,7 +151,8 @@ export default defineComponent({ UploadDialog, AppContainer, AppSection, - DownloadProgress + DownloadProgress, + DownloadFileLarge }, props: { namespace: String, From 6b09bb3be11a63dc2a40529f1687953f886f6779 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Wed, 30 Apr 2025 13:55:44 +0200 Subject: [PATCH 37/43] Add error message for large files to admin --- .../packages/admin-lib/src/modules/admin/views/ProjectView.vue | 2 ++ web-app/packages/lib/src/modules/project/components/index.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue index 5da36817..4a975d9a 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue @@ -101,6 +101,7 @@ + @@ -111,6 +112,7 @@ import { useProjectStore, ProjectApi, DownloadProgress, + DownloadFileLarge } from '@mergin/lib' import { computed, watch, defineProps } from 'vue' import { useRouter, useRoute } from 'vue-router' diff --git a/web-app/packages/lib/src/modules/project/components/index.ts b/web-app/packages/lib/src/modules/project/components/index.ts index 2255fc00..87086c9a 100644 --- a/web-app/packages/lib/src/modules/project/components/index.ts +++ b/web-app/packages/lib/src/modules/project/components/index.ts @@ -26,3 +26,4 @@ export { default as FilesTable } from './FilesTable.vue' export { default as ProjectVersionsTable } from './ProjectVersionsTable.vue' export { default as ProjectVersionChanges } from './ProjectVersionChanges.vue' export { default as DownloadProgress } from './DownloadProgress.vue' +export { default as DownloadFileLarge } from './DownloadFileLarge.vue' From b70a2185a6cee3285cf6ca5514d5eeead9af3c40 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Wed, 30 Apr 2025 20:25:58 +0200 Subject: [PATCH 38/43] Added primary and info severity. info is just fallback --- .../common/components/AppSectionBanner.vue | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/web-app/packages/lib/src/common/components/AppSectionBanner.vue b/web-app/packages/lib/src/common/components/AppSectionBanner.vue index ce5d4545..72c37942 100644 --- a/web-app/packages/lib/src/common/components/AppSectionBanner.vue +++ b/web-app/packages/lib/src/common/components/AppSectionBanner.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -->