mirror of
https://github.com/skidoodle/mediaproxy
synced 2026-04-28 08:27:34 +02:00
Add direct middleware mode
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.25.1-alpine AS builder
|
FROM golang:1.25.4-alpine AS builder
|
||||||
RUN apk add --no-cache build-base vips-dev
|
RUN apk add --no-cache build-base vips-dev
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|||||||
+3
-2
@@ -11,11 +11,12 @@ services:
|
|||||||
# - ALLOWED_DOMAINS=images.unsplash.com,static.pexels.com,*.example.com
|
# - ALLOWED_DOMAINS=images.unsplash.com,static.pexels.com,*.example.com
|
||||||
# - DEFAULT_IMAGE_QUALITY=100
|
# - DEFAULT_IMAGE_QUALITY=100
|
||||||
# - MAX_ALLOWED_SIZE=10485760
|
# - MAX_ALLOWED_SIZE=10485760
|
||||||
|
# - BASE_URL=cdn.example.com
|
||||||
# deploy:
|
# deploy:
|
||||||
# resources:
|
# resources:
|
||||||
# limits:
|
# limits:
|
||||||
# cpus: '0.5' # Limit to 50% of a single CPU core
|
# cpus: '0.5'
|
||||||
# memory: '256M' # Limit to 256 Megabytes of RAM
|
# memory: '256M'
|
||||||
# reservations:
|
# reservations:
|
||||||
# cpus: '0.25'
|
# cpus: '0.25'
|
||||||
# memory: '128M'
|
# memory: '128M'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- CACHE_TTL=30m
|
- CACHE_TTL=30m
|
||||||
|
- BASE_URL=cdn.example.com
|
||||||
- ALLOWED_DOMAINS=images.unsplash.com,static.pexels.com,*.example.com
|
- ALLOWED_DOMAINS=images.unsplash.com,static.pexels.com,*.example.com
|
||||||
- DEFAULT_IMAGE_QUALITY=100
|
- DEFAULT_IMAGE_QUALITY=100
|
||||||
- MAX_ALLOWED_SIZE=10485760
|
- MAX_ALLOWED_SIZE=10485760
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ func getEnvDuration(key string, fallback time.Duration) time.Duration {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvString(key string, fallback string) string {
|
||||||
|
if value, ok := os.LookupEnv(key); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func getEnvStringSlice(key string, fallback []string) []string {
|
func getEnvStringSlice(key string, fallback []string) []string {
|
||||||
if value, ok := os.LookupEnv(key); ok {
|
if value, ok := os.LookupEnv(key); ok {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
module mediaproxy
|
module mediaproxy
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dgraph-io/ristretto v0.2.0
|
github.com/dgraph-io/ristretto v1.0.1
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10
|
github.com/gabriel-vasile/mimetype v1.4.11
|
||||||
github.com/h2non/bimg v1.1.9
|
github.com/h2non/bimg v1.1.9
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,5 +13,5 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
|
github.com/dgraph-io/ristretto v1.0.1 h1:g3/HaHVSP+eC3kbryP9vjUY6IAW7jcGYRpj9wUVr0bU=
|
||||||
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
|
github.com/dgraph-io/ristretto v1.0.1/go.mod h1:TrOB7p4tcGX5Un6ppslIxtZaH2JLAk5GITV3TX2fuU8=
|
||||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
|
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
|
||||||
github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
|
github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -18,7 +18,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ type Config struct {
|
|||||||
DefaultImageQuality int
|
DefaultImageQuality int
|
||||||
ClientTimeout time.Duration
|
ClientTimeout time.Duration
|
||||||
LogLevel slog.Level
|
LogLevel slog.Level
|
||||||
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Config *Config
|
Config *Config
|
||||||
Cache *ristretto.Cache
|
Cache *ristretto.Cache[string, CacheEntry]
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -50,10 +51,11 @@ func main() {
|
|||||||
MaxAllowedSize: getEnvInt64("MAX_ALLOWED_SIZE", 1024*1024*50),
|
MaxAllowedSize: getEnvInt64("MAX_ALLOWED_SIZE", 1024*1024*50),
|
||||||
DefaultImageQuality: getEnvInt("DEFAULT_IMAGE_QUALITY", 80),
|
DefaultImageQuality: getEnvInt("DEFAULT_IMAGE_QUALITY", 80),
|
||||||
ClientTimeout: getEnvDuration("CLIENT_TIMEOUT", 2*time.Minute),
|
ClientTimeout: getEnvDuration("CLIENT_TIMEOUT", 2*time.Minute),
|
||||||
|
BaseURL: getEnvString("BASE_URL", ""),
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
}
|
}
|
||||||
|
|
||||||
cache, err := ristretto.NewCache(&ristretto.Config{
|
cache, err := ristretto.NewCache(&ristretto.Config[string, CacheEntry]{
|
||||||
NumCounters: 1e7, // Number of keys to track frequency of (10M).
|
NumCounters: 1e7, // Number of keys to track frequency of (10M).
|
||||||
MaxCost: 1 << 30, // Maximum cost of cache (1GB).
|
MaxCost: 1 << 30, // Maximum cost of cache (1GB).
|
||||||
BufferItems: 64, // Number of keys per Get buffer.
|
BufferItems: 64, // Number of keys per Get buffer.
|
||||||
@@ -111,13 +113,19 @@ func main() {
|
|||||||
func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
logger := ctx.Value(loggerKey).(*slog.Logger)
|
logger := ctx.Value(loggerKey).(*slog.Logger)
|
||||||
mediaURL := r.URL.Path[1:]
|
|
||||||
|
|
||||||
if strings.HasPrefix(mediaURL, "https:/") && !strings.HasPrefix(mediaURL, "https://") {
|
var mediaURL string
|
||||||
mediaURL = "https://" + mediaURL[6:]
|
if app.Config.BaseURL != "" {
|
||||||
}
|
baseURL := strings.TrimSuffix(app.Config.BaseURL, "/")
|
||||||
if strings.HasPrefix(mediaURL, "http:/") && !strings.HasPrefix(mediaURL, "http://") {
|
mediaURL = "https://" + baseURL + r.URL.Path
|
||||||
mediaURL = "http://" + mediaURL[5:]
|
} else {
|
||||||
|
mediaURL = r.URL.Path[1:]
|
||||||
|
if strings.HasPrefix(mediaURL, "https:/") && !strings.HasPrefix(mediaURL, "https://") {
|
||||||
|
mediaURL = "https://" + mediaURL[6:]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mediaURL, "http:/") && !strings.HasPrefix(mediaURL, "http://") {
|
||||||
|
mediaURL = "http://" + mediaURL[5:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger = logger.With("media_url", mediaURL)
|
logger = logger.With("media_url", mediaURL)
|
||||||
@@ -134,19 +142,17 @@ func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isAllowedDomain(parsedURL.Host, app.Config.AllowedDomains) {
|
if app.Config.BaseURL == "" && !isAllowedDomain(parsedURL.Host, app.Config.AllowedDomains) {
|
||||||
logger.Warn("Domain not allowed", "domain", parsedURL.Host)
|
logger.Warn("Domain not allowed", "domain", parsedURL.Host)
|
||||||
http.Error(w, "Domain not allowed", http.StatusForbidden)
|
http.Error(w, "Domain not allowed", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if value, found := app.Cache.Get(mediaURL); found {
|
if cachedEntry, found := app.Cache.Get(mediaURL); found {
|
||||||
if cachedEntry, ok := value.(CacheEntry); ok {
|
logger.Debug("Serving from cache")
|
||||||
logger.Debug("Serving image from cache")
|
w.Header().Set("Content-Type", cachedEntry.ContentType)
|
||||||
w.Header().Set("Content-Type", cachedEntry.ContentType)
|
w.Write(cachedEntry.Data)
|
||||||
w.Write(cachedEntry.Data)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug("Cache miss, performing HEAD request to origin")
|
logger.Debug("Cache miss, performing HEAD request to origin")
|
||||||
@@ -156,12 +162,13 @@ func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Could not fetch media metadata", http.StatusInternalServerError)
|
http.Error(w, "Could not fetch media metadata", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer headResp.Body.Close()
|
||||||
|
|
||||||
if headResp.StatusCode != http.StatusOK {
|
if headResp.StatusCode != http.StatusOK {
|
||||||
logger.Warn("Origin server returned non-200 status for HEAD request", "status", headResp.StatusCode)
|
logger.Warn("Origin server returned non-200 status for HEAD request", "status", headResp.StatusCode)
|
||||||
http.Error(w, fmt.Sprintf("Media source returned status: %d", headResp.StatusCode), headResp.StatusCode)
|
http.Error(w, fmt.Sprintf("Media source returned status: %d", headResp.StatusCode), headResp.StatusCode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer headResp.Body.Close()
|
|
||||||
|
|
||||||
headerContentType := headResp.Header.Get("Content-Type")
|
headerContentType := headResp.Header.Get("Content-Type")
|
||||||
if !isAllowedType(headerContentType, true) {
|
if !isAllowedType(headerContentType, true) {
|
||||||
@@ -174,19 +181,18 @@ func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
switch mediaTypeCategory {
|
switch mediaTypeCategory {
|
||||||
case "image":
|
case "image":
|
||||||
logger.Debug("Delegating to image handler")
|
logger.Debug("Delegating to image handler")
|
||||||
app.handleImage(w, r)
|
app.handleImage(w, r, mediaURL)
|
||||||
case "video", "audio":
|
case "video", "audio":
|
||||||
logger.Debug("Delegating to stream handler")
|
logger.Debug("Delegating to stream handler")
|
||||||
app.handleStream(w, r)
|
app.handleStream(w, r, mediaURL)
|
||||||
default:
|
default:
|
||||||
logger.Warn("Media type passed initial checks but is not an image, video, or audio", "category", mediaTypeCategory)
|
logger.Warn("Media type passed initial checks but is not an image, video, or audio", "category", mediaTypeCategory)
|
||||||
http.Error(w, "Unsupported media type", http.StatusUnsupportedMediaType)
|
http.Error(w, "Unsupported media type", http.StatusUnsupportedMediaType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) handleImage(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleImage(w http.ResponseWriter, r *http.Request, mediaURL string) {
|
||||||
logger := r.Context().Value(loggerKey).(*slog.Logger)
|
logger := r.Context().Value(loggerKey).(*slog.Logger)
|
||||||
mediaURL := r.URL.Path[1:]
|
|
||||||
|
|
||||||
resp, err := app.Client.Get(mediaURL)
|
resp, err := app.Client.Get(mediaURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,11 +202,16 @@ func (app *App) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, app.Config.MaxAllowedSize)
|
limitedReader := &io.LimitedReader{R: resp.Body, N: app.Config.MaxAllowedSize}
|
||||||
mediaData, err := io.ReadAll(resp.Body)
|
mediaData, err := io.ReadAll(limitedReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Could not read image data", "error", err)
|
logger.Error("Could not read image data", "error", err)
|
||||||
http.Error(w, "Could not read image data", http.StatusRequestEntityTooLarge)
|
http.Error(w, "Could not read image data", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if limitedReader.N == 0 {
|
||||||
|
logger.Error("Image exceeds max allowed size", "limit", app.Config.MaxAllowedSize)
|
||||||
|
http.Error(w, "Image exceeds max allowed size", http.StatusRequestEntityTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,25 +244,32 @@ func (app *App) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
entryToCache = CacheEntry{ContentType: "image/webp", Data: optimizedImage}
|
entryToCache = CacheEntry{ContentType: "image/webp", Data: optimizedImage}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Cache.SetWithTTL(mediaURL, entryToCache, 1, app.Config.CacheTTL)
|
app.Cache.SetWithTTL(mediaURL, entryToCache, int64(len(entryToCache.Data)), app.Config.CacheTTL)
|
||||||
app.Cache.Wait()
|
app.Cache.Wait()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", entryToCache.ContentType)
|
w.Header().Set("Content-Type", entryToCache.ContentType)
|
||||||
w.Write(entryToCache.Data)
|
w.Write(entryToCache.Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) handleStream(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleStream(w http.ResponseWriter, r *http.Request, mediaURL string) {
|
||||||
logger := r.Context().Value(loggerKey).(*slog.Logger)
|
logger := r.Context().Value(loggerKey).(*slog.Logger)
|
||||||
mediaURL := r.URL.Path[1:]
|
|
||||||
|
|
||||||
originReq, err := http.NewRequestWithContext(r.Context(), r.Method, mediaURL, r.Body)
|
originReq, err := http.NewRequestWithContext(r.Context(), r.Method, mediaURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to create origin request", "error", err)
|
logger.Error("Failed to create origin request", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
originReq.Header = r.Header.Clone()
|
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||||
|
originReq.Header.Set("Range", rangeHeader)
|
||||||
|
}
|
||||||
|
if acceptHeader := r.Header.Get("Accept"); acceptHeader != "" {
|
||||||
|
originReq.Header.Set("Accept", acceptHeader)
|
||||||
|
}
|
||||||
|
if userAgentHeader := r.Header.Get("User-Agent"); userAgentHeader != "" {
|
||||||
|
originReq.Header.Set("User-Agent", userAgentHeader)
|
||||||
|
}
|
||||||
|
|
||||||
originResp, err := app.Client.Do(originReq)
|
originResp, err := app.Client.Do(originReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,25 +7,47 @@
|
|||||||
- **Intelligent**: Optimizes static images to WebP, preserves GIF animations, and streams video/audio.
|
- **Intelligent**: Optimizes static images to WebP, preserves GIF animations, and streams video/audio.
|
||||||
- **High-Performance**: Uses an in-memory cache (ristretto) for instant delivery of hot assets.
|
- **High-Performance**: Uses an in-memory cache (ristretto) for instant delivery of hot assets.
|
||||||
- **Low Usage**: Built with Go and `libvips` for minimal CPU and memory footprint.
|
- **Low Usage**: Built with Go and `libvips` for minimal CPU and memory footprint.
|
||||||
- **Whitelisting**: Optionally restrict proxying to a specific list of allowed domains.
|
- **Flexible Modes**: Can be used as a standard proxy requiring the full media URL or as a direct middleware with a pre-configured base URL.
|
||||||
|
- **Whitelisting**: In standard mode, you can restrict proxying to a specific list of allowed domains.
|
||||||
- **Configurable**: All settings are managed via environment variables for easy deployment.
|
- **Configurable**: All settings are managed via environment variables for easy deployment.
|
||||||
|
|
||||||
## Example Usage
|
## Example Usage
|
||||||
|
|
||||||
|
`mediaproxy` can run in two modes:
|
||||||
|
|
||||||
|
### Standard Mode
|
||||||
|
|
||||||
|
In this mode, you pass the full URL of the media asset in the path.
|
||||||
|
|
||||||
The service endpoint is `https://<your-domain>/<full_media_url>`.
|
The service endpoint is `https://<your-domain>/<full_media_url>`.
|
||||||
|
|
||||||
|
**Example:** `https://cdn.example.com/https://images.pexels.com/photos/1314550/pexels-photo-1314550.jpeg`
|
||||||
|
|
||||||
|
### Middleware Mode
|
||||||
|
|
||||||
|
This mode is activated by setting the `BASE_URL` environment variable. It allows you to use the service as a direct proxy without passing the full URL.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
1. Set the environment variable `BASE_URL=cdn.example.com`.
|
||||||
|
2. Request `https://media.example.com/object/image.jpg`.
|
||||||
|
3. The service will proxy the request to `https://cdn.albert.lol/object/image.jpg`.
|
||||||
|
|
||||||
|
This is useful for cleaner URLs and when proxying to a single, trusted domain.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The service is configured using environment variables:
|
The service is configured using environment variables:
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| --------------------- | ------------------------------------------------------------ | ---------------- |
|
| :--- | :--- | :--- |
|
||||||
| `LOG_LEVEL` | The logging level (`DEBUG`, `INFO`, `WARN`, `ERROR`). | `INFO` |
|
| `LOG_LEVEL` | The logging level (`DEBUG`, `INFO`, `WARN`, `ERROR`). | `INFO` |
|
||||||
| `CACHE_TTL` | The duration for which images are cached. | `10m` |
|
| `BASE_URL` | If set, activates middleware mode. The service will prepend `https://<BASE_URL>/` to all incoming request paths. When active, `ALLOWED_DOMAINS` is ignored. | (empty, disabled) |
|
||||||
| `ALLOWED_DOMAINS` | Comma-separated list of domains to whitelist. | (empty, all allowed) |
|
| `CACHE_TTL` | The duration for which images are cached. | `10m` |
|
||||||
| `MAX_ALLOWED_SIZE` | Maximum media file size in bytes. | `52428800` (50MB)|
|
| `ALLOWED_DOMAINS` | Comma-separated list of domains to whitelist. Ignored if `BASE_URL` is set. | (empty, all allowed) |
|
||||||
| `DEFAULT_IMAGE_QUALITY` | Quality for optimized WebP images (1-100). | `80` |
|
| `MAX_ALLOWED_SIZE` | Maximum media file size in bytes. | `52428800` (50MB)|
|
||||||
| `CLIENT_TIMEOUT` | Timeout for fetching media from the origin. | `2m` |
|
| `DEFAULT_IMAGE_QUALITY` | Quality for optimized WebP images (1-100). | `80` |
|
||||||
|
| `CLIENT_TIMEOUT` | Timeout for fetching media from the origin. | `2m` |
|
||||||
|
|
||||||
## Running Locally
|
## Running Locally
|
||||||
|
|
||||||
@@ -57,7 +79,9 @@ go run .
|
|||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
Create a `compose.yaml` file with the following content:
|
Create a `compose.yaml` file with your desired configuration.
|
||||||
|
|
||||||
|
**Standard Mode Example:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -73,6 +97,22 @@ services:
|
|||||||
ALLOWED_DOMAINS: images.pexels.com,media.giphy.com,videos.pexels.com
|
ALLOWED_DOMAINS: images.pexels.com,media.giphy.com,videos.pexels.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Middleware Mode Example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mediaproxy:
|
||||||
|
image: ghcr.io/skidoodle/mediaproxy:main
|
||||||
|
container_name: mediaproxy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
CACHE_TTL: 1h
|
||||||
|
BASE_URL: cdn.example.com
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GPL-3.0](https://github.com/skidoodle/mediaproxy/blob/main/license)
|
[GPL-3.0](https://github.com/skidoodle/mediaproxy/blob/main/license)
|
||||||
|
|||||||
Reference in New Issue
Block a user