diff --git a/Dockerfile b/Dockerfile index d951e16..824fd56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./ diff --git a/compose.dev.yaml b/compose.dev.yaml index 6e91312..86f9969 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -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' diff --git a/compose.yaml b/compose.yaml index 120ea08..9f3b103 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/env.go b/env.go index 49dad1e..9803809 100644 --- a/env.go +++ b/env.go @@ -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 == "" { diff --git a/go.mod b/go.mod index d1f38db..c3a521a 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 5e6d367..d72c9f2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index a06eae6..377979d 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/readme.md b/readme.md index 1b549eb..649bd40 100644 --- a/readme.md +++ b/readme.md @@ -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:///`. +**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:///` 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)