diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml new file mode 100644 index 0000000..c7d3772 --- /dev/null +++ b/.github/workflows/install-smoke.yml @@ -0,0 +1,25 @@ +name: Install Smoke Test + +on: + push: + branches: + - "**" + +jobs: + + install: + name: Fresh install (${{ matrix.database }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + database: [sqlite, mysql, postgres] + steps: + + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + # Builds the Docker image and walks the install wizard against a + # fresh database (#651). + - name: Install smoke test + run: scripts/install-smoke-test.sh ${{ matrix.database }} diff --git a/README.md b/README.md index d59e85f..fcd1ddc 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,11 @@ Running GoBlog yourself? [Open a pull request](https://github.com/goblogplatform go test ./... ``` +The install smoke test builds the Docker image and walks the install wizard against a fresh database (needs Docker and curl): +```bash +scripts/install-smoke-test.sh sqlite # or mysql, postgres +``` + ## Architecture - **Gin** for HTTP routing and middleware diff --git a/admin/admin.go b/admin/admin.go index e6ebdfc..ebbb6cf 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -571,7 +571,7 @@ func (a *Admin) GetSetting(c *gin.Context) { key := c.Param("key") log.Println("Getting setting: ", key) - err := (*a.db).Where("key = ?", key).First(&blog.Setting{}).Error + err := (*a.db).Where(map[string]any{"key": key}).First(&blog.Setting{}).Error if err != nil { c.JSON(http.StatusNotFound, "Setting not found") } else { diff --git a/auth/otp.go b/auth/otp.go index d733436..da13a88 100644 --- a/auth/otp.go +++ b/auth/otp.go @@ -112,7 +112,7 @@ func hashLoginCode(code string) string { // and falls back to "GoBlog" when the table or value is missing. func (a *Auth) siteTitle() string { var title string - err := (*a.db).Table("settings").Where("key = ?", "site_title").Select("value").Scan(&title).Error + err := (*a.db).Table("settings").Where(map[string]any{"key": "site_title"}).Select("value").Scan(&title).Error if err != nil || strings.TrimSpace(title) == "" { return "GoBlog" } diff --git a/blog/blog.go b/blog/blog.go index 9f36576..15fb991 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -377,7 +377,7 @@ func (b *Blog) SettingValue(key, def string) string { return def } var s Setting - if err := (*b.db).Where("key = ?", key).First(&s).Error; err != nil || s.Value == "" { + if err := (*b.db).Where(map[string]any{"key": key}).First(&s).Error; err != nil || s.Value == "" { return def } return s.Value @@ -515,7 +515,7 @@ func (b *Blog) isSelfHost(c *gin.Context, refHost string) bool { siteHosts = append(siteHosts, strings.TrimSpace(h)) } var siteURLSetting Setting - if err := (*b.db).Where("key = ?", "site_url").First(&siteURLSetting).Error; err == nil { + if err := (*b.db).Where(map[string]any{"key": "site_url"}).First(&siteURLSetting).Error; err == nil { if siteURL, err := url.Parse(siteURLSetting.Value); err == nil { siteHosts = append(siteHosts, siteURL.Host) } @@ -1663,7 +1663,7 @@ func (b *Blog) recordComment(ip string) { // row (or a settings table that hasn't been seeded) fails closed. func (b *Blog) CommentsRequireLogin() bool { var setting Setting - if err := (*b.db).Where("key = ?", "comments_require_login").First(&setting).Error; err != nil { + if err := (*b.db).Where(map[string]any{"key": "comments_require_login"}).First(&setting).Error; err != nil { return true } return setting.Value != "false" diff --git a/blog/page.go b/blog/page.go index a13677c..8576930 100644 --- a/blog/page.go +++ b/blog/page.go @@ -21,7 +21,7 @@ type Page struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `sql:"index" json:"deleted_at,omitempty"` Title string `json:"title"` - Slug string `gorm:"uniqueIndex" json:"slug"` + Slug string `gorm:"uniqueIndex:,length:191" json:"slug"` // length: MySQL cannot index a text column without one; other databases ignore it Content string `sql:"type:text;" json:"content"` HeroURL string `json:"hero_url"` HeroType string `json:"hero_type"` // "image" or "video" diff --git a/blog/post.go b/blog/post.go index bf147c5..1266c70 100644 --- a/blog/post.go +++ b/blog/post.go @@ -40,7 +40,7 @@ type Backlink struct { type ExternalBacklink struct { ID uint `gorm:"primaryKey"` PostID uint `gorm:"uniqueIndex:idx_post_referer" json:"post_id"` - Referer string `gorm:"uniqueIndex:idx_post_referer;type:text" json:"referer"` + Referer string `gorm:"uniqueIndex:idx_post_referer,length:255;type:text" json:"referer"` // length: see Page.Slug FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` HitCount int `json:"hit_count"` diff --git a/blog/post_type.go b/blog/post_type.go index 1eaf4ea..1039697 100644 --- a/blog/post_type.go +++ b/blog/post_type.go @@ -9,7 +9,7 @@ type PostType struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `sql:"index" json:"deleted_at,omitempty"` Name string `json:"name"` - Slug string `gorm:"uniqueIndex" json:"slug"` + Slug string `gorm:"uniqueIndex:,length:191" json:"slug"` // length: see Page.Slug Description string `sql:"type:text;" json:"description"` } diff --git a/plugin/registry.go b/plugin/registry.go index c25bfaf..4dbb846 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -209,7 +209,7 @@ func (r *Registry) Init() error { func initPlugin(db *gorm.DB, p Plugin) error { for _, s := range p.Settings() { setting := PluginSetting{PluginName: p.Name(), Key: s.Key, Value: s.DefaultValue} - db.Where("plugin_name = ? AND key = ?", p.Name(), s.Key).FirstOrCreate(&setting) + db.Where(map[string]any{"plugin_name": p.Name(), "key": s.Key}).FirstOrCreate(&setting) } ensurePages(db, p) if err := p.OnInit(db); err != nil { @@ -673,7 +673,7 @@ func (r *Registry) HasPageType(pageType string) bool { // UpdateSetting saves a single plugin setting. func (r *Registry) UpdateSetting(pluginName, key, value string) { - r.db.Where("plugin_name = ? AND key = ?", pluginName, key). + r.db.Where(map[string]any{"plugin_name": pluginName, "key": key}). Assign(PluginSetting{Value: value}). FirstOrCreate(&PluginSetting{PluginName: pluginName, Key: key, Value: value}) } diff --git a/plugin/store.go b/plugin/store.go index 4c6ce54..78bb371 100644 --- a/plugin/store.go +++ b/plugin/store.go @@ -62,7 +62,7 @@ func (s *dbStore) Get(pluginName, key string) ([]byte, bool, error) { return nil, false, err } var e PluginStoreEntry - err = db.Where("plugin_name = ? AND key = ?", pluginName, key).First(&e).Error + err = db.Where(map[string]any{"plugin_name": pluginName, "key": key}).First(&e).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, nil } @@ -97,16 +97,16 @@ func (s *dbStore) Set(pluginName, key string, value []byte) error { // quota. An overwrite frees its old value first and never adds a row. func checkQuota(db *gorm.DB, pluginName, key string, newBytes int) error { var usage struct { - Rows int64 - Bytes int64 + RowCount int64 + Bytes int64 } if err := db.Model(&PluginStoreEntry{}). - Select("COUNT(*) AS rows, COALESCE(SUM(LENGTH(value)), 0) AS bytes"). - Where("plugin_name = ? AND key <> ?", pluginName, key). + Select("COUNT(*) AS row_count, COALESCE(SUM(LENGTH(value)), 0) AS bytes"). + Where("plugin_name = ?", pluginName).Not(map[string]any{"key": key}). Scan(&usage).Error; err != nil { return fmt.Errorf("plugin store: usage: %w", err) } - if usage.Rows >= MaxStorePluginRows { + if usage.RowCount >= MaxStorePluginRows { return fmt.Errorf("plugin store: plugin has reached its %d-key quota", MaxStorePluginRows) } if usage.Bytes+int64(newBytes) > MaxStorePluginBytes { @@ -120,7 +120,7 @@ func (s *dbStore) Delete(pluginName, key string) error { if err != nil { return err } - return db.Where("plugin_name = ? AND key = ?", pluginName, key).Delete(&PluginStoreEntry{}).Error + return db.Where(map[string]any{"plugin_name": pluginName, "key": key}).Delete(&PluginStoreEntry{}).Error } func (s *dbStore) List(pluginName, prefix string) ([]string, error) { @@ -131,9 +131,9 @@ func (s *dbStore) List(pluginName, prefix string) ([]string, error) { var keys []string q := db.Model(&PluginStoreEntry{}).Where("plugin_name = ?", pluginName) if prefix != "" { - q = q.Where("key LIKE ? ESCAPE '\\'", escapeLike(prefix)+"%") + q = q.Where("? LIKE ? ESCAPE '!'", clause.Column{Name: "key"}, escapeLike(prefix)+"%") } - if err := q.Order("key asc").Pluck("key", &keys).Error; err != nil { + if err := q.Order(clause.OrderByColumn{Column: clause.Column{Name: "key"}}).Pluck("key", &keys).Error; err != nil { return nil, err } return keys, nil @@ -147,12 +147,14 @@ func (s *dbStore) DeleteAll(pluginName string) error { return db.Where("plugin_name = ?", pluginName).Delete(&PluginStoreEntry{}).Error } -// escapeLike escapes LIKE wildcards so a prefix is matched literally. +// escapeLike escapes LIKE wildcards so a prefix is matched literally. The +// escape character is '!' rather than a backslash, which MySQL would read +// as escaping the closing quote of ESCAPE '\'. func escapeLike(s string) string { out := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { - if s[i] == '%' || s[i] == '_' || s[i] == '\\' { - out = append(out, '\\') + if s[i] == '%' || s[i] == '_' || s[i] == '!' { + out = append(out, '!') } out = append(out, s[i]) } diff --git a/plugins/directory/directory.go b/plugins/directory/directory.go index 98b2432..d790d1c 100644 --- a/plugins/directory/directory.go +++ b/plugins/directory/directory.go @@ -206,7 +206,7 @@ func ensurePage(db *gorm.DB, def gplugin.PageDefinition) error { // in the index is built from it. func siteURL(db *gorm.DB) string { var s blog.Setting - if err := db.Where("key = ?", "site_url").First(&s).Error; err != nil { + if err := db.Where(map[string]any{"key": "site_url"}).First(&s).Error; err != nil { return "" } return strings.TrimSpace(s.Value) diff --git a/scripts/install-smoke-test.sh b/scripts/install-smoke-test.sh new file mode 100755 index 0000000..6adf6d1 --- /dev/null +++ b/scripts/install-smoke-test.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Install smoke test (#651): a fresh goblog container, walked through the +# install wizard the way a new user's browser would, against one database. +# +# scripts/install-smoke-test.sh sqlite|mysql|postgres +# +# Needs docker and curl. Builds the image from the working tree unless +# GOBLOG_IMAGE names one that already exists. GOBLOG_PORT (default 7007) is +# the host port the container is published on. +# +# What it cannot do is the wizard's last step, authorising a GitHub OAuth +# app. It writes placeholder client_id/client_secret into the container's +# .env and restarts instead, so GitHub's callback and the first admin login +# are not covered. +set -euo pipefail + +DB=${1:-} +case "$DB" in + sqlite|mysql|postgres) ;; + *) echo "usage: $0 sqlite|mysql|postgres" >&2; exit 2 ;; +esac + +IMAGE=${GOBLOG_IMAGE:-goblog:smoke} +PORT=${GOBLOG_PORT:-7007} +BASE="http://127.0.0.1:$PORT" +NET="goblog-smoke-$DB" +APP="goblog-smoke-$DB-app" +DBC="goblog-smoke-$DB-db" +APP_DIR=/go/src/github.com/compscidr/goblog +TITLE="Smoke Test Blog" +TMP=$(mktemp -d) + +cleanup() { + code=$? + if [ "$code" -ne 0 ]; then + echo "--- goblog log (last 60 lines)" >&2 + docker logs "$APP" 2>&1 | tail -60 >&2 || true + fi + docker rm -f "$APP" "$DBC" >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true + rm -rf "$TMP" + exit "$code" +} +trap cleanup EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } +step() { echo "== $*"; } + +# request DESC WANT_STATUS CURL_ARGS...: the body is left in $TMP/body. +request() { + desc=$1 want=$2; shift 2 + got=$(curl -sS -o "$TMP/body" -w '%{http_code}' "$@") || fail "$desc: curl failed" + [ "$got" = "$want" ] || { head -c 600 "$TMP/body" >&2; echo >&2; fail "$desc: status $got, want $want"; } +} +body_has() { grep -qF -- "$1" "$TMP/body" || { head -c 600 "$TMP/body" >&2; echo >&2; fail "$2: body does not contain '$1'"; }; } +body_lacks() { ! grep -qF -- "$1" "$TMP/body" || fail "$2: body contains '$1'"; } + +wait_for() { # wait_for DESC SECONDS COMMAND... + desc=$1 secs=$2; shift 2 + for _ in $(seq "$secs"); do + if "$@" >/dev/null 2>&1; then return 0; fi + sleep 1 + done + fail "timed out after ${secs}s waiting for $desc" +} +app_up() { [ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/")" = 200 ]; } + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + step "building $IMAGE" + docker build -q -t "$IMAGE" --build-arg VERSION=smoke "$(dirname "$0")/.." +fi + +docker rm -f "$APP" "$DBC" >/dev/null 2>&1 || true +docker network rm "$NET" >/dev/null 2>&1 || true +docker network create "$NET" >/dev/null + +# The wizard's database form, as the browser posts it. +case "$DB" in + sqlite) + form=(-d dbtype=sqlite -d sqlite_file=goblog.db) + ;; + mysql) + step "starting mysql" + docker run -d --name "$DBC" --network "$NET" \ + -e MYSQL_ROOT_PASSWORD=rootpass -e MYSQL_DATABASE=goblog \ + -e MYSQL_USER=goblog -e MYSQL_PASSWORD=goblogpass mysql:9.6 >/dev/null + # Over TCP: the image's first-run init server only listens on the socket. + wait_for mysql 120 docker exec "$DBC" mysql -h127.0.0.1 -ugoblog -pgoblogpass -e 'select 1' goblog + form=(-d dbtype=mysql -d "mysql_host=$DBC" -d mysql_port=3306 -d mysql_user=goblog -d mysql_pass=goblogpass -d mysql_db=goblog) + badform=(-d dbtype=mysql -d "mysql_host=$DBC" -d mysql_port=3306 -d mysql_user=goblog -d mysql_pass=wrong -d mysql_db=goblog) + ;; + postgres) + step "starting postgres" + docker run -d --name "$DBC" --network "$NET" \ + -e POSTGRES_USER=goblog -e POSTGRES_PASSWORD=goblogpass -e POSTGRES_DB=goblog postgres:16-alpine >/dev/null + wait_for postgres 60 docker exec -e PGPASSWORD=goblogpass "$DBC" psql -h127.0.0.1 -Ugoblog -c 'select 1' goblog + form=(-d dbtype=postgres -d "postgres_host=$DBC" -d postgres_port=5432 -d postgres_user=goblog -d postgres_pass=goblogpass -d postgres_db=goblog -d postgres_sslmode=disable) + badform=(-d dbtype=postgres -d "postgres_host=$DBC" -d postgres_port=5432 -d postgres_user=goblog -d postgres_pass=wrong -d postgres_db=goblog -d postgres_sslmode=disable) + ;; +esac + +step "starting goblog ($DB)" +docker run -d --name "$APP" --network "$NET" -p "127.0.0.1:$PORT:7000" "$IMAGE" >/dev/null +wait_for goblog 60 app_up + +step "a fresh install shows the database wizard" +request "GET /" 200 "$BASE/" +body_has "Install Wizard" "GET /" +body_has 'name="dbtype"' "GET /" + +step "Test Database" +request "POST /test_db, no database type" 400 -X POST "$BASE/test_db" -d dbtype= +if [ "$DB" != sqlite ]; then + request "POST /test_db, wrong password" 400 -X POST "$BASE/test_db" "${badform[@]}" +fi +request "POST /test_db" 200 -X POST "$BASE/test_db" "${form[@]}" +body_has success "POST /test_db" + +step "saving the database step connects and migrates" +request "POST /wizard_db" 303 -X POST "$BASE/wizard_db" "${form[@]}" +request "GET / after the database step" 200 "$BASE/" +body_has 'id="settings-form"' "GET / after the database step" + +step "settings step" +request "PATCH /api/v1/settings" 202 -X PATCH "$BASE/api/v1/settings" -H 'Content-Type: application/json' \ + -d "[{\"key\":\"site_title\",\"value\":\"$TITLE\",\"type\":\"text\"},{\"key\":\"site_subtitle\",\"value\":\"installed by the smoke test\",\"type\":\"text\"}]" +request "GET /?page=auth" 200 "$BASE/?page=auth" +body_has 'name="client_id"' "GET /?page=auth" + +# The real last step is GitHub redirecting back to /login, whose handler +# stores the credentials and registers the site's routes. With placeholders +# there is no callback, so the routes arrive with the restart below instead; +# that also covers a restart of a finished install (.env re-read, database +# reconnected, migrations re-run). +step "auth step (placeholder GitHub credentials written to .env), then restart" +docker exec "$APP" sh -c "printf 'client_id=smoke\nclient_secret=smoke\n' >> $APP_DIR/.env" +docker restart "$APP" >/dev/null +wait_for "goblog after restart" 60 app_up + +check_site() { + request "GET /" 200 "$BASE/" + body_has "$TITLE" "GET /" + body_lacks "Install Wizard" "GET /" + for path in /login /search?q=goblog /robots.txt /rss.xml /sitemap.xml /theme/css/goblog.css; do + request "GET $path" 200 "$BASE$path" + done + request "GET /sitemap.xml" 200 "$BASE/sitemap.xml" + body_has "&1 | grep -v -e "Couldn't connect to the database" -e "failed to initialize database" \ + | grep -E "panic|Error [0-9]{4} \(|SQLSTATE|syntax error|SQL logic error|no such (table|column)" >"$TMP/errors"; then + head -20 "$TMP/errors" >&2 + fail "goblog logged errors" +fi + +echo "PASS: install smoke test ($DB)" diff --git a/tools/migrate.go b/tools/migrate.go index 8b7d1ed..5515c08 100644 --- a/tools/migrate.go +++ b/tools/migrate.go @@ -8,6 +8,7 @@ import ( "goblog/blog" gplugin "goblog/plugin" "gorm.io/gorm" + "gorm.io/gorm/clause" "log" "net/url" "regexp" @@ -340,7 +341,7 @@ func seedDefaultSettings(db *gorm.DB) { {Key: "theme_directory_url", Type: "text", Value: "https://www.goblog.live/themes/index.json"}, } for _, s := range defaults { - db.Where("key = ?", s.Key).FirstOrCreate(&s) + db.Where(map[string]any{"key": s.Key}).FirstOrCreate(&s) } } @@ -468,7 +469,7 @@ func migrateSocialURLsToPlugin(db *gorm.DB) { for _, key := range socialKeys { var setting blog.Setting - if err := db.Where("key = ?", key).First(&setting).Error; err != nil { + if err := db.Where(map[string]any{"key": key}).First(&setting).Error; err != nil { continue // not found, skip } if setting.Value == "" { @@ -480,11 +481,11 @@ func migrateSocialURLsToPlugin(db *gorm.DB) { Key: key, Value: setting.Value, } - db.Where("plugin_name = ? AND key = ?", "socialicons", key).FirstOrCreate(&ps) + db.Where(map[string]any{"plugin_name": "socialicons", "key": key}).FirstOrCreate(&ps) } // Also ensure the enabled setting exists - db.Where("plugin_name = ? AND key = ?", "socialicons", "enabled"). + db.Where(map[string]any{"plugin_name": "socialicons", "key": "enabled"}). FirstOrCreate(&gplugin.PluginSetting{ PluginName: "socialicons", Key: "enabled", @@ -492,7 +493,7 @@ func migrateSocialURLsToPlugin(db *gorm.DB) { }) // Remove migrated keys from main settings table - db.Where("key IN ?", socialKeys).Delete(&blog.Setting{}) + db.Where(map[string]any{"key": socialKeys}).Delete(&blog.Setting{}) } // cleanupPluginSettingsFromMainTable removes known plugin-namespaced keys @@ -501,7 +502,7 @@ func migrateSocialURLsToPlugin(db *gorm.DB) { func cleanupPluginSettingsFromMainTable(db *gorm.DB) { knownPrefixes := []string{"analytics.%", "socialicons.%", "scholar.%"} for _, prefix := range knownPrefixes { - result := db.Exec("DELETE FROM settings WHERE key LIKE ?", prefix) + result := db.Where("? LIKE ?", clause.Column{Name: "key"}, prefix).Delete(&blog.Setting{}) if result.Error != nil { log.Printf("Warning: failed to clean up %s from main table: %v", prefix, result.Error) continue @@ -518,7 +519,7 @@ func cleanupPluginSettingsFromMainTable(db *gorm.DB) { func cleanupSelfExternalBacklinks(db *gorm.DB) { var siteHosts []string var siteURLSetting blog.Setting - if err := db.Where("key = ?", "site_url").First(&siteURLSetting).Error; err == nil { + if err := db.Where(map[string]any{"key": "site_url"}).First(&siteURLSetting).Error; err == nil { if siteURL, err := url.Parse(siteURLSetting.Value); err == nil { siteHosts = append(siteHosts, siteURL.Host) }