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! 🎉 - -![GoXCMS Admin](https://i.imgur.com/ipHrr9x.png) - -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! 🎉 + +![GoXCMS Admin](https://i.imgur.com/ipHrr9x.png) + +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 := ` - - - ` - - return c.SendString(button_show_post_and_edit_post_html) - + return c.Render("partials/post-created", post) } func AdminEditBlogPost(c *fiber.Ctx, db *gorm.DB) error { - if c.Locals("isAdmin") == false || c.Locals("isLoggedin") == false { - c.Redirect("/") - return nil - } - postID, _ := c.ParamsInt("post_id") var post model.Post @@ -137,93 +152,55 @@ func AdminEditBlogPost(c *fiber.Ctx, db *gorm.DB) error { } func AdminUpdateBlogPost(c *fiber.Ctx, db *gorm.DB) error { - postID := c.FormValue("id") - - // Parse form values with validation - if postID == "" || postID == "0" { - return c.SendString("Missing required fields: post_id") // Show toast error - } - - title, content, slug := c.FormValue("title"), c.FormValue("content"), c.FormValue("post_slug") - - if title == "" || content == "" || slug == "" { - return c.SendString("Missing required fields: title, content, slug") // Show toast error + postID, err := strconv.ParseUint(c.FormValue("id"), 10, 64) + if err != nil || postID == 0 { + ShowToastError(c, "Missing required fields: post_id") + return c.SendString("Missing required fields: post_id") } - image := c.FormValue("image") - - if image == "" { - ShowToast(c, "Missing required fields: image") - return c.SendString("Missing required fields: image") // Show toast error + f, ok := parsePostForm(c) + if !ok { + return nil } - categoryIDs, tagIDs := extractIDs(c.FormValue("categories_input")), extractIDs(c.FormValue("tags_input")) // c.FormValue("categories"), c.FormValue("tags") - - // Start a transaction - tx := db.Begin() - defer func() { - if r := recover(); r != nil || tx.Error != nil { - tx.Rollback() + err = db.Transaction(func(tx *gorm.DB) error { + var post model.Post + if err := tx.First(&post, postID).Error; err != nil { + return fmt.Errorf("fetching post: %w", err) } - }() - // Fetch the existing post from the database - var post model.Post - if err := tx.Preload("Categories").Preload("Tags").First(&post, postID).Error; err != nil { - return c.SendString("Error fetching post: " + err.Error()) - } - - // Update the post with the new values - post.Title = title - post.Content = content - post.Slug = slug - post.ImageURL = image - - // Fetch categories and tags from the database - var categories []model.Category - if err := tx.Find(&categories, categoryIDs).Error; err != nil { - return c.SendString("Error fetching categories: " + err.Error()) - } + if taken, err := postSlugTaken(tx, f.slug, post.ID); err != nil { + return err + } else if taken { + return errSlugTaken + } - var tags []model.Tag - if err := tx.Find(&tags, tagIDs).Error; err != nil { - return c.SendString("Error fetching tags: " + err.Error()) - } + categories, tags, err := loadPostRelations(tx, f) + if err != nil { + return err + } - // Assign the fetched categories and tags to the post - post.Categories = categories - post.Tags = tags + post.Title = f.title + post.Content = f.content + post.Slug = f.slug + post.ImageURL = f.image - // Check if slug is unique - var postWithSlug model.Post - if err := tx.Where("slug = ? AND id != ?", slug, postID).First(&postWithSlug).Error; err != nil { - if err.Error() != "record not found" { - return c.SendString("Error checking if slug is unique: " + err.Error()) + if err := tx.Omit(clause.Associations).Save(&post).Error; err != nil { + return err } - if postWithSlug.ID != 0 { - return c.SendString("Slug is not unique") + if err := tx.Model(&post).Association("Categories").Replace(categories); err != nil { + return fmt.Errorf("updating categories: %w", err) } - } - - // Update the post in the database - if err := tx.Save(&post).Error; err != nil { + if err := tx.Model(&post).Association("Tags").Replace(tags); err != nil { + return fmt.Errorf("updating tags: %w", err) + } + return nil + }) + if err != nil { + ShowToastError(c, "Post update failed: "+err.Error()) return c.SendString("Post update failed: " + err.Error()) } - // Update the post's categories and tags in the database - if err := tx.Model(&post).Association("Categories").Replace(&categories); err != nil { - return c.SendString("Error updating post's categories: " + err.Error()) - } - - if err := tx.Model(&post).Association("Tags").Replace(&tags); err != nil { - return c.SendString("Error updating post's tags: " + err.Error()) - } - - tx.Commit() - if tx.Error != nil { - return c.SendString("Transaction commit failed: " + tx.Error.Error()) - } - message := map[string]string{"showToast": "Post updated successfully", "clearForm": "true"} messageBytes, _ := json.Marshal(message) c.Set("HX-Trigger", string(messageBytes)) @@ -234,14 +211,10 @@ func AdminUpdateBlogPost(c *fiber.Ctx, db *gorm.DB) error { func AdminSearchPosts(c *fiber.Ctx, db *gorm.DB) error { var posts []model.Post searchQuery := c.Query("query") - page := c.Query("page", "1") pageSize := 10 // Or whatever your default page size is // Convert page string to int - pageInt, err := strconv.Atoi(page) - if err != nil || pageInt < 1 { - pageInt = 1 - } + pageInt := queryPage(c) // Implement search logic with pagination db.Preload("Categories").Preload("Tags"). @@ -256,7 +229,7 @@ func AdminSearchPosts(c *fiber.Ctx, db *gorm.DB) error { db.Model(&model.Post{}). Where("title LIKE ?", "%"+searchQuery+"%"). Count(&count) - totalPages := int(math.Ceil(float64(count) / float64(pageSize))) + totalPages := pageCount(count, pageSize) return c.Render("admin/table/post-table", fiber.Map{ "Posts": posts, @@ -298,9 +271,6 @@ func AdminDeletePost(c *fiber.Ctx, db *gorm.DB) error { func BlogPage(c *fiber.Ctx, db *gorm.DB) error { - /// set header for cache X-No-Cache to prevent caching - c.Set("X-No-Cache", "true") - page := c.Params("page") if page == "" { page = "1" @@ -330,7 +300,7 @@ func BlogPage(c *fiber.Ctx, db *gorm.DB) error { totalPages := 1 if totalPosts > 0 { - totalPages = int(math.Ceil(float64(totalPosts) / float64(postsPerPage))) + totalPages = pageCount(totalPosts, postsPerPage) } var totalPagesArray []int @@ -359,13 +329,7 @@ func BlogPage(c *fiber.Ctx, db *gorm.DB) error { func BlogPostPage(c *fiber.Ctx, db *gorm.DB) error { slug := c.Params("slug") - /// get current loged in userID if any - userID := uint(0) - if c.Locals("isLoggedin") == true { - - userID = c.Locals("user").(model.User).ID - - } + userID := currentUserID(c) if slug == "" { return c.Redirect("/blog") @@ -384,7 +348,7 @@ func BlogPostPage(c *fiber.Ctx, db *gorm.DB) error { db.Preload("User").Where("post_id = ? AND status != ?", post.ID, "pending").Find(&comments) // Handle unpublished posts - if !post.Published && !c.Locals("isAdmin").(bool) { + if !post.Published && !IsTrue(c, "isAdmin") { return c.Status(404).Render("404", fiber.Map{ "Title": "404", "Settings": c.Locals("Settings"), @@ -421,36 +385,14 @@ func TogglePostStatus(c *fiber.Ctx, db *gorm.DB) error { return ShowToastError(c, "Error updating post status") } + post.Published = newStatus if newStatus { - ShowToastError(c, "Post published successfully") - button_unpublish_html := ` - - ` - return c.SendString(button_unpublish_html) - + ShowToast(c, "Post published successfully") + } else { + ShowToast(c, "Post unpublished successfully") } - ShowToastError(c, "Post unpublished successfully") - button_unpublish_html := ` - - ` - return c.SendString(button_unpublish_html) + return c.Render("partials/post-status-button", post) } func extractIDs(ids string) []uint { @@ -473,3 +415,12 @@ func extractIDs(ids string) []uint { return idList } + +// currentUserID returns the ID of the logged-in user, or 0 for anonymous +// requests. +func currentUserID(c *fiber.Ctx) uint { + if user, ok := CurrentUser(c); ok { + return user.ID + } + return 0 +} diff --git a/handler/categoryHandlers.go b/handler/categoryHandlers.go index 99416a4..f0ea5f9 100644 --- a/handler/categoryHandlers.go +++ b/handler/categoryHandlers.go @@ -5,8 +5,6 @@ import ( "goxcms/model" "strconv" - "math" - "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) @@ -55,7 +53,7 @@ func BlogCategoryPage(c *fiber.Ctx, db *gorm.DB) error { Where("post_categories.category_id = ? AND posts.published = ?", category.ID, true). Count(&totalPosts) - totalPages := int(math.Ceil(float64(totalPosts) / float64(postsPerPage))) + totalPages := pageCount(totalPosts, postsPerPage) if pageNumber > totalPages { return c.Redirect("/blog/category/" + slug + "/1") @@ -131,20 +129,16 @@ func DeleteCategory(c *fiber.Ctx, db *gorm.DB) error { return ShowToastError(c, "Error deleting category") } - return ShowToastError(c, "Category deleted successfully") + return ShowToast(c, "Category deleted successfully") } func SearchCategories(c *fiber.Ctx, db *gorm.DB) error { // Get the page number and search query from the query parameters - page := c.Query("page", "1") pageSize := 10 // Default page size searchQuery := c.Query("query") // Convert page string to int - pageInt, err := strconv.Atoi(page) - if err != nil || pageInt < 1 { - pageInt = 1 - } + pageInt := queryPage(c) // Search for categories with pagination var categories []model.Category @@ -168,7 +162,7 @@ func SearchCategories(c *fiber.Ctx, db *gorm.DB) error { db.Model(&model.Category{}). Where("name LIKE ?", "%"+searchQuery+"%"). Count(&totalMatchingCount) - totalPages := int(math.Ceil(float64(totalMatchingCount) / float64(pageSize))) + totalPages := pageCount(totalMatchingCount, pageSize) return c.Render("admin/table/category-table", fiber.Map{ "Categories": categories, diff --git a/handler/commentHandlers.go b/handler/commentHandlers.go index b55e711..dccc06d 100644 --- a/handler/commentHandlers.go +++ b/handler/commentHandlers.go @@ -7,40 +7,19 @@ import ( "strconv" "github.com/gofiber/fiber/v2" - "github.com/spf13/viper" "gorm.io/gorm" ) func AddComment(c *fiber.Ctx, db *gorm.DB) 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 } var comment model.Comment // Extract comment data from the form - comment.Content = sanitizeHTML(c.FormValue("comment")) + comment.Content = SanitizeText(c.FormValue("comment")) postID, _ := strconv.Atoi(c.FormValue("post_id")) comment.PostID = uint(postID) userID, _ := strconv.Atoi(c.FormValue("user_id")) @@ -51,7 +30,7 @@ func AddComment(c *fiber.Ctx, db *gorm.DB) error { comment.Status = "pending" // Check if the user is authenticated and is the same user as in the form data - if uint(userID) != c.Locals("user").(model.User).ID { + if uid := currentUserID(c); uid == 0 || uint(userID) != uid { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "message": "Unauthorized", }) @@ -74,26 +53,18 @@ func AddComment(c *fiber.Ctx, db *gorm.DB) error { return c.Status(fiber.StatusCreated).SendString(string(htmlMessage)) } -// SanitizeHTML sanitizes the HTML input to prevent XSS attacks -func sanitizeHTML(input string) string { - // Remove any HTML tags and attributes - sanitized := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(input, "") +var htmlTagPattern = regexp.MustCompile(`<[^>]*>`) - // Replace special characters with their HTML entities - sanitized = template.HTMLEscapeString(sanitized) - - return sanitized +// SanitizeText strips HTML tags and escapes what is left, so the result is +// safe to render as HTML. +func SanitizeText(input string) string { + return template.HTMLEscapeString(htmlTagPattern.ReplaceAllString(input, "")) } func SearchCommentsView(c *fiber.Ctx, db *gorm.DB) error { var comments []model.Comment searchQuery := c.FormValue("query") - page, err := strconv.Atoi(c.FormValue("page", "1")) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Invalid page number", - }) - } + page := queryPage(c) limit := 10 offset := (page - 1) * limit @@ -104,8 +75,7 @@ func SearchCommentsView(c *fiber.Ctx, db *gorm.DB) error { /// count total comments for pagination /// var totalComments int64 db.Model(&model.Comment{}).Where("content LIKE ?", "%"+searchQuery+"%").Count(&totalComments) - TotalPages := int(totalComments / int64(limit)) - + TotalPages := pageCount(totalComments, limit) if TotalPages == 0 { TotalPages = 1 } @@ -130,41 +100,10 @@ func ToggleCommentStatus(c *fiber.Ctx, db *gorm.DB) error { } db.Save(&comment) - button := GetCommentStatusButton(comment) - button = string(template.HTML(button)) // convert to string ShowToast(c, "Comment status changed successfully") - return c.SendString(button) - /// return the button with the new status -} - -func GetCommentStatusButton(comment model.Comment) string { - var button string - - commentID := strconv.Itoa(int(comment.ID)) - - if comment.Status == "approved" { - button = `` - } else { - button = `` - } - - return button + return c.Render("partials/comment-status-button", comment) } func DeleteComment(c *fiber.Ctx, db *gorm.DB) error { diff --git a/handler/customPageHandlers.go b/handler/customPageHandlers.go index 2eadf07..bf92dcb 100644 --- a/handler/customPageHandlers.go +++ b/handler/customPageHandlers.go @@ -2,48 +2,48 @@ package handlers import ( "goxcms/model" - "math" "strconv" + "strings" "github.com/gofiber/fiber/v2" - "github.com/gofiber/template/html/v2" "gorm.io/gorm" ) -func RenderCustomPage(c *fiber.Ctx, db *gorm.DB, app *fiber.App, slug string, engine *html.Engine) { - var customPage model.CustomPage - result := db.Where("slug = ?", slug).First(&customPage) - if result.Error != nil { - c.Status(fiber.StatusNotFound) - c.SendString("Custom Page not found" + result.Error.Error()) - return - } - - app.Get("/"+slug, func(c *fiber.Ctx) error { - /// reload the engine to reflect changes - engine.Load() - - return c.Render("custom/"+customPage.Template, fiber.Map{ - "Title": customPage.Title, - "Content": customPage.Content, - }) - }) +// customPageTemplates lists the views/page templates a custom page may use. +var customPageTemplates = map[string]bool{ + "page": true, + "page_sidebar": true, + "page_fullwidth": true, +} - /// reload the engine to reflect changes - engine.Load() +// CustomPageTemplate returns name if it is an allowed page template, and the +// default "page" template otherwise. +func CustomPageTemplate(name string) string { + if customPageTemplates[name] { + return name + } + return "page" +} +// normalizePageSlug trims surrounding slashes and whitespace from a slug. +func normalizePageSlug(slug string) string { + return strings.Trim(strings.TrimSpace(slug), "/") } -func AddCustomPage(c *fiber.Ctx, db *gorm.DB, app *fiber.App, engine *html.Engine) error { +func AddCustomPage(c *fiber.Ctx, db *gorm.DB) error { title := c.FormValue("title") content := c.FormValue("content") - slug := c.FormValue("slug") + slug := normalizePageSlug(c.FormValue("slug")) template := c.FormValue("template") if title == "" || content == "" || slug == "" || template == "" { return c.SendString("Missing required fields: title, content, slug, template") } + if !customPageTemplates[template] { + return c.SendString("Unknown template: " + template) + } + var existingPage model.CustomPage result := db.Where("slug = ? OR title = ?", slug, title).First(&existingPage) if result.Error == nil { @@ -57,29 +57,22 @@ func AddCustomPage(c *fiber.Ctx, db *gorm.DB, app *fiber.App, engine *html.Engin Template: template, } - //RenderCustomPage(c, db, app, slug, engine) - result = db.Create(&customPage) if result.Error != nil { ShowToastError(c, "Error adding custom page: "+result.Error.Error()) return c.Status(fiber.StatusInternalServerError).SendString(result.Error.Error()) } - return ShowToast(c, "Custom Page Added - Restart server to see changes") + return ShowToast(c, "Custom Page Added") } func SearchCustomPages(c *fiber.Ctx, db *gorm.DB) error { - page := c.Query("page", "1") pageSize := 10 // Default page size searchQuery := c.Query("query", "") - pageInt, err := strconv.Atoi(page) - - if err != nil || pageInt < 1 { - pageInt = 1 - } + pageInt := queryPage(c) var custom_pages []model.CustomPage db.Where("title LIKE ?", "%"+searchQuery+"%"). @@ -92,7 +85,7 @@ func SearchCustomPages(c *fiber.Ctx, db *gorm.DB) error { db.Model(&model.CustomPage{}). Where("title LIKE ?", "%"+searchQuery+"%"). Count(&count) - totalPages := int(math.Ceil(float64(count) / float64(pageSize))) + totalPages := pageCount(count, pageSize) return c.Render("admin/table/custom-page-table", fiber.Map{ "CustomPages": custom_pages, @@ -106,7 +99,7 @@ func EditCustomPage(c *fiber.Ctx, db *gorm.DB) error { id := c.FormValue("id") title := c.FormValue("title") content := c.FormValue("content") - slug := c.FormValue("slug") + slug := normalizePageSlug(c.FormValue("slug")) template := c.FormValue("template") // convert id to int @@ -123,6 +116,15 @@ func EditCustomPage(c *fiber.Ctx, db *gorm.DB) error { return c.SendString("Missing required fields: id, title, content, slug, template") } + if !customPageTemplates[template] { + return c.SendString("Unknown template: " + template) + } + + var existingPage model.CustomPage + if err := db.Where("slug = ? AND id <> ?", slug, idInt).First(&existingPage).Error; err == nil { + return c.SendString("Slug already exists: " + slug) + } + customPage := model.CustomPage{ Title: title, Content: content, @@ -135,7 +137,7 @@ func EditCustomPage(c *fiber.Ctx, db *gorm.DB) error { return c.Status(fiber.StatusInternalServerError).SendString(result.Error.Error()) } - return ShowToastError(c, "Custom Page Updated") + return ShowToast(c, "Custom Page Updated") } func DeleteCustomPage(c *fiber.Ctx, db *gorm.DB) error { @@ -154,5 +156,5 @@ func DeleteCustomPage(c *fiber.Ctx, db *gorm.DB) error { return c.Status(fiber.StatusInternalServerError).SendString(result.Error.Error()) } - return ShowToastError(c, "Custom Page Deleted") + return ShowToast(c, "Custom Page Deleted") } diff --git a/handler/fileUploadHandlers.go b/handler/fileUploadHandlers.go index fd88097..38582c9 100644 --- a/handler/fileUploadHandlers.go +++ b/handler/fileUploadHandlers.go @@ -1,19 +1,21 @@ package handlers import ( + "crypto/rand" "goxcms/model" - "math" - "math/rand" + "io" + "mime/multipart" + "net/http" "os" "path/filepath" - "strconv" + "strings" "github.com/gofiber/fiber/v2" + "github.com/spf13/viper" "gorm.io/gorm" ) const ( - MaxFileSize = 10 * 1024 * 1024 // 10 MB UploadDir = "./static/uploads" RandomFilenameSize = 10 ) @@ -26,88 +28,95 @@ var ( ".gif": true, } - AllowedContentTypes = []string{ - "image/jpeg", - "image/png", - "image/gif", - "image/jpg", + // AllowedContentTypes are checked against the sniffed file content, not + // the client-supplied Content-Type header. + AllowedContentTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, } ) +// maxUploadSize returns the upload limit in bytes from upload.max_size_mb. +func maxUploadSize() int64 { + return viper.GetInt64("upload.max_size_mb") * 1024 * 1024 +} + func UploadFile(c *fiber.Ctx, db *gorm.DB) error { file, err := c.FormFile("file") if err != nil { return c.Status(fiber.StatusBadRequest).SendString("Cannot read file: " + err.Error()) } - // Validate file size - if file.Size > MaxFileSize { + if file.Size > maxUploadSize() { return c.Status(fiber.StatusBadRequest).SendString("File size exceeds the limit") } - // Validate file type based on extension - fileType := filepath.Ext(file.Filename) - if !isValidFileType(fileType) { + fileType := strings.ToLower(filepath.Ext(file.Filename)) + if !AllowedFileTypes[fileType] { return c.Status(fiber.StatusBadRequest).SendString("File type not allowed") } - // Check file content type from header - if !isValidContentType(file.Header.Get("Content-Type")) { + contentType, err := sniffContentType(file) + if err != nil { + return c.Status(fiber.StatusBadRequest).SendString("Cannot read file: " + err.Error()) + } + if !AllowedContentTypes[contentType] { return c.Status(fiber.StatusBadRequest).SendString("Invalid content type") } - // Generate a random string for the filename to ensure uniqueness - randomString := generateRandomFilenameString(RandomFilenameSize) - oldFilename := file.Filename - filename := oldFilename[:len(oldFilename)-len(fileType)] + "_" + randomString + fileType + // Add a random suffix so uploads never overwrite each other. + baseName := strings.TrimSuffix(filepath.Base(file.Filename), filepath.Ext(file.Filename)) + filename := baseName + "_" + randomFilenameString(RandomFilenameSize) + fileType - // Save the file to the disk + if err := os.MkdirAll(UploadDir, 0o755); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Cannot create upload directory") + } - // Save the file to the disk if err := c.SaveFile(file, filepath.Join(UploadDir, filename)); err != nil { return c.Status(fiber.StatusInternalServerError).SendString("Cannot save file to disk") } - // Represent the file in the database fileModel := model.File{ - Name: filename, - Path: "/static/uploads/" + filename, // Save the path to the file + Name: filename, + Extension: fileType, + Path: "/static/uploads/" + filename, } - // Save file reference to the database if err := db.Create(&fileModel).Error; err != nil { return c.Status(fiber.StatusInternalServerError).SendString("Cannot save file to database") } - // Respond with success message - c.SendStatus(fiber.StatusOK) - ShowToast(c, "File uploaded successfully") - return nil + return ShowToast(c, "File uploaded successfully") } -// Check if file type is allowed -func isValidFileType(fileType string) bool { - return AllowedFileTypes[fileType] -} +// sniffContentType detects the MIME type from the first 512 bytes of the file. +func sniffContentType(fh *multipart.FileHeader) (string, error) { + f, err := fh.Open() + if err != nil { + return "", err + } + defer f.Close() -// Check if content type is allowed -func isValidContentType(contentType string) bool { - for _, validType := range AllowedContentTypes { - if validType == contentType { - return true - } + head := make([]byte, 512) + n, err := io.ReadFull(f, head) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return "", err } - return false + return http.DetectContentType(head[:n]), nil } -// Generate random filename string -func generateRandomFilenameString(length int) string { +// randomFilenameString returns a random alphanumeric string. +func randomFilenameString(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - randomString := make([]byte, length) - for i := range randomString { - randomString[i] = charset[rand.Intn(len(charset))] + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + panic(err) + } + for i := range b { + b[i] = charset[int(b[i])%len(charset)] } - return string(randomString) + return string(b) } func DeleteFile(c *fiber.Ctx, db *gorm.DB) error { @@ -138,12 +147,8 @@ func DeleteFile(c *fiber.Ctx, db *gorm.DB) error { // SearchFiles searches for files based on a query and returns the results. func SearchFiles(c *fiber.Ctx, db *gorm.DB) error { searchQuery := c.Query("query", "") - page := c.Query("page", "1") // validate page number - pageInt, err := strconv.Atoi(page) - if err != nil || pageInt < 1 { - pageInt = 1 - } + pageInt := queryPage(c) pageSize := 20 var files []model.File @@ -169,7 +174,7 @@ func SearchFiles(c *fiber.Ctx, db *gorm.DB) error { Find(&files) } - totalPages := int(math.Ceil(float64(totalMatchingCount) / float64(pageSize))) + totalPages := pageCount(totalMatchingCount, pageSize) return c.Render("partials/file-manager", fiber.Map{ "Files": files, diff --git a/handler/menuHandlers.go b/handler/menuHandlers.go index 3b3af41..7c43695 100644 --- a/handler/menuHandlers.go +++ b/handler/menuHandlers.go @@ -3,7 +3,6 @@ package handlers import ( "fmt" "goxcms/model" - "math" "sort" "strconv" @@ -272,14 +271,10 @@ func EditMenuItemView(c *fiber.Ctx, db *gorm.DB) error { func SearchMenuAdminTable(c *fiber.Ctx, db *gorm.DB) error { var menus []model.Menu searchQuery := c.Query("query") - page := c.Query("page", "1") pageSize := 10 // Default page size // Convert page string to int for pagination calculation - pageInt, err := strconv.Atoi(page) - if err != nil || pageInt < 1 { - pageInt = 1 - } + pageInt := queryPage(c) // Search for menus with pagination and order them by position // Ensure to order both menus and their items by their position @@ -300,7 +295,7 @@ func SearchMenuAdminTable(c *fiber.Ctx, db *gorm.DB) error { db.Model(&model.Menu{}). Where("title LIKE ?", "%"+searchQuery+"%"). Count(&totalMatchingCount) - totalPages := int(math.Ceil(float64(totalMatchingCount) / float64(pageSize))) + totalPages := pageCount(totalMatchingCount, pageSize) return c.Render("admin/table/menu-table", fiber.Map{ "Menus": menus, // No need to separate and recombine by primary status for ordering diff --git a/handler/pagination.go b/handler/pagination.go new file mode 100644 index 0000000..66f6188 --- /dev/null +++ b/handler/pagination.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "math" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// queryPage returns the 1-based "page" query or form value, defaulting to 1. +func queryPage(c *fiber.Ctx) int { + page, err := strconv.Atoi(c.Query("page", c.FormValue("page", "1"))) + if err != nil || page < 1 { + return 1 + } + return page +} + +// pageCount returns how many pages of pageSize items are needed for count +// items. +func pageCount(count int64, pageSize int) int { + return int(math.Ceil(float64(count) / float64(pageSize))) +} diff --git a/handler/pagination_test.go b/handler/pagination_test.go new file mode 100644 index 0000000..a9d311b --- /dev/null +++ b/handler/pagination_test.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestPageCount(t *testing.T) { + cases := []struct { + count int64 + pageSize int + want int + }{ + {0, 10, 0}, + {1, 10, 1}, + {10, 10, 1}, + {11, 10, 2}, + {25, 10, 3}, + } + for _, tc := range cases { + if got := pageCount(tc.count, tc.pageSize); got != tc.want { + t.Errorf("pageCount(%d, %d) = %d, want %d", tc.count, tc.pageSize, got, tc.want) + } + } +} + +func TestQueryPage(t *testing.T) { + cases := map[string]int{ + "/": 1, + "/?page=3": 3, + "/?page=0": 1, + "/?page=-2": 1, + "/?page=x": 1, + } + + for target, want := range cases { + app := fiber.New() + var got int + app.Get("/", func(c *fiber.Ctx) error { + got = queryPage(c) + return nil + }) + if _, err := app.Test(httptest.NewRequest("GET", target, nil)); err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("queryPage(%q) = %d, want %d", target, got, want) + } + } +} diff --git a/handler/settingsHandlers.go b/handler/settingsHandlers.go index c540d25..b37feee 100644 --- a/handler/settingsHandlers.go +++ b/handler/settingsHandlers.go @@ -2,8 +2,12 @@ package handlers import ( "goxcms/model" + "strconv" + "sync" + "time" "github.com/gofiber/fiber/v2" + "github.com/spf13/viper" "gorm.io/gorm" ) @@ -26,11 +30,12 @@ func UpdateSettings(c *fiber.Ctx, db *gorm.DB) error { return c.Status(fiber.StatusInternalServerError).SendString("Failed to update settings") } - // Update settings in locals - c.Locals("Settings", MapSettingsToMap(updatedSettings)) + // Refresh the cached settings and this request's copy. + ReloadSiteSettings(db) + c.Locals("Settings", SiteSettings(db)) // Show success message - ShowToastError(c, "Settings updated successfully, please clear your cache to see the changes") + ShowToast(c, "Settings updated successfully") return c.Status(fiber.StatusOK).SendString("Settings updated successfully") } @@ -91,5 +96,45 @@ func MapSettingsToMap(settings model.BasicWebsiteInfo) map[string]string { "TimeZone": settings.TimeZone, "SelectedTheme": settings.SelectedTheme, "ContainerClass": settings.ContainerClass, + "CaptchaEnabled": strconv.FormatBool(viper.GetBool("captcha.enabled")), + "CaptchaSiteKey": viper.GetString("captcha.public_key"), } } + +// settingsTTL bounds how stale cached settings can get in another process +// (prefork or multiple instances) after an update. +const settingsTTL = 30 * time.Second + +var siteSettings struct { + sync.RWMutex + values map[string]string + loadedAt time.Time +} + +// SiteSettings returns the website settings used by every page, loading them +// from the database at most once per settingsTTL. The returned map is shared +// and must not be modified. +func SiteSettings(db *gorm.DB) map[string]string { + siteSettings.RLock() + values, fresh := siteSettings.values, time.Since(siteSettings.loadedAt) < settingsTTL + siteSettings.RUnlock() + + if values != nil && fresh { + return values + } + return ReloadSiteSettings(db) +} + +// ReloadSiteSettings reads the settings from the database into the cache. +func ReloadSiteSettings(db *gorm.DB) map[string]string { + var settings model.BasicWebsiteInfo + db.First(&settings) + values := MapSettingsToMap(settings) + + siteSettings.Lock() + siteSettings.values = values + siteSettings.loadedAt = time.Now() + siteSettings.Unlock() + + return values +} diff --git a/handler/tagHandlers.go b/handler/tagHandlers.go index 2cb9cc4..32dbbf5 100644 --- a/handler/tagHandlers.go +++ b/handler/tagHandlers.go @@ -7,8 +7,6 @@ import ( "github.com/gofiber/fiber/v2" - "math" - "gorm.io/gorm" ) @@ -55,7 +53,7 @@ func BlogTagPage(c *fiber.Ctx, db *gorm.DB) error { Where("post_tags.tag_id = ? AND posts.published = ?", tag.ID, true). Count(&totalPosts) - totalPages := int(math.Ceil(float64(totalPosts) / float64(postsPerPage))) + totalPages := pageCount(totalPosts, postsPerPage) if pageNumber > totalPages { return c.Redirect("/blog/tag/" + slug + "/1") @@ -85,14 +83,10 @@ func SearchTag(c *fiber.Ctx, db *gorm.DB) error { var tags []model.Tag searchQuery := c.Query("query") - page := c.Query("page", "1") pageSize := 10 // Default page size // Convert page string to int - pageInt, err := strconv.Atoi(page) - if err != nil || pageInt < 1 { - pageInt = 1 - } + pageInt := queryPage(c) // Search for tags with pagination db.Where("name LIKE ?", "%"+searchQuery+"%"). @@ -116,7 +110,7 @@ func SearchTag(c *fiber.Ctx, db *gorm.DB) error { db.Model(&model.Tag{}). Where("name LIKE ?", "%"+searchQuery+"%"). Count(&totalMatchingCount) - totalPages := int(math.Ceil(float64(totalMatchingCount) / float64(pageSize))) + totalPages := pageCount(totalMatchingCount, pageSize) return c.Render("admin/table/tag-table", fiber.Map{ "Tags": tags, @@ -175,5 +169,5 @@ func DeleteTag(c *fiber.Ctx, db *gorm.DB) error { return ShowToastError(c, "Error deleting tag") } - return ShowToastError(c, "Tag with ID "+id+" deleted successfully") + return ShowToast(c, "Tag with ID "+id+" deleted successfully") } diff --git a/handler/toastHandlers.go b/handler/toastHandlers.go index 339cb5a..0c81416 100644 --- a/handler/toastHandlers.go +++ b/handler/toastHandlers.go @@ -6,20 +6,21 @@ import ( "github.com/gofiber/fiber/v2" ) -func ShowToastError(c *fiber.Ctx, message string) error { - messageMap := map[string]string{"showToast": message} - messageBytes, _ := json.Marshal(messageMap) +// setToast sends an HX-Trigger header that views/main.html turns into a toast. +func setToast(c *fiber.Ctx, event, message string) error { + messageBytes, _ := json.Marshal(map[string]string{event: message}) c.Set("HX-Trigger", string(messageBytes)) c.Status(fiber.StatusOK) return nil } +// ShowToast shows an informational toast. func ShowToast(c *fiber.Ctx, message string) error { - messageMap := map[string]string{"showToast": message} - messageBytes, _ := json.Marshal(messageMap) - c.Set("HX-Trigger", string(messageBytes)) - c.Status(fiber.StatusOK) + return setToast(c, "showToast", message) +} - return nil +// ShowToastError shows an error toast. +func ShowToastError(c *fiber.Ctx, message string) error { + return setToast(c, "ShowToastError", message) } diff --git a/handler/userHandlers.go b/handler/userHandlers.go index d3f5fb4..9cca053 100644 --- a/handler/userHandlers.go +++ b/handler/userHandlers.go @@ -1,71 +1,65 @@ -package handlers - -import ( - "goxcms/model" - "math" - "strconv" - - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" -) - -func SearchUsers(c *fiber.Ctx, db *gorm.DB) error { - var users []model.User - searchQuery := c.Query("query") - page := c.Query("page", "1") - pageSize := 10 - - pageInt, err := strconv.Atoi(page) - - if err != nil || pageInt < 1 { - pageInt = 1 - } - - db.Where("username LIKE ?", "%"+searchQuery+"%"). - Limit(pageSize). - Offset((pageInt - 1) * pageSize). - Find(&users) - - var count int64 - db.Model(&model.User{}). - Where("username LIKE ?", "%"+searchQuery+"%"). - Count(&count) - totalPages := int(math.Ceil(float64(count) / float64(pageSize))) - - return c.Render("admin/table/user-table", fiber.Map{ - "Users": users, - "TotalPages": totalPages, - "CurrentPage": pageInt, - "SearchQuery": searchQuery, - }) -} - -func DeleteUser(c *fiber.Ctx, db *gorm.DB) error { - id := c.Params("id") - var user model.User - - current_user := c.Locals("user").(model.User) - - idUint, err := strconv.ParseUint(id, 10, 64) - if err != nil { - return err - } - - if current_user.ID == uint(idUint) { - return c.Status(fiber.StatusBadRequest).SendString("You cannot delete yourself") - } - - if err := db.First(&user, id).Error; err != nil { - return err - } - - if err := db.Delete(&user).Error; err != nil { - return err - } - - c.Status(fiber.StatusOK) - - ShowToast(c, "User deleted successfully") - - return nil -} +package handlers + +import ( + "goxcms/model" + "strconv" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +func SearchUsers(c *fiber.Ctx, db *gorm.DB) error { + var users []model.User + searchQuery := c.Query("query") + pageSize := 10 + + pageInt := queryPage(c) + + db.Where("username LIKE ?", "%"+searchQuery+"%"). + Limit(pageSize). + Offset((pageInt - 1) * pageSize). + Find(&users) + + var count int64 + db.Model(&model.User{}). + Where("username LIKE ?", "%"+searchQuery+"%"). + Count(&count) + totalPages := pageCount(count, pageSize) + + return c.Render("admin/table/user-table", fiber.Map{ + "Users": users, + "TotalPages": totalPages, + "CurrentPage": pageInt, + "SearchQuery": searchQuery, + }) +} + +func DeleteUser(c *fiber.Ctx, db *gorm.DB) error { + id := c.Params("id") + var user model.User + + current_user, _ := CurrentUser(c) + + idUint, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return err + } + + if current_user.ID == uint(idUint) { + return c.Status(fiber.StatusBadRequest).SendString("You cannot delete yourself") + } + + if err := db.First(&user, id).Error; err != nil { + return err + } + + if err := db.Delete(&user).Error; err != nil { + return err + } + + c.Status(fiber.StatusOK) + + ShowToast(c, "User deleted successfully") + + return nil +} diff --git a/main.go b/main.go index 3a85421..4860e81 100644 --- a/main.go +++ b/main.go @@ -2,17 +2,18 @@ package main import ( "goxcms/database" + handlers "goxcms/handler" "goxcms/plugin_system" "goxcms/routes" "goxcms/utils" "log" - "net/http" + "strings" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/csrf" - "github.com/gofiber/fiber/v2/middleware/filesystem" + "github.com/gofiber/fiber/v2/middleware/recover" "github.com/spf13/viper" "gorm.io/gorm" ) @@ -45,27 +46,60 @@ func setupFiberApp(db *gorm.DB) *fiber.App { engine.Debug(buildMode != "production") engine.Reload(buildMode != "production") + trustedProxies := viper.GetStringSlice("server.trusted_proxies") + app := fiber.New(fiber.Config{ Views: engine, Prefork: viper.GetBool("server.prefork"), CompressedFileSuffix: ".fiber.gz", BodyLimit: viper.GetInt("server.body_limit") * 1024 * 1024, - ProxyHeader: "X-Forwarded-For", + ProxyHeader: fiber.HeaderXForwardedFor, EnableTrustedProxyCheck: true, + TrustedProxies: trustedProxies, DisableStartupMessage: false, }) - fsConfig := filesystem.Config{ - Root: http.Dir("./static"), - Browse: false, - Index: "index.html", - MaxAge: 3600, - } + // Middleware order matters: Fiber runs app.Use handlers in registration + // order, and anything registered after the 404 catch-all never runs. + + // Recover from handler panics so one bad request cannot take the server down. + app.Use(recover.New()) + + // Static files are served before the auth and settings middleware so they + // do not cost a database round trip per asset. + app.Static("/static", "./static", fiber.Static{ + Compress: true, + ByteRange: true, + Browse: false, + CacheDuration: 24 * time.Hour, + MaxAge: 3600, + }) + + app.Use(cors.New(cors.Config{ + AllowCredentials: viper.GetBool("cors.allow_credentials"), + AllowOrigins: strings.Join(corsOrigins(), ","), + })) store := utils.SetupStore(app) utils.SetupRateLimiter(app, store) + // CSRF: the token lives in the csrf_ cookie and must be echoed back in the + // X-Csrf-Token header on unsafe requests. views/main.html adds the header + // to every HTMX request. + app.Use(csrf.New(csrf.Config{ + KeyLookup: "header:" + csrf.HeaderName, + CookieName: "csrf_", + CookieSameSite: "Lax", + CookieSecure: utils.SecureCookies(), + Expiration: 24 * time.Hour, + Storage: store.Storage, + ErrorHandler: func(c *fiber.Ctx, err error) error { + handlers.ShowToastError(c, "Your session expired, please reload the page and try again") + return c.Status(fiber.StatusForbidden).SendString("Forbidden: invalid CSRF token") + }, + })) + routes.SetupRoutes(app, db, store, engine) pluginsToRegister := plugin_system.PluginList() @@ -76,28 +110,10 @@ func setupFiberApp(db *gorm.DB) *fiber.App { plugin_system.InitializePlugins(app, db, engine) plugin_system.AddPluginManagerRoutes(app, db) - app.Use("/static", filesystem.New(fsConfig)) - - app.Static("/static", "./static", fiber.Static{ - Compress: true, - ByteRange: true, - CacheDuration: 24 * time.Hour, - }) - - csrfMiddleware := csrf.New(csrf.Config{ - KeyLookup: "form:csrf", - CookieName: "csrf", - ContextKey: "csrf", - ErrorHandler: func(c *fiber.Ctx, err error) error { - return c.Status(fiber.StatusForbidden).SendString(err.Error()) - }, - }) - - app.Use(cors.New(cors.Config{ - AllowCredentials: viper.GetBool("cors.allow_credentials"), - AllowOrigins: viper.GetString("cors.allow_origins"), - })) + // Custom pages match any remaining path, so they go after all other routes. + routes.SetupCustomPageRoutes(app, db) + // 404 catch-all: must stay last. app.Use(func(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).Render("404", fiber.Map{ "Title": "404 - Page Not Found", @@ -105,15 +121,19 @@ func setupFiberApp(db *gorm.DB) *fiber.App { }, "main") }) - app.Use(csrfMiddleware) - - app.Use(func(c *fiber.Ctx) error { - c.Locals("captcha_enabled", viper.GetBool("captcha.enabled")) - return c.Next() - }) - - utils.GenerateSiteMap(db) utils.CreateBasicWebsiteInfo(db) return app } + +// corsOrigins returns the configured allowed origins, defaulting to the site's +// own URL. +func corsOrigins() []string { + origins := viper.GetStringSlice("cors.allowed_origins") + if len(origins) == 0 { + if url := viper.GetString("app.url"); url != "" { + origins = []string{url} + } + } + return origins +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ceccbd3 --- /dev/null +++ b/main_test.go @@ -0,0 +1,464 @@ +package main + +import ( + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "goxcms/database" + handlers "goxcms/handler" + "goxcms/model" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/viper" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +const testSecret = "test-secret-0123456789abcdef0123456789" + +// newTestApp builds the full application against a fresh in-memory database. +func newTestApp(t *testing.T) (*fiber.App, *gorm.DB) { + t.Helper() + + viper.Reset() + viper.Set("build.mode", "development") + viper.Set("database.driver", "sqlite") + viper.Set("database.sqlite.dsn", "file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared") + viper.Set("app.secret", testSecret) + viper.Set("app.url", "http://localhost:3000") + viper.Set("server.body_limit", 10) + viper.Set("captcha.enabled", false) + viper.Set("ratelimiter.enabled", false) + viper.Set("cors.allowed_origins", []string{"http://localhost:3000"}) + + db := database.InitDB() + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + }) + + return setupFiberApp(db), db +} + +func createUser(t *testing.T, db *gorm.DB, username string, role uint) model.User { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + user := model.User{Username: username, Password: string(hash), RoleID: role, FirstName: "Test", LastName: "User"} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + return user +} + +func authCookie(t *testing.T, userID uint) *http.Cookie { + t.Helper() + token, err := handlers.GenerateJWT(userID) + if err != nil { + t.Fatal(err) + } + return &http.Cookie{Name: "jwt", Value: token} +} + +func do(t *testing.T, app *fiber.App, method, path string, cookies ...*http.Cookie) (*http.Response, string) { + t.Helper() + req := httptest.NewRequest(method, path, nil) + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := app.Test(req, -1) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp, string(body) +} + +func isDenied(status int) bool { + return status == fiber.StatusFound || status == fiber.StatusUnauthorized || status == fiber.StatusForbidden +} + +var adminRoutes = []struct{ method, path string }{ + {"GET", "/admin"}, + {"GET", "/admin-settings"}, + {"GET", "/admin/post/add"}, + {"POST", "/admin/post/add"}, + {"GET", "/admin/post/edit/1"}, + {"POST", "/admin/post/edit"}, + {"GET", "/add-custompage"}, + {"GET", "/edit-custompage/1"}, + {"GET", "/search-posts"}, + {"GET", "/search-tags"}, + {"GET", "/search-menu"}, + {"GET", "/search-categories"}, + {"GET", "/search-users"}, + {"GET", "/search-comments"}, + {"GET", "/search-custompages"}, + {"GET", "/search-files"}, + {"POST", "/update-settings"}, + {"POST", "/toggle-post-status"}, + {"DELETE", "/delete-post/1"}, + {"DELETE", "/delete-user/1"}, + {"POST", "/upload-file"}, + {"POST", "/admin/plugins/enable/ShopPlugin"}, +} + +func TestAdminRoutesRejectAnonymous(t *testing.T) { + app, _ := newTestApp(t) + + for _, r := range adminRoutes { + resp, _ := do(t, app, r.method, r.path) + if !isDenied(resp.StatusCode) { + t.Errorf("anonymous %s %s: got status %d, want 302/401/403", r.method, r.path, resp.StatusCode) + } + } +} + +func TestAdminRoutesRejectRegularUser(t *testing.T) { + app, db := newTestApp(t) + user := createUser(t, db, "regular", model.RoleUser) + cookie := authCookie(t, user.ID) + + for _, r := range adminRoutes { + resp, _ := do(t, app, r.method, r.path, cookie) + if !isDenied(resp.StatusCode) { + t.Errorf("regular user %s %s: got status %d, want 302/401/403", r.method, r.path, resp.StatusCode) + } + } +} + +func TestAdminCanOpenAdminPanel(t *testing.T) { + app, db := newTestApp(t) + admin := createUser(t, db, "boss", model.RoleAdmin) + + resp, _ := do(t, app, "GET", "/admin", authCookie(t, admin.ID)) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("admin GET /admin: got status %d, want 200", resp.StatusCode) + } +} + +func TestForgedJWTIsRejected(t *testing.T) { + sign := func(t *testing.T, method jwt.SigningMethod, key interface{}, claims jwt.MapClaims) string { + token, err := jwt.NewWithClaims(method, claims).SignedString(key) + if err != nil { + t.Fatal(err) + } + return token + } + exp := func() int64 { return time.Now().Add(time.Hour).Unix() } + + cases := map[string]func(t *testing.T, userID uint) string{ + "empty key": func(t *testing.T, id uint) string { + return sign(t, jwt.SigningMethodHS256, []byte(""), jwt.MapClaims{"user_id": id, "exp": exp()}) + }, + "wrong key": func(t *testing.T, id uint) string { + return sign(t, jwt.SigningMethodHS256, []byte("not-the-secret"), jwt.MapClaims{"user_id": id, "exp": exp()}) + }, + "alg none": func(t *testing.T, id uint) string { + return sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, jwt.MapClaims{"user_id": id, "exp": exp()}) + }, + "no expiry": func(t *testing.T, id uint) string { + return sign(t, jwt.SigningMethodHS256, []byte(testSecret), jwt.MapClaims{"user_id": id}) + }, + "expired": func(t *testing.T, id uint) string { + return sign(t, jwt.SigningMethodHS256, []byte(testSecret), jwt.MapClaims{"user_id": id, "exp": time.Now().Add(-time.Hour).Unix()}) + }, + } + + for name, forge := range cases { + t.Run(name, func(t *testing.T) { + // A fresh app per case, so no state from one request can mask another. + app, db := newTestApp(t) + admin := createUser(t, db, "boss", model.RoleAdmin) + + resp, _ := do(t, app, "GET", "/admin", &http.Cookie{Name: "jwt", Value: forge(t, admin.ID)}) + if !isDenied(resp.StatusCode) { + t.Errorf("got status %d, want the token to be rejected", resp.StatusCode) + } + }) + } +} + +func TestAdminResponsesAreNotCachedForAnonymousUsers(t *testing.T) { + app, db := newTestApp(t) + admin := createUser(t, db, "boss", model.RoleAdmin) + + for _, path := range []string{"/admin", "/search-users"} { + if resp, _ := do(t, app, "GET", path, authCookie(t, admin.ID)); resp.StatusCode != fiber.StatusOK { + t.Fatalf("admin GET %s: got status %d", path, resp.StatusCode) + } + resp, body := do(t, app, "GET", path) + if !isDenied(resp.StatusCode) { + t.Errorf("anonymous GET %s after admin visit: got status %d", path, resp.StatusCode) + } + if strings.Contains(body, "boss") { + t.Errorf("anonymous GET %s leaked admin content", path) + } + } +} + +func TestUnpublishedPostIs404ForAnonymous(t *testing.T) { + app, db := newTestApp(t) + post := model.Post{Title: "Draft", Content: "secret draft", Slug: "draft", Published: false} + if err := db.Create(&post).Error; err != nil { + t.Fatal(err) + } + + resp, body := do(t, app, "GET", "/blog/post/draft") + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("got status %d, want 404", resp.StatusCode) + } + if strings.Contains(body, "secret draft") { + t.Fatal("unpublished post content leaked") + } + + // The server must still be serving requests afterwards. + if resp, _ := do(t, app, "GET", "/"); resp.StatusCode != fiber.StatusOK { + t.Fatalf("GET / after draft request: got status %d", resp.StatusCode) + } +} + +// csrfCookie fetches a page to obtain the CSRF cookie that unsafe requests +// must echo back in the X-Csrf-Token header. +func csrfCookie(t *testing.T, app *fiber.App, cookies ...*http.Cookie) *http.Cookie { + t.Helper() + req := httptest.NewRequest("GET", "/login", nil) + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := app.Test(req, -1) + if err != nil { + t.Fatal(err) + } + for _, c := range resp.Cookies() { + if c.Name == "csrf_" { + return c + } + } + t.Fatal("no csrf_ cookie set on GET /login") + return nil +} + +func postForm(t *testing.T, app *fiber.App, path string, form url.Values, csrf *http.Cookie, cookies ...*http.Cookie) (*http.Response, string) { + t.Helper() + req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + if csrf != nil { + req.AddCookie(csrf) + req.Header.Set("X-Csrf-Token", csrf.Value) + } + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := app.Test(req, -1) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp, string(body) +} + +func TestCSRFTokenRequiredForUnsafeRequests(t *testing.T) { + app, db := newTestApp(t) + admin := createUser(t, db, "boss", model.RoleAdmin) + auth := authCookie(t, admin.ID) + post := model.Post{Title: "Hello", Content: "world", Slug: "hello"} + if err := db.Create(&post).Error; err != nil { + t.Fatal(err) + } + form := url.Values{"id": {strconv.Itoa(int(post.ID))}} + + if resp, _ := postForm(t, app, "/toggle-post-status", form, nil, auth); resp.StatusCode != fiber.StatusForbidden { + t.Fatalf("POST without CSRF token: got status %d, want 403", resp.StatusCode) + } + + token := csrfCookie(t, app, auth) + if resp, _ := postForm(t, app, "/toggle-post-status", form, token, auth); resp.StatusCode != fiber.StatusOK { + t.Fatalf("POST with CSRF token: got status %d, want 200", resp.StatusCode) + } + + db.First(&post, post.ID) + if !post.Published { + t.Fatal("post was not published") + } +} + +func TestLoginSetsSessionCookie(t *testing.T) { + app, db := newTestApp(t) + createUser(t, db, "alice", model.RoleUser) + token := csrfCookie(t, app) + + resp, _ := postForm(t, app, "/login", url.Values{"username": {"alice"}, "password": {"password123"}}, token) + if resp.StatusCode != fiber.StatusOK || resp.Header.Get("HX-Redirect") != "/" { + t.Fatalf("login: got status %d, HX-Redirect %q", resp.StatusCode, resp.Header.Get("HX-Redirect")) + } + + var jwtCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "jwt" { + jwtCookie = c + } + } + if jwtCookie == nil || !jwtCookie.HttpOnly { + t.Fatalf("login did not set an HttpOnly jwt cookie: %+v", jwtCookie) + } + + resp, _ = postForm(t, app, "/login", url.Values{"username": {"alice"}, "password": {"wrong"}}, token) + if resp.StatusCode != fiber.StatusUnauthorized { + t.Fatalf("login with wrong password: got status %d, want 401", resp.StatusCode) + } +} + +func TestRegisterWorksWithCaptchaDisabled(t *testing.T) { + app, db := newTestApp(t) + token := csrfCookie(t, app) + + form := url.Values{ + "username": {"newuser"}, + "password": {"secret123"}, + "first_name": {"New"}, + "last_name": {"User"}, + } + resp, body := postForm(t, app, "/register", form, token) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("register: got status %d, body %q", resp.StatusCode, body) + } + + var user model.User + if err := db.Where("username = ?", "newuser").First(&user).Error; err != nil { + t.Fatalf("user not created: %v", err) + } + if user.RoleID != model.RoleUser { + t.Fatalf("new user has role %d, want %d", user.RoleID, model.RoleUser) + } +} + +func TestPostSlugMustBeUnique(t *testing.T) { + app, db := newTestApp(t) + admin := createUser(t, db, "boss", model.RoleAdmin) + auth := authCookie(t, admin.ID) + token := csrfCookie(t, app, auth) + + first := model.Post{Title: "First", Content: "one", Slug: "first"} + second := model.Post{Title: "Second", Content: "two", Slug: "second"} + db.Create(&first) + db.Create(&second) + cat := model.Category{Name: "News", Slug: "news"} + db.Create(&cat) + + form := url.Values{"title": {"Dup"}, "content": {"x"}, "post_slug": {"first"}, "image": {"/img.png"}} + postForm(t, app, "/admin/post/add", form, token, auth) + var count int64 + db.Model(&model.Post{}).Where("slug = ?", "first").Count(&count) + if count != 1 { + t.Fatalf("adding a post with a duplicate slug created it (count %d)", count) + } + + form = url.Values{"id": {strconv.Itoa(int(second.ID))}, "title": {"Second"}, "content": {"two"}, "post_slug": {"first"}, "image": {"/img.png"}} + postForm(t, app, "/admin/post/edit", form, token, auth) + db.First(&second, second.ID) + if second.Slug != "second" { + t.Fatalf("editing a post to a duplicate slug succeeded") + } + + form = url.Values{ + "id": {strconv.Itoa(int(second.ID))}, "title": {"Renamed"}, "content": {"two"}, + "post_slug": {"second-renamed"}, "image": {"/img.png"}, "categories_input": {strconv.Itoa(int(cat.ID))}, + } + if resp, body := postForm(t, app, "/admin/post/edit", form, token, auth); resp.StatusCode != fiber.StatusOK || !strings.Contains(body, "updated") { + t.Fatalf("valid edit failed: %d %q", resp.StatusCode, body) + } + db.Preload("Categories").First(&second, second.ID) + if second.Title != "Renamed" || second.Slug != "second-renamed" || len(second.Categories) != 1 { + t.Fatalf("edit not applied: %+v", second) + } +} + +func TestCustomPagesAreServedWithoutRestart(t *testing.T) { + app, db := newTestApp(t) + admin := createUser(t, db, "boss", model.RoleAdmin) + auth := authCookie(t, admin.ID) + token := csrfCookie(t, app, auth) + + form := url.Values{"title": {"About"}, "content": {"

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("
Product
") - htmlMessage += template.HTML("
Your product
") return c.Render("plugins/shop_plugin/product", fiber.Map{ "Title": "Product", "Product": product, @@ -377,12 +345,10 @@ func (p *ShopPlugin) Setup(app *fiber.App, db *gorm.DB, engine *html.Engine) err }, "main") }) - println("ShopPlugin setup done") return nil } func (p *ShopPlugin) Teardown() error { - fmt.Println("ShopPlugin teardown") return nil } @@ -406,13 +372,12 @@ func (p *ShopPlugin) Settings(db *gorm.DB) map[string]string { plugin := &model.Plugin{} db.Where("name = ?", PluginName).First(plugin) - fmt.Println(PluginName, "settings:", plugin.Settings) settings := plugin.Settings if len(settings) == 0 { 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 p.DefaultSettings() } @@ -425,7 +390,7 @@ func (p *ShopPlugin) Settings(db *gorm.DB) map[string]string { mappedSettings := make(map[string]string) err := json.Unmarshal([]byte(settings), &mappedSettings) if err != nil { - fmt.Println("Error unmarshaling settings:", err) + log.Printf("shop: unmarshaling settings: %v", err) return p.DefaultSettings() } @@ -435,6 +400,5 @@ func (p *ShopPlugin) Settings(db *gorm.DB) map[string]string { func (p *ShopPlugin) Enabled(db *gorm.DB) bool { plugin := &model.Plugin{} db.Where("name = ?", PluginName).First(plugin) - fmt.Println(PluginName, "enabled status:", plugin.Enabled) return plugin.Enabled } diff --git a/routes/admin.go b/routes/admin.go new file mode 100644 index 0000000..3f1e351 --- /dev/null +++ b/routes/admin.go @@ -0,0 +1,286 @@ +package routes + +import ( + "html/template" + + handlers "goxcms/handler" + "goxcms/model" + "goxcms/plugin_system" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" + "gorm.io/gorm" +) + +// setupAdminRoutes registers the admin panel and its HTMX endpoints. Every +// route here must be guarded by handlers.IsAdmin. +func setupAdminRoutes(app *fiber.App, db *gorm.DB, engine *html.Engine) { + app.Post("/clear-cache", handlers.IsAdmin, func(c *fiber.Ctx) error { + + // Reload cached settings and templates. This deliberately does not + // touch the session store, which also holds every user's CSRF token. + handlers.ReloadSiteSettings(db) + if err := engine.Load(); err != nil { + return handlers.ShowToastError(c, "Failed to reload templates: "+err.Error()) + } + + handlers.ShowToast(c, "Cache cleared successfully") + + return nil + }) + + app.Get("/admin-settings", 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.IsAdmin, func(c *fiber.Ctx) error { + + return handlers.UpdateSettings(c, db) + }) + + app.Post("/toggle-post-status", handlers.IsAdmin, func(c *fiber.Ctx) error { + + return handlers.TogglePostStatus(c, db) + }) + + app.Get("/search-posts", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AdminSearchPosts(c, db) + }) + + app.Delete("/delete-post/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AdminDeletePost(c, db) + }) + + app.Get("/search-tags", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.SearchTag(c, db) + }) + + app.Delete("/delete-tag", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteTag(c, db) + }) + + /// add tag + app.Post("/add-tag", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AddTag(c, db) + }) + + /// add menu + app.Post("/add-menu", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AddMenu(c, db) + + }) + + // add menu item to menu + app.Post("/add-menu-item", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AddMenuItem(c, db) + }) + + // delete menu item + app.Delete("/delete-menu-item/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteMenuItem(c, db) + }) + + // delete menu + app.Delete("/delete-menu/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteMenu(c, db) + }) + + // edit menu + app.Post("/edit-menu/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.EditMenu(c, db) + }) + + /// remove submenu from menu + app.Delete("/remove-submenu/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.RemoveSubmenuFromMenu(c, db) + }) + + // edit menu item + app.Post("/edit-menu-item/:id", 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.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.IsAdmin, func(c *fiber.Ctx) error { + return handlers.EditMenuItemView(c, db) + }) + + app.Get("/search-menu", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.SearchMenuAdminTable(c, db) + }) + + app.Get("/search-users", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.SearchUsers(c, db) + }) + + app.Get("/search-comments", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.SearchCommentsView(c, db) + }) + + /// toggle comment status# + app.Post("/toggle-comment-status/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.ToggleCommentStatus(c, db) + }) + + /// delete comment + app.Delete("/delete-comment/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteComment(c, db) + }) + + app.Delete("/delete-user/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteUser(c, db) + }) + + app.Get("/search-categories", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.SearchCategories(c, db) + }) + + app.Post("/add-category", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AddCategory(c, db) + }) + + app.Delete("/delete-category", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteCategory(c, db) + }) + + app.Get("/search-custompages", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.SearchCustomPages(c, db) + }) + + app.Post("/add-custompage", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AddCustomPage(c, db) + }) + + app.Get("/add-custompage", handlers.IsAdmin, func(c *fiber.Ctx) error { + return c.Render("page/page_add", fiber.Map{ + "TitleView": "Add Custom Page", + "Settings": c.Locals("Settings"), + }, "main") + }) + + app.Get("/edit-custompage/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + + id, err := c.ParamsInt("id") + if err != nil { + return c.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + var customPage model.CustomPage + if err := db.First(&customPage, id).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return c.Render("page/page_edit", fiber.Map{ + "Title": customPage.Title, + "Content": customPage.Content, + "ID": customPage.ID, + "Slug": customPage.Slug, + "Template": customPage.Template, + "Settings": c.Locals("Settings"), + }, "main") + }) + + app.Post("/edit-custompage", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.EditCustomPage(c, db) + }) + + app.Delete("/delete-custompage/:id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteCustomPage(c, db) + }) + + app.Get("/search-files", handlers.IsAdmin, func(c *fiber.Ctx) error { + + return handlers.SearchFiles(c, db) + }) + + app.Post("/upload-file", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.UploadFile(c, db) + }) + + app.Delete("/delete-file", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.DeleteFile(c, db) + }) + + app.Get("/admin/post/edit/:post_id", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AdminEditBlogPost(c, db) + }) + + app.Post("/admin/post/edit", handlers.IsAdmin, func(c *fiber.Ctx) error { + return handlers.AdminUpdateBlogPost(c, db) + }) + + app.Get("/admin/post/add", 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", 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 @@ - - - - http://localhost:3000/blog/post/test - - - http://localhost:3000/blog/post/weqweqweqwe - - - http://localhost:3000/blog/post/kawasakininja215 - - - http://localhost:3000/user/2 - - - http://localhost:3000/user/3 - - - http://localhost:3000/user/4 - - - http://localhost:3000/blog/category/infos - - - http://localhost:3000/blog/category/living - - - http://localhost:3000/blog/category/animals - - - http://localhost:3000/blog/category/1234 - - - http://localhost:3000/blog/tag/312312312331231231123 - - - http://localhost:3000/blog/tag/3123123123312331231231123 - - - http://localhost:3000/blog/tag/3121231233123123312331231231123 - - - http://localhost:3000/blog/tag/342341123fffff - - - http://localhost:3000/blog/tag/34fsdfdfd2341123fffff - - - http://localhost:3000/blog/tag/34fsdfdfd23sdfdf41123fffff - - - http://localhost:3000/blog/tag/sdfsdfa34fsdfdfd23sdfdf41123fffff - - - http://localhost:3000/blog/tag/12312312 - - - http://localhost:3000/blog/tag/12312312312312 - - - http://localhost:3000/blog/tag/123123123123asdfsdff12 - - - http://localhost:3000/blog/tag/qweqweqwe - - - http://localhost:3000/blog/tag/we - - - http://localhost:3000/blog/tag/weeqweweqwsdasdasd323123123123123e - - - http://localhost:3000/blog/tag/weeqwe123123123123123weqwsdasdasd323123123123123e - - - http://localhost:3000/blog/tag/fgdfgdfgfgweeqwe123123123123123weqwsdasdasd323123123123123e - - - http://localhost:3000/blog/tag/sdfasdfasdf - - - http://localhost:3000/blog/tag/sd9867324534255fasdfasdf - - - http://localhost:3000/blog/tag/sd98673rrrrrr24534255fasdfasdf - - - http://localhost:3000/blog/tag/wwww - - - http://localhost:3000/blog/tag/edrtfvzgbhjnk - - \ No newline at end of file diff --git a/utils/utils.go b/utils/utils.go index 6da2600..38ff876 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,20 +1,21 @@ package utils import ( - "bufio" + "bytes" + "crypto/rand" + "encoding/xml" "fmt" "goxcms/model" + htmlstd "html" "html/template" "log" + "math/big" "os" "regexp" - "runtime" - "strconv" "strings" "time" "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cache" "github.com/gofiber/fiber/v2/middleware/limiter" "github.com/gofiber/fiber/v2/middleware/session" "github.com/gofiber/storage/redis/v3" @@ -45,7 +46,6 @@ func InitConfig() { viper.SetDefault("upload.max_size_mb", 50) viper.SetDefault("ratelimiter.enabled", false) viper.SetDefault("ratelimiter.max_requests", 10) - viper.SetDefault("cors.allow_origins", "*") viper.SetDefault("redis.enabled", false) viper.SetDefault("redis.host", "localhost") viper.SetDefault("redis.port", 6379) @@ -54,7 +54,6 @@ func InitConfig() { viper.SetDefault("redis.database", 0) viper.SetDefault("redis.pool_size", 10) viper.SetDefault("server.body_limit", 10) - viper.SetDefault("app.hotload_custom_pages", false) viper.SetDefault("captcha.public_key", "") viper.SetDefault("captcha.secret_key", "") viper.SetDefault("captcha.enabled", false) @@ -62,6 +61,47 @@ func InitConfig() { if viper.GetBool("redis.enabled") { log.Println("Redis enabled") } + + validateSecret() +} + +const exampleSecret = "change_this_secret" + +// validateSecret makes sure app.secret, which signs the login JWTs, is set to +// something that is not publicly known. Production refuses to start without +// one; development falls back to a random per-process secret. +func validateSecret() { + secret := viper.GetString("app.secret") + weak := secret == "" || secret == exampleSecret + + if viper.GetString("build.mode") == "production" { + if weak { + log.Fatal("app.secret must be set to a long random value in production (e.g. `openssl rand -hex 32`)") + } + if len(secret) < 32 { + log.Println("WARNING: app.secret is shorter than 32 characters; use a longer random value") + } + return + } + + if weak { + viper.Set("app.secret", randomString(32)) + log.Println("WARNING: app.secret is not set; using a random secret for this process. Logins will not survive a restart.") + } +} + +// randomString returns a URL-safe random string of the given length. +func randomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + out := make([]byte, length) + for i := range out { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + log.Fatalf("Failed to generate random value: %v", err) + } + out[i] = charset[n.Int64()] + } + return string(out) } func SetupEngine() *html.Engine { @@ -72,8 +112,9 @@ func SetupEngine() *html.Engine { return fmt.Sprintf("?v=%d", time.Now().Unix()) }, "truncate": func(s string, length int) string { - if len(s) > length { - return s[:length] + "..." + runes := []rune(s) + if len(runes) > length { + return string(runes[:length]) + "..." } return s }, @@ -103,9 +144,8 @@ func SetupEngine() *html.Engine { } return count }, - "escape": func(s string) template.HTML { - return template.HTML(htmlToPlainText(s)) - }, + // escape converts HTML to plain text; the template escapes the result. + "escape": htmlToPlainText, "unescape": func(s string) template.HTML { return template.HTML(s) }, @@ -139,31 +179,15 @@ func SetupStore(app *fiber.App) *session.Store { store = session.New(session.Config{ Expiration: 24 * time.Hour, CookieHTTPOnly: true, - CookieSecure: !isWindows(), + CookieSecure: SecureCookies(), Storage: redisStorage, }) - - app.Use(cache.New(cache.Config{ - Next: func(c *fiber.Ctx) bool { - return c.Get("X-No-Cache") == "true" - }, - Expiration: 30 * time.Minute, - Storage: redisStorage, - })) } else { store = session.New(session.Config{ Expiration: 24 * time.Hour, CookieHTTPOnly: true, - CookieSecure: !isWindows(), + CookieSecure: SecureCookies(), }) - - app.Use(cache.New(cache.Config{ - Next: func(c *fiber.Ctx) bool { - return c.Get("X-No-Cache") == "true" - }, - Expiration: 30 * time.Minute, - Storage: store.Storage, - })) } if store == nil { @@ -201,72 +225,42 @@ func SetupRateLimiter(app *fiber.App, store *session.Store) { } } -func GenerateSiteMap(db *gorm.DB) { - baseURL := viper.GetString("app.url") - urls := []string{"/", "/blog", "/login", "/register"} - - // Use a single query to fetch all required data - var posts []model.Post - var users []model.User - var categories []model.Category - var tags []model.Tag - var customPages []model.CustomPage - - db.Where("published = ?", true).Find(&posts) - db.Select("id").Find(&users) - db.Select("slug").Find(&categories) - db.Select("slug").Find(&tags) - db.Where("published = ?", true).Select("slug").Find(&customPages) - - // Pre-allocate the urls slice - totalURLs := len(urls) + len(posts) + len(users) + len(categories) + len(tags) + len(customPages) - urls = make([]string, 0, totalURLs) - - for _, post := range posts { - urls = append(urls, "/blog/post/"+post.Slug) - } +// BuildSitemap renders sitemap.xml for all published content. It is built per +// request so new posts and pages show up without a restart. +func BuildSitemap(db *gorm.DB) []byte { + baseURL := strings.TrimSuffix(viper.GetString("app.url"), "/") + paths := []string{"/", "/blog", "/login", "/register"} - for _, user := range users { - urls = append(urls, "/user/"+strconv.FormatUint(uint64(user.ID), 10)) - } + var postSlugs, categorySlugs, tagSlugs, pageSlugs []string + db.Model(&model.Post{}).Where("published = ?", true).Pluck("slug", &postSlugs) + db.Model(&model.Category{}).Pluck("slug", &categorySlugs) + db.Model(&model.Tag{}).Pluck("slug", &tagSlugs) + db.Model(&model.CustomPage{}).Pluck("slug", &pageSlugs) - for _, category := range categories { - urls = append(urls, "/blog/category/"+category.Slug) + for _, slug := range postSlugs { + paths = append(paths, "/blog/post/"+slug) } - - for _, tag := range tags { - urls = append(urls, "/blog/tag/"+tag.Slug) + for _, slug := range categorySlugs { + paths = append(paths, "/blog/category/"+slug) } - - for _, customPage := range customPages { - urls = append(urls, "/"+customPage.Slug) + for _, slug := range tagSlugs { + paths = append(paths, "/blog/tag/"+slug) } - - writeSitemapToFile(baseURL, urls) -} - -func writeSitemapToFile(baseURL string, urls []string) { - filePath := "./static/sitemap.xml" - file, err := os.Create(filePath) - if err != nil { - log.Fatalf("Failed to create sitemap file: %v", err) + for _, slug := range pageSlugs { + paths = append(paths, "/"+slug) } - defer file.Close() - - writer := bufio.NewWriter(file) - defer writer.Flush() - - writer.WriteString(` - -`) - for _, url := range urls { - writer.WriteString(fmt.Sprintf(" \n %s%s\n \n", baseURL, url)) + var buf bytes.Buffer + buf.WriteString(xml.Header) + buf.WriteString(`` + "\n") + for _, path := range paths { + buf.WriteString(" \n ") + xml.EscapeText(&buf, []byte(baseURL+path)) + buf.WriteString("\n \n") } + buf.WriteString("\n") - writer.WriteString("") - - log.Println("Sitemap generated successfully") + return buf.Bytes() } func CreateBasicWebsiteInfo(db *gorm.DB) { @@ -311,45 +305,58 @@ func CreateBasicWebsiteInfo(db *gorm.DB) { createDefaultAdminUser(db) } +// createDefaultAdminUser creates the first administrator on a fresh install. +// The password comes from the ADMIN_PASSWORD environment variable or +// app.admin_password; if neither is set a random one is generated and printed +// once to the log. func createDefaultAdminUser(db *gorm.DB) { var count int64 - db.Model(&model.User{}).Where("username = ?", "admin").Count(&count) + db.Model(&model.User{}).Where("role_id = ?", model.RoleAdmin).Count(&count) + if count > 0 { + return + } - if count == 0 { - hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin1234"), bcrypt.DefaultCost) - if err != nil { - log.Fatalf("Failed to hash password: %v", err) - } + password := os.Getenv("ADMIN_PASSWORD") + if password == "" { + password = viper.GetString("app.admin_password") + } + generated := password == "" + if generated { + password = randomString(20) + } - email := "admin@goxcms.com" - newUser := model.User{ - Username: "admin", - Password: string(hashedPassword), - RoleID: 2, // Assuming 2 is the admin role ID - FirstName: "Admin", - LastName: "User", - Email: &email, - } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("Failed to hash password: %v", err) + } - result := db.Create(&newUser) - if result.Error != nil { - log.Fatalf("Failed to create admin user: %v", result.Error) - } - log.Println("Default admin user created successfully") + email := "admin@goxcms.com" + newUser := model.User{ + Username: "admin", + Password: string(hashedPassword), + RoleID: model.RoleAdmin, + FirstName: "Admin", + LastName: "User", + Email: &email, + } + + if err := db.Create(&newUser).Error; err != nil { + log.Fatalf("Failed to create admin user: %v", err) + } + + if generated { + log.Printf("Default admin user created. Username: admin Password: %s (change it after logging in)", password) + } else { + log.Println("Default admin user created with the configured password") } } -func htmlToPlainText(html string) string { - // Remove HTML tags using a regular expression - re := regexp.MustCompile(`\<[^>]*\>`) - plainText := re.ReplaceAllString(html, "") - // Replace HTML entities with their plain text equivalents - plainText = strings.ReplaceAll(plainText, "&", "&") - plainText = strings.ReplaceAll(plainText, "<", "<") - plainText = strings.ReplaceAll(plainText, ">", ">") - plainText = strings.ReplaceAll(plainText, """, "\"") - plainText = strings.ReplaceAll(plainText, "'", "'") - return plainText +var htmlTagPattern = regexp.MustCompile(`<[^>]*>?`) + +// htmlToPlainText strips tags and decodes entities. The result is plain text +// and must be escaped before it is written into HTML. +func htmlToPlainText(s string) string { + return htmlstd.UnescapeString(htmlTagPattern.ReplaceAllString(s, "")) } func max(a, b int) int { @@ -371,6 +378,8 @@ func gt(a, b int) bool { return a > b } func le(a, b int) bool { return a <= b } func lt(a, b int) bool { return a < b } -func isWindows() bool { - return runtime.GOOS == "windows" +// SecureCookies reports whether cookies should carry the Secure flag, which is +// the case whenever the site is served over HTTPS. +func SecureCookies() bool { + return strings.HasPrefix(viper.GetString("app.url"), "https://") } diff --git a/views/admin/admin.html b/views/admin/admin.html index 0f3189e..0a9da4b 100644 --- a/views/admin/admin.html +++ b/views/admin/admin.html @@ -454,10 +454,7 @@
{{.Name}}
diff --git a/views/admin/table/comments-table.html b/views/admin/table/comments-table.html index af0b8f6..7ab570a 100644 --- a/views/admin/table/comments-table.html +++ b/views/admin/table/comments-table.html @@ -18,9 +18,7 @@ {{ unescape (truncate .Content 200) }} {{.CreatedAt.Format "02 Jan 2006"}} - + {{template "partials/comment-status-button" .}} diff --git a/views/admin/table/post-table.html b/views/admin/table/post-table.html index 6c62922..2f15ade 100644 --- a/views/admin/table/post-table.html +++ b/views/admin/table/post-table.html @@ -48,13 +48,7 @@ - + {{template "partials/post-status-button" .}} diff --git a/views/blog/blog.html b/views/blog/blog.html index b754100..38fc595 100644 --- a/views/blog/blog.html +++ b/views/blog/blog.html @@ -13,7 +13,7 @@
{{.Title}}

{{.CreatedAt.Format "02 Jan 2006"}}

-

{{ escape (truncate .Content 200) }}

+

{{ truncate (escape .Content) 200 }}

Read More
diff --git a/views/blog/blog_category.html b/views/blog/blog_category.html index ab61905..f170fc1 100644 --- a/views/blog/blog_category.html +++ b/views/blog/blog_category.html @@ -10,7 +10,7 @@

CATEGORY: {{.Title}}

{{.Title}}

{{.CreatedAt.Format "02 Jan 2006"}}

-

{{ escape (truncate .Content 200) }}

+

{{ truncate (escape .Content) 200 }}

Read More
diff --git a/views/blog/blog_tag.html b/views/blog/blog_tag.html index 4488d01..d1cd949 100644 --- a/views/blog/blog_tag.html +++ b/views/blog/blog_tag.html @@ -11,7 +11,7 @@

Posts Tagged: {{.Title}}

{{.Title}}

{{.CreatedAt.Format "02 Jan 2006"}}

-

{{ escape (truncate .Content 200) }}

+

{{ truncate (escape .Content) 200 }}

Read More diff --git a/views/main.html b/views/main.html index 241af2c..4641c50 100644 --- a/views/main.html +++ b/views/main.html @@ -25,6 +25,15 @@ + @@ -78,14 +87,14 @@ htmx.on("showToast", (e) => { message = e.message; - toastBody.innerHTML = e.detail.value; + toastBody.textContent = e.detail.value; toast.show(); }); htmx.on("ShowToastError", (e) => { message = e.message; - toastErrorBody.innerHTML = e.detail.value; + toastErrorBody.textContent = e.detail.value; toastError.show(); }); diff --git a/views/partials/comment-status-button.html b/views/partials/comment-status-button.html new file mode 100644 index 0000000..60e350f --- /dev/null +++ b/views/partials/comment-status-button.html @@ -0,0 +1,5 @@ + diff --git a/views/partials/file-manager.html b/views/partials/file-manager.html index e190a23..9ea2e3b 100644 --- a/views/partials/file-manager.html +++ b/views/partials/file-manager.html @@ -83,7 +83,7 @@ /// send showToast using htmx to trigger htmx.on showToast htmx.trigger(document.body, 'showToast', { - details: 'URL Copied to Clipboard', + value: 'URL Copied to Clipboard', }); } diff --git a/views/partials/plugin-button.html b/views/partials/plugin-button.html new file mode 100644 index 0000000..9be188d --- /dev/null +++ b/views/partials/plugin-button.html @@ -0,0 +1,4 @@ + diff --git a/views/partials/post-created.html b/views/partials/post-created.html new file mode 100644 index 0000000..1de8dee --- /dev/null +++ b/views/partials/post-created.html @@ -0,0 +1,11 @@ + diff --git a/views/partials/post-status-button.html b/views/partials/post-status-button.html new file mode 100644 index 0000000..b1af69f --- /dev/null +++ b/views/partials/post-status-button.html @@ -0,0 +1,7 @@ +