Auth: Refactor user management API and CLI commands #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
e19697bd98
commit
60162b3fc5
54 changed files with 818 additions and 327 deletions
|
@ -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",
|
||||
|
|
|
@ -476,6 +476,14 @@
|
|||
</v-list-tile-content>
|
||||
</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"
|
||||
@click.stop="">
|
||||
<v-list-tile-content>
|
||||
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -14,10 +14,10 @@
|
|||
</v-card-title>
|
||||
<v-card-text class="py-0 px-2">
|
||||
<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>
|
||||
</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-model="oldPassword"
|
||||
hide-details required box flat
|
||||
|
@ -88,10 +88,16 @@
|
|||
</v-dialog>
|
||||
</template>
|
||||
<script>
|
||||
import User from "../../model/user";
|
||||
|
||||
export default {
|
||||
name: 'PAccountPasswordDialog',
|
||||
props: {
|
||||
show: Boolean,
|
||||
model: {
|
||||
type: Object,
|
||||
default: () => new User(null),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -105,7 +111,17 @@ export default {
|
|||
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() {
|
||||
if(this.isPublic && !this.isDemo) {
|
||||
this.$emit('cancel');
|
||||
|
@ -113,11 +129,11 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
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() {
|
||||
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.$emit('confirm');
|
||||
}).finally(() => {
|
||||
|
|
|
@ -43,6 +43,7 @@ import PWebdavDialog from "dialog/webdav.vue";
|
|||
import PReloadDialog from "dialog/reload.vue";
|
||||
import PSponsorDialog from "dialog/sponsor.vue";
|
||||
import PConfirmDialog from "dialog/confirm.vue";
|
||||
import PAccountPasswordDialog from "dialog/account/password.vue";
|
||||
|
||||
const dialogs = {};
|
||||
|
||||
|
@ -67,6 +68,7 @@ dialogs.install = (Vue) => {
|
|||
Vue.component("PReloadDialog", PReloadDialog);
|
||||
Vue.component("PSponsorDialog", PSponsorDialog);
|
||||
Vue.component("PConfirmDialog", PConfirmDialog);
|
||||
Vue.component("PAccountPasswordDialog", PAccountPasswordDialog);
|
||||
};
|
||||
|
||||
export default dialogs;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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-title primary-title class="pa-2">
|
||||
<v-layout row wrap>
|
||||
|
@ -76,7 +76,7 @@ import Service from "model/service";
|
|||
import * as options from "options/options";
|
||||
|
||||
export default {
|
||||
name: 'PAccountCreateDialog',
|
||||
name: 'PAccountAddDialog',
|
||||
props: {
|
||||
show: Boolean,
|
||||
},
|
||||
|
|
|
@ -31,7 +31,11 @@ import { $gettext } from "common/vm";
|
|||
|
||||
export class Rest extends Model {
|
||||
getId() {
|
||||
return this.UID ? this.UID : this.ID;
|
||||
if (this.UID) {
|
||||
return this.UID;
|
||||
}
|
||||
|
||||
return this.ID ? this.ID : "";
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.hasId()) {
|
||||
return this.update();
|
||||
|
@ -72,7 +86,7 @@ export class Rest extends Model {
|
|||
}
|
||||
|
||||
// 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))
|
||||
);
|
||||
}
|
||||
|
|
|
@ -101,6 +101,7 @@ export class User extends RestModel {
|
|||
CreatedAt: "",
|
||||
UpdatedAt: "",
|
||||
},
|
||||
LoginAt: "",
|
||||
VerifiedAt: "",
|
||||
ConsentAt: "",
|
||||
BornAt: "",
|
||||
|
@ -184,6 +185,10 @@ export class User extends RestModel {
|
|||
);
|
||||
}
|
||||
|
||||
isLocal() {
|
||||
return !this.AuthProvider || this.AuthProvider === "local";
|
||||
}
|
||||
|
||||
changePassword(oldPassword, newPassword) {
|
||||
return Api.put(this.getEntityResource() + "/password", {
|
||||
old: oldPassword,
|
||||
|
@ -191,12 +196,6 @@ export class User extends RestModel {
|
|||
}).then((response) => Promise.resolve(response.data));
|
||||
}
|
||||
|
||||
save() {
|
||||
return Api.post(this.getEntityResource(), this.getValues()).then((response) =>
|
||||
Promise.resolve(this.setValues(response.data))
|
||||
);
|
||||
}
|
||||
|
||||
static getCollectionResource() {
|
||||
return "users";
|
||||
}
|
||||
|
|
18
frontend/src/page/admin.vue
Normal file
18
frontend/src/page/admin.vue
Normal 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>
|
|
@ -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,
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<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"
|
||||
@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-actions>
|
||||
<v-layout row wrap align-top>
|
||||
|
@ -296,7 +296,7 @@
|
|||
</v-card-actions>
|
||||
</v-card>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
|
||||
<v-card-actions>
|
||||
<v-layout wrap align-top>
|
||||
|
||||
<v-flex xs12 sm4 class="px-2 pb-2 pt-2">
|
||||
<v-checkbox
|
||||
v-model="settings.features.estimates"
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
:items="results"
|
||||
hide-actions
|
||||
disable-initial-sort
|
||||
class="elevation-0 p-accounts p-accounts-list p-results"
|
||||
class="elevation-0 account-results list-view"
|
||||
item-key="ID"
|
||||
:no-data-text="$gettext('No services configured.')"
|
||||
>
|
||||
|
@ -19,6 +19,7 @@
|
|||
<td class="text-xs-center">
|
||||
<v-btn icon small flat :ripple="false"
|
||||
class="action-toggle-share"
|
||||
color="transparent"
|
||||
@click.stop.prevent="editSharing(props.item)">
|
||||
<v-icon v-if="props.item.AccShare" color="secondary-dark">check</v-icon>
|
||||
<v-icon v-else color="secondary-dark">settings</v-icon>
|
||||
|
@ -27,6 +28,7 @@
|
|||
<td class="text-xs-center">
|
||||
<v-btn icon small flat :ripple="false"
|
||||
class="action-toggle-sync"
|
||||
color="transparent"
|
||||
@click.stop.prevent="editSync(props.item)">
|
||||
<v-icon v-if="props.item.AccErrors" color="secondary-dark" :title="props.item.AccError">report_problem
|
||||
</v-icon>
|
||||
|
@ -37,12 +39,14 @@
|
|||
<td class="hidden-sm-and-down">{{ formatDate(props.item.SyncDate) }}</td>
|
||||
<td class="hidden-xs-only text-xs-right" nowrap>
|
||||
<v-btn icon small flat :ripple="false"
|
||||
class="p-account-remove"
|
||||
class="action-remove action-secondary"
|
||||
color="transparent"
|
||||
@click.stop.prevent="remove(props.item)">
|
||||
<v-icon color="secondary-dark">delete</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon small flat :ripple="false"
|
||||
class="p-account-remove"
|
||||
class="action-edit"
|
||||
color="transparent"
|
||||
@click.stop.prevent="edit(props.item)">
|
||||
<v-icon color="secondary-dark">edit</v-icon>
|
||||
</v-btn>
|
||||
|
|
2
go.mod
2
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
|
||||
|
|
5
go.sum
5
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=
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
|
|
|
@ -50,9 +50,3 @@ const (
|
|||
IsStackable int8 = 0
|
||||
IsUnstacked int8 = -1
|
||||
)
|
||||
|
||||
// Authentication providers.
|
||||
const (
|
||||
ProviderNone = ""
|
||||
ProviderPassword = "password"
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)"`
|
||||
|
|
28
internal/form/search_users.go
Normal file
28
internal/form/search_users.go
Normal 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}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
49
internal/search/users.go
Normal file
49
internal/search/users.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
25
pkg/authn/authn.go
Normal file
25
pkg/authn/authn.go
Normal 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
19
pkg/authn/providers.go
Normal 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
|
||||
}
|
15
pkg/authn/providers_test.go
Normal file
15
pkg/authn/providers_test.go
Normal 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))
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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 "))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue