375 lines
11 KiB
Go
375 lines
11 KiB
Go
package docparser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
// minImageDimension is the minimum width/height in pixels; images smaller
|
|
// than this on either axis are treated as icons and filtered out.
|
|
minImageDimension = 128
|
|
// minImageBytes is the minimum file size in bytes; very small images are
|
|
// almost certainly icons or decorative elements.
|
|
minImageBytes = 512 // 512 bytes
|
|
)
|
|
|
|
// isIconImage returns true if the image data looks like a small icon or
|
|
// decorative element that should be filtered out. It checks pixel dimensions
|
|
// when decodable, and falls back to raw byte size otherwise.
|
|
func isIconImage(data []byte) bool {
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
|
if err != nil {
|
|
// Cannot decode dimensions — fall back to size-only heuristic.
|
|
return len(data) < minImageBytes
|
|
}
|
|
if cfg.Width < minImageDimension || cfg.Height < minImageDimension {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// StoredImage describes an image that has been saved to storage.
|
|
type StoredImage struct {
|
|
OriginalRef string // reference in the original markdown
|
|
ServingURL string // provider:// URL (e.g. local://images/xxx.png, minio://bucket/key)
|
|
MimeType string
|
|
}
|
|
|
|
// ImageResolver reads images from a DocReader ReadResult (inline bytes only)
|
|
// and saves them via FileService, replacing markdown references with unified URLs.
|
|
type ImageResolver struct {
|
|
// TenantID for storage path namespacing
|
|
TenantID uint64
|
|
}
|
|
|
|
// NewImageResolver creates a resolver.
|
|
func NewImageResolver() *ImageResolver {
|
|
return &ImageResolver{}
|
|
}
|
|
|
|
// 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 := UnwrapLinkedImages(result.MarkdownContent)
|
|
if len(result.ImageRefs) == 0 {
|
|
return markdown, nil, nil
|
|
}
|
|
|
|
// Build a map of original_ref -> image ref for fast lookup
|
|
refMap := make(map[string]types.ImageRef)
|
|
for _, ref := range result.ImageRefs {
|
|
refMap[ref.OriginalRef] = ref
|
|
}
|
|
|
|
// Process each image reference found in the markdown.
|
|
// The URL group supports one level of balanced parentheses so that URLs
|
|
// like https://example.com/item_(abc)/123 are captured in full.
|
|
imgPattern := regexp.MustCompile(`!\[([^\]]*)\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)`)
|
|
matches := imgPattern.FindAllStringSubmatchIndex(markdown, -1)
|
|
|
|
// Process in reverse order to preserve positions when replacing
|
|
for i := len(matches) - 1; i >= 0; i-- {
|
|
m := matches[i]
|
|
refPath := markdown[m[4]:m[5]] // group 2: the URL/path
|
|
|
|
// Skip already-resolved URLs (http/https, unified /files/, or provider:// scheme)
|
|
if strings.HasPrefix(refPath, "http://") || strings.HasPrefix(refPath, "https://") ||
|
|
isProviderScheme(refPath) {
|
|
continue
|
|
}
|
|
|
|
// Find inline image bytes from the result
|
|
ref, found := refMap[refPath]
|
|
if !found || len(ref.ImageData) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Filter out small icons and decorative images
|
|
if isIconImage(ref.ImageData) {
|
|
// Remove the image reference from markdown entirely
|
|
markdown = markdown[:m[0]] + markdown[m[1]:]
|
|
continue
|
|
}
|
|
|
|
// Determine extension
|
|
ext := extFromMime(ref.MimeType)
|
|
if ext == "" {
|
|
ext = filepath.Ext(ref.Filename)
|
|
}
|
|
if ext == "" {
|
|
ext = ".png"
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
images = append(images, StoredImage{
|
|
OriginalRef: refPath,
|
|
ServingURL: servingURL,
|
|
MimeType: ref.MimeType,
|
|
})
|
|
|
|
// Replace in markdown
|
|
markdown = markdown[:m[4]] + servingURL + markdown[m[5]:]
|
|
}
|
|
|
|
return markdown, images, nil
|
|
}
|
|
|
|
func extFromMime(mime string) string {
|
|
switch mime {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "image/bmp":
|
|
return ".bmp"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// isProviderScheme checks if the path uses a provider:// scheme (local://, minio://, cos://, tos://).
|
|
func isProviderScheme(p string) bool {
|
|
for _, prefix := range []string{"local://", "minio://", "cos://", "tos://"} {
|
|
if strings.HasPrefix(p, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Remote image resolution (for manual / web-clipped markdown content)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
// maxRemoteImageSize is the maximum allowed size for a single remote image download.
|
|
maxRemoteImageSize = 10 * 1024 * 1024 // 10 MB
|
|
// maxRemoteImages is the maximum number of remote images to process per document.
|
|
maxRemoteImages = 30
|
|
// remoteImageFetchTimeout is the per-image HTTP request timeout.
|
|
remoteImageFetchTimeout = 15 * time.Second
|
|
)
|
|
|
|
// reLinkedImage matches the nested [](link_url) pattern where
|
|
// an image is wrapped inside a Markdown link. We unwrap it to just 
|
|
// so that downstream image-processing regexes only have to handle the flat form.
|
|
// The URL groups support one level of balanced parentheses.
|
|
var reLinkedImage = regexp.MustCompile(
|
|
`\[!\[([^\]]*)\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)\]` + // []
|
|
`\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`, // (link_url) — captured but discarded
|
|
)
|
|
|
|
// UnwrapLinkedImages replaces all [](link_url) occurrences in
|
|
// the markdown with just , stripping the outer link wrapper.
|
|
// This should be called before any image-extraction regex so that only the
|
|
// flat  form needs to be handled.
|
|
func UnwrapLinkedImages(markdown string) string {
|
|
return reLinkedImage.ReplaceAllString(markdown, "")
|
|
}
|
|
|
|
// imgMarkdownPattern matches Markdown image syntax: .
|
|
// The URL group supports one level of balanced parentheses so that URLs
|
|
// like https://example.com/item_(abc)/123 are captured in full.
|
|
var imgMarkdownPattern = regexp.MustCompile(`!\[([^\]]*)\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)`)
|
|
|
|
// ResolveRemoteImages scans a Markdown string for image references whose URL
|
|
// is http:// or https://, downloads each one through an SSRF-safe HTTP client,
|
|
// uploads the bytes via fileSvc, and replaces the original URL with the
|
|
// provider:// serving URL.
|
|
//
|
|
// Images that fail SSRF validation, exceed size limits, or cannot be downloaded
|
|
// are left unchanged (the original URL is preserved).
|
|
//
|
|
// Returns the updated Markdown and a list of successfully stored images.
|
|
func (r *ImageResolver) ResolveRemoteImages(
|
|
ctx context.Context,
|
|
markdown string,
|
|
fileSvc interfaces.FileService,
|
|
tenantID uint64,
|
|
) (updatedMarkdown string, images []StoredImage, err error) {
|
|
markdown = UnwrapLinkedImages(markdown)
|
|
|
|
matches := imgMarkdownPattern.FindAllStringSubmatchIndex(markdown, -1)
|
|
if len(matches) == 0 {
|
|
return markdown, nil, nil
|
|
}
|
|
|
|
// Build a shared SSRF-safe HTTP client for all downloads.
|
|
httpClient := secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
|
|
Timeout: remoteImageFetchTimeout,
|
|
MaxRedirects: 5,
|
|
})
|
|
|
|
processed := 0
|
|
|
|
// Process in reverse order so that earlier indices stay valid after replacements.
|
|
for i := len(matches) - 1; i >= 0; i-- {
|
|
if processed >= maxRemoteImages {
|
|
break
|
|
}
|
|
m := matches[i]
|
|
imgURL := markdown[m[4]:m[5]] // group 2: the URL
|
|
|
|
// Only process remote http(s) URLs.
|
|
if !strings.HasPrefix(imgURL, "http://") && !strings.HasPrefix(imgURL, "https://") {
|
|
continue
|
|
}
|
|
|
|
// Already a provider scheme — skip.
|
|
if isProviderScheme(imgURL) {
|
|
continue
|
|
}
|
|
|
|
// --- SSRF check ---
|
|
if safe, reason := secutils.IsSSRFSafeURL(imgURL); !safe {
|
|
log.Printf("WARN: remote image blocked by SSRF check (%s): %s", reason, imgURL)
|
|
continue
|
|
}
|
|
|
|
// --- Download ---
|
|
data, mimeType, dlErr := downloadImage(ctx, httpClient, imgURL)
|
|
if dlErr != nil {
|
|
log.Printf("WARN: failed to download remote image %s: %v", imgURL, dlErr)
|
|
continue
|
|
}
|
|
|
|
// Filter out icons / tiny decorative images.
|
|
if isIconImage(data) {
|
|
continue
|
|
}
|
|
|
|
// Determine file extension.
|
|
ext := extFromMime(mimeType)
|
|
if ext == "" {
|
|
ext = extFromURLPath(imgURL)
|
|
}
|
|
if ext == "" {
|
|
ext = ".png" // safe default
|
|
}
|
|
|
|
// --- Upload to storage ---
|
|
fileName := uuid.New().String() + ext
|
|
servingURL, saveErr := fileSvc.SaveBytes(ctx, data, tenantID, fileName, false)
|
|
if saveErr != nil {
|
|
log.Printf("WARN: failed to save remote image %s: %v", imgURL, saveErr)
|
|
continue
|
|
}
|
|
|
|
images = append(images, StoredImage{
|
|
OriginalRef: imgURL,
|
|
ServingURL: servingURL,
|
|
MimeType: mimeType,
|
|
})
|
|
|
|
// Replace URL in markdown.
|
|
markdown = markdown[:m[4]] + servingURL + markdown[m[5]:]
|
|
processed++
|
|
}
|
|
|
|
return markdown, images, nil
|
|
}
|
|
|
|
// downloadImage fetches an image from remoteURL using the provided SSRF-safe
|
|
// client. It validates Content-Type and enforces maxRemoteImageSize.
|
|
func downloadImage(ctx context.Context, client *http.Client, remoteURL string) (data []byte, mimeType string, err error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteURL, nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("create request: %w", err)
|
|
}
|
|
// Some CDNs require a browser-like User-Agent.
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; WeKnora/1.0)")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("HTTP GET: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, "", fmt.Errorf("unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Determine MIME type from Content-Type header.
|
|
ct := resp.Header.Get("Content-Type")
|
|
mimeType, _, _ = mime.ParseMediaType(ct)
|
|
if mimeType == "" {
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
|
|
// Only allow image content types (or octet-stream which we sniff later).
|
|
if !strings.HasPrefix(mimeType, "image/") && mimeType != "application/octet-stream" {
|
|
return nil, "", fmt.Errorf("non-image content type: %s", mimeType)
|
|
}
|
|
|
|
// Read body with size limit.
|
|
limited := io.LimitReader(resp.Body, maxRemoteImageSize+1)
|
|
body, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("read body: %w", err)
|
|
}
|
|
if len(body) > maxRemoteImageSize {
|
|
return nil, "", fmt.Errorf("image exceeds %d bytes limit", maxRemoteImageSize)
|
|
}
|
|
|
|
// If MIME was octet-stream, sniff the real type from body.
|
|
if mimeType == "application/octet-stream" {
|
|
detected := http.DetectContentType(body)
|
|
if strings.HasPrefix(detected, "image/") {
|
|
mimeType = detected
|
|
} else {
|
|
return nil, "", fmt.Errorf("downloaded data is not an image (sniffed: %s)", detected)
|
|
}
|
|
}
|
|
|
|
return body, mimeType, nil
|
|
}
|
|
|
|
// extFromURLPath extracts the image file extension from the URL path segment.
|
|
func extFromURLPath(rawURL string) string {
|
|
p := path.Ext(path.Base(rawURL))
|
|
switch strings.ToLower(p) {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
|
return strings.ToLower(p)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|