mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-30 16:53:21 +08:00
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:
+7
-4
@@ -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
@@ -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 设为公网),或设本变量走 <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:-}
|
||||
|
||||
@@ -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` 说明。
|
||||
|
||||
### 企业微信接入
|
||||
|
||||
企业微信提供两种接入模式,根据你的应用类型选择:
|
||||
|
||||
@@ -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/ {
|
||||
|
||||
@@ -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/")
|
||||
})
|
||||
}
|
||||
@@ -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 := ""
|
||||
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 := ""
|
||||
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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user