From c4032e3040af9b7bb92dbb90f12bcbbd0abe810d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Apr 2026 13:05:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20filestorage=20upload=20=D0=B8=20pre?= =?UTF-8?q?view=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDESIGN-CODE.md | 10 + docker/docker-compose.yaml | 3 +- docker/plane.env | 1 + plane-app/docker-compose.yaml | 3 +- plane-app/plane.env | 1 + plane-src/apps/api/plane/api/views/asset.py | 7 +- plane-src/apps/api/plane/api/views/issue.py | 20 +- .../apps/api/plane/app/serializers/base.py | 1 + .../api/plane/app/serializers/workspace.py | 5 + .../apps/api/plane/app/views/asset/v2.py | 13 +- .../api/plane/app/views/issue/attachment.py | 27 +- .../0126_workspace_storage_limits.py | 23 ++ .../apps/api/plane/db/models/workspace.py | 2 + .../tests/unit/utils/test_upload_limits.py | 23 ++ .../api/plane/utils/attachment_preview.py | 59 +++ .../apps/api/plane/utils/upload_limits.py | 24 ++ plane-src/apps/proxy/Caddyfile.aio.ce | 4 +- plane-src/apps/proxy/Caddyfile.ce | 4 +- .../web/app/(all)/[workspaceSlug]/layout.tsx | 2 + plane-src/apps/web/ce/hooks/use-file-size.ts | 16 +- .../issues/attachment/attachment-detail.tsx | 2 +- .../attachment/attachment-item-list.tsx | 70 ++-- .../attachment/attachment-list-item.tsx | 69 ++-- .../attachment/attachment-pdf-preview.tsx | 367 ++++++++++++++++++ .../issues/attachment/attachment-upload.tsx | 6 +- .../attachments/quick-action-button.tsx | 16 +- .../components/navigation/app-rail-root.tsx | 12 +- .../workspace/settings/workspace-details.tsx | 110 +++++- .../settings/workspace-settings-modal.tsx | 75 ++++ .../workspace/sidebar/dropdown-item.tsx | 9 +- .../sidebar/workspace-menu-header.tsx | 9 +- .../web/core/services/file-upload.service.ts | 2 +- .../issue/issue_attachment.service.ts | 34 +- .../issue/issue-details/attachment.store.ts | 57 ++- plane-src/apps/web/nginx/nginx.conf | 8 + plane-src/apps/web/package.json | 1 + .../deployments/aio/community/variables.env | 1 + .../cli/community/docker-compose.yml | 3 +- .../deployments/cli/community/variables.env | 1 + plane-src/docker-compose.yml | 3 +- .../types/src/issues/issue_attachment.ts | 1 + plane-src/packages/types/src/workspace.ts | 2 + plane-src/pnpm-lock.yaml | 3 + 43 files changed, 982 insertions(+), 127 deletions(-) create mode 100644 plane-src/apps/api/plane/db/migrations/0126_workspace_storage_limits.py create mode 100644 plane-src/apps/api/plane/tests/unit/utils/test_upload_limits.py create mode 100644 plane-src/apps/api/plane/utils/attachment_preview.py create mode 100644 plane-src/apps/api/plane/utils/upload_limits.py create mode 100644 plane-src/apps/web/core/components/issues/attachment/attachment-pdf-preview.tsx create mode 100644 plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx diff --git a/HDESIGN-CODE.md b/HDESIGN-CODE.md index 8e8ab11..23963b2 100644 --- a/HDESIGN-CODE.md +++ b/HDESIGN-CODE.md @@ -80,6 +80,16 @@ - единая вертикальная высота для одного класса контролов - Placeholder и label должны быть читаемы и не прилипать к краям. +## Чекеры +- Для бинарных настроек в glass-интерфейсе используется круглый checker в стиле фильтров отображения. +- Активное состояние: + - круг залит `rgb(var(--nodedc-accent-rgb))` + - внутри маленькая точка `rgb(var(--nodedc-on-accent-rgb))` +- Неактивное состояние: + - круг на мягком `white/10` + - без внешнего outline и без синей browser-рамки +- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox. + ## Toolbar и верхние панели - Элементы верхней панели центрируются по одной горизонтальной оси. - Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой. diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0406ad2..60517af 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env x-proxy-env: &proxy-env APP_DOMAIN: ${APP_DOMAIN:-localhost} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} + PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} CERT_EMAIL: ${CERT_EMAIL} CERT_ACME_CA: ${CERT_ACME_CA} CERT_ACME_DNS: ${CERT_ACME_DNS} diff --git a/docker/plane.env b/docker/plane.env index 16ba3a0..77c0288 100644 --- a/docker/plane.env +++ b/docker/plane.env @@ -64,6 +64,7 @@ AWS_SECRET_ACCESS_KEY=secret-key AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_BUCKET_NAME=uploads FILE_SIZE_LIMIT=5242880 +PROXY_BODY_SIZE_LIMIT=1073741824 POSTHOG_API_KEY= POSTHOG_HOST= INSTANCE_CHANGELOG_URL= diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index 0406ad2..60517af 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env x-proxy-env: &proxy-env APP_DOMAIN: ${APP_DOMAIN:-localhost} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} + PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} CERT_EMAIL: ${CERT_EMAIL} CERT_ACME_CA: ${CERT_ACME_CA} CERT_ACME_DNS: ${CERT_ACME_DNS} diff --git a/plane-app/plane.env b/plane-app/plane.env index 16ba3a0..77c0288 100644 --- a/plane-app/plane.env +++ b/plane-app/plane.env @@ -64,6 +64,7 @@ AWS_SECRET_ACCESS_KEY=secret-key AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_BUCKET_NAME=uploads FILE_SIZE_LIMIT=5242880 +PROXY_BODY_SIZE_LIMIT=1073741824 POSTHOG_API_KEY= POSTHOG_HOST= INSTANCE_CHANGELOG_URL= diff --git a/plane-src/apps/api/plane/api/views/asset.py b/plane-src/apps/api/plane/api/views/asset.py index 88c34c3..318ff87 100644 --- a/plane-src/apps/api/plane/api/views/asset.py +++ b/plane-src/apps/api/plane/api/views/asset.py @@ -43,6 +43,7 @@ from plane.utils.openapi import ( asset_docs, ) from plane.utils.exception_logger import log_exception +from plane.utils.upload_limits import resolve_workspace_upload_size_limit class UserAssetEndpoint(BaseAPIView): @@ -512,9 +513,6 @@ class GenericAssetEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - # Check if the file size is within the limit - size_limit = min(size, settings.FILE_SIZE_LIMIT) - # Check if the file type is allowed if not type or type not in settings.ATTACHMENT_MIME_TYPES: return Response( @@ -525,6 +523,9 @@ class GenericAssetEndpoint(BaseAPIView): # Get the workspace workspace = Workspace.objects.get(slug=slug) + # Check if the file size is within the limit + size_limit = resolve_workspace_upload_size_limit(workspace, size) + # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" diff --git a/plane-src/apps/api/plane/api/views/issue.py b/plane-src/apps/api/plane/api/views/issue.py index 97e8e7c..1d36d7a 100644 --- a/plane-src/apps/api/plane/api/views/issue.py +++ b/plane-src/apps/api/plane/api/views/issue.py @@ -86,6 +86,8 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView from plane.utils.host import base_host from plane.utils.issue_relation_mapper import get_actual_relation +from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response +from plane.utils.upload_limits import resolve_workspace_upload_size_limit from plane.bgtasks.webhook_task import model_activity from plane.app.permissions import ROLE from plane.utils.openapi import ( @@ -1874,8 +1876,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - size_limit = min(size, settings.FILE_SIZE_LIMIT) - if not type or type not in settings.ATTACHMENT_MIME_TYPES: return Response( {"error": "Invalid file type.", "status": False}, @@ -1885,6 +1885,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): # Get the workspace workspace = Workspace.objects.get(slug=slug) + size_limit = resolve_workspace_upload_size_limit(workspace, size) + # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" @@ -2100,13 +2102,8 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - storage = S3Storage(request=request) - presigned_url = storage.generate_presigned_url( - object_name=asset.asset.name, - disposition="attachment", - filename=asset.attributes.get("name"), - ) - return HttpResponseRedirect(presigned_url) + disposition = "inline" if request.GET.get("preview") == "true" else "attachment" + return get_attachment_preview_response(request, asset, disposition=disposition) @issue_attachment_docs( operation_id="upload_work_item_attachment", @@ -2157,6 +2154,11 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) serializer = IssueAttachmentSerializer(issue_attachment) + if not attachment_object_exists(issue_attachment): + return Response( + {"error": "The uploaded attachment object was not found.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) # Send this activity only if the attachment is not uploaded before if not issue_attachment.is_uploaded: diff --git a/plane-src/apps/api/plane/app/serializers/base.py b/plane-src/apps/api/plane/app/serializers/base.py index 6457eec..2654858 100644 --- a/plane-src/apps/api/plane/app/serializers/base.py +++ b/plane-src/apps/api/plane/app/serializers/base.py @@ -192,6 +192,7 @@ class DynamicBaseSerializer(BaseSerializer): issue_attachments = FileAsset.objects.filter( issue_id=issue_id, entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=True, ) # Serialize issue_attachments and add them to the response response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data diff --git a/plane-src/apps/api/plane/app/serializers/workspace.py b/plane-src/apps/api/plane/app/serializers/workspace.py index 608cdad..49df602 100644 --- a/plane-src/apps/api/plane/app/serializers/workspace.py +++ b/plane-src/apps/api/plane/app/serializers/workspace.py @@ -61,6 +61,11 @@ class WorkSpaceSerializer(DynamicBaseSerializer): ) return value + def validate_storage_file_size_limit(self, value): + if value is not None and int(value) < 1: + raise serializers.ValidationError("Storage file size limit must be greater than zero") + return value + class Meta: model = Workspace fields = "__all__" diff --git a/plane-src/apps/api/plane/app/views/asset/v2.py b/plane-src/apps/api/plane/app/views/asset/v2.py index 62c5f84..3d8d052 100644 --- a/plane-src/apps/api/plane/app/views/asset/v2.py +++ b/plane-src/apps/api/plane/app/views/asset/v2.py @@ -24,6 +24,7 @@ from plane.app.permissions import allow_permission, ROLE from plane.utils.cache import invalidate_cache_directly from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.throttles.asset import AssetRateThrottle +from plane.utils.upload_limits import resolve_workspace_upload_size_limit class UserAssetsV2Endpoint(BaseAPIView): @@ -342,12 +343,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - # Get the size limit - size_limit = min(settings.FILE_SIZE_LIMIT, size) - # Get the workspace workspace = Workspace.objects.get(slug=slug) + # Get the size limit + size_limit = resolve_workspace_upload_size_limit(workspace, size) + # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" @@ -541,12 +542,12 @@ class ProjectAssetEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - # Get the size limit - size_limit = min(settings.FILE_SIZE_LIMIT, size) - # Get the workspace workspace = Workspace.objects.get(slug=slug) + # Get the size limit + size_limit = resolve_workspace_upload_size_limit(workspace, size) + # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" diff --git a/plane-src/apps/api/plane/app/views/issue/attachment.py b/plane-src/apps/api/plane/app/views/issue/attachment.py index df027c4..a66b6a8 100644 --- a/plane-src/apps/api/plane/app/views/issue/attachment.py +++ b/plane-src/apps/api/plane/app/views/issue/attachment.py @@ -10,7 +10,6 @@ import uuid from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder from django.conf import settings -from django.http import HttpResponseRedirect # Third Party imports from rest_framework.response import Response @@ -26,6 +25,8 @@ from plane.app.permissions import allow_permission, ROLE from plane.settings.storage import S3Storage from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.utils.host import base_host +from plane.utils.upload_limits import resolve_workspace_upload_size_limit +from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response class IssueAttachmentEndpoint(BaseAPIView): @@ -86,7 +87,13 @@ class IssueAttachmentEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, issue_id): - issue_attachments = FileAsset.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id) + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=True, + ) serializer = IssueAttachmentSerializer(issue_attachments, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -114,7 +121,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" # Get the size limit - size_limit = min(size, settings.FILE_SIZE_LIMIT) + size_limit = resolve_workspace_upload_size_limit(workspace, size) # Create a File Asset asset = FileAsset.objects.create( @@ -179,13 +186,8 @@ class IssueAttachmentV2Endpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - storage = S3Storage(request=request) - presigned_url = storage.generate_presigned_url( - object_name=asset.asset.name, - disposition="attachment", - filename=asset.attributes.get("name"), - ) - return HttpResponseRedirect(presigned_url) + disposition = "inline" if request.GET.get("preview") == "true" else "attachment" + return get_attachment_preview_response(request, asset, disposition=disposition) # Get all the attachments issue_attachments = FileAsset.objects.filter( @@ -203,6 +205,11 @@ class IssueAttachmentV2Endpoint(BaseAPIView): def patch(self, request, slug, project_id, issue_id, pk): issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) serializer = IssueAttachmentSerializer(issue_attachment) + if not attachment_object_exists(issue_attachment): + return Response( + {"error": "The uploaded attachment object was not found.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) # Send this activity only if the attachment is not uploaded before if not issue_attachment.is_uploaded: diff --git a/plane-src/apps/api/plane/db/migrations/0126_workspace_storage_limits.py b/plane-src/apps/api/plane/db/migrations/0126_workspace_storage_limits.py new file mode 100644 index 0000000..f6e3c3e --- /dev/null +++ b/plane-src/apps/api/plane/db/migrations/0126_workspace_storage_limits.py @@ -0,0 +1,23 @@ +# Generated by Codex on 2026-04-25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0125_voice_task_sessions"), + ] + + operations = [ + migrations.AddField( + model_name="workspace", + name="storage_file_size_limit_enabled", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="workspace", + name="storage_file_size_limit", + field=models.PositiveBigIntegerField(default=5242880), + ), + ] diff --git a/plane-src/apps/api/plane/db/models/workspace.py b/plane-src/apps/api/plane/db/models/workspace.py index 80a3e3e..919e2fe 100644 --- a/plane-src/apps/api/plane/db/models/workspace.py +++ b/plane-src/apps/api/plane/db/models/workspace.py @@ -137,6 +137,8 @@ class Workspace(BaseModel): organization_size = models.CharField(max_length=20, blank=True, null=True) timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) background_color = models.CharField(max_length=255, default=get_random_color) + storage_file_size_limit_enabled = models.BooleanField(default=True) + storage_file_size_limit = models.PositiveBigIntegerField(default=5242880) def __str__(self): """Return name of the Workspace""" diff --git a/plane-src/apps/api/plane/tests/unit/utils/test_upload_limits.py b/plane-src/apps/api/plane/tests/unit/utils/test_upload_limits.py new file mode 100644 index 0000000..906e729 --- /dev/null +++ b/plane-src/apps/api/plane/tests/unit/utils/test_upload_limits.py @@ -0,0 +1,23 @@ +from types import SimpleNamespace + +import pytest + +from plane.utils.upload_limits import get_workspace_file_size_limit, resolve_workspace_upload_size_limit + + +@pytest.mark.unit +class TestWorkspaceUploadLimits: + def test_returns_none_when_workspace_limit_is_disabled(self): + workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024) + + assert get_workspace_file_size_limit(workspace) is None + + def test_caps_requested_size_by_workspace_limit(self): + workspace = SimpleNamespace(storage_file_size_limit_enabled=True, storage_file_size_limit=1024) + + assert resolve_workspace_upload_size_limit(workspace, 4096) == 1024 + + def test_uses_requested_size_when_workspace_limit_is_disabled(self): + workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024) + + assert resolve_workspace_upload_size_limit(workspace, 4096) == 4096 diff --git a/plane-src/apps/api/plane/utils/attachment_preview.py b/plane-src/apps/api/plane/utils/attachment_preview.py new file mode 100644 index 0000000..feafa90 --- /dev/null +++ b/plane-src/apps/api/plane/utils/attachment_preview.py @@ -0,0 +1,59 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from urllib.parse import quote + +from botocore.exceptions import ClientError +from django.http import HttpResponse, StreamingHttpResponse + +from plane.settings.storage import S3Storage + + +def attachment_object_exists(asset): + storage = S3Storage(request=None) + try: + storage.s3_client.head_object( + Bucket=storage.aws_storage_bucket_name, + Key=str(asset.asset.name), + ) + return True + except ClientError: + return False + + +def get_attachment_preview_response(request, asset, disposition="inline"): + storage = S3Storage(request=None) + range_header = request.META.get("HTTP_RANGE") + request_kwargs = { + "Bucket": storage.aws_storage_bucket_name, + "Key": str(asset.asset.name), + } + if range_header: + request_kwargs["Range"] = range_header + + try: + storage_response = storage.s3_client.get_object(**request_kwargs) + except ClientError: + return HttpResponse("Attachment object not found.", status=404) + + content_type = ( + asset.attributes.get("type") + or storage_response.get("ContentType") + or "application/octet-stream" + ) + filename = quote(asset.attributes.get("name") or "attachment") + response = StreamingHttpResponse( + storage_response["Body"].iter_chunks(chunk_size=8192), + status=206 if storage_response.get("ContentRange") else 200, + content_type=content_type, + ) + response["Content-Disposition"] = f"{disposition}; filename*=UTF-8''{filename}" + response["Accept-Ranges"] = "bytes" + + if storage_response.get("ContentLength") is not None: + response["Content-Length"] = storage_response["ContentLength"] + if storage_response.get("ContentRange"): + response["Content-Range"] = storage_response["ContentRange"] + + return response diff --git a/plane-src/apps/api/plane/utils/upload_limits.py b/plane-src/apps/api/plane/utils/upload_limits.py new file mode 100644 index 0000000..5f7eb0e --- /dev/null +++ b/plane-src/apps/api/plane/utils/upload_limits.py @@ -0,0 +1,24 @@ +from django.conf import settings + + +def get_workspace_file_size_limit(workspace): + if not getattr(workspace, "storage_file_size_limit_enabled", True): + return None + + limit = getattr(workspace, "storage_file_size_limit", None) or settings.FILE_SIZE_LIMIT + return max(1, int(limit)) + + +def resolve_workspace_upload_size_limit(workspace, requested_size): + try: + requested_size = int(requested_size) + except (TypeError, ValueError): + requested_size = settings.FILE_SIZE_LIMIT + + requested_size = max(1, requested_size) + workspace_limit = get_workspace_file_size_limit(workspace) + + if workspace_limit is None: + return requested_size + + return min(requested_size, workspace_limit) diff --git a/plane-src/apps/proxy/Caddyfile.aio.ce b/plane-src/apps/proxy/Caddyfile.aio.ce index 9cf6d8d..e84f77c 100644 --- a/plane-src/apps/proxy/Caddyfile.aio.ce +++ b/plane-src/apps/proxy/Caddyfile.aio.ce @@ -1,6 +1,6 @@ (plane_proxy) { request_body { - max_size {$FILE_SIZE_LIMIT} + max_size {$PROXY_BODY_SIZE_LIMIT:1073741824} } handle /spaces/* { @@ -43,4 +43,4 @@ {$SITE_ADDRESS} { import plane_proxy -} \ No newline at end of file +} diff --git a/plane-src/apps/proxy/Caddyfile.ce b/plane-src/apps/proxy/Caddyfile.ce index 14559f2..699dd69 100644 --- a/plane-src/apps/proxy/Caddyfile.ce +++ b/plane-src/apps/proxy/Caddyfile.ce @@ -1,6 +1,6 @@ (plane_proxy) { request_body { - max_size {$FILE_SIZE_LIMIT} + max_size {$PROXY_BODY_SIZE_LIMIT:1073741824} } redir /spaces /spaces/ permanent @@ -36,4 +36,4 @@ {$SITE_ADDRESS} { import plane_proxy -} \ No newline at end of file +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx index e3ea04b..9f5812e 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -10,6 +10,7 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail"; import { GlobalModals } from "@/plane-web/components/common/modal/global"; import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; +import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal"; import type { Route } from "./+types/layout"; export default function WorkspaceLayout(props: Route.ComponentProps) { @@ -21,6 +22,7 @@ export default function WorkspaceLayout(props: Route.ComponentProps) { + diff --git a/plane-src/apps/web/ce/hooks/use-file-size.ts b/plane-src/apps/web/ce/hooks/use-file-size.ts index a453d65..c4532fa 100644 --- a/plane-src/apps/web/ce/hooks/use-file-size.ts +++ b/plane-src/apps/web/ce/hooks/use-file-size.ts @@ -8,16 +8,30 @@ import { MAX_FILE_SIZE } from "@plane/constants"; // hooks import { useInstance } from "@/hooks/store/use-instance"; +import { useWorkspace } from "@/hooks/store/use-workspace"; type TReturnProps = { maxFileSize: number; + fileSizeLimitEnabled: boolean; }; export const useFileSize = (): TReturnProps => { // store hooks const { config } = useInstance(); + const { currentWorkspace } = useWorkspace(); + + const workspaceLimitEnabled = currentWorkspace?.storage_file_size_limit_enabled ?? true; + const workspaceLimit = currentWorkspace?.storage_file_size_limit; + const fallbackLimit = config?.file_size_limit ?? MAX_FILE_SIZE; + const maxFileSize = + workspaceLimitEnabled && workspaceLimit && workspaceLimit > 0 + ? workspaceLimit + : workspaceLimitEnabled + ? fallbackLimit + : Number.MAX_SAFE_INTEGER; return { - maxFileSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxFileSize, + fileSizeLimitEnabled: workspaceLimitEnabled, }; }; diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx index fb486b0..aa48959 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx @@ -53,7 +53,7 @@ export const IssueAttachmentsDetail = observer(function IssueAttachmentsDetail(p // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; const fileName = getFileName(attachment?.attributes.name ?? ""); - const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileExtension = getFileExtension(attachment?.attributes.name ?? ""); const fileIcon = getFileIcon(fileExtension, 28); const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx index e05096b..b9b254f 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -51,21 +51,33 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList attachment: { getAttachmentsByIssueId }, attachmentDeleteModalId, toggleDeleteAttachmentModal, + fetchAttachments, fetchActivities, } = useIssueDetail(issueServiceType); const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers; const { create: createAttachment } = attachmentOperations; const { uploadStatus } = attachmentSnapshot; // file size - const { maxFileSize } = useFileSize(); + const { fileSizeLimitEnabled, maxFileSize } = useFileSize(); // derived values const issueAttachments = getAttachmentsByIssueId(issueId) ?? []; + const activeUploads = uploadStatus ?? []; const hasAttachmentRows = issueAttachments.length > 0 || !!uploadStatus?.length; + const uploadProgress = + activeUploads.length > 0 + ? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length) + : 0; // handlers const handleFetchPropertyActivities = useCallback(() => { fetchActivities(workspaceSlug, projectId, issueId); }, [fetchActivities, workspaceSlug, projectId, issueId]); + const handleRefreshAttachments = useCallback(() => { + if (!workspaceSlug || !projectId || !issueId) return; + fetchAttachments(workspaceSlug, projectId, issueId).catch((error) => { + console.error("Error in refreshing issue attachments after upload:", error); + }); + }, [fetchAttachments, workspaceSlug, projectId, issueId]); const onDrop = useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { @@ -73,38 +85,39 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList if (!workspaceSlug) return; setIsUploading(true); - Promise.allSettled(acceptedFiles.map((file) => createAttachment(file))) - .then((results) => { - const failedUploads = results.filter((result) => result.status === "rejected").length; - if (failedUploads > 0) - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: t("attachment.error"), - }); - return undefined; - }) - .finally(() => { - handleFetchPropertyActivities(); - setIsUploading(false); - }); + Promise.allSettled(acceptedFiles.map((file) => createAttachment(file))).finally(() => { + handleRefreshAttachments(); + window.setTimeout(handleRefreshAttachments, 1200); + handleFetchPropertyActivities(); + setIsUploading(false); + }); } if (rejectedFiles.length > 0) setToast({ type: TOAST_TYPE.ERROR, title: t("toast.error"), - message: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }), + message: fileSizeLimitEnabled + ? t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }) + : t("attachment.error"), }); return; }, - [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t] + [ + createAttachment, + fileSizeLimitEnabled, + maxFileSize, + workspaceSlug, + handleRefreshAttachments, + handleFetchPropertyActivities, + t, + ] ); const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ onDrop, - maxSize: maxFileSize, + maxSize: fileSizeLimitEnabled ? maxFileSize : undefined, multiple: true, disabled: isUploading || disabled, }); @@ -139,7 +152,9 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList {isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
- Любой формат, несколько файлов за раз, до {maxFileSize / 1024 / 1024} МБ на файл + {fileSizeLimitEnabled + ? `Любой формат, несколько файлов за раз, до ${maxFileSize / 1024 / 1024} МБ на файл` + : "Любой формат, несколько файлов за раз, без ограничения размера"}
@@ -148,11 +163,22 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList + {activeUploads.length > 0 && ( +
+
+
+ )} + {hasAttachmentRows && (
-
Вложения
-
{issueAttachments.length}
+
Вложения
+
+ {activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length} +
{uploadStatus?.map((status) => ( diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx index 2b467fe..3968287 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -17,11 +17,12 @@ import { EIssueServiceType } from "@plane/types"; import type { TContextMenuItem } from "@plane/ui"; import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; -import { Download, FileText, ImageIcon, Play, X } from "lucide-react"; +import { Download, ImageIcon, Play, X } from "lucide-react"; // components // import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { getFileIcon } from "@/components/icons"; +import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview"; // helpers // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; @@ -38,6 +39,13 @@ const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", " const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]); const PDF_EXTENSIONS = new Set(["pdf"]); +const appendSearchParam = (url: string | undefined, key: string, value: string): string => { + if (!url) return ""; + const [urlWithoutHash, hash] = url.split("#"); + const separator = urlWithoutHash.includes("?") ? "&" : "?"; + return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`; +}; + const getPreviewType = (extension: string) => { const normalizedExtension = extension.toLowerCase(); if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image"; @@ -64,7 +72,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const previewType = getPreviewType(fileExtension); const fileIcon = getFileIcon(fileExtension, 32); const fileURL = getFileURL(attachment?.asset_url ?? ""); + const previewURL = appendSearchParam(fileURL, "preview", "true"); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isThumbnailError, setIsThumbnailError] = useState(false); const menuItems: TContextMenuItem[] = [ { key: "delete", @@ -93,19 +103,23 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt }} >
- {previewType === "image" && fileURL ? ( - {fullFileName} - ) : previewType === "video" && fileURL ? ( + {previewType === "image" && previewURL && !isThumbnailError ? ( + {fullFileName} setIsThumbnailError(true)} + /> + ) : previewType === "video" && previewURL ? ( <> -