From 60162b3fc510c6cafd7983ad08fee9547b893858 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 8 Mar 2023 23:30:39 +0100 Subject: [PATCH] Auth: Refactor user management API and CLI commands #98 Signed-off-by: Michael Mayer --- frontend/src/app/routes.js | 15 +- frontend/src/component/navigation.vue | 9 + frontend/src/css/results.css | 8 + frontend/src/dialog/account/password.vue | 26 ++- frontend/src/dialog/dialogs.js | 2 + frontend/src/dialog/service/add.vue | 4 +- frontend/src/model/rest.js | 18 +- frontend/src/model/user.js | 11 +- frontend/src/page/admin.vue | 18 ++ frontend/src/page/{ => auth}/login.vue | 0 frontend/src/page/discover.vue | 2 +- frontend/src/page/settings/account.vue | 4 +- frontend/src/page/settings/library.vue | 1 - frontend/src/page/settings/services.vue | 10 +- go.mod | 2 +- go.sum | 5 +- internal/acl/acl_resources.go | 2 +- internal/api/users_avatar.go | 53 +++-- internal/api/users_password.go | 24 +- internal/api/users_update.go | 13 +- internal/api/users_update_test.go | 2 +- internal/api/users_upload.go | 5 +- internal/commands/passwd.go | 4 +- internal/commands/users.go | 9 +- internal/commands/users_add.go | 2 +- internal/commands/users_list.go | 9 +- internal/entity/auth_session.go | 4 +- internal/entity/auth_session_login.go | 18 +- internal/entity/auth_session_report.go | 2 +- internal/entity/auth_user.go | 182 +++++++++++---- internal/entity/auth_user_add.go | 2 +- internal/entity/auth_user_default.go | 4 + internal/entity/auth_user_fixtures_test.go | 6 +- internal/entity/auth_user_report.go | 2 +- internal/entity/auth_user_test.go | 164 ++++++++++++-- internal/entity/entity_const.go | 6 - internal/entity/src.go | 2 + internal/form/search_photos.go | 2 +- internal/form/search_users.go | 28 +++ internal/form/user.go | 57 ++--- internal/form/user_login.go | 14 +- internal/form/user_login_test.go | 4 +- internal/form/user_test.go | 8 +- internal/query/users_test.go | 2 +- internal/search/users.go | 49 +++++ internal/server/routes.go | 243 ++++++++++----------- internal/server/routes_webdav.go | 2 +- internal/server/start.go | 3 + pkg/authn/authn.go | 25 +++ pkg/authn/providers.go | 19 ++ pkg/authn/providers_test.go | 15 ++ pkg/clean/auth.go | 8 +- pkg/clean/auth_test.go | 14 +- pkg/txt/empty.go | 2 +- 54 files changed, 818 insertions(+), 327 deletions(-) create mode 100644 frontend/src/page/admin.vue rename frontend/src/page/{ => auth}/login.vue (100%) create mode 100644 internal/form/search_users.go create mode 100644 internal/search/users.go create mode 100644 pkg/authn/authn.go create mode 100644 pkg/authn/providers.go create mode 100644 pkg/authn/providers_test.go diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index ddd8a89a4..2ee646ca7 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -33,7 +33,8 @@ import Labels from "page/labels.vue"; import People from "page/people.vue"; import Library from "page/library.vue"; import Settings from "page/settings.vue"; -import Login from "page/login.vue"; +import Admin from "page/admin.vue"; +import Login from "page/auth/login.vue"; import Discover from "page/discover.vue"; import About from "page/about/about.vue"; import Feedback from "page/about/feedback.vue"; @@ -91,6 +92,18 @@ export default [ } }, }, + { + name: "admin", + path: "/admin/*", + component: Admin, + meta: { + title: $gettext("Settings"), + auth: true, + admin: true, + settings: true, + background: "application-light", + }, + }, { name: "upgrade", path: "/upgrade", diff --git a/frontend/src/component/navigation.vue b/frontend/src/component/navigation.vue index fe3ec54c1..f4e129a1f 100644 --- a/frontend/src/component/navigation.vue +++ b/frontend/src/component/navigation.vue @@ -476,6 +476,14 @@ + + + + Users + + + + @@ -696,6 +704,7 @@ export default { canAccessPrivate: !isRestricted && this.$config.allow("photos", "access_private"), canManagePhotos: this.$config.allow("photos", "manage"), canManagePeople: this.$config.allow("people", "manage"), + canManageUsers: this.$config.allow("users", "manage"), appNameSuffix: appNameSuffix, appName: this.$config.getName(), appAbout: this.$config.getAbout(), diff --git a/frontend/src/css/results.css b/frontend/src/css/results.css index 130031192..bdbdc2b73 100644 --- a/frontend/src/css/results.css +++ b/frontend/src/css/results.css @@ -63,6 +63,14 @@ body.chrome #photoprism .search-results .result { top: 12px; } +#photoprism .list-view .action-secondary { + opacity: 0.4; +} + +#photoprism .list-view tr:hover .action-secondary { + opacity: 1; +} + #photoprism .p-clipboard.--ltr { right: 8px; bottom: 12px; diff --git a/frontend/src/dialog/account/password.vue b/frontend/src/dialog/account/password.vue index 02480385e..c7fa27c21 100644 --- a/frontend/src/dialog/account/password.vue +++ b/frontend/src/dialog/account/password.vue @@ -14,10 +14,10 @@ - + Please note that changing your password will log you out on other devices and browsers. - + diff --git a/frontend/src/page/login.vue b/frontend/src/page/auth/login.vue similarity index 100% rename from frontend/src/page/login.vue rename to frontend/src/page/auth/login.vue diff --git a/frontend/src/page/discover.vue b/frontend/src/page/discover.vue index f9827dc31..f699f6be6 100644 --- a/frontend/src/page/discover.vue +++ b/frontend/src/page/discover.vue @@ -50,7 +50,7 @@ import tabColors from "page/discover/colors.vue"; import tabTodo from "page/discover/todo.vue"; export default { - name: 'PPageSettings', + name: 'PPageDiscover', components: { 'p-tab-discover-colors': tabColors, 'p-tab-discover-todo': tabTodo, diff --git a/frontend/src/page/settings/account.vue b/frontend/src/page/settings/account.vue index d0ad05232..d152d9877 100644 --- a/frontend/src/page/settings/account.vue +++ b/frontend/src/page/settings/account.vue @@ -2,7 +2,7 @@ diff --git a/frontend/src/page/settings/library.vue b/frontend/src/page/settings/library.vue index 7b69b9923..9a65b7c41 100644 --- a/frontend/src/page/settings/library.vue +++ b/frontend/src/page/settings/library.vue @@ -12,7 +12,6 @@ - @@ -19,6 +19,7 @@ check settings @@ -27,6 +28,7 @@ report_problem @@ -37,12 +39,14 @@ {{ formatDate(props.item.SyncDate) }} 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 }