搜尋完整文件

輸入關鍵字,例如 idempotency、proposal lifecycle 或權限。

選擇 開啟
English
瀏覽文件

Relay · Attachments

上傳並綁定 attachment

Relay 不讓 conversation API 承載 file byte。Client 先向 Relay 取得短效 upload URL,再把 byte 直接傳到 object storage,最後在傳送 message 時綁定 attachment ID。

request upload URL
→ PUT bytes to object storage
→ send message with attachment_id
→ Relay verifies the object and binds it atomically
→ request a fresh download URL when needed

Relay 目前透過 HTTP 提供這套流程。Public SDK 尚未提供 channel.upload(file)

1. 取得 upload URL

Caller 必須是 target channel 的 member:

POST /api/uploads/presign
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "channel_id": "channel-uuid",
  "original_filename": "photo.png",
  "declared_content_type": "image/png"
}

Request 成功時會建立 tenant-scoped pending attachment,並回傳 201 Created

{
  "attachment_id": "attachment-uuid",
  "upload_url": "https://storage.example/presigned-put-url",
  "expires_at": "2026-07-14T12:05:00Z",
  "key": "tenant_uuid/channel_uuid/storage-uuid"
}

Upload URL 預設有效五分鐘。key 是 server 產生的 storage path。Client 應使用 attachment_id 作為 Relay reference,不應保存或自行建立 storage key。

declared_content_type 只提供參考。Storage upload 可以帶上這個值,但 Relay 不會檢查 file byte 以證明 MIME type。

Filename contract

Relay 會 trim 前後的 ASCII space,並把通過檢查的 filename normalize 成 Unicode NFC。符合以下任一條件時,回傳 422{"error":"invalid_filename"}

  • Trim 後是 empty string
  • 超過 255 UTF-8 bytes
  • 包含 /\\、ASCII 或 Unicode control character、invisible format character,或 non-ASCII whitespace
  • . 開頭
  • 使用 Windows reserved device name,例如 CONNULCOM1LPT1

Original filename 只作為 metadata 保存,不會成為 object-storage key。

2. 上傳 file byte

直接把 file 上傳到回傳的 URL:

const upload = await fetch(uploadUrl, {
  method: "PUT",
  headers: {
    "Content-Type": file.type || "application/octet-stream"
  },
  body: file
});

if (!upload.ok) throw new Error(`upload failed: ${upload.status}`);

完成 PUT 不代表 attachment 已發布。Message 綁定前,row 仍維持 pending

不能把 client declared 或 uploaded Content-Type 當成可信的 content detection。目前的 contract 沒有 byte sniffing、file type allowlist、malware scan、quarantine step,也沒有強制 object size 上限。

3. 綁定 attachment 與 message

透過一般 message write path 傳入最多 20 個 attachment ID:

POST /api/channels/{channel_id}/messages
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "body": "Here is the file",
  "nonce": "client-generated-uuid",
  "attachment_ids": ["attachment-uuid"]
}

Relay 會逐一驗證:

  • ID 存在於同一 tenant
  • Pending attachment 由 message sender 建立
  • Attachment 屬於同一 channel
  • Status 仍是 pending
  • Object 已存在於 storage

Relay 會在這一步讀取 object size、Content-Type metadata 與 ETag。所有 attachment 都在 message 的同一個 database transaction 內完成綁定。任一 attachment 失敗時,message 與所有 binding 會一起 rollback。

Attachment 是 single-use。成為 bound 後,不能再綁定另一則 message。

Message response

REST、WebSocket 與 webhook message payload 都包含 bound attachment metadata:

{
  "data": {
    "id": "message-uuid",
    "nonce": "client-generated-uuid",
    "seq": 42,
    "attachments": [
      {
        "id": "attachment-uuid",
        "content_type": "image/png",
        "size_bytes": 1024,
        "original_filename": "photo.png",
        "bound_at": "2026-07-14T12:01:00Z"
      }
    ]
  }
}

Payload 不包含 storage key 或 long-lived download URL。

Retry uncertain send

Message delivery timeout 時,以相同 nonce 與相同 attachment ID retry。如果第一次 send 已 commit,Relay 會回傳 original message。

不要只因 message response 遺失就申請第二個 upload URL。使用 existing message nonce 引用的新 attachment 不會綁定 original message,並會保持 pending,直到 orphan cleanup。

4. 取得 download URL

目前的 channel member 可以為 bound attachment 取得 fresh URL:

GET /api/attachments/{attachment_id}/url
Authorization: Bearer <jwt>
{
  "url": "https://storage.example/presigned-get-url",
  "expires_at": "2026-07-14T12:10:00Z"
}

URL 預設有效五分鐘,並透過 Content-Disposition: attachment 強制下載。PNG、JPEG、GIF 與 WebP 保留 image MIME type;其他所有 type,包括 SVG,都以 application/octet-stream 提供。

Relay 會在 Content-Disposition 同時提供 ASCII fallback 與 UTF-8 filename,因此 client 應使用 response header,不要從 URL 重建 filename。

Download error

StatusError意義
403forbiddenCaller 不是目前的 channel member
404attachment_not_foundAttachment 不存在,或屬於另一個 tenant
409attachment_not_boundUpload 存在,但尚未綁定 message
410attachment_tombstonedDeletion 已開始,attachment 不再可用
429rate_limitedDownload URL budget 已耗盡

Cleanup 與 deletion

Pending attachment 超過一小時後,符合 orphan cleanup 條件。Sweeper 每 15 分鐘執行一次,並使用 bounded batch,所以一小時是 eligibility threshold,不是精確的 deletion time。

Admin message deletion 會 tombstone attachment,並排程刪除 object。Admin user deletion 也會對該 user 上傳的 attachment 執行相同行為。Storage deletion 是 asynchronous,transient failure 會 retry。

Retract message 不會刪除 attachment。Caller 仍是 channel member 時,attachment 會維持 bound 且可下載。目前沒有獨立於 message 的 public attachment delete endpoint。

Product boundary

Direct upload、tenant 與 channel scoping、single-use transactional binding、short-lived download、orphan cleanup 與 asynchronous deletion 已實作。

Maximum file size、trusted content detection、MIME policy、malware scanning、quarantine、independent attachment deletion、resumable upload、upload progress 與 SDK upload helper 仍屬於 product work。處理 untrusted file 的 application 必須自行驗證與掃描,再讓 user 取得這些檔案。

下一步:搜尋 channel