diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..03b9b0b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,54 @@ +name: Build and Publish Binary + +on: + push: + tags: + - "v*.*.*" # Trigger on tag creation with versioning pattern + workflow_dispatch: # Allow manual runs from the GitHub Actions UI + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.18 # Use the Go version required by your project + + - name: Install Dependencies + run: go mod tidy # Ensures dependencies are properly installed + + - name: Build the binary + run: | + go build -o dist/image-optimizer # Build binary with the name image-optimizer + env: + GOOS: linux # You can specify other OS like windows or darwin (Mac) + GOARCH: amd64 # Architecture type + + - name: Upload binary as artifact + uses: actions/upload-artifact@v3 + with: + name: image-optimizer + path: dist/image-optimizer + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download built binary + uses: actions/download-artifact@v3 + with: + name: image-optimizer + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: dist/image-optimizer + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 4a2d1d5..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,28 +0,0 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - -# workflow name -name: Generate release-artifacts - -# on events -on: - release: - types: - - created - -# workflow tasks -jobs: - generate: - name: Generate cross-platform builds - runs-on: ubuntu-latest - steps: - - name: Checkout the repository - uses: actions/checkout@v2 - - name: Generate build files - uses: thatisuday/go-cross-build@v1 - with: - platforms: 'linux/amd64, linux/arm64, darwin/arm64, darwin/amd64, windows/amd64' - package: 'main' - name: 'image-compressor-api' - compress: 'true' - dest: 'dist' diff --git a/README.md b/README.md index a3c8180..405b090 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,150 @@ -# Image Compressor API +# Image Optimizer Service Documentation ## Overview +The Image Optimizer Service allows users to download and compress images from specified URLs. It supports a variety of image formats, including JPEG, PNG, and WebP, with additional features for resizing and quality adjustment. The service also caches compressed images to avoid redundant processing, improving performance. -Image Compressor API is a simple HTTP service written in Go that allows you to compress and resize images from a given URL. It supports popular image formats such as JPEG, PNG, and WebP. +### Key Features: +- Supports multiple image formats: JPEG, PNG, WebP. +- Allows custom resizing based on user-provided resolution. +- Adjustable JPEG quality. +- Caching system based on MD5 hashing to avoid duplicate processing. +- Domain whitelisting to control allowed image sources. -## Features +--- -- Image compression and resizing based on provided parameters. -- Automatic determination of the image format based on the URL's content type. -- Support for JPEG, PNG, and WebP output formats. -- Option to specify output quality and resolution. -- Efficient caching: If the compressed image already exists, it is served without re-compression. +## Installation -## Getting Started +### Prerequisites: +- **Go version**: 1.18 or later. +- Required Go libraries: + - `github.com/gorilla/mux` for routing. + - `github.com/chai2010/webp` for WebP image handling. + - `github.com/nfnt/resize` for resizing functionality. -### Prerequisites - -- Go (Golang) installed on your machine. -- [mux](https://github.com/gorilla/mux), [nfnt/resize](https://github.com/nfnt/resize), and [chai2010/webp](https://github.com/chai2010/webp) Go packages. - -### Installation - -1. Clone the repository: - - ```bash - git clone https://github.com/yourusername/your-repo.git - cd your-repo - ``` - -2. Install dependencies: +### Steps: +1. **Clone the repository:** ```bash - go get -u github.com/gorilla/mux - go get -u github.com/nfnt/resize - go get -u github.com/chai2010/webp + git clone ``` -3. Build and run the project: - +2. **Install dependencies:** ```bash - go run *.go -o ./tmp + go get github.com/gorilla/mux + go get github.com/chai2010/webp + go get github.com/nfnt/resize ``` - Alternatively, for a production build: - +3. **Build the application:** ```bash go build -o image-compressor - ./image-compressor -o ./tmp ``` -### Usage +--- -To compress an image, make a GET request to the `/compressor` endpoint with the following parameters: +## Usage -- `url`: URL of the image to be compressed. -- `output`: Desired output format (e.g., "jpeg", "png", "webp"). -- `quality`: Output quality (0-100, applicable for JPEG). -- `resolution`: Output resolution in the format "widthxheight" (e.g., "1024x720"). +### Command-Line Options: +- `-o` (default: `.`): Specifies the output directory where compressed images will be saved. +- `-p` (default: `8080`): Defines the port the service listens on. +- `-s` (default: `*`): Comma-separated list of allowed domains for downloading images. Use `*` to allow all domains. Example: - ```bash -curl "http://localhost:8080/compressor?url=https://example.com/image.jpg&output=webp&quality=80&resolution=1024x720" +./image-compressor -o ./compressed-images -p 8080 -s example.com,another.com ``` -## API Endpoints +### API Endpoints -### `/compressor` +#### 1. **GET /optimize** +Compresses an image from a provided URL, optionally resizing and adjusting the quality. If the compressed image already exists, the cached version is returned without recompression. -- **Method:** GET -- **Parameters:** - - `url` (required): URL of the image to be compressed. - - `output` (optional): Desired output format (e.g., "jpeg", "png", "webp"). - - `quality` (optional): Output quality (0-100, applicable for JPEG). - - `resolution` (optional): Output resolution in the format "widthxheight" (e.g., "1024x720"). +##### Query Parameters: +- **url** (required): The URL of the image to download and compress. +- **output** (optional): The desired output format (`jpeg`, `png`, `webp`). Defaults to the original format of the image. +- **quality** (optional): JPEG quality (1-100). Only applicable to JPEG images. Default is 75. +- **resolution** (optional): Desired image resolution in the format `widthxheight`. Use `auto` for either width or height to maintain aspect ratio. Example: `800x600`, `auto x 600`. +- **v** (optional): Versioning parameter to force new compression if image parameters change. -Example: +##### Example: +``` +GET /optimize?url=https://example.com/image.jpg&output=webp&quality=80&resolution=800x600&v=1 +``` -```bash -curl "http://localhost:8080/compressor?url=https://example.com/image.jpg&output=webp&quality=80&resolution=1024x720" +##### Response: +Returns the compressed image in the specified format or the original format if none is specified. + +--- + +#### 2. **GET /optimize/{filename}** +Fetches a previously compressed image by its filename (typically the MD5 hash of the image parameters). + +##### Example: +``` +GET /optimize/abcd1234.jpeg ``` -## License +##### Response: +Returns the requested image file from the output directory. + +--- + +#### 3. **GET /** +A basic health check endpoint to confirm that the server is running. Returns `"ok!"`. + +##### Example: +``` +GET / +``` +##### Response: +``` +ok! +``` + +--- + +## How It Works + +### 1. **Image Download** +The service downloads the image from the specified URL and decodes it into an `image.Image` object. Supported formats include JPEG, PNG, and WebP. + +### 2. **Image Compression** +The downloaded image can be resized based on user-specified resolution parameters. For JPEGs, quality adjustment is supported. After processing, the image is saved in the desired format. + +### 3. **MD5 Hashing for Caching** +To avoid redundant processing, the service generates an MD5 hash based on the image URL, desired output format, quality, resolution, and versioning parameters. This hash is used to create a unique filename for the compressed image. If the file already exists, the cached version is returned. + +### 4. **Caching and Performance** +If the compressed image already exists in the output directory, the service retrieves and returns it directly, bypassing the need to download and process the image again. This caching mechanism significantly improves performance and reduces redundant computation. + +--- + +## Error Handling + +- **403 Forbidden:** Returned if the domain of the image URL is not on the allowed list (`-s` parameter). +- **500 Internal Server Error:** Occurs if an unsupported image format is provided or an error occurs during the image processing stage. + +--- + +## Example Workflow + +1. **Download and compress an image:** + ```bash + curl "http://localhost:8080/optimize?url=https://example.com/image.jpg&output=png&resolution=800x600&quality=90&v=1" + ``` + +2. **Retrieve an existing compressed image:** + ```bash + curl "http://localhost:8080/optimize/.png" + ``` + +--- + +## License This project is licensed under the [MIT License](LICENSE). + +--- + +### Contributions +Feel free to contribute to this project by submitting issues or pull requests on the official repository. \ No newline at end of file diff --git a/image-compressor b/image-compressor deleted file mode 100755 index b994dcf..0000000 Binary files a/image-compressor and /dev/null differ diff --git a/main.go b/main.go index 18da83a..f84488b 100644 --- a/main.go +++ b/main.go @@ -9,41 +9,79 @@ import ( "image/jpeg" "image/png" "io" + "log" "net/http" "os" "path/filepath" + "strconv" "strings" + "sync" + + "net/url" "github.com/chai2010/webp" "github.com/gorilla/mux" "github.com/nfnt/resize" ) -var outputDirectory string +var ( + outputDirectory string + port int + allowedDomains string + contentTypeMap = map[string]string{ + "jpeg": "image/jpeg", + "png": "image/png", + "webp": "image/webp", + } + initOnce sync.Once +) + +func initConfig() { + initOnce.Do(func() { + flag.StringVar(&outputDirectory, "o", ".", "Output directory for compressed images") + flag.IntVar(&port, "p", 8080, "Port for the server to listen on") + flag.StringVar(&allowedDomains, "s", "*", "Allowed domains separated by comma (,)") + flag.Parse() + }) +} + +func isDomainAllowed(urlString string) bool { + if allowedDomains == "*" { + return true + } + + allowedDomainList := strings.Split(allowedDomains, ",") + parsedURL, err := url.Parse(urlString) + if err != nil { + return false + } + + for _, domain := range allowedDomainList { + if strings.HasSuffix(parsedURL.Hostname(), domain) { + return true + } + } -func init() { - flag.StringVar(&outputDirectory, "o", ".", "Output directory for compressed images") - flag.Parse() + return false } -func downloadImage(url string) (image.Image, string, error) { - resp, err := http.Get(url) +func downloadImage(urlString string) (image.Image, string, error) { + resp, err := http.Get(urlString) if err != nil { return nil, "", err } defer resp.Body.Close() + contentType := resp.Header.Get("Content-Type") var img image.Image var format string - // Determine the image format based on content type - contentType := resp.Header.Get("Content-Type") switch { case strings.Contains(contentType, "jpeg"): - img, _, err = image.Decode(resp.Body) + img, format, err = image.Decode(resp.Body) format = "jpeg" case strings.Contains(contentType, "png"): - img, _, err = image.Decode(resp.Body) + img, format, err = image.Decode(resp.Body) format = "png" case strings.Contains(contentType, "webp"): img, err = webp.Decode(resp.Body) @@ -60,40 +98,30 @@ func downloadImage(url string) (image.Image, string, error) { } func compressImage(img image.Image, format, output string, quality int, resolution string) error { - // Resize the image if resolution is provided if resolution != "" { size := strings.Split(resolution, "x") width, height := parseResolution(size[0], size[1], img.Bounds().Dx(), img.Bounds().Dy()) img = resize.Resize(width, height, img, resize.Lanczos3) } - // Create the output file in the specified directory out, err := os.Create(filepath.Join(outputDirectory, output)) if err != nil { return err } defer out.Close() - // Compress and save the image in the specified format switch format { case "jpeg": - options := jpeg.Options{Quality: quality} - err = jpeg.Encode(out, img, &options) + err = jpeg.Encode(out, img, &jpeg.Options{Quality: quality}) case "png": - encoder := png.Encoder{CompressionLevel: png.BestCompression} - err = encoder.Encode(out, img) + err = (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(out, img) case "webp": - options := &webp.Options{Lossless: true} - err = webp.Encode(out, img, options) + err = webp.Encode(out, img, &webp.Options{Lossless: true}) default: return fmt.Errorf("unsupported output format") } - if err != nil { - return err - } - - return nil + return err } func generateMD5Hash(input string) string { @@ -102,96 +130,44 @@ func generateMD5Hash(input string) string { return hex.EncodeToString(hasher.Sum(nil)) } -func atoi(s string) int { - result := 0 - for _, c := range s { - result = result*10 + int(c-'0') - } - return result -} - func parseResolution(width, height string, originalWidth, originalHeight int) (uint, uint) { - var newWidth, newHeight uint - if width == "auto" && height == "auto" { - // If both dimensions are "auto," maintain the original size - newWidth = uint(originalWidth) - newHeight = uint(originalHeight) + return uint(originalWidth), uint(originalHeight) } else if width == "auto" { - // If width is "auto," calculate height maintaining the aspect ratio - ratio := float64(originalWidth) / float64(originalHeight) - newHeight = uint(atoi(height)) - newWidth = uint(float64(newHeight) * ratio) + newHeight, _ := strconv.Atoi(height) + return uint(float64(newHeight) * float64(originalWidth) / float64(originalHeight)), uint(newHeight) } else if height == "auto" { - // If height is "auto," calculate width maintaining the aspect ratio - ratio := float64(originalHeight) / float64(originalWidth) - newWidth = uint(atoi(width)) - newHeight = uint(float64(newWidth) * ratio) - } else { - // Use the provided width and height - newWidth = uint(atoi(width)) - newHeight = uint(atoi(height)) + newWidth, _ := strconv.Atoi(width) + return uint(newWidth), uint(float64(newWidth) * float64(originalHeight) / float64(originalWidth)) } - - return newWidth, newHeight + newWidth, _ := strconv.Atoi(width) + newHeight, _ := strconv.Atoi(height) + return uint(newWidth), uint(newHeight) } func compressHandler(w http.ResponseWriter, r *http.Request) { - url := r.URL.Query().Get("url") + urlString := r.URL.Query().Get("url") format := r.URL.Query().Get("output") - quality := r.URL.Query().Get("quality") + qualityStr := r.URL.Query().Get("quality") resolution := r.URL.Query().Get("resolution") + version := r.URL.Query().Get("v") - // Concatenate parameters into a single string - paramsString := fmt.Sprintf("%s-%s-%s-%s", url, format, quality, resolution) + if !isDomainAllowed(urlString) { + http.Error(w, "URL domain not allowed", http.StatusForbidden) + return + } - // Generate MD5 hash from the concatenated parameters + paramsString := fmt.Sprintf("%s-%s-%s-%s-%s", urlString, format, qualityStr, resolution, version) hash := generateMD5Hash(paramsString) - - // Generate the output filename using the hash and format output := fmt.Sprintf("%s.%s", hash, format) - // Check if the compressed file already exists in the output directory filePath := filepath.Join(outputDirectory, output) if _, err := os.Stat(filePath); err == nil { - // File exists, no need to download and compress again - - // Open and send the existing compressed image file - compressedFile, err := os.Open(filePath) - if err != nil { - http.Error(w, fmt.Sprintf("Error opening compressed image file: %s", err), http.StatusInternalServerError) - return - } - defer compressedFile.Close() - - // Set the appropriate Content-Type based on the output format - var contentType string - switch format { - case "jpeg": - contentType = "image/jpeg" - case "png": - contentType = "image/png" - case "webp": - contentType = "image/webp" - default: - http.Error(w, "Unsupported output format", http.StatusInternalServerError) - return - } - - // Set the Content-Type header - w.Header().Set("Content-Type", contentType) - - // Copy the existing compressed image file to the response writer - _, err = io.Copy(w, compressedFile) - if err != nil { - http.Error(w, fmt.Sprintf("Error sending compressed image: %s", err), http.StatusInternalServerError) - return - } - + sendExistingFile(w, filePath, format) return } - img, imgFormat, err := downloadImage(url) + img, imgFormat, err := downloadImage(urlString) if err != nil { http.Error(w, fmt.Sprintf("Error downloading image: %s", err), http.StatusInternalServerError) return @@ -201,30 +177,17 @@ func compressHandler(w http.ResponseWriter, r *http.Request) { format = imgFormat } - err = compressImage(img, format, output, atoi(quality), resolution) + quality, _ := strconv.Atoi(qualityStr) + err = compressImage(img, format, output, quality, resolution) if err != nil { http.Error(w, fmt.Sprintf("Error compressing image: %s", err), http.StatusInternalServerError) return } - // Set the appropriate Content-Type based on the output format - var contentType string - switch format { - case "jpeg": - contentType = "image/jpeg" - case "png": - contentType = "image/png" - case "webp": - contentType = "image/webp" - default: - http.Error(w, "Unsupported output format", http.StatusInternalServerError) - return - } - - // Set the Content-Type header - w.Header().Set("Content-Type", contentType) + sendExistingFile(w, filePath, format) +} - // Open and send the compressed image file +func sendExistingFile(w http.ResponseWriter, filePath, format string) { compressedFile, err := os.Open(filePath) if err != nil { http.Error(w, fmt.Sprintf("Error opening compressed image file: %s", err), http.StatusInternalServerError) @@ -232,24 +195,37 @@ func compressHandler(w http.ResponseWriter, r *http.Request) { } defer compressedFile.Close() - // Copy the compressed image file to the response writer - _, err = io.Copy(w, compressedFile) - if err != nil { - http.Error(w, fmt.Sprintf("Error sending compressed image: %s", err), http.StatusInternalServerError) - return - } - fmt.Fprintf(w, "Image compressed and saved to %s\n", filePath) + w.Header().Set("Content-Type", contentTypeMap[format]) + io.Copy(w, compressedFile) } +func printBanner() { + banner := ` +------------------------------------ +Image Optimizer Service + +Author: https://github.com/daniwebdev` + fmt.Println(banner) + fmt.Printf("Server is running on port: %d\n", port) + fmt.Printf("Allowed Domains: %s\n", allowedDomains) + fmt.Printf("Output Directory: %s\n", outputDirectory) + fmt.Println("------------------------------------") +} func main() { + initConfig() + r := mux.NewRouter() - r.HandleFunc("/compressor", compressHandler).Methods("GET") - r.HandleFunc("/compressor/{filename}", compressHandler).Methods("GET") + r.HandleFunc("/optimize", compressHandler).Methods("GET") + r.HandleFunc("/optimize/{filename}", compressHandler).Methods("GET") + r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "ok!") + }) + + log.Printf("Server is listening on :%d. Output directory: %s\n", port, outputDirectory) - http.Handle("/", r) + printBanner() - fmt.Printf("Server is listening on :8080. Output directory: %s\n", outputDirectory) - http.ListenAndServe(":8080", nil) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), r)) }