Skip to content

feat(api): add resource_urls=public so API responses return directly loadable file URLs - #51

Draft
lyingbug wants to merge 5 commits into
mainfrom
cursor/public-resource-urls-in-api-51d5
Draft

feat(api): add resource_urls=public so API responses return directly loadable file URLs#51
lyingbug wants to merge 5 commits into
mainfrom
cursor/public-resource-urls-in-api-51d5

Conversation

@lyingbug

@lyingbug lyingbug commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Description

API responses reference stored files as opaque resource://<handle> values, e.g. ![示意图](resource://xifDo7NTSL300Lp1goVutw). A browser can't load that, so an integrating app has to make a second authenticated call to GET /files?file_path=… for every image before it can render anything.

This adds an opt-in that resolves those references server-side into time-limited HTTP(S) URLs, reusing the exact mechanism the IM channels already rely on (FileService.GetFileURL → object-store presigned URL, or APP_EXTERNAL_URL + /r/<token>).

Knob Usage Scope
Request parameter ?resource_urls=public that request only
Deployment default RESOURCE_URL_MODE=public requests that omit the parameter

resource_urls accepts handle (default, unchanged) or public; anything else returns 400. An explicit parameter always beats the deployment default, so ?resource_urls=handle still opts back out.

Endpoints: POST /knowledge-chat/{session_id}, POST /agent-chat/{session_id}, GET /sessions/continue-stream/{session_id}, GET /messages/{session_id}/load, POST /knowledge-search.

Rewriting covers the answer body, knowledge_references (including image_info), agent steps and tool results, and message image attachments.

Design notes

  • internal/storageurl (new). The IM channels already did this translation, but the code was private to internal/im so nothing else could reuse it. The first commit moves the reference pattern, the HTTP-result guard, the tenant-aware backend resolver and the streaming holdback helpers into a shared package; internal/im now delegates with no behaviour change. The new Rewriter memoises resolutions, because each resource:// resolution writes an access-grant row and a streamed answer repeats the same image across many chunks.
  • Streaming safety. Answer/thinking/reflection events are deltas that clients accumulate, so a reference can straddle two events. A per-event-id holdback buffer retains an incomplete trailing reference until the next chunk completes it, and releases it before the complete marker. The buffer is bounded so a never-closed ![ can't buffer a whole answer.
  • No shared-state mutation. SSE payloads share *SearchResult pointers and metadata maps with the stream replay buffer and the assistant message being persisted, so those are rewritten as copies. The end-to-end test caught a real leak here: the references event carries its results twice, and the copy in data holds a typed []*SearchResult the first implementation didn't traverse.
  • Fails soft. When no HTTP URL can be produced (for example local storage with no APP_EXTERNAL_URL), the reference stays a handle so clients keep the /files fallback.
  • Embed channels excluded. Their visitors are anonymous and keep using the channel-scoped authenticated proxy.
  • No extra authorization gate, unlike /files which rejects KB-restricted API keys. That gate exists because /files takes an arbitrary caller-supplied path it cannot bind to a KB allow-list; here the server — not the client — chooses which resources get a URL, from a response the caller is already authorized to receive.

Security trade-off (documented)

public mode issues short-lived anonymously-readable URLs (WeKnora grants 2h, MinIO presigned 24h) for each referenced file. This is the same exposure the IM channels have always had, it is opt-in, and it is called out in both .env.example and docs/api/README.md.

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 📚 Documentation update
  • 🎨 Refactor
  • ⚡ Performance improvement
  • 🧪 Test
  • 🔧 Configuration / Build / CI

Not a breaking change: the default handle mode leaves every response byte-identical.

Related Issue

Addresses the request that image references be returned as directly loadable links instead of resource://, so integrating apps don't need a separate /files call.

Testing

Verification used the real gin handler and the real SSE writer, so the assertions are made against actual wire bytes.

Default mode is unchanged, public returns loadable URLs, bad values are rejected — captured from GET /api/v1/sessions/continue-stream/... driven through ContinueStream:

$ curl -N '.../continue-stream/sess1?message_id=msg1'   -> HTTP 200
  data:{"response_type":"answer","content":"The diagram ![fig](resource://xifDo7","done":false}
  data:{"response_type":"answer","content":"NTSL300Lp1goVutw) shows the flow.","done":false}
  data:{"response_type":"references",...,"content":"figure ![f](resource://xifDo7NTSL300Lp1goVutw)",...}
  data:{"response_type":"complete","content":"","done":true}

$ curl -N '.../continue-stream/sess1?message_id=msg1&resource_urls=public'   -> HTTP 200
  data:{"response_type":"answer","content":"The diagram ","done":false}
  data:{"response_type":"answer","content":"![fig](https://cdn.example.com/signed.png) shows the flow.","done":false}
  data:{"response_type":"references",...,"content":"figure ![f](https://cdn.example.com/signed.png)",...}
  data:{"response_type":"complete","content":"","done":true}

$ curl '...&resource_urls=signed'   -> HTTP 400
  {"error":{"code":1000,"message":"invalid resource_urls value \"signed\": expected \"handle\" or \"public\""},"success":false}

Note the handle was deliberately split across two answer deltas (resource://xifDo7 + NTSL300Lp1goVutw) and comes back as one intact Markdown image, which is what the holdback buffer is for.

Commands run:

go build ./...                                                  # clean
go vet ./internal/... ./cmd/...                                 # clean
gofmt -l <changed files>                                        # clean
git diff --check origin/main...HEAD                             # clean
golangci-lint run --new-from-rev=origin/main ./internal/...      # 0 issues
go test ./internal/... -count=1                                 # 64 packages ok, 0 failures

New tests: internal/storageurl/{storageurl,stream,request}_test.go, internal/handler/message_resource_urls_test.go, internal/handler/session/{resource_urls,continue_stream_resource_urls}_test.go. They cover every reference form, memoisation, the no-op paths (already-HTTP, resolve failure, unknown backend, non-HTTP result), split-reference holdback and its bound, stream-key independence, copy-not-mutate for references and metadata, mode precedence, and the 400.

go test ./client/... ./cli/... fails with directory prefix client does not contain main module on origin/main too — those are separate Go modules, not a regression. gofmt -l also reports pre-existing drift in files this PR does not touch (internal/handler/dto/auth.go, internal/handler/session/attachment_processor.go, and others).

Checklist

  • git diff --check origin/main...HEAD passes
  • Changed source files are formatted
  • Targeted tests for the changed packages/components pass
  • Diff-scoped lint passes where applicable (for Go: golangci-lint run --new-from-rev=origin/main ./...)
  • Full-repository checks were run, or any unrelated/environment-dependent failures are documented above
  • Self-reviewed the code
  • Added/updated tests covering the change
  • Updated related documentation (README, docs/, Swagger annotations, etc.)
  • Breaking changes are clearly called out in the description above

Docs updated: new "文件与图片引用" section in docs/api/README.md, pointers from chat.md / message.md / knowledge-search.md, RESOURCE_URL_MODE in .env.example, and Swagger annotations on all five endpoints. The generated docs/{docs.go,swagger.json,swagger.yaml} were updated for these parameters only — the committed output has unrelated drift from a stale checkout, and a full make docs regeneration was deliberately left out of this diff.

Screenshots / Recordings

Not applicable — backend-only change with no UI surface. The SSE wire output above is the user-visible artifact.

Open in Web Open in Cursor 

lyingbug and others added 5 commits August 4, 2026 09:04
…nal/storageurl

The IM channels rewrite resource:// and provider:// references into loadable
HTTP URLs because IM clients cannot attach WeKnora credentials to an image
fetch. That logic was private to internal/im, so no other surface could reuse
it.

Move the reference pattern, HTTP-result guard, tenant-aware storage backend
resolver and streaming holdback helpers into internal/storageurl, and add a
Rewriter that memoises resolutions (a resource:// handle costs one access-grant
row per resolution). internal/im now delegates; behaviour is unchanged.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
…URLs

API responses reference stored files as opaque resource:// handles, so an
integrating app had to make a second authenticated call to /files for every
image before it could render anything.

Add an opt-in that resolves those references server-side into time-limited
HTTP(S) URLs, using the same mechanism the IM channels already rely on:

  - per request: ?resource_urls=public (default: handle, unchanged)
  - per deployment: RESOURCE_URL_MODE=public

Applied to the chat SSE endpoints (knowledge-chat, agent-chat,
continue-stream), message history load, and knowledge-search. Streamed answers
buffer a trailing incomplete reference so a handle split across two deltas is
still rewritten. References that cannot become an HTTP URL (for example local
storage with no APP_EXTERNAL_URL) stay handles, so clients keep the /files
fallback. Embed channels are deliberately excluded: their visitors are
anonymous.

SSE payloads share SearchResult pointers and metadata maps with the stream
replay buffer and the message being persisted, so those are rewritten as copies.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
…nd SSE wire

Includes an end-to-end ContinueStream test that asserts the full SSE wire
output. It caught a leak: the references event carries its results twice, and
the copy in Data holds the typed []*SearchResult that CopyData did not
traverse, so handles reached a caller that asked for public URLs. CopyData now
handles the typed slice, []string and map[string]string forms.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Adds a cross-cutting "文件与图片引用" section to docs/api/README.md covering the
parameter, the deployment default, the endpoints it applies to, and the two
caveats that matter in practice: it needs APP_EXTERNAL_URL (or a publicly
reachable storage backend) to produce a link at all, and the links it produces
are time-limited but anonymously readable.

Also annotates the affected endpoints for Swagger and refreshes the generated
files for those parameters only, leaving unrelated drift in the committed
swagger output alone.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
In public resource URL mode, building the payload consumes the chunk into the
holdback buffer, so returning between build and write would drop it. Reusing
emitStreamEvent also removes the duplicated flush-before-completion logic.

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant