mirror of
https://github.com/skidoodle/mediaproxy
synced 2026-04-28 00: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
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
+3
-2
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,13 +113,19 @@ 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:]
|
||||
|
||||
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:]
|
||||
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,19 +142,17 @@ 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")
|
||||
w.Header().Set("Content-Type", cachedEntry.ContentType)
|
||||
w.Write(cachedEntry.Data)
|
||||
return
|
||||
}
|
||||
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")
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,25 +7,47 @@
|
||||
- **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` |
|
||||
| `CACHE_TTL` | The duration for which images are cached. | `10m` |
|
||||
| `ALLOWED_DOMAINS` | Comma-separated list of domains to whitelist. | (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` |
|
||||
| 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. 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` |
|
||||
|
||||
## Running Locally
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user