- HTML 提取至 templates/status.html、templates/review.html(go:embed 内嵌) - status.go 1022→271 行、review.go 1281→438 行,动态数据全部经 html/template 按上下文自动转义 - 评论循环/作者徽章/贴纸/图片条件渲染翻译为模板 range/if;emojiMap 以 template.JS 注入 - 与旧版渲染输出实测对比:22 条评论结构完全一致;差异仅为 JS 上下文安全转义(& 等)与 html/template 剥除 CSS 注释,均无功能影响 - 其余文件为 gofmt 顺手格式化
616 lines
16 KiB
Go
616 lines
16 KiB
Go
package fqapi
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/gzip"
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"fanqie/internal/common"
|
||
"fanqie/internal/config"
|
||
)
|
||
|
||
var (
|
||
mangaLogger = common.NewLogger("manga", "INFO", "")
|
||
// 存储漫画图片信息的缓存(用于代理模式)
|
||
mangaImageCache = make(map[string]*MangaImageInfo)
|
||
mangaCacheMutex sync.RWMutex
|
||
)
|
||
|
||
// MangaImageInfo 漫画图片信息
|
||
type MangaImageInfo struct {
|
||
PicURL string
|
||
EncryptKey string
|
||
Width int
|
||
Height int
|
||
CachedAt time.Time
|
||
}
|
||
|
||
// MangaData 漫画数据结构
|
||
type MangaData struct {
|
||
PicInfos []PicInfo `json:"picInfos"`
|
||
EncryptKey string `json:"encrypt_key"`
|
||
}
|
||
|
||
// PicInfo 图片信息
|
||
type PicInfo struct {
|
||
PicURL string `json:"picUrl"`
|
||
Md5 string `json:"md5"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
}
|
||
|
||
// ImageResult 图片结果
|
||
type ImageResult struct {
|
||
Data string `json:"data"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
Format string `json:"format"`
|
||
Index int `json:"index"`
|
||
}
|
||
|
||
// HandleGetManga 处理漫画请求
|
||
func HandleGetManga(itemID string, showHTML bool, urlOutputMode string) (interface{}, error) {
|
||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, 0)
|
||
}
|
||
|
||
// getMangaWithRetry 获取漫画内容,支持设备切换重试
|
||
func getMangaWithRetry(itemID string, showHTML bool, urlOutputMode string, retryCount int) (interface{}, error) {
|
||
// 防止无限递归
|
||
const maxRetries = 5
|
||
if retryCount >= maxRetries {
|
||
mangaLogger.Warning(fmt.Sprintf("已重试 %d 次,获取漫画失败", maxRetries))
|
||
return nil, fmt.Errorf("已重试 %d 次,所有设备均失败", maxRetries)
|
||
}
|
||
|
||
// 1. 从设备密钥池获取设备
|
||
pool := GetDeviceKeyPool()
|
||
item, err := pool.GetNext()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("从设备池获取设备失败: %w", err)
|
||
}
|
||
|
||
header := item.DeviceHeader
|
||
keyInfo := &KeyInfo{
|
||
Key: item.Key,
|
||
Ver: item.KeyVer,
|
||
}
|
||
mangaLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (重试次数: %d)", item.ID, retryCount))
|
||
|
||
// 2. 构建URL - 使用 BuildURL 添加设备参数
|
||
headerWithoutVersion := strings.Replace(header, "&version_name=5.8.9.32", "", -1)
|
||
|
||
url := BuildURL("https://reading.snssdk.com/reading/reader/full/v/",
|
||
"aid=1967",
|
||
"app_name=novelapp",
|
||
"channel=0",
|
||
"device_platform=android",
|
||
"version_code=66.9",
|
||
"item_id="+itemID,
|
||
"os_version=0",
|
||
headerWithoutVersion,
|
||
)
|
||
|
||
// 3. 调用API - 使用 GorgonRequest 添加签名
|
||
response, err := GorgonRequest(url, map[string]interface{}{
|
||
"method": "GET",
|
||
"headers": map[string]string{
|
||
"User-Agent": "com.dragon.read",
|
||
},
|
||
})
|
||
if err != nil {
|
||
mangaLogger.Warning(fmt.Sprintf("API请求失败,切换设备重试: %v", err))
|
||
pool.MarkInvalid(header)
|
||
pool.RemoveInvalid()
|
||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||
}
|
||
|
||
// 4. 解析响应
|
||
var body map[string]interface{}
|
||
if err := json.Unmarshal(response.Content, &body); err != nil {
|
||
mangaLogger.Warning(fmt.Sprintf("JSON解析失败,切换设备重试: %v", err))
|
||
pool.MarkInvalid(header)
|
||
pool.RemoveInvalid()
|
||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||
}
|
||
|
||
// 5. 获取加密的content
|
||
data, ok := body["data"].(map[string]interface{})
|
||
if !ok {
|
||
mangaLogger.Warning("响应data字段无效,切换设备重试")
|
||
pool.MarkInvalid(header)
|
||
pool.RemoveInvalid()
|
||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||
}
|
||
|
||
encryptedContent, ok := data["content"].(string)
|
||
if !ok || encryptedContent == "Invalid" {
|
||
mangaLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (重试: %d)", retryCount))
|
||
pool.MarkInvalid(header)
|
||
pool.RemoveInvalid()
|
||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||
}
|
||
|
||
// 检查密钥版本
|
||
if keyVersion, ok := data["key_version"].(float64); ok {
|
||
if int(keyVersion) != keyInfo.Ver {
|
||
mangaLogger.Warning("密钥版本不匹配,重新获取密钥")
|
||
newKeyInfo, err := GetKey(header)
|
||
if err == nil {
|
||
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
|
||
keyInfo = newKeyInfo
|
||
} else {
|
||
mangaLogger.Warning("获取密钥失败,切换设备重试")
|
||
pool.MarkInvalid(header)
|
||
pool.RemoveInvalid()
|
||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. 解密content
|
||
decrypted, err := decryptAESContent(encryptedContent, keyInfo.Key)
|
||
if err != nil {
|
||
mangaLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err))
|
||
pool.MarkInvalid(header)
|
||
pool.RemoveInvalid()
|
||
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
|
||
}
|
||
|
||
// 7. 判断是否为漫画内容
|
||
if strings.Contains(decrypted, "picInfos") {
|
||
// 是漫画,决定输出模式
|
||
// 优先级:URL参数 > 配置文件 > 默认值
|
||
outputMode := "proxy" // 默认值
|
||
|
||
// 从配置文件读取
|
||
cfg := config.GetConfig()
|
||
if cfg != nil && cfg.Fanqie.Manga.OutputMode != "" {
|
||
outputMode = cfg.Fanqie.Manga.OutputMode
|
||
}
|
||
|
||
// URL参数优先级最高
|
||
if urlOutputMode != "" {
|
||
outputMode = urlOutputMode
|
||
}
|
||
|
||
// 解析漫画图片(传入keyInfo用于解密模式)
|
||
images, err := parseMangaImages(decrypted, showHTML, outputMode, itemID, keyInfo)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析漫画失败: %w", err)
|
||
}
|
||
return images, nil
|
||
}
|
||
|
||
// 不是漫画,返回普通文本
|
||
return map[string]interface{}{
|
||
"content": decrypted,
|
||
}, nil
|
||
}
|
||
|
||
// decryptAESContent 使用AES-128-CBC解密content
|
||
func decryptAESContent(encrypted, secretKey string) (string, error) {
|
||
// Base64解码加密内容
|
||
raw, err := base64.StdEncoding.DecodeString(encrypted)
|
||
if err != nil {
|
||
return "", fmt.Errorf("Base64解码失败: %w", err)
|
||
}
|
||
|
||
if len(raw) < 16 {
|
||
return "", fmt.Errorf("数据长度不足")
|
||
}
|
||
|
||
// 提取IV和密文
|
||
iv := raw[:16]
|
||
ciphertext := raw[16:]
|
||
|
||
// Base64解码密钥
|
||
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
|
||
if err != nil {
|
||
return "", fmt.Errorf("密钥解码失败: %w", err)
|
||
}
|
||
|
||
if len(keyBytes) != 16 {
|
||
return "", fmt.Errorf("密钥长度必须为16字节,实际: %d", len(keyBytes))
|
||
}
|
||
|
||
// 创建AES cipher
|
||
block, err := aes.NewCipher(keyBytes)
|
||
if err != nil {
|
||
return "", fmt.Errorf("创建cipher失败: %w", err)
|
||
}
|
||
|
||
// CBC模式解密
|
||
mode := cipher.NewCBCDecrypter(block, iv)
|
||
plaintext := make([]byte, len(ciphertext))
|
||
mode.CryptBlocks(plaintext, ciphertext)
|
||
|
||
// PKCS7 unpadding
|
||
plaintext = pkcs7Unpad(plaintext)
|
||
|
||
// 尝试gzip解压
|
||
if len(plaintext) > 2 && plaintext[0] == 0x1f && plaintext[1] == 0x8b {
|
||
// 是gzip压缩的数据
|
||
reader, err := gzip.NewReader(bytes.NewReader(plaintext))
|
||
if err == nil {
|
||
defer reader.Close()
|
||
decompressed, err := io.ReadAll(reader)
|
||
if err == nil {
|
||
return string(decompressed), nil
|
||
}
|
||
}
|
||
}
|
||
|
||
return string(plaintext), nil
|
||
}
|
||
|
||
// pkcs7Unpad PKCS7 unpadding
|
||
func pkcs7Unpad(data []byte) []byte {
|
||
length := len(data)
|
||
if length == 0 {
|
||
return data
|
||
}
|
||
unpadding := int(data[length-1])
|
||
if unpadding > length {
|
||
return data
|
||
}
|
||
return data[:(length - unpadding)]
|
||
}
|
||
|
||
// buildMangaResponse 构建漫画响应(统一HTML和JSON格式输出)
|
||
func buildMangaResponse(images []ImageResult, showHTML bool) interface{} {
|
||
if showHTML {
|
||
var htmlContent strings.Builder
|
||
for _, img := range images {
|
||
htmlContent.WriteString(fmt.Sprintf(`<img src="%s" width="%d" height="%d" alt="图片%d">`,
|
||
img.Data, img.Width, img.Height, img.Index))
|
||
htmlContent.WriteString("\n")
|
||
}
|
||
return map[string]interface{}{
|
||
"content": htmlContent.String(),
|
||
}
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"images": images,
|
||
}
|
||
}
|
||
|
||
// processURLMode 处理URL模式(MD5直链)
|
||
func processURLMode(mangaData *MangaData) []ImageResult {
|
||
baseDomain := "https://p6-novel.byteimg.com/origin/"
|
||
validPath := detectMangaPath(baseDomain, mangaData.PicInfos[0].Md5)
|
||
|
||
images := make([]ImageResult, len(mangaData.PicInfos))
|
||
for i, pic := range mangaData.PicInfos {
|
||
imageURL := baseDomain + validPath + pic.Md5
|
||
images[i] = ImageResult{
|
||
Data: imageURL,
|
||
Width: pic.Width,
|
||
Height: pic.Height,
|
||
Format: "unknown",
|
||
Index: i,
|
||
}
|
||
}
|
||
return images
|
||
}
|
||
|
||
// processProxyMode 处理代理模式(生成短URL并缓存)
|
||
func processProxyMode(mangaData *MangaData, itemID string) []ImageResult {
|
||
images := make([]ImageResult, len(mangaData.PicInfos))
|
||
for i, pic := range mangaData.PicInfos {
|
||
cacheKey := fmt.Sprintf("%s_%d", itemID, i)
|
||
|
||
// 存储图片信息到缓存
|
||
mangaCacheMutex.Lock()
|
||
mangaImageCache[cacheKey] = &MangaImageInfo{
|
||
PicURL: pic.PicURL,
|
||
EncryptKey: mangaData.EncryptKey,
|
||
Width: pic.Width,
|
||
Height: pic.Height,
|
||
CachedAt: time.Now(),
|
||
}
|
||
mangaCacheMutex.Unlock()
|
||
|
||
// 返回短的代理URL
|
||
imageData := fmt.Sprintf("/manga/image/%s/%d", itemID, i)
|
||
images[i] = ImageResult{
|
||
Data: imageData,
|
||
Width: pic.Width,
|
||
Height: pic.Height,
|
||
Format: "unknown",
|
||
Index: i,
|
||
}
|
||
}
|
||
return images
|
||
}
|
||
|
||
// fillMangaCache 填充漫画图片缓存(供所有模式使用)
|
||
func fillMangaCache(mangaData *MangaData, itemID string) {
|
||
for i, pic := range mangaData.PicInfos {
|
||
cacheKey := fmt.Sprintf("%s_%d", itemID, i)
|
||
|
||
// 检查是否已经存在,避免覆盖
|
||
mangaCacheMutex.RLock()
|
||
_, exists := mangaImageCache[cacheKey]
|
||
mangaCacheMutex.RUnlock()
|
||
|
||
if exists {
|
||
continue
|
||
}
|
||
|
||
// 存储图片信息到缓存
|
||
mangaCacheMutex.Lock()
|
||
mangaImageCache[cacheKey] = &MangaImageInfo{
|
||
PicURL: pic.PicURL,
|
||
EncryptKey: mangaData.EncryptKey,
|
||
Width: pic.Width,
|
||
Height: pic.Height,
|
||
CachedAt: time.Now(),
|
||
}
|
||
mangaCacheMutex.Unlock()
|
||
}
|
||
}
|
||
|
||
// processDataURIMode 处理DataURI模式(下载、解密、Base64编码)
|
||
func processDataURIMode(mangaData *MangaData) []ImageResult {
|
||
keyBytes, err := hex.DecodeString(mangaData.EncryptKey)
|
||
if err != nil {
|
||
mangaLogger.Warning(fmt.Sprintf("密钥解码失败: %v", err))
|
||
return []ImageResult{}
|
||
}
|
||
|
||
images := make([]ImageResult, 0, len(mangaData.PicInfos))
|
||
for i, pic := range mangaData.PicInfos {
|
||
// 下载加密图片
|
||
encryptedData, err := downloadImageWithRetry(pic.PicURL, 3)
|
||
if err != nil {
|
||
mangaLogger.Warning(fmt.Sprintf("下载图片失败 [%d]: %v", i, err))
|
||
continue
|
||
}
|
||
|
||
// 解密图片
|
||
decryptedData, err := decryptImage(encryptedData, keyBytes)
|
||
if err != nil {
|
||
mangaLogger.Warning(fmt.Sprintf("解密图片失败 [%d]: %v", i, err))
|
||
continue
|
||
}
|
||
|
||
// 识别格式
|
||
format := getImageFormat(decryptedData, pic.PicURL)
|
||
|
||
// Base64编码图片数据
|
||
base64Data := base64.StdEncoding.EncodeToString(decryptedData)
|
||
|
||
// Data URI模式:返回完整的Base64编码
|
||
mimeType := fmt.Sprintf("image/%s", format)
|
||
if format == "jpg" {
|
||
mimeType = "image/jpeg"
|
||
}
|
||
imageData := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||
|
||
images = append(images, ImageResult{
|
||
Data: imageData,
|
||
Width: pic.Width,
|
||
Height: pic.Height,
|
||
Format: format,
|
||
Index: i,
|
||
})
|
||
}
|
||
return images
|
||
}
|
||
|
||
// parseMangaImages 解析并处理漫画图片
|
||
func parseMangaImages(content string, showHTML bool, outputMode string, itemID string, keyInfo *KeyInfo) (interface{}, error) {
|
||
// 提取JSON部分
|
||
jsonStart := strings.Index(content, "{")
|
||
jsonEnd := strings.LastIndex(content, "}")
|
||
if jsonStart == -1 || jsonEnd == -1 {
|
||
return nil, fmt.Errorf("无法找到JSON数据")
|
||
}
|
||
|
||
jsonStr := content[jsonStart : jsonEnd+1]
|
||
var mangaData MangaData
|
||
if err := json.Unmarshal([]byte(jsonStr), &mangaData); err != nil {
|
||
return nil, fmt.Errorf("JSON解析失败: %w", err)
|
||
}
|
||
|
||
if len(mangaData.PicInfos) == 0 {
|
||
return nil, fmt.Errorf("未找到图片信息")
|
||
}
|
||
|
||
if mangaData.EncryptKey == "" {
|
||
return nil, fmt.Errorf("缺少加密密钥")
|
||
}
|
||
|
||
// 根据不同模式处理图片
|
||
var images []ImageResult
|
||
switch outputMode {
|
||
case "url":
|
||
images = processURLMode(&mangaData)
|
||
case "proxy":
|
||
images = processProxyMode(&mangaData, itemID)
|
||
default: // "data_uri"
|
||
images = processDataURIMode(&mangaData)
|
||
}
|
||
|
||
// 无论什么模式,都填充缓存以便后续可以通过 /manga/image 访问
|
||
fillMangaCache(&mangaData, itemID)
|
||
|
||
return buildMangaResponse(images, showHTML), nil
|
||
}
|
||
|
||
// downloadImageWithRetry 带重试的图片下载
|
||
func downloadImageWithRetry(url string, maxRetries int) ([]byte, error) {
|
||
var lastErr error
|
||
|
||
for i := 0; i <= maxRetries; i++ {
|
||
data, err := downloadImage(url)
|
||
if err == nil && len(data) > 100 {
|
||
return data, nil
|
||
}
|
||
lastErr = err
|
||
if i < maxRetries {
|
||
// 重试间隔递增(2秒、4秒、6秒)
|
||
time.Sleep(time.Duration(i*2+2) * time.Second)
|
||
}
|
||
}
|
||
|
||
return nil, fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
|
||
}
|
||
|
||
// downloadImage 下载图片
|
||
func downloadImage(url string) ([]byte, error) {
|
||
// 创建专用的图片下载客户端,超时时间更长
|
||
client := &http.Client{
|
||
Timeout: 120 * time.Second,
|
||
}
|
||
|
||
req, err := http.NewRequest("GET", url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 使用与API一致的请求头
|
||
req.Header.Set("User-Agent", "okhttp/4.12.0")
|
||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||
req.Header.Set("Referer", "https://fanqie.novel.com/")
|
||
req.Header.Set("Origin", "https://fanqie.novel.com")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("下载失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("HTTP状态码: %d", resp.StatusCode)
|
||
}
|
||
|
||
return io.ReadAll(resp.Body)
|
||
}
|
||
|
||
// decryptImage 解密图片数据(AES-GCM)
|
||
func decryptImage(encryptedData, keyBytes []byte) ([]byte, error) {
|
||
// 加密格式: IV(12字节) + Ciphertext + Tag(16字节)
|
||
dataLen := len(encryptedData)
|
||
if dataLen < 28 {
|
||
return nil, fmt.Errorf("数据长度不足(至少需要28字节)")
|
||
}
|
||
|
||
// 提取各部分
|
||
iv := encryptedData[:12]
|
||
tag := encryptedData[dataLen-16:]
|
||
ciphertext := encryptedData[12 : dataLen-16]
|
||
|
||
// 创建AES cipher
|
||
block, err := aes.NewCipher(keyBytes)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建cipher失败: %w", err)
|
||
}
|
||
|
||
// 创建GCM mode
|
||
aesGCM, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建GCM失败: %w", err)
|
||
}
|
||
|
||
// 组合ciphertext和tag(Go的GCM要求tag附加在ciphertext后面)
|
||
ciphertextWithTag := append(ciphertext, tag...)
|
||
|
||
// 解密
|
||
plaintext, err := aesGCM.Open(nil, iv, ciphertextWithTag, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解密失败: %w", err)
|
||
}
|
||
|
||
return plaintext, nil
|
||
}
|
||
|
||
// getImageFormat 识别图片格式
|
||
// detectMangaPath 探测正确的漫画图片路径(novel-images/ 或 novel-pic/)
|
||
func detectMangaPath(baseDomain, firstMd5 string) string {
|
||
// 优先尝试 novel-images/
|
||
testURL := baseDomain + "novel-images/" + firstMd5
|
||
|
||
client := &http.Client{
|
||
Timeout: 8 * time.Second,
|
||
}
|
||
|
||
resp, err := client.Get(testURL)
|
||
if err != nil {
|
||
// 请求失败,使用novel-pic/
|
||
return "novel-pic/"
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应内容
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "novel-pic/"
|
||
}
|
||
|
||
// 检查是否为JSON响应(错误响应通常是JSON)
|
||
var jsonData interface{}
|
||
if json.Unmarshal(body, &jsonData) == nil {
|
||
// 是JSON,说明路径无效,切换到novel-pic/
|
||
return "novel-pic/"
|
||
}
|
||
|
||
// 不是JSON,说明返回的是图片,路径有效
|
||
return "novel-images/"
|
||
}
|
||
|
||
func getImageFormat(data []byte, url string) string {
|
||
// 首先尝试从URL提取扩展名
|
||
if idx := strings.LastIndex(url, "."); idx != -1 {
|
||
ext := strings.ToLower(url[idx+1:])
|
||
// 移除query参数
|
||
if qIdx := strings.Index(ext, "?"); qIdx != -1 {
|
||
ext = ext[:qIdx]
|
||
}
|
||
if ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "gif" || ext == "webp" {
|
||
return ext
|
||
}
|
||
}
|
||
|
||
// 通过文件头识别
|
||
if len(data) < 16 {
|
||
return "png"
|
||
}
|
||
|
||
// JPEG: FF D8 FF
|
||
if bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}) {
|
||
return "jpg"
|
||
}
|
||
|
||
// PNG: 89 50 4E 47
|
||
if bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4E, 0x47}) {
|
||
return "png"
|
||
}
|
||
|
||
// GIF: 47 49 46 38
|
||
if bytes.HasPrefix(data, []byte{0x47, 0x49, 0x46, 0x38}) {
|
||
return "gif"
|
||
}
|
||
|
||
// WEBP: RIFF...WEBP
|
||
if bytes.HasPrefix(data, []byte{0x52, 0x49, 0x46, 0x46}) &&
|
||
len(data) >= 12 &&
|
||
bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}) {
|
||
return "webp"
|
||
}
|
||
|
||
return "png"
|
||
}
|