package vision import ( "fmt" "net/http" "strings" ) // sniffBytes 是字节嗅探读取的前缀长度:http.DetectContentType 只看前 // 512 字节。 const sniffBytes = 512 // knownRasterMIME 把一个 MIME 归一化到允许内联进视觉模型的光栅图 // 白名单。白名单与网关上传同口径(internal/services/gateway/upload.go // 的 uploadExtForMIME):SVG/HTML 是可执行内容,绝不进模型。 // 第二个返回值为 false 时该 MIME 不是白名单内的图像类型。 func knownRasterMIME(mime string) (string, bool) { switch mediaType(mime) { case "image/png": return "image/png", true case "image/jpeg", "image/jpg": // image/jpg 不是标准写法,但确有其事,归一化到 image/jpeg。 return "image/jpeg", true case "image/gif": return "image/gif", true case "image/webp": return "image/webp", true case "image/bmp": return "image/bmp", true default: return "", false } } // imageMediaType 返回 Content-Type 里声明的 image/* 媒体类型(去掉参数、 // 转小写)。缺失、非 image/*、或载体可执行的 image/svg+xml 都返回 false。 func imageMediaType(ct string) (string, bool) { m := mediaType(ct) if !strings.HasPrefix(m, "image/") || m == "image/svg+xml" { return "", false } return m, true } // mediaType 归一化一个 MIME 字符串:截掉 ";charset=..." 参数、去空白、转小写。 func mediaType(mime string) string { return strings.ToLower(strings.TrimSpace(strings.Split(mime, ";")[0])) } // sniffMIME 按真实字节嗅探类型。它是对声明类型(HTTP Content-Type 或文件 // 扩展名)的否决权:两者都可能骗人,字节不会。 func sniffMIME(data []byte) string { if len(data) > sniffBytes { data = data[:sniffBytes] } return http.DetectContentType(data) } // isTextualMIME 报告 mime 是否是可读文本 —— HTML/PROXY 错误页、SVG、 // JSON 等「看起来像图但其实是文本」的载荷都落在这里。 func isTextualMIME(mime string) bool { m := mediaType(mime) if strings.HasPrefix(m, "text/") { return true } switch m { case "application/json", "application/xml", "application/xhtml+xml", "application/javascript": return true default: return strings.HasSuffix(m, "+xml") } } // imageMIMEFor 决定内联给视觉模型的 MIME。declared 是来源声明的类型 // (HTTP Content-Type 或文件扩展名),data 是真实字节: // // - 字节能判出白名单光栅图 → 以字节为准(声明的类型只作参考, // 谎报或错标的类型骗不了模型); // - 字节是文本(HTML/PROXY 错误页、SVG)→ 报错,绝不喂给模型; // - 字节判不定(application/octet-stream 等)且来源明确声明 image/* → // 采信声明(否则 avif 这类嗅探不出的格式会被无谓拒绝); // - 其余(含类型缺失且字节判不定)→ 报错。 // // 绝不静默回退成 image/png:把一张 HTML 错误页当 PNG 交给视觉模型, // 会让模型对着乱码编描述。 func imageMIMEFor(declared string, data []byte) (string, error) { sniffed := sniffMIME(data) if sniffedMIME, ok := knownRasterMIME(sniffed); ok { return sniffedMIME, nil } declaredMIME, declaredImage := imageMediaType(declared) if isTextualMIME(sniffed) { return "", fmt.Errorf("vision: %w: declared %s but the payload is %s", ErrNotImage, declaredLabel(declared), mediaType(sniffed)) } if declaredImage { if known, ok := knownRasterMIME(declaredMIME); ok { return known, nil } return declaredMIME, nil } return "", fmt.Errorf("vision: %w: declared %s, payload is %s", ErrNotImage, declaredLabel(declared), mediaType(sniffed)) } // declaredLabel 把声明的类型渲染成错误信息里的可读标签;缺失时给出 // 明确措辞,避免错误信息里出现空的引号。错误信息只含类型名, // 绝不含图像字节或来源 URL(后者可能带凭据)。 func declaredLabel(declared string) string { if m := mediaType(declared); m != "" { return fmt.Sprintf("content type %q", m) } return "no content type" } // mimeByExt 给出文件路径扩展名声明的 MIME;未知扩展名返回 "",交给字节 // 嗅探裁决(不再静默假定 png)。 func mimeByExt(path string) string { switch strings.ToLower(pathExtension(path)) { 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 "" } } // pathExtension 返回带点号的文件扩展名,没有则返回空串。 func pathExtension(path string) string { base := path if idx := strings.LastIndexByte(path, '/'); idx >= 0 { base = path[idx+1:] } else if idx := strings.LastIndexByte(path, '\\'); idx >= 0 { base = path[idx+1:] } dot := strings.LastIndexByte(base, '.') if dot < 0 { return "" } return base[dot:] }