158 lines
4.3 KiB
Go
158 lines
4.3 KiB
Go
package main
|
||
|
||
// imgproxy.go:远程图片拉取。
|
||
// WebView 页面源是 http://wails.localhost,直接加载外站 HTTPS 图常会破图。
|
||
// 优先经 Go 拉成 dataURL(FetchRemoteImageAsDataURL);AssetServer /__ccimg 作兜底。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
imgProxyPath = "/__ccimg"
|
||
imgProxyMaxBytes = 12 << 20 // 12MB
|
||
)
|
||
|
||
var imgProxyClient = &http.Client{Timeout: 20 * time.Second}
|
||
|
||
var (
|
||
imgDataURLMu sync.Mutex
|
||
imgDataURLCache = map[string]string{}
|
||
errBadImageURL = errors.New("bad url")
|
||
)
|
||
|
||
// remoteImageMiddleware 拦截 /__ccimg?u=<urlencoded http(s) URL>,拉取上游图片并回传。
|
||
func remoteImageMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||
if path != imgProxyPath {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
raw := strings.TrimSpace(r.URL.Query().Get("u"))
|
||
data, ct, err := fetchRemoteImage(r.Context(), raw)
|
||
if err != nil {
|
||
code := http.StatusBadGateway
|
||
if errors.Is(err, errBadImageURL) {
|
||
code = http.StatusBadRequest
|
||
}
|
||
http.Error(w, err.Error(), code)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", ct)
|
||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||
_, _ = w.Write(data)
|
||
})
|
||
}
|
||
|
||
func fetchRemoteImage(ctx context.Context, raw string) ([]byte, string, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
u, err := url.Parse(raw)
|
||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||
return nil, "", errBadImageURL
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||
if err != nil {
|
||
return nil, "", errBadImageURL
|
||
}
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
|
||
resp, err := imgProxyClient.Do(req)
|
||
if err != nil {
|
||
return nil, "", errors.New("upstream unreachable")
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, "", errors.New("upstream error")
|
||
}
|
||
ct := resp.Header.Get("Content-Type")
|
||
if i := strings.Index(ct, ";"); i >= 0 {
|
||
ct = strings.TrimSpace(ct[:i])
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, imgProxyMaxBytes+1))
|
||
if err != nil {
|
||
return nil, "", errors.New("upstream read failed")
|
||
}
|
||
if len(data) > imgProxyMaxBytes {
|
||
return nil, "", errors.New("image too large")
|
||
}
|
||
if len(data) == 0 {
|
||
return nil, "", errors.New("empty image")
|
||
}
|
||
if ct == "" || (!strings.HasPrefix(ct, "image/") && !strings.Contains(ct, "octet-stream")) {
|
||
if sniffed := sniffImageMIME(data); sniffed != "" {
|
||
ct = sniffed
|
||
}
|
||
}
|
||
if ct == "" {
|
||
ct = "application/octet-stream"
|
||
}
|
||
return data, ct, nil
|
||
}
|
||
|
||
func sniffImageMIME(b []byte) string {
|
||
if len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff {
|
||
return "image/jpeg"
|
||
}
|
||
if len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n" {
|
||
return "image/png"
|
||
}
|
||
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
|
||
return "image/gif"
|
||
}
|
||
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
|
||
return "image/webp"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// FetchRemoteImageAsDataURL 由 Go 拉取远程图片并返回 dataURL,绕过 WebView 外站限制。
|
||
func (a *App) FetchRemoteImageAsDataURL(rawURL string) (string, error) {
|
||
rawURL = strings.TrimSpace(rawURL)
|
||
if rawURL == "" {
|
||
return "", errors.New("EMPTY_URL")
|
||
}
|
||
if strings.HasPrefix(rawURL, "data:") {
|
||
return rawURL, nil
|
||
}
|
||
imgDataURLMu.Lock()
|
||
if cached, ok := imgDataURLCache[rawURL]; ok {
|
||
imgDataURLMu.Unlock()
|
||
return cached, nil
|
||
}
|
||
imgDataURLMu.Unlock()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
defer cancel()
|
||
data, ct, err := fetchRemoteImage(ctx, rawURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !strings.HasPrefix(ct, "image/") {
|
||
ct = "image/png"
|
||
if sniffed := sniffImageMIME(data); sniffed != "" {
|
||
ct = sniffed
|
||
}
|
||
}
|
||
out := fmt.Sprintf("data:%s;base64,%s", ct, base64.StdEncoding.EncodeToString(data))
|
||
imgDataURLMu.Lock()
|
||
if len(imgDataURLCache) > 128 {
|
||
imgDataURLCache = map[string]string{}
|
||
}
|
||
imgDataURLCache[rawURL] = out
|
||
imgDataURLMu.Unlock()
|
||
return out, nil
|
||
}
|