bb-plane-fork/apps/api/plane/utils/openapi/responses.py
binarybeach 9fb1ad44cd binarybeachio: presigned PUT for uploads (R2/B2 don't implement PostObject)
== 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>
2026-04-30 17:56:52 -10:00

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"})],
)