fix(im): render resource:// images in IM channels (nginx /r/ + config guards)

IM channels (DingTalk/Feishu) showed broken images because resource://
image references resolve to WeKnora capability links that were neither
reachable nor renderable:

- nginx never proxied /r/, so <APP_EXTERNAL_URL>/r/<token> links fell into
  the SPA fallback and returned a blank page. Add a /r/ proxy block
  mirroring /files, using the ${APP_SCHEME}/${APP_HOST}/${APP_PORT} vars.
- rewriteStorageURLs treated any "dst != src" as success and emitted
  non-HTTP results (e.g. an internal storage:// path when APP_EXTERNAL_URL
  is unset) that IM clients cannot fetch. Only substitute http(s) URLs;
  otherwise leave the reference unchanged and log an actionable WARN.
- Warn once at startup when IM channels are active but APP_EXTERNAL_URL is
  unset — the config state that silently breaks resource:// images on the
  default MinIO/local deployment.
- Document that IM knowledge-base images need an IM-reachable http URL via
  one of two paths: a publicly reachable storage backend, or APP_EXTERNAL_URL
  routing resource:// through nginx /r/.
This commit is contained in:
nullkey
2026-07-27 20:26:27 +08:00
committed by lyingbug
parent 400bbbf717
commit 4e3b512af6
7 changed files with 121 additions and 10 deletions
+7 -4
View File
@@ -79,10 +79,13 @@ FRONTEND_PORT=80
DOCREADER_ADDR=docreader:50051
# Docreader 连接方式:grpc / http / https。
DOCREADER_TRANSPORT=grpc
# 应用外部访问地址(影响 IM 渠道中 local 后端的图片/文件链)。
# 设为 WeKnora 实例「IM 平台/客户端公网可达」URL,如 https://weknora.example.com。
# 作用范围:local 后端必填,否则 IM 收到 local://... 无法渲染;
# MinIO/COS/OSS/S3/TOS/OBS 不读此变量,链接由各自 *_ENDPOINT 决定。
# 应用外部访问地址(影响 IM 渠道的图片/文件链)。
# 设为 WeKnora 实例「IM 平台/客户端公网可达」URL,如 https://weknora.example.com。
# IM 要显示知识库图片,需让 IM 端能通过公网 http URL 拿到图,二选一:
# (A) 存储后端本身公网可达(对象存储用公网 endpoint,或 MINIO_ENDPOINT 设为公网 host);
# 此时 resource:// 回退到后端预签名 URL,无需本变量。
# (B) 设本变量,resource:// 图片改写成 <APP_EXTERNAL_URL>/r/<token> 走 WeKnora(需 nginx 代理 /r/)。
# 默认 MinIO 部署(minio:9000 为内网)走 (B) 最简单;local 后端只能走 (B)。
# 内网 IP 和 localhost 对飞书/企微/Slack 等 IM 平台不可达;本地开发请用 ngrok/cloudflared/frp。
# APP_EXTERNAL_URL=
# 前端外部 origin,用于邀请链接等绝对 URL(留空走 host-relative)。
+3 -1
View File
@@ -86,7 +86,9 @@ services:
# - WEKNORA_TRUSTED_PROXIES=${WEKNORA_TRUSTED_PROXIES:-}
# 启动时自动执行数据库迁移;设为 false 禁用,默认启用
- AUTO_MIGRATE=${AUTO_MIGRATE:-true}
# 本地存储后端生成下载链接用的外部可达 URLIM 渠道 local 后端必填)
# IM 渠道图片/文件外链的外部可达 URLIM 显示知识库图片需二选一:
# 存储后端公网可达(如 MINIO_ENDPOINT 设为公网),或设本变量走 <APP_EXTERNAL_URL>/r/<token>(需 nginx 代理 /r/)。
# 默认 MinIO 内网部署 / local 后端需设本变量。
# - APP_EXTERNAL_URL=${APP_EXTERNAL_URL:-}
# 前端外部 origin,用于邀请链接等绝对 URL(留空走 host-relative
- FRONTEND_BASE_URL=${FRONTEND_BASE_URL:-}
+5
View File
@@ -46,6 +46,11 @@ IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道,所有配置
- 已创建至少一个 Agent(自定义智能体)
- Agent 已配置好模型和知识库
> **回复图片需公网可达**:IM 显示知识库图片需让 IM 端拿到公网 http 图片 URL,二选一——
> (A) 存储后端本身公网可达(对象存储公网 endpoint / `MINIO_ENDPOINT` 设为公网);
> (B) 配 `APP_EXTERNAL_URL`,图片走 WeKnora 的 `/r/` 短链(nginx 已内置 `/r/` 代理)。
> 默认 MinIO 部署走 (B) 最简单。详见 `.env.example` 中 `APP_EXTERNAL_URL` 说明。
### 企业微信接入
企业微信提供两种接入模式,根据你的应用类型选择:
+10
View File
@@ -102,6 +102,16 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# 资源能力短链 /r/<token>IM 渠道渲染 resource:// 图片依赖此路由,
# 缺此段会落进 SPA fallback 返回空白页,图片加载不出来。
location ^~ /r/ {
proxy_pass ${APP_SCHEME}://${APP_HOST}:${APP_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API请求代理到后端服务
# APP_SCHEME 默认 http,远程 HTTPS 后端可设为 https
location /api/ {
+27
View File
@@ -0,0 +1,27 @@
package im
import (
"testing"
"github.com/stretchr/testify/assert"
)
// imImageConfigWarning warns only when IM channels are active AND APP_EXTERNAL_URL
// is unset — the config state that silently breaks resource:// images in IM.
func TestIMImageConfigWarning(t *testing.T) {
t.Run("no channels -> silent", func(t *testing.T) {
assert.Empty(t, imImageConfigWarning(0, ""))
})
t.Run("external URL set -> silent", func(t *testing.T) {
assert.Empty(t, imImageConfigWarning(3, "https://weknora.example.com"))
})
t.Run("external URL whitespace-only -> warns", func(t *testing.T) {
assert.NotEmpty(t, imImageConfigWarning(1, " "))
})
t.Run("channels active but external URL empty -> warns with count", func(t *testing.T) {
msg := imImageConfigWarning(2, "")
assert.Contains(t, msg, "APP_EXTERNAL_URL")
assert.Contains(t, msg, "2 IM channel")
assert.Contains(t, msg, "/r/")
})
}
+30
View File
@@ -123,6 +123,36 @@ func TestRewriteStorageURLs_ScopedPath(t *testing.T) {
assert.Contains(t, output, "https://storage.example/a.png")
}
// When GetFileURL resolves a resource:// alias to a still-internal storage://
// path (no public HTTP URL), the rewrite must be a no-op rather than emit the
// unrenderable URL to the IM client.
func TestRewriteStorageURLs_NonHTTPResultIsNoOp(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, _ string) (string, error) {
return "storage://7cb970a6/oss://bcjy/10000/exports/a.png", nil
},
}
in := "![img](resource://xifDo7NTSL300Lp1goVutw)"
out := rewriteStorageURLs(context.Background(), in, newIMFileServiceResolver(&types.Tenant{}, stub))
assert.Equal(t, in, out)
assert.NotContains(t, out, "storage://")
}
// URL schemes are case-insensitive (RFC 3986); an uppercase-scheme result (e.g.
// from an OBS_PROXY_DOMAIN configured as HTTPS://...) is renderable and must be
// substituted, not dropped as "non-HTTP".
func TestRewriteStorageURLs_UppercaseSchemeIsSubstituted(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, _ string) (string, error) {
return "HTTPS://cdn.example.com/x.png", nil
},
}
in := "![img](resource://xifDo7NTSL300Lp1goVutw)"
out := rewriteStorageURLs(context.Background(), in, newIMFileServiceResolver(&types.Tenant{}, stub))
assert.Contains(t, out, "HTTPS://cdn.example.com/x.png")
assert.NotContains(t, out, "resource://")
}
func TestCleanIMContent_MinIOFallbackIntegration(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, _ string) (string, error) {
+39 -5
View File
@@ -88,6 +88,15 @@ var storageSchemeRe = regexp.MustCompile(
`(?:local|minio|s3|cos|tos|oss|obs|ks3)://[^\s)\]>"]+)`,
)
// isHTTPResolvedURL reports whether s is an http(s) URL — the only form an IM
// client can fetch; any provider:// scheme (oss://, local://, …) is not.
// Scheme match is case-insensitive per RFC 3986 §3.1: a backend may emit an
// operator-configured host (e.g. OBS_PROXY_DOMAIN) with an uppercase scheme.
func isHTTPResolvedURL(s string) bool {
return len(s) >= 7 && strings.EqualFold(s[:7], "http://") ||
len(s) >= 8 && strings.EqualFold(s[:8], "https://")
}
// rewriteStorageURLs replaces all provider:// URLs in content with HTTP URLs
// obtained from fileService.GetFileURL. URLs that are already HTTP or cannot
// be resolved are left unchanged.
@@ -98,8 +107,9 @@ var storageSchemeRe = regexp.MustCompile(
// trade-off: anyone with log access can use a signed URL until it
// expires (WeKnora 2h, MinIO 24h). Acceptable for diagnosability.
// - Failure or no-op rewrite logs at WARN. The no-op case typically means
// APP_EXTERNAL_URL is not configured for the local backend, which is
// the most common cause of "image broken in IM" reports.
// APP_EXTERNAL_URL is not configured (local backend, or resource:// content
// that must be served via /r/), the most common cause of "image broken in
// IM" reports.
func rewriteStorageURLs(ctx context.Context, content string, resolver *imFileServiceResolver) string {
if resolver == nil {
return content
@@ -115,10 +125,13 @@ func rewriteStorageURLs(ctx context.Context, content string, resolver *imFileSer
logger.Warnf(ctx, "[IM] rewriteStorageURLs failed: src=%s err=%v", match, err)
return match
}
if httpURL == match {
// A non-http(s) result cannot be rendered by an IM client — covers both the
// unchanged no-op and a resource:// alias left as an internal storage:// path
// (APP_EXTERNAL_URL unset / nginx not proxying /r/).
if !isHTTPResolvedURL(httpURL) {
logger.Warnf(ctx,
"[IM] rewriteStorageURLs no-op (URL unchanged; for local storage set APP_EXTERNAL_URL): src=%s",
match)
"[IM] rewriteStorageURLs no-op (resolved to non-HTTP URL %q; for local/private storage set APP_EXTERNAL_URL and ensure nginx proxies /r/): src=%s",
httpURL, match)
return match
}
logger.Infof(ctx, "[IM] rewriteStorageURLs: src=%s dst=%s", match, httpURL)
@@ -949,6 +962,23 @@ func (s *Service) dedupCleanupLoop() {
}
}
// imImageConfigWarning returns an operator warning when IM channels are active
// but APP_EXTERNAL_URL is unset. resource:// images then render only if the
// storage backend is itself publicly reachable (e.g. a cloud bucket with a
// public endpoint); on the default MinIO (internal minio:9000) or local
// deployment it is not, so images silently break. Advisory only; returns ""
// when there is nothing to warn about. Pure (no receiver/closures) so it is
// unit-testable.
func imImageConfigWarning(activeChannels int, externalURL string) string {
if activeChannels == 0 || strings.TrimSpace(externalURL) != "" {
return ""
}
return fmt.Sprintf("[IM] %d IM channel(s) active but APP_EXTERNAL_URL is unset; "+
"resource:// images render only if the storage backend is publicly reachable — "+
"otherwise set APP_EXTERNAL_URL so they route through nginx /r/",
activeChannels)
}
// LoadAndStartChannels loads all enabled channels from the database and starts them.
func (s *Service) LoadAndStartChannels() error {
s.startChannelConfigSubscriber()
@@ -959,6 +989,10 @@ func (s *Service) LoadAndStartChannels() error {
return fmt.Errorf("load im channels: %w", err)
}
if msg := imImageConfigWarning(len(channels), os.Getenv("APP_EXTERNAL_URL")); msg != "" {
logger.Warnf(ctx, "%s", msg)
}
for i := range channels {
ch := channels[i]
if err := s.StartChannel(&ch); err != nil {