├── .gitignore ├── go.mod ├── Dockerfile ├── .github └── workflows │ ├── go.yml │ └── release.yml ├── README.md ├── go.sum └── gamo.go /.gitignore: -------------------------------------------------------------------------------- 1 | gamo 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gamo 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/Jeffail/tunny v0.1.4 7 | github.com/discord/lilliput v0.0.0-20220922234446-1ef1bb44bb7e 8 | github.com/google/uuid v1.3.0 9 | github.com/sirupsen/logrus v1.9.0 10 | ) 11 | 12 | require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ## Build 4 | FROM golang:1.19-buster AS build 5 | 6 | WORKDIR /app 7 | 8 | COPY go.mod ./ 9 | COPY go.sum ./ 10 | RUN go mod download 11 | 12 | COPY *.go ./ 13 | 14 | RUN go build -o /gamo 15 | 16 | ## Deploy 17 | FROM gcr.io/distroless/cc-debian11 18 | 19 | WORKDIR / 20 | 21 | COPY --from=build /gamo /gamo 22 | 23 | EXPOSE 8080 24 | 25 | USER nonroot:nonroot 26 | 27 | ENTRYPOINT [ "/gamo", "--bind=0.0.0.0:8080" ] 28 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "master" 7 | push: 8 | branches: 9 | - "master" 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version-file: "go.mod" 23 | cache: true 24 | 25 | - name: Build 26 | run: go build -v . 27 | 28 | - name: Test 29 | run: go test -v . 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gamo 2 | ==== 3 | 4 | An image proxy and optimization server. Like Camo, and compatible with Camo, but 5 | running proxied images through optimization, and with added functionality to resize 6 | them to specified dimensions using the URL path. 7 | 8 | It expects to run behind a reverse proxy like Nginx or Varnish which would perform 9 | caching. By itself, it makes no attempt to do so. 10 | 11 | URL structure: 12 | 13 | /[HMAC]/[Hex-encoded URL] 14 | 15 | Optionally: 16 | 17 | /[HMAC]/[Hex-encoded URL]/[Dimensions] 18 | 19 | The dimensions are to be given as a single integer as one side of a square. The 20 | image will be resized proportionally to fit within the total number of pixels. 21 | 22 | Arbitrary dimensions cannot be used. Pre-determined values are configured through 23 | the command-line invocation. 24 | 25 | Usage: 26 | 27 | gamo --key=SHARED_HMAC_SECRET --bind=127.0.0.1:8081 --dimensions=256,512,1024 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | 22 | - name: Log in to the Container registry 23 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 24 | with: 25 | registry: ${{ env.REGISTRY }} 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Extract metadata (tags, labels) for Docker 30 | id: meta 31 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 32 | with: 33 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 34 | 35 | - name: Build and push Docker image 36 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 37 | with: 38 | context: . 39 | push: true 40 | tags: ${{ steps.meta.outputs.tags }} 41 | labels: ${{ steps.meta.outputs.labels }} 42 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Jeffail/tunny v0.1.4 h1:chtpdz+nUtaYQeCKlNBg6GycFF/kGVHOr6A3cmzTJXs= 2 | github.com/Jeffail/tunny v0.1.4/go.mod h1:P8xAx4XQl0xsuhjX1DtfaMDCSuavzdb2rwbd0lk+fvo= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/discord/lilliput v0.0.0-20220922234446-1ef1bb44bb7e h1:waSNIW5ermIo264QXREgf+6qtKh0qpoGSOoMqxgPiJM= 7 | github.com/discord/lilliput v0.0.0-20220922234446-1ef1bb44bb7e/go.mod h1:0euuUBAD72MAYRm2ElLaG1h0nBR+CgpfnKc/U6y/uE8= 8 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 9 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 13 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 16 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 17 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= 18 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 20 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | -------------------------------------------------------------------------------- /gamo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "math" 11 | "net/http" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/Jeffail/tunny" 18 | "github.com/discord/lilliput" 19 | "github.com/google/uuid" 20 | "github.com/sirupsen/logrus" 21 | ) 22 | 23 | const ( 24 | DEFAULT_BIND = "127.0.0.1:8081" 25 | DEFAULT_KEY = "0x24FEEDFACEDEADBEEFCAFE" 26 | MAX_CONTENT_LENGTH = 5242880 27 | MAX_DIMENSIONS = 8912 28 | OUTPUT_BUFFER_SIZE = 10 * 1024 * 1024 29 | ) 30 | 31 | type imageOpsWorker struct { 32 | ops *lilliput.ImageOps 33 | } 34 | 35 | type imageOpsPayload struct { 36 | decoder lilliput.Decoder 37 | options *lilliput.ImageOptions 38 | } 39 | 40 | type imageOpsResult struct { 41 | result []byte 42 | err error 43 | } 44 | 45 | func (w *imageOpsWorker) Process(payload interface{}) interface{} { 46 | log.Debug("Allocating memory for transformation") 47 | 48 | input := payload.(*imageOpsPayload) 49 | output := make([]byte, OUTPUT_BUFFER_SIZE) 50 | 51 | output, err := w.ops.Transform(input.decoder, input.options, output) 52 | 53 | return &imageOpsResult{ 54 | result: output, 55 | err: err, 56 | } 57 | } 58 | 59 | func (w *imageOpsWorker) BlockUntilReady() { 60 | // 61 | } 62 | 63 | func (w *imageOpsWorker) Interrupt() { 64 | // 65 | } 66 | 67 | func (w *imageOpsWorker) Terminate() { 68 | log.Debug("Shutting down worker") 69 | w.ops.Close() 70 | } 71 | 72 | func newImageOpsWorker() *imageOpsWorker { 73 | log.Debug("Initializing worker") 74 | 75 | return &imageOpsWorker{ 76 | ops: lilliput.NewImageOps(MAX_DIMENSIONS), 77 | } 78 | } 79 | 80 | var ( 81 | configListenAddr string 82 | configSharedKey string 83 | configDimensions string 84 | ) 85 | 86 | var EncodeOptions = map[string]map[int]int{ 87 | ".jpeg": {lilliput.JpegQuality: 85}, 88 | ".png": {lilliput.PngCompression: 7}, 89 | ".webp": {lilliput.WebpQuality: 85}, 90 | } 91 | 92 | func nextRequestID() string { 93 | return uuid.New().String() 94 | } 95 | 96 | var log = logrus.New() 97 | 98 | func main() { 99 | log.Out = os.Stdout 100 | log.Level = logrus.DebugLevel 101 | 102 | flag.StringVar(&configListenAddr, "bind", DEFAULT_BIND, "Bind address") 103 | flag.StringVar(&configSharedKey, "key", DEFAULT_KEY, "Shared HMAC secret") 104 | flag.StringVar(&configDimensions, "dimensions", "", "Which target sizes besides the original one will be accessible (comma-separated)") 105 | flag.Parse() 106 | 107 | listenAddr := configListenAddr 108 | sharedKey := []byte(configSharedKey) 109 | dimensionsMap := map[int64]bool{} 110 | 111 | log.Info("Welcome to Gamo, the image proxy and optimization server") 112 | log.Info(fmt.Sprintf("Starting on %s...", listenAddr)) 113 | 114 | if len(configDimensions) > 0 { 115 | log.Info("With dimensions: ", configDimensions) 116 | 117 | for _, x := range strings.Split(configDimensions, ",") { 118 | parsedDimensions, err := strconv.ParseInt(x, 10, 0) 119 | 120 | if err != nil { 121 | log.Fatal("Unrecognized value in dimensions: ", x) 122 | } 123 | 124 | dimensionsMap[parsedDimensions] = true 125 | } 126 | } else { 127 | log.Info("Without resizing") 128 | } 129 | 130 | pool := tunny.New(2, func() tunny.Worker { 131 | return newImageOpsWorker() 132 | }) 133 | 134 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 135 | requestID := nextRequestID() 136 | requestLog := log.WithFields(logrus.Fields{"request-id": requestID}) 137 | 138 | w.Header().Set("X-Request-Id", requestID) 139 | 140 | segments := strings.Split(r.URL.Path, "/") 141 | dimensions := MAX_DIMENSIONS 142 | 143 | if len(segments) < 3 { 144 | http.Error(w, "Not found", http.StatusNotFound) 145 | return 146 | } 147 | 148 | encodedMAC, encodedImageURL := segments[1], segments[2] 149 | 150 | if len(segments) >= 4 { 151 | parsedDimensions, err := strconv.ParseInt(segments[3], 10, 0) 152 | 153 | if err != nil || !dimensionsMap[parsedDimensions] { 154 | http.Error(w, "Not found", http.StatusNotFound) 155 | return 156 | } 157 | 158 | dimensions = int(parsedDimensions) 159 | } 160 | 161 | imageURL, err := hex.DecodeString(encodedImageURL) 162 | 163 | if err != nil { 164 | http.Error(w, "Bad request", http.StatusBadRequest) 165 | return 166 | } 167 | 168 | messageMAC, err := hex.DecodeString(encodedMAC) 169 | 170 | if err != nil { 171 | http.Error(w, "Bad request", http.StatusBadRequest) 172 | return 173 | } 174 | 175 | mac := hmac.New(sha1.New, sharedKey) 176 | mac.Write(imageURL) 177 | expectedMAC := mac.Sum(nil) 178 | 179 | if hmac.Equal(messageMAC, expectedMAC) { 180 | requestLog = requestLog.WithFields(logrus.Fields{"url": string(imageURL)}) 181 | resp, err := http.Get(string(imageURL)) 182 | 183 | if err != nil { 184 | requestLog.Error(fmt.Sprintf("Error performing request: %s", err)) 185 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 186 | return 187 | } 188 | 189 | defer resp.Body.Close() 190 | 191 | contentLength, _ := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 0) 192 | 193 | if contentLength > MAX_CONTENT_LENGTH { 194 | requestLog.Error("Image exceeds length limit") 195 | http.Error(w, "Bad request", http.StatusBadRequest) 196 | return 197 | } 198 | 199 | originalImage, err := io.ReadAll(resp.Body) 200 | 201 | if err != nil { 202 | requestLog.Error(fmt.Sprintf("Error reading response body: %s", err)) 203 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 204 | return 205 | } 206 | 207 | decoder, err := lilliput.NewDecoder(originalImage) 208 | originalImage = nil 209 | 210 | if err != nil { 211 | requestLog.Error(fmt.Sprintf("Error decoding image: %s", err)) 212 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 213 | return 214 | } 215 | 216 | defer decoder.Close() 217 | 218 | header, err := decoder.Header() 219 | 220 | if err != nil { 221 | requestLog.Error(fmt.Sprintf("Error reading image header: %s", err)) 222 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 223 | return 224 | } 225 | 226 | outputFormat := "." + strings.ToLower(decoder.Description()) 227 | outputPixels := dimensions * dimensions 228 | 229 | var ( 230 | outputWidth int 231 | outputHeight int 232 | ) 233 | 234 | if (header.Width() * header.Height()) > outputPixels { 235 | outputWidth = int(math.Round(math.Sqrt(float64(outputPixels) * (float64(header.Width()) / float64(header.Height()))))) 236 | outputHeight = int(math.Round(math.Sqrt(float64(outputPixels) * (float64(header.Height()) / float64(header.Width()))))) 237 | } else { 238 | outputWidth = header.Width() 239 | outputHeight = header.Height() 240 | } 241 | 242 | resizeOptions := &lilliput.ImageOptions{ 243 | FileType: outputFormat, 244 | Width: outputWidth, 245 | Height: outputHeight, 246 | ResizeMethod: lilliput.ImageOpsResize, 247 | NormalizeOrientation: true, 248 | EncodeOptions: EncodeOptions[outputFormat], 249 | } 250 | 251 | result := pool.Process(&imageOpsPayload{ 252 | decoder: decoder, 253 | options: resizeOptions, 254 | }).(*imageOpsResult) 255 | 256 | if result.err != nil { 257 | requestLog.Error(fmt.Sprintf("Error transforming image: %s", err)) 258 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 259 | return 260 | } 261 | 262 | outputImage := result.result 263 | 264 | w.Header().Set("Content-Length", strconv.FormatInt(int64(len(outputImage)), 10)) 265 | w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) 266 | w.Header().Set("Cache-Control", "public, max-age=31536000") 267 | w.Header().Set("Expires", time.Now().Add(31536000*time.Second).In(time.UTC).Format("Mon, 02 Jan 2006 15:04:05 GMT")) 268 | w.Header().Set("Vary", "Accept-Encoding") 269 | w.Header().Set("Etag", fmt.Sprintf("%d-%x", len(outputImage), sha1.Sum(outputImage))) 270 | 271 | if r.Method != "HEAD" { 272 | _, err = w.Write(outputImage) 273 | 274 | if err != nil { 275 | requestLog.Error(fmt.Sprintf("Error writing response: %s", err)) 276 | } 277 | } 278 | } else { 279 | http.Error(w, "Unauthorized", http.StatusUnauthorized) 280 | } 281 | }) 282 | 283 | log.Fatal(http.ListenAndServe(listenAddr, nil)) 284 | } 285 | --------------------------------------------------------------------------------