Files
multica/server/internal/cli/client.go
Naiyuan Qing a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* feat(chat): support images/files in agent chat replies (MUL-4287)

Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.

Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
  handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
  this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
  the assistant message via BindChatAttachmentsToMessage (rejects rows already
  owned by an issue/comment/chat_message). An empty-output reply that produced
  files still creates a message so the images have an owner. FailTask binds
  nothing.

CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
  (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
  ready-to-paste markdown snippet.

Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.

Mobile:
- chat:done handler now always invalidates the messages list so attachments
  (absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
  CommentAttachmentList (dedup vs inline references), matching web.

Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.

Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review blockers on chat reply attachments (MUL-4287)

Two final-review blockers on PR #5164:

1. Mobile inline dedup only checked raw `url`, so an attachment referenced
   inline via `markdown_url` (exactly what the CLI snippet emits) rendered
   twice — once inline, once as a standalone card. Reuse the core
   `contentReferencesAttachment` helper so dedup covers every real reference
   form (stable /api/attachments/<id>/download path, url, download_url,
   markdown_url), matching web's AttachmentList. Extracted the filter into a
   pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
   regression test covering `content` containing `attachment.markdown_url`
   (plus the other URL forms and same-identity sibling dedup).

2. CLI `attachment upload` emitted `![...]` image markdown for every file,
   producing a broken-image snippet for non-images. Emit image markdown only
   for image/* content types and a plain link otherwise, with a CLI contract
   test for both.

Approved scope otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)

Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): render agent-produced files as attachment cards, not raw links

The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.

Return the block-level `!file[name](url)` card syntax instead (images keep
`![name](url)` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): soften message-list scroll fade (32px → 16px)

The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): renumber attachment_task_id migration 157 -> 158

main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): pin attachment upload to the token's task; build index concurrently

Two code-review findings on the chat-attachment path (MUL-4287):

- Isolation/privacy: POST /api/upload-file only checked the form task_id
  belonged to the caller's agent, not that it matched the task-scoped token's
  authoritative X-Task-ID. A run authorized for task A could tag an attachment
  onto task B (another chat task of the same agent, possibly another user's
  session), binding it into that reply on completion. Require the form task_id
  to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.

- Migration: split the task_id lookup index into its own migration (159) built
  with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
  multi-command file with the ADD COLUMN in 158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)

Addresses the two remaining Preflight BLOCKERs on PR #5164.

Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.

Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)

Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.

- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
  output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
  matched — the line stayed literal AND `standaloneAttachments` still hid the
  fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
  allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
  restricted to the same allowlist (site-relative /uploads + /api/attachments/
  <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
  would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
  by the renderer — unlike web's HTML data-filename), so a raw `]` never
  truncates the link text.

No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 09:15:36 +08:00

722 lines
22 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// ClientVersion is the CLI version sent on every request as X-Client-Version.
// Set by the multica binary at init() so the package doesn't depend on the
// concrete cmd package. Defaults to "dev" when running unset (e.g. tests).
var ClientVersion = "dev"
// ClientPlatform identifies this client to the server. Override for tests
// or alternative entry points; defaults to "cli".
var ClientPlatform = "cli"
// ClientOS is the normalized operating system string sent as X-Client-OS.
// Computed once from runtime.GOOS so the server doesn't need to reverse-map
// Go's os names ("darwin"/"windows"/"linux") into the protocol vocabulary.
var ClientOS = normalizeGOOS(runtime.GOOS)
func normalizeGOOS(goos string) string {
switch goos {
case "darwin":
return "macos"
case "windows":
return "windows"
case "linux":
return "linux"
default:
return goos
}
}
// APIClient is a REST client for the Multica server API.
// Used by ctrl subcommands (agent, runtime, status, etc.). Requests
// automatically include auth and execution context headers when configured.
type APIClient struct {
BaseURL string
WorkspaceID string
Token string
AgentID string // When set, requests are attributed to this agent instead of the user.
TaskID string // When set, sent as X-Task-ID for agent-task validation.
HTTPClient *http.Client
// Identity overrides. Empty values fall back to the package-level
// ClientPlatform / ClientVersion / ClientOS.
Platform string
Version string
OS string
}
type HTTPError struct {
Method string
Path string
StatusCode int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, strings.TrimSpace(e.Body))
}
// newHTTPError builds a *HTTPError from an error response (status >= 400),
// reading a capped slice of the body. Every Multica API helper funnels its
// >= 400 responses through this so the top-level FormatError / ExitCodeFor can
// classify the failure via errors.As(err, **HTTPError) regardless of which
// HTTP verb the command used.
func newHTTPError(method, path string, resp *http.Response) *HTTPError {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &HTTPError{
Method: method,
Path: path,
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
// defaultHTTPTimeout is the per-request timeout for the CLI's HTTP client.
// It can be overridden with the MULTICA_HTTP_TIMEOUT environment variable
// (see httpTimeout). 30s is chosen over the historical 15s because complex
// networks (notably in mainland China) routinely need more than 15s to
// complete the TLS handshake plus request round-trip, which surfaced as an
// opaque "context deadline exceeded" to users.
const defaultHTTPTimeout = 30 * time.Second
// httpTimeout returns the HTTP client timeout, honoring MULTICA_HTTP_TIMEOUT.
// The value may be a Go duration string ("45s", "2m") or a plain integer
// number of seconds ("45"). Invalid or non-positive values fall back to the
// default.
func httpTimeout() time.Duration {
v := strings.TrimSpace(os.Getenv("MULTICA_HTTP_TIMEOUT"))
if v == "" {
return defaultHTTPTimeout
}
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return defaultHTTPTimeout
}
// apiContextGrace is added on top of the HTTP transport timeout when deriving
// a command-level context deadline, so the transport timeout (which produces a
// clean, classifiable "request timed out" error) is the one that fires rather
// than the outer context being canceled first.
const apiContextGrace = 5 * time.Second
// APITimeout returns the deadline budget for a single CLI API command. It is
// always at least the configured HTTP transport timeout (see httpTimeout,
// which honors MULTICA_HTTP_TIMEOUT) plus a small grace margin, so a
// command-level context never truncates an in-flight request below the timeout
// the user configured. This is the fix for command contexts that previously
// hardcoded a 15s deadline shorter than the 30s/env transport timeout.
func APITimeout() time.Duration {
return AtLeastAPITimeout(0)
}
// AtLeastAPITimeout returns max(min, APITimeout()). Use it for commands that
// need a larger floor than usual (for example file uploads, which historically
// used a 60s budget).
func AtLeastAPITimeout(min time.Duration) time.Duration {
budget := httpTimeout() + apiContextGrace
if min > budget {
return min
}
return budget
}
// APIContext derives a command-scoped context whose deadline is APITimeout().
// The returned cancel func must be called (typically via defer) to release
// resources. Commands should use this instead of context.WithTimeout with a
// hardcoded duration so the deadline always respects MULTICA_HTTP_TIMEOUT.
func APIContext(parent context.Context) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, APITimeout())
}
// NewAPIClient creates a new API client for ctrl commands.
func NewAPIClient(baseURL, workspaceID, token string) *APIClient {
return &APIClient{
BaseURL: strings.TrimRight(baseURL, "/"),
WorkspaceID: workspaceID,
Token: token,
HTTPClient: &http.Client{Timeout: httpTimeout()},
}
}
func (c *APIClient) setHeaders(req *http.Request) {
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
if c.AgentID != "" {
req.Header.Set("X-Agent-ID", c.AgentID)
}
if c.TaskID != "" {
req.Header.Set("X-Task-ID", c.TaskID)
}
platform := c.Platform
if platform == "" {
platform = ClientPlatform
}
if platform != "" {
req.Header.Set("X-Client-Platform", platform)
}
version := c.Version
if version == "" {
version = ClientVersion
}
if version != "" {
req.Header.Set("X-Client-Version", version)
}
osName := c.OS
if osName == "" {
osName = ClientOS
}
if osName != "" {
req.Header.Set("X-Client-OS", osName)
}
}
// GetJSON performs a GET request and decodes the JSON response.
//
// On an HTTP error response (status >= 400) the returned error is a
// *HTTPError so callers can use errors.As to inspect the status code
// (for example to recognize a 404 from a server that does not expose a
// given endpoint and degrade gracefully). The error string format
// ("GET <path> returned <code>: <body>") is preserved by HTTPError.Error().
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodGet, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// GetJSONWithHeaders performs a GET request, decodes the JSON response, and
// returns the response headers. Useful when callers need header values like
// X-Total-Count for pagination.
func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, newHTTPError(http.MethodGet, path, resp)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp.Header, err
}
}
return resp.Header, nil
}
// DeleteJSON performs a DELETE request.
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
return c.DeleteJSONResponse(ctx, path, nil)
}
// DeleteJSONResponse performs a DELETE request and optionally decodes the JSON response.
func (c *APIClient) DeleteJSONResponse(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
if err != nil {
return err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodDelete, path, resp)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// DeleteJSONWithBody performs a DELETE request with a JSON body.
func (c *APIClient) DeleteJSONWithBody(ctx context.Context, path string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodDelete, path, resp)
}
return nil
}
// PostJSON performs a POST request with a JSON body.
func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPost, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// PutJSON performs a PUT request with a JSON body.
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPut, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// PatchJSON performs a PATCH request with a JSON body.
func (c *APIClient) PatchJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPatch, path, resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// AttachmentResponse mirrors the server's upload-file response.
type AttachmentResponse struct {
ID string `json:"id"`
URL string `json:"url"`
DownloadURL string `json:"download_url"`
// MarkdownURL is the durable, persistable URL to embed in markdown bodies
// (chat replies, comments). Unlike DownloadURL it never carries a TTL.
MarkdownURL string `json:"markdown_url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
// UploadFile uploads a file via multipart form to /api/upload-file.
// It returns the attachment ID from the server response.
func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return "", fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return "", fmt.Errorf("write file data: %w", err)
}
if issueID != "" {
if err := writer.WriteField("issue_id", issueID); err != nil {
return "", fmt.Errorf("write issue_id field: %w", err)
}
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode upload response: %w", err)
}
id, _ := result["id"].(string)
if id == "" {
return "", fmt.Errorf("upload response missing attachment id")
}
return id, nil
}
// UploadChatAttachment uploads a file via multipart form to /api/upload-file
// tagged with a chat task (task_id). The server binds the row to the assistant
// reply that task produces on completion. Returns the full AttachmentResponse
// (id + markdown_url) so the agent can embed the image inline in its reply.
func (c *APIClient) UploadChatAttachment(ctx context.Context, fileData []byte, filename, taskID string) (AttachmentResponse, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return AttachmentResponse{}, fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return AttachmentResponse{}, fmt.Errorf("write file data: %w", err)
}
if taskID != "" {
if err := writer.WriteField("task_id", taskID); err != nil {
return AttachmentResponse{}, fmt.Errorf("write task_id field: %w", err)
}
}
if err := writer.Close(); err != nil {
return AttachmentResponse{}, fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
if err != nil {
return AttachmentResponse{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
// Honor a longer context deadline for large images, same as UploadFileWithURL.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return AttachmentResponse{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return AttachmentResponse{}, newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
var result AttachmentResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return AttachmentResponse{}, fmt.Errorf("decode upload response: %w", err)
}
if result.ID == "" {
return AttachmentResponse{}, fmt.Errorf("upload response missing attachment id")
}
return result, nil
}
// UploadFileWithURL uploads a file via multipart form to /api/upload-file
// without associating it with an issue or comment. It decodes the full
// AttachmentResponse and returns the attachment ID and URL.
func (c *APIClient) UploadFileWithURL(ctx context.Context, fileData []byte, filename string) (string, string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return "", "", fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return "", "", fmt.Errorf("write file data: %w", err)
}
if err := writer.Close(); err != nil {
return "", "", fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
// Use a client that respects the context deadline for slow uploads
// (e.g. avatar uploads with 5MB files). The default HTTP client timeout
// shadows any longer context deadline.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
var result AttachmentResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", fmt.Errorf("decode upload response: %w", err)
}
if result.URL == "" {
return "", "", fmt.Errorf("upload response missing attachment url")
}
// Allow empty ID: the server returns id="" in the fallback path where
// S3 upload succeeded but the attachment DB record failed. The file
// is still usable via its URL.
return result.ID, result.URL, nil
}
// ImportSkillFile imports a skill from a local archive (.skill / .zip) by
// POSTing it as multipart/form-data to /api/skills/import, alongside the
// on_conflict strategy. The structured import result is decoded into out.
func (c *APIClient) ImportSkillFile(ctx context.Context, fileData []byte, filename, onConflict string, out any) error {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return fmt.Errorf("write file data: %w", err)
}
if onConflict != "" {
if err := writer.WriteField("on_conflict", onConflict); err != nil {
return fmt.Errorf("write on_conflict field: %w", err)
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/skills/import", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
// Respect a longer context deadline for slow uploads, mirroring
// UploadFileWithURL: the default client timeout would otherwise shadow it.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPost, "/api/skills/import", resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// DownloadFile downloads a file from the given URL and returns the response body.
// This is used for downloading attachments via their signed download_url.
// Downloads are limited to 100 MB to match the upload size limit.
//
// The URL may be absolute (a signed CloudFront/S3 URL) or relative
// (a server-relative path like "/api/attachments/{id}/download" or
// "/uploads/...") depending on how the
// server is configured. Relative URLs are resolved against the client's
// BaseURL and sent with the standard auth headers; absolute URLs are
// used as-is so that their query-string signatures are not disturbed.
func (c *APIClient) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
isRelative := !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://")
if isRelative {
if c.BaseURL == "" {
return nil, fmt.Errorf("download URL %q is relative but client has no BaseURL", downloadURL)
}
downloadURL = c.BaseURL + downloadURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
if isRelative {
c.setHeaders(req)
}
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, newHTTPError(http.MethodGet, downloadURL, resp)
}
const maxDownloadSize = 100 << 20 // 100 MB
return io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize))
}
// HealthCheck hits the /health endpoint and returns the response body.
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
if err != nil {
return "", err
}
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 400 {
return "", &HTTPError{
Method: http.MethodGet,
Path: "/health",
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
return strings.TrimSpace(string(data)), nil
}