diff --git a/http/handler.go b/http/handler.go index 143eded..31fb551 100644 --- a/http/handler.go +++ b/http/handler.go @@ -1,110 +1,25 @@ package http import ( - "context" "github.com/labstack/echo/v4" - client "github.com/siteworxpro/top-wallpaper/reddit" - "github.com/siteworxpro/top-wallpaper/resize" - "io" + "github.com/siteworxpro/top-wallpaper/redis" "net/http" - "os" - "strconv" - "time" ) func Get(c echo.Context) error { - cc := c.(*CustomContext) - var size int - var err error - - if sizeS, ok := os.LookupEnv("MAX_SIZE"); ok { - size, err = strconv.Atoi(sizeS) - if err != nil { - size = 1200 - } - } else { - size = 1200 - } - - if size < 100 { - size = 1200 - } - - var latestImageVal string - if cc.redis != nil { - latestImage := cc.redis.Get(context.TODO(), "latestImage") - latestImageVal, err = latestImage.Result() - } - - if err != nil || latestImageVal == "" { - c.Logger().Info("Fetching latest image") - latestImageVal, err = client.GetLatestImage(func(url string) (*http.Response, error) { - return http.Get(url) - }) - if err != nil { - return c.String(http.StatusInternalServerError, "Error fetching latest image") - } - - if cc.redis != nil { - cmd := cc.redis.Set(context.TODO(), "latestImage", latestImageVal, 600*time.Second) - if cmd.Err() != nil { - c.Logger().Warn("could not cache image") - } - } - } else { - c.Logger().Info("Image name fetched from cache") - } - - var imageData string - if cc.redis != nil { - latestImageBin := cc.redis.Get(context.TODO(), "latestImage:bin:"+latestImageVal) - imageData = latestImageBin.Val() - } - - if imageData != "" { - c.Logger().Info("Image data fetched from cache") - - return c.Blob(http.StatusOK, "image/jpeg", []byte(imageData)) - } - - response, err := http.Get(latestImageVal) + rc, err := redis.NewRedis() if err != nil { - return c.String(http.StatusInternalServerError, "Error fetching image") + return c.String(http.StatusInternalServerError, "Internal Server Error: "+err.Error()) } - if response.StatusCode != http.StatusOK { - return c.String(http.StatusInternalServerError, "Error fetching image") + val, err := rc.Get(redis.CacheKey) + + if err != nil || val == "" { + return c.NoContent(http.StatusNoContent) } - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(response.Body) - - imageDataBytes, err := io.ReadAll(response.Body) - if err != nil { - return c.String(http.StatusInternalServerError, "Error fetching image") - } - - imageData = string(imageDataBytes) - resized, err := resize.Shrink(imageData, uint(size), 70) - - if err != nil { - return c.String(http.StatusInternalServerError, "Error resizing image") - } - - go func(data string) { - if cc.redis == nil { - return - } - - _, err = cc.redis.Set(context.TODO(), "latestImage:bin:"+latestImageVal, data, 600*time.Second).Result() - if err != nil { - c.Logger().Warn("could not cache image") - } - }(resized) - c.Response().Header().Set("Cache-Control", "public, max-age=600") - return c.Blob(http.StatusOK, "image/jpeg", []byte(resized)) + return c.Blob(http.StatusOK, "image/jpeg", []byte(val)) } diff --git a/http/middleware.go b/http/middleware.go deleted file mode 100644 index 8b87abd..0000000 --- a/http/middleware.go +++ /dev/null @@ -1,59 +0,0 @@ -package http - -import ( - "context" - "fmt" - "github.com/labstack/echo/v4" - "github.com/redis/go-redis/v9" - "os" - "strconv" -) - -type CustomContext struct { - echo.Context - redis *redis.Client -} - -func GetCustomContext(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - redisUrl := os.Getenv("REDIS_URL") - if redisUrl == "" { - c.Logger().Warn("REDIS_URL not set, skipping redis connection. Use REDIS_URL to cache the image") - - return next(&CustomContext{c, nil}) - } - - redisPort := os.Getenv("REDIS_PORT") - if redisPort == "" { - redisPort = "6379" - } - - redisDb := os.Getenv("REDIS_DB") - if redisDb == "" { - redisDb = "0" - } - - redisDbInt, err := strconv.ParseInt(redisDb, 10, 64) - if err != nil { - c.Logger().Warn("REDIS_DB is not a valid integer, skipping redis connection. Use REDIS_DB to cache the image") - - return next(&CustomContext{}) - } - - cc := &CustomContext{c, redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%s", redisUrl, redisPort), - Password: os.Getenv("REDIS_PASSWORD"), - DB: int(redisDbInt), - })} - - cmd := cc.redis.Ping(context.Background()) - - if cmd.Err() != nil { - c.Logger().Warn("could not connect to redis") - - cc.redis = nil - } - - return next(cc) - } -} diff --git a/main.go b/main.go index e7f37e4..54a9ca6 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,13 @@ package main import ( + "context" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/labstack/gommon/log" tphttp "github.com/siteworxpro/top-wallpaper/http" + "github.com/siteworxpro/top-wallpaper/reddit" + "github.com/siteworxpro/top-wallpaper/redis" "net/http" "os" "strings" @@ -12,12 +15,23 @@ import ( func main() { - e := echo.New() - e.Logger.SetLevel(log.INFO) + ctx, fn := context.WithCancel(context.Background()) + defer fn() + rc, err := redis.NewRedis() + + if err != nil { + log.Error("Could not initialize Redis client: ", err) + os.Exit(1) + } + + e := echo.New() + e.AcquireContext().Set("redisClient", rc) + e.Logger.SetOutput(os.Stdout) + + e.Logger.SetLevel(log.INFO) e.HideBanner = true - e.Use(tphttp.GetCustomContext) e.Use(middleware.Logger()) e.Use(middleware.RequestID()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ @@ -34,12 +48,16 @@ func main() { e.GET(path, tphttp.Get) e.Logger.Info("Starting server at path " + path) - // start the server port := os.Getenv("PORT") if port == "" { port = "8080" } + ctx = redis.WithContext(ctx, rc) + ctx = context.WithValue(ctx, "logger", e.Logger) + + go reddit.Fetch(ctx) + e.Logger.Fatal(e.Start(":" + port)) } diff --git a/reddit/fetcher.go b/reddit/fetcher.go new file mode 100644 index 0000000..c3dee54 --- /dev/null +++ b/reddit/fetcher.go @@ -0,0 +1,132 @@ +package reddit + +import ( + "context" + "github.com/labstack/echo/v4" + "github.com/siteworxpro/top-wallpaper/redis" + "github.com/siteworxpro/top-wallpaper/resize" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +func Fetch(ctx context.Context) { + const defaultImageSize = 1600 + const minImageSize = 100 + + rc, err := redis.FromContext(ctx) + if err != nil { + panic("Redis client not found in context: " + err.Error()) + } + + l := ctx.Value("logger").(echo.Logger) + + var size int + if sizeS, ok := os.LookupEnv("MAX_SIZE"); ok { + size, err = strconv.Atoi(sizeS) + if err != nil { + size = defaultImageSize + } + } else { + size = defaultImageSize + } + + if size < minImageSize { + size = defaultImageSize + } + + for { + select { + case <-ctx.Done(): + + l.Info("Stopping Reddit fetcher...") + _ = rc.Close() + + return + default: + + val, err := rc.Get(redis.CacheKey) + if val != "" { + l.Info("Reddit image fetched from cache...") + + break + } + + l.Info("Fetching latest image from Reddit...") + + latestImageVal, err := GetLatestImage(func(u string) (*http.Response, error) { + + request := &http.Request{ + Method: http.MethodGet, + Header: http.Header{ + "User-Agent": []string{"Mozilla/5.0 (compatible; RedditBot/1.0; +https://www.reddit.com/wiki/redditauth)"}, + }, + URL: &url.URL{ + Scheme: "https", + Host: "www.reddit.com", + Path: strings.TrimPrefix(u, "https://www.reddit.com"), + }, + } + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + response, err := httpClient.Do(request) + + if err != nil { + l.Error("Error fetching image from Reddit: ", err) + return nil, err + } + + if response.StatusCode != http.StatusOK { + l.Error("Error fetching image from Reddit: ", response.Status) + return nil, &echo.HTTPError{ + Code: response.StatusCode, + Message: "Error fetching image from Reddit", + } + } + + return response, nil + }) + + response, err := http.Get(latestImageVal) + if err != nil { + l.Error("Error fetching image from Reddit: ", err) + + break + } + + if response.StatusCode != http.StatusOK { + l.Error("Error fetching image from Reddit: ", response.Status) + + break + } + + imageDataBytes, err := io.ReadAll(response.Body) + if err != nil { + l.Error("Error reading image data: ", err) + _ = response.Body.Close() + break + } + + _ = response.Body.Close() + + imageData := string(imageDataBytes) + resized, err := resize.Shrink(imageData, uint(size), 70) + + err = rc.Set(redis.CacheKey, resized, 10*time.Minute) + if err != nil { + l.Warn("could not cache image") + } + + l.Info("Reddit image fetched and resized successfully, cached for 10 minutes.") + } + + time.Sleep(10 * time.Minute) + } +} diff --git a/redis/client.go b/redis/client.go new file mode 100644 index 0000000..7b3efe1 --- /dev/null +++ b/redis/client.go @@ -0,0 +1,118 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "github.com/redis/go-redis/v9" + "os" + "strconv" + "time" +) + +type contextKey string + +const redisKey contextKey = "redisClient" + +const CacheKey = "top-wallpaper:latestImage" + +type Cache interface { + Get(key string) (string, error) + Set(key string, value string, expiration int64) error + Close() error +} + +type Redis struct { + client *redis.Client +} + +func NewRedis() (*Redis, error) { + + redisUrl := os.Getenv("REDIS_URL") + if redisUrl == "" { + return nil, errors.New("REDIS_URL not set, cannot initialize Redis client") + } + + redisPort := os.Getenv("REDIS_PORT") + if redisPort == "" { + redisPort = "6379" + } + + redisDb := os.Getenv("REDIS_DB") + if redisDb == "" { + redisDb = "0" + } + + redisDbInt, err := strconv.ParseInt(redisDb, 10, 64) + if err != nil { + return nil, errors.New("REDIS_DB is not a valid integer, cannot initialize Redis client") + } + + rc := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", redisUrl, redisPort), + Password: os.Getenv("REDIS_PASSWORD"), + DB: int(redisDbInt), + }) + + cmd := rc.Ping(context.Background()) + + if cmd.Err() != nil { + return nil, fmt.Errorf("could not connect to Redis: %v", cmd.Err()) + } + if cmd.Val() != "PONG" { + return nil, fmt.Errorf("unexpected response from Redis: %s", cmd.Val()) + } + + return &Redis{ + client: rc, + }, nil +} + +func (r *Redis) Get(key string) (string, error) { + cmd := r.client.Get(context.Background(), key) + if cmd.Err() != nil { + if errors.Is(cmd.Err(), redis.Nil) { + return "", nil // Key does not exist + } + return "", fmt.Errorf("could not get value for key %s: %v", key, cmd.Err()) + } + return cmd.Val(), nil +} + +func (r *Redis) Set(key string, value string, expiration time.Duration) error { + cmd := r.client.Set(context.Background(), key, value, expiration) + if cmd.Err() != nil { + return fmt.Errorf("could not set value for key %s: %v", key, cmd.Err()) + } + if cmd.Val() != "OK" { + return fmt.Errorf("unexpected response from Redis when setting key %s: %s", key, cmd.Val()) + } + + return nil +} + +func (r *Redis) Close() error { + if r.client == nil { + return nil + } + err := r.client.Close() + if err != nil { + return fmt.Errorf("could not close Redis client: %v", err) + } + return nil +} + +func WithContext(ctx context.Context, r *Redis) context.Context { + return context.WithValue(ctx, redisKey, r) +} + +func FromContext(ctx context.Context) (*Redis, error) { + r, ok := ctx.Value(redisKey).(*Redis) + if !ok { + return nil, errors.New("no Redis client found in context") + } + if r == nil { + return nil, errors.New("redis client is nil") + } + return r, nil +}