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) }