first commit
This commit is contained in:
commit
fbacedc6dd
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
./app
|
||||
.env
|
||||
docker-compose.yml
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.env
|
||||
docker-compose.yml
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache build-base vips-dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go build -o app
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache vips
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/app .
|
||||
|
||||
CMD ["./app"]
|
||||
39
config.go
Normal file
39
config.go
Normal file
@ -0,0 +1,39 @@
|
||||
// config.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RabbitURL string
|
||||
QueueName string
|
||||
MinioURL string
|
||||
MinioAccessKey string
|
||||
MinioSecretKey string
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
log.Println("No .env file found, using environment variables")
|
||||
}
|
||||
|
||||
return Config{
|
||||
RabbitURL: getEnv("RABBITMQ_URL", "amqp://localhost"),
|
||||
QueueName: getEnv("QUEUE_NAME", "storage.file"),
|
||||
MinioURL: getEnv("MINIO_ENDPOINT", "localhost:9000"),
|
||||
MinioAccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
|
||||
MinioSecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
23
go.mod
Normal file
23
go.mod
Normal file
@ -0,0 +1,23 @@
|
||||
module git.farahty.com/nimer/minio-worker-go
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/h2non/bimg v1.1.9 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/minio/crc64nvme v1.0.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.91 // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
35
go.sum
Normal file
35
go.sum
Normal file
@ -0,0 +1,35 @@
|
||||
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/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
|
||||
github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
|
||||
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.91 h1:tWLZnEfo3OZl5PoXQwcwTAPNNrjyWwOh6cbZitW5JQc=
|
||||
github.com/minio/minio-go/v7 v7.0.91/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
109
main.go
Normal file
109
main.go
Normal file
@ -0,0 +1,109 @@
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type S3Event struct {
|
||||
Key string `json:"Key"`
|
||||
Records []struct {
|
||||
EventName string `json:"eventName"`
|
||||
S3 struct {
|
||||
Bucket struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"bucket"`
|
||||
Object struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"object"`
|
||||
} `json:"s3"`
|
||||
} `json:"Records"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := LoadConfig()
|
||||
|
||||
rabbitConn, rabbitChannel, err := ConnectRabbitMQ(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("🚨 Failed to connect RabbitMQ: %v", err)
|
||||
}
|
||||
defer rabbitConn.Close()
|
||||
defer rabbitChannel.Close()
|
||||
|
||||
minioClient, err := ConnectMinIO(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("🚨 Failed to connect MinIO: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := rabbitChannel.Consume(
|
||||
cfg.QueueName,
|
||||
"",
|
||||
false, // manual ack
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("🚨 Failed to consume RabbitMQ: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-c
|
||||
log.Println("👋 Shutting down...")
|
||||
cancel()
|
||||
rabbitConn.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
for msg := range msgs {
|
||||
go func(msg amqp091.Delivery) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var event S3Event
|
||||
err := json.Unmarshal(msg.Body, &event)
|
||||
if err != nil || len(event.Records) == 0 {
|
||||
log.Printf("❌ Invalid message: %v", err)
|
||||
msg.Nack(false, false)
|
||||
return
|
||||
}
|
||||
|
||||
record := event.Records[0]
|
||||
if record.EventName != "s3:ObjectCreated:Put" {
|
||||
log.Printf("⏭️ Skipping event: %s", record.EventName)
|
||||
msg.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
bucket := record.S3.Bucket.Name
|
||||
key := strings.TrimPrefix(event.Key, bucket+"/")
|
||||
log.Printf("📥 Processing image: %s/%s", bucket, key)
|
||||
err = ProcessImage(ctx, minioClient, bucket, key)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error processing image: %v", err)
|
||||
log.Println("🔁 Retrying once")
|
||||
SleepWithCountdown(5)
|
||||
msg.Nack(false, true)
|
||||
return
|
||||
}
|
||||
|
||||
msg.Ack(false)
|
||||
}(msg)
|
||||
}
|
||||
}
|
||||
39
minio.go
Normal file
39
minio.go
Normal file
@ -0,0 +1,39 @@
|
||||
// minio.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
func ConnectMinIO(cfg Config) (*minio.Client, error) {
|
||||
minioClient, err := minio.New(cfg.MinioURL, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.MinioAccessKey, cfg.MinioSecretKey, ""),
|
||||
Secure: true, // change to true if using https
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return minioClient, nil
|
||||
}
|
||||
|
||||
func UploadToMinIO(ctx context.Context, client *minio.Client, bucket, path, mimeType string, data []byte, meta map[string]string) error {
|
||||
|
||||
key := strings.TrimPrefix(path, "/")
|
||||
key = strings.TrimPrefix(key, "./")
|
||||
|
||||
log.Printf("Uploading to MinIO: %s", key)
|
||||
_, err := client.PutObject(ctx, bucket, key,
|
||||
NewBytesReader(data),
|
||||
int64(len(data)),
|
||||
minio.PutObjectOptions{
|
||||
ContentType: mimeType,
|
||||
UserMetadata: meta,
|
||||
})
|
||||
return err
|
||||
}
|
||||
125
processor.go
Normal file
125
processor.go
Normal file
@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/h2non/bimg"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
func ProcessImage(ctx context.Context, client *minio.Client, bucket, key string) error {
|
||||
stat, err := client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
log.Printf("🚨 Failed to stat object: %s, error: %v", key, err)
|
||||
return err
|
||||
}
|
||||
|
||||
contentType := stat.ContentType
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
log.Printf("⏭️ Skipping non-image file: %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
if processed := stat.UserMetadata["X-Amz-Meta-Processed"]; processed == "true" {
|
||||
log.Printf("♻️ Already processed: %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
if processed := stat.UserMetadata["Processed"]; processed == "true" {
|
||||
log.Printf("♻️ Already processed: %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
object, err := client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
log.Printf("🚨 Failed to get object: %s, error: %v", key, err)
|
||||
return err
|
||||
}
|
||||
defer object.Close()
|
||||
|
||||
sourceBuffer := new(bytes.Buffer)
|
||||
_, err = sourceBuffer.ReadFrom(object)
|
||||
if err != nil {
|
||||
log.Printf("🚨 Failed to read object: %s, error: %v", key, err)
|
||||
return err
|
||||
}
|
||||
|
||||
img := bimg.NewImage(sourceBuffer.Bytes())
|
||||
|
||||
fileName := filepath.Base(key)
|
||||
filePath := path.Dir(key)
|
||||
|
||||
meta := map[string]string{
|
||||
"Processed": "true",
|
||||
}
|
||||
|
||||
// Generate optimized JPEG: just convert without resizing, keep quality high
|
||||
jpegBuf, err := img.Process(bimg.Options{
|
||||
Quality: 90,
|
||||
Type: bimg.JPEG,
|
||||
StripMetadata: true,
|
||||
NoAutoRotate: false,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate optimized jpeg: %w", err)
|
||||
}
|
||||
|
||||
// Generate optimized WebP: just convert without resizing, keep quality high
|
||||
webpBuf, err := img.Process(bimg.Options{
|
||||
Quality: 90,
|
||||
Type: bimg.WEBP,
|
||||
StripMetadata: true,
|
||||
NoAutoRotate: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate webp: %w", err)
|
||||
}
|
||||
|
||||
// Generate thumbnail: resize to 400px width
|
||||
thumbBuf, err := img.Process(bimg.Options{
|
||||
Width: 400,
|
||||
Quality: 85,
|
||||
Type: bimg.JPEG,
|
||||
StripMetadata: true,
|
||||
NoAutoRotate: false,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate thumbnail: %w", err)
|
||||
}
|
||||
|
||||
// Upload optimized JPEG
|
||||
err = UploadToMinIO(ctx, client, bucket, path.Join(filePath, "optimized", fileName), "image/jpeg", jpegBuf, meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload optimized jpeg: %w", err)
|
||||
}
|
||||
|
||||
// Upload WebP
|
||||
webpName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".webp"
|
||||
err = UploadToMinIO(ctx, client, bucket, path.Join(filePath, "webp", webpName), "image/webp", webpBuf, meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload webp image: %w", err)
|
||||
}
|
||||
|
||||
// Upload thumbnail
|
||||
err = UploadToMinIO(ctx, client, bucket, path.Join(filePath, "thumbs", fileName), "image/jpeg", thumbBuf, meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload thumbnail: %w", err)
|
||||
}
|
||||
|
||||
// Reupload original with processed=true metadata (unchanged)
|
||||
err = UploadToMinIO(ctx, client, bucket, key, contentType, sourceBuffer.Bytes(), meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reupload original image: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Image processed: %s", key)
|
||||
return nil
|
||||
}
|
||||
46
rabbitmq.go
Normal file
46
rabbitmq.go
Normal file
@ -0,0 +1,46 @@
|
||||
// rabbitmq.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
func ConnectRabbitMQ(cfg Config) (*amqp091.Connection, *amqp091.Channel, error) {
|
||||
conn, err := amqp091.Dial(cfg.RabbitURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = ch.Qos(1, 0, false) // Prefetch 1
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
_, err = ch.QueueDeclare(
|
||||
cfg.QueueName,
|
||||
true, // durable
|
||||
false, // delete when unused
|
||||
false, // exclusive
|
||||
false, // no-wait
|
||||
amqp091.Table{
|
||||
"x-dead-letter-exchange": "optimize.images.dlx",
|
||||
"x-dead-letter-routing-key": "file.uploaded.failed",
|
||||
"x-delivery-limit": int32(3),
|
||||
"x-queue-type": "quorum",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
log.Printf("🎧 Listening to queue: %s", cfg.QueueName)
|
||||
|
||||
return conn, ch, nil
|
||||
}
|
||||
20
utils.go
Normal file
20
utils.go
Normal file
@ -0,0 +1,20 @@
|
||||
// utils.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SleepWithCountdown(seconds int) {
|
||||
for i := seconds; i > 0; i-- {
|
||||
log.Printf("⏳ Retrying in %d seconds...", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func NewBytesReader(data []byte) io.ReadSeeker {
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user