20 Commits

Author SHA1 Message Date
838051c880 update-golang (#2)
All checks were successful
🚨 Test Code Base / 🔍 🐹 Go Tests (push) Successful in 1m12s
🚨 Test Code Base / 🧹 Lint (push) Successful in 2m9s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 4m50s
Reviewed-on: #2
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-08-31 02:34:23 +00:00
5ff262d5da Refactor context key for logger in main.go
All checks were successful
🚨 Test Code Base / 🔍 🐹 Go Tests (push) Successful in 4m27s
🚨 Test Code Base / 🧹 Lint (push) Successful in 4m40s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 6m35s
2025-08-27 00:29:47 -04:00
2e2f9f7fee Refactor context key for logger in main.go
Some checks failed
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Has been cancelled
🚨 Test Code Base / 🧹 Lint (push) Has been cancelled
🚨 Test Code Base / 🔍 🐹 Go Tests (push) Has been cancelled
2025-08-27 00:28:36 -04:00
ff66877192 update-deps (#1)
All checks were successful
🚨 Test Code Base / 🔍 🐹 Go Tests (push) Successful in 1m22s
🚨 Test Code Base / 🧹 Lint (push) Successful in 1m31s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 11m11s
Reviewed-on: #1
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-08-26 16:36:17 +00:00
aa1699243a Update README with Docker run command version and add Docker Compose instructions 2025-08-08 14:56:23 -04:00
7c236a49b6 Add GitHub Actions build workflow and update Docker images
All checks were successful
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 11m12s
2025-08-08 14:43:19 -04:00
df992caf1e Add Docker Compose configuration for Traefik and Redis services 2025-08-08 14:40:19 -04:00
88ef0c23a0 Implement Redis caching for Reddit image fetching 2025-07-22 12:13:38 -04:00
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
19 changed files with 856 additions and 215 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.idea/
top-wallpaper

View File

@@ -0,0 +1,42 @@
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to build and push'
required: false
default: 'latest'
push:
tags:
- "v*"
name: 🏗️✨ Build Workflow
jobs:
Build:
name: 🖥️ 🔨 Build
runs-on: ubuntu-latest
steps:
- name: 📖 🔍 Checkout Repository Code
uses: actions/checkout@v4
with:
fetch-depth: 1
submodules: 'true'
- name: 🔑 🔐 Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: 🏗️ 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🐳 🔨 Build Container
uses: docker/build-push-action@v6
with:
push: true
sbom: true
provenance: true
context: .
dockerfile: Dockerfile
tags: siteworxpro/top-wallpaper:${{ gitea.ref_name }}

54
.gitea/workflows/lint.yml Normal file
View File

@@ -0,0 +1,54 @@
on:
push:
branches:
- "*"
name: 🚨 Test Code Base
run-name: ${{ github.workflow }}-{{ github.ref_name }}-{{ github.run_number }}
env:
GO_VERSION: '1.25.0'
jobs:
test-go:
name: 🔍 🐹 Go Tests
runs-on: ubuntu-latest
steps:
- name: ⚙️ 🐹 Set up Go Environment
uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: 📖 🔍 Checkout Repository Code
uses: actions/checkout@v2
with:
fetch-depth: 1
- name: 📦 📥 Install Dependencies
run: |
go mod download
- name: ✅ 🔍 Run Go Tests
run: |
go test -v ./... -coverprofile=coverage.out
golangci-lint:
name: 🧹 Lint
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout code
uses: actions/checkout@v3
- name: 🛠️ Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- name: 📦 📥 Install Dependencies
run: |
go mod download
- name: ✅ 🔍 Run Go Lint
uses: golangci/golangci-lint-action@v8.0.0

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}"

61
.golangci.yml Normal file
View File

@@ -0,0 +1,61 @@
version: "2"
linters:
default: none
enable:
- bodyclose
- gocritic
- govet
- ineffassign
- staticcheck
- testifylint
- unused
settings:
govet:
disable:
- fieldalignment
enable-all: true
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- bodyclose
path: .*_test.go
- linters:
- bodyclose
path: router/timeout_response.go
# False positives on CGO generated code
- linters:
- staticcheck
path: vips/.*
text: 'SA4000:'
# False positives on CGO generated code
- linters:
- gocritic
path: vips/.*
text: dupSubExpr
# False positives on CGO generated code
- linters:
- staticcheck
text: 'ST1005:'
paths:
- .tmp
- vendor
- third_party$
- builtin$
- examples$
formatters:
enable:
- goimports
exclusions:
generated: lax
paths:
- .tmp
- vendor
- third_party$
- builtin$
- examples$

View File

@@ -1,10 +1,10 @@
FROM siteworxpro/golang:1.24.0 AS build
FROM siteworxpro/golang:1.25.0 AS build
WORKDIR /app
ADD . .
ENV GOPRIVATE=git.s.int
ENV GOPRIVATE=gitea.siteworxpro.com
ENV GOPROXY=direct
ENV CGO_ENABLED=0

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.

View File

@@ -30,7 +30,20 @@ PORT: Port to serve the image (default: 8080)
## docker
```shell
docker run --rm -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 siteworxpro/top-wallpaper:latest
docker run --rm -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 siteworxpro/top-wallpaper:v1.3.1
```
## Docker Compose
```shell
curl -L https://gitea.siteworxpro.com/siteworxpro/top-wallpaper/raw/tag/v1.3.1/docker-compose.yml | docker compose -f - up -d
````
Access the app at [https://localhost](https://localhost).
## Embedding the Image
You can embed the image in your HTML using the following code:
```html
<img src="https://localhost" alt="Top Wallpaper" />
```
## License

50
docker-compose.yml Normal file
View File

@@ -0,0 +1,50 @@
volumes:
redis_data:
services:
traefik:
image: traefik:latest
container_name: traefik
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
restart: always
command:
- "--providers.docker=true"
- "--providers.docker.exposedByDefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web-secure.address=:443"
- "--accesslog=true"
- "--entrypoints.web.http.redirections.entryPoint.to=web-secure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
redis:
image: redis:latest
container_name: redis
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
volumes:
- redis_data:/data
api:
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.entrypoints=web-secure"
- "traefik.http.routers.api.rule=Host(`localhost`) || Host(`127.0.0.1`)"
- "traefik.http.routers.api.tls=true"
- "traefik.http.services.api.loadbalancer.server.port=8080"
image: siteworxpro/top-wallpaper:v1.3.1
container_name: top-wallpaper
environment:
REDIS_URL: "redis"
CORS_ORIGINS: "https://localhost"
depends_on:
redis:
condition: service_healthy

23
go.mod
View File

@@ -1,23 +1,24 @@
module github.com/siteworxpro/top-wallpaper
go 1.24.0
go 1.25.0
require (
github.com/labstack/echo/v4 v4.13.3
github.com/redis/go-redis/v9 v9.7.0
github.com/labstack/echo/v4 v4.13.4
github.com/labstack/gommon v0.4.2
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/redis/go-redis/v9 v9.12.1
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.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-colorable v0.1.14 // 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
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/time v0.12.0 // indirect
)

20
go.sum
View File

@@ -4,23 +4,33 @@ 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.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/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
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=
@@ -29,15 +39,25 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
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/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
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/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

26
http/handler.go Normal file
View File

@@ -0,0 +1,26 @@
package http
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/siteworxpro/top-wallpaper/redis"
)
func Get(c echo.Context) error {
rc, err := redis.NewRedis()
if err != nil {
return c.String(http.StatusInternalServerError, "Internal Server Error: "+err.Error())
}
val, err := rc.Get(redis.CacheKey)
if err != nil || val == "" {
return c.NoContent(http.StatusNoContent)
}
c.Response().Header().Set("Cache-Control", "public, max-age=600")
return c.Blob(http.StatusOK, "image/jpeg", []byte(val))
}

229
main.go
View File

@@ -2,122 +2,38 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"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"
tphttp "github.com/siteworxpro/top-wallpaper/http"
"github.com/siteworxpro/top-wallpaper/reddit"
"github.com/siteworxpro/top-wallpaper/redis"
)
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() {
ctx, fn := context.WithCancel(context.Background())
defer fn()
rc, err := redis.NewRedis()
if err != nil {
log.Error("Could not initialize Redis client: ", err)
return
}
e := echo.New()
e.AcquireContext().Set("redisClient", rc)
e.Logger.SetOutput(os.Stdout)
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{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(middleware.Logger())
e.Use(middleware.RequestID())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
@@ -130,110 +46,21 @@ 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
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
ctx = redis.WithContext(ctx, rc)
ctx = context.WithValue(ctx, "logger", e.Logger) //nolint:staticcheck
go reddit.Fetch(ctx)
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
}

89
reddit/client.go Normal file
View File

@@ -0,0 +1,89 @@
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 response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("error fetching reddit data: %s", response.Status)
}
jsonBytes, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
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)
}
}

148
reddit/fetcher.go Normal file
View File

@@ -0,0 +1,148 @@
package reddit
import (
"context"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/siteworxpro/top-wallpaper/redis"
"github.com/siteworxpro/top-wallpaper/resize"
)
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 err != nil {
l.Error("Error fetching from Redis: ", err)
break
}
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, e := httpClient.Do(request)
if e != nil {
l.Error("Error fetching image from Reddit: ", e)
return nil, e
}
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
})
if err != nil {
l.Error("Error getting latest image URL: ", err)
break
}
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)
if err != nil {
l.Error("Error resizing image: ", err)
break
}
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)
}
}

119
redis/client.go Normal file
View File

@@ -0,0 +1,119 @@
package redis
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
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
}

31
resize/resize.go Normal file
View File

@@ -0,0 +1,31 @@
package resize
import (
"bytes"
"fmt"
i "image"
"image/jpeg"
_ "image/png"
"github.com/nfnt/resize"
)
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")
}
}