From d796eeba9807fb1628c3ddcd53fef0cd70f5291b Mon Sep 17 00:00:00 2001 From: Thomas Miceli <27960254+thomiceli@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:28:49 +0700 Subject: [PATCH] Make gists username/urls case insensitive in URLS (#641) Signed-off-by: Thomas Miceli --- internal/db/gist.go | 9 ++- internal/db/migration.go | 81 +++++++++++++++-------- internal/db/user.go | 38 ++++++----- internal/web/handlers/gist/gist_test.go | 34 ++++++++++ internal/web/handlers/settings/account.go | 27 +++++--- 5 files changed, 134 insertions(+), 55 deletions(-) diff --git a/internal/db/gist.go b/internal/db/gist.go index 2a62c69..7f9eadd 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -71,6 +71,7 @@ type Gist struct { Uuid string Title string URL string + URLNormalized string Preview string PreviewFilename string PreviewMimeType string @@ -98,6 +99,11 @@ type Like struct { CreatedAt int64 } +func (gist *Gist) BeforeSave(_ *gorm.DB) error { + gist.URLNormalized = strings.ToLower(gist.URL) + return nil +} + func (gist *Gist) BeforeDelete(tx *gorm.DB) error { // Decrement fork counter if the gist was forked err := tx.Model(&Gist{}). @@ -110,7 +116,8 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error { func GetGist(user string, gistUuid string) (*Gist, error) { gist := new(Gist) err := db.Preload("User").Preload("Forked.User").Preload("Topics"). - Where("(gists.uuid like ? OR gists.url = ?) AND users.username like ?", gistUuid+"%", gistUuid, user). + Where("(gists.uuid LIKE ? OR gists.url_normalized = ?) AND users.username_normalized = ?", + strings.ToLower(gistUuid)+"%", strings.ToLower(gistUuid), strings.ToLower(user)). Joins("join users on gists.user_id = users.id"). First(&gist).Error diff --git a/internal/db/migration.go b/internal/db/migration.go index a549d41..a8293a6 100644 --- a/internal/db/migration.go +++ b/internal/db/migration.go @@ -2,7 +2,9 @@ package db import ( "fmt" + "github.com/rs/zerolog/log" + "gorm.io/gorm" ) type MigrationVersion struct { @@ -12,60 +14,74 @@ type MigrationVersion struct { func applyMigrations(dbInfo *databaseInfo) error { switch dbInfo.Type { - case SQLite: - return applySqliteMigrations() - case PostgreSQL, MySQL: - return nil + case SQLite, PostgreSQL, MySQL: + return applyAllMigrations(dbInfo.Type) default: return fmt.Errorf("unknown database type: %s", dbInfo.Type) } - } -func applySqliteMigrations() error { - // Create migration table if it doesn't exist +func applyAllMigrations(dbType databaseType) error { if err := db.AutoMigrate(&MigrationVersion{}); err != nil { log.Fatal().Err(err).Msg("Error creating migration version table") return err } - // Get the current migration version var currentVersion MigrationVersion db.First(¤tVersion) - // Define migrations migrations := []struct { Version uint + DBTypes []databaseType // nil = all types Func func() error }{ - {1, v1_modifyConstraintToSSHKeys}, - {2, v2_lowercaseEmails}, - // Add more migrations here as needed + {1, []databaseType{SQLite}, v1_modifyConstraintToSSHKeys}, + {2, []databaseType{SQLite}, v2_lowercaseEmails}, + {3, nil, v3_normalizedColumns}, } - // Apply migrations for _, m := range migrations { - if m.Version > currentVersion.Version { - tx := db.Begin() - if err := tx.Error; err != nil { - log.Fatal().Err(err).Msg("Error starting transaction") - return err - } + if m.Version <= currentVersion.Version { + continue + } - if err := m.Func(); err != nil { - log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version)) - tx.Rollback() - return err - } else { - if err = tx.Commit().Error; err != nil { - log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version)) - return err + // Skip migrations not intended for this DB type + if len(m.DBTypes) > 0 { + applicable := false + for _, t := range m.DBTypes { + if t == dbType { + applicable = true + break } + } + if !applicable { + // Advance version so we don't retry on next startup currentVersion.Version = m.Version db.Save(¤tVersion) - log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version)) + continue } } + + tx := db.Begin() + if err := tx.Error; err != nil { + log.Fatal().Err(err).Msg("Error starting transaction") + return err + } + + if err := m.Func(); err != nil { + tx.Rollback() + log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version)) + return err + } + + if err := tx.Commit().Error; err != nil { + log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version)) + return err + } + + currentVersion.Version = m.Version + db.Save(¤tVersion) + log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version)) } return nil @@ -112,3 +128,12 @@ func v2_lowercaseEmails() error { copySQL := `UPDATE users SET email = lower(email);` return db.Exec(copySQL).Error } + +func v3_normalizedColumns() error { + if err := db.Model(&User{}).Where("username_normalized = '' OR username_normalized IS NULL"). + Updates(map[string]interface{}{"username_normalized": gorm.Expr("LOWER(username)")}).Error; err != nil { + return err + } + return db.Model(&Gist{}).Where("url_normalized = '' OR url_normalized IS NULL"). + Updates(map[string]interface{}{"url_normalized": gorm.Expr("LOWER(url)")}).Error +} diff --git a/internal/db/user.go b/internal/db/user.go index e11bf87..d12bccf 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -2,24 +2,27 @@ package db import ( "encoding/json" + "strings" + "github.com/thomiceli/opengist/internal/git" "gorm.io/gorm" ) type User struct { - ID uint `gorm:"primaryKey"` - Username string `gorm:"uniqueIndex,size:191"` - Password string - IsAdmin bool - CreatedAt int64 - Email string - MD5Hash string // for gravatar, if no Email is specified, the value is random - AvatarURL string - GithubID string - GitlabID string - GiteaID string - OIDCID string `gorm:"column:oidc_id"` - StylePreferences string + ID uint `gorm:"primaryKey"` + Username string `gorm:"uniqueIndex,size:191"` + UsernameNormalized string `gorm:"index"` + Password string + IsAdmin bool + CreatedAt int64 + Email string + MD5Hash string // for gravatar, if no Email is specified, the value is random + AvatarURL string + GithubID string + GitlabID string + GiteaID string + OIDCID string `gorm:"column:oidc_id"` + StylePreferences string Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` @@ -28,6 +31,11 @@ type User struct { AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` } +func (user *User) BeforeSave(_ *gorm.DB) error { + user.UsernameNormalized = strings.ToLower(user.Username) + return nil +} + func (user *User) BeforeDelete(tx *gorm.DB) error { // Decrement likes counter using derived table err := tx.Exec(` @@ -93,7 +101,7 @@ func (user *User) BeforeDelete(tx *gorm.DB) error { func UserExists(username string) (bool, error) { var count int64 - err := db.Model(&User{}).Where("username like ?", username).Count(&count).Error + err := db.Model(&User{}).Where("username_normalized = ?", strings.ToLower(username)).Count(&count).Error return count > 0, err } @@ -111,7 +119,7 @@ func GetAllUsers(offset int) ([]*User, error) { func GetUserByUsername(username string) (*User, error) { user := new(User) err := db. - Where("username like ?", username). + Where("username_normalized = ?", strings.ToLower(username)). First(&user).Error return user, err } diff --git a/internal/web/handlers/gist/gist_test.go b/internal/web/handlers/gist/gist_test.go index b66ac22..ae36c21 100644 --- a/internal/web/handlers/gist/gist_test.go +++ b/internal/web/handlers/gist/gist_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/url" + "strings" "testing" "github.com/stretchr/testify/require" @@ -291,3 +292,36 @@ func TestGistAccess(t *testing.T) { }) } } + +func TestGetGistCaseInsensitive(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "THOmas") + s.Login(t, "THOmas") + + s.Request(t, "POST", "/", url.Values{ + "title": {"Test"}, + "name": {"file.txt"}, + "content": {"hello world"}, + "url": {"my-GIST"}, + "private": {"0"}, + }, 302) + + gist, err := db.GetGistByID("1") + require.NoError(t, err) + + s.Logout() + + t.Run("URL", func(t *testing.T) { + s.Request(t, "GET", "/thomas/my-gist", nil, 200) + s.Request(t, "GET", "/THOMAS/MY-GIST", nil, 200) + s.Request(t, "GET", "/thomas/MY-GIST", nil, 200) + s.Request(t, "GET", "/THOMAS/my-gist", nil, 200) + }) + + t.Run("UUID", func(t *testing.T) { + s.Request(t, "GET", "/thomas/"+strings.ToLower(gist.Uuid), nil, 200) + s.Request(t, "GET", "/THOMAS/"+strings.ToUpper(gist.Uuid), nil, 200) + }) +} diff --git a/internal/web/handlers/settings/account.go b/internal/web/handlers/settings/account.go index d54cc14..3876a53 100644 --- a/internal/web/handlers/settings/account.go +++ b/internal/web/handlers/settings/account.go @@ -3,16 +3,17 @@ package settings import ( "crypto/md5" "fmt" + "os" + "path/filepath" + "strings" + "time" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/web/context" - "os" - "path/filepath" - "strings" - "time" ) func EmailProcess(ctx *context.Context) error { @@ -61,18 +62,22 @@ func UsernameProcess(ctx *context.Context) error { return ctx.RedirectTo("/settings") } - if exists, err := db.UserExists(dto.Username); err != nil || exists { - ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error") - return ctx.RedirectTo("/settings") + if !strings.EqualFold(dto.Username, user.Username) { + if exists, err := db.UserExists(dto.Username); err != nil || exists { + ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error") + return ctx.RedirectTo("/settings") + } } sourceDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(user.Username)) destinationDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(dto.Username)) - if _, err := os.Stat(sourceDir); !os.IsNotExist(err) { - err := os.Rename(sourceDir, destinationDir) - if err != nil { - return ctx.ErrorRes(500, "Cannot rename user directory", err) + if sourceDir != destinationDir { + if _, err := os.Stat(sourceDir); !os.IsNotExist(err) { + err := os.Rename(sourceDir, destinationDir) + if err != nil { + return ctx.ErrorRes(500, "Cannot rename user directory", err) + } } }