Merge 52f195a6c8 into 076cd214fe
This commit is contained in:
commit
0e09762da2
|
|
@ -0,0 +1,125 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"src.techknowlogick.com/xormigrate"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
"xorm.io/xorm/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usersFrontendSettings20260627101958 reads the raw frontend_settings JSON text.
|
||||||
|
// The json tag makes xorm hand back the stored column text verbatim instead of
|
||||||
|
// decoding it into a typed value.
|
||||||
|
type usersFrontendSettings20260627101958 struct {
|
||||||
|
ID int64 `xorm:"bigint autoincr not null unique pk"`
|
||||||
|
FrontendSettings string `xorm:"frontend_settings json null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (usersFrontendSettings20260627101958) TableName() string {
|
||||||
|
return "users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyFrontendSettingsWhereClause20260627101958 selects rows whose
|
||||||
|
// frontend_settings is a JSON string ('"…"'). Healthy values are JSON objects
|
||||||
|
// ('{…}'); only the legacy double-encoded values are stored as JSON strings.
|
||||||
|
func legacyFrontendSettingsWhereClause20260627101958(dbType schemas.DBType) string {
|
||||||
|
if dbType == schemas.POSTGRES {
|
||||||
|
return `frontend_settings IS NOT NULL AND frontend_settings::text LIKE '"%'`
|
||||||
|
}
|
||||||
|
return `frontend_settings IS NOT NULL AND frontend_settings LIKE '"%'`
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairLegacyFrontendSettings20260627101958 reverses the historical double
|
||||||
|
// encoding of frontend_settings. The pre-fix UpdateUser stored
|
||||||
|
// json.Marshal(FrontendSettings) back into the interface field, so xorm
|
||||||
|
// base64-encoded the resulting []byte: a nil value became the JSON string
|
||||||
|
// "bnVsbA==" (base64 of "null") and a real settings object became the base64 of
|
||||||
|
// its JSON.
|
||||||
|
//
|
||||||
|
// changed reports whether a repair applies; setNull reports the value should
|
||||||
|
// become SQL NULL; value carries the repaired JSON object otherwise. Only values
|
||||||
|
// that base64-decode to JSON null or a JSON object are touched, so a legitimate
|
||||||
|
// string-valued setting is never rewritten.
|
||||||
|
func repairLegacyFrontendSettings20260627101958(raw string) (value string, setNull, changed bool) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 || trimmed[0] != '"' {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner string
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &inner); err != nil {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(inner)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded = bytes.TrimSpace(decoded)
|
||||||
|
if bytes.Equal(decoded, []byte("null")) {
|
||||||
|
return "", true, true
|
||||||
|
}
|
||||||
|
if len(decoded) > 0 && decoded[0] == '{' && json.Valid(decoded) {
|
||||||
|
return string(decoded), false, true
|
||||||
|
}
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
migrations = append(migrations, &xormigrate.Migration{
|
||||||
|
ID: "20260627101958",
|
||||||
|
Description: "repair double-encoded frontend_settings stored before UpdateUser stopped re-marshalling the column",
|
||||||
|
Migrate: func(tx *xorm.Engine) error {
|
||||||
|
users := []*usersFrontendSettings20260627101958{}
|
||||||
|
if err := tx.
|
||||||
|
Where(legacyFrontendSettingsWhereClause20260627101958(tx.Dialect().URI().DBType)).
|
||||||
|
Find(&users); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
value, setNull, changed := repairLegacyFrontendSettings20260627101958(u.FrontendSettings)
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if setNull {
|
||||||
|
if _, err := tx.Exec("UPDATE users SET frontend_settings = NULL WHERE id = ?", u.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec("UPDATE users SET frontend_settings = ? WHERE id = ?", value, u.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(tx *xorm.Engine) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"xorm.io/xorm/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func legacyFrontendSettingsRaw20260627101958(jsonValue string) string {
|
||||||
|
return `"` + base64.StdEncoding.EncodeToString([]byte(jsonValue)) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairLegacyFrontendSettings20260627101958(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantValue string
|
||||||
|
wantNull bool
|
||||||
|
wantChange bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "legacy null heals to NULL",
|
||||||
|
raw: legacyFrontendSettingsRaw20260627101958("null"),
|
||||||
|
wantNull: true,
|
||||||
|
wantChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy object is decoded back to the object",
|
||||||
|
raw: legacyFrontendSettingsRaw20260627101958(`{"color_schema":"dark"}`),
|
||||||
|
wantValue: `{"color_schema":"dark"}`,
|
||||||
|
wantChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "healthy object is left untouched",
|
||||||
|
raw: `{"color_schema":"dark"}`,
|
||||||
|
wantChange: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-base64 string is left untouched",
|
||||||
|
raw: `"hello world"`,
|
||||||
|
wantChange: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "base64 of a scalar is left untouched",
|
||||||
|
raw: legacyFrontendSettingsRaw20260627101958("123"),
|
||||||
|
wantChange: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "base64 of an array is left untouched",
|
||||||
|
raw: legacyFrontendSettingsRaw20260627101958("[]"),
|
||||||
|
wantChange: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value is left untouched",
|
||||||
|
raw: "",
|
||||||
|
wantChange: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
value, setNull, changed := repairLegacyFrontendSettings20260627101958(tt.raw)
|
||||||
|
if changed != tt.wantChange {
|
||||||
|
t.Fatalf("changed = %v, want %v", changed, tt.wantChange)
|
||||||
|
}
|
||||||
|
if setNull != tt.wantNull {
|
||||||
|
t.Fatalf("setNull = %v, want %v", setNull, tt.wantNull)
|
||||||
|
}
|
||||||
|
if value != tt.wantValue {
|
||||||
|
t.Fatalf("value = %q, want %q", value, tt.wantValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyFrontendSettingsWhereClause20260627101958(t *testing.T) {
|
||||||
|
postgres := legacyFrontendSettingsWhereClause20260627101958(schemas.POSTGRES)
|
||||||
|
if want := `frontend_settings IS NOT NULL AND frontend_settings::text LIKE '"%'`; postgres != want {
|
||||||
|
t.Fatalf("postgres clause\nwant: %s\ngot: %s", want, postgres)
|
||||||
|
}
|
||||||
|
|
||||||
|
other := legacyFrontendSettingsWhereClause20260627101958(schemas.SQLITE)
|
||||||
|
if want := `frontend_settings IS NOT NULL AND frontend_settings LIKE '"%'`; other != want {
|
||||||
|
t.Fatalf("default clause\nwant: %s\ngot: %s", want, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -633,34 +633,15 @@ func UpdateUser(s *xorm.Session, user *User, forceOverride bool) (updatedUser *U
|
||||||
return nil, &ErrInvalidTimezone{Name: user.Timezone, LoadError: err}
|
return nil, &ErrInvalidTimezone{Name: user.Timezone, LoadError: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
frontendSettingsJSON, err := json.Marshal(user.FrontendSettings)
|
cols, err := userUpdateColumns(user, forceOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user.FrontendSettings = frontendSettingsJSON
|
|
||||||
|
|
||||||
// Update it
|
// Update it
|
||||||
_, err = s.
|
_, err = s.
|
||||||
ID(user.ID).
|
ID(user.ID).
|
||||||
Cols(
|
Cols(cols...).
|
||||||
"username",
|
|
||||||
"email",
|
|
||||||
"avatar_provider",
|
|
||||||
"avatar_file_id",
|
|
||||||
"status",
|
|
||||||
"name",
|
|
||||||
"email_reminders_enabled",
|
|
||||||
"discoverable_by_name",
|
|
||||||
"discoverable_by_email",
|
|
||||||
"overdue_tasks_reminders_enabled",
|
|
||||||
"default_project_id",
|
|
||||||
"week_start",
|
|
||||||
"language",
|
|
||||||
"timezone",
|
|
||||||
"overdue_tasks_reminders_time",
|
|
||||||
"frontend_settings",
|
|
||||||
"extra_settings_links",
|
|
||||||
).
|
|
||||||
Update(user)
|
Update(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &User{}, err
|
return &User{}, err
|
||||||
|
|
@ -675,6 +656,52 @@ func UpdateUser(s *xorm.Session, user *User, forceOverride bool) (updatedUser *U
|
||||||
return updatedUser, err
|
return updatedUser, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userUpdateColumns lists the columns UpdateUser persists and, for settings
|
||||||
|
// updates, normalises frontend_settings into the JSON-string form xorm stores
|
||||||
|
// correctly.
|
||||||
|
//
|
||||||
|
// frontend_settings is written only when forceOverride is set, which the
|
||||||
|
// user-settings endpoints pass and which no other caller does. Skipping the
|
||||||
|
// column elsewhere is deliberate: it is set on no other path, and listing a nil
|
||||||
|
// interface in Cols makes xorm write NULL, wiping the user's settings on every
|
||||||
|
// OIDC login. We marshal the value to a string ourselves because xorm passes a
|
||||||
|
// Go map in an interface{} json column straight to the driver (an error) and
|
||||||
|
// base64-encodes a []byte (the historical double-encoding bug).
|
||||||
|
func userUpdateColumns(user *User, forceOverride bool) ([]string, error) {
|
||||||
|
cols := []string{
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"avatar_provider",
|
||||||
|
"avatar_file_id",
|
||||||
|
"status",
|
||||||
|
"name",
|
||||||
|
"email_reminders_enabled",
|
||||||
|
"discoverable_by_name",
|
||||||
|
"discoverable_by_email",
|
||||||
|
"overdue_tasks_reminders_enabled",
|
||||||
|
"default_project_id",
|
||||||
|
"week_start",
|
||||||
|
"language",
|
||||||
|
"timezone",
|
||||||
|
"overdue_tasks_reminders_time",
|
||||||
|
"extra_settings_links",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !forceOverride {
|
||||||
|
return cols, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FrontendSettings != nil {
|
||||||
|
frontendSettingsJSON, err := json.Marshal(user.FrontendSettings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal frontend settings: %w", err)
|
||||||
|
}
|
||||||
|
user.FrontendSettings = string(frontendSettingsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(cols, "frontend_settings"), nil
|
||||||
|
}
|
||||||
|
|
||||||
func SetUserStatus(s *xorm.Session, user *User, status Status) (err error) {
|
func SetUserStatus(s *xorm.Session, user *User, status Status) (err error) {
|
||||||
_, err = s.Where("id = ?", user.ID).
|
_, err = s.Where("id = ?", user.ID).
|
||||||
Cols("status").
|
Cols("status").
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.vikunja.io/api/pkg/db"
|
"code.vikunja.io/api/pkg/db"
|
||||||
|
|
@ -475,6 +477,103 @@ func TestUpdateUser(t *testing.T) {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.True(t, IsErrUserDoesNotExist(err))
|
assert.True(t, IsErrUserDoesNotExist(err))
|
||||||
})
|
})
|
||||||
|
t.Run("frontend settings survive profile-only update", func(t *testing.T) {
|
||||||
|
db.LoadAndAssertFixtures(t)
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
originalSettings := map[string]any{
|
||||||
|
"color_schema": "dark",
|
||||||
|
}
|
||||||
|
settingsJSON, err := json.Marshal(originalSettings)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = s.Table("users").
|
||||||
|
Where("id = ?", 1).
|
||||||
|
Cols("frontend_settings").
|
||||||
|
Update(&struct {
|
||||||
|
FrontendSettings string `xorm:"frontend_settings"`
|
||||||
|
}{
|
||||||
|
FrontendSettings: string(settingsJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := UpdateUser(s, &User{
|
||||||
|
ID: 1,
|
||||||
|
Email: "testing@example.com",
|
||||||
|
}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, map[string]interface{}(originalSettings), updated.FrontendSettings)
|
||||||
|
|
||||||
|
var stored sql.NullString
|
||||||
|
has, err := s.Table("users").
|
||||||
|
Where("id = ?", 1).
|
||||||
|
Cols("frontend_settings").
|
||||||
|
Get(&stored)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, has)
|
||||||
|
require.True(t, stored.Valid)
|
||||||
|
assert.JSONEq(t, string(settingsJSON), stored.String)
|
||||||
|
})
|
||||||
|
t.Run("frontend settings can be saved from request map", func(t *testing.T) {
|
||||||
|
db.LoadAndAssertFixtures(t)
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
frontendSettings := map[string]any{
|
||||||
|
"color_schema": "dark",
|
||||||
|
"nested": map[string]any{
|
||||||
|
"a": float64(1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := UpdateUser(s, &User{
|
||||||
|
ID: 1,
|
||||||
|
FrontendSettings: frontendSettings,
|
||||||
|
}, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, frontendSettings, updated.FrontendSettings)
|
||||||
|
|
||||||
|
var stored sql.NullString
|
||||||
|
has, err := s.Table("users").
|
||||||
|
Where("id = ?", 1).
|
||||||
|
Cols("frontend_settings").
|
||||||
|
Get(&stored)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, has)
|
||||||
|
require.True(t, stored.Valid)
|
||||||
|
assert.JSONEq(t, `{"color_schema":"dark","nested":{"a":1}}`, stored.String)
|
||||||
|
})
|
||||||
|
t.Run("frontend settings can be cleared", func(t *testing.T) {
|
||||||
|
db.LoadAndAssertFixtures(t)
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
_, err := s.Table("users").
|
||||||
|
Where("id = ?", 1).
|
||||||
|
Cols("frontend_settings").
|
||||||
|
Update(&struct {
|
||||||
|
FrontendSettings string `xorm:"frontend_settings"`
|
||||||
|
}{
|
||||||
|
FrontendSettings: `{"color_schema":"dark"}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := UpdateUser(s, &User{
|
||||||
|
ID: 1,
|
||||||
|
FrontendSettings: nil,
|
||||||
|
}, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, updated.FrontendSettings)
|
||||||
|
|
||||||
|
var stored sql.NullString
|
||||||
|
has, err := s.Table("users").
|
||||||
|
Where("id = ?", 1).
|
||||||
|
Cols("frontend_settings").
|
||||||
|
Get(&stored)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, has)
|
||||||
|
assert.False(t, stored.Valid)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateUserPassword(t *testing.T) {
|
func TestUpdateUserPassword(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue