image-proxy/handler.go
2024-02-08 00:41:17 +01:00

224 lines
5.7 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"os"
"regexp"
"strconv"
"time"
"github.com/davidbyttow/govips/v2/vips"
)
type Handler struct {
// Port is the port to serve on.
Port int `json:"port"`
// Whether to log debug messages
Debug bool `json:"debug"`
// Patterns is an array of image URL patterns to serve.
Patterns []pattern `json:"patterns"`
// ProxyTarget is the host that requests for the default image size will be rewritten to.
ProxyTarget string `json:"proxy_target"`
proxy *httputil.ReverseProxy
}
type pattern struct {
Input string `json:"input"`
Output string `json:"output"`
CacheKey string `json:"cache_key"`
ContentType string `json:"content_type"`
Sizes []int `json:"sizes"`
regexp *regexp.Regexp
}
func (p *pattern) acceptsSize(size int) bool {
for i := range p.Sizes {
if p.Sizes[i] == size {
return true
}
}
return false
}
// The proxy handler entrypoint. This handles rewriting the URL to the original image, and
func (hn *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var pattern *pattern
for i := range hn.Patterns {
if hn.Patterns[i].regexp.MatchString(r.URL.Path) {
pattern = &hn.Patterns[i]
break
}
}
if pattern == nil {
http.Error(w, "Invalid image URL", http.StatusBadRequest)
return
}
var size int
if s := r.FormValue("size"); s != "" {
i, err := strconv.Atoi(s)
if err != nil {
http.Error(w, "Invalid image size", http.StatusBadRequest)
return
}
size = i
} else {
size = pattern.Sizes[0]
}
if !pattern.acceptsSize(size) {
http.Error(w, "Invalid image size", http.StatusBadRequest)
return
}
// If the requested size is the default, just proxy the request
if size == pattern.Sizes[0] {
if hn.Debug {
log.Printf("[DEBUG] Proxying image %q as it has default size of %v", r.URL.Path, size)
}
hn.proxy.ServeHTTP(w, r)
return
}
url := pattern.regexp.ReplaceAllString(r.URL.Path, pattern.Output)
cacheKey := pattern.regexp.ReplaceAllString(r.URL.Path, pattern.CacheKey)
if hn.Debug {
log.Printf("[DEBUG] Resizing image %q to size %v and serving it", r.URL.Path, size)
}
hn.proxyImage(r.Context(), w, cacheKey, url, pattern.ContentType, size)
}
func (hn *Handler) proxyImage(ctx context.Context, w http.ResponseWriter, cacheKey, url, contentType string, size int) {
// Check if we've cached the image
cacheName := fmt.Sprintf("cache/%v/%v", size, cacheKey)
cacheFile, err := os.Open(cacheName)
if err == nil {
defer func() {
err := cacheFile.Close()
if err != nil {
log.Printf("error closing file %q: %v", cacheName, err)
}
}()
fi, err := cacheFile.Stat()
if err != nil {
log.Printf("error getting file info for file %q: %v", cacheName, err)
return
}
// If so, just copy it to the output
w.Header().Add("Content-Type", contentType)
w.Header().Add("Content-Length", strconv.FormatInt(fi.Size(), 10))
_, err = io.Copy(w, cacheFile)
if err != nil {
log.Printf("error serving cached file %q: %v", cacheName, err)
}
return
} else {
if !errors.Is(err, os.ErrNotExist) {
log.Printf("error opening cache file %q: %v", cacheName, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Otherwise, fall back to fetching the image + resizing it
start := time.Now()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log.Printf("creating request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("executing request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
http.Error(w, "Not found", http.StatusNotFound)
return
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
image, err := vips.NewImageFromReader(resp.Body)
if err != nil {
log.Printf("creating vips image: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
err = image.ThumbnailWithSize(size, size, vips.InterestingCentre, vips.SizeBoth)
if err != nil {
log.Printf("resizing vips image: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
out, _, err := export(image, contentType)
if err != nil {
log.Printf("exporting vips image: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if hn.Debug {
log.Printf("resized image %q in %v", cacheKey, time.Since(start).Round(time.Microsecond))
}
go func(b []byte) {
err = os.WriteFile(cacheName, b, 0o644)
if err != nil {
log.Printf("writing image to cache: %v", err)
return
}
}(out)
w.Header().Add("Content-Type", contentType)
w.Header().Add("Content-Length", strconv.Itoa(len(out)))
_, err = w.Write(out)
if err != nil {
log.Printf("error writing resized file to response: %v", err)
}
}
func export(image *vips.ImageRef, contentType string) ([]byte, *vips.ImageMetadata, error) {
switch contentType {
case "image/webp":
params := vips.NewWebpExportParams()
params.Quality = 90
return image.ExportWebp(params)
case "image/jpeg":
params := vips.NewJpegExportParams()
params.Quality = 90
return image.ExportJpeg(params)
case "image/png":
params := vips.NewPngExportParams()
params.Quality = 90
return image.ExportPng(params)
case "image/gif":
params := vips.NewGifExportParams()
params.Quality = 90
return image.ExportGIF(params)
}
return nil, nil, errors.New("invalid image content type")
}