Auth: Refactor user management API and CLI commands #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-03-08 23:30:39 +01:00
parent e19697bd98
commit 60162b3fc5
54 changed files with 818 additions and 327 deletions

View file

@ -33,7 +33,8 @@ import Labels from "page/labels.vue";
import People from "page/people.vue"; import People from "page/people.vue";
import Library from "page/library.vue"; import Library from "page/library.vue";
import Settings from "page/settings.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 Discover from "page/discover.vue";
import About from "page/about/about.vue"; import About from "page/about/about.vue";
import Feedback from "page/about/feedback.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", name: "upgrade",
path: "/upgrade", path: "/upgrade",

View file

@ -476,6 +476,14 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile v-if="canManageUsers" :to="{ path: '/admin/users' }" :exact="false" class="nav-admin-users" @click.stop="">
<v-list-tile-content>
<v-list-tile-title :class="`menu-item ${rtl ? '--rtl' : ''}`">
<translate>Users</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-show="!isPublic && isAdmin && isSponsor" :to="{ name: 'feedback' }" :exact="true" class="nav-feedback" <v-list-tile v-show="!isPublic && isAdmin && isSponsor" :to="{ name: 'feedback' }" :exact="true" class="nav-feedback"
@click.stop=""> @click.stop="">
<v-list-tile-content> <v-list-tile-content>
@ -696,6 +704,7 @@ export default {
canAccessPrivate: !isRestricted && this.$config.allow("photos", "access_private"), canAccessPrivate: !isRestricted && this.$config.allow("photos", "access_private"),
canManagePhotos: this.$config.allow("photos", "manage"), canManagePhotos: this.$config.allow("photos", "manage"),
canManagePeople: this.$config.allow("people", "manage"), canManagePeople: this.$config.allow("people", "manage"),
canManageUsers: this.$config.allow("users", "manage"),
appNameSuffix: appNameSuffix, appNameSuffix: appNameSuffix,
appName: this.$config.getName(), appName: this.$config.getName(),
appAbout: this.$config.getAbout(), appAbout: this.$config.getAbout(),

View file

@ -63,6 +63,14 @@ body.chrome #photoprism .search-results .result {
top: 12px; top: 12px;
} }
#photoprism .list-view .action-secondary {
opacity: 0.4;
}
#photoprism .list-view tr:hover .action-secondary {
opacity: 1;
}
#photoprism .p-clipboard.--ltr { #photoprism .p-clipboard.--ltr {
right: 8px; right: 8px;
bottom: 12px; bottom: 12px;

View file

@ -14,10 +14,10 @@
</v-card-title> </v-card-title>
<v-card-text class="py-0 px-2"> <v-card-text class="py-0 px-2">
<v-layout wrap align-top> <v-layout wrap align-top>
<v-flex xs12 class="px-2 pb-2 caption"> <v-flex v-if="oldRequired" xs12 class="px-2 pb-2 caption">
<translate>Please note that changing your password will log you out on other devices and browsers.</translate> <translate>Please note that changing your password will log you out on other devices and browsers.</translate>
</v-flex> </v-flex>
<v-flex xs12 class="px-2 py-1"> <v-flex v-if="oldRequired" xs12 class="px-2 py-1">
<v-text-field <v-text-field
v-model="oldPassword" v-model="oldPassword"
hide-details required box flat hide-details required box flat
@ -88,10 +88,16 @@
</v-dialog> </v-dialog>
</template> </template>
<script> <script>
import User from "../../model/user";
export default { export default {
name: 'PAccountPasswordDialog', name: 'PAccountPasswordDialog',
props: { props: {
show: Boolean, show: Boolean,
model: {
type: Object,
default: () => new User(null),
},
}, },
data() { data() {
return { return {
@ -105,7 +111,17 @@ export default {
rtl: this.$rtl, rtl: this.$rtl,
}; };
}, },
computed: {}, computed: {
oldRequired() {
if (!this.model) {
return true;
}
const sessionUser = this.$session.getUser();
return !sessionUser.SuperAdmin || this.model.getId() === sessionUser.getId();
},
},
created() { created() {
if(this.isPublic && !this.isDemo) { if(this.isPublic && !this.isDemo) {
this.$emit('cancel'); this.$emit('cancel');
@ -113,11 +129,11 @@ export default {
}, },
methods: { methods: {
disabled() { disabled() {
return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < this.passwordLength || (this.newPassword !== this.confirmPassword)); return (this.isDemo || this.busy || this.oldPassword === "" && this.oldRequired || this.newPassword.length < this.passwordLength || (this.newPassword !== this.confirmPassword));
}, },
confirm() { confirm() {
this.busy = true; this.busy = true;
this.$session.getUser().changePassword(this.oldPassword, this.newPassword).then(() => { this.model.changePassword(this.oldPassword, this.newPassword).then(() => {
this.$notify.success(this.$gettext("Password changed")); this.$notify.success(this.$gettext("Password changed"));
this.$emit('confirm'); this.$emit('confirm');
}).finally(() => { }).finally(() => {

View file

@ -43,6 +43,7 @@ import PWebdavDialog from "dialog/webdav.vue";
import PReloadDialog from "dialog/reload.vue"; import PReloadDialog from "dialog/reload.vue";
import PSponsorDialog from "dialog/sponsor.vue"; import PSponsorDialog from "dialog/sponsor.vue";
import PConfirmDialog from "dialog/confirm.vue"; import PConfirmDialog from "dialog/confirm.vue";
import PAccountPasswordDialog from "dialog/account/password.vue";
const dialogs = {}; const dialogs = {};
@ -67,6 +68,7 @@ dialogs.install = (Vue) => {
Vue.component("PReloadDialog", PReloadDialog); Vue.component("PReloadDialog", PReloadDialog);
Vue.component("PSponsorDialog", PSponsorDialog); Vue.component("PSponsorDialog", PSponsorDialog);
Vue.component("PConfirmDialog", PConfirmDialog); Vue.component("PConfirmDialog", PConfirmDialog);
Vue.component("PAccountPasswordDialog", PAccountPasswordDialog);
}; };
export default dialogs; export default dialogs;

View file

@ -1,5 +1,5 @@
<template> <template>
<v-dialog :value="show" lazy persistent max-width="500" class="p-account-create-dialog" @keydown.esc="cancel"> <v-dialog :value="show" lazy persistent max-width="500" class="p-account-add-dialog" @keydown.esc="cancel">
<v-card raised elevation="24"> <v-card raised elevation="24">
<v-card-title primary-title class="pa-2"> <v-card-title primary-title class="pa-2">
<v-layout row wrap> <v-layout row wrap>
@ -76,7 +76,7 @@ import Service from "model/service";
import * as options from "options/options"; import * as options from "options/options";
export default { export default {
name: 'PAccountCreateDialog', name: 'PAccountAddDialog',
props: { props: {
show: Boolean, show: Boolean,
}, },

View file

@ -31,7 +31,11 @@ import { $gettext } from "common/vm";
export class Rest extends Model { export class Rest extends Model {
getId() { getId() {
return this.UID ? this.UID : this.ID; if (this.UID) {
return this.UID;
}
return this.ID ? this.ID : "";
} }
hasId() { hasId() {
@ -52,6 +56,16 @@ export class Rest extends Model {
); );
} }
load() {
if (!this.hasId()) {
return;
}
return Api.get(this.getEntityResource(this.getId())).then((resp) =>
Promise.resolve(this.setValues(resp.data))
);
}
save() { save() {
if (this.hasId()) { if (this.hasId()) {
return this.update(); return this.update();
@ -72,7 +86,7 @@ export class Rest extends Model {
} }
// Send PUT request. // Send PUT request.
return Api.put(this.getEntityResource(), this.getValues(true)).then((resp) => return Api.put(this.getEntityResource(), values).then((resp) =>
Promise.resolve(this.setValues(resp.data)) Promise.resolve(this.setValues(resp.data))
); );
} }

View file

@ -101,6 +101,7 @@ export class User extends RestModel {
CreatedAt: "", CreatedAt: "",
UpdatedAt: "", UpdatedAt: "",
}, },
LoginAt: "",
VerifiedAt: "", VerifiedAt: "",
ConsentAt: "", ConsentAt: "",
BornAt: "", BornAt: "",
@ -184,6 +185,10 @@ export class User extends RestModel {
); );
} }
isLocal() {
return !this.AuthProvider || this.AuthProvider === "local";
}
changePassword(oldPassword, newPassword) { changePassword(oldPassword, newPassword) {
return Api.put(this.getEntityResource() + "/password", { return Api.put(this.getEntityResource() + "/password", {
old: oldPassword, old: oldPassword,
@ -191,12 +196,6 @@ export class User extends RestModel {
}).then((response) => Promise.resolve(response.data)); }).then((response) => Promise.resolve(response.data));
} }
save() {
return Api.post(this.getEntityResource(), this.getValues()).then((response) =>
Promise.resolve(this.setValues(response.data))
);
}
static getCollectionResource() { static getCollectionResource() {
return "users"; return "users";
} }

View file

@ -0,0 +1,18 @@
<template>
<p-page-about></p-page-about>
</template>
<script>
import PPageAbout from "./about/about.vue";
export default {
name: 'PPageAdmin',
components: {PPageAbout},
data() {
return {
rtl: this.$rtl,
};
},
methods: {},
};
</script>

View file

@ -50,7 +50,7 @@ import tabColors from "page/discover/colors.vue";
import tabTodo from "page/discover/todo.vue"; import tabTodo from "page/discover/todo.vue";
export default { export default {
name: 'PPageSettings', name: 'PPageDiscover',
components: { components: {
'p-tab-discover-colors': tabColors, 'p-tab-discover-colors': tabColors,
'p-tab-discover-todo': tabTodo, 'p-tab-discover-todo': tabTodo,

View file

@ -2,7 +2,7 @@
<div class="p-tab p-settings-account"> <div class="p-tab p-settings-account">
<v-form ref="form" v-model="valid" lazy-validation dense class="p-form-account pb-4 width-lg" accept-charset="UTF-8" <v-form ref="form" v-model="valid" lazy-validation dense class="p-form-account pb-4 width-lg" accept-charset="UTF-8"
@submit.prevent="onChange"> @submit.prevent="onChange">
<input ref="upload" type="file" class="d-none input-upload" @change.stop="onUploadAvatar()"> <input ref="upload" type="file" class="d-none input-upload" accept="image/png, image/jpeg" @change.stop="onUploadAvatar()">
<v-card flat tile class="mt-2 px-1 application"> <v-card flat tile class="mt-2 px-1 application">
<v-card-actions> <v-card-actions>
<v-layout row wrap align-top> <v-layout row wrap align-top>
@ -296,7 +296,7 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-form> </v-form>
<p-account-password-dialog :show="dialog.password" @cancel="dialog.password = false" @confirm="dialog.password = false"></p-account-password-dialog> <p-account-password-dialog :show="dialog.password" :model="user" @cancel="dialog.password = false" @confirm="dialog.password = false"></p-account-password-dialog>
<p-webdav-dialog :show="dialog.webdav" @close="dialog.webdav = false"></p-webdav-dialog> <p-webdav-dialog :show="dialog.webdav" @close="dialog.webdav = false"></p-webdav-dialog>
</div> </div>
</template> </template>

View file

@ -12,7 +12,6 @@
<v-card-actions> <v-card-actions>
<v-layout wrap align-top> <v-layout wrap align-top>
<v-flex xs12 sm4 class="px-2 pb-2 pt-2"> <v-flex xs12 sm4 class="px-2 pb-2 pt-2">
<v-checkbox <v-checkbox
v-model="settings.features.estimates" v-model="settings.features.estimates"

View file

@ -6,7 +6,7 @@
:items="results" :items="results"
hide-actions hide-actions
disable-initial-sort disable-initial-sort
class="elevation-0 p-accounts p-accounts-list p-results" class="elevation-0 account-results list-view"
item-key="ID" item-key="ID"
:no-data-text="$gettext('No services configured.')" :no-data-text="$gettext('No services configured.')"
> >
@ -19,6 +19,7 @@
<td class="text-xs-center"> <td class="text-xs-center">
<v-btn icon small flat :ripple="false" <v-btn icon small flat :ripple="false"
class="action-toggle-share" class="action-toggle-share"
color="transparent"
@click.stop.prevent="editSharing(props.item)"> @click.stop.prevent="editSharing(props.item)">
<v-icon v-if="props.item.AccShare" color="secondary-dark">check</v-icon> <v-icon v-if="props.item.AccShare" color="secondary-dark">check</v-icon>
<v-icon v-else color="secondary-dark">settings</v-icon> <v-icon v-else color="secondary-dark">settings</v-icon>
@ -27,6 +28,7 @@
<td class="text-xs-center"> <td class="text-xs-center">
<v-btn icon small flat :ripple="false" <v-btn icon small flat :ripple="false"
class="action-toggle-sync" class="action-toggle-sync"
color="transparent"
@click.stop.prevent="editSync(props.item)"> @click.stop.prevent="editSync(props.item)">
<v-icon v-if="props.item.AccErrors" color="secondary-dark" :title="props.item.AccError">report_problem <v-icon v-if="props.item.AccErrors" color="secondary-dark" :title="props.item.AccError">report_problem
</v-icon> </v-icon>
@ -37,12 +39,14 @@
<td class="hidden-sm-and-down">{{ formatDate(props.item.SyncDate) }}</td> <td class="hidden-sm-and-down">{{ formatDate(props.item.SyncDate) }}</td>
<td class="hidden-xs-only text-xs-right" nowrap> <td class="hidden-xs-only text-xs-right" nowrap>
<v-btn icon small flat :ripple="false" <v-btn icon small flat :ripple="false"
class="p-account-remove" class="action-remove action-secondary"
color="transparent"
@click.stop.prevent="remove(props.item)"> @click.stop.prevent="remove(props.item)">
<v-icon color="secondary-dark">delete</v-icon> <v-icon color="secondary-dark">delete</v-icon>
</v-btn> </v-btn>
<v-btn icon small flat :ripple="false" <v-btn icon small flat :ripple="false"
class="p-account-remove" class="action-edit"
color="transparent"
@click.stop.prevent="edit(props.item)"> @click.stop.prevent="edit(props.item)">
<v-icon color="secondary-dark">edit</v-icon> <v-icon color="secondary-dark">edit</v-icon>
</v-btn> </v-btn>

2
go.mod
View file

@ -66,7 +66,7 @@ require (
golang.org/x/oauth2 v0.4.0 // indirect 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 ( require (
golang.org/x/sync v0.1.0 golang.org/x/sync v0.1.0

5
go.sum
View file

@ -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/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.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/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.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= 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 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= 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= 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-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-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-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-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.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=

View file

@ -62,7 +62,7 @@ var Resources = ACL{
RoleAdmin: GrantFullAccess, RoleAdmin: GrantFullAccess,
}, },
ResourceUsers: Roles{ ResourceUsers: Roles{
RoleAdmin: GrantFullAccess, RoleAdmin: Grant{AccessAll: true, AccessOwn: true, ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionSubscribe: true},
}, },
ResourceConfig: Roles{ ResourceConfig: Roles{
RoleAdmin: GrantFullAccess, RoleAdmin: GrantFullAccess,

View file

@ -6,15 +6,15 @@ import (
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin" "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/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
) )
// UploadUserAvatar updates the avatar image of the currently authenticated user. // UploadUserAvatar updates the avatar image of the currently authenticated user.
@ -35,15 +35,18 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return 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")) uid := clean.UID(c.Param("uid"))
// Users may only change their own avatar. // 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) event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "user uid does not match"}, s.RefID)
AbortForbidden(c) AbortForbidden(c)
return return
} }
// Parse upload form.
f, err := c.MultipartForm() f, err := c.MultipartForm()
if err != nil { if err != nil {
@ -52,6 +55,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return return
} }
// Check number of files.
files := f.File["files"] files := f.File["files"]
if len(files) != 1 { if len(files) != 1 {
@ -59,7 +63,16 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return 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 { if err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to create folder", "%s"}, s.RefID, err) 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] 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 { if file.Size > 20000000 {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "file size exceeded"}, s.RefID) event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "file size exceeded"}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrFileTooLarge) 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) event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed) Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return return
} else if !mimeType.Is(fs.MimeTypeJPEG) { } else {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "only jpeg supported"}, s.RefID) switch {
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat) case mimeType.Is(fs.MimeTypePNG):
return 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) filePath := path.Join(uploadDir, fileName)
// Save avatar image.
if err = c.SaveUploadedFile(file, filePath); err != nil { 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)) event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to save %s"}, s.RefID, clean.Log(filePath))
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed) 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)) 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 { if mediaFile, mediaErr := photoprism.NewMediaFile(filePath); mediaErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err) event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat) Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
return return
} else if err = mediaFile.CreateThumbnails(conf.ThumbCachePath(), false); err != nil { } else if err = mediaFile.CreateThumbnails(conf.ThumbCachePath(), false); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err) 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) 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() s.ClearCache()
// Show success message.
log.Info(i18n.Msg(i18n.MsgFileUploaded)) log.Info(i18n.Msg(i18n.MsgFileUploaded))
// Return updated user profile.
c.JSON(http.StatusOK, entity.FindUserByUID(uid)) c.JSON(http.StatusOK, entity.FindUserByUID(uid))
}) })
} }

View file

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/get"
@ -41,15 +42,22 @@ func UpdateUserPassword(router *gin.RouterGroup) {
return 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. // Users may only change their own password.
if s.User().UserUID != clean.UID(c.Param("uid")) { if !isPrivileged && s.User().UserUID != uid {
AbortForbidden(c) AbortForbidden(c)
return return
} } else if s.User().UserUID == uid {
u = s.User()
u := s.User() isPrivileged = false
isSuperAdmin = false
if u == nil { } else if u = entity.FindUserByUID(uid); u == nil {
Abort(c, http.StatusNotFound, i18n.ErrUserNotFound) Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
return return
} }
@ -62,7 +70,9 @@ func UpdateUserPassword(router *gin.RouterGroup) {
} }
// Verify that the old password is correct. // 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)) limiter.Login.Reserve(ClientIP(c))
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword) Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return return

View file

@ -20,11 +20,12 @@ func UpdateUser(router *gin.RouterGroup) {
router.PUT("/users/:uid", func(c *gin.Context) { router.PUT("/users/:uid", func(c *gin.Context) {
conf := get.Config() conf := get.Config()
if conf.Demo() || conf.DisableSettings() { if conf.Public() || conf.DisableSettings() {
AbortForbidden(c) AbortForbidden(c)
return 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}) s := AuthAny(c, acl.ResourceUsers, acl.Permissions{acl.ActionManage, acl.AccessOwn, acl.ActionUpdate})
if s.Abort(c) { if s.Abort(c) {
@ -56,8 +57,16 @@ func UpdateUser(router *gin.RouterGroup) {
return 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. // Save model with values from form.
if err = m.SaveForm(f); err != nil { if err = m.SaveForm(f, isPrivileged); err != nil {
log.Error(err) log.Error(err)
AbortSaveFailed(c) AbortSaveFailed(c)
return return

View file

@ -31,6 +31,6 @@ func TestUpdateUser(t *testing.T) {
reqUrl := fmt.Sprintf("/api/v1/users/%s", adminUid) reqUrl := fmt.Sprintf("/api/v1/users/%s", adminUid)
UpdateUser(router) UpdateUser(router)
r := PerformRequestWithBody(app, "PUT", reqUrl, "{foo:123}") r := PerformRequestWithBody(app, "PUT", reqUrl, "{foo:123}")
assert.Equal(t, http.StatusBadRequest, r.Code) assert.Equal(t, http.StatusForbidden, r.Code)
}) })
} }

View file

@ -40,8 +40,11 @@ func UploadUserFiles(router *gin.RouterGroup) {
return return
} }
uid := clean.UID(c.Param("uid"))
// Users may only upload their own files. // 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) AbortForbidden(c)
return return
} }

View file

@ -61,7 +61,7 @@ func passwdAction(ctx *cli.Context) error {
return fmt.Errorf("user %s has been deleted", clean.LogQuote(id)) 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: ") newPassword := getPassword("New Password: ")
@ -79,7 +79,7 @@ func passwdAction(ctx *cli.Context) error {
return err 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 return nil
} }

View file

@ -10,9 +10,8 @@ import (
const ( const (
UserNameUsage = "full `NAME` for display in the interface" UserNameUsage = "full `NAME` for display in the interface"
UserEmailUsage = "unique `EMAIL` address of the user" UserEmailUsage = "unique `EMAIL` address of the user"
UserPasswordUsage = "`PASSWORD` for authentication" UserPasswordUsage = "`PASSWORD` for local authentication"
UserRoleUsage = "user account `ROLE`" UserRoleUsage = "user role `NAME` (leave blank for default)"
UserAttrUsage = "custom user account `ATTRIBUTES`"
UserAdminUsage = "make user super admin with full access" UserAdminUsage = "make user super admin with full access"
UserNoLoginUsage = "disable login on the web interface" UserNoLoginUsage = "disable login on the web interface"
UserWebDAVUsage = "allow to sync files via WebDAV" UserWebDAVUsage = "allow to sync files via WebDAV"
@ -53,10 +52,6 @@ var UserFlags = []cli.Flag{
Usage: UserRoleUsage, Usage: UserRoleUsage,
Value: acl.RoleAdmin.String(), Value: acl.RoleAdmin.String(),
}, },
cli.StringFlag{
Name: "attr, a",
Usage: UserAttrUsage,
},
cli.BoolFlag{ cli.BoolFlag{
Name: "superadmin, s", Name: "superadmin, s",
Usage: UserAdminUsage, Usage: UserAdminUsage,

View file

@ -47,7 +47,7 @@ func usersAddAction(ctx *cli.Context) error {
return err return err
} }
frm.UserName = clean.DN(res) frm.UserName = clean.Username(res)
} }
// Check if account exists but is deleted. // Check if account exists but is deleted.

View file

@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/report" "github.com/photoprism/photoprism/pkg/report"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
@ -23,7 +24,7 @@ var UsersListCommand = cli.Command{
// usersListAction displays existing user accounts. // usersListAction displays existing user accounts.
func usersListAction(ctx *cli.Context) error { func usersListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) 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. // Fetch users from database.
users := query.RegisteredUsers() users := query.RegisteredUsers()
@ -36,14 +37,12 @@ func usersListAction(ctx *cli.Context) error {
for i, user := range users { for i, user := range users {
rows[i] = []string{ rows[i] = []string{
user.UID(), user.UID(),
user.FullName(), user.Username(),
user.Login(),
user.Email(),
user.AclRole().String(), user.AclRole().String(),
authn.ProviderString(user.Provider()),
report.Bool(user.SuperAdmin, report.Yes, report.No), report.Bool(user.SuperAdmin, report.Yes, report.No),
report.Bool(user.CanLogIn(), report.Enabled, report.Disabled), report.Bool(user.CanLogIn(), report.Enabled, report.Disabled),
report.Bool(user.CanUseWebDAV(), report.Enabled, report.Disabled), report.Bool(user.CanUseWebDAV(), report.Enabled, report.Disabled),
user.Attr(),
txt.TimeStamp(&user.CreatedAt), txt.TimeStamp(&user.CreatedAt),
} }
} }

View file

@ -257,8 +257,8 @@ func (m *Session) SetUser(u *User) *Session {
return m return m
} }
// Login returns the login name. // Username returns the login name.
func (m *Session) Login() string { func (m *Session) Username() string {
return m.UserName return m.UserName
} }

View file

@ -10,27 +10,31 @@ import (
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter" "github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
// Auth checks if the credentials are valid and returns the user and authentication provider. // 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) { 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) user = FindUserByName(name)
err = AuthPassword(user, f, m) err = AuthLocal(user, f, m)
if err != nil { 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. // AuthLocal authenticates against the local user database with the specified username and password.
func AuthPassword(user *User, f form.Login, m *Session) (err error) { func AuthLocal(user *User, f form.Login, m *Session) (err error) {
name := f.Name() name := f.Username()
// User found? // User found?
if user == nil { if user == nil {

View file

@ -19,7 +19,7 @@ func (m *Session) Report(skipEmpty bool) (rows [][]string, cols []string) {
rows = make([][]string, 0, len(values)) rows = make([][]string, 0, len(values))
for k, v := range values { for k, v := range values {
s := fmt.Sprintf("%v", v) s := fmt.Sprintf("%#v", v)
// Skip empty values? // Skip empty values?
if !skipEmpty || s != "" { if !skipEmpty || s != "" {

View file

@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list" "github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
@ -41,22 +42,22 @@ type User struct {
ID int `gorm:"primary_key" json:"-" yaml:"-"` ID int `gorm:"primary_key" json:"-" yaml:"-"`
UUID string `gorm:"type:VARBINARY(64);column:user_uuid;index;" json:"UUID,omitempty" yaml:"UUID,omitempty"` 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"` 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"` AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,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"` UserName string `gorm:"size:255;index;" json:"Name" yaml:"Name,omitempty"`
DisplayName string `gorm:"size:200;" json:"DisplayName" yaml:"DisplayName,omitempty"` DisplayName string `gorm:"size:200;" json:"DisplayName" yaml:"DisplayName,omitempty"`
UserEmail string `gorm:"size:255;index;" json:"Email" yaml:"Email,omitempty"` UserEmail string `gorm:"size:255;index;" json:"Email" yaml:"Email,omitempty"`
BackupEmail string `gorm:"size:255;" json:"BackupEmail,omitempty" yaml:"BackupEmail,omitempty"` BackupEmail string `gorm:"size:255;" json:"BackupEmail,omitempty" yaml:"BackupEmail,omitempty"`
UserRole string `gorm:"size:64;default:'';" json:"Role,omitempty" yaml:"Role,omitempty"` UserRole string `gorm:"size:64;default:'';" json:"Role" yaml:"Role,omitempty"`
UserAttr string `gorm:"size:1024;" json:"Attr,omitempty" yaml:"Attr,omitempty"` UserAttr string `gorm:"size:1024;" json:"Attr" yaml:"Attr,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"` SuperAdmin bool `json:"SuperAdmin" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"` CanLogin bool `json:"CanLogin" yaml:"CanLogin,omitempty"`
LoginAt *time.Time `json:"LoginAt,omitempty" yaml:"LoginAt,omitempty"` LoginAt *time.Time `json:"LoginAt" yaml:"LoginAt,omitempty"`
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"` ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
WebDAV bool `gorm:"column:webdav;" json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"` WebDAV bool `gorm:"column:webdav;" json:"WebDAV" yaml:"WebDAV,omitempty"`
BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath,omitempty" yaml:"BasePath,omitempty"` BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath" yaml:"BasePath,omitempty"`
UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"` UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath" yaml:"UploadPath,omitempty"`
CanInvite bool `json:"CanInvite,omitempty" yaml:"CanInvite,omitempty"` CanInvite bool `json:"CanInvite" yaml:"CanInvite,omitempty"`
InviteToken string `gorm:"type:VARBINARY(64);index;" json:"-" yaml:"-"` InviteToken string `gorm:"type:VARBINARY(64);index;" json:"-" yaml:"-"`
InvitedBy string `gorm:"size:64;" json:"-" yaml:"-"` InvitedBy string `gorm:"size:64;" json:"-" yaml:"-"`
VerifyToken string `gorm:"type:VARBINARY(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) stmt = stmt.Where("id = ?", find.ID)
} else if rnd.IsUID(find.UserUID, UserUID) { } else if rnd.IsUID(find.UserUID, UserUID) {
stmt = stmt.Where("user_uid = ?", find.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 != "" { } else if find.UserName != "" {
stmt = stmt.Where("user_name = ?", find.UserName) stmt = stmt.Where("user_name = ?", find.UserName)
} else if find.UserEmail != "" { } 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. // FindUserByName returns the matching user or nil if it was not found.
func FindUserByName(name string) *User { func FindUserByName(userName string) *User {
name = clean.DN(name) userName = clean.Username(userName)
if name == "" { if userName == "" {
return nil 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. // 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. // Change username if needed.
if initName != "" && initName != m.UserName { 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) 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 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. // CanLogIn checks if the user is allowed to log in and use the web UI.
func (m *User) CanLogIn() bool { func (m *User) CanLogIn() bool {
if m == nil { if m == nil {
@ -416,7 +449,7 @@ func (m *User) SetUploadPath(dir string) *User {
// String returns an identifier that can be used in logs. // String returns an identifier that can be used in logs.
func (m *User) String() string { func (m *User) String() string {
if n := m.Name(); n != "" { if n := m.Username(); n != "" {
return clean.LogQuote(n) return clean.LogQuote(n)
} else if n = m.FullName(); n != "" { } else if n = m.FullName(); n != "" {
return clean.LogQuote(n) return clean.LogQuote(n)
@ -425,13 +458,52 @@ func (m *User) String() string {
return clean.Log(m.UserUID) return clean.Log(m.UserUID)
} }
// Name returns the user's login name for authentication. // Provider returns the authentication provider name.
func (m *User) Name() string { 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) return clean.Username(m.UserName)
} }
// SetName sets the login username to the specified string. // SetUsername sets the login username to the specified string.
func (m *User) SetName(login string) (err error) { func (m *User) SetUsername(login string) (err error) {
if m.ID < 0 { if m.ID < 0 {
return fmt.Errorf("system users cannot be modified") return fmt.Errorf("system users cannot be modified")
} }
@ -458,9 +530,9 @@ func (m *User) SetName(login string) (err error) {
return nil return nil
} }
// UpdateName changes the login username and saves it to the database. // UpdateUsername changes the login username and saves it to the database.
func (m *User) UpdateName(login string) (err error) { func (m *User) UpdateUsername(login string) (err error) {
if err = m.SetName(login); err != nil { if err = m.SetUsername(login); err != nil {
return err return err
} }
@ -558,6 +630,15 @@ func (m *User) NotRegistered() bool {
return !m.IsRegistered() 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. // IsAdmin checks if the user is an admin with username.
func (m *User) IsAdmin() bool { func (m *User) IsAdmin() bool {
if m == nil { 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. // Validate checks if username, email and role are valid and returns an error otherwise.
func (m *User) Validate() (err error) { func (m *User) Validate() (err error) {
// Empty name? // Empty name?
if m.Name() == "" { if m.Username() == "" {
return errors.New("username must not be empty") return errors.New("username must not be empty")
} }
// Name too short? // Name too short?
if len(m.Name()) < UsernameLength { if len(m.Username()) < UsernameLength {
return fmt.Errorf("username must have at least %d characters", 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. // SetFormValues sets the values specified in the form.
func (m *User) SetFormValues(frm form.User) *User { 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.UserEmail = frm.Email()
m.DisplayName = frm.DisplayName m.DisplayName = frm.DisplayName
m.SuperAdmin = frm.SuperAdmin 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. // 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 { 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. // Ignore details if not set.
@ -870,7 +958,7 @@ func (m *User) SaveForm(f form.User) error {
// Sanitize display name. // Sanitize display name.
if n := clean.Name(f.DisplayName); n != "" && n != m.DisplayName { if n := clean.Name(f.DisplayName); n != "" && n != m.DisplayName {
m.SetDisplayName(n) m.SetDisplayName(n, SrcManual)
} }
// Sanitize email address. // Sanitize email address.
@ -880,20 +968,34 @@ func (m *User) SaveForm(f form.User) error {
m.VerifyToken = GenerateToken() 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() return m.Save()
} }
// SetDisplayName sets a new display name and, if possible, splits it into its components. // 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) name = clean.Name(name)
d := m.Details() d := m.Details()
if name == "" || SrcPriority[SrcAuto] < SrcPriority[d.NameSrc] { if name == "" || SrcPriority[src] < SrcPriority[d.NameSrc] {
return m return m
} }
m.DisplayName = name m.DisplayName = name
d.NameSrc = src
// Try to parse name into components. // Try to parse name into components.
n := txt.ParseName(name) 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}) 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 ""
}

View file

@ -32,7 +32,7 @@ func AddUser(frm form.User) error {
return err return err
} }
log.Infof("successfully added user %s", clean.LogQuote(user.Name())) log.Infof("successfully added user %s", clean.LogQuote(user.Username()))
return nil return nil
}) })

View file

@ -3,6 +3,7 @@ package entity
import ( import (
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
) )
// Role defaults. // Role defaults.
@ -17,6 +18,7 @@ const (
var Admin = User{ var Admin = User{
ID: 1, ID: 1,
UserName: AdminUserName, UserName: AdminUserName,
AuthProvider: authn.ProviderLocal,
UserRole: acl.RoleAdmin.String(), UserRole: acl.RoleAdmin.String(),
DisplayName: AdminDisplayName, DisplayName: AdminDisplayName,
SuperAdmin: true, SuperAdmin: true,
@ -33,6 +35,7 @@ var UnknownUser = User{
ID: -1, ID: -1,
UserUID: "u000000000000001", UserUID: "u000000000000001",
UserName: "", UserName: "",
AuthProvider: authn.ProviderNone,
UserRole: acl.RoleUnknown.String(), UserRole: acl.RoleUnknown.String(),
CanLogin: false, CanLogin: false,
WebDAV: false, WebDAV: false,
@ -48,6 +51,7 @@ var Visitor = User{
ID: -2, ID: -2,
UserUID: "u000000000000002", UserUID: "u000000000000002",
UserName: "", UserName: "",
AuthProvider: authn.ProviderToken,
UserRole: acl.RoleVisitor.String(), UserRole: acl.RoleVisitor.String(),
DisplayName: VisitorDisplayName, DisplayName: VisitorDisplayName,
CanLogin: false, CanLogin: false,

View file

@ -12,14 +12,14 @@ func TestUserMap_Get(t *testing.T) {
t.Run("Alice", func(t *testing.T) { t.Run("Alice", func(t *testing.T) {
r := UserFixtures.Get("alice") r := UserFixtures.Get("alice")
assert.Equal(t, "alice", r.UserName) assert.Equal(t, "alice", r.UserName)
assert.Equal(t, "alice", r.Name()) assert.Equal(t, "alice", r.Username())
assert.IsType(t, User{}, r) assert.IsType(t, User{}, r)
}) })
t.Run("Invalid", func(t *testing.T) { t.Run("Invalid", func(t *testing.T) {
r := UserFixtures.Get("monstera") r := UserFixtures.Get("monstera")
assert.Equal(t, "", r.UserName) assert.Equal(t, "", r.UserName)
assert.Equal(t, "", r.Name()) assert.Equal(t, "", r.Username())
assert.IsType(t, User{}, r) assert.IsType(t, User{}, r)
}) })
} }
@ -27,7 +27,7 @@ func TestUserMap_Get(t *testing.T) {
func TestUserMap_Pointer(t *testing.T) { func TestUserMap_Pointer(t *testing.T) {
t.Run("Alice", func(t *testing.T) { t.Run("Alice", func(t *testing.T) {
r := UserFixtures.Pointer("alice") 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", r.UserName)
assert.Equal(t, "alice@example.com", r.Email()) assert.Equal(t, "alice@example.com", r.Email())
assert.Equal(t, "alice@example.com", r.UserEmail) assert.Equal(t, "alice@example.com", r.UserEmail)

View file

@ -19,7 +19,7 @@ func (m *User) Report(skipEmpty bool) (rows [][]string, cols []string) {
rows = make([][]string, 0, len(values)) rows = make([][]string, 0, len(values))
for k, v := range values { for k, v := range values {
s := fmt.Sprintf("%v", v) s := fmt.Sprintf("%#v", v)
// Skip empty values? // Skip empty values?
if !skipEmpty || s != "" { if !skipEmpty || s != "" {

View file

@ -7,6 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@ -17,6 +18,89 @@ func TestNewUser(t *testing.T) {
assert.True(t, rnd.IsUID(m.UserUID, UserUID)) 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) { func TestFindUserByName(t *testing.T) {
t.Run("Admin", func(t *testing.T) { t.Run("Admin", func(t *testing.T) {
m := FindUserByName("admin") m := FindUserByName("admin")
@ -28,9 +112,9 @@ func TestFindUserByName(t *testing.T) {
assert.Equal(t, 1, m.ID) assert.Equal(t, 1, m.ID)
assert.NotEmpty(t, m.UserUID) assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "admin", m.UserName) assert.Equal(t, "admin", m.UserName)
assert.Equal(t, "admin", m.Name()) assert.Equal(t, "admin", m.Username())
m.UserName = "Admin " 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.UserName)
assert.Equal(t, "Admin", m.DisplayName) assert.Equal(t, "Admin", m.DisplayName)
assert.Equal(t, acl.RoleAdmin, m.AclRole()) assert.Equal(t, acl.RoleAdmin, m.AclRole())
@ -114,10 +198,10 @@ func TestUser_Create(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, "example", m.Name()) assert.Equal(t, "example", m.Username())
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") t.Fatal("error expected")
} }
}) })
@ -136,14 +220,14 @@ func TestUser_SetName(t *testing.T) {
t.Fatal("result should not be nil") 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) assert.Equal(t, "admin", m.UserName)
if err := m.SetName("photoprism"); err != nil { if err := m.SetUsername("photoprism"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, "photoprism", m.Name()) assert.Equal(t, "photoprism", m.Username())
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, 5, m.ID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID) 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", m.DisplayName)
assert.Equal(t, "alice@example.com", m.UserEmail) assert.Equal(t, "alice@example.com", m.UserEmail)
assert.True(t, m.SuperAdmin) assert.True(t, m.SuperAdmin)
@ -697,7 +781,20 @@ func TestUser_Disabled(t *testing.T) {
assert.True(t, UserFixtures.Pointer("deleted").Disabled()) 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.True(t, UserFixtures.Pointer("alice").CanLogIn())
assert.False(t, UserFixtures.Pointer("deleted").CanLogIn()) assert.False(t, UserFixtures.Pointer("deleted").CanLogIn())
} }
@ -752,7 +849,7 @@ func TestUser_SaveForm(t *testing.T) {
frm, err := UnknownUser.Form() frm, err := UnknownUser.Form()
assert.NoError(t, err) assert.NoError(t, err)
err = UnknownUser.SaveForm(frm) err = UnknownUser.SaveForm(frm, false)
assert.Error(t, err) assert.Error(t, err)
}) })
t.Run("Admin", func(t *testing.T) { t.Run("Admin", func(t *testing.T) {
@ -770,7 +867,32 @@ func TestUser_SaveForm(t *testing.T) {
frm.UserEmail = "admin@example.com" frm.UserEmail = "admin@example.com"
frm.UserDetails.UserLocation = "GoLand" 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.NoError(t, err)
assert.Equal(t, "admin@example.com", Admin.UserEmail) assert.Equal(t, "admin@example.com", Admin.UserEmail)
@ -785,7 +907,7 @@ func TestUser_SaveForm(t *testing.T) {
func TestUser_SetDisplayName(t *testing.T) { func TestUser_SetDisplayName(t *testing.T) {
t.Run("BillGates", func(t *testing.T) { t.Run("BillGates", func(t *testing.T) {
user := NewUser() user := NewUser()
user.SetDisplayName("Sir William Henry Gates III") user.SetDisplayName("Sir William Henry Gates III", SrcAuto)
d := user.Details() d := user.Details()
assert.Equal(t, "Sir", d.NameTitle) assert.Equal(t, "Sir", d.NameTitle)
assert.Equal(t, "William", d.GivenName) 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) { t.Run("Visitor", func(t *testing.T) {
assert.Equal(t, "", Visitor.Login()) assert.Equal(t, "", Visitor.Username())
}) })
t.Run("UnknownUser", func(t *testing.T) { t.Run("UnknownUser", func(t *testing.T) {
assert.Equal(t, "", UnknownUser.Login()) assert.Equal(t, "", UnknownUser.Username())
}) })
t.Run("Admin", func(t *testing.T) { 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) { func TestUser_Provider(t *testing.T) {
t.Run("Visitor", func(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) { 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) { 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, 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()) assert.Equal(t, "mr-happy", u.Handle())
u.UserName = "mr.happy@cat.com" u.UserName = "mr.happy@cat.com"
@ -959,7 +1081,7 @@ func TestUser_FullName(t *testing.T) {
assert.Equal(t, "Foo", u.FullName()) assert.Equal(t, "Foo", u.FullName())
u.SetDisplayName("Jane Doe") u.SetDisplayName("Jane Doe", SrcManual)
assert.Equal(t, "Jane Doe", u.FullName()) assert.Equal(t, "Jane Doe", u.FullName())
}) })

View file

@ -50,9 +50,3 @@ const (
IsStackable int8 = 0 IsStackable int8 = 0
IsUnstacked int8 = -1 IsUnstacked int8 = -1
) )
// Authentication providers.
const (
ProviderNone = ""
ProviderPassword = "password"
)

View file

@ -11,6 +11,7 @@ const (
SrcEstimate = "estimate" // Prio 2 SrcEstimate = "estimate" // Prio 2
SrcName = "name" // Prio 4 SrcName = "name" // Prio 4
SrcYaml = "yaml" // Prio 8 SrcYaml = "yaml" // Prio 8
SrcLDAP = "ldap" // Prio 8
SrcLocation = classify.SrcLocation // Prio 8 SrcLocation = classify.SrcLocation // Prio 8
SrcMarker = "marker" // Prio 8 SrcMarker = "marker" // Prio 8
SrcImage = classify.SrcImage // Prio 8 SrcImage = classify.SrcImage // Prio 8
@ -37,6 +38,7 @@ var SrcPriority = Priorities{
SrcEstimate: 2, SrcEstimate: 2,
SrcName: 4, SrcName: 4,
SrcYaml: 8, SrcYaml: 8,
SrcLDAP: 8,
SrcLocation: 8, SrcLocation: 8,
SrcMarker: 8, SrcMarker: 8,
SrcImage: 8, SrcImage: 8,

View file

@ -41,7 +41,7 @@ type SearchPhotos struct {
Archived bool `form:"archived" notes:"Finds archived pictures"` Archived bool `form:"archived" notes:"Finds archived pictures"`
Public bool `form:"public" notes:"Excludes private pictures"` Public bool `form:"public" notes:"Excludes private pictures"`
Private bool `form:"private" notes:"Finds 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"` Unsorted bool `form:"unsorted" notes:"Finds pictures not in an album"`
Lat float32 `form:"lat" notes:"Latitude (GPS Position)"` Lat float32 `form:"lat" notes:"Latitude (GPS Position)"`
Lng float32 `form:"lng" notes:"Longitude (GPS Position)"` Lng float32 `form:"lng" notes:"Longitude (GPS Position)"`

View file

@ -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}
}

View file

@ -8,42 +8,49 @@ import (
// User represents a user account form. // User represents a user account form.
type User struct { type User struct {
UserName string `json:"Name,omitempty" yaml:"Name,omitempty"` UserName string `json:"Name,omitempty" yaml:"Name,omitempty"`
UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"` AuthProvider string `json:"Provider,omitempty" yaml:"Provider,omitempty"`
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"` UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"` DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"` UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"` SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"` CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"` WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"` UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"`
UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"` BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
Password string `json:"Password,omitempty" yaml:"Password,omitempty"` UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
UserDetails *UserDetails `json:"Details,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. // NewUserFromCli creates a new form with values from a CLI context.
func NewUserFromCli(ctx *cli.Context) User { func NewUserFromCli(ctx *cli.Context) User {
return User{ return User{
UserName: clean.Username(ctx.Args().First()), UserName: clean.Username(ctx.Args().First()),
UserEmail: clean.Email(ctx.String("email")), AuthProvider: clean.TypeLower(ctx.String("provider")),
DisplayName: clean.Name(ctx.String("name")), UserEmail: clean.Email(ctx.String("email")),
UserRole: clean.Role(ctx.String("role")), DisplayName: clean.Name(ctx.String("name")),
SuperAdmin: ctx.Bool("superadmin"), UserRole: clean.Role(ctx.String("role")),
CanLogin: !ctx.Bool("no-login"), SuperAdmin: ctx.Bool("superadmin"),
WebDAV: ctx.Bool("webdav"), CanLogin: !ctx.Bool("no-login"),
UserAttr: clean.Attr(ctx.String("attr")), WebDAV: ctx.Bool("webdav"),
BasePath: clean.UserPath(ctx.String("base-path")), UserAttr: clean.Attr(ctx.String("attr")),
UploadPath: clean.UserPath(ctx.String("upload-path")), BasePath: clean.UserPath(ctx.String("base-path")),
Password: clean.Password(ctx.String("password")), UploadPath: clean.UserPath(ctx.String("upload-path")),
Password: clean.Password(ctx.String("password")),
} }
} }
// Name returns the sanitized username in lowercase. // Username returns the sanitized username in lowercase.
func (f *User) Name() string { func (f *User) Username() string {
return clean.Username(f.UserName) 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. // Email returns the sanitized email in lowercase.
func (f *User) Email() string { func (f *User) Email() string {
return clean.Email(f.UserEmail) return clean.Email(f.UserEmail)

View file

@ -12,9 +12,9 @@ type Login struct {
AuthToken string `json:"token,omitempty"` AuthToken string `json:"token,omitempty"`
} }
// Name returns the sanitized username in lowercase. // Username returns the sanitized username in lowercase.
func (f Login) Name() string { func (f Login) Username() string {
return clean.DN(f.UserName) return clean.Username(f.UserName)
} }
// Email returns the sanitized email in lowercase. // Email returns the sanitized email in lowercase.
@ -22,9 +22,9 @@ func (f Login) Email() string {
return clean.Email(f.UserEmail) return clean.Email(f.UserEmail)
} }
// HasName checks if a username is set. // HasUsername checks if a username is set.
func (f Login) HasName() bool { func (f Login) HasUsername() bool {
if l := len(f.Name()); l == 0 || l > 255 { if l := len(f.Username()); l == 0 || l > 255 {
return false return false
} }
return true return true
@ -42,5 +42,5 @@ func (f Login) HasToken() bool {
// HasCredentials checks if all credentials is set. // HasCredentials checks if all credentials is set.
func (f Login) HasCredentials() bool { func (f Login) HasCredentials() bool {
return f.HasName() && f.HasPassword() return f.HasUsername() && f.HasPassword()
} }

View file

@ -20,11 +20,11 @@ func TestLogin_HasToken(t *testing.T) {
func TestLogin_HasName(t *testing.T) { func TestLogin_HasName(t *testing.T) {
t.Run("false", func(t *testing.T) { t.Run("false", func(t *testing.T) {
form := &Login{UserEmail: "test@test.com", Password: "passwd", AuthToken: ""} 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) { t.Run("true", func(t *testing.T) {
form := &Login{UserEmail: "test@test.com", UserName: "John", Password: "passwd", AuthToken: "123"} form := &Login{UserEmail: "test@test.com", UserName: "John", Password: "passwd", AuthToken: "123"}
assert.Equal(t, true, form.HasName()) assert.Equal(t, true, form.HasUsername())
}) })
} }

View file

@ -14,18 +14,18 @@ func TestUser(t *testing.T) {
assert.Equal(t, "passwd", form.Password) 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) { t.Run("Empty", func(t *testing.T) {
form := &User{UserName: "", UserEmail: "test@test.com", Password: "passwd"} 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) { t.Run("Valid", func(t *testing.T) {
form := &User{UserName: "foobar", UserEmail: "test@test.com", Password: "passwd"} 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) { t.Run("Invalid", func(t *testing.T) {
form := &User{UserName: " Foo Bar4w45 !", UserEmail: "test@test.com", Password: "passwd"} form := &User{UserName: " Foo Bar4w45 !", UserEmail: "test@test.com", Password: "passwd"}
assert.Equal(t, "foobar4w45", form.Name()) assert.Equal(t, "foo bar4w45 !", form.Username())
}) })
} }

View file

@ -11,7 +11,7 @@ func TestRegisteredUsers(t *testing.T) {
users := RegisteredUsers() users := RegisteredUsers()
for _, user := range users { 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) assert.NotEmpty(t, user.UserUID)
} }

49
internal/search/users.go Normal file
View file

@ -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
}

View file

@ -7,6 +7,8 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
) )
var APIv1 *gin.RouterGroup
// registerRoutes configures the available web server routes. // registerRoutes configures the available web server routes.
func registerRoutes(router *gin.Engine, conf *config.Config) { func registerRoutes(router *gin.Engine, conf *config.Config) {
// Enables automatic redirection if the current route cannot be matched but a // 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) registerSharingRoutes(router, conf)
// JSON-REST API Version 1 // JSON-REST API Version 1
v1 := router.Group(conf.BaseUri(config.ApiUri)) // Authentication.
{ api.CreateSession(APIv1)
// Authentication. api.GetSession(APIv1)
api.CreateSession(v1) api.DeleteSession(APIv1)
api.GetSession(v1)
api.DeleteSession(v1)
// Global Config. // Global Config.
api.GetConfigOptions(v1) api.GetConfigOptions(APIv1)
api.SaveConfigOptions(v1) api.SaveConfigOptions(APIv1)
// Custom Settings. // Custom Settings.
api.GetClientConfig(v1) api.GetClientConfig(APIv1)
api.GetSettings(v1) api.GetSettings(APIv1)
api.SaveSettings(v1) api.SaveSettings(APIv1)
// Profile and Uploads. // Profile and Uploads.
api.UploadUserFiles(v1) api.UploadUserFiles(APIv1)
api.ProcessUserUpload(v1) api.ProcessUserUpload(APIv1)
api.UploadUserAvatar(v1) api.UploadUserAvatar(APIv1)
api.UpdateUserPassword(v1) api.UpdateUserPassword(APIv1)
api.UpdateUser(v1) api.UpdateUser(APIv1)
// Service Accounts. // Service Accounts.
api.SearchServices(v1) api.SearchServices(APIv1)
api.GetService(v1) api.GetService(APIv1)
api.GetServiceFolders(v1) api.GetServiceFolders(APIv1)
api.UploadToService(v1) api.UploadToService(APIv1)
api.AddService(v1) api.AddService(APIv1)
api.DeleteService(v1) api.DeleteService(APIv1)
api.UpdateService(v1) api.UpdateService(APIv1)
// Thumbnail Images. // Thumbnail Images.
api.GetThumb(v1) api.GetThumb(APIv1)
// Video Streaming. // Video Streaming.
api.GetVideo(v1) api.GetVideo(APIv1)
// Downloads. // Downloads.
api.GetDownload(v1) api.GetDownload(APIv1)
api.ZipCreate(v1) api.ZipCreate(APIv1)
api.ZipDownload(v1) api.ZipDownload(APIv1)
// Index and Import. // Index and Import.
api.StartImport(v1) api.StartImport(APIv1)
api.CancelImport(v1) api.CancelImport(APIv1)
api.StartIndexing(v1) api.StartIndexing(APIv1)
api.CancelIndexing(v1) api.CancelIndexing(APIv1)
// Photo Search and Organization. // Photo Search and Organization.
api.SearchPhotos(v1) api.SearchPhotos(APIv1)
api.SearchGeo(v1) api.SearchGeo(APIv1)
api.GetPhoto(v1) api.GetPhoto(APIv1)
api.GetPhotoYaml(v1) api.GetPhotoYaml(APIv1)
api.UpdatePhoto(v1) api.UpdatePhoto(APIv1)
api.GetPhotoDownload(v1) api.GetPhotoDownload(APIv1)
// api.GetPhotoLinks(v1) // api.GetPhotoLinks(APIv1)
// api.CreatePhotoLink(v1) // api.CreatePhotoLink(APIv1)
// api.UpdatePhotoLink(v1) // api.UpdatePhotoLink(APIv1)
// api.DeletePhotoLink(v1) // api.DeletePhotoLink(APIv1)
api.ApprovePhoto(v1) api.ApprovePhoto(APIv1)
api.LikePhoto(v1) api.LikePhoto(APIv1)
api.DislikePhoto(v1) api.DislikePhoto(APIv1)
api.AddPhotoLabel(v1) api.AddPhotoLabel(APIv1)
api.RemovePhotoLabel(v1) api.RemovePhotoLabel(APIv1)
api.UpdatePhotoLabel(v1) api.UpdatePhotoLabel(APIv1)
api.GetMomentsTime(v1) api.GetMomentsTime(APIv1)
api.GetFile(v1) api.GetFile(APIv1)
api.DeleteFile(v1) api.DeleteFile(APIv1)
api.UpdateMarker(v1) api.UpdateMarker(APIv1)
api.ClearMarkerSubject(v1) api.ClearMarkerSubject(APIv1)
api.PhotoPrimary(v1) api.PhotoPrimary(APIv1)
api.PhotoUnstack(v1) api.PhotoUnstack(APIv1)
// Photo Albums. // Photo Albums.
api.SearchAlbums(v1) api.SearchAlbums(APIv1)
api.GetAlbum(v1) api.GetAlbum(APIv1)
api.AlbumCover(v1) api.AlbumCover(APIv1)
api.CreateAlbum(v1) api.CreateAlbum(APIv1)
api.UpdateAlbum(v1) api.UpdateAlbum(APIv1)
api.DeleteAlbum(v1) api.DeleteAlbum(APIv1)
api.DownloadAlbum(v1) api.DownloadAlbum(APIv1)
api.GetAlbumLinks(v1) api.GetAlbumLinks(APIv1)
api.CreateAlbumLink(v1) api.CreateAlbumLink(APIv1)
api.UpdateAlbumLink(v1) api.UpdateAlbumLink(APIv1)
api.DeleteAlbumLink(v1) api.DeleteAlbumLink(APIv1)
api.LikeAlbum(v1) api.LikeAlbum(APIv1)
api.DislikeAlbum(v1) api.DislikeAlbum(APIv1)
api.CloneAlbums(v1) api.CloneAlbums(APIv1)
api.AddPhotosToAlbum(v1) api.AddPhotosToAlbum(APIv1)
api.RemovePhotosFromAlbum(v1) api.RemovePhotosFromAlbum(APIv1)
// Photo Labels. // Photo Labels.
api.SearchLabels(v1) api.SearchLabels(APIv1)
api.LabelCover(v1) api.LabelCover(APIv1)
api.UpdateLabel(v1) api.UpdateLabel(APIv1)
// api.GetLabelLinks(v1) // api.GetLabelLinks(APIv1)
// api.CreateLabelLink(v1) // api.CreateLabelLink(APIv1)
// api.UpdateLabelLink(v1) // api.UpdateLabelLink(APIv1)
// api.DeleteLabelLink(v1) // api.DeleteLabelLink(APIv1)
api.LikeLabel(v1) api.LikeLabel(APIv1)
api.DislikeLabel(v1) api.DislikeLabel(APIv1)
// Files and Folders. // Files and Folders.
api.SearchFoldersOriginals(v1) api.SearchFoldersOriginals(APIv1)
api.SearchFoldersImport(v1) api.SearchFoldersImport(APIv1)
api.FolderCover(v1) api.FolderCover(APIv1)
// People. // People.
api.SearchSubjects(v1) api.SearchSubjects(APIv1)
api.GetSubject(v1) api.GetSubject(APIv1)
api.UpdateSubject(v1) api.UpdateSubject(APIv1)
api.LikeSubject(v1) api.LikeSubject(APIv1)
api.DislikeSubject(v1) api.DislikeSubject(APIv1)
// Faces. // Faces.
api.SearchFaces(v1) api.SearchFaces(APIv1)
api.GetFace(v1) api.GetFace(APIv1)
api.UpdateFace(v1) api.UpdateFace(APIv1)
// Batch Operations. // Batch Operations.
api.BatchPhotosApprove(v1) api.BatchPhotosApprove(APIv1)
api.BatchPhotosArchive(v1) api.BatchPhotosArchive(APIv1)
api.BatchPhotosRestore(v1) api.BatchPhotosRestore(APIv1)
api.BatchPhotosPrivate(v1) api.BatchPhotosPrivate(APIv1)
api.BatchPhotosDelete(v1) api.BatchPhotosDelete(APIv1)
api.BatchAlbumsDelete(v1) api.BatchAlbumsDelete(APIv1)
api.BatchLabelsDelete(v1) api.BatchLabelsDelete(APIv1)
// Technical Endpoints. // Technical Endpoints.
api.GetSvg(v1) api.GetSvg(APIv1)
api.GetStatus(v1) api.GetStatus(APIv1)
api.GetErrors(v1) api.GetErrors(APIv1)
api.DeleteErrors(v1) api.DeleteErrors(APIv1)
api.SendFeedback(v1) api.SendFeedback(APIv1)
api.Connect(v1) api.Connect(APIv1)
api.WebSocket(v1) api.WebSocket(APIv1)
}
} }

View file

@ -167,5 +167,5 @@ func MarkUploadAsFavorite(fileName string) {
} }
// Log success. // 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)))
} }

View file

@ -43,6 +43,9 @@ func Start(ctx context.Context, conf *config.Config) {
// Register common middleware. // Register common middleware.
router.Use(Recovery(), Security(conf), Logger()) router.Use(Recovery(), Security(conf), Logger())
// Create REST API router group.
APIv1 = router.Group(conf.BaseUri(config.ApiUri))
// Initialize package extensions. // Initialize package extensions.
Ext().Init(router, conf) Ext().Init(router, conf)

25
pkg/authn/authn.go Normal file
View file

@ -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"):
<https://docs.photoprism.app/license/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:
<https://www.photoprism.app/trademark>
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:
<https://docs.photoprism.app/developer-guide/>
*/
package authn

19
pkg/authn/providers.go Normal file
View file

@ -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
}

View file

@ -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))
}

View file

@ -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])?)*$") 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. // Handle returns the sanitized username with trimmed whitespace and in lowercase.
func Username(s string) string { func Handle(s string) string {
// Remove unwanted characters. // Remove unwanted characters.
s = strings.Map(func(r rune) rune { s = strings.Map(func(r rune) rune {
if r <= 42 || r == 127 { if r <= 42 || r == 127 {
@ -32,8 +32,8 @@ func Username(s string) string {
return strings.ToLower(s) return strings.ToLower(s)
} }
// DN returns the sanitized distinguished name (DN) with trimmed whitespace and in lowercase. // Username returns the sanitized distinguished name (Username) with trimmed whitespace and in lowercase.
func DN(s string) string { func Username(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
// Remove unwanted characters. // Remove unwanted characters.

View file

@ -6,12 +6,24 @@ import (
"github.com/stretchr/testify/assert" "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) { func TestUsername(t *testing.T) {
t.Run("Admin ", func(t *testing.T) { t.Run("Admin ", func(t *testing.T) {
assert.Equal(t, "admin", Username("Admin ")) assert.Equal(t, "admin", Username("Admin "))
}) })
t.Run(" Admin ", func(t *testing.T) { 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) { t.Run(" admin ", func(t *testing.T) {
assert.Equal(t, "admin", Username(" admin ")) assert.Equal(t, "admin", Username(" admin "))

View file

@ -10,7 +10,7 @@ func Empty(s string) bool {
return true return true
} else if s = strings.Trim(s, "%* "); s == "" || s == "0" || s == "-1" || DateTimeDefault(s) { } else if s = strings.Trim(s, "%* "); s == "" || s == "0" || s == "-1" || DateTimeDefault(s) {
return true 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 return true
} }