85561547cc
Signed-off-by: Michael Mayer <michael@photoprism.app>
501 lines
15 KiB
Go
501 lines
15 KiB
Go
package entity
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"time"
|
|
|
|
"github.com/jinzhu/gorm"
|
|
|
|
"github.com/photoprism/photoprism/internal/acl"
|
|
"github.com/photoprism/photoprism/internal/form"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
|
"github.com/photoprism/photoprism/pkg/txt"
|
|
)
|
|
|
|
var UsernameLength = 3
|
|
var PasswordLength = 4
|
|
|
|
// Users represents a list of users.
|
|
type Users []User
|
|
|
|
// User represents a person that may optionally log in as user.
|
|
type User struct {
|
|
ID int `gorm:"primary_key" json:"-" yaml:"-"`
|
|
UserUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
|
UserSlug string `gorm:"type:VARBINARY(160);index;" json:"Slug" yaml:"Slug,omitempty"`
|
|
Username string `gorm:"size:64;index;" json:"Username" yaml:"Username,omitempty"`
|
|
Email string `gorm:"size:255;index;" json:"Email" yaml:"Email,omitempty"`
|
|
UserRole string `gorm:"size:32;" json:"Role" yaml:"Role,omitempty"`
|
|
SuperAdmin bool `gorm:"default:false;" json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
|
|
CanLogin bool `gorm:"default:false;" json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
|
|
CanInvite bool `gorm:"default:false;" json:"CanInvite,omitempty" yaml:"CanInvite,omitempty"`
|
|
ShareUID string `gorm:"type:VARBINARY(42);index;" json:"ShareUID" yaml:"ShareUID,omitempty"`
|
|
AuthUID string `gorm:"type:VARBINARY(512);column:auth_uid;" json:"-" yaml:"-"`
|
|
AuthSrc string `gorm:"type:VARBINARY(64);column:auth_src;" json:"-" yaml:"-"`
|
|
WebDAV string `gorm:"size:16;column:webdav;" json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
|
|
AvatarURL string `gorm:"type:VARBINARY(255);column:avatar_url;" json:"AvatarURL" yaml:"AvatarURL,omitempty"`
|
|
AvatarSrc string `gorm:"type:VARBINARY(64);column:avatar_src;" json:"AvatarSrc" yaml:"AvatarSrc,omitempty"`
|
|
UserCountry string `gorm:"type:VARBINARY(2);" json:"Country" yaml:"Country,omitempty"`
|
|
UserLocale string `gorm:"type:VARBINARY(64);" json:"Locale" yaml:"Locale,omitempty"`
|
|
TimeZone string `gorm:"type:VARBINARY(64);default:'';" json:"TimeZone" yaml:"TimeZone,omitempty"`
|
|
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID,omitempty" yaml:"-"`
|
|
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc,omitempty" yaml:"PlaceSrc,omitempty"`
|
|
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"CellID"`
|
|
SubjUID string `gorm:"type:VARBINARY(42);index;" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
|
UserBio string `gorm:"size:255;" json:"Bio,omitempty" yaml:"Bio,omitempty"`
|
|
UserStatus string `gorm:"size:32;" json:"Status,omitempty" yaml:"Status,omitempty"`
|
|
UserURL string `gorm:"size:255;column:user_url" json:"URL,omitempty" yaml:"URL,omitempty"`
|
|
UserPhone string `gorm:"size:32;" json:"Phone,omitempty" yaml:"Phone,omitempty"`
|
|
FullName string `gorm:"size:128;" json:"FullName" yaml:"FullName,omitempty"`
|
|
DisplayName string `gorm:"size:64;" json:"DisplayName" yaml:"DisplayName,omitempty"`
|
|
UserAlias string `gorm:"size:64;" json:"Alias" yaml:"Alias,omitempty"`
|
|
ArtistName string `gorm:"size:64;" json:"ArtistName,omitempty" yaml:"ArtistName,omitempty"`
|
|
UserArtist bool `gorm:"default:false;" json:"Artist,omitempty" yaml:"Artist,omitempty"`
|
|
UserFavorite bool `gorm:"default:false;" json:"Favorite" yaml:"Favorite,omitempty"`
|
|
UserHidden bool `gorm:"default:false;" json:"Hidden" yaml:"Hidden,omitempty"`
|
|
UserPrivate bool `gorm:"default:false;" json:"Private" yaml:"Private,omitempty"`
|
|
UserExcluded bool `gorm:"default:false;" json:"Excluded" yaml:"Excluded,omitempty"`
|
|
CompanyName string `gorm:"size:128;" json:"CompanyName,omitempty" yaml:"CompanyName,omitempty"`
|
|
DepartmentName string `gorm:"size:128;" json:"DepartmentName,omitempty" yaml:"DepartmentName,omitempty"`
|
|
JobTitle string `gorm:"size:64;" json:"JobTitle,omitempty" yaml:"JobTitle,omitempty"`
|
|
BusinessURL string `gorm:"size:255" json:"BusinessURL,omitempty" yaml:"BusinessURL,omitempty"`
|
|
BusinessPhone string `gorm:"size:32;" json:"BusinessPhone,omitempty" yaml:"BusinessPhone,omitempty"`
|
|
BusinessEmail string `gorm:"size:255;" json:"BusinessEmail,omitempty" yaml:"BusinessEmail,omitempty"`
|
|
BackupEmail string `gorm:"size:255;" json:"BackupEmail,omitempty" yaml:"BackupEmail,omitempty"`
|
|
BirthYear int `gorm:"default:-1;" json:"BirthYear" yaml:"BirthYear,omitempty"`
|
|
BirthMonth int `gorm:"default:-1;" json:"BirthMonth" yaml:"BirthMonth,omitempty"`
|
|
BirthDay int `gorm:"default:-1;" json:"BirthDay" yaml:"BirthDay,omitempty"`
|
|
FileRoot string `gorm:"type:VARBINARY(16);column:file_root;" json:"FileRoot,omitempty" yaml:"FileRoot,omitempty"`
|
|
FilePath string `gorm:"type:VARBINARY(500);column:file_path;" json:"FilePath,omitempty" yaml:"FilePath,omitempty"`
|
|
InviteToken string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
|
|
InvitedBy string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
|
|
DownloadToken string `gorm:"column:download_token;type:VARBINARY(128);" json:"-" yaml:"-"`
|
|
PreviewToken string `gorm:"column:preview_token;type:VARBINARY(128);" json:"-" yaml:"-"`
|
|
ResetToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
|
|
ConfirmToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
|
|
ConfirmedAt *time.Time `json:"ConfirmedAt,omitempty" yaml:"ConfirmedAt,omitempty"`
|
|
TermsAccepted *time.Time `json:"TermsAccepted,omitempty" yaml:"TermsAccepted,omitempty"`
|
|
LoginAttempts int `json:"-" yaml:"-"`
|
|
LoginAt *time.Time `json:"-" yaml:"-"`
|
|
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
|
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
|
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
|
}
|
|
|
|
// TableName returns the entity database table name.
|
|
func (User) TableName() string {
|
|
return "auth_users"
|
|
}
|
|
|
|
// InitAccount sets the name and password of the initial admin account.
|
|
func (m *User) InitAccount(login, password string) (updated bool) {
|
|
if !m.IsRegistered() {
|
|
log.Warn("only registered users can change their password")
|
|
return false
|
|
}
|
|
|
|
// Password must not be empty.
|
|
if password == "" {
|
|
return false
|
|
}
|
|
|
|
// Update username as well if needed.
|
|
if err := m.UpdateName(login); err != nil {
|
|
log.Errorf("user: %s", err.Error())
|
|
return false
|
|
}
|
|
|
|
existing := FindPassword(m.UserUID)
|
|
|
|
if existing != nil {
|
|
return false
|
|
}
|
|
|
|
pw := NewPassword(m.UserUID, password)
|
|
|
|
// Update password in database.
|
|
if err := pw.Save(); err != nil {
|
|
log.Error(err)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Create new entity in the database.
|
|
func (m *User) Create() error {
|
|
return Db().Create(m).Error
|
|
}
|
|
|
|
// Save entity properties.
|
|
func (m *User) Save() error {
|
|
return Db().Save(m).Error
|
|
}
|
|
|
|
// Updates multiple properties in the database.
|
|
func (m *User) Updates(values interface{}) error {
|
|
return UnscopedDb().Model(m).Updates(values).Error
|
|
}
|
|
|
|
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
|
|
func (m *User) BeforeCreate(scope *gorm.Scope) error {
|
|
m.UserSlug = m.GenerateSlug()
|
|
|
|
if err := scope.SetColumn("UserSlug", m.UserSlug); err != nil {
|
|
return err
|
|
}
|
|
|
|
if rnd.ValidID(m.UserUID, 'u') {
|
|
return nil
|
|
}
|
|
|
|
m.UserUID = rnd.GenerateUID('u')
|
|
|
|
return scope.SetColumn("UserUID", m.UserUID)
|
|
}
|
|
|
|
// GenerateSlug returns an updated slug.
|
|
func (m *User) GenerateSlug() string {
|
|
if l := clean.Login(m.Username); l != "" {
|
|
return txt.Slug(l)
|
|
} else if m.UserSlug == "" {
|
|
return rnd.GenerateToken(8)
|
|
}
|
|
|
|
return m.UserSlug
|
|
}
|
|
|
|
// FirstOrCreateUser returns an existing row, inserts a new row, or nil in case of errors.
|
|
func FirstOrCreateUser(m *User) *User {
|
|
result := User{}
|
|
|
|
m.UserSlug = m.GenerateSlug()
|
|
|
|
if err := Db().Where("id = ? OR user_uid = ?", m.ID, m.UserUID).First(&result).Error; err == nil {
|
|
return &result
|
|
} else if err := m.Create(); err != nil {
|
|
log.Debugf("user: %s", err)
|
|
return nil
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// FindUserByLogin returns an existing user or nil if not found.
|
|
func FindUserByLogin(s string) *User {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
|
|
result := User{}
|
|
|
|
// Find by Login.
|
|
if name := clean.Login(s); name == "" {
|
|
return nil
|
|
} else if err := Db().Where("username = ?", name).First(&result).Error; err == nil {
|
|
return &result
|
|
} else {
|
|
log.Debugf("username %s not found", clean.LogQuote(name))
|
|
}
|
|
|
|
// Find by Email.
|
|
if email := clean.Email(s); email == "" {
|
|
return nil
|
|
} else if err := Db().Where("email = ?", email).First(&result).Error; err == nil {
|
|
return &result
|
|
} else {
|
|
log.Debugf("email %s not found", clean.LogQuote(email))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FindUserByUID returns an existing user or nil if not found.
|
|
func FindUserByUID(uid string) *User {
|
|
if uid == "" {
|
|
return nil
|
|
}
|
|
|
|
result := User{}
|
|
|
|
if err := Db().Where("user_uid = ?", uid).First(&result).Error; err == nil {
|
|
return &result
|
|
} else {
|
|
log.Debugf("user uid %s not found", clean.LogQuote(uid))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Delete marks the entity as deleted.
|
|
func (m *User) Delete() error {
|
|
if m.ID <= 1 {
|
|
return fmt.Errorf("cannot delete system user")
|
|
}
|
|
|
|
return Db().Delete(m).Error
|
|
}
|
|
|
|
// Deleted tests if the entity is marked as deleted.
|
|
func (m *User) Deleted() bool {
|
|
if m.DeletedAt == nil {
|
|
return false
|
|
}
|
|
|
|
return !m.DeletedAt.IsZero()
|
|
}
|
|
|
|
// String returns an identifier that can be used in logs.
|
|
func (m *User) String() string {
|
|
if n := m.UserName(); n != "" {
|
|
return clean.Log(n)
|
|
} else if n = m.RealName(); n != "" {
|
|
return clean.Log(n)
|
|
} else if m.UserSlug != "" {
|
|
return clean.Log(m.UserSlug)
|
|
}
|
|
|
|
return clean.Log(m.UserUID)
|
|
}
|
|
|
|
// UserName returns the login username.
|
|
func (m *User) UserName() string {
|
|
return clean.Login(m.Username)
|
|
}
|
|
|
|
// UserEmail returns the login email address.
|
|
func (m *User) UserEmail() string {
|
|
switch {
|
|
case m.Email != "":
|
|
return m.Email
|
|
case m.BackupEmail != "":
|
|
return m.BackupEmail
|
|
case m.BusinessEmail != "":
|
|
return m.BusinessEmail
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// RealName returns the user' real name if known.
|
|
func (m *User) RealName() string {
|
|
switch {
|
|
case m.FullName != "":
|
|
return m.FullName
|
|
case m.DisplayName != "":
|
|
return m.DisplayName
|
|
case m.ArtistName != "":
|
|
return m.ArtistName
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// SetUsername sets the login username to the specified string.
|
|
func (m *User) SetUsername(login string) (err error) {
|
|
login = clean.Login(login)
|
|
|
|
// Empty?
|
|
if login == "" {
|
|
return fmt.Errorf("new username is empty")
|
|
}
|
|
|
|
// Update username and slug.
|
|
m.Username = login
|
|
m.UserSlug = m.GenerateSlug()
|
|
|
|
// Update display name.
|
|
if m.DisplayName == "" || m.DisplayName == DefaultAdminFullName {
|
|
m.DisplayName = clean.Name(login)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateName changes the login username and saves it to the database.
|
|
func (m *User) UpdateName(login string) (err error) {
|
|
if err = m.SetUsername(login); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Save to database.
|
|
return m.Updates(Values{
|
|
"UserSlug": m.UserSlug,
|
|
"Username": m.Username,
|
|
"DisplayName": m.DisplayName,
|
|
})
|
|
}
|
|
|
|
// AclRole returns the user role for ACL permission checks.
|
|
func (m *User) AclRole() acl.Role {
|
|
role := clean.Role(m.UserRole)
|
|
|
|
switch {
|
|
case m.SuperAdmin:
|
|
return acl.RoleAdmin
|
|
case role == "":
|
|
return acl.RoleDefault
|
|
case acl.RoleAdmin.Equal(role):
|
|
return acl.RoleAdmin
|
|
case acl.RoleEditor.Equal(role):
|
|
return acl.RoleEditor
|
|
case acl.RoleViewer.Equal(role):
|
|
return acl.RoleViewer
|
|
case acl.RoleGuest.Equal(role):
|
|
return acl.RoleGuest
|
|
default:
|
|
return acl.Role(role)
|
|
}
|
|
}
|
|
|
|
// IsRegistered checks if the user is registered e.g. has a username.
|
|
func (m *User) IsRegistered() bool {
|
|
return m.UserName() != "" && rnd.EntityUID(m.UserUID, 'u')
|
|
}
|
|
|
|
// IsAdmin checks if the user is an admin with username.
|
|
func (m *User) IsAdmin() bool {
|
|
return m.IsRegistered() && m.AclRole() == acl.RoleAdmin
|
|
}
|
|
|
|
// IsEditor checks if the user is an editor with username.
|
|
func (m *User) IsEditor() bool {
|
|
return m.IsRegistered() && m.AclRole() == acl.RoleEditor
|
|
}
|
|
|
|
// IsViewer checks if the user is a viewer with username.
|
|
func (m *User) IsViewer() bool {
|
|
return m.IsRegistered() && m.AclRole() == acl.RoleViewer
|
|
}
|
|
|
|
// IsGuest checks if the user is a guest.
|
|
func (m *User) IsGuest() bool {
|
|
return m.AclRole() == acl.RoleGuest
|
|
}
|
|
|
|
// IsAnonymous checks if the user is unknown.
|
|
func (m *User) IsAnonymous() bool {
|
|
return !rnd.EntityUID(m.UserUID, 'u') || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID
|
|
}
|
|
|
|
// SetPassword sets a new password stored as hash.
|
|
func (m *User) SetPassword(password string) error {
|
|
if !m.IsRegistered() {
|
|
return fmt.Errorf("only registered users can change their password")
|
|
}
|
|
|
|
if len(password) < PasswordLength {
|
|
return fmt.Errorf("password must have at least %d characters", PasswordLength)
|
|
}
|
|
|
|
pw := NewPassword(m.UserUID, password)
|
|
|
|
return pw.Save()
|
|
}
|
|
|
|
// InvalidPassword returns true if the given password does not match the hash.
|
|
func (m *User) InvalidPassword(password string) bool {
|
|
if !m.IsRegistered() {
|
|
log.Warn("only registered users can change their password")
|
|
return true
|
|
}
|
|
|
|
if password == "" {
|
|
return true
|
|
}
|
|
|
|
time.Sleep(time.Second * 5 * time.Duration(m.LoginAttempts))
|
|
|
|
pw := FindPassword(m.UserUID)
|
|
|
|
if pw == nil {
|
|
return true
|
|
}
|
|
|
|
if pw.InvalidPassword(password) {
|
|
if err := Db().Model(m).UpdateColumn("login_attempts", gorm.Expr("login_attempts + ?", 1)).Error; err != nil {
|
|
log.Errorf("user: %s (update login attempts)", err)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
if err := Db().Model(m).Updates(map[string]interface{}{"login_attempts": 0, "login_at": TimeStamp()}).Error; err != nil {
|
|
log.Errorf("user: %s (update last login)", err)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Validate Makes sure username and email are unique and meet requirements. Returns error if any property is invalid
|
|
func (m *User) Validate() error {
|
|
if m.UserName() == "" {
|
|
return errors.New("username must not be empty")
|
|
}
|
|
|
|
if len(m.UserName()) < UsernameLength {
|
|
return fmt.Errorf("username must have at least %d characters", UsernameLength)
|
|
}
|
|
|
|
var err error
|
|
var resultName = User{}
|
|
|
|
if err = Db().Where("username = ? AND id <> ?", m.Username, m.ID).First(&resultName).Error; err == nil {
|
|
return errors.New("username already exists")
|
|
} else if err != gorm.ErrRecordNotFound {
|
|
return err
|
|
}
|
|
|
|
// stop here if no email is provided
|
|
if m.Email == "" {
|
|
return nil
|
|
}
|
|
|
|
// validate email address.
|
|
if a, err := mail.ParseAddress(m.Email); err != nil {
|
|
return err
|
|
} else {
|
|
m.Email = a.Address // make sure email will be used without name.
|
|
}
|
|
|
|
var resultMail = User{}
|
|
|
|
if err = Db().Where("email = ? AND id <> ?", m.Email, m.ID).First(&resultMail).Error; err == nil {
|
|
return errors.New("email already exists")
|
|
} else if err != gorm.ErrRecordNotFound {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateWithPassword Creates User with Password in db transaction.
|
|
func CreateWithPassword(uc form.UserCreate) error {
|
|
u := &User{
|
|
Username: uc.Username,
|
|
Email: uc.Email,
|
|
SuperAdmin: true,
|
|
}
|
|
|
|
if len(uc.Password) < PasswordLength {
|
|
return fmt.Errorf("password must have at least %d characters", PasswordLength)
|
|
}
|
|
|
|
if err := u.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return Db().Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(u).Error; err != nil {
|
|
return err
|
|
}
|
|
pw := NewPassword(u.UserUID, uc.Password)
|
|
if err := tx.Create(&pw).Error; err != nil {
|
|
return err
|
|
}
|
|
log.Infof("added user %s with uid %s", clean.Log(u.UserName()), clean.Log(u.UserUID))
|
|
return nil
|
|
})
|
|
}
|