12 Commits

Author SHA1 Message Date
8d0f11e681 [no message] 2025-04-24 20:52:11 -04:00
c86eb6520d LOL! 2025-04-22 13:56:35 -04:00
a05558351f hmmm 2025-04-21 23:05:42 -04:00
4ff0c534c6 hoo boy 2025-04-21 23:04:34 -04:00
92d4be2f23 ALL SORTS OF THINGS 2025-04-21 23:04:24 -04:00
b79b34e8cc Merge branch 'code-reorg' into 'master'
Push poorly written test can down the road another ten years

See merge request rrise/top-wallpaper!2
2025-04-21 22:43:34 -04:00
c00032c7c4 Push poorly written test can down the road another ten years 2025-04-21 22:42:38 -04:00
9585e300fb Merge branch 'rrise-master-patch-31953' into 'master'
Add new file

See merge request rrise/top-wallpaper!1
2025-04-21 22:31:47 -04:00
280af327ed Add new file 2025-04-21 22:31:04 -04:00
c49e6d6c54 betterer code 2025-04-21 21:57:54 -04:00
12460a4519 someday I gonna kill someone for this shit... 2025-04-21 21:50:58 -04:00
6df72a9ced more fixes 2025-04-21 21:46:07 -04:00
11 changed files with 451 additions and 198 deletions

18
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,18 @@
stages:
- Testing
- build
include:
- component: $CI_SERVER_FQDN/shared/blueprints/golang-tests@v1.2.0
rules:
- if: '$CI_COMMIT_BRANCH'
- component: $CI_SERVER_FQDN/shared/blueprints/docker-build@v1.2.0
rules:
- if: '$CI_COMMIT_TAG'
inputs:
platform: "linux/amd64"
dockerHubUser: ${DOCKER_HUB_USER}
dockerHubPat: ${DOCKER_HUB_PAT}
stage: build
repo: siteworxpro/top-wallpaper
tag: "${CI_COMMIT_TAG}"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
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.

3
go.mod
View File

@@ -4,13 +4,14 @@ go 1.24.0
require (
github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
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

2
go.sum
View File

@@ -17,6 +17,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
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=

110
http/handler.go Normal file
View File

@@ -0,0 +1,110 @@
package http
import (
"context"
"github.com/labstack/echo/v4"
client "github.com/siteworxpro/top-wallpaper/reddit"
"github.com/siteworxpro/top-wallpaper/resize"
"io"
"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)
if err != nil {
return c.String(http.StatusInternalServerError, "Error fetching image")
}
if response.StatusCode != http.StatusOK {
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)
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))
}

59
http/middleware.go Normal file
View File

@@ -0,0 +1,59 @@
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)
}
}

200
main.go
View File

@@ -1,71 +1,15 @@
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"
tphttp "github.com/siteworxpro/top-wallpaper/http"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type redditResponse struct {
Kind string `json:"kind"`
Data struct {
After string `json:"after"`
Dist int `json:"dist"`
ModHash string `json:"modhash"`
GeoFilter string `json:"geo_filter"`
Children []struct {
Kind string `json:"kind"`
Data struct {
ApprovedAtUtc interface{} `json:"approved_at_utc"`
Subreddit string `json:"subreddit"`
SelfText string `json:"selftext"`
Url string `json:"url"`
UrlOverriddenByDest string `json:"url_overridden_by_dest"`
MediaMetadata map[string]struct {
Status string `json:"status"`
Id string `json:"id"`
E string `json:"e"`
M string `json:"m"`
S struct {
U string `json:"u"`
X int `json:"x"`
Y int `json:"y"`
} `json:"s"`
P []struct {
U string `json:"u"`
X int `json:"x"`
Y int `json:"y"`
} `json:"p"`
} `json:"media_metadata"`
Preview struct {
Enabled bool `json:"enabled"`
Images []struct {
Source struct {
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"source"`
} `json:"images"`
} `json:"preview"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
type CustomContext struct {
echo.Context
redis *redis.Client
}
func main() {
e := echo.New()
@@ -73,51 +17,7 @@ func main() {
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{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)
}
})
e.Use(tphttp.GetCustomContext)
e.Use(middleware.Logger())
e.Use(middleware.RequestID())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
@@ -130,74 +30,8 @@ func main() {
if path == "" {
path = "/"
}
e.GET(path, func(c echo.Context) error {
cc := c.(*CustomContext)
var latestImageVal string
var err error
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 = getLatestImage()
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 == "" {
response, err := http.Get(latestImageVal)
if err != nil {
return c.String(http.StatusInternalServerError, "Error fetching image")
}
if response.StatusCode != http.StatusOK {
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) {
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")
}
}(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.GET(path, tphttp.Get)
e.Logger.Info("Starting server at path " + path)
// start the server
@@ -209,31 +43,3 @@ func main() {
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)
}
index := 1
url := redditData.Data.Children[index].Data.UrlOverriddenByDest
for strings.Contains(url, "gallery") {
index++
url = redditData.Data.Children[index].Data.UrlOverriddenByDest
}
return url
}

88
reddit/client.go Normal file
View File

@@ -0,0 +1,88 @@
package reddit
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type redditResponse struct {
Kind string `json:"kind"`
Data struct {
After string `json:"after"`
Dist int `json:"dist"`
ModHash string `json:"modhash"`
GeoFilter string `json:"geo_filter"`
Children []struct {
Kind string `json:"kind"`
Data struct {
ApprovedAtUtc interface{} `json:"approved_at_utc"`
Subreddit string `json:"subreddit"`
SelfText string `json:"selftext"`
Url string `json:"url"`
UrlOverriddenByDest string `json:"url_overridden_by_dest"`
MediaMetadata map[string]struct {
Status string `json:"status"`
Id string `json:"id"`
E string `json:"e"`
M string `json:"m"`
S struct {
U string `json:"u"`
X int `json:"x"`
Y int `json:"y"`
} `json:"s"`
P []struct {
U string `json:"u"`
X int `json:"x"`
Y int `json:"y"`
} `json:"p"`
} `json:"media_metadata"`
Preview struct {
Enabled bool `json:"enabled"`
Images []struct {
Source struct {
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"source"`
} `json:"images"`
} `json:"preview"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
func GetLatestImage(httpGet func(url string) (*http.Response, error)) (string, error) {
response, err := httpGet("https://www.reddit.com/r/wallpaper/.json")
if err != nil {
return "", err
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(response.Body)
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("error fetching reddit data: %s", response.Status)
}
jsonBytes, err := io.ReadAll(response.Body)
redditData := redditResponse{}
err = json.Unmarshal(jsonBytes, &redditData)
if err != nil {
return "", err
}
index := 1
url := redditData.Data.Children[index].Data.UrlOverriddenByDest
for strings.Contains(url, "gallery") {
index++
url = redditData.Data.Children[index].Data.UrlOverriddenByDest
}
return url, nil
}

70
reddit/client_test.go Normal file
View File

@@ -0,0 +1,70 @@
package reddit
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetLatestImage(t *testing.T) {
// Mock Reddit API response
mockResponse := `{
"kind": "Listing",
"data": {
"after": null,
"dist": 1,
"modhash": "",
"geo_filter": "",
"children": [
{
"kind": "t3",
"data": {
"url_overridden_by_dest": "https://example.com/gallery",
"media_metadata": {},
"preview": {}
}
},
{
"kind": "t3",
"data": {
"url_overridden_by_dest": "https://example.com/image.jpg",
"media_metadata": {},
"preview": {}
}
}
]
}
}`
// Create a test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/r/wallpaper/.json") {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(mockResponse))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
// Custom httpGet function for testing
httpGet := func(url string) (*http.Response, error) {
if strings.Contains(url, "/r/wallpaper/.json") {
return http.Get(server.URL + "/r/wallpaper/.json")
}
return nil, fmt.Errorf("unexpected URL: %s", url)
}
// Test the function
imageURL, err := GetLatestImage(httpGet)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
expectedURL := "https://example.com/image.jpg"
if imageURL != expectedURL {
t.Errorf("expected %s, got %s", expectedURL, imageURL)
}
}

30
resize/resize.go Normal file
View File

@@ -0,0 +1,30 @@
package resize
import (
"bytes"
"fmt"
"github.com/nfnt/resize"
i "image"
"image/jpeg"
_ "image/png"
)
func Shrink(image string, maxSize uint, quality int) (string, error) {
if quality < 1 || quality > 100 {
return "", fmt.Errorf("quality must be between 1 and 100, got %d", quality)
}
img, _, err := i.Decode(bytes.NewReader([]byte(image)))
if err != nil {
return "", err
}
resizedImg := resize.Thumbnail(maxSize, maxSize, img, resize.Lanczos3)
var buffer bytes.Buffer
err = jpeg.Encode(&buffer, resizedImg, &jpeg.Options{Quality: quality})
if err != nil {
return "", err
}
return buffer.String(), nil
}

48
resize/resize_test.go Normal file
View File

@@ -0,0 +1,48 @@
package resize
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"testing"
)
func createTestImage() []byte {
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
for x := 0; x < 100; x++ {
for y := 0; y < 100; y++ {
img.Set(x, y, color.RGBA{uint8(x), uint8(y), 255, 255})
}
}
var buf bytes.Buffer
_ = jpeg.Encode(&buf, img, nil)
return buf.Bytes()
}
func TestShrink_Success(t *testing.T) {
testImage := createTestImage()
result, err := Shrink(string(testImage), 50, 80)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(result) == 0 {
t.Fatalf("expected non-empty result, got empty string")
}
}
func TestShrink_InvalidImage(t *testing.T) {
invalidImage := "not-an-image"
_, err := Shrink(invalidImage, 50, 80)
if err == nil {
t.Fatalf("expected an error for invalid image input, got nil")
}
}
func TestShrink_InvalidQuality(t *testing.T) {
testImage := createTestImage()
_, err := Shrink(string(testImage), 50, -10)
if err == nil {
t.Fatalf("expected an error for invalid quality, got nil")
}
}