Files
MeBox/internal/service/image_proxy_remote.go
T

190 lines
5.9 KiB
Go

package service
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
func (p *ImageProxy) PrefetchRemote(ctx context.Context, raw string) error {
_, _, err := p.Fetch(ctx, raw)
return err
}
func (p *ImageProxy) RemoveCached(raw string) error {
if !isHTTPish(raw) {
return nil
}
_, cachePath, failPath, err := p.remoteImageCachePaths(raw)
if err != nil {
return nil
}
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// Serve writes the requested image to w. Caller is expected to validate
// the JWT before invoking it.
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
if isLocalImagePath(raw) {
return p.serveLocalImage(w, r, raw)
}
return p.serveRemoteImage(ctx, w, r, raw)
}
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string) error {
path := filepath.Clean(raw)
abs, err := filepath.Abs(path)
if err != nil || !p.isAllowedLocalPath(abs) {
servePlaceholder(w)
return nil
}
if !serveImageFile(w, r, filepath.Base(abs), abs, imageBrowserCacheControl) {
servePlaceholder(w)
}
return nil
}
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
u, err := p.validateURL(raw)
if err != nil {
return err
}
host := strings.ToLower(u.Host)
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
if serveCachedImageFile(w, r, key, cachePath) {
return nil
}
if p.serveFreshRemoteFailure(w, failPath) {
return nil
}
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
if err != nil {
if errors.Is(err, errImageProxyRequestSetup) {
servePlaceholder(w)
} else {
serveCachedPlaceholder(w)
}
return nil
}
w.Header().Set("Content-Type", ctype)
if contentLength != "" {
w.Header().Set("Content-Length", contentLength)
}
modTime := time.Now()
if stat, err := os.Stat(cachePath); err == nil && stat.Size() > 0 {
modTime = stat.ModTime()
w.Header().Set("ETag", imageFileETag(key, stat))
}
w.Header().Set("Cache-Control", imageBrowserCacheControl)
http.ServeContent(w, r, key, modTime, bytes.NewReader(data))
return nil
}
func (p *ImageProxy) serveFreshRemoteFailure(w http.ResponseWriter, failPath string) bool {
if stat, err := os.Stat(failPath); err == nil && time.Since(stat.ModTime()) < imageNegativeCacheTTL {
serveCachedPlaceholder(w)
return true
} else if err == nil {
_ = os.Remove(failPath)
}
return false
}
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) {
if err := os.MkdirAll(p.cacheDir, 0o750); err != nil {
p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err))
return nil, "", "", errImageProxyRequestSetup
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
return nil, "", "", errImageProxyRequestSetup
}
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")
if strings.Contains(host, "doubanio.com") {
req.Header.Set("Referer", "https://movie.douban.com/")
}
resp, err := p.client.Do(req)
if err != nil {
p.log.Warn("imageproxy: upstream fetch failed", zap.String("host", host), zap.Error(err))
p.markImageFetchFailed(failPath)
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("status", resp.Status))
p.markImageFetchFailed(failPath)
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.Error(err))
p.markImageFetchFailed(failPath)
if err == nil {
err = errors.New("upstream image body is empty")
}
return nil, "", "", err
}
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
ctype := resp.Header.Get("Content-Type")
if ctype == "" {
ctype = detectContentType(data)
}
return data, ctype, resp.Header.Get("Content-Length"), nil
}
// Fetch pulls a remote image and returns bytes plus Content-Type using cache.
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
if _, err := p.validateURL(raw); err != nil {
return nil, "", err
}
_, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is SHA-derived under cacheDir.
return data, detectContentType(data), nil
}
if stat, err := os.Stat(failPath); err == nil && time.Since(stat.ModTime()) < imageNegativeCacheTTL {
return nil, "", errors.New("recent image fetch failure")
} else if err == nil {
_ = os.Remove(failPath)
}
data, ctype, _, err := p.fetchAndCacheRemoteImage(ctx, raw, "", cachePath, failPath)
return data, ctype, err
}
func (p *ImageProxy) writeImageCache(cachePath, failPath, pattern string, data []byte) {
p.mu.Lock()
defer p.mu.Unlock()
tmp, tmpErr := os.CreateTemp(p.cacheDir, pattern)
if tmpErr != nil {
return
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return
}
_ = tmp.Close()
if err := os.Rename(tmp.Name(), cachePath); err != nil {
_ = os.Remove(tmp.Name())
return
}
_ = os.Remove(failPath)
}