Attachment upload fails permanently when S3 CopyObjectCommand fails cross-bucket (no fallback to download+upload)

(#868) Bug Needs triage media self-hosting

Observed behaviour

Environment
  • Self-hosted, official (latest) Docker image
  • S3-compatible storage: Hetzner Object Storage (Ceph RGW, region nbg1)
  • FLUXER_S3_FORCE_PATH_STYLE=false
Expected result The attachment upload succeeds and the message is sent, regardless of the underlying S3-compatible provider's cross-bucket copy support. Actual result StorageService.copyObjectWithMetadataStripping calls copyObject (a CopyObjectCommand), which fails against our provider whenever source and destination buckets differ, even though same-bucket copies work fine. This isn't a Fluxer misconfiguration — I've isolated it with the AWS CLI directly against our provider (Hetzner Object Storage), bypassing Fluxer entirely: identical CopyObjectCommand succeeds within one bucket, but returns 404 NoSuchKey across buckets, despite same region, same owner, identical ACLs, and the source object confirmed to exist via head-object immediately before. The same code path is triggered whenever processMediaFile fails too (e.g. certain HEIC files that libheif can't decode) — the code tries to fall back to copyObject when processing fails, but that fallback uses the same cross-bucket CopyObjectCommand, so it fails the same way instead of gracefully preserving the original file. Relevant code: fluxer_api/src/api/infrastructure/StorageService.ts, copyObjectWithMetadataStripping (~line 487) and copyObject (~line 459). Suggested fix When the S3 CopyObjectCommand fails, fall back to a plain download-then-upload (writeObjectToDisk + uploadObjectFromFile) instead of failing the whole attachment/message. This would make Fluxer resilient to S3-compatible providers with partial CopyObjectCommand support, which isn't uncommon outside AWS itself.

Reproduction steps

Steps to reproduce
  1. Configure FLUXER_S3_BUCKET_UPLOADS and FLUXER_S3_BUCKET_CDN as two different buckets (this is the documented/expected setup).
  2. Send a message with a non-image attachment (e.g. a PDF) in a DM or channel.
  3. Observe the request fails with a 500 error and the message is never created.

Build information

Canary Desktop 2026.925.203704, Web 2026.925.203709, macOS 27.0.0 (arm64), Electron 44.4.1, Chrome 152.0.7977.78, Node 24.21.0, Locale en-US

Platform

Self-hosting

Evidence

Logs
media-proxy-1 | {"timestamp":"2026-09-25T22:22:37.722550Z","level":"INFO","fields":{"message":"request","req":"FG6T9DNEBWGY","kind":"metadata","method":"POST","path":"/_metadata","query":"","status":200,"duration_ms":380,"fetch_ms":0,"transform_ms":0,"nsfw_ms":0},"target":"fluxer_media_proxy::request_log"}

api-1 | {"level":"error","time":"2026-09-25T22:22:37.957Z","service":"fluxer-api","env":"production","error":{"type":"Error","message":"/tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 313018\n/tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 312999\n/tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 312989\n/tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 312987\nheif: Decoder plugin generated an error: Unspecified (7.0)","stack":"Error: /tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 313018\n/tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 312999\n/tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 312989\n/tmp/080e6eb507cbd8ba5460aa2229aa544e: bad seek to 312987\nheif: Decoder plugin generated an error: Unspecified (7.0)\n    at Sharp.toFile (file:///usr/src/app/fluxer_api/node_modules/.pnpm/sharp@0.35.4_@types+node@26.6.1/node_modules/sharp/dist/output.mjs:90:19)\n    at stripNonJpegImageFileToFile (/usr/src/app/fluxer_api/src/api/infrastructure/StorageObjectHelpers.ts:509:23)\n    at async processMediaFile (/usr/src/app/fluxer_api/src/api/infrastructure/StorageObjectHelpers.ts:545:12)\n    at async StorageService.copyObjectWithMetadataStripping (/usr/src/app/fluxer_api/src/api/infrastructure/StorageService.ts:519:22)\n    at async worker (/usr/src/app/fluxer_api/src/api/utils/ConcurrencyUtils.ts:47:22)\n    at async Promise.all (index 0)\n    at async mapWithConcurrency (/usr/src/app/fluxer_api/src/api/utils/ConcurrencyUtils.ts:53:2)\n    at async AttachmentProcessingService.computeAttachments (/usr/src/app/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts:119:7)\n    at async Promise.all (index 1)\n    at async MessagePersistenceService.createMessage (/usr/src/app/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts:173:67)"},"contentType":"image/heic","sourceBucket":"site-thewhole-chat-uploads","sourceKey":"56918aeb-f898-4cab-9150-2baa76f61902","msg":"Failed to strip attachment media metadata"}

api-1 | {"level":"error","time":"2026-09-25T22:22:38.082Z","service":"errors","env":"production","err":{"type":"NoSuchKey","message":"UnknownError","stack":"NoSuchKey: UnknownError\n    at S3RestXmlProtocol.handleError (/usr/src/app/fluxer_api/node_modules/.pnpm/@aws-sdk+core@3.978.0/node_modules/@aws-sdk/core/dist-cjs/submodules/protocols/index.js:2834:27)\n    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n    at async S3RestXmlProtocol.deserializeResponse (/usr/src/app/fluxer_api/node_modules/.pnpm/@smithy+core@3.34.1/node_modules/@smithy/core/dist-cjs/submodules/protocols/index.js:365:13)\n    at async /usr/src/app/fluxer_api/node_modules/.pnpm/@smithy+core@3.34.1/node_modules/@smithy/core/dist-cjs/submodules/schema/index.js:23:24\n    at async /usr/src/app/fluxer_api/node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.76/node_modules/@aws-sdk/middleware-sdk-s3/dist-cjs/submodules/s3/index.js:365:20\n    at async /usr/src/app/fluxer_api/node_modules/.pnpm/@smithy+core@3.34.1/node_modules/@smithy/core/dist-cjs/submodules/retry/index.js:175:50\n    at async /usr/src/app/fluxer_api/node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.76/node_modules/@aws-sdk/middleware-sdk-s3/dist-cjs/submodules/s3/index.js:62:28\n    at async /usr/src/app/fluxer_api/node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.76/node_modules/@aws-sdk/middleware-sdk-s3/dist-cjs/submodules/s3/index.js:89:20\n    at async /usr/src/app/fluxer_api/node_modules/.pnpm/@aws-sdk+core@3.978.0/node_modules/@aws-sdk/core/dist-cjs/submodules/client/index.js:125:26\n    at async StorageService.copyObject (/usr/src/app/fluxer_api/src/api/infrastructure/StorageService.ts:476:3)","$fault":"client","$metadata":{"httpStatusCode":404,"requestId":"tx0000003e13fcf62c3a476-006ab6f42e-9082cead-nbg1-prod1-ceph3","attempts":1,"totalRetryDelay":0},"name":"NoSuchKey","Code":"NoSuchKey","BucketName":"site-thewhole-chat","RequestId":"tx0000003e13fcf62c3a476-006ab6f42e-9082cead-nbg1-prod1-ceph3","HostId":"9082cead-nbg1-prod1-ceph3-nbg1"},"status":500,"method":"POST","path":"/v1/channels/1517780557365248000/messages","requestId":"248c5ec5-aad2-4212-8fd2-422bb13d4b89","msg":"Unhandled error occurred"}

api-1 | {"level":"info","time":"2026-09-25T22:22:38.082Z","service":"fluxer-api","env":"production","method":"POST","path":"/v1/channels/1517780557365248000/messages","status":500,"durationMs":955,"msg":"Request completed"}
AWS CLI reproduction (isolating the issue outside Fluxer)
  1. Confirm the source object exists:
    aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api head-object \
      --bucket site-thewhole-chat-uploads \
      --key 0401030e-51c3-4d7b-84c6-13b9e8f04964
    
Result: object exists (ContentLength: 1785313, ContentType: application/pdf, LastModified: 2026-09-25T14:43:07+00:00).
  1. Copy within the same bucket — succeeds:
    aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api copy-object \
      --copy-source "site-thewhole-chat-uploads/0401030e-51c3-4d7b-84c6-13b9e8f04964" \
      --bucket site-thewhole-chat-uploads \
      --key test-copy-same-bucket
    
Result: 200 OK, returns valid CopyObjectResult with ETag and LastModified.
  1. Copy to a different bucket in the same account — fails:
    aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api copy-object \
      --copy-source "site-thewhole-chat-uploads/0401030e-51c3-4d7b-84c6-13b9e8f04964" \
      --bucket site-thewhole-chat \
      --key test-copy-cross-bucket
    
Result: 404 NoSuchKey
<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchKey</Code><Message></Message><BucketName>site-thewhole-chat</BucketName><RequestId>tx000003f018ce2018b2340-006ab76535-90848504-nbg1-prod1-ceph3</RequestId><HostId>90848504-nbg1-prod1-ceph3-nbg1</HostId></Error>
  1. Same test repeated with a leading slash in the copy source — identical result:
    aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api copy-object \
      --copy-source "/site-thewhole-chat-uploads/0401030e-51c3-4d7b-84c6-13b9e8f04964" \
      --bucket site-thewhole-chat \
      --key test-copy-cross-bucket-slash
    
Result: same 404 NoSuchKey.
  1. Bucket location and ACL checks (ruling out region/permission mismatch):
    aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api get-bucket-location --bucket site-thewhole-chat-uploads
    aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api get-bucket-location --bucket site-thewhole-chat
    
Both return {"LocationConstraint": "nbg1"}.
aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api get-bucket-acl --bucket site-thewhole-chat-uploads
aws --region nbg1 --endpoint-url https://nbg1.your-objectstorage.com s3api get-bucket-acl --bucket site-thewhole-chat
Both return identical ACLs — FULL_CONTROL for the same owner (p11327098), no public grants on either bucket. Environment: aws-cli 2.37.4, Ubuntu 26 (host OS), endpoint https://nbg1.your-objectstorage.com/, both buckets confirmed to exist under the same account via list-buckets.

1 comment

Sign in with Fluxer to comment and vote.
Comment by Hampus
HampusStaff 1 vote originally by @hampus-fluxer on GitHub
This should be fixed via #3147, pretty much as you suggested. Feel free to retest and close the issue if you can confirm it's fixed!