@@ -19,6 +19,7 @@
check
settings
@@ -27,6 +28,7 @@
report_problem
@@ -37,12 +39,14 @@
{{ formatDate(props.item.SyncDate) }} |
delete
edit
diff --git a/go.mod b/go.mod
index 88d20c4c9..a1f96ac06 100644
--- a/go.mod
+++ b/go.mod
@@ -66,7 +66,7 @@ require (
golang.org/x/oauth2 v0.4.0 // indirect
)
-require github.com/gabriel-vasile/mimetype v1.4.1
+require github.com/gabriel-vasile/mimetype v1.4.2
require (
golang.org/x/sync v0.1.0
diff --git a/go.sum b/go.sum
index c96085efb..a7227b8d7 100644
--- a/go.sum
+++ b/go.sum
@@ -129,8 +129,8 @@ github.com/esimov/pigo v1.4.6 h1:wpB9FstbqeGP/CZP+nTR52tUJe7XErq8buG+k4xCXlw=
github.com/esimov/pigo v1.4.6/go.mod h1:uqj9Y3+3IRYhFK071rxz1QYq0ePhA6+R9jrUZavi46M=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
-github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
-github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
+github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
+github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -533,7 +533,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
diff --git a/internal/acl/acl_resources.go b/internal/acl/acl_resources.go
index b8811fc3e..50d07954b 100644
--- a/internal/acl/acl_resources.go
+++ b/internal/acl/acl_resources.go
@@ -62,7 +62,7 @@ var Resources = ACL{
RoleAdmin: GrantFullAccess,
},
ResourceUsers: Roles{
- RoleAdmin: GrantFullAccess,
+ RoleAdmin: Grant{AccessAll: true, AccessOwn: true, ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionSubscribe: true},
},
ResourceConfig: Roles{
RoleAdmin: GrantFullAccess,
diff --git a/internal/api/users_avatar.go b/internal/api/users_avatar.go
index 62b2b2516..1983ad60a 100644
--- a/internal/api/users_avatar.go
+++ b/internal/api/users_avatar.go
@@ -6,15 +6,15 @@ import (
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
- "github.com/photoprism/photoprism/internal/entity"
- "github.com/photoprism/photoprism/internal/event"
- "github.com/photoprism/photoprism/internal/photoprism"
- "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/internal/acl"
+ "github.com/photoprism/photoprism/internal/entity"
+ "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
+ "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/clean"
+ "github.com/photoprism/photoprism/pkg/fs"
)
// UploadUserAvatar updates the avatar image of the currently authenticated user.
@@ -35,15 +35,18 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return
}
+ // Check if the session user is has user management privileges.
+ isPrivileged := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
uid := clean.UID(c.Param("uid"))
// Users may only change their own avatar.
- if s.User().UserUID != uid {
+ if !isPrivileged && s.User().UserUID != uid {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "user uid does not match"}, s.RefID)
AbortForbidden(c)
return
}
+ // Parse upload form.
f, err := c.MultipartForm()
if err != nil {
@@ -52,6 +55,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return
}
+ // Check number of files.
files := f.File["files"]
if len(files) != 1 {
@@ -59,7 +63,16 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return
}
- uploadDir, err := conf.UserUploadPath(s.UserUID, "")
+ // Find user entity to update.
+ m := entity.FindUserByUID(uid)
+
+ if m == nil {
+ Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
+ return
+ }
+
+ // Get user upload folder.
+ uploadDir, err := conf.UserUploadPath(uid, "")
if err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to create folder", "%s"}, s.RefID, err)
@@ -68,8 +81,9 @@ func UploadUserAvatar(router *gin.RouterGroup) {
}
file := files[0]
+ var fileName string
- // Uploaded images must be JPEGs with a maximum file size of 20 MB.
+ // The user avatar must be a PNG or JPEG image with a maximum size of 20 MB.
if file.Size > 20000000 {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "file size exceeded"}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrFileTooLarge)
@@ -82,15 +96,23 @@ func UploadUserAvatar(router *gin.RouterGroup) {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
- } else if !mimeType.Is(fs.MimeTypeJPEG) {
- event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "only jpeg supported"}, s.RefID)
- Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
- return
+ } else {
+ switch {
+ case mimeType.Is(fs.MimeTypePNG):
+ fileName = "avatar.png"
+ case mimeType.Is(fs.MimeTypeJPEG):
+ fileName = "avatar.jpg"
+ default:
+ event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", " %s not supported"}, s.RefID, mimeType)
+ Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
+ return
+ }
}
- fileName := "avatar.jpg"
+ // Get absolute file path.
filePath := path.Join(uploadDir, fileName)
+ // Save avatar image.
if err = c.SaveUploadedFile(file, filePath); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to save %s"}, s.RefID, clean.Log(filePath))
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
@@ -99,21 +121,24 @@ func UploadUserAvatar(router *gin.RouterGroup) {
event.AuditInfo([]string{ClientIP(c), "session %s", "upload avatar", "saved as %s"}, s.RefID, clean.Log(filePath))
}
+ // Create avatar thumbnails.
if mediaFile, mediaErr := photoprism.NewMediaFile(filePath); mediaErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
return
} else if err = mediaFile.CreateThumbnails(conf.ThumbCachePath(), false); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
- } else if err = s.User().SetAvatar(mediaFile.Hash(), entity.SrcManual); err != nil {
+ } else if err = m.SetAvatar(mediaFile.Hash(), entity.SrcManual); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
}
- // Clear the session cache, as it contains user information.
+ // Clear session cache to update user details.
s.ClearCache()
+ // Show success message.
log.Info(i18n.Msg(i18n.MsgFileUploaded))
+ // Return updated user profile.
c.JSON(http.StatusOK, entity.FindUserByUID(uid))
})
}
diff --git a/internal/api/users_password.go b/internal/api/users_password.go
index d63d7f291..3ea8a13cc 100644
--- a/internal/api/users_password.go
+++ b/internal/api/users_password.go
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
+ "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
@@ -41,15 +42,22 @@ func UpdateUserPassword(router *gin.RouterGroup) {
return
}
+ // Check if the session user is has user management privileges.
+ isPrivileged := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
+ isSuperAdmin := isPrivileged && s.User().IsSuperAdmin()
+ uid := clean.UID(c.Param("uid"))
+
+ var u *entity.User
+
// Users may only change their own password.
- if s.User().UserUID != clean.UID(c.Param("uid")) {
+ if !isPrivileged && s.User().UserUID != uid {
AbortForbidden(c)
return
- }
-
- u := s.User()
-
- if u == nil {
+ } else if s.User().UserUID == uid {
+ u = s.User()
+ isPrivileged = false
+ isSuperAdmin = false
+ } else if u = entity.FindUserByUID(uid); u == nil {
Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
return
}
@@ -62,7 +70,9 @@ func UpdateUserPassword(router *gin.RouterGroup) {
}
// Verify that the old password is correct.
- if u.WrongPassword(f.OldPassword) {
+ if isSuperAdmin && f.OldPassword == "" {
+ // Do nothing.
+ } else if u.WrongPassword(f.OldPassword) {
limiter.Login.Reserve(ClientIP(c))
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return
diff --git a/internal/api/users_update.go b/internal/api/users_update.go
index 459e29868..32c0b1855 100644
--- a/internal/api/users_update.go
+++ b/internal/api/users_update.go
@@ -20,11 +20,12 @@ func UpdateUser(router *gin.RouterGroup) {
router.PUT("/users/:uid", func(c *gin.Context) {
conf := get.Config()
- if conf.Demo() || conf.DisableSettings() {
+ if conf.Public() || conf.DisableSettings() {
AbortForbidden(c)
return
}
+ // Check if the session user is allowed to manage all accounts or update his/her own account.
s := AuthAny(c, acl.ResourceUsers, acl.Permissions{acl.ActionManage, acl.AccessOwn, acl.ActionUpdate})
if s.Abort(c) {
@@ -56,8 +57,16 @@ func UpdateUser(router *gin.RouterGroup) {
return
}
+ // Check if the session user is has user management privileges.
+ isPrivileged := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
+
+ // Prevent super admins from locking themselves out.
+ if u := s.User(); u.IsSuperAdmin() && u.Equal(m) && !f.CanLogin {
+ f.CanLogin = true
+ }
+
// Save model with values from form.
- if err = m.SaveForm(f); err != nil {
+ if err = m.SaveForm(f, isPrivileged); err != nil {
log.Error(err)
AbortSaveFailed(c)
return
diff --git a/internal/api/users_update_test.go b/internal/api/users_update_test.go
index 8c5ac39f6..b7b48a440 100644
--- a/internal/api/users_update_test.go
+++ b/internal/api/users_update_test.go
@@ -31,6 +31,6 @@ func TestUpdateUser(t *testing.T) {
reqUrl := fmt.Sprintf("/api/v1/users/%s", adminUid)
UpdateUser(router)
r := PerformRequestWithBody(app, "PUT", reqUrl, "{foo:123}")
- assert.Equal(t, http.StatusBadRequest, r.Code)
+ assert.Equal(t, http.StatusForbidden, r.Code)
})
}
diff --git a/internal/api/users_upload.go b/internal/api/users_upload.go
index 595e98aec..18f762748 100644
--- a/internal/api/users_upload.go
+++ b/internal/api/users_upload.go
@@ -40,8 +40,11 @@ func UploadUserFiles(router *gin.RouterGroup) {
return
}
+ uid := clean.UID(c.Param("uid"))
+
// Users may only upload their own files.
- if s.User().UserUID != clean.UID(c.Param("uid")) {
+ if s.User().UserUID != uid {
+ event.AuditErr([]string{ClientIP(c), "session %s", "upload files", "user uid does not match"}, s.RefID)
AbortForbidden(c)
return
}
diff --git a/internal/commands/passwd.go b/internal/commands/passwd.go
index bebce5f90..1aed644ff 100644
--- a/internal/commands/passwd.go
+++ b/internal/commands/passwd.go
@@ -61,7 +61,7 @@ func passwdAction(ctx *cli.Context) error {
return fmt.Errorf("user %s has been deleted", clean.LogQuote(id))
}
- log.Infof("please enter a new password for %s (minimum %d characters)\n", clean.Log(m.Name()), entity.PasswordLength)
+ log.Infof("please enter a new password for %s (minimum %d characters)\n", clean.Log(m.Username()), entity.PasswordLength)
newPassword := getPassword("New Password: ")
@@ -79,7 +79,7 @@ func passwdAction(ctx *cli.Context) error {
return err
}
- log.Infof("changed password for %s\n", clean.Log(m.Name()))
+ log.Infof("changed password for %s\n", clean.Log(m.Username()))
return nil
}
diff --git a/internal/commands/users.go b/internal/commands/users.go
index f36cd154d..25a1d4371 100644
--- a/internal/commands/users.go
+++ b/internal/commands/users.go
@@ -10,9 +10,8 @@ import (
const (
UserNameUsage = "full `NAME` for display in the interface"
UserEmailUsage = "unique `EMAIL` address of the user"
- UserPasswordUsage = "`PASSWORD` for authentication"
- UserRoleUsage = "user account `ROLE`"
- UserAttrUsage = "custom user account `ATTRIBUTES`"
+ UserPasswordUsage = "`PASSWORD` for local authentication"
+ UserRoleUsage = "user role `NAME` (leave blank for default)"
UserAdminUsage = "make user super admin with full access"
UserNoLoginUsage = "disable login on the web interface"
UserWebDAVUsage = "allow to sync files via WebDAV"
@@ -53,10 +52,6 @@ var UserFlags = []cli.Flag{
Usage: UserRoleUsage,
Value: acl.RoleAdmin.String(),
},
- cli.StringFlag{
- Name: "attr, a",
- Usage: UserAttrUsage,
- },
cli.BoolFlag{
Name: "superadmin, s",
Usage: UserAdminUsage,
diff --git a/internal/commands/users_add.go b/internal/commands/users_add.go
index 8bbe3943b..4df2db87b 100644
--- a/internal/commands/users_add.go
+++ b/internal/commands/users_add.go
@@ -47,7 +47,7 @@ func usersAddAction(ctx *cli.Context) error {
return err
}
- frm.UserName = clean.DN(res)
+ frm.UserName = clean.Username(res)
}
// Check if account exists but is deleted.
diff --git a/internal/commands/users_list.go b/internal/commands/users_list.go
index 1012138ad..c9e1854b8 100644
--- a/internal/commands/users_list.go
+++ b/internal/commands/users_list.go
@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/query"
+ "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/report"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -23,7 +24,7 @@ var UsersListCommand = cli.Command{
// usersListAction displays existing user accounts.
func usersListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
- cols := []string{"UID", "Name", "User", "Email", "Role", "Super Admin", "Web Login", "WebDAV", "Attributes", "Created At"}
+ cols := []string{"UID", "Username", "Role", "Auth Provider", "Super Admin", "Web Login", "WebDAV", "Created At"}
// Fetch users from database.
users := query.RegisteredUsers()
@@ -36,14 +37,12 @@ func usersListAction(ctx *cli.Context) error {
for i, user := range users {
rows[i] = []string{
user.UID(),
- user.FullName(),
- user.Login(),
- user.Email(),
+ user.Username(),
user.AclRole().String(),
+ authn.ProviderString(user.Provider()),
report.Bool(user.SuperAdmin, report.Yes, report.No),
report.Bool(user.CanLogIn(), report.Enabled, report.Disabled),
report.Bool(user.CanUseWebDAV(), report.Enabled, report.Disabled),
- user.Attr(),
txt.TimeStamp(&user.CreatedAt),
}
}
diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go
index 1f08e1723..e00be3b0a 100644
--- a/internal/entity/auth_session.go
+++ b/internal/entity/auth_session.go
@@ -257,8 +257,8 @@ func (m *Session) SetUser(u *User) *Session {
return m
}
-// Login returns the login name.
-func (m *Session) Login() string {
+// Username returns the login name.
+func (m *Session) Username() string {
return m.UserName
}
diff --git a/internal/entity/auth_session_login.go b/internal/entity/auth_session_login.go
index ec8e57715..7a0dd42f6 100644
--- a/internal/entity/auth_session_login.go
+++ b/internal/entity/auth_session_login.go
@@ -10,27 +10,31 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
+ "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
)
// Auth checks if the credentials are valid and returns the user and authentication provider.
var Auth = func(f form.Login, m *Session, c *gin.Context) (user *User, provider string, err error) {
- name := f.Name()
+ name := f.Username()
user = FindUserByName(name)
- err = AuthPassword(user, f, m)
+ err = AuthLocal(user, f, m)
if err != nil {
- return user, ProviderNone, err
+ return user, authn.ProviderNone, err
}
- return user, ProviderPassword, err
+ // Update login timestamp.
+ user.UpdateLoginTime()
+
+ return user, authn.ProviderLocal, err
}
-// AuthPassword checks if the username and password are valid and returns the user.
-func AuthPassword(user *User, f form.Login, m *Session) (err error) {
- name := f.Name()
+// AuthLocal authenticates against the local user database with the specified username and password.
+func AuthLocal(user *User, f form.Login, m *Session) (err error) {
+ name := f.Username()
// User found?
if user == nil {
diff --git a/internal/entity/auth_session_report.go b/internal/entity/auth_session_report.go
index e59aa2546..e8df0bd10 100644
--- a/internal/entity/auth_session_report.go
+++ b/internal/entity/auth_session_report.go
@@ -19,7 +19,7 @@ func (m *Session) Report(skipEmpty bool) (rows [][]string, cols []string) {
rows = make([][]string, 0, len(values))
for k, v := range values {
- s := fmt.Sprintf("%v", v)
+ s := fmt.Sprintf("%#v", v)
// Skip empty values?
if !skipEmpty || s != "" {
diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go
index 183bdfc2e..f490722a4 100644
--- a/internal/entity/auth_user.go
+++ b/internal/entity/auth_user.go
@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
+ "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
@@ -41,22 +42,22 @@ type User struct {
ID int `gorm:"primary_key" json:"-" yaml:"-"`
UUID string `gorm:"type:VARBINARY(64);column:user_uuid;index;" json:"UUID,omitempty" yaml:"UUID,omitempty"`
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;unique_index;" json:"UID" yaml:"UID"`
- AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
- AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,omitempty"`
+ AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
+ AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
UserName string `gorm:"size:255;index;" json:"Name" yaml:"Name,omitempty"`
DisplayName string `gorm:"size:200;" json:"DisplayName" yaml:"DisplayName,omitempty"`
UserEmail string `gorm:"size:255;index;" json:"Email" yaml:"Email,omitempty"`
BackupEmail string `gorm:"size:255;" json:"BackupEmail,omitempty" yaml:"BackupEmail,omitempty"`
- UserRole string `gorm:"size:64;default:'';" json:"Role,omitempty" yaml:"Role,omitempty"`
- UserAttr string `gorm:"size:1024;" json:"Attr,omitempty" yaml:"Attr,omitempty"`
- SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
- CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
- LoginAt *time.Time `json:"LoginAt,omitempty" yaml:"LoginAt,omitempty"`
+ UserRole string `gorm:"size:64;default:'';" json:"Role" yaml:"Role,omitempty"`
+ UserAttr string `gorm:"size:1024;" json:"Attr" yaml:"Attr,omitempty"`
+ SuperAdmin bool `json:"SuperAdmin" yaml:"SuperAdmin,omitempty"`
+ CanLogin bool `json:"CanLogin" yaml:"CanLogin,omitempty"`
+ LoginAt *time.Time `json:"LoginAt" yaml:"LoginAt,omitempty"`
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
- WebDAV bool `gorm:"column:webdav;" json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
- BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
- UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
- CanInvite bool `json:"CanInvite,omitempty" yaml:"CanInvite,omitempty"`
+ WebDAV bool `gorm:"column:webdav;" json:"WebDAV" yaml:"WebDAV,omitempty"`
+ BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath" yaml:"BasePath,omitempty"`
+ UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath" yaml:"UploadPath,omitempty"`
+ CanInvite bool `json:"CanInvite" yaml:"CanInvite,omitempty"`
InviteToken string `gorm:"type:VARBINARY(64);index;" json:"-" yaml:"-"`
InvitedBy string `gorm:"size:64;" json:"-" yaml:"-"`
VerifyToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
@@ -106,6 +107,8 @@ func FindUser(find User) *User {
stmt = stmt.Where("id = ?", find.ID)
} else if rnd.IsUID(find.UserUID, UserUID) {
stmt = stmt.Where("user_uid = ?", find.UserUID)
+ } else if find.UserName != "" && find.AuthProvider != "" {
+ stmt = stmt.Where("user_name = ? AND (auth_provider = ? OR auth_provider = '')", find.UserName, find.AuthProvider)
} else if find.UserName != "" {
stmt = stmt.Where("user_name = ?", find.UserName)
} else if find.UserEmail != "" {
@@ -142,14 +145,25 @@ func FirstOrCreateUser(m *User) *User {
}
// FindUserByName returns the matching user or nil if it was not found.
-func FindUserByName(name string) *User {
- name = clean.DN(name)
+func FindUserByName(userName string) *User {
+ userName = clean.Username(userName)
- if name == "" {
+ if userName == "" {
return nil
}
- return FindUser(User{UserName: name})
+ return FindUser(User{UserName: userName})
+}
+
+// FindLocalUser returns the matching local user or nil if it was not found.
+func FindLocalUser(userName string) *User {
+ userName = clean.Username(userName)
+
+ if userName == "" {
+ return nil
+ }
+
+ return FindUser(User{UserName: userName, AuthProvider: authn.ProviderLocal})
}
// FindUserByUID returns the matching user or nil if it was not found.
@@ -209,7 +223,7 @@ func (m *User) InitAccount(initName, initPasswd string) (updated bool) {
// Change username if needed.
if initName != "" && initName != m.UserName {
- if err := m.UpdateName(initName); err != nil {
+ if err := m.UpdateUsername(initName); err != nil {
event.AuditErr([]string{"user %s", "failed to change username to %s", "%s"}, m.RefID, clean.Log(initName), err)
}
}
@@ -333,6 +347,25 @@ func (m *User) Disabled() bool {
return m.Deleted() || m.Expired() && !m.SuperAdmin
}
+// UpdateLoginTime updates the login timestamp and returns it if successful.
+func (m *User) UpdateLoginTime() *time.Time {
+ if m == nil {
+ return nil
+ } else if m.Deleted() {
+ return nil
+ }
+
+ timeStamp := TimePointer()
+
+ if err := Db().Model(m).UpdateColumn("LoginAt", timeStamp).Error; err != nil {
+ return nil
+ }
+
+ m.LoginAt = timeStamp
+
+ return timeStamp
+}
+
// CanLogIn checks if the user is allowed to log in and use the web UI.
func (m *User) CanLogIn() bool {
if m == nil {
@@ -416,7 +449,7 @@ func (m *User) SetUploadPath(dir string) *User {
// String returns an identifier that can be used in logs.
func (m *User) String() string {
- if n := m.Name(); n != "" {
+ if n := m.Username(); n != "" {
return clean.LogQuote(n)
} else if n = m.FullName(); n != "" {
return clean.LogQuote(n)
@@ -425,13 +458,52 @@ func (m *User) String() string {
return clean.Log(m.UserUID)
}
-// Name returns the user's login name for authentication.
-func (m *User) Name() string {
+// Provider returns the authentication provider name.
+func (m *User) Provider() string {
+ if m.AuthProvider != "" {
+ return m.AuthProvider
+ } else if m.ID == Visitor.ID {
+ return authn.ProviderToken
+ } else if m.ID == 1 {
+ return authn.ProviderLocal
+ } else if m.UserName != "" && m.ID > 0 {
+ return authn.ProviderDefault
+ }
+
+ return authn.ProviderNone
+}
+
+// SetProvider set the authentication provider.
+func (m *User) SetProvider(s string) *User {
+ if m == nil {
+ return nil
+ } else if m.ID <= 0 {
+ return m
+ } else if s == authn.ProviderString(authn.ProviderDefault) {
+ s = ""
+ }
+
+ m.AuthProvider = clean.TypeLower(s)
+
+ return m
+}
+
+// IsLocal checks if the user is authenticated locally.
+func (m *User) IsLocal() bool {
+ if m.UserName == "" || m.ID <= 0 {
+ return false
+ }
+
+ return m.ID == 1 || m.AuthProvider == authn.ProviderDefault || m.AuthProvider == authn.ProviderLocal
+}
+
+// Username returns the user's login name as sanitized string.
+func (m *User) Username() string {
return clean.Username(m.UserName)
}
-// SetName sets the login username to the specified string.
-func (m *User) SetName(login string) (err error) {
+// SetUsername sets the login username to the specified string.
+func (m *User) SetUsername(login string) (err error) {
if m.ID < 0 {
return fmt.Errorf("system users cannot be modified")
}
@@ -458,9 +530,9 @@ func (m *User) SetName(login string) (err error) {
return nil
}
-// UpdateName changes the login username and saves it to the database.
-func (m *User) UpdateName(login string) (err error) {
- if err = m.SetName(login); err != nil {
+// UpdateUsername changes the login username and saves it to the database.
+func (m *User) UpdateUsername(login string) (err error) {
+ if err = m.SetUsername(login); err != nil {
return err
}
@@ -558,6 +630,15 @@ func (m *User) NotRegistered() bool {
return !m.IsRegistered()
}
+// Equal returns true if the user specified matches.
+func (m *User) Equal(u *User) bool {
+ if m == nil || u == nil {
+ return false
+ }
+
+ return m.UserUID == u.UserUID
+}
+
// IsAdmin checks if the user is an admin with username.
func (m *User) IsAdmin() bool {
if m == nil {
@@ -670,12 +751,12 @@ func (m *User) WrongPassword(s string) bool {
// Validate checks if username, email and role are valid and returns an error otherwise.
func (m *User) Validate() (err error) {
// Empty name?
- if m.Name() == "" {
+ if m.Username() == "" {
return errors.New("username must not be empty")
}
// Name too short?
- if len(m.Name()) < UsernameLength {
+ if len(m.Username()) < UsernameLength {
return fmt.Errorf("username must have at least %d characters", UsernameLength)
}
@@ -723,7 +804,8 @@ func (m *User) Validate() (err error) {
// SetFormValues sets the values specified in the form.
func (m *User) SetFormValues(frm form.User) *User {
- m.UserName = frm.Name()
+ m.UserName = frm.Username()
+ m.SetProvider(frm.AuthProvider)
m.UserEmail = frm.Email()
m.DisplayName = frm.DisplayName
m.SuperAdmin = frm.SuperAdmin
@@ -853,9 +935,15 @@ func (m *User) Form() (form.User, error) {
}
// SaveForm updates the entity using form data and stores it in the database.
-func (m *User) SaveForm(f form.User) error {
+func (m *User) SaveForm(f form.User, updateRights bool) error {
if m.UserName == "" || m.ID <= 0 {
- return fmt.Errorf("system users cannot be updated")
+ return fmt.Errorf("system users cannot be modified")
+ } else if (m.ID == 1 || f.SuperAdmin) && acl.RoleAdmin.NotEqual(f.Role()) {
+ return fmt.Errorf("super admin must not have a non-admin role")
+ } else if f.BasePath != "" && clean.UserPath(f.BasePath) == "" {
+ return fmt.Errorf("invalid base folder")
+ } else if f.UploadPath != "" && clean.UserPath(f.UploadPath) == "" {
+ return fmt.Errorf("invalid upload folder")
}
// Ignore details if not set.
@@ -870,7 +958,7 @@ func (m *User) SaveForm(f form.User) error {
// Sanitize display name.
if n := clean.Name(f.DisplayName); n != "" && n != m.DisplayName {
- m.SetDisplayName(n)
+ m.SetDisplayName(n, SrcManual)
}
// Sanitize email address.
@@ -880,20 +968,34 @@ func (m *User) SaveForm(f form.User) error {
m.VerifyToken = GenerateToken()
}
+ // Update user rights only if explicitly requested.
+ if updateRights {
+ m.UserRole = f.UserRole
+ m.UserAttr = f.UserAttr
+ m.SuperAdmin = f.SuperAdmin
+ m.CanLogin = f.CanLogin
+ m.WebDAV = f.WebDAV
+
+ m.SetProvider(f.AuthProvider)
+ m.SetBasePath(f.BasePath)
+ m.SetUploadPath(f.UploadPath)
+ }
+
return m.Save()
}
// SetDisplayName sets a new display name and, if possible, splits it into its components.
-func (m *User) SetDisplayName(name string) *User {
+func (m *User) SetDisplayName(name, src string) *User {
name = clean.Name(name)
d := m.Details()
- if name == "" || SrcPriority[SrcAuto] < SrcPriority[d.NameSrc] {
+ if name == "" || SrcPriority[src] < SrcPriority[d.NameSrc] {
return m
}
m.DisplayName = name
+ d.NameSrc = src
// Try to parse name into components.
n := txt.ParseName(name)
@@ -935,19 +1037,3 @@ func (m *User) SetAvatar(thumb, thumbSrc string) error {
return m.Updates(Values{"Thumb": m.Thumb, "ThumbSrc": m.ThumbSrc})
}
-
-// Login returns the username.
-func (m *User) Login() string {
- return m.UserName
-}
-
-// Provider returns the authentication provider name.
-func (m *User) Provider() string {
- if m.AuthProvider != "" {
- return m.AuthProvider
- } else if m.UserName != "" && m.ID > 0 {
- return "password"
- }
-
- return ""
-}
diff --git a/internal/entity/auth_user_add.go b/internal/entity/auth_user_add.go
index 17abeb68f..e4f698763 100644
--- a/internal/entity/auth_user_add.go
+++ b/internal/entity/auth_user_add.go
@@ -32,7 +32,7 @@ func AddUser(frm form.User) error {
return err
}
- log.Infof("successfully added user %s", clean.LogQuote(user.Name()))
+ log.Infof("successfully added user %s", clean.LogQuote(user.Username()))
return nil
})
diff --git a/internal/entity/auth_user_default.go b/internal/entity/auth_user_default.go
index 451465c5d..915ae1ee6 100644
--- a/internal/entity/auth_user_default.go
+++ b/internal/entity/auth_user_default.go
@@ -3,6 +3,7 @@ package entity
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
+ "github.com/photoprism/photoprism/pkg/authn"
)
// Role defaults.
@@ -17,6 +18,7 @@ const (
var Admin = User{
ID: 1,
UserName: AdminUserName,
+ AuthProvider: authn.ProviderLocal,
UserRole: acl.RoleAdmin.String(),
DisplayName: AdminDisplayName,
SuperAdmin: true,
@@ -33,6 +35,7 @@ var UnknownUser = User{
ID: -1,
UserUID: "u000000000000001",
UserName: "",
+ AuthProvider: authn.ProviderNone,
UserRole: acl.RoleUnknown.String(),
CanLogin: false,
WebDAV: false,
@@ -48,6 +51,7 @@ var Visitor = User{
ID: -2,
UserUID: "u000000000000002",
UserName: "",
+ AuthProvider: authn.ProviderToken,
UserRole: acl.RoleVisitor.String(),
DisplayName: VisitorDisplayName,
CanLogin: false,
diff --git a/internal/entity/auth_user_fixtures_test.go b/internal/entity/auth_user_fixtures_test.go
index 4ae64a327..666a60a1f 100644
--- a/internal/entity/auth_user_fixtures_test.go
+++ b/internal/entity/auth_user_fixtures_test.go
@@ -12,14 +12,14 @@ func TestUserMap_Get(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
r := UserFixtures.Get("alice")
assert.Equal(t, "alice", r.UserName)
- assert.Equal(t, "alice", r.Name())
+ assert.Equal(t, "alice", r.Username())
assert.IsType(t, User{}, r)
})
t.Run("Invalid", func(t *testing.T) {
r := UserFixtures.Get("monstera")
assert.Equal(t, "", r.UserName)
- assert.Equal(t, "", r.Name())
+ assert.Equal(t, "", r.Username())
assert.IsType(t, User{}, r)
})
}
@@ -27,7 +27,7 @@ func TestUserMap_Get(t *testing.T) {
func TestUserMap_Pointer(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
r := UserFixtures.Pointer("alice")
- assert.Equal(t, "alice", r.Name())
+ assert.Equal(t, "alice", r.Username())
assert.Equal(t, "alice", r.UserName)
assert.Equal(t, "alice@example.com", r.Email())
assert.Equal(t, "alice@example.com", r.UserEmail)
diff --git a/internal/entity/auth_user_report.go b/internal/entity/auth_user_report.go
index e5f27f963..22ea020f4 100644
--- a/internal/entity/auth_user_report.go
+++ b/internal/entity/auth_user_report.go
@@ -19,7 +19,7 @@ func (m *User) Report(skipEmpty bool) (rows [][]string, cols []string) {
rows = make([][]string, 0, len(values))
for k, v := range values {
- s := fmt.Sprintf("%v", v)
+ s := fmt.Sprintf("%#v", v)
// Skip empty values?
if !skipEmpty || s != "" {
diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go
index 90d46a3e9..8a1ba78ab 100644
--- a/internal/entity/auth_user_test.go
+++ b/internal/entity/auth_user_test.go
@@ -7,6 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
+ "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -17,6 +18,89 @@ func TestNewUser(t *testing.T) {
assert.True(t, rnd.IsUID(m.UserUID, UserUID))
}
+func TestFindLocalUser(t *testing.T) {
+ t.Run("Admin", func(t *testing.T) {
+ m := FindLocalUser("admin")
+
+ if m == nil {
+ t.Fatal("result should not be nil")
+ }
+
+ assert.Equal(t, 1, m.ID)
+ assert.NotEmpty(t, m.UserUID)
+ assert.Equal(t, "admin", m.UserName)
+ assert.Equal(t, "admin", m.Username())
+ m.UserName = "Admin "
+ assert.Equal(t, "admin", m.Username())
+ assert.Equal(t, "Admin ", m.UserName)
+ assert.Equal(t, "Admin", m.DisplayName)
+ assert.Equal(t, acl.RoleAdmin, m.AclRole())
+ assert.Equal(t, "", m.Attr())
+ assert.False(t, m.IsVisitor())
+ assert.True(t, m.SuperAdmin)
+ assert.True(t, m.CanLogin)
+ assert.True(t, m.CanInvite)
+ assert.NotEmpty(t, m.CreatedAt)
+ assert.NotEmpty(t, m.UpdatedAt)
+ })
+
+ t.Run("Alice", func(t *testing.T) {
+ m := FindLocalUser("alice")
+
+ if m == nil {
+ t.Fatal("result should not be nil")
+ }
+
+ assert.Equal(t, 5, m.ID)
+ assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
+ assert.Equal(t, "alice", m.UserName)
+ assert.Equal(t, "Alice", m.DisplayName)
+ assert.Equal(t, "alice@example.com", m.UserEmail)
+ assert.True(t, m.SuperAdmin)
+ assert.Equal(t, acl.RoleAdmin, m.AclRole())
+ assert.NotEqual(t, acl.RoleVisitor, m.AclRole())
+ assert.False(t, m.IsVisitor())
+ assert.True(t, m.CanLogin)
+ assert.NotEmpty(t, m.CreatedAt)
+ assert.NotEmpty(t, m.UpdatedAt)
+ })
+
+ t.Run("Bob", func(t *testing.T) {
+ m := FindLocalUser("bob")
+
+ if m == nil {
+ t.Fatal("result should not be nil")
+ }
+
+ assert.Equal(t, 7, m.ID)
+ assert.Equal(t, "uqxc08w3d0ej2283", m.UserUID)
+ assert.Equal(t, "bob", m.UserName)
+ assert.Equal(t, "Robert Rich", m.DisplayName)
+ assert.Equal(t, "bob@example.com", m.UserEmail)
+ assert.False(t, m.SuperAdmin)
+ assert.False(t, m.IsVisitor())
+ assert.True(t, m.CanLogin)
+ assert.NotEmpty(t, m.CreatedAt)
+ assert.NotEmpty(t, m.UpdatedAt)
+ })
+
+ t.Run("Unknown", func(t *testing.T) {
+ m := FindLocalUser("")
+
+ if m != nil {
+ t.Fatal("result should be nil")
+ }
+ })
+
+ t.Run("NotFound", func(t *testing.T) {
+ m := FindLocalUser("xxx")
+
+ if m != nil {
+ t.Fatal("result should be nil")
+ }
+ })
+}
+
func TestFindUserByName(t *testing.T) {
t.Run("Admin", func(t *testing.T) {
m := FindUserByName("admin")
@@ -28,9 +112,9 @@ func TestFindUserByName(t *testing.T) {
assert.Equal(t, 1, m.ID)
assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "admin", m.UserName)
- assert.Equal(t, "admin", m.Name())
+ assert.Equal(t, "admin", m.Username())
m.UserName = "Admin "
- assert.Equal(t, "admin", m.Name())
+ assert.Equal(t, "admin", m.Username())
assert.Equal(t, "Admin ", m.UserName)
assert.Equal(t, "Admin", m.DisplayName)
assert.Equal(t, acl.RoleAdmin, m.AclRole())
@@ -114,10 +198,10 @@ func TestUser_Create(t *testing.T) {
t.Fatal(err)
}
- assert.Equal(t, "example", m.Name())
+ assert.Equal(t, "example", m.Username())
assert.Equal(t, "example", m.UserName)
- if err := m.UpdateName("example-editor"); err == nil {
+ if err := m.UpdateUsername("example-editor"); err == nil {
t.Fatal("error expected")
}
})
@@ -136,14 +220,14 @@ func TestUser_SetName(t *testing.T) {
t.Fatal("result should not be nil")
}
- assert.Equal(t, "admin", m.Name())
+ assert.Equal(t, "admin", m.Username())
assert.Equal(t, "admin", m.UserName)
- if err := m.SetName("photoprism"); err != nil {
+ if err := m.SetUsername("photoprism"); err != nil {
t.Fatal(err)
}
- assert.Equal(t, "photoprism", m.Name())
+ assert.Equal(t, "photoprism", m.Username())
assert.Equal(t, "photoprism", m.UserName)
})
}
@@ -328,7 +412,7 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, 5, m.ID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
- assert.Equal(t, "alice", m.Name())
+ assert.Equal(t, "alice", m.Username())
assert.Equal(t, "Alice", m.DisplayName)
assert.Equal(t, "alice@example.com", m.UserEmail)
assert.True(t, m.SuperAdmin)
@@ -697,7 +781,20 @@ func TestUser_Disabled(t *testing.T) {
assert.True(t, UserFixtures.Pointer("deleted").Disabled())
}
-func TestUser_CanUseAPI(t *testing.T) {
+func TestUser_UpdateLoginTime(t *testing.T) {
+ alice := UserFixtures.Get("alice")
+ time1 := alice.LoginAt
+ assert.Nil(t, time1)
+ alice.UpdateLoginTime()
+ time2 := alice.LoginAt
+ assert.NotNil(t, time2)
+ alice.UpdateLoginTime()
+ time3 := alice.LoginAt
+ assert.NotNil(t, time3)
+ assert.True(t, time3.After(*time2) || time3.Equal(*time2))
+}
+
+func TestUser_CanLogIn(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").CanLogIn())
assert.False(t, UserFixtures.Pointer("deleted").CanLogIn())
}
@@ -752,7 +849,7 @@ func TestUser_SaveForm(t *testing.T) {
frm, err := UnknownUser.Form()
assert.NoError(t, err)
- err = UnknownUser.SaveForm(frm)
+ err = UnknownUser.SaveForm(frm, false)
assert.Error(t, err)
})
t.Run("Admin", func(t *testing.T) {
@@ -770,7 +867,32 @@ func TestUser_SaveForm(t *testing.T) {
frm.UserEmail = "admin@example.com"
frm.UserDetails.UserLocation = "GoLand"
- err = Admin.SaveForm(frm)
+ err = Admin.SaveForm(frm, false)
+
+ assert.NoError(t, err)
+ assert.Equal(t, "admin@example.com", Admin.UserEmail)
+ assert.Equal(t, "GoLand", Admin.Details().UserLocation)
+
+ m = FindUserByUID(Admin.UserUID)
+ assert.Equal(t, "admin@example.com", m.UserEmail)
+ assert.Equal(t, "GoLand", m.Details().UserLocation)
+ })
+ t.Run("UpdateRights", func(t *testing.T) {
+ m := FindUser(Admin)
+
+ if m == nil {
+ t.Fatal("result should not be nil")
+ }
+
+ frm, err := m.Form()
+
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ frm.UserEmail = "admin@example.com"
+ frm.UserDetails.UserLocation = "GoLand"
+ err = Admin.SaveForm(frm, true)
assert.NoError(t, err)
assert.Equal(t, "admin@example.com", Admin.UserEmail)
@@ -785,7 +907,7 @@ func TestUser_SaveForm(t *testing.T) {
func TestUser_SetDisplayName(t *testing.T) {
t.Run("BillGates", func(t *testing.T) {
user := NewUser()
- user.SetDisplayName("Sir William Henry Gates III")
+ user.SetDisplayName("Sir William Henry Gates III", SrcAuto)
d := user.Details()
assert.Equal(t, "Sir", d.NameTitle)
assert.Equal(t, "William", d.GivenName)
@@ -815,27 +937,27 @@ func TestUser_SetAvatar(t *testing.T) {
})
}
-func TestUser_Login(t *testing.T) {
+func TestUser_Username(t *testing.T) {
t.Run("Visitor", func(t *testing.T) {
- assert.Equal(t, "", Visitor.Login())
+ assert.Equal(t, "", Visitor.Username())
})
t.Run("UnknownUser", func(t *testing.T) {
- assert.Equal(t, "", UnknownUser.Login())
+ assert.Equal(t, "", UnknownUser.Username())
})
t.Run("Admin", func(t *testing.T) {
- assert.Equal(t, "admin", Admin.Login())
+ assert.Equal(t, "admin", Admin.Username())
})
}
func TestUser_Provider(t *testing.T) {
t.Run("Visitor", func(t *testing.T) {
- assert.Equal(t, "", Visitor.Provider())
+ assert.Equal(t, authn.ProviderToken, Visitor.Provider())
})
t.Run("UnknownUser", func(t *testing.T) {
- assert.Equal(t, "", UnknownUser.Provider())
+ assert.Equal(t, authn.ProviderNone, UnknownUser.Provider())
})
t.Run("Admin", func(t *testing.T) {
- assert.Equal(t, "password", Admin.Provider())
+ assert.Equal(t, authn.ProviderLocal, Admin.Provider())
})
}
@@ -918,7 +1040,7 @@ func TestUser_Handle(t *testing.T) {
CanInvite: false,
}
- assert.Equal(t, "mr-happy@cat.com", u.Login())
+ assert.Equal(t, "mr-happy@cat.com", u.Username())
assert.Equal(t, "mr-happy", u.Handle())
u.UserName = "mr.happy@cat.com"
@@ -959,7 +1081,7 @@ func TestUser_FullName(t *testing.T) {
assert.Equal(t, "Foo", u.FullName())
- u.SetDisplayName("Jane Doe")
+ u.SetDisplayName("Jane Doe", SrcManual)
assert.Equal(t, "Jane Doe", u.FullName())
})
diff --git a/internal/entity/entity_const.go b/internal/entity/entity_const.go
index 1df1ac6ea..82a556fbe 100644
--- a/internal/entity/entity_const.go
+++ b/internal/entity/entity_const.go
@@ -50,9 +50,3 @@ const (
IsStackable int8 = 0
IsUnstacked int8 = -1
)
-
-// Authentication providers.
-const (
- ProviderNone = ""
- ProviderPassword = "password"
-)
diff --git a/internal/entity/src.go b/internal/entity/src.go
index 347aafb71..ad2e908cf 100644
--- a/internal/entity/src.go
+++ b/internal/entity/src.go
@@ -11,6 +11,7 @@ const (
SrcEstimate = "estimate" // Prio 2
SrcName = "name" // Prio 4
SrcYaml = "yaml" // Prio 8
+ SrcLDAP = "ldap" // Prio 8
SrcLocation = classify.SrcLocation // Prio 8
SrcMarker = "marker" // Prio 8
SrcImage = classify.SrcImage // Prio 8
@@ -37,6 +38,7 @@ var SrcPriority = Priorities{
SrcEstimate: 2,
SrcName: 4,
SrcYaml: 8,
+ SrcLDAP: 8,
SrcLocation: 8,
SrcMarker: 8,
SrcImage: 8,
diff --git a/internal/form/search_photos.go b/internal/form/search_photos.go
index 59db5924c..e3c9f1b43 100644
--- a/internal/form/search_photos.go
+++ b/internal/form/search_photos.go
@@ -41,7 +41,7 @@ type SearchPhotos struct {
Archived bool `form:"archived" notes:"Finds archived pictures"`
Public bool `form:"public" notes:"Excludes private pictures"`
Private bool `form:"private" notes:"Finds private pictures"`
- Favorite bool `form:"favorite" notes:"Finds pictures marked as favorite"`
+ Favorite bool `form:"favorite" notes:"Finds favorites only"`
Unsorted bool `form:"unsorted" notes:"Finds pictures not in an album"`
Lat float32 `form:"lat" notes:"Latitude (GPS Position)"`
Lng float32 `form:"lng" notes:"Longitude (GPS Position)"`
diff --git a/internal/form/search_users.go b/internal/form/search_users.go
new file mode 100644
index 000000000..a5ec4e64b
--- /dev/null
+++ b/internal/form/search_users.go
@@ -0,0 +1,28 @@
+package form
+
+// SearchUsers represents a user search form.
+type SearchUsers struct {
+ User string `form:"user"`
+ Query string `form:"q"`
+ Name string `form:"name"`
+ Email string `form:"email"`
+ Count int `form:"count" binding:"required" serialize:"-"`
+ Offset int `form:"offset" serialize:"-"`
+ Order string `form:"order" serialize:"-"`
+}
+
+func (f *SearchUsers) GetQuery() string {
+ return f.Query
+}
+
+func (f *SearchUsers) SetQuery(q string) {
+ f.Query = q
+}
+
+func (f *SearchUsers) ParseQueryString() error {
+ return ParseQueryString(f)
+}
+
+func NewSearchUsers(query string) SearchUsers {
+ return SearchUsers{Query: query}
+}
diff --git a/internal/form/user.go b/internal/form/user.go
index ee02ff89e..564fe2e38 100644
--- a/internal/form/user.go
+++ b/internal/form/user.go
@@ -8,42 +8,49 @@ import (
// User represents a user account form.
type User struct {
- UserName string `json:"Name,omitempty" yaml:"Name,omitempty"`
- UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
- DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
- UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
- SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
- CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
- WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
- UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"`
- BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
- UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
- Password string `json:"Password,omitempty" yaml:"Password,omitempty"`
- UserDetails *UserDetails `json:"Details,omitempty"`
+ UserName string `json:"Name,omitempty" yaml:"Name,omitempty"`
+ AuthProvider string `json:"Provider,omitempty" yaml:"Provider,omitempty"`
+ UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
+ DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
+ UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
+ SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
+ CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
+ WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
+ UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"`
+ BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
+ UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
+ Password string `json:"Password,omitempty" yaml:"Password,omitempty"`
+ UserDetails *UserDetails `json:"Details,omitempty"`
}
// NewUserFromCli creates a new form with values from a CLI context.
func NewUserFromCli(ctx *cli.Context) User {
return User{
- UserName: clean.Username(ctx.Args().First()),
- UserEmail: clean.Email(ctx.String("email")),
- DisplayName: clean.Name(ctx.String("name")),
- UserRole: clean.Role(ctx.String("role")),
- SuperAdmin: ctx.Bool("superadmin"),
- CanLogin: !ctx.Bool("no-login"),
- WebDAV: ctx.Bool("webdav"),
- UserAttr: clean.Attr(ctx.String("attr")),
- BasePath: clean.UserPath(ctx.String("base-path")),
- UploadPath: clean.UserPath(ctx.String("upload-path")),
- Password: clean.Password(ctx.String("password")),
+ UserName: clean.Username(ctx.Args().First()),
+ AuthProvider: clean.TypeLower(ctx.String("provider")),
+ UserEmail: clean.Email(ctx.String("email")),
+ DisplayName: clean.Name(ctx.String("name")),
+ UserRole: clean.Role(ctx.String("role")),
+ SuperAdmin: ctx.Bool("superadmin"),
+ CanLogin: !ctx.Bool("no-login"),
+ WebDAV: ctx.Bool("webdav"),
+ UserAttr: clean.Attr(ctx.String("attr")),
+ BasePath: clean.UserPath(ctx.String("base-path")),
+ UploadPath: clean.UserPath(ctx.String("upload-path")),
+ Password: clean.Password(ctx.String("password")),
}
}
-// Name returns the sanitized username in lowercase.
-func (f *User) Name() string {
+// Username returns the sanitized username in lowercase.
+func (f *User) Username() string {
return clean.Username(f.UserName)
}
+// Provider returns the sanitized auth provider name.
+func (f *User) Provider() string {
+ return clean.TypeLower(f.AuthProvider)
+}
+
// Email returns the sanitized email in lowercase.
func (f *User) Email() string {
return clean.Email(f.UserEmail)
diff --git a/internal/form/user_login.go b/internal/form/user_login.go
index 150317c24..61494cfb6 100644
--- a/internal/form/user_login.go
+++ b/internal/form/user_login.go
@@ -12,9 +12,9 @@ type Login struct {
AuthToken string `json:"token,omitempty"`
}
-// Name returns the sanitized username in lowercase.
-func (f Login) Name() string {
- return clean.DN(f.UserName)
+// Username returns the sanitized username in lowercase.
+func (f Login) Username() string {
+ return clean.Username(f.UserName)
}
// Email returns the sanitized email in lowercase.
@@ -22,9 +22,9 @@ func (f Login) Email() string {
return clean.Email(f.UserEmail)
}
-// HasName checks if a username is set.
-func (f Login) HasName() bool {
- if l := len(f.Name()); l == 0 || l > 255 {
+// HasUsername checks if a username is set.
+func (f Login) HasUsername() bool {
+ if l := len(f.Username()); l == 0 || l > 255 {
return false
}
return true
@@ -42,5 +42,5 @@ func (f Login) HasToken() bool {
// HasCredentials checks if all credentials is set.
func (f Login) HasCredentials() bool {
- return f.HasName() && f.HasPassword()
+ return f.HasUsername() && f.HasPassword()
}
diff --git a/internal/form/user_login_test.go b/internal/form/user_login_test.go
index c958c84ed..49deb1e17 100644
--- a/internal/form/user_login_test.go
+++ b/internal/form/user_login_test.go
@@ -20,11 +20,11 @@ func TestLogin_HasToken(t *testing.T) {
func TestLogin_HasName(t *testing.T) {
t.Run("false", func(t *testing.T) {
form := &Login{UserEmail: "test@test.com", Password: "passwd", AuthToken: ""}
- assert.Equal(t, false, form.HasName())
+ assert.Equal(t, false, form.HasUsername())
})
t.Run("true", func(t *testing.T) {
form := &Login{UserEmail: "test@test.com", UserName: "John", Password: "passwd", AuthToken: "123"}
- assert.Equal(t, true, form.HasName())
+ assert.Equal(t, true, form.HasUsername())
})
}
diff --git a/internal/form/user_test.go b/internal/form/user_test.go
index 66f5b874b..8e53d0786 100644
--- a/internal/form/user_test.go
+++ b/internal/form/user_test.go
@@ -14,18 +14,18 @@ func TestUser(t *testing.T) {
assert.Equal(t, "passwd", form.Password)
}
-func TestUser_Name(t *testing.T) {
+func TestUser_Username(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
form := &User{UserName: "", UserEmail: "test@test.com", Password: "passwd"}
- assert.Equal(t, "", form.Name())
+ assert.Equal(t, "", form.Username())
})
t.Run("Valid", func(t *testing.T) {
form := &User{UserName: "foobar", UserEmail: "test@test.com", Password: "passwd"}
- assert.Equal(t, "foobar", form.Name())
+ assert.Equal(t, "foobar", form.Username())
})
t.Run("Invalid", func(t *testing.T) {
form := &User{UserName: " Foo Bar4w45 !", UserEmail: "test@test.com", Password: "passwd"}
- assert.Equal(t, "foobar4w45", form.Name())
+ assert.Equal(t, "foo bar4w45 !", form.Username())
})
}
diff --git a/internal/query/users_test.go b/internal/query/users_test.go
index 6fe4e4cf1..814dc32f1 100644
--- a/internal/query/users_test.go
+++ b/internal/query/users_test.go
@@ -11,7 +11,7 @@ func TestRegisteredUsers(t *testing.T) {
users := RegisteredUsers()
for _, user := range users {
- t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.Name(), user.DisplayName)
+ t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.Username(), user.DisplayName)
assert.NotEmpty(t, user.UserUID)
}
diff --git a/internal/search/users.go b/internal/search/users.go
new file mode 100644
index 000000000..69f9b16da
--- /dev/null
+++ b/internal/search/users.go
@@ -0,0 +1,49 @@
+package search
+
+import (
+ "strings"
+
+ "github.com/photoprism/photoprism/internal/entity"
+ "github.com/photoprism/photoprism/internal/form"
+ "github.com/photoprism/photoprism/pkg/rnd"
+ "github.com/photoprism/photoprism/pkg/txt"
+)
+
+// Users finds registered users.
+func Users(f form.SearchUsers) (result entity.Users, err error) {
+ result = entity.Users{}
+ stmt := Db()
+
+ search := strings.TrimSpace(f.Query)
+ sortOrder := f.Order
+ limit := f.Count
+ offset := f.Offset
+
+ if search == "all" {
+ // Don't filter.
+ } else if id := txt.Int(search); id != 0 {
+ stmt = stmt.Where("id = ?", id)
+ } else if rnd.IsUID(search, entity.UserUID) {
+ stmt = stmt.Where("user_uid = ?", search)
+ } else if search != "" {
+ stmt = stmt.Where("user_name LIKE ? OR user_email LIKE ? OR display_name LIKE ?", search+"%", search+"%", search+"%")
+ } else {
+ stmt = stmt.Where("id > 0")
+ }
+
+ if sortOrder == "" {
+ sortOrder = "user_name, id"
+ }
+
+ if limit > 0 {
+ stmt = stmt.Limit(limit)
+
+ if offset > 0 {
+ stmt = stmt.Offset(offset)
+ }
+ }
+
+ err = stmt.Order(sortOrder).Find(&result).Error
+
+ return result, err
+}
diff --git a/internal/server/routes.go b/internal/server/routes.go
index 5c72294e4..4a619eb79 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -7,6 +7,8 @@ import (
"github.com/photoprism/photoprism/internal/config"
)
+var APIv1 *gin.RouterGroup
+
// registerRoutes configures the available web server routes.
func registerRoutes(router *gin.Engine, conf *config.Config) {
// Enables automatic redirection if the current route cannot be matched but a
@@ -26,142 +28,139 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
registerSharingRoutes(router, conf)
// JSON-REST API Version 1
- v1 := router.Group(conf.BaseUri(config.ApiUri))
- {
- // Authentication.
- api.CreateSession(v1)
- api.GetSession(v1)
- api.DeleteSession(v1)
+ // Authentication.
+ api.CreateSession(APIv1)
+ api.GetSession(APIv1)
+ api.DeleteSession(APIv1)
- // Global Config.
- api.GetConfigOptions(v1)
- api.SaveConfigOptions(v1)
+ // Global Config.
+ api.GetConfigOptions(APIv1)
+ api.SaveConfigOptions(APIv1)
- // Custom Settings.
- api.GetClientConfig(v1)
- api.GetSettings(v1)
- api.SaveSettings(v1)
+ // Custom Settings.
+ api.GetClientConfig(APIv1)
+ api.GetSettings(APIv1)
+ api.SaveSettings(APIv1)
- // Profile and Uploads.
- api.UploadUserFiles(v1)
- api.ProcessUserUpload(v1)
- api.UploadUserAvatar(v1)
- api.UpdateUserPassword(v1)
- api.UpdateUser(v1)
+ // Profile and Uploads.
+ api.UploadUserFiles(APIv1)
+ api.ProcessUserUpload(APIv1)
+ api.UploadUserAvatar(APIv1)
+ api.UpdateUserPassword(APIv1)
+ api.UpdateUser(APIv1)
- // Service Accounts.
- api.SearchServices(v1)
- api.GetService(v1)
- api.GetServiceFolders(v1)
- api.UploadToService(v1)
- api.AddService(v1)
- api.DeleteService(v1)
- api.UpdateService(v1)
+ // Service Accounts.
+ api.SearchServices(APIv1)
+ api.GetService(APIv1)
+ api.GetServiceFolders(APIv1)
+ api.UploadToService(APIv1)
+ api.AddService(APIv1)
+ api.DeleteService(APIv1)
+ api.UpdateService(APIv1)
- // Thumbnail Images.
- api.GetThumb(v1)
+ // Thumbnail Images.
+ api.GetThumb(APIv1)
- // Video Streaming.
- api.GetVideo(v1)
+ // Video Streaming.
+ api.GetVideo(APIv1)
- // Downloads.
- api.GetDownload(v1)
- api.ZipCreate(v1)
- api.ZipDownload(v1)
+ // Downloads.
+ api.GetDownload(APIv1)
+ api.ZipCreate(APIv1)
+ api.ZipDownload(APIv1)
- // Index and Import.
- api.StartImport(v1)
- api.CancelImport(v1)
- api.StartIndexing(v1)
- api.CancelIndexing(v1)
+ // Index and Import.
+ api.StartImport(APIv1)
+ api.CancelImport(APIv1)
+ api.StartIndexing(APIv1)
+ api.CancelIndexing(APIv1)
- // Photo Search and Organization.
- api.SearchPhotos(v1)
- api.SearchGeo(v1)
- api.GetPhoto(v1)
- api.GetPhotoYaml(v1)
- api.UpdatePhoto(v1)
- api.GetPhotoDownload(v1)
- // api.GetPhotoLinks(v1)
- // api.CreatePhotoLink(v1)
- // api.UpdatePhotoLink(v1)
- // api.DeletePhotoLink(v1)
- api.ApprovePhoto(v1)
- api.LikePhoto(v1)
- api.DislikePhoto(v1)
- api.AddPhotoLabel(v1)
- api.RemovePhotoLabel(v1)
- api.UpdatePhotoLabel(v1)
- api.GetMomentsTime(v1)
- api.GetFile(v1)
- api.DeleteFile(v1)
- api.UpdateMarker(v1)
- api.ClearMarkerSubject(v1)
- api.PhotoPrimary(v1)
- api.PhotoUnstack(v1)
+ // Photo Search and Organization.
+ api.SearchPhotos(APIv1)
+ api.SearchGeo(APIv1)
+ api.GetPhoto(APIv1)
+ api.GetPhotoYaml(APIv1)
+ api.UpdatePhoto(APIv1)
+ api.GetPhotoDownload(APIv1)
+ // api.GetPhotoLinks(APIv1)
+ // api.CreatePhotoLink(APIv1)
+ // api.UpdatePhotoLink(APIv1)
+ // api.DeletePhotoLink(APIv1)
+ api.ApprovePhoto(APIv1)
+ api.LikePhoto(APIv1)
+ api.DislikePhoto(APIv1)
+ api.AddPhotoLabel(APIv1)
+ api.RemovePhotoLabel(APIv1)
+ api.UpdatePhotoLabel(APIv1)
+ api.GetMomentsTime(APIv1)
+ api.GetFile(APIv1)
+ api.DeleteFile(APIv1)
+ api.UpdateMarker(APIv1)
+ api.ClearMarkerSubject(APIv1)
+ api.PhotoPrimary(APIv1)
+ api.PhotoUnstack(APIv1)
- // Photo Albums.
- api.SearchAlbums(v1)
- api.GetAlbum(v1)
- api.AlbumCover(v1)
- api.CreateAlbum(v1)
- api.UpdateAlbum(v1)
- api.DeleteAlbum(v1)
- api.DownloadAlbum(v1)
- api.GetAlbumLinks(v1)
- api.CreateAlbumLink(v1)
- api.UpdateAlbumLink(v1)
- api.DeleteAlbumLink(v1)
- api.LikeAlbum(v1)
- api.DislikeAlbum(v1)
- api.CloneAlbums(v1)
- api.AddPhotosToAlbum(v1)
- api.RemovePhotosFromAlbum(v1)
+ // Photo Albums.
+ api.SearchAlbums(APIv1)
+ api.GetAlbum(APIv1)
+ api.AlbumCover(APIv1)
+ api.CreateAlbum(APIv1)
+ api.UpdateAlbum(APIv1)
+ api.DeleteAlbum(APIv1)
+ api.DownloadAlbum(APIv1)
+ api.GetAlbumLinks(APIv1)
+ api.CreateAlbumLink(APIv1)
+ api.UpdateAlbumLink(APIv1)
+ api.DeleteAlbumLink(APIv1)
+ api.LikeAlbum(APIv1)
+ api.DislikeAlbum(APIv1)
+ api.CloneAlbums(APIv1)
+ api.AddPhotosToAlbum(APIv1)
+ api.RemovePhotosFromAlbum(APIv1)
- // Photo Labels.
- api.SearchLabels(v1)
- api.LabelCover(v1)
- api.UpdateLabel(v1)
- // api.GetLabelLinks(v1)
- // api.CreateLabelLink(v1)
- // api.UpdateLabelLink(v1)
- // api.DeleteLabelLink(v1)
- api.LikeLabel(v1)
- api.DislikeLabel(v1)
+ // Photo Labels.
+ api.SearchLabels(APIv1)
+ api.LabelCover(APIv1)
+ api.UpdateLabel(APIv1)
+ // api.GetLabelLinks(APIv1)
+ // api.CreateLabelLink(APIv1)
+ // api.UpdateLabelLink(APIv1)
+ // api.DeleteLabelLink(APIv1)
+ api.LikeLabel(APIv1)
+ api.DislikeLabel(APIv1)
- // Files and Folders.
- api.SearchFoldersOriginals(v1)
- api.SearchFoldersImport(v1)
- api.FolderCover(v1)
+ // Files and Folders.
+ api.SearchFoldersOriginals(APIv1)
+ api.SearchFoldersImport(APIv1)
+ api.FolderCover(APIv1)
- // People.
- api.SearchSubjects(v1)
- api.GetSubject(v1)
- api.UpdateSubject(v1)
- api.LikeSubject(v1)
- api.DislikeSubject(v1)
+ // People.
+ api.SearchSubjects(APIv1)
+ api.GetSubject(APIv1)
+ api.UpdateSubject(APIv1)
+ api.LikeSubject(APIv1)
+ api.DislikeSubject(APIv1)
- // Faces.
- api.SearchFaces(v1)
- api.GetFace(v1)
- api.UpdateFace(v1)
+ // Faces.
+ api.SearchFaces(APIv1)
+ api.GetFace(APIv1)
+ api.UpdateFace(APIv1)
- // Batch Operations.
- api.BatchPhotosApprove(v1)
- api.BatchPhotosArchive(v1)
- api.BatchPhotosRestore(v1)
- api.BatchPhotosPrivate(v1)
- api.BatchPhotosDelete(v1)
- api.BatchAlbumsDelete(v1)
- api.BatchLabelsDelete(v1)
+ // Batch Operations.
+ api.BatchPhotosApprove(APIv1)
+ api.BatchPhotosArchive(APIv1)
+ api.BatchPhotosRestore(APIv1)
+ api.BatchPhotosPrivate(APIv1)
+ api.BatchPhotosDelete(APIv1)
+ api.BatchAlbumsDelete(APIv1)
+ api.BatchLabelsDelete(APIv1)
- // Technical Endpoints.
- api.GetSvg(v1)
- api.GetStatus(v1)
- api.GetErrors(v1)
- api.DeleteErrors(v1)
- api.SendFeedback(v1)
- api.Connect(v1)
- api.WebSocket(v1)
- }
+ // Technical Endpoints.
+ api.GetSvg(APIv1)
+ api.GetStatus(APIv1)
+ api.GetErrors(APIv1)
+ api.DeleteErrors(APIv1)
+ api.SendFeedback(APIv1)
+ api.Connect(APIv1)
+ api.WebSocket(APIv1)
}
diff --git a/internal/server/routes_webdav.go b/internal/server/routes_webdav.go
index e6afc2154..681ef7c7b 100644
--- a/internal/server/routes_webdav.go
+++ b/internal/server/routes_webdav.go
@@ -167,5 +167,5 @@ func MarkUploadAsFavorite(fileName string) {
}
// Log success.
- log.Infof("webdav: marked %s as favorite", clean.Log(filepath.Base(fileName)))
+ log.Infof("webdav: flagged %s as favorite", clean.Log(filepath.Base(fileName)))
}
diff --git a/internal/server/start.go b/internal/server/start.go
index 28363721d..20e4def99 100644
--- a/internal/server/start.go
+++ b/internal/server/start.go
@@ -43,6 +43,9 @@ func Start(ctx context.Context, conf *config.Config) {
// Register common middleware.
router.Use(Recovery(), Security(conf), Logger())
+ // Create REST API router group.
+ APIv1 = router.Group(conf.BaseUri(config.ApiUri))
+
// Initialize package extensions.
Ext().Init(router, conf)
diff --git a/pkg/authn/authn.go b/pkg/authn/authn.go
new file mode 100644
index 000000000..be1f10d03
--- /dev/null
+++ b/pkg/authn/authn.go
@@ -0,0 +1,25 @@
+/*
+Package authn helps integrate and abstract authentication providers.
+
+Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved.
+
+ This program is free software: you can redistribute it and/or modify
+ it under Version 3 of the GNU Affero General Public License (the "AGPL"):
+
+
+ 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.
+
+ The AGPL is supplemented by our Trademark and Brand Guidelines,
+ which describe how our Brand Assets may be used:
+
+
+Feel free to send an email to hello@photoprism.app if you have questions,
+want to support our work, or just want to say hello.
+
+Additional information can be found in our Developer Guide:
+
+*/
+package authn
diff --git a/pkg/authn/providers.go b/pkg/authn/providers.go
new file mode 100644
index 000000000..58d3332e8
--- /dev/null
+++ b/pkg/authn/providers.go
@@ -0,0 +1,19 @@
+package authn
+
+// Authentication providers.
+const (
+ ProviderDefault = ""
+ ProviderNone = "none"
+ ProviderToken = "token"
+ ProviderLocal = "local"
+ ProviderLDAP = "ldap"
+)
+
+// ProviderString returns the provider name as a string for use in logs and reports.
+func ProviderString(s string) string {
+ if s == ProviderDefault {
+ return "default"
+ }
+
+ return s
+}
diff --git a/pkg/authn/providers_test.go b/pkg/authn/providers_test.go
new file mode 100644
index 000000000..1dd97be4f
--- /dev/null
+++ b/pkg/authn/providers_test.go
@@ -0,0 +1,15 @@
+package authn
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestProviderString(t *testing.T) {
+ assert.Equal(t, "default", ProviderString(""))
+ assert.Equal(t, "default", ProviderString(ProviderDefault))
+ assert.Equal(t, "none", ProviderString(ProviderNone))
+ assert.Equal(t, "local", ProviderString(ProviderLocal))
+ assert.Equal(t, "ldap", ProviderString(ProviderLDAP))
+}
diff --git a/pkg/clean/auth.go b/pkg/clean/auth.go
index 4bb490c74..7d043c505 100644
--- a/pkg/clean/auth.go
+++ b/pkg/clean/auth.go
@@ -10,8 +10,8 @@ import (
var EmailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
-// Username returns the sanitized username with trimmed whitespace and in lowercase.
-func Username(s string) string {
+// Handle returns the sanitized username with trimmed whitespace and in lowercase.
+func Handle(s string) string {
// Remove unwanted characters.
s = strings.Map(func(r rune) rune {
if r <= 42 || r == 127 {
@@ -32,8 +32,8 @@ func Username(s string) string {
return strings.ToLower(s)
}
-// DN returns the sanitized distinguished name (DN) with trimmed whitespace and in lowercase.
-func DN(s string) string {
+// Username returns the sanitized distinguished name (Username) with trimmed whitespace and in lowercase.
+func Username(s string) string {
s = strings.TrimSpace(s)
// Remove unwanted characters.
diff --git a/pkg/clean/auth_test.go b/pkg/clean/auth_test.go
index e16669cbe..a4de93c83 100644
--- a/pkg/clean/auth_test.go
+++ b/pkg/clean/auth_test.go
@@ -6,12 +6,24 @@ import (
"github.com/stretchr/testify/assert"
)
+func TestHandle(t *testing.T) {
+ t.Run("Admin ", func(t *testing.T) {
+ assert.Equal(t, "admin", Handle("Admin "))
+ })
+ t.Run(" Admin ", func(t *testing.T) {
+ assert.Equal(t, "adminfoo", Handle(" Admin@foo "))
+ })
+ t.Run(" admin ", func(t *testing.T) {
+ assert.Equal(t, "admin", Handle(" admin "))
+ })
+}
+
func TestUsername(t *testing.T) {
t.Run("Admin ", func(t *testing.T) {
assert.Equal(t, "admin", Username("Admin "))
})
t.Run(" Admin ", func(t *testing.T) {
- assert.Equal(t, "admin", Username(" Admin "))
+ assert.Equal(t, "admin@foo", Username(" Admin@foo "))
})
t.Run(" admin ", func(t *testing.T) {
assert.Equal(t, "admin", Username(" admin "))
diff --git a/pkg/txt/empty.go b/pkg/txt/empty.go
index 6a3fd7848..5601bf8e6 100644
--- a/pkg/txt/empty.go
+++ b/pkg/txt/empty.go
@@ -10,7 +10,7 @@ func Empty(s string) bool {
return true
} else if s = strings.Trim(s, "%* "); s == "" || s == "0" || s == "-1" || DateTimeDefault(s) {
return true
- } else if s = strings.ToLower(s); s == "nil" || s == "null" || s == "nan" {
+ } else if s = strings.ToLower(s); s == "nil" || s == "null" || s == "none" || s == "nan" {
return true
}
| | |