Files
MeBox/internal/service/image_proxy_remote_fetch.go
T
2026-09-14 15:12:42 +08:00

220 lines
7.0 KiB
Go

package service
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os/exec"
"strings"
"time"
"go.uber.org/zap"
)
type remoteImageFetchClient struct {
name string
client *http.Client
}
func (p *ImageProxy) remoteImageFetchClients() []remoteImageFetchClient {
client := p.client
if client == nil {
client = NewExternalHTTPClient(30 * time.Second)
}
clients := []remoteImageFetchClient{{name: "default", client: client}}
if _, ok := client.Transport.(*http.Transport); ok {
timeout := client.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
clients = append(clients, remoteImageFetchClient{
name: "direct",
client: &http.Client{Timeout: timeout, Transport: NewInternalTransport()},
})
}
return clients
}
func (p *ImageProxy) canUseExternalImageFallback() bool {
if p == nil || p.client == nil {
return false
}
_, ok := p.client.Transport.(*http.Transport)
return ok
}
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient) ([]byte, string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
p.log.Warn("imageproxy: build request failed", zap.String("url", redactSensitiveURL(raw)), zap.Error(redactSensitiveError(err)))
return nil, "", "", errImageProxyRequestSetup
}
applyRemoteImageHeaders(req, host, raw)
resp, err := candidate.client.Do(req)
if err != nil {
logImageFetchError(p.log, "imageproxy: upstream fetch failed", host, candidate.name, err)
return nil, "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
return nil, "", "", errors.New("upstream returned " + resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil || len(data) == 0 {
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(redactSensitiveError(err)))
if err == nil {
err = errors.New("upstream image body is empty")
}
return nil, "", "", err
}
ctype, ok := validImageContentType(data)
if !ok {
p.log.Warn("imageproxy: upstream returned non-image content", zap.String("host", host), zap.String("client", candidate.name), zap.String("content_type", resp.Header.Get("Content-Type")))
return nil, "", "", errImageProxyNonImageContent
}
return data, ctype, resp.Header.Get("Content-Length"), nil
}
func logImageFetchError(log *zap.Logger, message, host, client string, err error) {
if log == nil || err == nil {
return
}
fields := []zap.Field{
zap.String("host", host),
zap.String("client", client),
zap.Error(redactSensitiveError(err)),
}
if errors.Is(err, context.Canceled) {
log.Debug(message, fields...)
return
}
log.Warn(message, fields...)
}
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
if cookie := remoteImageCookie(host); cookie != "" {
req.Header.Set("Cookie", cookie)
}
if referer := remoteImageReferer(host, raw); referer != "" {
req.Header.Set("Referer", referer)
}
}
func remoteImageCookie(host string) string {
h := strings.ToLower(strings.TrimSpace(host))
switch {
case strings.Contains(h, "javbus") || strings.Contains(h, "busjav") || strings.Contains(h, "dmmbus") || strings.Contains(h, "javsee") || strings.Contains(h, "cdnbus"):
return "age=verified; existmag=all"
case strings.Contains(h, "javdb") || strings.Contains(h, "jdbstatic"):
return "over18=1"
default:
return ""
}
}
func remoteImageReferer(host, raw string) string {
h := strings.ToLower(strings.TrimSpace(host))
switch {
case strings.Contains(h, "doubanio.com"):
return "https://movie.douban.com/"
case strings.Contains(h, "bgm.tv"):
return "https://bgm.tv/"
case strings.Contains(h, "javbus") || strings.Contains(h, "busjav") || strings.Contains(h, "dmmbus") || strings.Contains(h, "javsee"):
return "https://www.javbus.com/"
case strings.Contains(h, "javdb") || strings.Contains(h, "jdbstatic"):
return "https://javdb.com/"
case strings.Contains(h, "dmm.co.jp") || strings.Contains(h, "dmm.com"):
return "https://www.dmm.co.jp/"
case strings.Contains(h, "mgstage.com"):
return "https://www.mgstage.com/"
case strings.Contains(h, "faleno.jp"):
return "https://faleno.jp/"
case strings.Contains(h, "fc2.com"):
return "https://adult.contents.fc2.com/"
case h != "":
scheme := "https"
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http://") {
scheme = "http"
}
return scheme + "://" + h + "/"
default:
return ""
}
}
func isDoubanImageHost(host string) bool {
return strings.Contains(strings.ToLower(host), "doubanio.com")
}
func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, string, string, error) {
bin, err := exec.LookPath("curl")
if err != nil {
return nil, "", "", err
}
curlCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
args := []string{
"--fail",
"--location",
"--silent",
"--show-error",
"--http1.1",
"--max-time", "20",
"--proto", "=http,https",
"--proto-redir", "=http,https",
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
"--header", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"--header", "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8",
"--header", "Cache-Control: no-cache",
"--header", "Pragma: no-cache",
}
if referer := remoteImageReferer(host, raw); referer != "" {
args = append(args, "--referer", referer)
}
if cookie := remoteImageCookie(host); cookie != "" {
args = append(args, "--cookie", cookie)
}
args = append(args, "--", raw)
cmd := exec.CommandContext(curlCtx, bin, args...) // #nosec G204 -- bin is resolved by LookPath and args are not shell-expanded.
stderr := bytes.Buffer{}
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, "", "", err
}
if err := cmd.Start(); err != nil {
return nil, "", "", err
}
data, readErr := io.ReadAll(io.LimitReader(stdout, 32<<20))
waitErr := cmd.Wait()
if readErr != nil {
return nil, "", "", readErr
}
if waitErr != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return nil, "", "", redactSensitiveError(errors.New(message))
}
return nil, "", "", redactSensitiveError(waitErr)
}
if len(data) == 0 {
return nil, "", "", errors.New("curl image body is empty")
}
ctype := detectContentType(data)
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
return nil, "", "", errors.New("curl returned non-image content")
}
return data, ctype, "", nil
}