commit aa7fd1cdb7fadfae27ad08197bcfa61784396e08 Author: Ron Rise Date: Sat Feb 15 21:33:54 2025 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0942d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ + +top-wallpaper \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..306eeb8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM siteworxpro/golang:1.23.5 AS build + +WORKDIR /app + +ADD . . + +ENV GOPRIVATE=git.s.int +ENV GOPROXY=direct +ENV CGO_ENABLED=0 + +RUN go mod tidy && go build -o top-wallpaper . + +FROM alpine:latest AS runtime + +EXPOSE 8080 + +WORKDIR /app + +COPY --from=build /app/top-wallpaper /app/top-wallpaper + +ENTRYPOINT ["/app/top-wallpaper"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..68fcf4a --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Top Wallpaper Image + +A simple app that fetches the top wallpaper image from [/r/wallpaper](https://reddit.com/r/wallpaper) and serves it as a wallpaper. + +## Installation + +```bash +go mod tidy +go build +``` + +## Usage + +```bash +./top-wallpaper +``` + +[http://localhost:8080](http://localhost:8080) will serve the top wallpaper image. + +Available environment variables: +``` +REDIS_URL: Redis URL (default: localhost) +REDIS_PORT: Redis port (default: 6379) +REDIS_PASSWORD: Redis password (default: "") +REDIS_DB: Redis database (default: 0) +ALLOWED_ORIGINS: Allowed origins for CORS (default: "") +PATH: Path to serve the image (default: /) +PORT: Port to serve the image (default: 8080) +``` + +## docker +```shell +docker run -d -p 8080:8080 --name top-wallpaper -e REDIS_URL=redis -e REDIS_PORT=6379 -e REDIS_PASSWORD=pass -e REDIS_DB=0 -e ALLOWED_ORIGINS=http://localhost:8080 -e PATH=/wallpaper -e PORT=8080 --network=host --restart=always siteworxpro/top-wallpaper:latest +``` + +## License + +[MIT](https://choosealicense.com/licenses/mit/) +``` +MIT License + +Copyright (c) 2025 Siteworx Professionals, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5752a1f --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/siteworxpro/top-wallpaper + +go 1.23.5 + +require ( + github.com/labstack/echo/v4 v4.13.3 + github.com/redis/go-redis/v9 v9.7.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5e0ac1 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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 new file mode 100644 index 0000000..5f14457 --- /dev/null +++ b/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/labstack/gommon/log" + "github.com/redis/go-redis/v9" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +type redditResponse struct { + Data struct { + Children []struct { + Data struct { + Kind string `json:"kind"` + Title string `json:"title"` + URL string `json:"url"` + Data struct { + } `json:"data"` + } + } `json:"children"` + } `json:"data"` +} + +type CustomContext struct { + echo.Context + redis *redis.Client +} + +func main() { + + e := echo.New() + e.Logger.SetLevel(log.INFO) + + e.HideBanner = true + + // Middleware + e.Use(func(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{}) + } + + 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) + } + }) + + e.Use(middleware.Logger()) + e.Use(middleware.RequestID()) + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: strings.Split(os.Getenv("ALLOWED_ORIGINS"), ","), + AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept}, + AllowMethods: []string{http.MethodGet, http.MethodOptions}, + })) + + path := os.Getenv("PATH") + if path == "" { + path = "/" + } + e.GET(path, func(c echo.Context) error { + cc := c.(*CustomContext) + latestImage := cc.redis.Get(context.TODO(), "latestImage") + latestImageVal, err := latestImage.Result() + + if err != nil || latestImageVal == "" { + c.Logger().Info("Fetching latest image") + latestImageVal = getLatestImage() + 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") + } + + latestImageBin := cc.redis.Get(context.TODO(), "latestImage:bin:"+latestImageVal) + imageData := latestImageBin.Val() + if imageData == "" { + response, err := http.Get(latestImageVal) + if err != nil { + return c.String(http.StatusInternalServerError, "Error fetching image") + } + + 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) + + go func(data string) { + _, err = cc.redis.Set(context.TODO(), "latestImage:bin:"+latestImageVal, data, 600*time.Second).Result() + if err != nil { + c.Logger().Warn("could not cache image") + } + }(imageData) + } else { + c.Logger().Info("Image data fetched from cache") + } + + c.Response().Header().Set("Cache-Control", "public, max-age=600") + + return c.Blob(http.StatusOK, "image/jpeg", []byte(imageData)) + }) + + e.Logger.Info("Starting server at path " + path) + // start the server + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + e.Logger.Fatal(e.Start(":" + port)) +} + +func getLatestImage() string { + response, err := http.Get("https://www.reddit.com/r/wallpaper/.json") + if err != nil { + panic(err) + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(response.Body) + + jsonBytes, err := io.ReadAll(response.Body) + + redditData := redditResponse{} + err = json.Unmarshal(jsonBytes, &redditData) + if err != nil { + panic(err) + } + + url := redditData.Data.Children[1].Data.URL + + return url +}