Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/install-smoke.yml
Original file line number Diff line number Diff line change
@@ -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 }}
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion auth/otp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
6 changes: 3 additions & 3 deletions blog/blog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion blog/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion blog/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 1 addition & 1 deletion blog/post_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
4 changes: 2 additions & 2 deletions plugin/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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})
}
Expand Down
26 changes: 14 additions & 12 deletions plugin/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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])
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/directory/directory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
166 changes: 166 additions & 0 deletions scripts/install-smoke-test.sh
Original file line number Diff line number Diff line change
@@ -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 "<urlset" "GET /sitemap.xml"
request "GET /no-such-page" 404 "$BASE/no-such-page"
request "GET /admin, logged out" 302 "$BASE/admin"
}

step "the site is up"
check_site

# A query the database rejects is logged but often still answers 200 (a
# setting silently reads as its default), so the log is part of the result.
# The wrong-password check above is the one failure that is meant to be there.
step "no panics or SQL errors in the log"
if docker logs "$APP" 2>&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)"
Loading
Loading