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
WORKDIR /app
COPY go.mod go.sum ./
+3 -2
View File
@@ -11,11 +11,12 @@ services:
# - ALLOWED_DOMAINS=images.unsplash.com,static.pexels.com,*.example.com
# - DEFAULT_IMAGE_QUALITY=100
# - MAX_ALLOWED_SIZE=10485760
# - BASE_URL=cdn.example.com
# deploy:
# resources:
# limits:
# cpus: '0.5' # Limit to 50% of a single CPU core
# memory: '256M' # Limit to 256 Megabytes of RAM
# cpus: '0.5'
# memory: '256M'
# reservations:
# cpus: '0.25'
# memory: '128M'
+1
View File
@@ -7,6 +7,7 @@ services:
restart: unless-stopped
environment:
- CACHE_TTL=30m
- BASE_URL=cdn.example.com
- ALLOWED_DOMAINS=images.unsplash.com,static.pexels.com,*.example.com
- DEFAULT_IMAGE_QUALITY=100
- MAX_ALLOWED_SIZE=10485760
+7
View File
@@ -33,6 +33,13 @@ func getEnvDuration(key string, fallback time.Duration) time.Duration {
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 {
if value, ok := os.LookupEnv(key); ok {
if value == "" {
+4 -4
View File
@@ -1,10 +1,10 @@
module mediaproxy
go 1.25.1
go 1.25.4
require (
github.com/dgraph-io/ristretto v0.2.0
github.com/gabriel-vasile/mimetype v1.4.10
github.com/dgraph-io/ristretto v1.0.1
github.com/gabriel-vasile/mimetype v1.4.11
github.com/h2non/bimg v1.1.9
)
@@ -13,5 +13,5 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/pkg/errors v0.9.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/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/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
github.com/dgraph-io/ristretto v1.0.1 h1:g3/HaHVSP+eC3kbryP9vjUY6IAW7jcGYRpj9wUVr0bU=
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/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
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/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+39 -21
View File
@@ -30,11 +30,12 @@ type Config struct {
DefaultImageQuality int
ClientTimeout time.Duration
LogLevel slog.Level
BaseURL string
}
type App struct {
Config *Config
Cache *ristretto.Cache
Cache *ristretto.Cache[string, CacheEntry]
Client *http.Client
Logger *slog.Logger
}
@@ -50,10 +51,11 @@ func main() {
MaxAllowedSize: getEnvInt64("MAX_ALLOWED_SIZE", 1024*1024*50),
DefaultImageQuality: getEnvInt("DEFAULT_IMAGE_QUALITY", 80),
ClientTimeout: getEnvDuration("CLIENT_TIMEOUT", 2*time.Minute),
BaseURL: getEnvString("BASE_URL", ""),
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).
MaxCost: 1 << 30, // Maximum cost of cache (1GB).
BufferItems: 64, // Number of keys per Get buffer.
@@ -111,14 +113,20 @@ func main() {
func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := ctx.Value(loggerKey).(*slog.Logger)
mediaURL := r.URL.Path[1:]
var mediaURL string
if app.Config.BaseURL != "" {
baseURL := strings.TrimSuffix(app.Config.BaseURL, "/")
mediaURL = "https://" + baseURL + r.URL.Path
} 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)
@@ -134,20 +142,18 @@ func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
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)
http.Error(w, "Domain not allowed", http.StatusForbidden)
return
}
if value, found := app.Cache.Get(mediaURL); found {
if cachedEntry, ok := value.(CacheEntry); ok {
logger.Debug("Serving image from cache")
if cachedEntry, found := app.Cache.Get(mediaURL); found {
logger.Debug("Serving from cache")
w.Header().Set("Content-Type", cachedEntry.ContentType)
w.Write(cachedEntry.Data)
return
}
}
logger.Debug("Cache miss, performing HEAD request to origin")
headResp, err := app.Client.Head(mediaURL)
@@ -156,12 +162,13 @@ func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Could not fetch media metadata", http.StatusInternalServerError)
return
}
defer headResp.Body.Close()
if headResp.StatusCode != http.StatusOK {
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)
return
}
defer headResp.Body.Close()
headerContentType := headResp.Header.Get("Content-Type")
if !isAllowedType(headerContentType, true) {
@@ -174,19 +181,18 @@ func (app *App) handleProxy(w http.ResponseWriter, r *http.Request) {
switch mediaTypeCategory {
case "image":
logger.Debug("Delegating to image handler")
app.handleImage(w, r)
app.handleImage(w, r, mediaURL)
case "video", "audio":
logger.Debug("Delegating to stream handler")
app.handleStream(w, r)
app.handleStream(w, r, mediaURL)
default:
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)
}
}
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)
mediaURL := r.URL.Path[1:]
resp, err := app.Client.Get(mediaURL)
if err != nil {
@@ -196,11 +202,16 @@ func (app *App) handleImage(w http.ResponseWriter, r *http.Request) {
}
defer resp.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, app.Config.MaxAllowedSize)
mediaData, err := io.ReadAll(resp.Body)
limitedReader := &io.LimitedReader{R: resp.Body, N: app.Config.MaxAllowedSize}
mediaData, err := io.ReadAll(limitedReader)
if err != nil {
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
}
@@ -233,25 +244,32 @@ func (app *App) handleImage(w http.ResponseWriter, r *http.Request) {
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()
w.Header().Set("Content-Type", entryToCache.ContentType)
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)
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 {
logger.Error("Failed to create origin request", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
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)
if err != nil {
+44 -4
View File
@@ -7,22 +7,44 @@
- **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.
- **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.
## 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>`.
**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
The service is configured using environment variables:
| Variable | Description | Default |
| --------------------- | ------------------------------------------------------------ | ---------------- |
| :--- | :--- | :--- |
| `LOG_LEVEL` | The logging level (`DEBUG`, `INFO`, `WARN`, `ERROR`). | `INFO` |
| `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) |
| `CACHE_TTL` | The duration for which images are cached. | `10m` |
| `ALLOWED_DOMAINS` | Comma-separated list of domains to whitelist. | (empty, all allowed) |
| `ALLOWED_DOMAINS` | Comma-separated list of domains to whitelist. Ignored if `BASE_URL` is set. | (empty, all allowed) |
| `MAX_ALLOWED_SIZE` | Maximum media file size in bytes. | `52428800` (50MB)|
| `DEFAULT_IMAGE_QUALITY` | Quality for optimized WebP images (1-100). | `80` |
| `CLIENT_TIMEOUT` | Timeout for fetching media from the origin. | `2m` |
@@ -57,7 +79,9 @@ go run .
### Docker Compose
Create a `compose.yaml` file with the following content:
Create a `compose.yaml` file with your desired configuration.
**Standard Mode Example:**
```yaml
services:
@@ -73,6 +97,22 @@ services:
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
[GPL-3.0](https://github.com/skidoodle/mediaproxy/blob/main/license)