refactor: simplify image handling in _resolve_images function

- Removed the image storage handling from the `_resolve_images` function, which now only decodes images and returns them as inline bytes.
- Updated the function's docstring to clarify that image persistence is managed by the Go App, and the return value for `image_dir_path` is always empty.
- Adjusted related gRPC and protobuf files to remove the `ConvertToPDF` method and its associated types, streamlining the API.
- Enhanced security measures in the frontend by implementing a placeholder for provider images and ensuring proper hydration of protected file images.

These changes improve the clarity and efficiency of image processing and API interactions within the application.
This commit is contained in:
wizardchen
2026-03-03 21:30:27 +08:00
committed by lyingbug
parent 894d2c060a
commit 4192cfd072
26 changed files with 835 additions and 897 deletions
+8 -53
View File
@@ -52,16 +52,16 @@ init_logging_request_id()
def _resolve_images(images: dict, request_id: str, storage_map: dict | None = None) -> tuple[str, list]:
"""Resolve document images with priority: shared storage > temp dir > inline bytes.
"""Resolve document images into inline bytes for the Go App to persist.
``images`` is a dict of {relative_path: raw_data} where raw_data is
base64-encoded string or raw bytes.
``storage_map`` is a dict from the request's config.image_storage map.
When it contains a valid "provider" key, images are uploaded to shared object
storage and storage_key is set (no inline bytes, saving gRPC message size).
The Go App is solely responsible for persisting images to the configured
storage backend (local/minio/cos/tos). This function only decodes images
and returns them as inline bytes via ImageRef.
Returns (image_dir_path, list[ImageRef]).
Returns ("", list[ImageRef]). image_dir_path is always empty.
"""
import base64
@@ -73,28 +73,6 @@ def _resolve_images(images: dict, request_id: str, storage_map: dict | None = No
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
}
storage_client = None
use_storage = False
provider = (storage_map or {}).get("provider", "")
if provider:
try:
from docreader.parser.storage import create_storage
storage_client = create_storage(storage_map)
use_storage = True
logger.info("Using shared storage (%s) for image upload", provider)
except Exception as e:
logger.warning("Failed to init shared storage, falling back to inline bytes: %s", e)
base_dir = CONFIG.image_output_dir
output_dir = os.path.join(base_dir, request_id, "images")
wrote_to_dir = False
if not use_storage:
try:
os.makedirs(output_dir, exist_ok=True)
wrote_to_dir = True
except OSError:
logger.warning("Cannot write to image output dir %s", output_dir)
refs = []
for ref_path, b64data in images.items():
try:
@@ -106,26 +84,6 @@ def _resolve_images(images: dict, request_id: str, storage_map: dict | None = No
ext = os.path.splitext(fname)[1].lower()
mime = mime_map.get(ext, "application/octet-stream")
if use_storage and storage_client:
storage_key = storage_client.upload_bytes(img_bytes, ext)
if storage_key:
refs.append(ImageRef(
filename=fname,
original_ref=ref_path,
mime_type=mime,
storage_key=storage_key,
))
continue
logger.warning("Storage upload failed for %s, falling back to inline bytes", fname)
if wrote_to_dir:
dest = os.path.join(output_dir, fname)
try:
with open(dest, "wb") as f:
f.write(img_bytes)
except OSError as e:
logger.warning("Failed to write image %s: %s", dest, e)
refs.append(ImageRef(
filename=fname,
original_ref=ref_path,
@@ -133,10 +91,8 @@ def _resolve_images(images: dict, request_id: str, storage_map: dict | None = No
image_data=img_bytes,
))
image_dir = os.path.join(base_dir, request_id) if wrote_to_dir else ""
mode = "storage" if use_storage else ("dir+inline" if wrote_to_dir else "inline")
logger.info("Resolved %d images (mode=%s)", len(refs), mode)
return image_dir, refs
logger.info("Resolved %d images (mode=inline)", len(refs))
return "", refs
class DocReaderServicer(docreader_pb2_grpc.DocReaderServicer):
@@ -154,7 +110,6 @@ class DocReaderServicer(docreader_pb2_grpc.DocReaderServicer):
cfg = request.config
parser_engine = cfg.parser_engine if cfg else ""
engine_overrides = dict(cfg.parser_engine_overrides) if cfg else {}
storage_map = dict(cfg.image_storage) if cfg and cfg.image_storage else None
if is_url:
logger.info("Read(URL): url=%s", request.url)
@@ -189,7 +144,7 @@ class DocReaderServicer(docreader_pb2_grpc.DocReaderServicer):
_c = to_valid_utf8_text
image_dir, image_refs = _resolve_images(
result.images, request_id, storage_map=storage_map
result.images, request_id
)
response = ReadResponse(
+32 -171
View File
@@ -25,7 +25,6 @@ type ReadConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
ParserEngine string `protobuf:"bytes,1,opt,name=parser_engine,json=parserEngine,proto3" json:"parser_engine,omitempty"`
ParserEngineOverrides map[string]string `protobuf:"bytes,2,rep,name=parser_engine_overrides,json=parserEngineOverrides,proto3" json:"parser_engine_overrides,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
ImageStorage map[string]string `protobuf:"bytes,3,rep,name=image_storage,json=imageStorage,proto3" json:"image_storage,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // storage provider config (provider, endpoint, bucket, ...)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -74,13 +73,6 @@ func (x *ReadConfig) GetParserEngineOverrides() map[string]string {
return nil
}
func (x *ReadConfig) GetImageStorage() map[string]string {
if x != nil {
return x.ImageStorage
}
return nil
}
// Unified read request: set file_content for file mode, url for URL mode.
type ReadRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -490,134 +482,18 @@ func (x *ListEnginesResponse) GetEngines() []*ParserEngineInfo {
return nil
}
type ConvertToPDFRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
FileContent []byte `protobuf:"bytes,1,opt,name=file_content,json=fileContent,proto3" json:"file_content,omitempty"`
FileName string `protobuf:"bytes,2,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"`
FileType string `protobuf:"bytes,3,opt,name=file_type,json=fileType,proto3" json:"file_type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ConvertToPDFRequest) Reset() {
*x = ConvertToPDFRequest{}
mi := &file_docreader_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ConvertToPDFRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConvertToPDFRequest) ProtoMessage() {}
func (x *ConvertToPDFRequest) ProtoReflect() protoreflect.Message {
mi := &file_docreader_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ConvertToPDFRequest.ProtoReflect.Descriptor instead.
func (*ConvertToPDFRequest) Descriptor() ([]byte, []int) {
return file_docreader_proto_rawDescGZIP(), []int{7}
}
func (x *ConvertToPDFRequest) GetFileContent() []byte {
if x != nil {
return x.FileContent
}
return nil
}
func (x *ConvertToPDFRequest) GetFileName() string {
if x != nil {
return x.FileName
}
return ""
}
func (x *ConvertToPDFRequest) GetFileType() string {
if x != nil {
return x.FileType
}
return ""
}
type ConvertToPDFResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
PdfContent []byte `protobuf:"bytes,1,opt,name=pdf_content,json=pdfContent,proto3" json:"pdf_content,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ConvertToPDFResponse) Reset() {
*x = ConvertToPDFResponse{}
mi := &file_docreader_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ConvertToPDFResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConvertToPDFResponse) ProtoMessage() {}
func (x *ConvertToPDFResponse) ProtoReflect() protoreflect.Message {
mi := &file_docreader_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ConvertToPDFResponse.ProtoReflect.Descriptor instead.
func (*ConvertToPDFResponse) Descriptor() ([]byte, []int) {
return file_docreader_proto_rawDescGZIP(), []int{8}
}
func (x *ConvertToPDFResponse) GetPdfContent() []byte {
if x != nil {
return x.PdfContent
}
return nil
}
func (x *ConvertToPDFResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
var File_docreader_proto protoreflect.FileDescriptor
const file_docreader_proto_rawDesc = "" +
"\n" +
"\x0fdocreader.proto\x12\tdocreader\"\xf4\x02\n" +
"\x0fdocreader.proto\x12\tdocreader\"\xeb\x01\n" +
"\n" +
"ReadConfig\x12#\n" +
"\rparser_engine\x18\x01 \x01(\tR\fparserEngine\x12h\n" +
"\x17parser_engine_overrides\x18\x02 \x03(\v20.docreader.ReadConfig.ParserEngineOverridesEntryR\x15parserEngineOverrides\x12L\n" +
"\rimage_storage\x18\x03 \x03(\v2'.docreader.ReadConfig.ImageStorageEntryR\fimageStorage\x1aH\n" +
"\x17parser_engine_overrides\x18\x02 \x03(\v20.docreader.ReadConfig.ParserEngineOverridesEntryR\x15parserEngineOverrides\x1aH\n" +
"\x1aParserEngineOverridesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a?\n" +
"\x11ImageStorageEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe0\x01\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x03\x10\x04\"\xe0\x01\n" +
"\vReadRequest\x12!\n" +
"\ffile_content\x18\x01 \x01(\fR\vfileContent\x12\x1b\n" +
"\tfile_name\x18\x02 \x01(\tR\bfileName\x12\x1b\n" +
@@ -658,19 +534,10 @@ const file_docreader_proto_rawDesc = "" +
"\tavailable\x18\x04 \x01(\bR\tavailable\x12-\n" +
"\x12unavailable_reason\x18\x05 \x01(\tR\x11unavailableReason\"L\n" +
"\x13ListEnginesResponse\x125\n" +
"\aengines\x18\x01 \x03(\v2\x1b.docreader.ParserEngineInfoR\aengines\"r\n" +
"\x13ConvertToPDFRequest\x12!\n" +
"\ffile_content\x18\x01 \x01(\fR\vfileContent\x12\x1b\n" +
"\tfile_name\x18\x02 \x01(\tR\bfileName\x12\x1b\n" +
"\tfile_type\x18\x03 \x01(\tR\bfileType\"M\n" +
"\x14ConvertToPDFResponse\x12\x1f\n" +
"\vpdf_content\x18\x01 \x01(\fR\n" +
"pdfContent\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error2\xe9\x01\n" +
"\aengines\x18\x01 \x03(\v2\x1b.docreader.ParserEngineInfoR\aengines2\x96\x01\n" +
"\tDocReader\x129\n" +
"\x04Read\x12\x16.docreader.ReadRequest\x1a\x17.docreader.ReadResponse\"\x00\x12N\n" +
"\vListEngines\x12\x1d.docreader.ListEnginesRequest\x1a\x1e.docreader.ListEnginesResponse\"\x00\x12Q\n" +
"\fConvertToPDF\x12\x1e.docreader.ConvertToPDFRequest\x1a\x1f.docreader.ConvertToPDFResponse\"\x00B5Z3github.com/Tencent/WeKnora/internal/docreader/protob\x06proto3"
"\vListEngines\x12\x1d.docreader.ListEnginesRequest\x1a\x1e.docreader.ListEnginesResponse\"\x00B5Z3github.com/Tencent/WeKnora/internal/docreader/protob\x06proto3"
var (
file_docreader_proto_rawDescOnce sync.Once
@@ -684,41 +551,35 @@ func file_docreader_proto_rawDescGZIP() []byte {
return file_docreader_proto_rawDescData
}
var file_docreader_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_docreader_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_docreader_proto_goTypes = []any{
(*ReadConfig)(nil), // 0: docreader.ReadConfig
(*ReadRequest)(nil), // 1: docreader.ReadRequest
(*ImageRef)(nil), // 2: docreader.ImageRef
(*ReadResponse)(nil), // 3: docreader.ReadResponse
(*ListEnginesRequest)(nil), // 4: docreader.ListEnginesRequest
(*ParserEngineInfo)(nil), // 5: docreader.ParserEngineInfo
(*ListEnginesResponse)(nil), // 6: docreader.ListEnginesResponse
(*ConvertToPDFRequest)(nil), // 7: docreader.ConvertToPDFRequest
(*ConvertToPDFResponse)(nil), // 8: docreader.ConvertToPDFResponse
nil, // 9: docreader.ReadConfig.ParserEngineOverridesEntry
nil, // 10: docreader.ReadConfig.ImageStorageEntry
nil, // 11: docreader.ReadResponse.MetadataEntry
nil, // 12: docreader.ListEnginesRequest.ConfigOverridesEntry
(*ReadConfig)(nil), // 0: docreader.ReadConfig
(*ReadRequest)(nil), // 1: docreader.ReadRequest
(*ImageRef)(nil), // 2: docreader.ImageRef
(*ReadResponse)(nil), // 3: docreader.ReadResponse
(*ListEnginesRequest)(nil), // 4: docreader.ListEnginesRequest
(*ParserEngineInfo)(nil), // 5: docreader.ParserEngineInfo
(*ListEnginesResponse)(nil), // 6: docreader.ListEnginesResponse
nil, // 7: docreader.ReadConfig.ParserEngineOverridesEntry
nil, // 8: docreader.ReadResponse.MetadataEntry
nil, // 9: docreader.ListEnginesRequest.ConfigOverridesEntry
}
var file_docreader_proto_depIdxs = []int32{
9, // 0: docreader.ReadConfig.parser_engine_overrides:type_name -> docreader.ReadConfig.ParserEngineOverridesEntry
10, // 1: docreader.ReadConfig.image_storage:type_name -> docreader.ReadConfig.ImageStorageEntry
0, // 2: docreader.ReadRequest.config:type_name -> docreader.ReadConfig
2, // 3: docreader.ReadResponse.image_refs:type_name -> docreader.ImageRef
11, // 4: docreader.ReadResponse.metadata:type_name -> docreader.ReadResponse.MetadataEntry
12, // 5: docreader.ListEnginesRequest.config_overrides:type_name -> docreader.ListEnginesRequest.ConfigOverridesEntry
5, // 6: docreader.ListEnginesResponse.engines:type_name -> docreader.ParserEngineInfo
1, // 7: docreader.DocReader.Read:input_type -> docreader.ReadRequest
4, // 8: docreader.DocReader.ListEngines:input_type -> docreader.ListEnginesRequest
7, // 9: docreader.DocReader.ConvertToPDF:input_type -> docreader.ConvertToPDFRequest
3, // 10: docreader.DocReader.Read:output_type -> docreader.ReadResponse
6, // 11: docreader.DocReader.ListEngines:output_type -> docreader.ListEnginesResponse
8, // 12: docreader.DocReader.ConvertToPDF:output_type -> docreader.ConvertToPDFResponse
10, // [10:13] is the sub-list for method output_type
7, // [7:10] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
7, // 0: docreader.ReadConfig.parser_engine_overrides:type_name -> docreader.ReadConfig.ParserEngineOverridesEntry
0, // 1: docreader.ReadRequest.config:type_name -> docreader.ReadConfig
2, // 2: docreader.ReadResponse.image_refs:type_name -> docreader.ImageRef
8, // 3: docreader.ReadResponse.metadata:type_name -> docreader.ReadResponse.MetadataEntry
9, // 4: docreader.ListEnginesRequest.config_overrides:type_name -> docreader.ListEnginesRequest.ConfigOverridesEntry
5, // 5: docreader.ListEnginesResponse.engines:type_name -> docreader.ParserEngineInfo
1, // 6: docreader.DocReader.Read:input_type -> docreader.ReadRequest
4, // 7: docreader.DocReader.ListEngines:input_type -> docreader.ListEnginesRequest
3, // 8: docreader.DocReader.Read:output_type -> docreader.ReadResponse
6, // 9: docreader.DocReader.ListEngines:output_type -> docreader.ListEnginesResponse
8, // [8:10] is the sub-list for method output_type
6, // [6:8] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_docreader_proto_init() }
@@ -732,7 +593,7 @@ func file_docreader_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_docreader_proto_rawDesc), len(file_docreader_proto_rawDesc)),
NumEnums: 0,
NumMessages: 13,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
+3 -1
View File
@@ -12,7 +12,9 @@ service DocReader {
message ReadConfig {
string parser_engine = 1;
map<string, string> parser_engine_overrides = 2;
map<string, string> image_storage = 3; // storage provider config (provider, endpoint, bucket, ...)
// image_storage removed: image persistence is now handled entirely by the Go App.
// Field number 3 is reserved for backward compatibility.
reserved 3;
}
// Unified read request: set file_content for file mode, url for URL mode.
+2 -40
View File
@@ -19,9 +19,8 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
DocReader_Read_FullMethodName = "/docreader.DocReader/Read"
DocReader_ListEngines_FullMethodName = "/docreader.DocReader/ListEngines"
DocReader_ConvertToPDF_FullMethodName = "/docreader.DocReader/ConvertToPDF"
DocReader_Read_FullMethodName = "/docreader.DocReader/Read"
DocReader_ListEngines_FullMethodName = "/docreader.DocReader/ListEngines"
)
// DocReaderClient is the client API for DocReader service.
@@ -30,7 +29,6 @@ const (
type DocReaderClient interface {
Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error)
ListEngines(ctx context.Context, in *ListEnginesRequest, opts ...grpc.CallOption) (*ListEnginesResponse, error)
ConvertToPDF(ctx context.Context, in *ConvertToPDFRequest, opts ...grpc.CallOption) (*ConvertToPDFResponse, error)
}
type docReaderClient struct {
@@ -61,23 +59,12 @@ func (c *docReaderClient) ListEngines(ctx context.Context, in *ListEnginesReques
return out, nil
}
func (c *docReaderClient) ConvertToPDF(ctx context.Context, in *ConvertToPDFRequest, opts ...grpc.CallOption) (*ConvertToPDFResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ConvertToPDFResponse)
err := c.cc.Invoke(ctx, DocReader_ConvertToPDF_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// DocReaderServer is the server API for DocReader service.
// All implementations must embed UnimplementedDocReaderServer
// for forward compatibility.
type DocReaderServer interface {
Read(context.Context, *ReadRequest) (*ReadResponse, error)
ListEngines(context.Context, *ListEnginesRequest) (*ListEnginesResponse, error)
ConvertToPDF(context.Context, *ConvertToPDFRequest) (*ConvertToPDFResponse, error)
mustEmbedUnimplementedDocReaderServer()
}
@@ -94,9 +81,6 @@ func (UnimplementedDocReaderServer) Read(context.Context, *ReadRequest) (*ReadRe
func (UnimplementedDocReaderServer) ListEngines(context.Context, *ListEnginesRequest) (*ListEnginesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListEngines not implemented")
}
func (UnimplementedDocReaderServer) ConvertToPDF(context.Context, *ConvertToPDFRequest) (*ConvertToPDFResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ConvertToPDF not implemented")
}
func (UnimplementedDocReaderServer) mustEmbedUnimplementedDocReaderServer() {}
func (UnimplementedDocReaderServer) testEmbeddedByValue() {}
@@ -154,24 +138,6 @@ func _DocReader_ListEngines_Handler(srv interface{}, ctx context.Context, dec fu
return interceptor(ctx, in, info, handler)
}
func _DocReader_ConvertToPDF_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConvertToPDFRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocReaderServer).ConvertToPDF(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocReader_ConvertToPDF_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocReaderServer).ConvertToPDF(ctx, req.(*ConvertToPDFRequest))
}
return interceptor(ctx, in, info, handler)
}
// DocReader_ServiceDesc is the grpc.ServiceDesc for DocReader service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -187,10 +153,6 @@ var DocReader_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListEngines",
Handler: _DocReader_ListEngines_Handler,
},
{
MethodName: "ConvertToPDF",
Handler: _DocReader_ConvertToPDF_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "docreader.proto",
+22 -30
View File
@@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x64ocreader.proto\x12\tdocreader\"\xa9\x02\n\nReadConfig\x12\x15\n\rparser_engine\x18\x01 \x01(\t\x12Q\n\x17parser_engine_overrides\x18\x02 \x03(\x0b\x32\x30.docreader.ReadConfig.ParserEngineOverridesEntry\x12>\n\rimage_storage\x18\x03 \x03(\x0b\x32\'.docreader.ReadConfig.ImageStorageEntry\x1a<\n\x1aParserEngineOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x33\n\x11ImageStorageEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xa0\x01\n\x0bReadRequest\x12\x14\n\x0c\x66ile_content\x18\x01 \x01(\x0c\x12\x11\n\tfile_name\x18\x02 \x01(\t\x12\x11\n\tfile_type\x18\x03 \x01(\t\x12\x0b\n\x03url\x18\x04 \x01(\t\x12\r\n\x05title\x18\x05 \x01(\t\x12%\n\x06\x63onfig\x18\x06 \x01(\x0b\x32\x15.docreader.ReadConfig\x12\x12\n\nrequest_id\x18\x07 \x01(\t\"n\n\x08ImageRef\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x14\n\x0coriginal_ref\x18\x02 \x01(\t\x12\x11\n\tmime_type\x18\x03 \x01(\t\x12\x13\n\x0bstorage_key\x18\x04 \x01(\t\x12\x12\n\nimage_data\x18\x05 \x01(\x0c\"\xe2\x01\n\x0cReadResponse\x12\x18\n\x10markdown_content\x18\x01 \x01(\t\x12\'\n\nimage_refs\x18\x02 \x03(\x0b\x32\x13.docreader.ImageRef\x12\x16\n\x0eimage_dir_path\x18\x03 \x01(\t\x12\x37\n\x08metadata\x18\x04 \x03(\x0b\x32%.docreader.ReadResponse.MetadataEntry\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x9a\x01\n\x12ListEnginesRequest\x12L\n\x10\x63onfig_overrides\x18\x01 \x03(\x0b\x32\x32.docreader.ListEnginesRequest.ConfigOverridesEntry\x1a\x36\n\x14\x43onfigOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"x\n\x10ParserEngineInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x12\n\nfile_types\x18\x03 \x03(\t\x12\x11\n\tavailable\x18\x04 \x01(\x08\x12\x1a\n\x12unavailable_reason\x18\x05 \x01(\t\"C\n\x13ListEnginesResponse\x12,\n\x07\x65ngines\x18\x01 \x03(\x0b\x32\x1b.docreader.ParserEngineInfo\"Q\n\x13\x43onvertToPDFRequest\x12\x14\n\x0c\x66ile_content\x18\x01 \x01(\x0c\x12\x11\n\tfile_name\x18\x02 \x01(\t\x12\x11\n\tfile_type\x18\x03 \x01(\t\":\n\x14\x43onvertToPDFResponse\x12\x13\n\x0bpdf_content\x18\x01 \x01(\x0c\x12\r\n\x05\x65rror\x18\x02 \x01(\t2\xe9\x01\n\tDocReader\x12\x39\n\x04Read\x12\x16.docreader.ReadRequest\x1a\x17.docreader.ReadResponse\"\x00\x12N\n\x0bListEngines\x12\x1d.docreader.ListEnginesRequest\x1a\x1e.docreader.ListEnginesResponse\"\x00\x12Q\n\x0c\x43onvertToPDF\x12\x1e.docreader.ConvertToPDFRequest\x1a\x1f.docreader.ConvertToPDFResponse\"\x00\x42\x35Z3github.com/Tencent/WeKnora/internal/docreader/protob\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x64ocreader.proto\x12\tdocreader\"\xba\x01\n\nReadConfig\x12\x15\n\rparser_engine\x18\x01 \x01(\t\x12Q\n\x17parser_engine_overrides\x18\x02 \x03(\x0b\x32\x30.docreader.ReadConfig.ParserEngineOverridesEntry\x1a<\n\x1aParserEngineOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04\"\xa0\x01\n\x0bReadRequest\x12\x14\n\x0c\x66ile_content\x18\x01 \x01(\x0c\x12\x11\n\tfile_name\x18\x02 \x01(\t\x12\x11\n\tfile_type\x18\x03 \x01(\t\x12\x0b\n\x03url\x18\x04 \x01(\t\x12\r\n\x05title\x18\x05 \x01(\t\x12%\n\x06\x63onfig\x18\x06 \x01(\x0b\x32\x15.docreader.ReadConfig\x12\x12\n\nrequest_id\x18\x07 \x01(\t\"n\n\x08ImageRef\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x14\n\x0coriginal_ref\x18\x02 \x01(\t\x12\x11\n\tmime_type\x18\x03 \x01(\t\x12\x13\n\x0bstorage_key\x18\x04 \x01(\t\x12\x12\n\nimage_data\x18\x05 \x01(\x0c\"\xe2\x01\n\x0cReadResponse\x12\x18\n\x10markdown_content\x18\x01 \x01(\t\x12\'\n\nimage_refs\x18\x02 \x03(\x0b\x32\x13.docreader.ImageRef\x12\x16\n\x0eimage_dir_path\x18\x03 \x01(\t\x12\x37\n\x08metadata\x18\x04 \x03(\x0b\x32%.docreader.ReadResponse.MetadataEntry\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x9a\x01\n\x12ListEnginesRequest\x12L\n\x10\x63onfig_overrides\x18\x01 \x03(\x0b\x32\x32.docreader.ListEnginesRequest.ConfigOverridesEntry\x1a\x36\n\x14\x43onfigOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"x\n\x10ParserEngineInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x12\n\nfile_types\x18\x03 \x03(\t\x12\x11\n\tavailable\x18\x04 \x01(\x08\x12\x1a\n\x12unavailable_reason\x18\x05 \x01(\t\"C\n\x13ListEnginesResponse\x12,\n\x07\x65ngines\x18\x01 \x03(\x0b\x32\x1b.docreader.ParserEngineInfo2\x96\x01\n\tDocReader\x12\x39\n\x04Read\x12\x16.docreader.ReadRequest\x1a\x17.docreader.ReadResponse\"\x00\x12N\n\x0bListEngines\x12\x1d.docreader.ListEnginesRequest\x1a\x1e.docreader.ListEnginesResponse\"\x00\x42\x35Z3github.com/Tencent/WeKnora/internal/docreader/protob\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -34,38 +34,30 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._serialized_options = b'Z3github.com/Tencent/WeKnora/internal/docreader/proto'
_globals['_READCONFIG_PARSERENGINEOVERRIDESENTRY']._loaded_options = None
_globals['_READCONFIG_PARSERENGINEOVERRIDESENTRY']._serialized_options = b'8\001'
_globals['_READCONFIG_IMAGESTORAGEENTRY']._loaded_options = None
_globals['_READCONFIG_IMAGESTORAGEENTRY']._serialized_options = b'8\001'
_globals['_READRESPONSE_METADATAENTRY']._loaded_options = None
_globals['_READRESPONSE_METADATAENTRY']._serialized_options = b'8\001'
_globals['_LISTENGINESREQUEST_CONFIGOVERRIDESENTRY']._loaded_options = None
_globals['_LISTENGINESREQUEST_CONFIGOVERRIDESENTRY']._serialized_options = b'8\001'
_globals['_READCONFIG']._serialized_start=31
_globals['_READCONFIG']._serialized_end=328
_globals['_READCONFIG_PARSERENGINEOVERRIDESENTRY']._serialized_start=215
_globals['_READCONFIG_PARSERENGINEOVERRIDESENTRY']._serialized_end=275
_globals['_READCONFIG_IMAGESTORAGEENTRY']._serialized_start=277
_globals['_READCONFIG_IMAGESTORAGEENTRY']._serialized_end=328
_globals['_READREQUEST']._serialized_start=331
_globals['_READREQUEST']._serialized_end=491
_globals['_IMAGEREF']._serialized_start=493
_globals['_IMAGEREF']._serialized_end=603
_globals['_READRESPONSE']._serialized_start=606
_globals['_READRESPONSE']._serialized_end=832
_globals['_READRESPONSE_METADATAENTRY']._serialized_start=785
_globals['_READRESPONSE_METADATAENTRY']._serialized_end=832
_globals['_LISTENGINESREQUEST']._serialized_start=835
_globals['_LISTENGINESREQUEST']._serialized_end=989
_globals['_LISTENGINESREQUEST_CONFIGOVERRIDESENTRY']._serialized_start=935
_globals['_LISTENGINESREQUEST_CONFIGOVERRIDESENTRY']._serialized_end=989
_globals['_PARSERENGINEINFO']._serialized_start=991
_globals['_PARSERENGINEINFO']._serialized_end=1111
_globals['_LISTENGINESRESPONSE']._serialized_start=1113
_globals['_LISTENGINESRESPONSE']._serialized_end=1180
_globals['_CONVERTTOPDFREQUEST']._serialized_start=1182
_globals['_CONVERTTOPDFREQUEST']._serialized_end=1263
_globals['_CONVERTTOPDFRESPONSE']._serialized_start=1265
_globals['_CONVERTTOPDFRESPONSE']._serialized_end=1323
_globals['_DOCREADER']._serialized_start=1326
_globals['_DOCREADER']._serialized_end=1559
_globals['_READCONFIG']._serialized_end=217
_globals['_READCONFIG_PARSERENGINEOVERRIDESENTRY']._serialized_start=151
_globals['_READCONFIG_PARSERENGINEOVERRIDESENTRY']._serialized_end=211
_globals['_READREQUEST']._serialized_start=220
_globals['_READREQUEST']._serialized_end=380
_globals['_IMAGEREF']._serialized_start=382
_globals['_IMAGEREF']._serialized_end=492
_globals['_READRESPONSE']._serialized_start=495
_globals['_READRESPONSE']._serialized_end=721
_globals['_READRESPONSE_METADATAENTRY']._serialized_start=674
_globals['_READRESPONSE_METADATAENTRY']._serialized_end=721
_globals['_LISTENGINESREQUEST']._serialized_start=724
_globals['_LISTENGINESREQUEST']._serialized_end=878
_globals['_LISTENGINESREQUEST_CONFIGOVERRIDESENTRY']._serialized_start=824
_globals['_LISTENGINESREQUEST_CONFIGOVERRIDESENTRY']._serialized_end=878
_globals['_PARSERENGINEINFO']._serialized_start=880
_globals['_PARSERENGINEINFO']._serialized_end=1000
_globals['_LISTENGINESRESPONSE']._serialized_start=1002
_globals['_LISTENGINESRESPONSE']._serialized_end=1069
_globals['_DOCREADER']._serialized_start=1072
_globals['_DOCREADER']._serialized_end=1222
# @@protoc_insertion_point(module_scope)
+2 -29
View File
@@ -7,7 +7,7 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class ReadConfig(_message.Message):
__slots__ = ("parser_engine", "parser_engine_overrides", "image_storage")
__slots__ = ("parser_engine", "parser_engine_overrides")
class ParserEngineOverridesEntry(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
@@ -15,20 +15,11 @@ class ReadConfig(_message.Message):
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
class ImageStorageEntry(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
PARSER_ENGINE_FIELD_NUMBER: _ClassVar[int]
PARSER_ENGINE_OVERRIDES_FIELD_NUMBER: _ClassVar[int]
IMAGE_STORAGE_FIELD_NUMBER: _ClassVar[int]
parser_engine: str
parser_engine_overrides: _containers.ScalarMap[str, str]
image_storage: _containers.ScalarMap[str, str]
def __init__(self, parser_engine: _Optional[str] = ..., parser_engine_overrides: _Optional[_Mapping[str, str]] = ..., image_storage: _Optional[_Mapping[str, str]] = ...) -> None: ...
def __init__(self, parser_engine: _Optional[str] = ..., parser_engine_overrides: _Optional[_Mapping[str, str]] = ...) -> None: ...
class ReadRequest(_message.Message):
__slots__ = ("file_content", "file_name", "file_type", "url", "title", "config", "request_id")
@@ -115,21 +106,3 @@ class ListEnginesResponse(_message.Message):
ENGINES_FIELD_NUMBER: _ClassVar[int]
engines: _containers.RepeatedCompositeFieldContainer[ParserEngineInfo]
def __init__(self, engines: _Optional[_Iterable[_Union[ParserEngineInfo, _Mapping]]] = ...) -> None: ...
class ConvertToPDFRequest(_message.Message):
__slots__ = ("file_content", "file_name", "file_type")
FILE_CONTENT_FIELD_NUMBER: _ClassVar[int]
FILE_NAME_FIELD_NUMBER: _ClassVar[int]
FILE_TYPE_FIELD_NUMBER: _ClassVar[int]
file_content: bytes
file_name: str
file_type: str
def __init__(self, file_content: _Optional[bytes] = ..., file_name: _Optional[str] = ..., file_type: _Optional[str] = ...) -> None: ...
class ConvertToPDFResponse(_message.Message):
__slots__ = ("pdf_content", "error")
PDF_CONTENT_FIELD_NUMBER: _ClassVar[int]
ERROR_FIELD_NUMBER: _ClassVar[int]
pdf_content: bytes
error: str
def __init__(self, pdf_content: _Optional[bytes] = ..., error: _Optional[str] = ...) -> None: ...
-43
View File
@@ -44,11 +44,6 @@ class DocReaderStub(object):
request_serializer=docreader__pb2.ListEnginesRequest.SerializeToString,
response_deserializer=docreader__pb2.ListEnginesResponse.FromString,
_registered_method=True)
self.ConvertToPDF = channel.unary_unary(
'/docreader.DocReader/ConvertToPDF',
request_serializer=docreader__pb2.ConvertToPDFRequest.SerializeToString,
response_deserializer=docreader__pb2.ConvertToPDFResponse.FromString,
_registered_method=True)
class DocReaderServicer(object):
@@ -66,12 +61,6 @@ class DocReaderServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ConvertToPDF(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_DocReaderServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -85,11 +74,6 @@ def add_DocReaderServicer_to_server(servicer, server):
request_deserializer=docreader__pb2.ListEnginesRequest.FromString,
response_serializer=docreader__pb2.ListEnginesResponse.SerializeToString,
),
'ConvertToPDF': grpc.unary_unary_rpc_method_handler(
servicer.ConvertToPDF,
request_deserializer=docreader__pb2.ConvertToPDFRequest.FromString,
response_serializer=docreader__pb2.ConvertToPDFResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'docreader.DocReader', rpc_method_handlers)
@@ -154,30 +138,3 @@ class DocReader(object):
timeout,
metadata,
_registered_method=True)
@staticmethod
def ConvertToPDF(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/docreader.DocReader/ConvertToPDF',
docreader__pb2.ConvertToPDFRequest.SerializeToString,
docreader__pb2.ConvertToPDFResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+136 -6
View File
@@ -4,6 +4,8 @@
import DOMPurify from 'dompurify';
const PROVIDER_IMAGE_PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
// 配置 DOMPurify 的安全策略
const DOMPurifyConfig = {
// 允许的标签
@@ -21,7 +23,7 @@ const DOMPurifyConfig = {
],
// 允许的属性
ALLOWED_ATTR: [
'href', 'title', 'alt', 'src', 'class', 'id', 'style',
'href', 'title', 'alt', 'src', 'class', 'id', 'style', 'data-protected-src',
'target', 'rel', 'width', 'height',
// Mermaid SVG 支持的属性
'd', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
@@ -39,7 +41,7 @@ const DOMPurifyConfig = {
'display', 'pointer-events', 'cursor', 'data-emit', 'direction'
],
// 允许的协议
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|(?:local|minio|cos|tos):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
// 禁止的标签和属性
FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
@@ -99,7 +101,8 @@ export function sanitizeHTML(html: string): string {
}
try {
return DOMPurify.sanitize(html, DOMPurifyConfig);
const preparedHTML = protectProviderImageSrcInHTML(html);
return DOMPurify.sanitize(preparedHTML, DOMPurifyConfig);
} catch (error) {
console.error('HTML sanitization failed:', error);
// 如果清理失败,返回转义的纯文本
@@ -107,6 +110,24 @@ export function sanitizeHTML(html: string): string {
}
}
function protectProviderImageSrcInHTML(html: string): string {
if (!html) return html;
const decodeProviderURL = (raw: string): string =>
raw
.replace(/&#x2f;/gi, '/')
.replace(/&#47;/g, '/')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"');
return html.replace(
/<img\b([^>]*?)\ssrc=(["'])(local|minio|cos|tos):(?:\/\/|&#x2f;&#x2f;|&#47;&#47;)([^"']+)\2([^>]*)>/gi,
(_m, before, quote, provider, restPathRaw, after) => {
const restPath = decodeProviderURL(restPathRaw);
const protectedSrc = `${provider}://${restPath}`;
return `<img${before} src=${quote}${PROVIDER_IMAGE_PLACEHOLDER}${quote} data-protected-src=${quote}${protectedSrc}${quote}${after}>`;
},
);
}
/**
* 转义 HTML 特殊字符
* @param text 需要转义的文本
@@ -140,14 +161,23 @@ export function isValidURL(url: string): boolean {
if (!url || typeof url !== 'string') {
return false;
}
const trimmed = url.trim();
if (!trimmed) {
return false;
}
// 允许以 / 开头的站内相对路径(如本地存储 /files/images/xxx.jpg)
if (url.startsWith('/') && !url.startsWith('//')) {
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
return true;
}
// 允许 provider:// 形式,由前端后续鉴权拉取并替换为 blob URL
if (/^(local|minio|cos|tos):\/\/\S+$/i.test(trimmed)) {
return true;
}
try {
const urlObj = new URL(url);
const urlObj = new URL(trimmed);
return ['http:', 'https:'].includes(urlObj.protocol);
} catch {
return false;
@@ -220,9 +250,109 @@ export function createSafeImage(src: string, alt: string = '', title: string = '
return '';
}
const safeSrc = escapeHTML(src);
// src is validated by isValidImageURL; keep URL structure unchanged.
// Only escape quotes to avoid breaking attributes.
const safeSrc = src.replace(/"/g, '&quot;');
const safeAlt = escapeHTML(alt);
const safeTitle = escapeHTML(title);
return `<img src="${safeSrc}" alt="${safeAlt}" title="${safeTitle}" class="markdown-image" style="max-width: 100%; height: auto;">`;
}
const protectedFileBlobCache = new Map<string, string>();
function getProtectedFileRequestHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
try {
const token = (localStorage.getItem('weknora_token') || '').trim();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const selectedTenantId = (localStorage.getItem('weknora_selected_tenant_id') || '').trim();
const tenantRaw = localStorage.getItem('weknora_tenant');
if (selectedTenantId) {
try {
const tenant = tenantRaw ? JSON.parse(tenantRaw) : null;
const defaultTenantId = tenant?.id ? String(tenant.id) : '';
if (selectedTenantId !== defaultTenantId) {
headers['X-Tenant-ID'] = selectedTenantId;
}
} catch {
// ignore tenant parse error and skip X-Tenant-ID
}
}
} catch {
// ignore localStorage read errors
}
return headers;
}
/**
* 将 Markdown 里通过 /files 代理的图片,改为用带鉴权 Header 的 fetch 拉取后再显示。
* 用于避免在 URL 中暴露 token。
*/
export async function hydrateProtectedFileImages(root: ParentNode | null | undefined): Promise<void> {
if (!root || typeof window === 'undefined') {
return;
}
const images = root.querySelectorAll<HTMLImageElement>(
'img[data-protected-src], img[src^="local://"], img[src^="minio://"], img[src^="cos://"], img[src^="tos://"]',
);
if (!images.length) {
return;
}
const headers = getProtectedFileRequestHeaders();
await Promise.all(Array.from(images).map(async (img) => {
const protectedSrc = (img.getAttribute('data-protected-src') || '').trim();
const src = (img.getAttribute('src') || '').trim();
const sourceURL = protectedSrc || src;
if (!sourceURL) {
return;
}
if (img.dataset.authHydrated === '1') {
return;
}
img.dataset.authHydrated = '1';
const isProviderScheme = /^(local|minio|cos|tos):\/\//.test(sourceURL);
const requestURL = isProviderScheme
? `/files?${new URLSearchParams({ file_path: sourceURL }).toString()}`
: sourceURL;
if (!requestURL.startsWith('/files?') || !requestURL.includes('file_path=')) {
img.dataset.authHydrated = '0';
return;
}
const cachedBlobURL = protectedFileBlobCache.get(requestURL);
if (cachedBlobURL) {
img.src = cachedBlobURL;
return;
}
try {
const resp = await fetch(requestURL, {
method: 'GET',
headers,
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const blob = await resp.blob();
const blobURL = URL.createObjectURL(blob);
protectedFileBlobCache.set(requestURL, blobURL);
img.src = blobURL;
if (protectedSrc) {
img.removeAttribute('data-protected-src');
}
} catch (error) {
console.warn('[security] hydrateProtectedFileImages failed:', error);
img.dataset.authHydrated = '0';
}
}));
}
@@ -227,6 +227,7 @@ import { MessagePlugin } from 'tdesign-vue-next';
import { useUIStore } from '@/stores/ui';
import { useI18n } from 'vue-i18n';
import { openMermaidFullscreen } from '@/utils/mermaidViewer';
import { hydrateProtectedFileImages } from '@/utils/security';
const router = useRouter();
const uiStore = useUIStore();
@@ -278,7 +279,7 @@ const DOMPurifyConfig = {
],
ALLOWED_ATTR: [
'href', 'title', 'target', 'rel', 'data-tooltip', 'data-url', 'data-kb-id',
'data-chunk-id', 'data-doc', 'class', 'role', 'tabindex', 'src', 'alt',
'data-chunk-id', 'data-doc', 'class', 'role', 'tabindex', 'src', 'alt', 'data-protected-src',
'width', 'height', 'style', 'id',
// Mermaid SVG 支持的属性
'd', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
@@ -294,7 +295,9 @@ const DOMPurifyConfig = {
'filterunits', 'primitiveunits', 'xmlns', 'xmlns:xlink', 'xlink:href',
'version', 'baseprofile', 'enable-background', 'overflow', 'visibility',
'display', 'pointer-events', 'cursor', 'data-emit', 'direction'
]
],
// Allow provider:// URLs so they can be hydrated later.
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|(?:local|minio|cos|tos):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
};
const TOOL_NAME_I18N: Record<string, string> = {
@@ -460,12 +463,13 @@ watch(eventStream, (stream) => {
}
});
// 只在会话完成后渲染 Mermaid 图表
if (props.session?.is_completed) {
nextTick(() => {
nextTick(async () => {
await hydrateProtectedFileImages(rootElement.value);
// 只在会话完成后渲染 Mermaid 图表
if (props.session?.is_completed) {
renderMermaidDiagrams();
});
}
}
});
}, { immediate: true, deep: true });
// State for intermediate steps collapse
@@ -1096,7 +1100,7 @@ const onRootKeydown = (e: KeyboardEvent) => {
onMounted(() => {
// 使用 nextTick 确保 DOM 已渲染
nextTick(() => {
nextTick(async () => {
const root = rootElement.value;
if (!root) return;
root.addEventListener('click', onRootClick, true);
@@ -1109,6 +1113,7 @@ onMounted(() => {
root.addEventListener('mouseout', onHoverOut, true);
window.addEventListener('scroll', scheduleFloatClose, true);
window.addEventListener('resize', scheduleFloatClose, true);
await hydrateProtectedFileImages(rootElement.value);
});
});
@@ -1148,17 +1153,9 @@ const parseTagAttributes = (attrString: string): Record<string, string> => {
const preprocessMarkdown = (contentStr: string): string => {
if (!contentStr.trim()) return '';
// Handle streaming image syntax to prevent flickering
const lastImgStart = contentStr.lastIndexOf('![');
if (lastImgStart !== -1) {
const potentialImgTag = contentStr.slice(lastImgStart);
const hasClosingParen = potentialImgTag.includes(')');
const hasClosingBracket = potentialImgTag.includes(']');
if (!hasClosingBracket || !hasClosingParen) {
contentStr = contentStr.slice(0, lastImgStart);
}
}
// Replace incomplete streaming image markdown with an in-place loading placeholder.
// This avoids showing a half-baked provider:// URL while keeping layout stable.
contentStr = replaceIncompleteImageWithPlaceholder(contentStr);
// Preprocess custom citation tags
return contentStr
@@ -1220,6 +1217,26 @@ const preprocessMarkdown = (contentStr: string): string => {
);
};
const replaceIncompleteImageWithPlaceholder = (content: string): string => {
const lastImgStart = content.lastIndexOf('![');
if (lastImgStart < 0) return content;
const tail = content.slice(lastImgStart);
const hasImageOpen = tail.startsWith('![');
const hasBracketClose = tail.includes(']');
const hasParenOpen = tail.includes('(');
const hasParenClose = tail.includes(')');
if (!hasImageOpen) return content;
// Incomplete image syntax at stream tail, e.g. ![alt](local://...
if (!hasBracketClose || (hasParenOpen && !hasParenClose)) {
const placeholder = `<span class="streaming-image-loading"><span class="streaming-image-loading__skeleton"></span></span>`;
return content.slice(0, lastImgStart) + placeholder;
}
return content;
};
// Get tokens from markdown content
const getTokens = (content: any) => {
const contentStr = typeof content === 'string' ? content : String(content || '');
@@ -1250,7 +1267,8 @@ agentRenderer.code = function(code, infostring) {
const getTokenHTML = (token: any): string => {
try {
const html = marked.parser([token], { renderer: agentRenderer });
return DOMPurify.sanitize(html, DOMPurifyConfig);
const protectedHTML = protectProviderImageSrcInHTML(html);
return DOMPurify.sanitize(protectedHTML, DOMPurifyConfig);
} catch (e) {
console.error('Token rendering error:', e);
return '';
@@ -1267,13 +1285,26 @@ const renderMarkdown = (content: any): string => {
const html = marked.parse(processed, { renderer: agentRenderer }) as string;
if (!html) return '';
return DOMPurify.sanitize(html, DOMPurifyConfig);
const protectedHTML = protectProviderImageSrcInHTML(html);
return DOMPurify.sanitize(protectedHTML, DOMPurifyConfig);
} catch (e) {
console.error('Markdown rendering error:', e, 'Content:', contentStr.substring(0, 100));
return contentStr.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
};
const protectProviderImageSrcInHTML = (html: string): string => {
if (!html) return html;
const placeholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
return html.replace(
/<img\b([^>]*?)\ssrc=(["'])(local|minio|cos|tos):\/\/([^"']+)\2([^>]*)>/gi,
(_m, before, quote, provider, restPath, after) => {
const src = `${provider}://${restPath}`;
return `<img${before} src=${quote}${placeholder}${quote} data-protected-src=${quote}${src}${quote}${after}>`;
},
);
};
// 已渲染的 mermaid 元素 ID 集合
const renderedMermaidIds = new Set<string>();
@@ -3216,10 +3247,103 @@ const handleAddToKnowledge = (answerEvent: any) => {
}
}
:deep(.streaming-image-loading) {
display: inline-block;
position: relative;
width: clamp(150px, 30vw, 260px);
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: 10px;
border: 1px solid rgba(175, 190, 210, 0.55);
background: linear-gradient(
145deg,
rgba(245, 249, 255, 0.72) 0%,
rgba(228, 236, 248, 0.62) 45%,
rgba(214, 225, 242, 0.58) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.75),
inset 0 -1px 0 rgba(168, 182, 206, 0.28),
0 8px 20px rgba(100, 121, 152, 0.12);
backdrop-filter: blur(3px) saturate(115%);
-webkit-backdrop-filter: blur(3px) saturate(115%);
overflow: hidden;
vertical-align: middle;
animation: streamingImageBreath 2.2s ease-in-out infinite;
}
:deep(.streaming-image-loading__skeleton) {
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
rgba(234, 239, 246, 0.95) 8%,
rgba(248, 250, 252, 0.98) 18%,
rgba(234, 239, 246, 0.95) 33%
);
background-size: 220% 100%;
animation: streamingImageShimmer 1.4s linear infinite;
}
:deep(.streaming-image-loading)::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.45);
pointer-events: none;
}
:deep(.streaming-image-loading)::after {
content: '';
position: absolute;
left: -35%;
top: -55%;
width: 62%;
height: 210%;
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.38) 46%,
rgba(255, 255, 255, 0) 100%
);
transform: rotate(16deg);
animation: streamingImageMirror 2.8s ease-in-out infinite;
pointer-events: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes streamingImageShimmer {
to {
background-position-x: -220%;
}
}
@keyframes streamingImageMirror {
0%, 100% {
left: -38%;
opacity: 0.35;
}
50% {
left: 110%;
opacity: 0.75;
}
}
@keyframes streamingImageBreath {
0%, 100% {
transform: translateY(0) scale(1);
filter: saturate(1);
}
50% {
transform: translateY(-0.5px) scale(1.01);
filter: saturate(1.06);
}
}
</style>
+124 -8
View File
@@ -53,7 +53,7 @@ import docInfo from './docInfo.vue';
import deepThink from './deepThink.vue';
import AgentStreamDisplay from './AgentStreamDisplay.vue';
import picturePreview from '@/components/picture-preview.vue';
import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL } from '@/utils/security';
import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL, hydrateProtectedFileImages } from '@/utils/security';
import { openMermaidFullscreen } from '@/utils/mermaidViewer';
import { useI18n } from 'vue-i18n';
import { MessagePlugin } from 'tdesign-vue-next';
@@ -172,14 +172,35 @@ const markdownTokens = computed(() => {
if (!text || typeof text !== 'string') {
return [];
}
const processed = replaceIncompleteImageWithPlaceholder(text);
// 首先对 Markdown 内容进行安全处理
const safeMarkdown = safeMarkdownToHTML(text);
const safeMarkdown = safeMarkdownToHTML(processed);
// 使用 marked.lexer 分词
return marked.lexer(safeMarkdown);
});
const replaceIncompleteImageWithPlaceholder = (content) => {
const lastImgStart = content.lastIndexOf('![');
if (lastImgStart < 0) return content;
const tail = content.slice(lastImgStart);
const hasImageOpen = tail.startsWith('![');
const hasBracketClose = tail.includes(']');
const hasParenOpen = tail.includes('(');
const hasParenClose = tail.includes(')');
if (!hasImageOpen) return content;
// Incomplete image syntax at stream tail, e.g. ![alt](local://...
if (!hasBracketClose || (hasParenOpen && !hasParenClose)) {
const placeholder = `<span class="streaming-image-loading"><span class="streaming-image-loading__skeleton"></span></span>`;
return content.slice(0, lastImgStart) + placeholder;
}
return content;
};
// 计算属性:判断是否有实际内容(非空且不只是空白)
const hasActualContent = computed(() => {
const text = props.content || props.session?.content || '';
@@ -354,20 +375,22 @@ const bindMermaidClickEvents = () => {
// 监听内容变化并渲染 Mermaid - 只在会话完成后渲染
watch(() => [props.content, props.session?.content, props.session?.is_completed], () => {
// 只在会话完成后渲染 mermaid
if (props.session?.is_completed) {
nextTick(() => {
nextTick(async () => {
await hydrateProtectedFileImages(parentMd.value);
// 只在会话完成后渲染 mermaid
if (props.session?.is_completed) {
renderMermaidDiagrams();
});
}
}
});
}, { immediate: true });
onMounted(async () => {
// 为 markdown-content 中的图片添加点击事件
nextTick(() => {
nextTick(async () => {
if (parentMd.value) {
parentMd.value.addEventListener('click', handleMarkdownImageClick, true);
}
await hydrateProtectedFileImages(parentMd.value);
// 初始渲染 Mermaid 图表
renderMermaidDiagrams();
});
@@ -687,6 +710,99 @@ onBeforeUnmount(() => {
border-radius: 8px;
}
:deep(.streaming-image-loading) {
display: inline-block;
position: relative;
width: clamp(150px, 30vw, 260px);
max-width: 100%;
aspect-ratio: 4 / 3;
border-radius: 10px;
border: 1px solid rgba(175, 190, 210, 0.55);
background: linear-gradient(
145deg,
rgba(245, 249, 255, 0.72) 0%,
rgba(228, 236, 248, 0.62) 45%,
rgba(214, 225, 242, 0.58) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.75),
inset 0 -1px 0 rgba(168, 182, 206, 0.28),
0 8px 20px rgba(100, 121, 152, 0.12);
backdrop-filter: blur(3px) saturate(115%);
-webkit-backdrop-filter: blur(3px) saturate(115%);
overflow: hidden;
vertical-align: middle;
animation: streamingImageBreath 2.2s ease-in-out infinite;
}
:deep(.streaming-image-loading__skeleton) {
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
rgba(234, 239, 246, 0.95) 8%,
rgba(248, 250, 252, 0.98) 18%,
rgba(234, 239, 246, 0.95) 33%
);
background-size: 220% 100%;
animation: streamingImageShimmer 1.4s linear infinite;
}
:deep(.streaming-image-loading)::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.45);
pointer-events: none;
}
:deep(.streaming-image-loading)::after {
content: '';
position: absolute;
left: -35%;
top: -55%;
width: 62%;
height: 210%;
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.38) 46%,
rgba(255, 255, 255, 0) 100%
);
transform: rotate(16deg);
animation: streamingImageMirror 2.8s ease-in-out infinite;
pointer-events: none;
}
@keyframes streamingImageShimmer {
to {
background-position-x: -220%;
}
}
@keyframes streamingImageMirror {
0%, 100% {
left: -38%;
opacity: 0.35;
}
50% {
left: 110%;
opacity: 0.75;
}
}
@keyframes streamingImageBreath {
0%, 100% {
transform: translateY(0) scale(1);
filter: saturate(1);
}
50% {
transform: translateY(-0.5px) scale(1.01);
filter: saturate(1.06);
}
}
:deep(.t-loading__gradient-conic) {
background: conic-gradient(from 90deg at 50% 50%, #fff 0deg, #676767 360deg) !important;
+28 -6
View File
@@ -26,8 +26,12 @@ type cosFileService struct {
cosPathPrefix string
tempClient *cos.Client
tempBucketURL string
bucketName string
region string
}
const cosScheme = "cos://"
// newCosClient creates a bare cosFileService with just the SDK client initialised.
// Shared by NewCosFileService* constructors and CheckCosConnectivity.
func newCosClient(bucketName, region, secretID, secretKey string) (*cosFileService, error) {
@@ -43,7 +47,7 @@ func newCosClient(bucketName, region, secretID, secretKey string) (*cosFileServi
SecretKey: secretKey,
},
})
return &cosFileService{client: client, bucketURL: bucketURL}, nil
return &cosFileService{client: client, bucketURL: bucketURL, bucketName: bucketName, region: region}, nil
}
// NewCosFileService creates a new COS file service instance
@@ -114,12 +118,12 @@ func (s *cosFileService) SaveFile(ctx context.Context,
if err != nil {
return "", fmt.Errorf("failed to upload file to COS: %w", err)
}
return fmt.Sprintf("%s%s", s.bucketURL, objectName), nil
return fmt.Sprintf("cos://%s/%s/%s", s.bucketName, s.region, objectName), nil
}
// GetFile retrieves a file from COS storage by its path URL
func (s *cosFileService) GetFile(ctx context.Context, filePathUrl string) (io.ReadCloser, error) {
objectName := strings.TrimPrefix(filePathUrl, s.bucketURL)
objectName := s.parseCosObjectName(filePathUrl)
if err := utils.SafeObjectKey(objectName); err != nil {
return nil, fmt.Errorf("invalid file path: %w", err)
}
@@ -132,7 +136,7 @@ func (s *cosFileService) GetFile(ctx context.Context, filePathUrl string) (io.Re
// DeleteFile removes a file from COS storage
func (s *cosFileService) DeleteFile(ctx context.Context, filePath string) error {
objectName := strings.TrimPrefix(filePath, s.bucketURL)
objectName := s.parseCosObjectName(filePath)
if err := utils.SafeObjectKey(objectName); err != nil {
return fmt.Errorf("invalid file path: %w", err)
}
@@ -143,6 +147,23 @@ func (s *cosFileService) DeleteFile(ctx context.Context, filePath string) error
return nil
}
// parseCosObjectName extracts the object name from:
// - provider scheme: cos://{bucket}/{region}/{objectKey}
// - legacy URL: https://bucket.cos.region.myqcloud.com/{objectKey}
func (s *cosFileService) parseCosObjectName(filePath string) string {
// Provider scheme format: cos://{bucket}/{region}/{objectKey}
if strings.HasPrefix(filePath, cosScheme) {
rest := strings.TrimPrefix(filePath, cosScheme)
parts := strings.SplitN(rest, "/", 3)
if len(parts) == 3 {
return parts[2]
}
return rest
}
// Legacy format: https://bucket.cos.region.myqcloud.com/{objectKey}
return strings.TrimPrefix(filePath, s.bucketURL)
}
// SaveBytes saves bytes data to COS
// If temp is true and temp bucket is configured, saves to temp bucket (with lifecycle auto-expiration)
// Otherwise saves to main bucket
@@ -161,6 +182,7 @@ func (s *cosFileService) SaveBytes(ctx context.Context, data []byte, tenantID ui
if err != nil {
return "", fmt.Errorf("failed to upload bytes to COS temp bucket: %w", err)
}
// Temp bucket still uses legacy URL format for backward compat (auto-expiring)
return fmt.Sprintf("%s%s", s.tempBucketURL, objectName), nil
}
@@ -171,7 +193,7 @@ func (s *cosFileService) SaveBytes(ctx context.Context, data []byte, tenantID ui
return "", fmt.Errorf("failed to upload bytes to COS: %w", err)
}
return fmt.Sprintf("%s%s", s.bucketURL, objectName), nil
return fmt.Sprintf("cos://%s/%s/%s", s.bucketName, s.region, objectName), nil
}
// GetFileURL returns a presigned download URL for the file
@@ -190,7 +212,7 @@ func (s *cosFileService) GetFileURL(ctx context.Context, filePath string) (strin
return presignedURL.String(), nil
}
objectName := strings.TrimPrefix(filePath, s.bucketURL)
objectName := s.parseCosObjectName(filePath)
if err := utils.SafeObjectKey(objectName); err != nil {
return "", fmt.Errorf("invalid file path: %w", err)
}
@@ -0,0 +1,97 @@
package file
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// NewFileServiceFromStorageConfig builds a provider-specific FileService from tenant storage config.
// provider can be empty; in that case it falls back to sec.DefaultProvider.
// Returns the resolved provider name together with the service.
func NewFileServiceFromStorageConfig(
provider string,
sec *types.StorageEngineConfig,
localBaseDir string,
) (interfaces.FileService, string, error) {
p := strings.ToLower(strings.TrimSpace(provider))
if p == "" && sec != nil {
p = strings.ToLower(strings.TrimSpace(sec.DefaultProvider))
}
if p == "" {
return nil, "", fmt.Errorf("empty provider")
}
if localBaseDir == "" {
localBaseDir = strings.TrimSpace(os.Getenv("LOCAL_STORAGE_BASE_DIR"))
}
if localBaseDir == "" {
localBaseDir = "/data/files"
}
switch p {
case "local":
baseDir := localBaseDir
if sec != nil && sec.Local != nil {
rawPrefix := strings.TrimSpace(sec.Local.PathPrefix)
prefix := strings.Trim(rawPrefix, "/\\")
if prefix != "" {
candidate := filepath.Join(baseDir, prefix)
if safeBaseDir, err := secutils.SafePathUnderBase(baseDir, candidate); err == nil {
baseDir = safeBaseDir
}
}
}
return NewLocalFileService(baseDir), p, nil
case "minio":
if sec == nil || sec.MinIO == nil {
return nil, p, fmt.Errorf("missing minio config")
}
var endpoint, accessKeyID, secretAccessKey string
if sec.MinIO.Mode == "remote" {
endpoint = strings.TrimSpace(sec.MinIO.Endpoint)
accessKeyID = strings.TrimSpace(sec.MinIO.AccessKeyID)
secretAccessKey = strings.TrimSpace(sec.MinIO.SecretAccessKey)
} else {
endpoint = strings.TrimSpace(os.Getenv("MINIO_ENDPOINT"))
accessKeyID = strings.TrimSpace(os.Getenv("MINIO_ACCESS_KEY_ID"))
secretAccessKey = strings.TrimSpace(os.Getenv("MINIO_SECRET_ACCESS_KEY"))
}
bucketName := strings.TrimSpace(sec.MinIO.BucketName)
if bucketName == "" {
bucketName = strings.TrimSpace(os.Getenv("MINIO_BUCKET_NAME"))
}
if endpoint == "" || accessKeyID == "" || secretAccessKey == "" || bucketName == "" {
return nil, p, fmt.Errorf("incomplete minio config")
}
svc, err := NewMinioFileService(endpoint, accessKeyID, secretAccessKey, bucketName, sec.MinIO.UseSSL)
return svc, p, err
case "cos":
if sec == nil || sec.COS == nil || sec.COS.SecretID == "" || sec.COS.SecretKey == "" || sec.COS.BucketName == "" || sec.COS.Region == "" {
return nil, p, fmt.Errorf("incomplete cos config")
}
pathPrefix := strings.TrimSpace(sec.COS.PathPrefix)
if pathPrefix == "" {
pathPrefix = "weknora"
}
svc, err := NewCosFileService(sec.COS.BucketName, sec.COS.Region, sec.COS.SecretID, sec.COS.SecretKey, pathPrefix)
return svc, p, err
case "tos":
if sec == nil || sec.TOS == nil || sec.TOS.Endpoint == "" || sec.TOS.Region == "" || sec.TOS.AccessKey == "" || sec.TOS.SecretKey == "" || sec.TOS.BucketName == "" {
return nil, p, fmt.Errorf("incomplete tos config")
}
svc, err := NewTosFileService(sec.TOS.Endpoint, sec.TOS.Region, sec.TOS.AccessKey, sec.TOS.SecretKey, sec.TOS.BucketName, sec.TOS.PathPrefix)
return svc, p, err
default:
return nil, p, fmt.Errorf("unsupported provider %q", p)
}
}
+26 -5
View File
@@ -20,6 +20,8 @@ type localFileService struct {
baseDir string // Base directory for file storage
}
const localScheme = "local://"
// CheckConnectivity verifies the local storage directory exists and is accessible.
func (s *localFileService) CheckConnectivity(ctx context.Context) error {
info, err := os.Stat(s.baseDir)
@@ -93,11 +95,14 @@ func (s *localFileService) SaveFile(ctx context.Context,
}
logger.Infof(ctx, "File saved successfully: %s", filePath)
return filePath, nil
// Return provider:// path format: local://{relative_path}
relPath, _ := filepath.Rel(s.baseDir, filePath)
return localScheme + filepath.ToSlash(relPath), nil
}
// GetFile retrieves a file from the local file system by its path
// Returns a ReadCloser for reading the file content
// Supports both provider scheme: local://{relative_path} and legacy absolute paths.
// 路径必须在 baseDir 下,防止路径遍历(如 ../../)
func (s *localFileService) GetFile(ctx context.Context, filePath string) (io.ReadCloser, error) {
logger.Infof(ctx, "Getting file: %s", filePath)
@@ -174,21 +179,37 @@ func (s *localFileService) SaveBytes(ctx context.Context, data []byte, tenantID
}
logger.Infof(ctx, "Bytes data saved successfully: %s", filePath)
return filePath, nil
relPath, _ := filepath.Rel(s.baseDir, filePath)
return localScheme + filepath.ToSlash(relPath), nil
}
// GetFileURL returns a download URL for the file
// For local storage, returns the file path itself (no URL support)
// For local storage, returns the local://... path
func (s *localFileService) GetFileURL(ctx context.Context, filePath string) (string, error) {
// Local storage doesn't support URLs, return the path
return filePath, nil
// If already in provider:// format, return as-is
if strings.HasPrefix(filePath, localScheme) {
return filePath, nil
}
// Convert absolute path to provider:// format
relPath, err := filepath.Rel(s.baseDir, filePath)
if err != nil {
return filePath, nil
}
return localScheme + filepath.ToSlash(relPath), nil
}
// normalizePathForBase keeps backward compatibility for legacy file paths:
// - provider scheme: "local://tenant/.." → baseDir/tenant/..
// - absolute path: "/data/files/tenant/.."
// - path under base dir: "tenant/.."
// - legacy relative with base prefix: "data/files/tenant/.."
func (s *localFileService) normalizePathForBase(filePath string) string {
// Handle provider:// format: local://{relPath}
if strings.HasPrefix(filePath, localScheme) {
relPath := strings.TrimPrefix(filePath, localScheme)
return filepath.Join(s.baseDir, filepath.FromSlash(relPath))
}
clean := filepath.Clean(strings.TrimSpace(filePath))
if clean == "." || clean == "" {
return clean
+2 -3
View File
@@ -90,9 +90,9 @@ func CheckMinioConnectivity(ctx context.Context, endpoint, accessKeyID, secretAc
return svc.CheckConnectivity(ctx)
}
// parseMinioFilePath extracts the object name from a "minio://bucket/key" path,
// validates the bucket matches this service instance, and checks for path traversal.
// parseMinioFilePath extracts the object name from a provider scheme: minio://{bucket}/{objectKey}
func (s *minioFileService) parseMinioFilePath(filePath string) (string, error) {
// Provider scheme format: minio://{bucket}/{objectKey}
const prefix = "minio://"
if !strings.HasPrefix(filePath, prefix) {
return "", fmt.Errorf("invalid MinIO file path: %s", filePath)
@@ -134,7 +134,6 @@ func (s *minioFileService) SaveFile(ctx context.Context,
return "", fmt.Errorf("failed to upload file to MinIO: %w", err)
}
// Return the complete path to the object
return fmt.Sprintf("minio://%s/%s", s.bucketName, objectName), nil
}
+4 -3
View File
@@ -26,6 +26,8 @@ type tosFileService struct {
tempBucketName string
}
const tosScheme = "tos://"
// NewTosFileService creates a TOS file service.
func NewTosFileService(endpoint, region, accessKey, secretKey, bucketName, pathPrefix string) (interfaces.FileService, error) {
return NewTosFileServiceWithTempBucket(endpoint, region, accessKey, secretKey, bucketName, pathPrefix, "", "")
@@ -137,12 +139,11 @@ func joinTOSObjectKey(parts ...string) string {
}
func parseTOSFilePath(filePath string) (bucketName string, objectKey string, err error) {
const prefix = "tos://"
if !strings.HasPrefix(filePath, prefix) {
if !strings.HasPrefix(filePath, tosScheme) {
return "", "", fmt.Errorf("invalid TOS file path: %s", filePath)
}
rest := strings.TrimPrefix(filePath, prefix)
rest := strings.TrimPrefix(filePath, tosScheme)
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid TOS file path: %s", filePath)
@@ -4,10 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
filesvc "github.com/Tencent/WeKnora/internal/application/service/file"
"github.com/Tencent/WeKnora/internal/application/service/retriever"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/utils/ollama"
@@ -31,8 +33,8 @@ const (
)
// ImageMultimodalService handles image:multimodal asynq tasks.
// It reads images from local storage, performs OCR and VLM caption,
// and creates child chunks for the processed content.
// It reads images from storage (via FileService for provider:// URLs),
// performs OCR and VLM caption, and creates child chunks.
type ImageMultimodalService struct {
chunkService interfaces.ChunkService
modelService interfaces.ModelService
@@ -70,8 +72,8 @@ func (s *ImageMultimodalService) Handle(ctx context.Context, task *asynq.Task) e
return fmt.Errorf("unmarshal image multimodal payload: %w", err)
}
logger.Infof(ctx, "[ImageMultimodal] Processing image: chunk=%s, url=%s, local_path=%s, ocr=%v, caption=%v",
payload.ChunkID, payload.ImageURL, payload.ImageLocalPath, payload.EnableOCR, payload.EnableCaption)
logger.Infof(ctx, "[ImageMultimodal] Processing image: chunk=%s, url=%s, ocr=%v, caption=%v",
payload.ChunkID, payload.ImageURL, payload.EnableOCR, payload.EnableCaption)
ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID)
@@ -80,24 +82,43 @@ func (s *ImageMultimodalService) Handle(ctx context.Context, task *asynq.Task) e
return fmt.Errorf("resolve VLM: %w", err)
}
imgBytes, err := os.ReadFile(payload.ImageLocalPath)
if err != nil {
logger.Warnf(ctx, "[ImageMultimodal] Local file %s not available (%v), trying to download from URL", payload.ImageLocalPath, err)
// Read image bytes: try provider:// via tenant-resolved FileService,
// then legacy local path, then HTTP URL.
var imgBytes []byte
if types.ParseProviderScheme(payload.ImageURL) != "" {
fileSvc := s.resolveFileServiceForPayload(ctx, payload)
if fileSvc == nil {
logger.Warnf(ctx, "[ImageMultimodal] Resolve tenant file service failed, fallback to URL/local: tenant=%d kb=%s",
payload.TenantID, payload.KnowledgeBaseID)
} else {
// provider:// scheme — read via FileService
reader, getErr := fileSvc.GetFile(ctx, payload.ImageURL)
if getErr != nil {
logger.Warnf(ctx, "[ImageMultimodal] FileService.GetFile(%s) failed: %v", payload.ImageURL, getErr)
} else {
imgBytes, err = io.ReadAll(reader)
reader.Close()
if err != nil {
logger.Warnf(ctx, "[ImageMultimodal] Read provider file %s failed: %v", payload.ImageURL, err)
imgBytes = nil
}
}
}
}
if imgBytes == nil && payload.ImageLocalPath != "" {
imgBytes, err = os.ReadFile(payload.ImageLocalPath)
if err != nil {
logger.Warnf(ctx, "[ImageMultimodal] Local file %s not available (%v), trying URL", payload.ImageLocalPath, err)
imgBytes = nil
}
}
if imgBytes == nil {
imgBytes, err = downloadImageFromURL(payload.ImageURL)
if err != nil {
logger.Errorf(ctx, "[ImageMultimodal] Failed to download image from URL %s: %v", payload.ImageURL, err)
return fmt.Errorf("read image: local file %s not found and URL download failed: %w", payload.ImageLocalPath, err)
return fmt.Errorf("read image from URL %s failed: %w", payload.ImageURL, err)
}
logger.Infof(ctx, "[ImageMultimodal] Image downloaded from URL, len=%d", len(imgBytes))
} else {
logger.Infof(ctx, "[ImageMultimodal] Image bytes read from %s, len=%d", payload.ImageLocalPath, len(imgBytes))
// Only clean up the local file when images have been re-uploaded to
// external object storage (COS/TOS) — the serving URL will be an
// absolute http(s) URL in that case. For local and MinIO storage the
// local file IS the serving file and must be kept.
if strings.HasPrefix(payload.ImageURL, "http://") || strings.HasPrefix(payload.ImageURL, "https://") {
defer os.Remove(payload.ImageLocalPath)
}
}
imageInfo := types.ImageInfo{
@@ -352,6 +373,33 @@ func (s *ImageMultimodalService) resolveVLM(ctx context.Context, kbID string) (v
return vlm.NewVLMFromLegacyConfig(vlmCfg, s.ollamaService)
}
// resolveFileServiceForPayload resolves tenant/KB scoped file service for reading provider:// URLs.
func (s *ImageMultimodalService) resolveFileServiceForPayload(ctx context.Context, payload types.ImageMultimodalPayload) interfaces.FileService {
tenant, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID)
if err != nil || tenant == nil {
logger.Warnf(ctx, "[ImageMultimodal] GetTenantByID failed: tenant=%d err=%v", payload.TenantID, err)
return nil
}
provider := types.ParseProviderScheme(payload.ImageURL)
if provider == "" {
kb, kbErr := s.kbService.GetKnowledgeBaseByIDOnly(ctx, payload.KnowledgeBaseID)
if kbErr != nil {
logger.Warnf(ctx, "[ImageMultimodal] GetKnowledgeBaseByIDOnly failed: kb=%s err=%v", payload.KnowledgeBaseID, kbErr)
} else if kb != nil {
provider = strings.ToLower(strings.TrimSpace(kb.GetStorageProvider()))
}
}
baseDir := strings.TrimSpace(os.Getenv("LOCAL_STORAGE_BASE_DIR"))
fileSvc, _, svcErr := filesvc.NewFileServiceFromStorageConfig(provider, tenant.StorageEngineConfig, baseDir)
if svcErr != nil {
logger.Warnf(ctx, "[ImageMultimodal] resolve file service failed: tenant=%d provider=%s err=%v", payload.TenantID, provider, svcErr)
return nil
}
return fileSvc
}
// downloadImageFromURL downloads image bytes from an HTTP(S) URL.
func downloadImageFromURL(imageURL string) ([]byte, error) {
return secutils.DownloadBytes(imageURL)
+6 -285
View File
@@ -13,7 +13,6 @@ import (
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
@@ -6845,79 +6844,13 @@ func (s *knowledgeService) resolveFileService(ctx context.Context, kb *types.Kno
}
sec := tenant.StorageEngineConfig
var svc interfaces.FileService
var err error
switch provider {
case "local":
baseDir := strings.TrimSpace(os.Getenv("LOCAL_STORAGE_BASE_DIR"))
if baseDir == "" {
baseDir = "/data/files"
}
if sec.Local != nil && sec.Local.PathPrefix != "" {
// path_prefix is a subdirectory under local storage base dir, not a replacement.
// Validate it cannot escape baseDir (e.g. path traversal via "..").
rawPrefix := strings.TrimSpace(sec.Local.PathPrefix)
prefix := strings.Trim(rawPrefix, "/\\")
if prefix != "" {
candidate := filepath.Join(baseDir, prefix)
safeBaseDir, pathErr := secutils.SafePathUnderBase(baseDir, candidate)
if pathErr != nil {
logger.Warnf(ctx,
"[storage] invalid local path_prefix ignored: kb=%s raw_prefix=%q err=%v",
kb.ID, rawPrefix, pathErr)
} else {
baseDir = safeBaseDir
}
}
}
logger.Infof(ctx, "[storage] resolveFileService local: kb=%s base_dir=%s", kb.ID, baseDir)
return filesvc.NewLocalFileService(baseDir)
case "minio":
if sec.MinIO == nil {
return s.fileSvc
}
var endpoint, accessKeyID, secretAccessKey string
if sec.MinIO.Mode == "remote" {
endpoint = sec.MinIO.Endpoint
accessKeyID = sec.MinIO.AccessKeyID
secretAccessKey = sec.MinIO.SecretAccessKey
} else {
endpoint = os.Getenv("MINIO_ENDPOINT")
accessKeyID = os.Getenv("MINIO_ACCESS_KEY_ID")
secretAccessKey = os.Getenv("MINIO_SECRET_ACCESS_KEY")
}
bucketName := sec.MinIO.BucketName
if bucketName == "" {
bucketName = os.Getenv("MINIO_BUCKET_NAME")
}
if endpoint == "" || accessKeyID == "" || secretAccessKey == "" || bucketName == "" {
logger.Warnf(ctx, "Incomplete MinIO config for KB storage, falling back to default")
return s.fileSvc
}
svc, err = filesvc.NewMinioFileService(endpoint, accessKeyID, secretAccessKey, bucketName, sec.MinIO.UseSSL)
case "cos":
if sec.COS == nil || sec.COS.SecretID == "" || sec.COS.BucketName == "" || sec.COS.Region == "" {
logger.Warnf(ctx, "Incomplete COS config for KB storage, falling back to default")
return s.fileSvc
}
pathPrefix := sec.COS.PathPrefix
if pathPrefix == "" {
pathPrefix = "weknora"
}
svc, err = filesvc.NewCosFileService(sec.COS.BucketName, sec.COS.Region, sec.COS.SecretID, sec.COS.SecretKey, pathPrefix)
default:
return s.fileSvc
}
baseDir := strings.TrimSpace(os.Getenv("LOCAL_STORAGE_BASE_DIR"))
svc, resolvedProvider, err := filesvc.NewFileServiceFromStorageConfig(provider, sec, baseDir)
if err != nil {
logger.Errorf(ctx, "Failed to create %s file service from tenant config: %v, falling back to default", provider, err)
return s.fileSvc
}
logger.Infof(ctx, "[storage] resolveFileService selected: kb=%s provider=%s", kb.ID, provider)
logger.Infof(ctx, "[storage] resolveFileService selected: kb=%s provider=%s", kb.ID, resolvedProvider)
return svc
}
@@ -7236,7 +7169,9 @@ func (s *knowledgeService) ProcessDocument(ctx context.Context, t *asynq.Task) e
// Step 2: Store images and update markdown references
var storedImages []docparser.StoredImage
if s.imageResolver != nil && convertResult != nil {
updatedMarkdown, images, resolveErr := s.imageResolver.ResolveAndStore(convertResult)
fileSvc := s.resolveFileService(ctx, kb)
tenantID, _ := ctx.Value(types.TenantIDContextKey).(uint64)
updatedMarkdown, images, resolveErr := s.imageResolver.ResolveAndStore(ctx, convertResult, fileSvc, tenantID)
if resolveErr != nil {
logger.Warnf(ctx, "Image resolution partially failed: %v", resolveErr)
}
@@ -7245,14 +7180,6 @@ func (s *knowledgeService) ProcessDocument(ctx context.Context, t *asynq.Task) e
}
storedImages = images
logger.Infof(ctx, "Resolved %d images for knowledge %s", len(storedImages), knowledge.ID)
// If KB uses object storage (COS/MinIO), re-upload images from local to object storage
if len(storedImages) > 0 {
fileSvc := s.resolveFileService(ctx, kb)
reuploadedMd, reuploadedImages := s.reuploadImagesToStorage(ctx, kb, fileSvc, storedImages, convertResult.MarkdownContent)
convertResult.MarkdownContent = reuploadedMd
storedImages = reuploadedImages
}
}
// Step 3: Split into chunks using Go chunker
@@ -7291,13 +7218,6 @@ func (s *knowledgeService) ProcessDocument(ctx context.Context, t *asynq.Task) e
StoredImages: storedImages,
})
// Step 5: Clean up temp dir
if convertResult.ImageDirPath != "" {
if removeErr := os.RemoveAll(convertResult.ImageDirPath); removeErr != nil {
logger.Warnf(ctx, "Failed to clean up temp image dir %s: %v", convertResult.ImageDirPath, removeErr)
}
}
return nil
}
@@ -7341,27 +7261,12 @@ func (s *knowledgeService) convert(
return nil, nil
}
imageStorage := s.buildImageStorageConfig(ctx, kb)
if imageStorage == nil {
logger.Infof(ctx, "[storage] chunk image storage: kb=%s provider=%s image_storage=nil(use local temp/files)",
kb.ID, kb.GetStorageProvider())
} else {
logger.Infof(ctx, "[storage] chunk image storage: kb=%s provider=%s bucket=%s endpoint=%s path_prefix=%s",
kb.ID,
imageStorage["provider"],
imageStorage["bucket_name"],
imageStorage["endpoint"],
imageStorage["path_prefix"],
)
}
req := &types.ReadRequest{
URL: payload.URL,
Title: knowledge.Title,
ParserEngine: parserEngine,
RequestID: payload.RequestId,
ParserEngineOverrides: overrides,
ImageStorage: imageStorage,
}
if !isURL {
@@ -7429,189 +7334,6 @@ func (s *knowledgeService) failKnowledge(
return nil, fmt.Errorf(format, args...)
}
// reuploadImagesToStorage re-uploads locally stored images to the KB's object storage
// (COS/MinIO) when the KB is not using local storage. It updates the serving URLs in
// both the markdown content and the StoredImage list.
// Local copies are intentionally kept because async tasks (e.g. image:multimodal)
// may still need to read them; they are cleaned up after those tasks complete.
// Returns the original inputs unchanged when the storage provider is "local" or empty.
func (s *knowledgeService) reuploadImagesToStorage(
ctx context.Context,
kb *types.KnowledgeBase,
fileSvc interfaces.FileService,
images []docparser.StoredImage,
markdown string,
) (string, []docparser.StoredImage) {
provider := kb.GetStorageProvider()
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if provider == "" && tenant != nil && tenant.StorageEngineConfig != nil {
provider = strings.ToLower(strings.TrimSpace(tenant.StorageEngineConfig.DefaultProvider))
}
if provider == "" || provider == "local" {
return markdown, images
}
tenantID, _ := ctx.Value(types.TenantIDContextKey).(uint64)
updated := make([]docparser.StoredImage, len(images))
copy(updated, images)
for i, img := range updated {
data, err := os.ReadFile(img.LocalPath)
if err != nil {
logger.Warnf(ctx, "reuploadImagesToStorage: cannot read local image %s: %v", img.LocalPath, err)
continue
}
storagePath, err := fileSvc.SaveBytes(ctx, data, tenantID, filepath.Base(img.LocalPath), false)
if err != nil {
logger.Warnf(ctx, "reuploadImagesToStorage: failed to upload image to %s: %v", provider, err)
continue
}
// For COS, SaveBytes returns a direct public URL (https://bucket.cos...).
// Use it directly instead of GetFileURL which returns a presigned URL that
// expires after 24h — unsuitable for permanent markdown image references.
// For MinIO, SaveBytes returns minio://... which is not a usable URL, so
// keep the local /files/images/... serving URL (the app proxies MinIO files).
storageURL := storagePath
if provider == "minio" {
storageURL = img.ServingURL
}
if storageURL != img.ServingURL {
logger.Infof(ctx, "reuploadImagesToStorage: uploaded %s -> %s", img.ServingURL, storageURL)
markdown = strings.ReplaceAll(markdown, img.ServingURL, storageURL)
updated[i].ServingURL = storageURL
} else {
logger.Infof(ctx, "reuploadImagesToStorage: uploaded %s to %s storage (serving via local URL)", img.ServingURL, provider)
}
}
return markdown, updated
}
// buildImageStorageConfig returns storage config map to pass to DocReader so it can
// upload images to shared storage. Returns nil for local storage (use temp dir / inline bytes).
// Prioritises KB/tenant-level StorageEngineConfig; falls back to environment variables.
func (s *knowledgeService) buildImageStorageConfig(ctx context.Context, kb *types.KnowledgeBase) map[string]string {
provider := ""
if kb != nil {
provider = kb.GetStorageProvider()
}
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if provider == "" && tenant != nil && tenant.StorageEngineConfig != nil {
provider = strings.ToLower(strings.TrimSpace(tenant.StorageEngineConfig.DefaultProvider))
}
// Try tenant-level StorageEngineConfig first
if tenant != nil && tenant.StorageEngineConfig != nil {
sec := tenant.StorageEngineConfig
switch provider {
case "cos":
if sec.COS != nil && sec.COS.SecretID != "" && sec.COS.BucketName != "" && sec.COS.Region != "" {
m := map[string]string{
"provider": "cos",
"bucket_name": sec.COS.BucketName,
"access_key_id": sec.COS.SecretID,
"secret_access_key": sec.COS.SecretKey,
"region": sec.COS.Region,
}
if sec.COS.AppID != "" {
m["app_id"] = sec.COS.AppID
}
if sec.COS.PathPrefix != "" {
m["path_prefix"] = sec.COS.PathPrefix
}
return m
}
case "minio":
if sec.MinIO != nil {
var endpoint, accessKey, secretKey, bucket string
if sec.MinIO.Mode == "remote" {
endpoint = sec.MinIO.Endpoint
accessKey = sec.MinIO.AccessKeyID
secretKey = sec.MinIO.SecretAccessKey
} else {
endpoint = os.Getenv("MINIO_ENDPOINT")
accessKey = os.Getenv("MINIO_ACCESS_KEY_ID")
secretKey = os.Getenv("MINIO_SECRET_ACCESS_KEY")
}
bucket = sec.MinIO.BucketName
if bucket == "" {
bucket = os.Getenv("MINIO_BUCKET_NAME")
}
if endpoint != "" && accessKey != "" && secretKey != "" && bucket != "" {
m := map[string]string{
"provider": "minio",
"endpoint": endpoint,
"bucket_name": bucket,
"access_key_id": accessKey,
"secret_access_key": secretKey,
}
if sec.MinIO.UseSSL {
m["use_ssl"] = "true"
}
if sec.MinIO.PathPrefix != "" {
m["path_prefix"] = sec.MinIO.PathPrefix
}
return m
}
}
}
}
// Fallback: read from environment variables
storageType := strings.TrimSpace(os.Getenv("STORAGE_TYPE"))
switch storageType {
case "minio":
endpoint := os.Getenv("MINIO_ENDPOINT")
accessKey := os.Getenv("MINIO_ACCESS_KEY_ID")
secretKey := os.Getenv("MINIO_SECRET_ACCESS_KEY")
bucket := os.Getenv("MINIO_BUCKET_NAME")
if endpoint == "" || accessKey == "" || secretKey == "" || bucket == "" {
return nil
}
m := map[string]string{
"provider": "minio",
"endpoint": endpoint,
"bucket_name": bucket,
"access_key_id": accessKey,
"secret_access_key": secretKey,
}
if v := os.Getenv("MINIO_USE_SSL"); v != "" {
m["use_ssl"] = v
}
if v := os.Getenv("MINIO_PATH_PREFIX"); v != "" {
m["path_prefix"] = v
}
return m
case "cos":
secretID := os.Getenv("COS_SECRET_ID")
secretKey := os.Getenv("COS_SECRET_KEY")
region := os.Getenv("COS_REGION")
bucket := os.Getenv("COS_BUCKET_NAME")
if secretID == "" || secretKey == "" || region == "" || bucket == "" {
return nil
}
m := map[string]string{
"provider": "cos",
"bucket_name": bucket,
"access_key_id": secretID,
"secret_access_key": secretKey,
"region": region,
}
if v := os.Getenv("COS_APP_ID"); v != "" {
m["app_id"] = v
}
if v := os.Getenv("COS_PATH_PREFIX"); v != "" {
m["path_prefix"] = v
}
return m
default:
return nil
}
}
// enqueueImageMultimodalTasks enqueues asynq tasks for multimodal image processing.
func (s *knowledgeService) enqueueImageMultimodalTasks(
ctx context.Context,
@@ -7644,7 +7366,6 @@ func (s *knowledgeService) enqueueImageMultimodalTasks(
KnowledgeBaseID: kb.ID,
ChunkID: chunkID,
ImageURL: img.ServingURL,
ImageLocalPath: img.LocalPath,
EnableOCR: true,
EnableCaption: true,
}
+1 -14
View File
@@ -98,7 +98,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
// External service clients
logger.Debugf(ctx, "[Container] Registering external service clients...")
must(container.Provide(initDocReaderClient))
must(container.Provide(initImageResolver))
must(container.Provide(docparser.NewImageResolver))
must(container.Provide(initOllamaService))
must(container.Provide(initNeo4jClient))
must(container.Provide(stream.NewStreamManager))
@@ -762,19 +762,6 @@ func initDocReaderClient(cfg *config.Config) (interfaces.DocumentReader, error)
}
}
// initImageResolver creates the ImageResolver for storing images from DocReader.
func initImageResolver(cfg *config.Config) *docparser.ImageResolver {
baseDir := os.Getenv("LOCAL_STORAGE_BASE_DIR")
if baseDir == "" {
baseDir = "/data/files"
}
urlPrefix := os.Getenv("LOCAL_STORAGE_URL_PREFIX")
if urlPrefix == "" {
urlPrefix = "/files"
}
return docparser.NewImageResolver(baseDir, urlPrefix)
}
// initOllamaService initializes the Ollama service client
// Creates a client for interacting with Ollama API for model inference
// Parameters:
@@ -120,7 +120,6 @@ func (p *GRPCDocumentReader) Read(ctx context.Context, req *types.ReadRequest) (
Config: &proto.ReadConfig{
ParserEngine: req.ParserEngine,
ParserEngineOverrides: req.ParserEngineOverrides,
ImageStorage: req.ImageStorage,
},
}
@@ -25,7 +25,6 @@ const (
type httpReadConfig struct {
ParserEngine string `json:"parser_engine,omitempty"`
ParserEngineOverrides map[string]string `json:"parser_engine_overrides,omitempty"`
ImageStorage map[string]string `json:"image_storage,omitempty"`
}
type httpReadRequest struct {
@@ -197,7 +196,6 @@ func (p *HTTPDocumentReader) Read(ctx context.Context, req *types.ReadRequest) (
Config: &httpReadConfig{
ParserEngine: req.ParserEngine,
ParserEngineOverrides: req.ParserEngineOverrides,
ImageStorage: req.ImageStorage,
},
}
if len(req.FileContent) > 0 {
@@ -1,63 +1,50 @@
package docparser
import (
"fmt"
"os"
"context"
"log"
"path/filepath"
"regexp"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/utils"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
)
// StoredImage describes an image that has been saved to local storage.
// StoredImage describes an image that has been saved to storage.
type StoredImage struct {
OriginalRef string // reference in the original markdown
LocalPath string // absolute path on disk (/data/files/images/xxx.png)
ServingURL string // URL for frontend access (/files/images/xxx.png)
ServingURL string // provider:// URL (e.g. local://images/xxx.png, minio://bucket/key)
MimeType string
}
// ImageResolver reads images from a DocReader ReadResult (temp dir or shared storage)
// and saves them to the local file-serving directory, replacing markdown references.
// ImageResolver reads images from a DocReader ReadResult (inline bytes only)
// and saves them via FileService, replacing markdown references with unified URLs.
type ImageResolver struct {
// LocalStorageDir is where images are persisted (e.g. /data/files)
LocalStorageDir string
// URLPrefix is the HTTP path prefix for serving (e.g. /files)
URLPrefix string
// TenantID for storage path namespacing
TenantID uint64
}
// NewImageResolver creates a resolver with defaults from environment.
func NewImageResolver(localStorageDir, urlPrefix string) *ImageResolver {
if localStorageDir == "" {
localStorageDir = "/data/files"
}
if urlPrefix == "" {
urlPrefix = "/files"
}
return &ImageResolver{
LocalStorageDir: localStorageDir,
URLPrefix: urlPrefix,
}
// NewImageResolver creates a resolver.
func NewImageResolver() *ImageResolver {
return &ImageResolver{}
}
// ResolveAndStore reads images from the convert result and persists them.
// It returns the updated markdown with replaced image refs and a list of stored images.
// ResolveAndStore reads images from the convert result, persists them via fileSvc,
// and replaces markdown references with provider:// URLs.
// It returns the updated markdown and a list of stored images.
func (r *ImageResolver) ResolveAndStore(
ctx context.Context,
result *types.ReadResult,
fileSvc interfaces.FileService,
tenantID uint64,
) (updatedMarkdown string, images []StoredImage, err error) {
markdown := result.MarkdownContent
if len(result.ImageRefs) == 0 && result.ImageDirPath == "" {
if len(result.ImageRefs) == 0 {
return markdown, nil, nil
}
imageDir := filepath.Join(r.LocalStorageDir, "images")
if err := os.MkdirAll(imageDir, 0o755); err != nil {
return markdown, nil, fmt.Errorf("create image dir: %w", err)
}
// Build a map of original_ref -> image ref for fast lookup
refMap := make(map[string]types.ImageRef)
for _, ref := range result.ImageRefs {
@@ -73,41 +60,39 @@ func (r *ImageResolver) ResolveAndStore(
m := matches[i]
refPath := markdown[m[4]:m[5]] // group 2: the URL/path
// Skip already-resolved URLs
if strings.HasPrefix(refPath, "http://") || strings.HasPrefix(refPath, "https://") || strings.HasPrefix(refPath, "/files/") {
// Skip already-resolved URLs (http/https, unified /files/, or provider:// scheme)
if strings.HasPrefix(refPath, "http://") || strings.HasPrefix(refPath, "https://") ||
isProviderScheme(refPath) {
continue
}
// Try to find the image data
imgBytes, mimeType, err := r.readImageBytes(result, refPath, refMap)
if err != nil || imgBytes == nil {
// Find inline image bytes from the result
ref, found := refMap[refPath]
if !found || len(ref.ImageData) == 0 {
continue
}
// Determine extension
ext := extFromMime(mimeType)
ext := extFromMime(ref.MimeType)
if ext == "" {
ext = filepath.Ext(refPath)
ext = filepath.Ext(ref.Filename)
}
if ext == "" {
ext = ".png"
}
// Save to local storage
newName := uuid.New().String() + ext
destPath := filepath.Join(imageDir, newName)
if writeErr := os.WriteFile(destPath, imgBytes, 0o644); writeErr != nil {
logger.Printf("WARN: failed to save image %s: %v", refPath, writeErr)
// Save via FileService — returns provider:// path
fileName := uuid.New().String() + ext
servingURL, saveErr := fileSvc.SaveBytes(ctx, ref.ImageData, tenantID, fileName, false)
if saveErr != nil {
log.Printf("WARN: failed to save image %s: %v", refPath, saveErr)
continue
}
servingURL := r.URLPrefix + "/images/" + newName
images = append(images, StoredImage{
OriginalRef: refPath,
LocalPath: destPath,
ServingURL: servingURL,
MimeType: mimeType,
MimeType: ref.MimeType,
})
// Replace in markdown
@@ -117,67 +102,6 @@ func (r *ImageResolver) ResolveAndStore(
return markdown, images, nil
}
// readImageBytes resolves image data with cascading fallback:
// - Mode A: shared temp directory (same-machine / Docker volume)
// - Mode C: inline bytes carried in gRPC response (universal, cross-machine)
// - Mode B: shared object storage via storage_key (future)
func (r *ImageResolver) readImageBytes(
result *types.ReadResult,
refPath string,
refMap map[string]types.ImageRef,
) ([]byte, string, error) {
ref, found := refMap[refPath]
// Mode A: read from shared temp directory
if result.ImageDirPath != "" {
candidates := []string{
filepath.Join(result.ImageDirPath, "images", filepath.Base(refPath)),
filepath.Join(result.ImageDirPath, refPath),
}
for _, candidate := range candidates {
data, err := os.ReadFile(candidate)
if err == nil {
mime := "application/octet-stream"
if found {
mime = ref.MimeType
}
if mime == "" {
mime = mimeFromExt(filepath.Ext(candidate))
}
return data, mime, nil
}
}
}
// Mode C: inline image bytes from gRPC/HTTP response
if found && len(ref.ImageData) > 0 {
mime := ref.MimeType
if mime == "" {
mime = mimeFromExt(filepath.Ext(ref.Filename))
}
return ref.ImageData, mime, nil
}
// Mode B: shared object storage — storage_key is a download URL returned by storage.upload_bytes()
if found && ref.StorageKey != "" {
data, err := downloadImage(ref.StorageKey)
if err != nil {
return nil, "", fmt.Errorf("download image from storage %s: %w", ref.StorageKey, err)
}
mime := ref.MimeType
if mime == "" {
mime = mimeFromExt(filepath.Ext(ref.Filename))
}
return data, mime, nil
}
return nil, "", fmt.Errorf("image not found: %s", refPath)
}
func downloadImage(url string) ([]byte, error) {
return utils.DownloadBytes(url)
}
func extFromMime(mime string) string {
switch mime {
case "image/png":
@@ -195,19 +119,12 @@ func extFromMime(mime string) string {
}
}
func mimeFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".bmp":
return "image/bmp"
default:
return "application/octet-stream"
// isProviderScheme checks if the path uses a provider:// scheme (local://, minio://, cos://, tos://).
func isProviderScheme(path string) bool {
for _, prefix := range []string{"local://", "minio://", "cos://", "tos://"} {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
+61 -24
View File
@@ -2,12 +2,14 @@ package router
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
filesvc "github.com/Tencent/WeKnora/internal/application/service/file"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
@@ -19,6 +21,7 @@ import (
"github.com/Tencent/WeKnora/internal/handler/session"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
_ "github.com/Tencent/WeKnora/docs" // swagger docs
@@ -95,9 +98,6 @@ func NewRouter(params RouterParams) *gin.Engine {
))
}
// 本地存储文件服务(无需认证,用于 <img> 等标签访问图片)
serveLocalFiles(r)
// 前端静态文件(仅 Lite 版本内嵌前端)
if handler.Edition == "lite" {
serveFrontendStatic(r)
@@ -106,6 +106,9 @@ func NewRouter(params RouterParams) *gin.Engine {
// 认证中间件
r.Use(middleware.Auth(params.TenantService, params.UserService, params.Config))
// 文件服务:统一代理本地/MinIO/COS/TOS存储后端(需要认证)
serveFiles(r)
// 添加OpenTelemetry追踪中间件
// r.Use(middleware.TracingMiddleware())
@@ -601,10 +604,12 @@ func serveFrontendStatic(r *gin.Engine) {
})
}
// serveLocalFiles serves files from the local storage directory (e.g. /data/files)
// at the /files/ URL path. Registered before auth middleware so that <img> tags
// in rendered markdown can load images without authentication tokens.
func serveLocalFiles(r *gin.Engine) {
// serveFiles serves files via query parameters and tenant storage settings.
// It is registered after auth middleware, so tenant context comes from authentication.
//
// Route:
// - /files?file_path=<provider://...>
func serveFiles(r *gin.Engine) {
baseDir := os.Getenv("LOCAL_STORAGE_BASE_DIR")
if baseDir == "" {
baseDir = "/data/files"
@@ -613,35 +618,67 @@ func serveLocalFiles(r *gin.Engine) {
if info, err := os.Stat(absDir); err != nil || !info.IsDir() {
if err := os.MkdirAll(absDir, 0o755); err != nil {
logger.Warnf(context.Background(), "[Router] Cannot create local storage dir %s: %v", absDir, err)
return
}
}
logger.Infof(context.Background(), "[Router] Serving local storage files from %s at /files/", absDir)
logger.Infof(context.Background(), "[Router] Serving files from /files (local base: %s)", absDir)
r.GET("/files/*filepath", func(c *gin.Context) {
reqPath := strings.TrimPrefix(c.Param("filepath"), "/")
cleanPath := filepath.Clean(reqPath)
if cleanPath == "." || strings.HasPrefix(cleanPath, "..") {
logger.Warnf(context.Background(), "[Router] Reject invalid /files path: raw=%q clean=%q", c.Param("filepath"), cleanPath)
c.Status(http.StatusForbidden)
r.GET("/files", func(c *gin.Context) {
filePath := strings.TrimSpace(c.Query("file_path"))
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing required parameter: file_path"})
return
}
fullPath := filepath.Join(absDir, cleanPath)
if !strings.HasPrefix(fullPath, absDir) {
logger.Warnf(context.Background(), "[Router] Reject escaped /files path: raw=%q full=%q base=%q", c.Param("filepath"), fullPath, absDir)
c.Status(http.StatusForbidden)
provider := types.ParseProviderScheme(filePath)
tenant, _ := c.Request.Context().Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: tenant context missing"})
return
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
logger.Warnf(context.Background(), "[Router] /files not found: raw=%q full=%q err=%v", c.Param("filepath"), fullPath, err)
fileSvc, resolvedProvider, err := filesvc.NewFileServiceFromStorageConfig(provider, tenant.StorageEngineConfig, absDir)
if err != nil {
logger.Warnf(context.Background(), "[Router] /files resolve file service failed: tenant_id=%d provider=%s err=%v", tenant.ID, provider, err)
c.Status(http.StatusBadRequest)
return
}
reader, err := fileSvc.GetFile(c.Request.Context(), filePath)
if err != nil {
logger.Warnf(context.Background(), "[Router] /files get file failed: tenant_id=%d provider=%s path=%q err=%v", tenant.ID, resolvedProvider, filePath, err)
c.Status(http.StatusNotFound)
return
}
logger.Infof(context.Background(), "[Router] /files hit: raw=%q full=%q", c.Param("filepath"), fullPath)
c.File(fullPath)
defer reader.Close()
ext := filepath.Ext(filePath)
contentType := "application/octet-stream"
switch strings.ToLower(ext) {
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".gif":
contentType = "image/gif"
case ".webp":
contentType = "image/webp"
case ".bmp":
contentType = "image/bmp"
case ".svg":
contentType = "image/svg+xml"
case ".pdf":
contentType = "application/pdf"
case ".csv":
contentType = "text/csv; charset=utf-8"
}
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=86400")
c.Status(http.StatusOK)
if _, err := io.Copy(c.Writer, reader); err != nil {
logger.Warnf(context.Background(), "[Router] /files write response failed: %v", err)
}
})
}
-1
View File
@@ -11,7 +11,6 @@ type ReadRequest struct {
ParserEngine string
RequestID string
ParserEngineOverrides map[string]string
ImageStorage map[string]string // storage provider config (provider, endpoint, bucket, ...)
}
// ReadResult is the transport-agnostic result of document reading.
+2 -2
View File
@@ -104,8 +104,8 @@ type ImageMultimodalPayload struct {
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
ChunkID string `json:"chunk_id"` // parent text chunk
ImageURL string `json:"image_url"` // serving URL (/files/images/xxx.png)
ImageLocalPath string `json:"image_local_path"` // absolute path on disk
ImageURL string `json:"image_url"` // provider:// URL (e.g. local://..., minio://...)
ImageLocalPath string `json:"image_local_path"` // deprecated: kept for backward compat with in-flight tasks
EnableOCR bool `json:"enable_ocr"`
EnableCaption bool `json:"enable_caption"`
}
+19 -6
View File
@@ -230,21 +230,34 @@ func (kb *KnowledgeBase) SetStorageProvider(provider string) {
// InferStorageFromFilePath deduces the storage provider from a file path format.
// Used as a safety fallback when the KB's configured provider doesn't match the data.
// Supports provider:// scheme (local://, minio://, cos://, tos://),
// unified /files/{provider}/... format, and legacy formats.
func InferStorageFromFilePath(filePath string) string {
// Provider scheme format: provider://...
if p := ParseProviderScheme(filePath); p != "" {
return p
}
// Legacy formats
switch {
case strings.HasPrefix(filePath, "minio://"):
return "minio"
case strings.HasPrefix(filePath, "tos://"):
return "tos"
case strings.HasPrefix(filePath, "https://") && strings.Contains(filePath, ".cos."):
return "cos"
case strings.HasPrefix(filePath, "/"):
return "local"
default:
return ""
}
}
// ParseProviderScheme extracts the provider from a provider:// scheme path.
// e.g. "minio://bucket/key" → "minio", "local://tenant/file.pdf" → "local"
// Returns "" if the path does not use a known provider scheme.
func ParseProviderScheme(filePath string) string {
for _, provider := range []string{"local", "minio", "cos", "tos"} {
if strings.HasPrefix(filePath, provider+"://") {
return provider
}
}
return ""
}
// ImageProcessingConfig represents the image processing configuration
type ImageProcessingConfig struct {
// Model ID
+10 -3
View File
@@ -158,9 +158,16 @@ func IsValidURL(url string) bool {
return false
}
// 检查协议
if !strings.HasPrefix(strings.ToLower(url), "http://") &&
!strings.HasPrefix(strings.ToLower(url), "https://") {
// 检查协议, 只允许 http, https, local, minio, cos, tos 协议
allowedProtocols := []string{"http://", "https://", "local://", "minio://", "cos://", "tos://"}
isAllowed := false
for _, protocol := range allowedProtocols {
if strings.HasPrefix(strings.ToLower(url), protocol) {
isAllowed = true
break
}
}
if !isAllowed {
return false
}