== WHY (KEEP THIS — IT'S WHY THE FORK EXISTS) ==
Vanilla Plane's upload flow uses AWS S3 PostObject (presigned POST +
multipart/form-data + signed-policy-document). Cloudflare R2 AND
Backblaze B2 — the two most common self-host S3-compatible backends —
both return HTTP 501 NotImplemented for PostObject. Empirically verified
2026-04-30 against B2 s3.us-west-004.backblazeb2.com from inside Plane's
own prod api container, replicating Plane's exact boto3 call:
PUT against B2: 200 OK
POST against B2: 501 NotImplemented "This API call is not supported."
POST against R2: 501 NotImplemented (failure that started this thread)
The error code is `NotImplemented` (not `SignatureDoesNotMatch` etc),
meaning the server rejects the verb itself — no boto3 config, addressing-
style flag, or signature variant fixes it. Tested both path-style and
virtual-hosted-style URLs against B2; both fail identically for POST.
This patch rewrites the upload flow to use presigned PUT, which is
universally supported (R2, B2, AWS S3 native, MinIO, Wasabi, etc).
== WHAT (FIVE-FILE BACKEND, FIVE-FILE FRONTEND) ==
Backend:
* apps/api/plane/settings/storage.py — S3Storage.generate_presigned_post
now mints a presigned PUT URL via generate_presigned_url(HttpMethod="PUT").
Method name kept for caller compat. Response shape:
{url, method: "PUT", fields: {Content-Type, key}}.
* apps/api/plane/utils/openapi/responses.py — example response updated.
* apps/api/plane/tests/unit/settings/test_storage.py — 2 tests updated to
assert the new boto3 call.
Frontend:
* packages/types/src/file.ts — TFileSignedURLResponse.upload_data adds
optional method?: "PUT" | "POST"; drops AWS POST-form-data fields.
* packages/services/src/file/helper.ts — generateFileUploadPayload now
returns a TFileUploadRequest descriptor (url+method+body+headers) that
dispatches on method. POST branch kept for upstream parity but the
fork backend never emits POST.
* packages/services/src/file/file-upload.service.ts +
apps/web/core/services/file-upload.service.ts — uploadFile signature
changes from (url, FormData, progress?) to (payload, progress?).
* 5 caller sites updated (apps/web/core/services/file.service.ts x3,
issue_attachment.service.ts x1, sites-file.service.ts x1).
== TRADEOFFS ACCEPTED ==
* Lost: signed `content-length-range` enforcement at the storage layer.
Server-side validation in the API view still rejects oversized requests
with 413 before minting the URL, so a determined client could only
over-upload by misreporting size, capped at the bucket's own size limit.
* Different request shape on the wire (PUT with raw binary body vs POST
with multipart form). Externally invisible to users.
== ROLLBACK ==
If this becomes a maintenance nightmare:
git revert <this-commit-sha>
# rebuild + push images, swap compose tags, redeploy
After revert, uploads will only work against backends that implement
PostObject (MinIO, AWS S3 native). R2 and B2 will return 501 again.
== FULL DECISION RECORD ==
binarybeachio repo: docs/features/storage-upload-flow.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
489 lines
14 KiB
Python
489 lines
14 KiB
Python
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# See the LICENSE file for details.
|
|
|
|
"""
|
|
Common OpenAPI responses for drf-spectacular.
|
|
|
|
This module provides reusable response definitions for common HTTP status codes
|
|
and scenarios that occur across multiple API endpoints.
|
|
"""
|
|
|
|
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, inline_serializer
|
|
from rest_framework import serializers
|
|
from .examples import get_sample_for_schema
|
|
|
|
|
|
# Authentication & Authorization Responses
|
|
UNAUTHORIZED_RESPONSE = OpenApiResponse(
|
|
description="Authentication credentials were not provided or are invalid.",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Unauthorized",
|
|
value={
|
|
"error": "Authentication credentials were not provided",
|
|
"error_code": "AUTHENTICATION_REQUIRED",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
FORBIDDEN_RESPONSE = OpenApiResponse(
|
|
description="Permission denied. User lacks required permissions.",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Forbidden",
|
|
value={
|
|
"error": "You do not have permission to perform this action",
|
|
"error_code": "PERMISSION_DENIED",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
# Resource Responses
|
|
NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="The requested resource was not found.",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Not Found",
|
|
value={"error": "Not found", "error_code": "RESOURCE_NOT_FOUND"},
|
|
)
|
|
],
|
|
)
|
|
|
|
VALIDATION_ERROR_RESPONSE = OpenApiResponse(
|
|
description="Validation error occurred with the provided data.",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Validation Error",
|
|
value={
|
|
"error": "Validation failed",
|
|
"details": {"field_name": ["This field is required."]},
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Generic Success Responses
|
|
DELETED_RESPONSE = OpenApiResponse(
|
|
description="Resource deleted successfully",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Deleted Successfully",
|
|
value={"message": "Resource deleted successfully"},
|
|
)
|
|
],
|
|
)
|
|
|
|
ARCHIVED_RESPONSE = OpenApiResponse(
|
|
description="Resource archived successfully",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Archived Successfully",
|
|
value={"message": "Resource archived successfully"},
|
|
)
|
|
],
|
|
)
|
|
|
|
UNARCHIVED_RESPONSE = OpenApiResponse(
|
|
description="Resource unarchived successfully",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Unarchived Successfully",
|
|
value={"message": "Resource unarchived successfully"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Specific Error Responses
|
|
INVALID_REQUEST_RESPONSE = OpenApiResponse(
|
|
description="Invalid request data provided",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Invalid Request",
|
|
value={
|
|
"error": "Invalid request data",
|
|
"details": "Specific validation errors",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
CONFLICT_RESPONSE = OpenApiResponse(
|
|
description="Resource conflict - duplicate or constraint violation",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Resource Conflict",
|
|
value={
|
|
"error": "Resource with the same identifier already exists",
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
ADMIN_ONLY_RESPONSE = OpenApiResponse(
|
|
description="Only admin or creator can perform this action",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Admin Only",
|
|
value={"error": "Only admin or creator can perform this action"},
|
|
)
|
|
],
|
|
)
|
|
|
|
CANNOT_DELETE_RESPONSE = OpenApiResponse(
|
|
description="Resource cannot be deleted due to constraints",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Cannot Delete",
|
|
value={"error": "Resource cannot be deleted", "reason": "Has dependencies"},
|
|
)
|
|
],
|
|
)
|
|
|
|
CANNOT_ARCHIVE_RESPONSE = OpenApiResponse(
|
|
description="Resource cannot be archived in current state",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Cannot Archive",
|
|
value={
|
|
"error": "Resource cannot be archived",
|
|
"reason": "Not in valid state",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
REQUIRED_FIELDS_RESPONSE = OpenApiResponse(
|
|
description="Required fields are missing",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Required Fields Missing",
|
|
value={"error": "Required fields are missing", "fields": ["name", "type"]},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Project-specific Responses
|
|
PROJECT_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Project not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Project Not Found",
|
|
value={"error": "Project not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
WORKSPACE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Workspace not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Workspace Not Found",
|
|
value={"error": "Workspace not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
PROJECT_NAME_TAKEN_RESPONSE = OpenApiResponse(
|
|
description="Project name already taken",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Project Name Taken",
|
|
value={"error": "Project name already taken"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Issue-specific Responses
|
|
ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Issue not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Issue Not Found",
|
|
value={"error": "Issue not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
WORK_ITEM_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Work item not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Work Item Not Found",
|
|
value={"error": "Work item not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
EXTERNAL_ID_EXISTS_RESPONSE = OpenApiResponse(
|
|
description="Resource with same external ID already exists",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="External ID Exists",
|
|
value={
|
|
"error": "Resource with the same external id and external source already exists", # noqa: E501
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Label-specific Responses
|
|
LABEL_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Label not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Label Not Found",
|
|
value={"error": "Label not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
LABEL_NAME_EXISTS_RESPONSE = OpenApiResponse(
|
|
description="Label with the same name already exists",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Label Name Exists",
|
|
value={"error": "Label with the same name already exists in the project"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Module-specific Responses
|
|
MODULE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Module not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Module Not Found",
|
|
value={"error": "Module not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
MODULE_ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Module issue not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Module Issue Not Found",
|
|
value={"error": "Module issue not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Cycle-specific Responses
|
|
CYCLE_CANNOT_ARCHIVE_RESPONSE = OpenApiResponse(
|
|
description="Cycle cannot be archived",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Cycle Cannot Archive",
|
|
value={"error": "Only completed cycles can be archived"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# State-specific Responses
|
|
STATE_NAME_EXISTS_RESPONSE = OpenApiResponse(
|
|
description="State with the same name already exists",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="State Name Exists",
|
|
value={"error": "State with the same name already exists"},
|
|
)
|
|
],
|
|
)
|
|
|
|
STATE_CANNOT_DELETE_RESPONSE = OpenApiResponse(
|
|
description="State cannot be deleted",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="State Cannot Delete",
|
|
value={
|
|
"error": "State cannot be deleted",
|
|
"reason": "Default state or has issues",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Comment-specific Responses
|
|
COMMENT_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Comment not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Comment Not Found",
|
|
value={"error": "Comment not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Link-specific Responses
|
|
LINK_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Link not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Link Not Found",
|
|
value={"error": "Link not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Attachment-specific Responses
|
|
ATTACHMENT_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Attachment not found",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Attachment Not Found",
|
|
value={"error": "Attachment not found"},
|
|
)
|
|
],
|
|
)
|
|
|
|
# Search-specific Responses
|
|
BAD_SEARCH_REQUEST_RESPONSE = OpenApiResponse(
|
|
description="Bad request - invalid search parameters",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Bad Search Request",
|
|
value={"error": "Invalid search parameters"},
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
# Pagination Response Templates
|
|
def create_paginated_response(
|
|
item_schema,
|
|
schema_name,
|
|
description="Paginated results",
|
|
example_name="Paginated Response",
|
|
):
|
|
"""Create a paginated response with the specified item schema"""
|
|
|
|
return OpenApiResponse(
|
|
description=description,
|
|
response=inline_serializer(
|
|
name=schema_name,
|
|
fields={
|
|
"grouped_by": serializers.CharField(allow_null=True),
|
|
"sub_grouped_by": serializers.CharField(allow_null=True),
|
|
"total_count": serializers.IntegerField(),
|
|
"next_cursor": serializers.CharField(),
|
|
"prev_cursor": serializers.CharField(),
|
|
"next_page_results": serializers.BooleanField(),
|
|
"prev_page_results": serializers.BooleanField(),
|
|
"count": serializers.IntegerField(),
|
|
"total_pages": serializers.IntegerField(),
|
|
"total_results": serializers.IntegerField(),
|
|
"extra_stats": serializers.CharField(allow_null=True),
|
|
"results": serializers.ListField(child=item_schema()),
|
|
},
|
|
),
|
|
examples=[
|
|
OpenApiExample(
|
|
name=example_name,
|
|
value={
|
|
"grouped_by": "state",
|
|
"sub_grouped_by": "priority",
|
|
"total_count": 150,
|
|
"next_cursor": "20:1:0",
|
|
"prev_cursor": "20:0:0",
|
|
"next_page_results": True,
|
|
"prev_page_results": False,
|
|
"count": 20,
|
|
"total_pages": 8,
|
|
"total_results": 150,
|
|
"extra_stats": None,
|
|
"results": [get_sample_for_schema(schema_name)],
|
|
},
|
|
summary=example_name,
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
# Asset-specific Responses
|
|
PRESIGNED_URL_SUCCESS_RESPONSE = OpenApiResponse(description="Presigned URL generated successfully")
|
|
|
|
GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE = OpenApiResponse(
|
|
description="Presigned URL generated successfully",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Generic Asset Upload Response",
|
|
value={
|
|
"upload_data": {
|
|
"url": "https://s3.amazonaws.com/bucket-name/workspace-id/uuid-filename.pdf?X-Amz-Signature=...",
|
|
"method": "PUT",
|
|
"fields": {
|
|
"Content-Type": "application/pdf",
|
|
"key": "workspace-id/uuid-filename.pdf",
|
|
},
|
|
},
|
|
"asset_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"asset_url": "https://cdn.example.com/workspace-id/uuid-filename.pdf",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
GENERIC_ASSET_VALIDATION_ERROR_RESPONSE = OpenApiResponse(
|
|
description="Validation error",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Missing required fields",
|
|
value={"error": "Name and size are required fields.", "status": False},
|
|
),
|
|
OpenApiExample(
|
|
name="Invalid file type",
|
|
value={"error": "Invalid file type.", "status": False},
|
|
),
|
|
],
|
|
)
|
|
|
|
ASSET_CONFLICT_RESPONSE = OpenApiResponse(
|
|
description="Asset with same external ID already exists",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Duplicate external asset",
|
|
value={
|
|
"message": "Asset with same external id and source already exists",
|
|
"asset_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"asset_url": "https://cdn.example.com/existing-file.pdf",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
ASSET_DOWNLOAD_SUCCESS_RESPONSE = OpenApiResponse(
|
|
description="Presigned download URL generated successfully",
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Asset Download Response",
|
|
value={
|
|
"asset_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url",
|
|
"asset_name": "document.pdf",
|
|
"asset_type": "application/pdf",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
ASSET_DOWNLOAD_ERROR_RESPONSE = OpenApiResponse(
|
|
description="Bad request",
|
|
examples=[
|
|
OpenApiExample(name="Asset not uploaded", value={"error": "Asset not yet uploaded"}),
|
|
],
|
|
)
|
|
|
|
ASSET_UPDATED_RESPONSE = OpenApiResponse(description="Asset updated successfully")
|
|
|
|
ASSET_DELETED_RESPONSE = OpenApiResponse(description="Asset deleted successfully")
|
|
|
|
ASSET_NOT_FOUND_RESPONSE = OpenApiResponse(
|
|
description="Asset not found",
|
|
examples=[OpenApiExample(name="Asset not found", value={"error": "Asset not found"})],
|
|
)
|