diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a66c28 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... + + - name: go test + run: go test -race ./... + + govulncheck: + runs-on: ubuntu-latest + # Reports known vulnerabilities in dependencies. Kept separate and + # non-blocking so a newly published advisory does not block unrelated PRs. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... diff --git a/.gitignore b/.gitignore index 85505a3..58077bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,28 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib -._* -# Test binary, built with `go test -c` -*.test -sitemap.xml -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -config/config.yaml -go.mod -go.sum -data/database.sqlite -data/database.sqlite -static/uploads/b8hZ3RHiZ5login_EpxxSB0MXw.png -static/sitemap.xml -data/database.sqlite -static/sitemap.xml -static/uploads/* -/tmp/* - -.vscode/settings.json -static/sitemap.xml -main -.DS_Store -static/sitemap.xml +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +/main +/goxcms +/tmp/ +build-errors.log + +# Test binaries and coverage +*.test +*.out + +# Go workspace file +go.work + +# Local config, data and generated files +config/config.yaml +data/*.sqlite +static/uploads/* +static/sitemap.xml + +# Editor / OS +.vscode/settings.json +.DS_Store +._* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7431fe9 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: run build test lint fmt + +run: + go run . + +build: + go build -o goxcms . + +test: + go test ./... + +lint: + test -z "$$(gofmt -l .)" + go vet ./... + go run honnef.co/go/tools/cmd/staticcheck@latest ./... + +fmt: + gofmt -w . diff --git a/README.md b/README.md index 0df0420..beb1896 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,46 @@ -# Welcome to GoX CMS! 🎉 - - - -GoX CMS is a project that combines Go and HTMX to create a snappy, and enjoyable content management experience. It's a playground for experimenting, learning, and breaking things in a controlled environment. - -## Features - -- Blog -- Categories -- Tags -- Custom pages -- Comments -- Simple plugin system - - Shop Plugin - - Logger Plugin - - Latest Post Plugin -- Many different themes -- Media manager - -## Quick Start 🏁 - -To get started with GoX CMS: - -1. Clone the repository: `git clone https://github.com/ashba22/gox_cms.git` -2. Navigate into the project directory: `cd gox_cms` -3. Initialize the module: `go mod init goxcms` -4. Download the necessary dependencies: `go mod tidy` -5. Rename the `config-example` file to `config` located in the `config` folder. -6. Adjust the configuration settings in the `config` file according to your preferences. -7. Run the application: `go run main.go` or build and run `go build main.go && ./main` - -Explore the code, experiment with changes, and don't hesitate to break things. Your discoveries and creations are what make this project thrive. - - -### TODO -CSRF Token +# Welcome to GoX CMS! 🎉 + + + +GoX CMS is a project that combines Go and HTMX to create a snappy, and enjoyable content management experience. It's a playground for experimenting, learning, and breaking things in a controlled environment. + +## Features + +- Blog +- Categories +- Tags +- Custom pages +- Comments +- Simple plugin system + - Shop Plugin + - Logger Plugin + - Latest Post Plugin +- Many different themes +- Media manager + +## Quick Start 🏁 + +Requirements: Go (see `go.mod` for the version) and a C compiler (the SQLite driver uses cgo). + +1. Clone the repository: `git clone https://github.com/PhantomPixelDev/gox_cms.git` +2. Navigate into the project directory: `cd gox_cms` +3. Copy the example config: `cp config/config-example.yaml config/config.yaml` +4. Set `app.secret` in `config/config.yaml` to a long random value, e.g. the output of `openssl rand -hex 32`. In `build.mode: production` the server refuses to start without one. +5. Run the application: `go run .` (or `make run`), then open http://localhost:3000. + +On first start an `admin` account is created. Its password comes from the `ADMIN_PASSWORD` environment variable or `app.admin_password`; if neither is set, a random password is generated and printed once in the log. Change it after logging in. + +## Development + +- `make test` runs the test suite (`go test ./...`). +- `make lint` runs gofmt, `go vet` and staticcheck, the same checks as CI. +- `make fmt` formats the code. + +### Configuration notes + +- `app.url`: set it to the public URL. Cookies are marked `Secure` when it starts with `https://`, and it is used for the sitemap and as the default CORS origin. +- `server.trusted_proxies`: list your reverse proxies so client IPs are read from `X-Forwarded-For`. +- `server.prefork`: leave it off with SQLite. +- Custom pages are served as soon as they are saved; no restart is needed. + +Explore the code, experiment with changes, and don't hesitate to break things. Your discoveries and creations are what make this project thrive. diff --git a/config/config-example.yaml b/config/config-example.yaml index 8f78324..58b03d2 100644 --- a/config/config-example.yaml +++ b/config/config-example.yaml @@ -7,8 +7,12 @@ database: server: host: "127.0.0.1" port: 3000 - prefork: true - body_limit: 50000 + # Prefork starts one process per CPU; keep it off with SQLite. + prefork: false + body_limit: 50 + # IPs/CIDRs of reverse proxies allowed to set X-Forwarded-For. Leave empty + # when the app is reached directly. + trusted_proxies: [] build: mode: production app: @@ -16,8 +20,11 @@ app: version: "0.0.1" domain: "localhost" url: "http://localhost:3000" - secret: "change_this_secret" - hotload_custom_pages: false + # Signs login tokens. Required in production; generate one with `openssl rand -hex 32`. + secret: "" + # Password for the "admin" account created on first start. Leave empty to + # generate a random one, which is printed once to the log. + admin_password: "" upload: max_size_mb: 50 redis: @@ -31,7 +38,9 @@ redis: min_idle_conns: 10 max_conn_age: 0 cors: - allowed_origins: ["http://localhost:3000", "https://localhost:3000"] + # Defaults to app.url when empty. + allowed_origins: ["http://localhost:3000"] + allow_credentials: false ratelimiter: enabled: true max_requests: 100 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/database.sqlite b/data/database.sqlite deleted file mode 100644 index a53e774..0000000 Binary files a/data/database.sqlite and /dev/null differ diff --git a/database/database.go b/database/database.go index f54e9da..4403cd5 100644 --- a/database/database.go +++ b/database/database.go @@ -8,14 +8,13 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" - "gorm.io/plugin/dbresolver" - "goxcms/model" // Update with your actual project path + "goxcms/model" "github.com/spf13/viper" ) -// initDB initializes and returns a *gorm.DB instance. +// InitDB initializes and returns a *gorm.DB instance. func InitDB() *gorm.DB { var db *gorm.DB var err error @@ -44,11 +43,6 @@ func InitDB() *gorm.DB { log.Fatalf("Failed to connect to database: %v", err) } - db.Use(dbresolver.Register(dbresolver.Config{ - Replicas: []gorm.Dialector{db.Dialector}, - Policy: dbresolver.RandomPolicy{}, - })) - err = db.AutoMigrate( &model.User{}, &model.Post{}, @@ -68,48 +62,5 @@ func InitDB() *gorm.DB { log.Fatalf("Failed to auto migrate: %v", err) } - addForeignKeyConstraints(db) - return db } - -func addForeignKeyConstraints(db *gorm.DB) { - constraints := []struct { - Table1 string - Column1 string - Table2 string - Column2 string - }{ - {"Post", "CategoryID", "Category", "ID"}, - {"Post", "TagID", "Tag", "ID"}, - {"Post", "UserID", "User", "ID"}, - {"Post", "MenuID", "Menu", "ID"}, - {"Post", "MenuItemID", "MenuItem", "ID"}, - {"Post", "CustomPageID", "CustomPage", "ID"}, - {"Category", "PostID", "Post", "ID"}, - {"Category", "TagID", "Tag", "ID"}, - {"Comment", "UserID", "User", "ID"}, - {"Comment", "PostID", "Post", "ID"}, - {"Tag", "PostID", "Post", "ID"}, - {"Menu", "MenuItemID", "MenuItem", "ID"}, - {"MenuItem", "MenuID", "Menu", "ID"}, - {"CustomPage", "PostID", "Post", "ID"}, - {"File", "PostID", "Post", "ID"}, - {"BasicWebsiteInfo", "PostID", "Post", "ID"}, - {"User", "RoleID", "Role", "ID"}, - {"User", "PostID", "Post", "ID"}, - {"User", "CommentID", "Comment", "ID"}, - {"Role", "UserID", "User", "ID"}, - } - - for _, constraint := range constraints { - err := db.Migrator().CreateConstraint(constraint.Table1, constraint.Column1) - if err != nil { - log.Printf("Error creating constraint %s.%s: %v", constraint.Table1, constraint.Column1, err) - } - err = db.Migrator().CreateConstraint(constraint.Table2, constraint.Column2) - if err != nil { - log.Printf("Error creating constraint %s.%s: %v", constraint.Table2, constraint.Column2, err) - } - } -} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba353fd --- /dev/null +++ b/go.mod @@ -0,0 +1,66 @@ +module goxcms + +go 1.26.0 + +require ( + github.com/fatih/color v1.19.0 + github.com/go-playground/validator/v10 v10.30.5 + github.com/go-resty/resty/v2 v2.17.2 + github.com/gofiber/fiber/v2 v2.52.15 + github.com/gofiber/storage/redis/v3 v3.6.1 + github.com/gofiber/template/html/v2 v2.1.3 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.57.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.3 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.2 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofiber/template v1.8.3 // indirect + github.com/gofiber/utils v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/redis/go-redis/v9 v9.22.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/atomic v1.12.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0b14ba7 --- /dev/null +++ b/go.sum @@ -0,0 +1,236 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= +github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= +github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= +github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= +github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk= +github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/storage/redis/v3 v3.6.1 h1:aOLwtw9KHVrZHM9AnRRevSI9XoUSZRbJ8H0l+bM/Bx4= +github.com/gofiber/storage/redis/v3 v3.6.1/go.mod h1:FH5LmUU/bh8TYwLsQIW+jb+9ZsnEhVqPGFSfCAvfLXQ= +github.com/gofiber/storage/testhelpers/redis v0.1.0 h1:lDUwtanDf3f5YwlDwhbqnqCtj9Y/xc8ctxRE6HpQcws= +github.com/gofiber/storage/testhelpers/redis v0.1.0/go.mod h1:Y1UccxbGVL04+TF5RuyCsksX+76hu6nJIWjPukBBgJ4= +github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= +github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= +github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o= +github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE= +github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= +github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.18.12 h1:sT9zQpvTB3B4gzrX0tmZNTEaGyg8Zw55MFYRE32Mr9I= +github.com/magiconair/properties v1.18.12/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= +github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= +github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8= +github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo= +github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/redis v0.44.0 h1:43EH7N6yB5B2tY/9uhPit487tMLm5iQiyKQaXWXNbnk= +github.com/testcontainers/testcontainers-go/modules/redis v0.44.0/go.mod h1:k4nnCSzm3z8yRMBKBn3rhsllbFjjhVn/2JjWNxxArg8= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +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/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0= +gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/handler/authHandlers.go b/handler/authHandlers.go index ae0f7d3..eb5ae19 100644 --- a/handler/authHandlers.go +++ b/handler/authHandlers.go @@ -2,17 +2,17 @@ package handlers import ( "encoding/json" - "fmt" + "errors" "goxcms/model" + "goxcms/utils" "strings" "time" - "github.com/dgrijalva/jwt-go" "github.com/go-playground/validator/v10" "github.com/go-resty/resty/v2" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/session" - "github.com/google/uuid" + "github.com/golang-jwt/jwt/v5" "github.com/spf13/viper" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -26,18 +26,13 @@ type HCaptchaResponse struct { } func verifyHCaptcha(hCaptchaResponse string) (bool, error) { - client := resty.New() - secret := viper.GetString("captcha.secret_key") - println("secret: ", secret) - resp, err := client.R(). + resp, err := resty.New().SetTimeout(10 * time.Second).R(). SetFormData(map[string]string{ - "secret": secret, + "secret": viper.GetString("captcha.secret_key"), "response": hCaptchaResponse, }). Post("https://hcaptcha.com/siteverify") - if err != nil { - /// show toast error here return false, err } @@ -49,66 +44,79 @@ func verifyHCaptcha(hCaptchaResponse string) (bool, error) { return result.Success, nil } -var jwtSecretKey = []byte(viper.GetString("app.secret")) +// captchaPassed verifies the hCaptcha token when captcha.enabled is set. On +// failure it writes the error response and returns false. +func captchaPassed(c *fiber.Ctx) bool { + if !viper.GetBool("captcha.enabled") { + return true + } -func GenerateJWT(userID uint) (string, error) { - token := jwt.New(jwt.SigningMethodHS256) - claims := token.Claims.(jwt.MapClaims) - claims["user_id"] = userID - claims["exp"] = time.Now().Add(time.Hour * 72).Unix() + status := fiber.StatusBadRequest + passed := false + if token := c.FormValue("h-captcha-response"); token != "" { + valid, err := verifyHCaptcha(token) + if err != nil { + status = fiber.StatusBadGateway + } + passed = err == nil && valid + } - tokenString, err := token.SignedString(jwtSecretKey) - return tokenString, err + if !passed { + ShowToastError(c, "CAPTCHA verification failed") + c.Status(status).SendString("CAPTCHA verification failed") + } + return passed } -// Function to validate CSRF token -func ValidateCSRFToken(c *fiber.Ctx) error { - csrfToken := c.Locals("csrf").(string) - submittedToken := c.FormValue("csrf") - if csrfToken != submittedToken { - return fiber.NewError(fiber.StatusForbidden, "CSRF token mismatch") - } - return nil +// jwtKey reads the signing secret on every call. It must not be captured in a +// package-level variable: package variables are initialised before main() +// loads the config file, which previously left the key empty. +func jwtKey() []byte { + return []byte(viper.GetString("app.secret")) } -func ValidateJWT(c *fiber.Ctx) error { - cookie := c.Cookies("jwt") +const jwtLifetime = 72 * time.Hour - token, err := jwt.Parse(cookie, func(token *jwt.Token) (interface{}, error) { - return jwtSecretKey, nil - }) +func GenerateJWT(userID uint) (string, error) { + key := jwtKey() + if len(key) == 0 { + return "", errors.New("app.secret is not configured") + } - if err != nil || !token.Valid { - return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized") + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(jwtLifetime).Unix(), } - return c.Next() + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key) } -func ValidateAdmin(c *fiber.Ctx, db *gorm.DB) error { - cookie := c.Cookies("jwt") - - token, err := jwt.Parse(cookie, func(token *jwt.Token) (interface{}, error) { - return jwtSecretKey, nil - }) +// parseJWT validates the token signature, algorithm and expiry and returns the +// user ID it was issued for. +func parseJWT(tokenString string) (uint, error) { + key := jwtKey() + if tokenString == "" || len(key) == 0 { + return 0, errors.New("no token") + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return key, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { - return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized") + return 0, errors.New("invalid token") } - claims := token.Claims.(jwt.MapClaims) - userID := claims["user_id"].(float64) - - var user model.User - db.First(&user, userID) - - if user.RoleID != 2 { - return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized") + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return 0, errors.New("invalid claims") } - c.Locals("isAdmin", true) + userID, ok := claims["user_id"].(float64) + if !ok || userID <= 0 { + return 0, errors.New("invalid user_id claim") + } - return c.Next() + return uint(userID), nil } func FormatValidationError(err error) string { @@ -143,37 +151,18 @@ func SetJWTTokenCookie(c *fiber.Ctx, tokenString string) { cookie.Name = "jwt" cookie.Value = tokenString cookie.HTTPOnly = true + cookie.Secure = utils.SecureCookies() cookie.SameSite = "Lax" cookie.Path = "/" + cookie.Expires = time.Now().Add(jwtLifetime) c.Cookie(cookie) - } func Login(db *gorm.DB, store *session.Store) fiber.Handler { return func(c *fiber.Ctx) error { - capcha_enabled := viper.GetBool("captcha.enabled") - if capcha_enabled { - - hCaptchaResponse := c.FormValue("h-captcha-response") - - println("hCaptchaResponse: ", hCaptchaResponse) - - if hCaptchaResponse == "" { - ShowToastError(c, "CAPTCHA verification failed") - return c.Status(fiber.StatusBadRequest).SendString("CAPTCHA verification failed") - } - - valid, err := verifyHCaptcha(hCaptchaResponse) - if err != nil { - ShowToastError(c, "CAPTCHA verification failed") - return c.Status(fiber.StatusInternalServerError).SendString("CAPTCHA verification failed") - } - - if !valid { - ShowToastError(c, "CAPTCHA verification failed") - return c.Status(fiber.StatusBadRequest).SendString("CAPTCHA verification failed") - } + if !captchaPassed(c) { + return nil } type loginRequest struct { @@ -219,21 +208,9 @@ func Login(db *gorm.DB, store *session.Store) fiber.Handler { SetJWTTokenCookie(c, tokenString) - /// csrf token generation - csrfToken := GenerateCSRFToken() - - cookie := new(fiber.Cookie) - cookie.Name = "csrf" - cookie.Value = csrfToken - cookie.HTTPOnly = true - cookie.SameSite = "Lax" - cookie.Path = "/" - c.Cookie(cookie) - c.Locals("user", user) c.Locals("isLoggedin", true) - c.Locals("isAdmin", user.RoleID == uint(2)) - c.Locals("csrf", csrfToken) + c.Locals("isAdmin", user.RoleID == model.RoleAdmin) c.Set("HX-Redirect", "/") c.Status(fiber.StatusOK).SendString("Logged in successfully" + user.Username) @@ -241,11 +218,6 @@ func Login(db *gorm.DB, store *session.Store) fiber.Handler { } } -func GenerateCSRFToken() string { - uuid := uuid.New() - return uuid.String() -} - func Logout(c *fiber.Ctx) error { sess := c.Locals("session").(*session.Session) sess.Destroy() @@ -255,22 +227,13 @@ func Logout(c *fiber.Ctx) error { cookie.Value = "" cookie.Expires = time.Now().Add(-1 * time.Hour) cookie.HTTPOnly = true + cookie.Path = "/" - /// remove the csrf cookie as well - csrfCookie := new(fiber.Cookie) - csrfCookie.Name = "csrf" - csrfCookie.Value = "" - csrfCookie.Expires = time.Now().Add(-1 * time.Hour) - csrfCookie.HTTPOnly = true - - c.Cookie(csrfCookie) c.Cookie(cookie) c.Locals("user", nil) c.Locals("isLoggedin", false) c.Locals("isAdmin", false) c.Locals("session", nil) - c.Locals("csrf", nil) - // remove the csrf cookie c.Set("HX-Redirect", "/") @@ -282,24 +245,8 @@ func Logout(c *fiber.Ctx) error { func Register(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { - hCaptchaResponse := c.FormValue("h-captcha-response") - - println("hCaptchaResponse: ", hCaptchaResponse) - - if hCaptchaResponse == "" { - ShowToastError(c, "CAPTCHA verification failed") - return c.Status(fiber.StatusBadRequest).SendString("CAPTCHA verification failed") - } - - valid, err := verifyHCaptcha(hCaptchaResponse) - if err != nil { - ShowToastError(c, "CAPTCHA verification failed") - return c.Status(fiber.StatusInternalServerError).SendString("CAPTCHA verification failed") - } - - if !valid { - ShowToastError(c, "CAPTCHA verification failed") - return c.Status(fiber.StatusBadRequest).SendString("CAPTCHA verification failed") + if !captchaPassed(c) { + return nil } var user model.User @@ -308,7 +255,7 @@ func Register(db *gorm.DB) fiber.Handler { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } - user.RoleID = uint(1) + user.RoleID = model.RoleUser validate := validator.New() if err := validate.Struct(&user); err != nil { @@ -348,42 +295,22 @@ func Register(db *gorm.DB) fiber.Handler { func AuthStatusMiddleware(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { - tokenString := c.Cookies("jwt") - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return jwtSecretKey, nil - - }) - - if err != nil || !token.Valid { - c.Locals("isLoggedin", false) - return c.Next() - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - c.Locals("isLoggedin", false) - return c.Next() - } + c.Locals("isLoggedin", false) + c.Locals("isAdmin", false) - userID, ok := claims["user_id"].(float64) - if !ok { - c.Locals("isLoggedin", false) + userID, err := parseJWT(c.Cookies("jwt")) + if err != nil { return c.Next() } var user model.User - if err := db.First(&user, uint(userID)).Error; err != nil { - c.Locals("isLoggedin", false) + if err := db.First(&user, userID).Error; err != nil { return c.Next() } c.Locals("isLoggedin", true) c.Locals("user", user) - - c.Locals("isAdmin", user.RoleID == uint(2)) + c.Locals("isAdmin", user.RoleID == model.RoleAdmin) return c.Next() } @@ -397,16 +324,48 @@ func HashPassword(password string) (string, error) { return string(hashedPassword), nil } -func IsAdmin(c *fiber.Ctx) error { - if c.Locals("isAdmin") == false { - return c.Status(fiber.StatusUnauthorized).Redirect("/login") +// IsTrue reads a boolean local. Missing or non-bool values count as false, so +// a request that never went through AuthStatusMiddleware is never trusted. +func IsTrue(c *fiber.Ctx, key string) bool { + v, _ := c.Locals(key).(bool) + return v +} + +// CurrentUser returns the logged-in user, if any. +func CurrentUser(c *fiber.Ctx) (model.User, bool) { + user, ok := c.Locals("user").(model.User) + return user, ok && IsTrue(c, "isLoggedin") +} + +// denyAccess rejects a request. HTMX requests get an HX-Redirect header, plain +// page loads a redirect, and everything else a bare status code. +func denyAccess(c *fiber.Ctx, status int, redirectTo string) error { + if c.Get("HX-Request") == "true" { + c.Set("HX-Redirect", redirectTo) + return c.SendStatus(status) } - return c.Next() + if c.Method() == fiber.MethodGet { + return c.Redirect(redirectTo) + } + return c.SendStatus(status) } +// IsLoggedIn only lets authenticated users through. func IsLoggedIn(c *fiber.Ctx) error { - if c.Locals("isLoggedin") == false { - return c.Status(fiber.StatusUnauthorized).Redirect("/login") + if !IsTrue(c, "isLoggedin") { + return denyAccess(c, fiber.StatusUnauthorized, "/login") + } + return c.Next() +} + +// IsAdmin only lets authenticated administrators through. It does not rely on +// IsLoggedIn having run first. +func IsAdmin(c *fiber.Ctx) error { + if !IsTrue(c, "isLoggedin") { + return denyAccess(c, fiber.StatusUnauthorized, "/login") + } + if !IsTrue(c, "isAdmin") { + return denyAccess(c, fiber.StatusForbidden, "/") } return c.Next() } diff --git a/handler/blogHandlers.go b/handler/blogHandlers.go index 97fbb9e..95190a0 100644 --- a/handler/blogHandlers.go +++ b/handler/blogHandlers.go @@ -2,104 +2,119 @@ package handlers import ( "encoding/json" + "errors" + "fmt" "goxcms/model" "html/template" "strconv" "strings" - "math" - "github.com/gofiber/fiber/v2" "gorm.io/gorm" + "gorm.io/gorm/clause" ) -// Register handles the registration process -func AdminAddBlogPost(c *fiber.Ctx, db *gorm.DB) error { - // Parse form values with validation - title, content, slug := c.FormValue("title"), c.FormValue("content"), c.FormValue("post_slug") +// postForm holds the fields shared by the add and edit post forms. +type postForm struct { + title, content, slug, image string + categoryIDs, tagIDs []uint +} - if title == "" || content == "" || slug == "" { - return c.SendString("Missing required fields: title, content, slug" + title + content + slug) // Show toast error +// parsePostForm reads and validates the post form. On failure it writes the +// response and returns false. +func parsePostForm(c *fiber.Ctx) (postForm, bool) { + f := postForm{ + title: strings.TrimSpace(c.FormValue("title")), + content: c.FormValue("content"), + slug: strings.TrimSpace(c.FormValue("post_slug")), + image: strings.TrimSpace(c.FormValue("image")), + categoryIDs: extractIDs(c.FormValue("categories_input")), + tagIDs: extractIDs(c.FormValue("tags_input")), } - image := c.FormValue("image") - if image == "" { + if f.title == "" || f.content == "" || f.slug == "" { + ShowToastError(c, "Missing required fields: title, content, slug") + c.SendString("Missing required fields: title, content, slug") + return f, false + } + if f.image == "" { ShowToastError(c, "Missing required fields: image") - return c.SendString("Missing required fields: image") // Show toast error + c.SendString("Missing required fields: image") + return f, false } + return f, true +} - categoryIDs, tagIDs := extractIDs(c.FormValue("categories_input")), extractIDs(c.FormValue("tags_input")) - - // Start a transaction - tx := db.Begin() - defer func() { - if r := recover(); r != nil || tx.Error != nil { - tx.Rollback() - } - }() +var errSlugTaken = errors.New("slug is already used by another post") - // Fetch categories and tags from the database +// loadPostRelations fetches the selected categories and tags. +func loadPostRelations(tx *gorm.DB, f postForm) ([]model.Category, []model.Tag, error) { var categories []model.Category - if err := tx.Find(&categories, categoryIDs).Error; err != nil { - return c.SendString("Error fetching categories: " + err.Error()) + if len(f.categoryIDs) > 0 { + if err := tx.Find(&categories, f.categoryIDs).Error; err != nil { + return nil, nil, fmt.Errorf("fetching categories: %w", err) + } } var tags []model.Tag - if err := tx.Find(&tags, tagIDs).Error; err != nil { - return c.SendString("Error fetching tags: " + err.Error()) + if len(f.tagIDs) > 0 { + if err := tx.Find(&tags, f.tagIDs).Error; err != nil { + return nil, nil, fmt.Errorf("fetching tags: %w", err) + } } + return categories, tags, nil +} - // Create a new Post instance - post := model.Post{ - Title: title, Content: content, Slug: slug, - ImageURL: image, - UserID: c.Locals("user").(model.User).ID, - Categories: categories, Tags: tags, - } +// postSlugTaken reports whether another post already uses slug. +func postSlugTaken(tx *gorm.DB, slug string, excludeID uint) (bool, error) { + var count int64 + err := tx.Model(&model.Post{}).Where("slug = ? AND id <> ?", slug, excludeID).Count(&count).Error + return count > 0, err +} - if err := tx.Create(&post).Error; err != nil { - return c.SendString("Post creation failed: " + err.Error()) +func AdminAddBlogPost(c *fiber.Ctx, db *gorm.DB) error { + f, ok := parsePostForm(c) + if !ok { + return nil } - tx.Commit() - if tx.Error != nil { - return c.SendString("Transaction commit failed: " + tx.Error.Error()) - } + post := model.Post{ + Title: f.title, + Content: f.content, + Slug: f.slug, + ImageURL: f.image, + UserID: currentUserID(c), + } + + err := db.Transaction(func(tx *gorm.DB) error { + if taken, err := postSlugTaken(tx, f.slug, 0); err != nil { + return err + } else if taken { + return errSlugTaken + } + + categories, tags, err := loadPostRelations(tx, f) + if err != nil { + return err + } + post.Categories, post.Tags = categories, tags - postID := strconv.Itoa(int(post.ID)) + return tx.Create(&post).Error + }) + if err != nil { + ShowToastError(c, "Post creation failed: "+err.Error()) + return c.SendString("Post creation failed: " + err.Error()) + } - message := map[string]string{"showToast": "Settings updated successfully", "clearForm": "true"} + message := map[string]string{"showToast": "Post created successfully", "clearForm": "true"} messageBytes, _ := json.Marshal(message) c.Set("HX-Trigger", string(messageBytes)) - button_show_post_and_edit_post_html := ` - -
Post ID: ` + postID + `
-Post Slug: ` + slug + `
-Post Title: ` + title + `
-About us
"}, "slug": {"about"}, "template": {"page"}} + postForm(t, app, "/add-custompage", form, token, auth) + + resp, body := do(t, app, "GET", "/about") + if resp.StatusCode != fiber.StatusOK || !strings.Contains(body, "About us") { + t.Fatalf("GET /about: got status %d", resp.StatusCode) + } + + form = url.Values{"title": {"Evil"}, "content": {"x"}, "slug": {"evil"}, "template": {"../admin/admin"}} + postForm(t, app, "/add-custompage", form, token, auth) + var count int64 + db.Model(&model.CustomPage{}).Where("slug = ?", "evil").Count(&count) + if count != 0 { + t.Fatal("custom page with a disallowed template was created") + } + + if resp, _ := do(t, app, "GET", "/does-not-exist"); resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("unknown path: got status %d, want 404", resp.StatusCode) + } +} + +func TestSitemap(t *testing.T) { + app, db := newTestApp(t) + db.Create(&model.Post{Title: "Live", Content: "x", Slug: "live", Published: true}) + db.Create(&model.Post{Title: "Draft", Content: "x", Slug: "draft"}) + + resp, body := do(t, app, "GET", "/sitemap.xml") + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("got status %d", resp.StatusCode) + } + for _, want := range []string{"http://localhost:3000/", "/blog", "/blog/post/live"} { + if !strings.Contains(body, want) { + t.Errorf("sitemap missing %q", want) + } + } + for _, unwanted := range []string{"/blog/post/draft", "/user/"} { + if strings.Contains(body, unwanted) { + t.Errorf("sitemap contains %q", unwanted) + } + } +} + +func TestUploadRejectsNonImageContent(t *testing.T) { + app, db := newTestApp(t) + admin := createUser(t, db, "boss", model.RoleAdmin) + auth := authCookie(t, admin.ID) + token := csrfCookie(t, app, auth) + viper.Set("upload.max_size_mb", 1) + + var buf strings.Builder + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "evil.PNG") + part.Write([]byte("")) + w.Close() + + req := httptest.NewRequest("POST", "/upload-file", strings.NewReader(buf.String())) + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("X-Csrf-Token", token.Value) + req.AddCookie(token) + req.AddCookie(auth) + resp, err := app.Test(req, -1) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("got status %d, want 400", resp.StatusCode) + } +} diff --git a/model/menu.go b/model/menu.go index 06992cc..b416fec 100644 --- a/model/menu.go +++ b/model/menu.go @@ -29,14 +29,3 @@ type MenuItem struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } - -// MenuRepository defines the interface for menu repository operations. -type MenuRepository interface { - FindAll() ([]*Menu, error) - FindByID(id uint) (*Menu, error) - FindBySlug(slug string) (*Menu, error) - Create(menu *Menu) (*Menu, error) - Update(menu *Menu) (*Menu, error) - Delete(id uint) error - FindByParentID(parentID uint) ([]*Menu, error) // Method to find sub-menus -} diff --git a/model/page.go b/model/page.go index 2fcf5a8..4acd50c 100644 --- a/model/page.go +++ b/model/page.go @@ -8,7 +8,7 @@ type CustomPage struct { ID uint `json:"id" gorm:"primaryKey"` Title string `json:"title"` Content string `json:"content"` - Slug string `json:"slug"` + Slug string `json:"slug" gorm:"index"` Template string `json:"template" gorm:"default:'page'"` Published bool `json:"published" gorm:"default:false"` CreatedAt time.Time `json:"created_at"` diff --git a/model/post.go b/model/post.go index bcd8fe3..6509b57 100644 --- a/model/post.go +++ b/model/post.go @@ -11,7 +11,7 @@ type Post struct { UserID uint `json:"user_id"` Categories []Category `json:"categories" gorm:"many2many:post_categories;"` Tags []Tag `json:"tags" gorm:"many2many:post_tags;"` - Slug string `json:"slug"` + Slug string `json:"slug" gorm:"index"` ImageURL string `json:"image_url"` Published bool `json:"published" gorm:"default:false"` CreatedAt time.Time `json:"created_at"` diff --git a/model/user.go b/model/user.go index c88fe14..2e55dbf 100644 --- a/model/user.go +++ b/model/user.go @@ -6,6 +6,12 @@ import ( "github.com/go-playground/validator/v10" ) +// Role IDs stored in User.RoleID. +const ( + RoleUser uint = 1 + RoleAdmin uint = 2 +) + // User struct with validation tags using go-playground validator type User struct { ID uint `json:"id" gorm:"primaryKey"` diff --git a/plugin_system/plugin_manager.go b/plugin_system/plugin_manager.go index 38a2ff5..e2e6d13 100644 --- a/plugin_system/plugin_manager.go +++ b/plugin_system/plugin_manager.go @@ -2,7 +2,6 @@ package plugin_system import ( "encoding/json" - "fmt" handlers "goxcms/handler" "goxcms/model" "log" @@ -86,45 +85,38 @@ func EnableDisablePlugin(pluginName string, db *gorm.DB) error { } /// change value in database and then reload the plugin if enabled pluginDB.Enabled = !pluginDB.Enabled - db.Save(&pluginDB) - println("Plugin enabled: ", pluginDB.Enabled) - /// reset app store from app store (cache) and reload the plugin - - return nil + return db.Save(&pluginDB).Error } -// / add route to enable/disable plugin +// AddPluginManagerRoutes registers the admin-only plugin toggle endpoint. It is +// a POST because it changes state. func AddPluginManagerRoutes(app *fiber.App, db *gorm.DB) { - app.Get("/admin/plugins/enable/:name", handlers.IsAdmin, handlers.IsLoggedIn, enableDisablePluginHandler(db)) + app.Post("/admin/plugins/enable/:name", handlers.IsAdmin, enableDisablePluginHandler(db)) } func enableDisablePluginHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { pluginName := c.Params("name") - fmt.Println("Plugin name: ", pluginName) - - EnableDisablePlugin(pluginName, db) plugin := GetPluginByName(pluginName) if plugin == nil { - fmt.Println("Plugin not found") return c.SendStatus(fiber.StatusNotFound) } - buttonText := "Enable" - if plugin.Enabled(db) { - buttonText = "Disable" + if err := EnableDisablePlugin(pluginName, db); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Failed to update plugin") } - buttonClass := "btn btn-success mt-2 btn-plugin" - if plugin.Enabled(db) { - buttonClass = "btn btn-danger mt-2 btn-plugin" + enabled := plugin.Enabled(db) + action := "disabled" + if enabled { + action = "enabled" } + handlers.ShowToast(c, "Plugin "+action+" successfully, restart the server to see changes") - htmxResponse := fmt.Sprintf(``, pluginName, buttonClass, pluginName, buttonText) - - handlers.ShowToast(c, "Plugin "+buttonText+"d successfully, restart the server to see changes") - - return c.Status(fiber.StatusOK).SendString(htmxResponse) + return c.Render("partials/plugin-button", fiber.Map{ + "Name": plugin.Name(), + "Enabled": enabled, + }) } } diff --git a/plugin_system/plugins_list.go b/plugin_system/plugins_list.go index 2bd3a69..e158045 100644 --- a/plugin_system/plugins_list.go +++ b/plugin_system/plugins_list.go @@ -2,6 +2,7 @@ package plugin_system import ( "goxcms/plugins/latest_posts_plugin" + "goxcms/plugins/logger_plugin" "goxcms/plugins/shop_plugin" ) @@ -9,7 +10,7 @@ func PluginList() []Plugin { plugin_list := []Plugin{ &latest_posts_plugin.LatestPostsPlugin{}, - //&logger_plugin.LoggerPlugin{}, + // &logger_plugin.LoggerPlugin{}, // uncomment to make the request logger available &shop_plugin.ShopPlugin{}, /// add plugins here @@ -19,3 +20,6 @@ func PluginList() []Plugin { } return plugin_list } + +// Compile-time check that the optional logger plugin satisfies Plugin. +var _ Plugin = (*logger_plugin.LoggerPlugin)(nil) diff --git a/plugins/latest_posts_plugin/latest_posts_plugin.go b/plugins/latest_posts_plugin/latest_posts_plugin.go index 9d14930..f2fe81f 100644 --- a/plugins/latest_posts_plugin/latest_posts_plugin.go +++ b/plugins/latest_posts_plugin/latest_posts_plugin.go @@ -20,7 +20,6 @@ const ( ) func (p *LatestPostsPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) error { - fmt.Println("LatestPosts Plugin setup") app.Get("/latest_posts_plugin", func(c *fiber.Ctx) error { /// if plugin is not enabled return 404 if !p.Enabled(db) { @@ -63,7 +62,6 @@ func (p *LatestPostsPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engi } func (p *LatestPostsPlugin) Teardown() error { - fmt.Println("LatestPostsPlugin teardown") return nil } @@ -94,6 +92,5 @@ func (p *LatestPostsPlugin) Enabled(db *gorm.DB) bool { /// get status from database plugin := &model.Plugin{} db.Where("name = ?", PluginName).First(plugin) - fmt.Println(PluginName, "enabled status:", plugin.Enabled) return plugin.Enabled } diff --git a/plugins/logger_plugin/logger_plugin.go b/plugins/logger_plugin/logger_plugin.go index 01d3eaa..66037f6 100644 --- a/plugins/logger_plugin/logger_plugin.go +++ b/plugins/logger_plugin/logger_plugin.go @@ -1,20 +1,27 @@ package logger_plugin import ( - "fmt" + "goxcms/model" + "strconv" "github.com/fatih/color" "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" "gorm.io/gorm" - // import print color package ) +const ( + PluginName = "LoggerPlugin" + Author = "Ashba22" + Version = "1.0" +) + +// LoggerPlugin prints a coloured line per request. Add it to +// plugin_system.PluginList and enable it from the admin panel to use it. type LoggerPlugin struct{} -func (p *LoggerPlugin) Setup(app *fiber.App, db *gorm.DB) error { - fmt.Println("LoggerPlugin setup") +func (p *LoggerPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) error { app.Use(func(c *fiber.Ctx) error { - // Print in different colors for different log levels color.Cyan("Path: %s", c.Path()) color.Green("Method: %s", c.Method()) color.Yellow("Connection: %s", c.Context().RemoteAddr()) @@ -28,22 +35,33 @@ func (p *LoggerPlugin) Setup(app *fiber.App, db *gorm.DB) error { } func (p *LoggerPlugin) Teardown() error { - fmt.Println("LoggerPlugin teardown") return nil } func (p *LoggerPlugin) Name() string { - return "LoggerPlugin" + return PluginName } func (p *LoggerPlugin) Author() string { - return "Ashba22" + return Author } func (p *LoggerPlugin) Version() string { - return "1.0" + return Version +} + +func (p *LoggerPlugin) DefaultSettings() map[string]string { + return map[string]string{} +} + +func (p *LoggerPlugin) Settings(db *gorm.DB) map[string]string { + return map[string]string{ + "Enabled": strconv.FormatBool(p.Enabled(db)), + } } func (p *LoggerPlugin) Enabled(db *gorm.DB) bool { - return true + plugin := &model.Plugin{} + db.Where("name = ?", PluginName).First(plugin) + return plugin.Enabled } diff --git a/plugins/shop_plugin/shop_plugin.go b/plugins/shop_plugin/shop_plugin.go index 1de6e87..6ad82d4 100644 --- a/plugins/shop_plugin/shop_plugin.go +++ b/plugins/shop_plugin/shop_plugin.go @@ -2,14 +2,12 @@ package shop_plugin import ( "encoding/json" - "fmt" handlers "goxcms/handler" "goxcms/model" - "html/template" + "log" "math/rand" "regexp" "strconv" - "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/template/html/v2" @@ -56,9 +54,9 @@ func (p *ShopPlugin) AddProduct(c *fiber.Ctx, db *gorm.DB) error { var product Product // Extract product data from the form - product.Name = sanitizeHTML(c.FormValue("name")) + product.Name = handlers.SanitizeText(c.FormValue("name")) price, _ := strconv.Atoi(c.FormValue("price")) - product.Description = sanitizeHTML(c.FormValue("description")) + product.Description = handlers.SanitizeText(c.FormValue("description")) product.Picture = c.FormValue("picture") product.MorePictures = c.FormValue("more_pictures") product.Price = uint(price) @@ -67,8 +65,7 @@ func (p *ShopPlugin) AddProduct(c *fiber.Ctx, db *gorm.DB) error { err := db.Create(&product).Error if err != nil { - price := strconv.Itoa(price) - println("Error creating product", err.Error(), product.Name, price) + log.Printf("shop: creating product %q: %v", product.Name, err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Invalid data", }) @@ -77,44 +74,36 @@ func (p *ShopPlugin) AddProduct(c *fiber.Ctx, db *gorm.DB) error { return c.Status(fiber.StatusCreated).SendString("Product created successfully") } -func sanitizeHTML(input string) string { - // Remove any HTML tags and attributes - sanitized := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(input, "") - - // Replace special characters with their HTML entities - sanitized = template.HTMLEscapeString(sanitized) - - return sanitized -} +var nonSlugChars = regexp.MustCompile(`[^a-zA-Z0-9]+`) func generateSlugFromProductName(productName string) string { - return regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(productName, "-") + return nonSlugChars.ReplaceAllString(productName, "-") } +// demoProductCount is how many placeholder products are seeded the first +// time the shop plugin starts with an empty catalogue. +const demoProductCount = 30 + func generateRandomProducts(db *gorm.DB) error { - rand.Seed(time.Now().UnixNano()) - - for i := 0; i < 30000; i++ { - println("Generating product ", i+1) - product := Product{ - Name: "Product " + strconv.Itoa(i+1), - Price: uint(rand.Float64() * 100), - Description: "Product " + strconv.Itoa(i+1) + " description", + products := make([]Product, 0, demoProductCount) + for i := 1; i <= demoProductCount; i++ { + name := "Product " + strconv.Itoa(i) + products = append(products, Product{ + Name: name, + Price: uint(rand.Intn(100)), + Description: name + " description", Status: "pending", - ProductCategory: ProductCategory{ID: 1}, - Slug: generateSlugFromProductName("Product " + strconv.Itoa(i+1)), + Slug: generateSlugFromProductName(name), ProductCategoryID: 1, Picture: "https://placehold.co/600x400/EEE/31343C", MorePictures: "https://placehold.co/600x400/EEE/31343C", - } - db.Create(&product) + }) } - return nil + return db.CreateInBatches(products, 100).Error } func (p *ShopPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) error { - fmt.Println("ShopPlugin setup") db.AutoMigrate(&Product{}) db.AutoMigrate(&ProductCategory{}) @@ -125,24 +114,16 @@ func (p *ShopPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) err settings := plugin.Settings if settings == "" { - println("Empty settings found, adding default settings") defaultSettingsJSON, err := json.Marshal(p.DefaultSettings()) if err != nil { - fmt.Println("Error marshaling default settings:", err) + log.Printf("shop: marshaling default settings: %v", err) return err } plugin.Settings = string(defaultSettingsJSON) db.Save(&plugin) - println("Default settings added") - } - - // break point here - for key, value := range p.Settings(db) { - println("Key: ", key, " Value: ", value) } - println("ShopPlugin setup done" + settings) // Check if product categories exist, if not, add an example category var productCategories []ProductCategory if err := db.Find(&productCategories).Error; err != nil { @@ -165,14 +146,14 @@ func (p *ShopPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) err } } - app.Post("/ShopPlugin/add_product", func(c *fiber.Ctx) error { + app.Post("/ShopPlugin/add_product", handlers.IsAdmin, func(c *fiber.Ctx) error { if !p.Enabled(db) { return c.Status(404).SendString("Plugin not enabled") } return p.AddProduct(c, db) }) - app.Get("/ShopPlugin/admin/:page?", handlers.IsLoggedIn, handlers.IsAdmin, handlers.AuthStatusMiddleware(db), func(c *fiber.Ctx) error { + app.Get("/ShopPlugin/admin/:page?", handlers.IsAdmin, func(c *fiber.Ctx) error { if !p.Enabled(db) { return c.Status(fiber.StatusNotFound).SendString("Plugin not enabled") } @@ -218,19 +199,12 @@ func (p *ShopPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) err }) /// /ShopPlugin/update-settings endpoint - app.Post("/ShopPlugin/update-settings", handlers.IsAdmin, handlers.IsLoggedIn, handlers.AuthStatusMiddleware(db), func(c *fiber.Ctx) error { + app.Post("/ShopPlugin/update-settings", handlers.IsAdmin, func(c *fiber.Ctx) error { if !p.Enabled(db) { return c.Status(fiber.StatusNotFound).SendString("Plugin not enabled") } - println("Updating settings ---- ") /// print the form values to debug - println("Shop Name: ", c.FormValue("shop_name")) - println("Shop Description: ", c.FormValue("shop_description")) - println("Shop Address: ", c.FormValue("shop_address")) - println("Shop Phone: ", c.FormValue("shop_phone")) - println("Shop Email: ", c.FormValue("shop_email")) - shopName := c.FormValue("shop_name") shopDescription := c.FormValue("shop_description") shopAddress := c.FormValue("shop_address") @@ -275,17 +249,13 @@ func (p *ShopPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) err if searchQuery == "" { db.Model(&Product{}).Count(&totalProducts) - println("Total products: ", totalProducts) } else { /// convert to string and remove any special characters - searchQuery = sanitizeHTML(searchQuery) - println("Search query: ", searchQuery) + searchQuery = handlers.SanitizeText(searchQuery) print("Search query: ", searchQuery) db.Model(&Product{}).Where("name LIKE ?", "%"+searchQuery+"%").Count(&totalProducts) } - println("Search query: ", searchQuery, " Page: ", pageInt) - totalPages := int(totalProducts / int64(limit)) if totalPages == 0 { totalPages = 1 @@ -368,8 +338,6 @@ func (p *ShopPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) err return c.Status(fiber.StatusNotFound).SendString("Product not found") } - htmlMessage := template.HTML("Write your post here
" + + return c.Render("admin/post/post_add", fiber.Map{ + "Title": "Add Post", + "Categories": categories, + "Tags": tags, + "Content": template.HTML(html_basic_test), + "IsAdmin": c.Locals("isAdmin"), + "IsLoggedIn": c.Locals("isLoggedin"), + "Settings": c.Locals("Settings"), + }, "main") + }) + + app.Post("/admin/post/add", handlers.IsAdmin, func(c *fiber.Ctx) error { + + return handlers.AdminAddBlogPost(c, db) + }) + + app.Get("/admin", handlers.IsAdmin, func(c *fiber.Ctx) error { + + plugins := plugin_system.GetPlugins() + pluginData := make([]map[string]interface{}, 0, len(plugins)) + for _, plugin := range plugins { + pluginData = append(pluginData, map[string]interface{}{ + "Name": plugin.Name(), + "Enabled": plugin.Enabled(db), + "Author": plugin.Author(), + "Version": plugin.Version(), + }) + } + + var enabled_plugins int64 + db.Model(&model.Plugin{}).Where("enabled = ?", true).Count(&enabled_plugins) + + return c.Render("admin/admin", fiber.Map{ + "Title": "Admin Panel", + "IsAdmin": c.Locals("isAdmin"), + "IsLoggedIn": c.Locals("isLoggedin"), + "Settings": c.Locals("Settings"), + "Plugins": pluginData, + "EnabledPlugins": enabled_plugins, + }, "main") + }) +} diff --git a/routes/auth.go b/routes/auth.go new file mode 100644 index 0000000..cb96057 --- /dev/null +++ b/routes/auth.go @@ -0,0 +1,45 @@ +package routes + +import ( + handlers "goxcms/handler" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" + "gorm.io/gorm" +) + +// setupAuthRoutes registers registration, login and logout. +func setupAuthRoutes(app *fiber.App, db *gorm.DB, store *session.Store) { + app.Get("/register", func(c *fiber.Ctx) error { + + if handlers.IsTrue(c, "isLoggedin") { + return c.Redirect("/") + } + + return c.Render("register", fiber.Map{ + "Title": "Register", + "Settings": c.Locals("Settings"), + }, "main") + }) + + app.Post("/register", handlers.Register(db)) + + app.Get("/login", func(c *fiber.Ctx) error { + + if handlers.IsTrue(c, "isLoggedin") { + return c.Redirect("/") + } + + return c.Render("login", fiber.Map{ + "Title": "Login", + "Settings": c.Locals("Settings"), + }, "main") + }) + + app.Post("/login", handlers.Login(db, store)) + + app.Post("/logout", func(c *fiber.Ctx) error { + + return handlers.Logout(c) + }) +} diff --git a/routes/public.go b/routes/public.go new file mode 100644 index 0000000..5b5b05c --- /dev/null +++ b/routes/public.go @@ -0,0 +1,51 @@ +package routes + +import ( + handlers "goxcms/handler" + "goxcms/utils" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// setupPublicRoutes registers the pages anyone can see. +func setupPublicRoutes(app *fiber.App, db *gorm.DB) { + app.Get("/", func(c *fiber.Ctx) error { + + return c.Render("index", fiber.Map{ + "Title": "GoX CMS - HomePage", + "IsLoggedIn": c.Locals("isLoggedin"), + "IsAdmin": c.Locals("isAdmin"), + "Settings": c.Locals("Settings"), + }, "main") + }) + + app.Get("/get-primary-menu", func(c *fiber.Ctx) error { + return handlers.GetPrimaryMenuRender(c, db) + }) + + app.Post("/add-comment", handlers.IsLoggedIn, func(c *fiber.Ctx) error { + return handlers.AddComment(c, db) + }) + + app.Get("/blog/:page?", func(c *fiber.Ctx) error { + return handlers.BlogPage(c, db) + }) + + app.Get("/blog/post/:slug", func(c *fiber.Ctx) error { + return handlers.BlogPostPage(c, db) + }) + + app.Get("/blog/category/:slug/:page?", func(c *fiber.Ctx) error { + return handlers.BlogCategoryPage(c, db) + }) + + app.Get("/blog/tag/:slug/:page?", func(c *fiber.Ctx) error { + return handlers.BlogTagPage(c, db) + }) + + app.Get("/sitemap.xml", func(c *fiber.Ctx) error { + c.Type("xml", "utf-8") + return c.Send(utils.BuildSitemap(db)) + }) +} diff --git a/routes/routes.go b/routes/routes.go index f211fe2..069bba4 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -1,450 +1,52 @@ -// routes/routes.go package routes import ( "html/template" - "strconv" "strings" handlers "goxcms/handler" "goxcms/model" - "goxcms/plugin_system" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/session" "github.com/gofiber/template/html/v2" - "github.com/spf13/viper" "gorm.io/gorm" ) +// SetupRoutes installs the auth and settings middleware and registers the +// public, auth and admin routes. func SetupRoutes(app *fiber.App, db *gorm.DB, store *session.Store, engine *html.Engine) { - app.Use(handlers.AuthStatusMiddleware(db)) app.Use(func(c *fiber.Ctx) error { - - settings_cms := model.BasicWebsiteInfo{} - - db.First(&settings_cms) - - captcha_enabled := viper.GetBool("captcha.enabled") - - settings_cms_db := map[string]string{ - "Name": settings_cms.Name, - "Tagline": settings_cms.Tagline, - "Email": settings_cms.Email, - "Phone": settings_cms.Phone, - "Address": settings_cms.Address, - "About": settings_cms.About, - "LogoURL": settings_cms.LogoURL, - "FaviconURL": settings_cms.FaviconURL, - "FacebookURL": settings_cms.FacebookURL, - "TwitterURL": settings_cms.TwitterURL, - "LinkedInURL": settings_cms.LinkedInURL, - "SEOKeywords": settings_cms.SEOKeywords, - "SEODescription": settings_cms.SEODescription, - "AnalyticsID": settings_cms.AnalyticsID, - "FooterText": settings_cms.FooterText, - "Theme": settings_cms.Theme, - "ContactEmail": settings_cms.ContactEmail, - "PrivacyPolicy": settings_cms.PrivacyPolicy, - "TermsOfService": settings_cms.TermsOfService, - "Language": settings_cms.Language, - "Locale": settings_cms.Locale, - "TimeZone": settings_cms.TimeZone, - "CaptchaEnabled": strconv.FormatBool(captcha_enabled), - "CaptchaSiteKey": viper.GetString("captcha.public_key"), - "ContainerClass": settings_cms.ContainerClass, - } - - c.Locals("Settings", settings_cms_db) + c.Locals("Settings", handlers.SiteSettings(db)) return c.Next() }) - hotload_custom_pages := viper.GetBool("app.hotload_custom_pages") - - if hotload_custom_pages { - - app.Use(func(c *fiber.Ctx) error { - // Get the path from the request - path := c.Path() - - path = strings.TrimPrefix(path, "/") - - var customPage model.CustomPage - if err := db.Where("slug = ?", path).First(&customPage).Error; err != nil { - return c.Next() - } - // Render the custom page with the custom page data - return c.Render("page/"+customPage.Template, fiber.Map{ - "Title": customPage.Title, - "Content": customPage.Content, - "Settings": c.Locals("Settings"), - }, "main") - }) - - } else { - - var customPages []model.CustomPage - db.Find(&customPages) - - println("Generating Custom Page Routes:", len(customPages)) - - for _, customPage := range customPages { - println("Custom Page Route Created:", customPage.Slug) - app.Get("/"+customPage.Slug, func(cp model.CustomPage) func(*fiber.Ctx) error { - return func(c *fiber.Ctx) error { - return c.Render("page/"+cp.Template, fiber.Map{ - "Title": cp.Title, - "Content": template.HTML(cp.Content), - "Settings": c.Locals("Settings"), - }, "main") - } - }(customPage)) - } - } - - app.Get("/", func(c *fiber.Ctx) error { - - return c.Render("index", fiber.Map{ - "Title": "GoX CMS - HomePage", - "IsLoggedIn": c.Locals("isLoggedin"), - "IsAdmin": c.Locals("isAdmin"), - "Settings": c.Locals("Settings"), - }, "main") - }) - - app.Post("/clear-cache", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - - store.Reset() - - handlers.ShowToast(c, "Cache cleared successfully") - - return nil - }) - - app.Get("/register", func(c *fiber.Ctx) error { - - if c.Locals("isLoggedin") == true { - c.Redirect("/") - return nil - } - - return c.Render("register", fiber.Map{ - "Title": "Register", - "Settings": c.Locals("Settings"), - }, "main") - }) - - app.Post("/register", handlers.Register(db)) - - app.Get("/login", func(c *fiber.Ctx) error { - - if c.Locals("isLoggedin") == true { - c.Redirect("/") - return nil - } - - return c.Render("login", fiber.Map{ - "Title": "Login", - "Settings": c.Locals("Settings"), - }, "main") - }) - - app.Post("/login", handlers.Login(db, store)) - - app.Post("/logout", func(c *fiber.Ctx) error { - - return handlers.Logout(c) - }) - - app.Get("/admin-settings", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - - settings_cms := model.BasicWebsiteInfo{} - - if err := db.First(&settings_cms).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) - } - - themes_list := []string{"cerulean", "cosmo", "cyborg", "darkly", "flatly", "journal", "litera", "lumen", "lux", "materia", "minty", "pulse", "sandstone", "simplex", "sketchy", "slate", "solar", "spacelab", "superhero", "united", "yeti", "morph", "quartz", "vapor", "zephyr"} - containers_list := []string{"container", "container-fluid"} - return c.Render("website_settings", fiber.Map{ - "Title": "Admin Settings", - "Settings": c.Locals("Settings"), - "SettingsAdmin": handlers.MapSettingsToMap(settings_cms), - "Themes": themes_list, - "Containers": containers_list, - }) - - }) - - app.Post("/update-settings", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - - return handlers.UpdateSettings(c, db) - }) - - app.Post("/toggle-post-status", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - - return handlers.TogglePostStatus(c, db) - }) - - app.Get("/search-posts", func(c *fiber.Ctx) error { - return handlers.AdminSearchPosts(c, db) - }) - - app.Delete("/delete-post/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.AdminDeletePost(c, db) - }) - - app.Get("/search-tags", func(c *fiber.Ctx) error { - return handlers.SearchTag(c, db) - }) - - app.Delete("/delete-tag", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteTag(c, db) - }) - - /// add tag - app.Post("/add-tag", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.AddTag(c, db) - }) - - /// add menu - app.Post("/add-menu", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.AddMenu(c, db) - - }) - - // add menu item to menu - app.Post("/add-menu-item", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.AddMenuItem(c, db) - }) - - // delete menu item - app.Delete("/delete-menu-item/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteMenuItem(c, db) - }) - - // delete menu - app.Delete("/delete-menu/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteMenu(c, db) - }) - - // edit menu - app.Post("/edit-menu/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.EditMenu(c, db) - }) - - /// remove submenu from menu - app.Delete("/remove-submenu/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.RemoveSubmenuFromMenu(c, db) - }) - - // edit menu item - app.Post("/edit-menu-item/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.EditMenuItem(c, db) - }) - - /// create get view for edit menu and menu item return modal htmx view - app.Get("/edit-menu/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.EditMenuView(c, db) - }) - - /// create get view for edit menu and menu item return modal htmx view - app.Get("/edit-menu-item/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.EditMenuItemView(c, db) - }) - - app.Get("/search-menu", func(c *fiber.Ctx) error { - return handlers.SearchMenuAdminTable(c, db) - }) - - app.Get("/get-primary-menu", func(c *fiber.Ctx) error { - return handlers.GetPrimaryMenuRender(c, db) - }) - - app.Get("/search-users", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.SearchUsers(c, db) - }) - - app.Get("/search-comments", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.SearchCommentsView(c, db) - }) - - /// toggle comment status# - app.Post("/toggle-comment-status/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.ToggleCommentStatus(c, db) - }) - - /// delete comment - app.Delete("/delete-comment/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteComment(c, db) - }) - - app.Delete("/delete-user/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteUser(c, db) - }) - - app.Get("/search-categories", func(c *fiber.Ctx) error { - return handlers.SearchCategories(c, db) - }) - - app.Post("/add-category", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.AddCategory(c, db) - }) - - app.Delete("/delete-category", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteCategory(c, db) - }) - - app.Get("/search-custompages", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.SearchCustomPages(c, db) - }) - - app.Post("/add-custompage", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.AddCustomPage(c, db, app, engine) - }) - - app.Get("/add-custompage", func(c *fiber.Ctx) error { - if c.Locals("isAdmin") == false { - return c.Redirect("/") - } - return c.Render("page/page_add", fiber.Map{ - "TitleView": "Add Custom Page", - "Settings": c.Locals("Settings"), - }, "main") - }) - - app.Get("/edit-custompage/:id", func(c *fiber.Ctx) error { - if c.Locals("isAdmin") == false { - return c.Redirect("/") - } + setupPublicRoutes(app, db) + setupAuthRoutes(app, db, store) + setupAdminRoutes(app, db, engine) +} - id, err := c.ParamsInt("id") - if err != nil { - return c.Status(fiber.StatusBadRequest).SendString(err.Error()) +// SetupCustomPageRoutes serves custom pages by slug. It must be registered +// after every other route so it never shadows them, and it reads the page on +// each request so new and edited pages are live without a restart. +func SetupCustomPageRoutes(app *fiber.App, db *gorm.DB) { + app.Get("/*", func(c *fiber.Ctx) error { + slug := strings.Trim(c.Params("*"), "/") + if slug == "" { + return c.Next() } var customPage model.CustomPage - if err := db.First(&customPage, id).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + if err := db.Where("slug = ?", slug).First(&customPage).Error; err != nil { + return c.Next() } - return c.Render("page/page_edit", fiber.Map{ + return c.Render("page/"+handlers.CustomPageTemplate(customPage.Template), fiber.Map{ "Title": customPage.Title, - "Content": customPage.Content, - "ID": customPage.ID, - "Slug": customPage.Slug, - "Template": customPage.Template, + "Content": template.HTML(customPage.Content), "Settings": c.Locals("Settings"), }, "main") }) - - app.Post("/edit-custompage", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.EditCustomPage(c, db) - }) - - app.Delete("/delete-custompage/:id", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteCustomPage(c, db) - }) - - app.Get("/search-files", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - - return handlers.SearchFiles(c, db) - }) - - app.Post("/add-comment", handlers.IsLoggedIn, func(c *fiber.Ctx) error { - return handlers.AddComment(c, db) - }) - - app.Post("/upload-file", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.UploadFile(c, db) - }) - - app.Delete("/delete-file", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - return handlers.DeleteFile(c, db) - }) - - app.Get("/blog/:page?", func(c *fiber.Ctx) error { - return handlers.BlogPage(c, db) - }) - - app.Get("/blog/post/:slug", func(c *fiber.Ctx) error { - return handlers.BlogPostPage(c, db) - }) - - app.Get("/blog/category/:slug/:page?", func(c *fiber.Ctx) error { - return handlers.BlogCategoryPage(c, db) - }) - - app.Get("/blog/tag/:slug/:page?", func(c *fiber.Ctx) error { - return handlers.BlogTagPage(c, db) - }) - - app.Get("/admin/post/edit/:post_id", func(c *fiber.Ctx) error { - return handlers.AdminEditBlogPost(c, db) - }) - - app.Post("/admin/post/edit", func(c *fiber.Ctx) error { - return handlers.AdminUpdateBlogPost(c, db) - }) - - app.Get("/admin/post/add", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - - var categories []model.Category - var tags []model.Tag - - db.Find(&categories) - db.Find(&tags) - - c.Set("HX-Trigger", "Action: addPost") - - html_basic_test := "Write your post here
" - - return c.Render("admin/post/post_add", fiber.Map{ - "Title": "Add Post", - "Categories": categories, - "Tags": tags, - "Content": template.HTML(html_basic_test), - "IsAdmin": c.Locals("isAdmin"), - "IsLoggedIn": c.Locals("isLoggedin"), - "Settings": c.Locals("Settings"), - }, "main") - }) - - app.Post("/admin/post/add", func(c *fiber.Ctx) error { - - return handlers.AdminAddBlogPost(c, db) - }) - - app.Get("/admin", handlers.IsLoggedIn, handlers.IsAdmin, func(c *fiber.Ctx) error { - - plugins := plugin_system.GetPlugins() - pluginData := make([]map[string]interface{}, 0, len(plugins)) - for _, plugin := range plugins { - pluginData = append(pluginData, map[string]interface{}{ - "Name": plugin.Name(), - "Enabled": plugin.Enabled(db), - "Author": plugin.Author(), - "Version": plugin.Version(), - }) - } - - var enabled_plugins int64 - db.Model(&model.Plugin{}).Where("enabled = ?", true).Count(&enabled_plugins) - - return c.Render("admin/admin", fiber.Map{ - "Title": "Admin Panel", - "IsAdmin": c.Locals("isAdmin"), - "IsLoggedIn": c.Locals("isLoggedin"), - "Settings": c.Locals("Settings"), - "Plugins": pluginData, - "EnabledPlugins": enabled_plugins, - }, "main") - }) - - app.Get("/sitemap.xml", func(c *fiber.Ctx) error { - return c.SendFile("./static/sitemap.xml") - }) - } diff --git a/static/sitemap.xml b/static/sitemap.xml deleted file mode 100644 index fab1164..0000000 --- a/static/sitemap.xml +++ /dev/null @@ -1,93 +0,0 @@ - -{{.CreatedAt.Format "02 Jan 2006"}}
-{{ escape (truncate .Content 200) }}
+{{ truncate (escape .Content) 200 }}
Read More{{.CreatedAt.Format "02 Jan 2006"}}
-{{ escape (truncate .Content 200) }}
+{{ truncate (escape .Content) 200 }}
Read More{{.CreatedAt.Format "02 Jan 2006"}}
-{{ escape (truncate .Content 200) }}
+{{ truncate (escape .Content) 200 }}