Add direct middleware mode

This commit is contained in:
2025-11-18 11:35:19 +01:00
parent d6759fb1b8
commit 24d7ecec8f
8 changed files with 119 additions and 52 deletions
+1 -1
View File
@@ -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
View File
@@ -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'
+1
View File
@@ -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
+7
View File
@@ -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 == "" {
+4 -4
View File
@@ -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
) )
+6 -6
View File
@@ -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=
+47 -29
View File
@@ -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 {
+50 -10
View File
@@ -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)