From aa45777c926adba2bbe9e269960476acf55abb33 Mon Sep 17 00:00:00 2001 From: Sybren <122987084+drsybren@users.noreply.github.com> Date: Thu, 16 Feb 2023 17:32:01 +0100 Subject: [PATCH] Allow custom "created" timestamps in user creation API (#22549) Allow back-dating user creation via the `adminCreateUser` API operation. `CreateUserOption` now has an optional field `created_at`, which can contain a datetime-formatted string. If this field is present, the user's `created_unix` database field will be updated to its value. This is important for Blender's migration of users from Phabricator to Gitea. There are many users, and the creation timestamp of their account can give us some indication as to how long someone's been part of the community. The back-dating is done in a separate query that just updates the user's `created_unix` field. This was the easiest and cleanest way I could find, as in the initial `INSERT` query the field always is set to "now". --- models/user/user.go | 15 +++++++++- models/user/user_test.go | 55 ++++++++++++++++++++++++++++++++++ modules/structs/admin_user.go | 7 +++++ routers/api/v1/admin/user.go | 9 ++++++ templates/swagger/v1_json.tmpl | 6 ++++ 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/models/user/user.go b/models/user/user.go index 0a43de7435..7e896e26da 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -640,6 +640,11 @@ func CreateUser(u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err e u.IsRestricted = setting.Service.DefaultUserIsRestricted u.IsActive = !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm) + // Ensure consistency of the dates. + if u.UpdatedUnix < u.CreatedUnix { + u.UpdatedUnix = u.CreatedUnix + } + // overwrite defaults if set if len(overwriteDefault) != 0 && overwriteDefault[0] != nil { overwrite := overwriteDefault[0] @@ -717,7 +722,15 @@ func CreateUser(u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err e return err } - if err = db.Insert(ctx, u); err != nil { + if u.CreatedUnix == 0 { + // Caller expects auto-time for creation & update timestamps. + err = db.Insert(ctx, u) + } else { + // Caller sets the timestamps themselves. They are responsible for ensuring + // both `CreatedUnix` and `UpdatedUnix` are set appropriately. + _, err = db.GetEngine(ctx).NoAutoTime().Insert(u) + } + if err != nil { return err } diff --git a/models/user/user_test.go b/models/user/user_test.go index 525da531f2..7a58d2f822 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -4,9 +4,11 @@ package user_test import ( + "context" "math/rand" "strings" "testing" + "time" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -14,6 +16,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" @@ -252,6 +255,58 @@ func TestCreateUserEmailAlreadyUsed(t *testing.T) { assert.True(t, user_model.IsErrEmailAlreadyUsed(err)) } +func TestCreateUserCustomTimestamps(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Add new user with a custom creation timestamp. + var creationTimestamp timeutil.TimeStamp = 12345 + user.Name = "testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + user.Email = "unique@example.com" + user.CreatedUnix = creationTimestamp + err := user_model.CreateUser(user) + assert.NoError(t, err) + + fetched, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + assert.Equal(t, creationTimestamp, fetched.CreatedUnix) + assert.Equal(t, creationTimestamp, fetched.UpdatedUnix) +} + +func TestCreateUserWithoutCustomTimestamps(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // There is no way to use a mocked time for the XORM auto-time functionality, + // so use the real clock to approximate the expected timestamp. + timestampStart := time.Now().Unix() + + // Add new user without a custom creation timestamp. + user.Name = "Testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + user.Email = "unique@example.com" + user.CreatedUnix = 0 + user.UpdatedUnix = 0 + err := user_model.CreateUser(user) + assert.NoError(t, err) + + timestampEnd := time.Now().Unix() + + fetched, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + + assert.LessOrEqual(t, timestampStart, fetched.CreatedUnix) + assert.LessOrEqual(t, fetched.CreatedUnix, timestampEnd) + + assert.LessOrEqual(t, timestampStart, fetched.UpdatedUnix) + assert.LessOrEqual(t, fetched.UpdatedUnix, timestampEnd) +} + func TestGetUserIDsByNames(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index 0739653eea..4d679c81d0 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -4,6 +4,8 @@ package structs +import "time" + // CreateUserOption create user options type CreateUserOption struct { SourceID int64 `json:"source_id"` @@ -20,6 +22,11 @@ type CreateUserOption struct { SendNotify bool `json:"send_notify"` Restricted *bool `json:"restricted"` Visibility string `json:"visibility" binding:"In(,public,limited,private)"` + + // For explicitly setting the user creation timestamp. Useful when users are + // migrated from other systems. When omitted, the user's creation timestamp + // will be set to "now". + Created *time.Time `json:"created_at"` } // EditUserOption edit user options diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 4ee1a320cc..75d5520a0e 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/password" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" @@ -120,6 +121,14 @@ func CreateUser(ctx *context.APIContext) { overwriteDefault.Visibility = &visibility } + // Update the user creation timestamp. This can only be done after the user + // record has been inserted into the database; the insert intself will always + // set the creation timestamp to "now". + if form.Created != nil { + u.CreatedUnix = timeutil.TimeStamp(form.Created.Unix()) + u.UpdatedUnix = u.CreatedUnix + } + if err := user_model.CreateUser(u, overwriteDefault); err != nil { if user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err) || diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e096faf3f3..00fc3b60c4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -15809,6 +15809,12 @@ "password" ], "properties": { + "created_at": { + "description": "For explicitly setting the user creation timestamp. Useful when users are\nmigrated from other systems. When omitted, the user's creation timestamp\nwill be set to \"now\".", + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, "email": { "type": "string", "format": "email",