From 4e3b512af6c153dc93ca46c5fce80422b3382ccd Mon Sep 17 00:00:00 2001 From: nullkey Date: Mon, 27 Jul 2026 20:26:27 +0800 Subject: [PATCH] fix(im): render resource:// images in IM channels (nginx /r/ + config guards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /r/ 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/. --- .env.example | 11 ++++--- docker-compose.yml | 4 ++- docs/IM集成开发文档.md | 5 +++ frontend/nginx.conf | 10 ++++++ internal/im/im_config_warning_test.go | 27 ++++++++++++++++ internal/im/im_file_service_test.go | 30 ++++++++++++++++++ internal/im/service.go | 44 ++++++++++++++++++++++++--- 7 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 internal/im/im_config_warning_test.go diff --git a/.env.example b/.env.example index c9fedbafa..6b3074f37 100644 --- a/.env.example +++ b/.env.example @@ -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:// 图片改写成 /r/ 走 WeKnora(需 nginx 代理 /r/)。 +# 默认 MinIO 部署(minio:9000 为内网)走 (B) 最简单;local 后端只能走 (B)。 # 内网 IP 和 localhost 对飞书/企微/Slack 等 IM 平台不可达;本地开发请用 ngrok/cloudflared/frp。 # APP_EXTERNAL_URL= # 前端外部 origin,用于邀请链接等绝对 URL(留空走 host-relative)。 diff --git a/docker-compose.yml b/docker-compose.yml index ad3a2db50..1c84d189a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,7 +86,9 @@ services: # - WEKNORA_TRUSTED_PROXIES=${WEKNORA_TRUSTED_PROXIES:-} # 启动时自动执行数据库迁移;设为 false 禁用,默认启用 - AUTO_MIGRATE=${AUTO_MIGRATE:-true} - # 本地存储后端生成下载链接用的外部可达 URL(IM 渠道 local 后端必填) + # IM 渠道图片/文件外链的外部可达 URL。IM 显示知识库图片需二选一: + # 存储后端公网可达(如 MINIO_ENDPOINT 设为公网),或设本变量走 /r/(需 nginx 代理 /r/)。 + # 默认 MinIO 内网部署 / local 后端需设本变量。 # - APP_EXTERNAL_URL=${APP_EXTERNAL_URL:-} # 前端外部 origin,用于邀请链接等绝对 URL(留空走 host-relative) - FRONTEND_BASE_URL=${FRONTEND_BASE_URL:-} diff --git a/docs/IM集成开发文档.md b/docs/IM集成开发文档.md index 7779c5266..6ec1dfc6d 100644 --- a/docs/IM集成开发文档.md +++ b/docs/IM集成开发文档.md @@ -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` 说明。 + ### 企业微信接入 企业微信提供两种接入模式,根据你的应用类型选择: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 93de15a50..11c8fd2cb 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -102,6 +102,16 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # 资源能力短链 /r/: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/ { diff --git a/internal/im/im_config_warning_test.go b/internal/im/im_config_warning_test.go new file mode 100644 index 000000000..a44658c9d --- /dev/null +++ b/internal/im/im_config_warning_test.go @@ -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/") + }) +} diff --git a/internal/im/im_file_service_test.go b/internal/im/im_file_service_test.go index e1244a23c..81948777e 100644 --- a/internal/im/im_file_service_test.go +++ b/internal/im/im_file_service_test.go @@ -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) { diff --git a/internal/im/service.go b/internal/im/service.go index f34add797..4eea47b3b 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -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 {