diff --git a/backend/go.mod b/backend/go.mod index ca31ebc8..419e62ad 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,7 @@ require ( github.com/emersion/go-smtp v0.21.3 github.com/fxamacker/cbor/v2 v2.7.0 github.com/gin-gonic/gin v1.10.0 + github.com/glebarez/go-sqlite v1.21.2 github.com/glebarez/sqlite v1.11.0 github.com/go-co-op/gocron/v2 v2.15.0 github.com/go-ldap/ldap/v3 v3.4.10 @@ -35,8 +36,9 @@ require ( go.opentelemetry.io/otel/sdk v1.35.0 go.opentelemetry.io/otel/sdk/metric v1.35.0 go.opentelemetry.io/otel/trace v1.35.0 - golang.org/x/crypto v0.37.0 + golang.org/x/crypto v0.39.0 golang.org/x/image v0.24.0 + golang.org/x/text v0.26.0 golang.org/x/time v0.9.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 @@ -57,7 +59,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.0.0 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -125,16 +126,15 @@ require ( golang.org/x/arch v0.14.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sync v0.14.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.24.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.65.6 // indirect + modernc.org/libc v1.65.10 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.10.0 // indirect - modernc.org/sqlite v1.37.0 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 0f86f8f3..70f021e9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -319,8 +319,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -331,8 +331,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -353,8 +353,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -387,8 +387,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -423,22 +423,22 @@ modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= -modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= +modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE= -modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA= +modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= +modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= -modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= +modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/backend/internal/bootstrap/db_bootstrap.go b/backend/internal/bootstrap/db_bootstrap.go index bab59a57..5624199c 100644 --- a/backend/internal/bootstrap/db_bootstrap.go +++ b/backend/internal/bootstrap/db_bootstrap.go @@ -20,6 +20,7 @@ import ( "gorm.io/gorm/logger" "github.com/pocket-id/pocket-id/backend/internal/common" + sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite" "github.com/pocket-id/pocket-id/backend/resources" ) @@ -88,6 +89,7 @@ func connectDatabase() (db *gorm.DB, err error) { if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") { return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'") } + sqliteutil.RegisterSqliteFunctions() connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString) if err != nil { return nil, err diff --git a/backend/internal/controller/api_key_controller.go b/backend/internal/controller/api_key_controller.go index 01bc0705..2bf4af0b 100644 --- a/backend/internal/controller/api_key_controller.go +++ b/backend/internal/controller/api_key_controller.go @@ -82,7 +82,7 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) { userID := ctx.GetString("userID") var input dto.ApiKeyCreateDto - if err := ctx.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil { _ = ctx.Error(err) return } diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index 16623336..0a7a97e6 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -109,7 +109,7 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { // @Router /api/application-configuration [put] func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { var input dto.AppConfigUpdateDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } diff --git a/backend/internal/controller/custom_claim_controller.go b/backend/internal/controller/custom_claim_controller.go index 9e26e35f..8b4731c3 100644 --- a/backend/internal/controller/custom_claim_controller.go +++ b/backend/internal/controller/custom_claim_controller.go @@ -59,7 +59,7 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) { var input []dto.CustomClaimCreateDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } @@ -93,7 +93,7 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) { var input []dto.CustomClaimCreateDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 311bf0e9..3ab88abb 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -193,7 +193,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) { // @Router /api/users [post] func (uc *UserController) createUserHandler(c *gin.Context) { var input dto.UserCreateDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } @@ -378,7 +378,7 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) { // @Router /api/one-time-access-email [post] func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) { var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } @@ -457,7 +457,7 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { // @Router /api/signup/setup [post] func (uc *UserController) signUpInitialAdmin(c *gin.Context) { var input dto.SignUpDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } @@ -606,7 +606,7 @@ func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) { // @Router /api/signup [post] func (uc *UserController) signupHandler(c *gin.Context) { var input dto.SignUpDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } @@ -635,7 +635,7 @@ func (uc *UserController) signupHandler(c *gin.Context) { // updateUser is an internal helper method, not exposed as an API endpoint func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { var input dto.UserCreateDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go index 1c0a24aa..3523a42a 100644 --- a/backend/internal/controller/user_group_controller.go +++ b/backend/internal/controller/user_group_controller.go @@ -120,7 +120,7 @@ func (ugc *UserGroupController) get(c *gin.Context) { // @Router /api/user-groups [post] func (ugc *UserGroupController) create(c *gin.Context) { var input dto.UserGroupCreateDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } @@ -152,7 +152,7 @@ func (ugc *UserGroupController) create(c *gin.Context) { // @Router /api/user-groups/{id} [put] func (ugc *UserGroupController) update(c *gin.Context) { var input dto.UserGroupCreateDto - if err := c.ShouldBindJSON(&input); err != nil { + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go index e0945c58..89742c07 100644 --- a/backend/internal/dto/api_key_dto.go +++ b/backend/internal/dto/api_key_dto.go @@ -5,8 +5,8 @@ import ( ) type ApiKeyCreateDto struct { - Name string `json:"name" binding:"required,min=3,max=50"` - Description string `json:"description"` + Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"` + Description string `json:"description" unorm:"nfc"` ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` } diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 3357a846..7787a82d 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -12,7 +12,7 @@ type AppConfigVariableDto struct { } type AppConfigUpdateDto struct { - AppName string `json:"appName" binding:"required,min=1,max=30"` + AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"` SessionDuration string `json:"sessionDuration" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"` DisableAnimations string `json:"disableAnimations" binding:"required"` diff --git a/backend/internal/dto/custom_claim_dto.go b/backend/internal/dto/custom_claim_dto.go index 933d34d4..9378f74c 100644 --- a/backend/internal/dto/custom_claim_dto.go +++ b/backend/internal/dto/custom_claim_dto.go @@ -6,6 +6,6 @@ type CustomClaimDto struct { } type CustomClaimCreateDto struct { - Key string `json:"key" binding:"required"` - Value string `json:"value" binding:"required"` + Key string `json:"key" binding:"required" unorm:"nfc"` + Value string `json:"value" binding:"required" unorm:"nfc"` } diff --git a/backend/internal/dto/dto_normalize.go b/backend/internal/dto/dto_normalize.go new file mode 100644 index 00000000..d366f1d3 --- /dev/null +++ b/backend/internal/dto/dto_normalize.go @@ -0,0 +1,94 @@ +package dto + +import ( + "net/http" + "reflect" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "golang.org/x/text/unicode/norm" +) + +// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag. +func Normalize(obj any) { + v := reflect.ValueOf(obj) + if v.Kind() != reflect.Ptr || v.IsNil() { + return + } + v = v.Elem() + + // Handle case where obj is a slice of models + if v.Kind() == reflect.Slice { + for i := 0; i < v.Len(); i++ { + elem := v.Index(i) + if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct { + Normalize(elem.Interface()) + } else if elem.Kind() == reflect.Struct && elem.CanAddr() { + Normalize(elem.Addr().Interface()) + } + } + return + } + + if v.Kind() != reflect.Struct { + return + } + + // Iterate through all fields looking for those with the "unorm" tag + t := v.Type() +loop: + for i := range t.NumField() { + field := t.Field(i) + + unormTag := field.Tag.Get("unorm") + if unormTag == "" { + continue + } + + fv := v.Field(i) + if !fv.CanSet() || fv.Kind() != reflect.String { + continue + } + + var form norm.Form + switch unormTag { + case "nfc": + form = norm.NFC + case "nfkc": + form = norm.NFKC + case "nfd": + form = norm.NFD + case "nfkd": + form = norm.NFKD + default: + continue loop + } + + val := fv.String() + val = form.String(val) + fv.SetString(val) + } +} + +func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error { + return ctx.ShouldBindWith(obj, binding.JSON) +} + +type NormalizerJSONBinding struct{} + +func (NormalizerJSONBinding) Name() string { + return "json" +} + +func (NormalizerJSONBinding) Bind(req *http.Request, obj any) error { + // Use the default JSON binder + err := binding.JSON.Bind(req, obj) + if err != nil { + return err + } + + // Perform normalization + Normalize(obj) + + return nil +} diff --git a/backend/internal/dto/dto_normalize_test.go b/backend/internal/dto/dto_normalize_test.go new file mode 100644 index 00000000..71647b75 --- /dev/null +++ b/backend/internal/dto/dto_normalize_test.go @@ -0,0 +1,84 @@ +package dto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/unicode/norm" +) + +type testDto struct { + Name string `unorm:"nfc"` + Description string `unorm:"nfd"` + Other string + BadForm string `unorm:"bad"` +} + +func TestNormalize(t *testing.T) { + input := testDto{ + // Is in NFC form already + Name: norm.NFC.String("Café"), + // NFC form will be normalized to NFD + Description: norm.NFC.String("vërø"), + // Should be unchanged + Other: "NöTag", + // Should be unchanged + BadForm: "BåD", + } + + Normalize(&input) + + assert.Equal(t, norm.NFC.String("Café"), input.Name) + assert.Equal(t, norm.NFD.String("vërø"), input.Description) + assert.Equal(t, "NöTag", input.Other) + assert.Equal(t, "BåD", input.BadForm) +} + +func TestNormalizeSlice(t *testing.T) { + obj1 := testDto{ + Name: norm.NFC.String("Café1"), + Description: norm.NFC.String("vërø1"), + Other: "NöTag1", + BadForm: "BåD1", + } + obj2 := testDto{ + Name: norm.NFD.String("Résumé2"), + Description: norm.NFD.String("accéléré2"), + Other: "NöTag2", + BadForm: "BåD2", + } + + t.Run("slice of structs", func(t *testing.T) { + slice := []testDto{obj1, obj2} + Normalize(&slice) + + // Verify first element + assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name) + assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description) + assert.Equal(t, "NöTag1", slice[0].Other) + assert.Equal(t, "BåD1", slice[0].BadForm) + + // Verify second element + assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name) + assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description) + assert.Equal(t, "NöTag2", slice[1].Other) + assert.Equal(t, "BåD2", slice[1].BadForm) + }) + + t.Run("slice of pointers to structs", func(t *testing.T) { + slice := []*testDto{&obj1, &obj2} + Normalize(&slice) + + // Verify first element + assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name) + assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description) + assert.Equal(t, "NöTag1", slice[0].Other) + assert.Equal(t, "BåD1", slice[0].BadForm) + + // Verify second element + assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name) + assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description) + assert.Equal(t, "NöTag2", slice[1].Other) + assert.Equal(t, "BåD2", slice[1].BadForm) + }) +} diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index b4208cc6..f9e91eb5 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -26,7 +26,7 @@ type OidcClientWithAllowedGroupsCountDto struct { } type OidcClientCreateDto struct { - Name string `json:"name" binding:"required,max=50"` + Name string `json:"name" binding:"required,max=50" unorm:"nfc"` CallbackURLs []string `json:"callbackURLs"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"` IsPublic bool `json:"isPublic"` diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index 4aa0ca7f..55fff4ce 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -1,6 +1,8 @@ package dto -import "time" +import ( + "time" +) type UserDto struct { ID string `json:"id"` @@ -17,10 +19,10 @@ type UserDto struct { } type UserCreateDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50"` - Email string `json:"email" binding:"required,email"` - FirstName string `json:"firstName" binding:"required,min=1,max=50"` - LastName string `json:"lastName" binding:"max=50"` + Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Email string `json:"email" binding:"required,email" unorm:"nfc"` + FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` IsAdmin bool `json:"isAdmin"` Locale *string `json:"locale"` Disabled bool `json:"disabled"` @@ -33,7 +35,7 @@ type OneTimeAccessTokenCreateDto struct { } type OneTimeAccessEmailAsUnauthenticatedUserDto struct { - Email string `json:"email" binding:"required,email"` + Email string `json:"email" binding:"required,email" unorm:"nfc"` RedirectPath string `json:"redirectPath"` } @@ -46,9 +48,9 @@ type UserUpdateUserGroupDto struct { } type SignUpDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50"` - Email string `json:"email" binding:"required,email"` - FirstName string `json:"firstName" binding:"required,min=1,max=50"` - LastName string `json:"lastName" binding:"max=50"` + Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Email string `json:"email" binding:"required,email" unorm:"nfc"` + FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` Token string `json:"token"` } diff --git a/backend/internal/dto/user_group_dto.go b/backend/internal/dto/user_group_dto.go index e05c03a3..57c2b22a 100644 --- a/backend/internal/dto/user_group_dto.go +++ b/backend/internal/dto/user_group_dto.go @@ -34,8 +34,8 @@ type UserGroupDtoWithUserCount struct { } type UserGroupCreateDto struct { - FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"` - Name string `json:"name" binding:"required,min=2,max=255"` + FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"` + Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"` LdapID string `json:"-"` } diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index 2481c881..90f9ff77 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -15,13 +15,14 @@ import ( "time" "unicode/utf8" - "github.com/google/uuid" - "github.com/go-ldap/ldap/v3" + "github.com/google/uuid" + "golang.org/x/text/unicode/norm" + "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" - "gorm.io/gorm" ) type LdapService struct { @@ -181,7 +182,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap. var databaseUser model.User err = tx. WithContext(ctx). - Where("username = ? AND ldap_id IS NOT NULL", username). + Where("username = ? AND ldap_id IS NOT NULL", norm.NFC.String(username)). First(&databaseUser). Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -199,6 +200,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap. FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), LdapID: ldapId, } + dto.Normalize(syncGroup) if databaseGroup.ID == "" { newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx) @@ -309,7 +311,6 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C // If a user is found (even if disabled), enable them since they're now back in LDAP if databaseUser.ID != "" && databaseUser.Disabled { - // Use the transaction instead of the direct context err = tx. WithContext(ctx). Model(&model.User{}). @@ -318,7 +319,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C Error if err != nil { - log.Printf("Failed to enable user %s: %v", databaseUser.Username, err) + return fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err) } } @@ -344,6 +345,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C IsAdmin: isAdmin, LdapID: ldapId, } + dto.Normalize(newUser) if databaseUser.ID == "" { _, err = s.userService.createUserInternal(ctx, newUser, true, tx) @@ -476,7 +478,7 @@ func getDNProperty(property string, str string) string { // LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data. func convertLdapIdToString(ldapId string) string { if utf8.ValidString(ldapId) { - return ldapId + return norm.NFC.String(ldapId) } // Try to parse as binary UUID (16 bytes) diff --git a/backend/internal/utils/sqlite/sqlite_util.go b/backend/internal/utils/sqlite/sqlite_util.go new file mode 100644 index 00000000..b30171be --- /dev/null +++ b/backend/internal/utils/sqlite/sqlite_util.go @@ -0,0 +1,51 @@ +package sqlite + +import ( + "database/sql/driver" + "errors" + "fmt" + "strings" + + sqlitelib "github.com/glebarez/go-sqlite" + "golang.org/x/text/unicode/norm" +) + +func RegisterSqliteFunctions() { + // Register the `normalize(text, form)` function, which performs Unicode normalization on the text + // This is currently only used in migration functions + sqlitelib.MustRegisterDeterministicScalarFunction("normalize", 2, func(ctx *sqlitelib.FunctionContext, args []driver.Value) (driver.Value, error) { + if len(args) != 2 { + return nil, errors.New("normalize requires 2 arguments") + } + + arg0, ok := args[0].(string) + if !ok { + return nil, fmt.Errorf("first argument for normalize is not a string: %T", args[0]) + } + + arg1, ok := args[1].(string) + if !ok { + return nil, fmt.Errorf("second argument for normalize is not a string: %T", args[1]) + } + + var form norm.Form + switch strings.ToLower(arg1) { + case "nfc": + form = norm.NFC + case "nfd": + form = norm.NFD + case "nfkc": + form = norm.NFKC + case "nfkd": + form = norm.NFKD + default: + return nil, fmt.Errorf("unsupported form: %s", arg1) + } + + if len(arg0) == 0 { + return arg0, nil + } + + return form.String(arg0), nil + }) +} diff --git a/backend/internal/utils/testing/database.go b/backend/internal/utils/testing/database.go index a58a789b..9d052d90 100644 --- a/backend/internal/utils/testing/database.go +++ b/backend/internal/utils/testing/database.go @@ -17,9 +17,14 @@ import ( "gorm.io/gorm/logger" "github.com/pocket-id/pocket-id/backend/internal/utils" + sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite" "github.com/pocket-id/pocket-id/backend/resources" ) +func init() { + sqliteutil.RegisterSqliteFunctions() +} + // NewDatabaseForTest returns a new instance of GORM connected to an in-memory SQLite database. // Each database connection is unique for the test. // All migrations are automatically performed. diff --git a/backend/resources/migrations/postgres/20250705000000_normalize.down.sql b/backend/resources/migrations/postgres/20250705000000_normalize.down.sql new file mode 100644 index 00000000..0813dbe8 --- /dev/null +++ b/backend/resources/migrations/postgres/20250705000000_normalize.down.sql @@ -0,0 +1 @@ +-- No-op diff --git a/backend/resources/migrations/postgres/20250705000000_normalize.up.sql b/backend/resources/migrations/postgres/20250705000000_normalize.up.sql new file mode 100644 index 00000000..27c5700f --- /dev/null +++ b/backend/resources/migrations/postgres/20250705000000_normalize.up.sql @@ -0,0 +1,25 @@ +-- Normalize (form NFC) all existing values in the database +UPDATE api_keys SET + name = normalize(name, 'nfc'), + description = normalize(description, 'nfc'); + +UPDATE app_config_variables SET + "value" = normalize("value", 'nfc') +WHERE "key" = 'appName'; + +UPDATE custom_claims SET + "key" = normalize("key", 'nfc'), + "value" = normalize("value", 'nfc'); + +UPDATE oidc_clients SET + name = normalize(name, 'nfc'); + +UPDATE users SET + username = normalize(username, 'nfc'), + email = normalize(email, 'nfc'), + first_name = normalize(first_name, 'nfc'), + last_name = normalize(last_name, 'nfc'); + +UPDATE user_groups SET + friendly_name = normalize(friendly_name, 'nfc'), + "name" = normalize("name", 'nfc'); diff --git a/backend/resources/migrations/sqlite/20250705000000_normalize.down.sql b/backend/resources/migrations/sqlite/20250705000000_normalize.down.sql new file mode 100644 index 00000000..0813dbe8 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250705000000_normalize.down.sql @@ -0,0 +1 @@ +-- No-op diff --git a/backend/resources/migrations/sqlite/20250705000000_normalize.up.sql b/backend/resources/migrations/sqlite/20250705000000_normalize.up.sql new file mode 100644 index 00000000..27c5700f --- /dev/null +++ b/backend/resources/migrations/sqlite/20250705000000_normalize.up.sql @@ -0,0 +1,25 @@ +-- Normalize (form NFC) all existing values in the database +UPDATE api_keys SET + name = normalize(name, 'nfc'), + description = normalize(description, 'nfc'); + +UPDATE app_config_variables SET + "value" = normalize("value", 'nfc') +WHERE "key" = 'appName'; + +UPDATE custom_claims SET + "key" = normalize("key", 'nfc'), + "value" = normalize("value", 'nfc'); + +UPDATE oidc_clients SET + name = normalize(name, 'nfc'); + +UPDATE users SET + username = normalize(username, 'nfc'), + email = normalize(email, 'nfc'), + first_name = normalize(first_name, 'nfc'), + last_name = normalize(last_name, 'nfc'); + +UPDATE user_groups SET + friendly_name = normalize(friendly_name, 'nfc'), + "name" = normalize("name", 'nfc');