diff --git a/docreader/main.py b/docreader/main.py index 4fc99b50..6972953e 100644 --- a/docreader/main.py +++ b/docreader/main.py @@ -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( diff --git a/docreader/proto/docreader.pb.go b/docreader/proto/docreader.pb.go index 5d456227..751ae9b5 100644 --- a/docreader/proto/docreader.pb.go +++ b/docreader/proto/docreader.pb.go @@ -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, }, diff --git a/docreader/proto/docreader.proto b/docreader/proto/docreader.proto index 89b7938a..0d803cac 100644 --- a/docreader/proto/docreader.proto +++ b/docreader/proto/docreader.proto @@ -12,7 +12,9 @@ service DocReader { message ReadConfig { string parser_engine = 1; map parser_engine_overrides = 2; - map 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. diff --git a/docreader/proto/docreader_grpc.pb.go b/docreader/proto/docreader_grpc.pb.go index 754854a5..4a163a87 100644 --- a/docreader/proto/docreader_grpc.pb.go +++ b/docreader/proto/docreader_grpc.pb.go @@ -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", diff --git a/docreader/proto/docreader_pb2.py b/docreader/proto/docreader_pb2.py index 59f2c320..68a61b46 100644 --- a/docreader/proto/docreader_pb2.py +++ b/docreader/proto/docreader_pb2.py @@ -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) diff --git a/docreader/proto/docreader_pb2.pyi b/docreader/proto/docreader_pb2.pyi index 19f96abd..53477a66 100644 --- a/docreader/proto/docreader_pb2.pyi +++ b/docreader/proto/docreader_pb2.pyi @@ -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: ... diff --git a/docreader/proto/docreader_pb2_grpc.py b/docreader/proto/docreader_pb2_grpc.py index a4f1c48c..c3808c21 100644 --- a/docreader/proto/docreader_pb2_grpc.py +++ b/docreader/proto/docreader_pb2_grpc.py @@ -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) diff --git a/frontend/src/utils/security.ts b/frontend/src/utils/security.ts index 53b995d8..ef6dd815 100644 --- a/frontend/src/utils/security.ts +++ b/frontend/src/utils/security.ts @@ -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(///gi, '/') + .replace(///g, '/') + .replace(/&/g, '&') + .replace(/"/g, '"'); + return html.replace( + /]*?)\ssrc=(["'])(local|minio|cos|tos):(?:\/\/|//|//)([^"']+)\2([^>]*)>/gi, + (_m, before, quote, provider, restPathRaw, after) => { + const restPath = decodeProviderURL(restPathRaw); + const protectedSrc = `${provider}://${restPath}`; + return ``; + }, + ); +} + /** * 转义 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, '"'); const safeAlt = escapeHTML(alt); const safeTitle = escapeHTML(title); return `${safeAlt}`; } + +const protectedFileBlobCache = new Map(); + +function getProtectedFileRequestHeaders(): Record { + const headers: Record = {}; + 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 { + if (!root || typeof window === 'undefined') { + return; + } + + const images = root.querySelectorAll( + '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'; + } + })); +} diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue index 595a5d50..a130ca29 100644 --- a/frontend/src/views/chat/components/AgentStreamDisplay.vue +++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue @@ -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 = { @@ -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 => { 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 = ``; + 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, '>'); } }; +const protectProviderImageSrcInHTML = (html: string): string => { + if (!html) return html; + const placeholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; + return html.replace( + /]*?)\ssrc=(["'])(local|minio|cos|tos):\/\/([^"']+)\2([^>]*)>/gi, + (_m, before, quote, provider, restPath, after) => { + const src = `${provider}://${restPath}`; + return ``; + }, + ); +}; + // 已渲染的 mermaid 元素 ID 集合 const renderedMermaidIds = new Set(); @@ -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); + } +} + diff --git a/frontend/src/views/chat/components/botmsg.vue b/frontend/src/views/chat/components/botmsg.vue index 9f9dead5..d7763ef5 100644 --- a/frontend/src/views/chat/components/botmsg.vue +++ b/frontend/src/views/chat/components/botmsg.vue @@ -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 = ``; + 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; diff --git a/internal/application/service/file/cos.go b/internal/application/service/file/cos.go index c0e2682c..3f933040 100644 --- a/internal/application/service/file/cos.go +++ b/internal/application/service/file/cos.go @@ -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) } diff --git a/internal/application/service/file/factory.go b/internal/application/service/file/factory.go new file mode 100644 index 00000000..93b07050 --- /dev/null +++ b/internal/application/service/file/factory.go @@ -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) + } +} diff --git a/internal/application/service/file/local.go b/internal/application/service/file/local.go index fe0640a7..f772dbff 100644 --- a/internal/application/service/file/local.go +++ b/internal/application/service/file/local.go @@ -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 diff --git a/internal/application/service/file/minio.go b/internal/application/service/file/minio.go index 2b3e617d..f55bff92 100644 --- a/internal/application/service/file/minio.go +++ b/internal/application/service/file/minio.go @@ -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 } diff --git a/internal/application/service/file/tos.go b/internal/application/service/file/tos.go index 354e9871..df905f44 100644 --- a/internal/application/service/file/tos.go +++ b/internal/application/service/file/tos.go @@ -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) diff --git a/internal/application/service/image_multimodal.go b/internal/application/service/image_multimodal.go index e5f3e1c6..07d05e89 100644 --- a/internal/application/service/image_multimodal.go +++ b/internal/application/service/image_multimodal.go @@ -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) diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index 7f0c684d..b5842780 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -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, } diff --git a/internal/container/container.go b/internal/container/container.go index 32f8d51c..a4ed19bb 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -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: diff --git a/internal/infrastructure/docparser/grpc_parser.go b/internal/infrastructure/docparser/grpc_parser.go index 76dad36e..49cc761a 100644 --- a/internal/infrastructure/docparser/grpc_parser.go +++ b/internal/infrastructure/docparser/grpc_parser.go @@ -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, }, } diff --git a/internal/infrastructure/docparser/http_parser.go b/internal/infrastructure/docparser/http_parser.go index 3b5028a6..720a3f02 100644 --- a/internal/infrastructure/docparser/http_parser.go +++ b/internal/infrastructure/docparser/http_parser.go @@ -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 { diff --git a/internal/infrastructure/docparser/image_resolver.go b/internal/infrastructure/docparser/image_resolver.go index 964a9349..540125e7 100644 --- a/internal/infrastructure/docparser/image_resolver.go +++ b/internal/infrastructure/docparser/image_resolver.go @@ -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 } diff --git a/internal/router/router.go b/internal/router/router.go index 75279339..dcbdfd55 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 { )) } - // 本地存储文件服务(无需认证,用于 等标签访问图片) - 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 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= +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) + } }) } diff --git a/internal/types/docparser.go b/internal/types/docparser.go index 8b18a636..e47a9872 100644 --- a/internal/types/docparser.go +++ b/internal/types/docparser.go @@ -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. diff --git a/internal/types/extract_graph.go b/internal/types/extract_graph.go index 454ab046..7a2b785c 100644 --- a/internal/types/extract_graph.go +++ b/internal/types/extract_graph.go @@ -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"` } diff --git a/internal/types/knowledgebase.go b/internal/types/knowledgebase.go index 436d0a99..6f68b026 100644 --- a/internal/types/knowledgebase.go +++ b/internal/types/knowledgebase.go @@ -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 diff --git a/internal/utils/security.go b/internal/utils/security.go index 4a61ae66..d73713fb 100644 --- a/internal/utils/security.go +++ b/internal/utils/security.go @@ -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 }