Auth: Add "PHOTOPRISM_ADMIN_USER" option and refactor user table #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2022-09-02 21:30:50 +02:00
parent 86086753c2
commit 85561547cc
56 changed files with 1385 additions and 1050 deletions

View File

@@ -131,23 +131,36 @@ export default class Session {
getEmail() {
if (this.isUser()) {
return this.user.PrimaryEmail;
return this.user.Email;
}
return "";
}
getNickName() {
getUsername() {
if (this.isUser()) {
return this.user.NickName;
if (this.user.Username) {
return this.user.Username;
}
}
return "";
}
getFullName() {
getDisplayName() {
if (this.isUser()) {
return this.user.FullName;
if (this.user.DisplayName) {
return this.user.DisplayName;
}
if (this.user.ArtistName) {
return this.user.ArtistName;
}
if (this.user.FullName) {
return this.user.FullName;
}
if (this.user.Username) {
return this.user.Username;
}
}
return "";
@@ -158,7 +171,7 @@ export default class Session {
}
isAdmin() {
return this.user && this.user.hasId() && this.user.RoleAdmin;
return this.user && this.user.hasId() && (this.user.Role === "admin" || this.user.SuperAdmin);
}
isAnonymous() {

View File

@@ -631,11 +631,11 @@ export default {
},
displayName() {
const user = this.$session.getUser();
return user.FullName ? user.FullName : user.UserName;
return user.DisplayName ? user.DisplayName : user.Username;
},
accountInfo() {
const user = this.$session.getUser();
return user.PrimaryEmail ? user.PrimaryEmail : this.$gettext("Account");
return user.Email ? user.Email : this.$gettext("Account");
},
},
created() {

View File

@@ -32,56 +32,72 @@ export class User extends RestModel {
getDefaults() {
return {
UID: "",
Address: {},
MotherUID: "",
FatherUID: "",
GlobalUID: "",
Slug: "",
Username: "",
Email: "",
Role: "",
SuperAdmin: false,
CanLogin: false,
CanInvite: false,
AuthUID: "",
AuthSrc: "",
WebUID: "",
WebDAV: "",
AvatarURL: "",
AvatarSrc: "",
Country: "",
TimeZone: "",
PlaceID: "",
PlaceSrc: "",
CellID: "",
SubjUID: "",
Bio: "",
Status: "",
URL: "",
Phone: "",
DisplayName: "",
FullName: "",
NickName: "",
MaidenName: "",
Alias: "",
ArtistName: "",
UserName: "",
UserStatus: "",
UserDisabled: false,
UserSettings: "",
PrimaryEmail: "",
EmailConfirmed: false,
BackupEmail: "",
PersonURL: "",
PersonPhone: "",
PersonStatus: "",
PersonAvatar: "",
PersonLocation: "",
PersonBio: "",
BusinessURL: "",
BusinessPhone: "",
BusinessEmail: "",
Artist: false,
Favorite: false,
Hidden: false,
Private: false,
Excluded: false,
CompanyName: "",
DepartmentName: "",
JobTitle: "",
BusinessURL: "",
BusinessPhone: "",
BusinessEmail: "",
BackupEmail: "",
BirthYear: -1,
BirthMonth: -1,
BirthDay: -1,
TermsAccepted: false,
IsArtist: false,
IsSubject: false,
RoleAdmin: false,
RoleGuest: false,
RoleChild: false,
RoleFamily: false,
RoleFriend: false,
WebDAV: false,
StoragePath: "",
CanInvite: false,
FileRoot: "",
FilePath: "",
InviteToken: "",
InvitedBy: "",
DownloadToken: "",
PreviewToken: "",
ConfirmedAt: "",
TermsAccepted: "",
CreatedAt: "",
UpdatedAt: "",
DeletedAt: "",
};
}
getEntityName() {
return this.FullName ? this.FullName : this.UserName;
if (this.DisplayName) {
return this.DisplayName;
}
if (this.FullName) {
return this.FullName;
}
return this.Name;
}
getRegisterForm() {

View File

@@ -1,5 +1,5 @@
<template>
<v-container fluid fill-height class="auth-login wallpaper background-welcome pa-3" :style="wallpaper()">
<v-container fluid fill-height class="auth-login wallpaper background-welcome pa-4" :style="wallpaper()">
<v-layout align-center justify-center>
<v-flex xs12 sm8 md4 xl3 xxl2>
<v-form ref="form" dense class="auth-login-form" accept-charset="UTF-8" @submit.prevent="login">

View File

@@ -61,10 +61,10 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
NickName: "Foo",
DisplayName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);
@@ -72,10 +72,10 @@ describe("common/session", () => {
assert.equal(result, "test@test.com");
const values2 = {
user: {
NickName: "Foo",
DisplayName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values2);
@@ -90,25 +90,25 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
NickName: "Foo",
DisplayName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);
const result = session.getNickName();
const result = session.getDisplayName();
assert.equal(result, "Foo");
const values2 = {
user: {
NickName: "Bar",
DisplayName: "Bar",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values2);
const result2 = session.getNickName();
const result2 = session.getDisplayName();
assert.equal(result2, "");
session.deleteData();
});
@@ -119,25 +119,25 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
NickName: "Foo",
DisplayName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);
const result = session.getFullName();
assert.equal(result, "Max Last");
const result = session.getDisplayName();
assert.equal(result, "Foo");
const values2 = {
user: {
NickName: "Bar",
DisplayName: "Bar",
FullName: "Max New",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values2);
const result2 = session.getFullName();
const result2 = session.getDisplayName();
assert.equal(result2, "");
session.deleteData();
});
@@ -148,10 +148,10 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
NickName: "Foo",
DisplayName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);
@@ -166,10 +166,10 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
NickName: "Foo",
DisplayName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);
@@ -184,10 +184,10 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
NickName: "Foo",
DisplayName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);

View File

@@ -6,14 +6,14 @@ let assert = chai.assert;
describe("model/user", () => {
it("should get entity name", () => {
const values = { ID: 5, FullName: "Max Last", PrimaryEmail: "test@test.com", RoleAdmin: true };
const values = { ID: 5, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const user = new User(values);
const result = user.getEntityName();
assert.equal(result, "Max Last");
});
it("should get id", () => {
const values = { ID: 5, FullName: "Max Last", PrimaryEmail: "test@test.com", RoleAdmin: true };
const values = { ID: 5, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const user = new User(values);
const result = user.getId();
assert.equal(result, 5);
@@ -30,28 +30,28 @@ describe("model/user", () => {
});
it("should get register form", async () => {
const values = { ID: 52, FullName: "Max Last" };
const values = { ID: 52, Username: "max", FullName: "Max Last" };
const user = new User(values);
const result = await user.getRegisterForm();
assert.equal(result.definition.foo, "register");
});
it("should get profile form", async () => {
const values = { ID: 53, FullName: "Max Last" };
const values = { ID: 53, Username: "max", FullName: "Max Last" };
const user = new User(values);
const result = await user.getProfileForm();
assert.equal(result.definition.foo, "profile");
});
it("should get change password", async () => {
const values = { ID: 54, FullName: "Max Last", PrimaryEmail: "test@test.com", RoleAdmin: true };
const values = { ID: 54, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const user = new User(values);
const result = await user.changePassword("old", "new");
assert.equal(result.new_password, "new");
});
it("should save profile", async () => {
const values = { ID: 55, FullName: "Max Last", PrimaryEmail: "test@test.com", RoleAdmin: true };
const values = { ID: 55, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const user = new User(values);
assert.equal(user.FullName, "Max Last");
await user.saveProfile();

View File

@@ -8,12 +8,12 @@ import (
"strings"
"testing"
"github.com/photoprism/photoprism/internal/form"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/service"
)
// NewApiTest returns new API test helper.
@@ -35,7 +35,7 @@ func AuthenticateAdmin(app *gin.Engine, router *gin.RouterGroup) (sessId string)
func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, username string, password string) (sessId string) {
CreateSession(router)
f := form.Login{
UserName: username,
Username: username,
Password: password,
}
loginStr, err := json.Marshal(f)

View File

@@ -5,8 +5,6 @@ import (
"os"
"path/filepath"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v2"
@@ -14,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
@@ -31,9 +30,9 @@ func GetConfig(router *gin.RouterGroup) {
conf := service.Config()
if s.User.Guest() {
if s.User.IsGuest() {
c.JSON(http.StatusOK, conf.GuestConfig())
} else if s.User.Registered() {
} else if s.User.IsRegistered() {
c.JSON(http.StatusOK, conf.UserConfig())
} else {
c.JSON(http.StatusOK, conf.PublicConfig())

View File

@@ -49,7 +49,7 @@ func Connect(router *gin.RouterGroup) {
s := Auth(SessionID(c), acl.ResourceConfigOptions, acl.ActionUpdate)
if s.Invalid() {
log.Errorf("connect: %s not authorized", clean.Log(s.User.UserName))
log.Errorf("connect: %s not authorized", clean.Log(s.User.Username))
AbortUnauthorized(c)
return
}

View File

@@ -3,15 +3,15 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/clean"
)
// POST /api/v1/session
@@ -52,11 +52,11 @@ func CreateSession(router *gin.RouterGroup) {
}
// Upgrade from anonymous to guest. Don't downgrade.
if data.User.Anonymous() {
if data.User.IsAnonymous() {
data.User = entity.Guest
}
} else if f.HasCredentials() {
user := entity.FindUserByName(f.UserName)
user := entity.FindUserByLogin(f.Username)
if user == nil {
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
@@ -80,7 +80,7 @@ func CreateSession(router *gin.RouterGroup) {
AddSessionHeader(c, id)
if data.User.Anonymous() {
if data.User.IsAnonymous() {
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id, "data": data, "config": conf.GuestConfig()})
} else {
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id, "data": data, "config": conf.UserConfig()})
@@ -99,7 +99,7 @@ func DeleteSession(router *gin.RouterGroup) {
})
}
// Gets session id from HTTP header.
// SessionID returns the session id from the HTTP header.
func SessionID(c *gin.Context) string {
return c.GetHeader("X-Session-ID")
}
@@ -119,7 +119,7 @@ func Session(id string) session.Data {
func Auth(id string, resource acl.Resource, action acl.Action) session.Data {
sess := Session(id)
if acl.Permissions.Deny(resource, sess.User.Role(), action) {
if acl.Permissions.Deny(resource, sess.User.AclRole(), action) {
return session.Data{}
}

View File

@@ -4,9 +4,10 @@ import (
"net/http"
"testing"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/i18n"
)
func TestCreateSession(t *testing.T) {
@@ -14,7 +15,8 @@ func TestCreateSession(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism"}`)
val2 := gjson.Get(r.Body.String(), "data.user.UserName")
log.Debugf("BODY: %s", r.Body.String())
val2 := gjson.Get(r.Body.String(), "data.user.Username")
assert.Equal(t, "admin", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
@@ -48,8 +50,8 @@ func TestCreateSession(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "alice", "password": "Alice123!"}`)
resEmail := gjson.Get(r.Body.String(), "data.user.PrimaryEmail")
resUsername := gjson.Get(r.Body.String(), "data.user.UserName")
resEmail := gjson.Get(r.Body.String(), "data.user.Email")
resUsername := gjson.Get(r.Body.String(), "data.user.Username")
assert.Equal(t, "alice@example.com", resEmail.String())
assert.Equal(t, "alice", resUsername.String())
assert.Equal(t, http.StatusOK, r.Code)
@@ -58,8 +60,8 @@ func TestCreateSession(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "Bobbob123!"}`)
resEmail := gjson.Get(r.Body.String(), "data.user.PrimaryEmail")
resUsername := gjson.Get(r.Body.String(), "data.user.UserName")
resEmail := gjson.Get(r.Body.String(), "data.user.Email")
resUsername := gjson.Get(r.Body.String(), "data.user.Username")
assert.Equal(t, "bob@example.com", resEmail.String())
assert.Equal(t, "bob", resUsername.String())
assert.Equal(t, http.StatusOK, r.Code)

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
@@ -65,9 +66,9 @@ func wsReader(ws *websocket.Conn, writeMutex *sync.Mutex, connId string, conf *c
var clientConfig config.ClientConfig
if sess.User.Guest() {
if sess.User.IsGuest() {
clientConfig = conf.GuestConfig()
} else if sess.User.Registered() {
} else if sess.User.IsRegistered() {
clientConfig = conf.UserConfig()
} else {
clientConfig = conf.PublicConfig()
@@ -150,7 +151,7 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
wsAuth.mutex.RUnlock()
if user.Registered() {
if user.IsRegistered() {
writeMutex.Lock()
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {

View File

@@ -0,0 +1,23 @@
package commands
import (
"os"
"testing"
"github.com/photoprism/photoprism/internal/config"
"github.com/sirupsen/logrus"
)
func TestMain(m *testing.M) {
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
c := config.NewTestConfig("commands")
code := m.Run()
_ = c.CloseDb()
os.Exit(code)
}

View File

@@ -5,11 +5,10 @@ import (
"time"
"github.com/leandro-lugaresi/hub"
"github.com/photoprism/photoprism/internal/event"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/capture"
)
@@ -20,13 +19,14 @@ func TestIndexCommand(t *testing.T) {
s := event.Subscribe("log.info")
defer event.Unsubscribe(s)
logs := ""
var l string
assert.IsType(t, hub.Subscription{}, s)
go func() {
for msg := range s.Receiver {
logs += msg.Fields["message"].(string) + "\n"
l += msg.Fields["message"].(string) + "\n"
}
}()
@@ -39,16 +39,16 @@ func TestIndexCommand(t *testing.T) {
}
if stdout != "" {
t.Errorf("unexpected stdout output: %s", stdout)
t.Logf("stdout: %s", stdout)
}
time.Sleep(time.Second)
if output := logs; output != "" {
if l != "" {
// Expected index command output.
assert.Contains(t, output, "indexing originals")
assert.Contains(t, output, "indexed")
assert.Contains(t, output, "files")
assert.Contains(t, l, "classify: loading labels")
assert.NotContains(t, l, "error")
assert.Contains(t, l, "found no .ppignore file")
} else {
t.Fatal("log output missing")
}

View File

@@ -39,7 +39,7 @@ func passwdAction(ctx *cli.Context) error {
user := entity.Admin
log.Infof("please enter a new password for %s (at least 6 characters)\n", clean.Log(user.Username()))
log.Infof("please enter a new password for %s (mininum 8 characters)\n", clean.Log(user.UserName()))
newPassword := getPassword("New Password: ")
@@ -57,7 +57,7 @@ func passwdAction(ctx *cli.Context) error {
return err
}
log.Infof("changed password for %s\n", clean.Log(user.Username()))
log.Infof("changed password for %s\n", clean.Log(user.UserName()))
conf.Shutdown()

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/clean"
)
// ResetCommand resets the index, clears the cache, and removes sidecar files after confirmation.
@@ -166,30 +167,32 @@ func resetAction(ctx *cli.Context) error {
}
// resetIndexDb resets the index database schema.
func resetIndexDb(conf *config.Config) {
func resetIndexDb(c *config.Config) {
start := time.Now()
tables := entity.Entities
log.Infoln("dropping existing tables")
tables.Drop(conf.Db())
tables.Drop(c.Db())
log.Infoln("restoring default schema")
entity.InitDb(true, false, nil)
if conf.AdminPassword() != "" {
log.Infoln("restoring initial admin password")
entity.Admin.InitPassword(conf.AdminPassword())
// Reset admin account?
if c.AdminPassword() == "" {
log.Warnf("password required to reset admin account")
} else if entity.Admin.InitAccount(c.AdminUser(), c.AdminPassword()) {
log.Infof("user %s has been restored", clean.LogQuote(c.AdminUser()))
}
log.Infof("database reset completed in %s", time.Since(start))
}
// resetCache removes all cache files and folders.
func resetCache(conf *config.Config) {
func resetCache(c *config.Config) {
start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.CachePath()) + "/**")
matches, err := filepath.Glob(regexp.QuoteMeta(c.CachePath()) + "/**")
if err != nil {
log.Errorf("reset: %s (find cache files)", err)
@@ -216,10 +219,10 @@ func resetCache(conf *config.Config) {
}
// resetSidecarJson removes generated *.json sidecar files.
func resetSidecarJson(conf *config.Config) {
func resetSidecarJson(c *config.Config) {
start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.json")
matches, err := filepath.Glob(regexp.QuoteMeta(c.SidecarPath()) + "/**/*.json")
if err != nil {
log.Errorf("reset: %s (find *.json sidecar files)", err)
@@ -230,7 +233,7 @@ func resetSidecarJson(conf *config.Config) {
log.Infof("removing %d *.json sidecar files", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
if err = os.Remove(name); err != nil {
fmt.Print("E")
} else {
fmt.Print(".")
@@ -246,10 +249,10 @@ func resetSidecarJson(conf *config.Config) {
}
// resetSidecarYaml removes generated *.yml files.
func resetSidecarYaml(conf *config.Config) {
func resetSidecarYaml(c *config.Config) {
start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.yml")
matches, err := filepath.Glob(regexp.QuoteMeta(c.SidecarPath()) + "/**/*.yml")
if err != nil {
log.Errorf("reset: %s (find *.yml metadata files)", err)

View File

@@ -15,63 +15,69 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report"
)
const UsernameUsage = "unique login identifier"
const EmailUsage = "unique email address"
const PasswordUsage = "secure login password"
// UsersCommand registers user management subcommands.
var UsersCommand = cli.Command{
Name: "users",
Usage: "User management subcommands",
Subcommands: []cli.Command{
{
Name: "list",
Usage: "Lists registered users",
Action: usersListAction,
Name: "ls",
Aliases: []string{"list"},
Usage: "Shows registered users",
Flags: report.CliFlags,
Action: usersListAction,
},
{
Name: "add",
Usage: "Adds a new user",
Action: usersAddAction,
Name: "add",
Aliases: []string{"create"},
Usage: "Adds a new user account",
Action: usersAddAction,
Flags: []cli.Flag{
cli.StringFlag{
Name: "fullname, n",
Usage: "full name of the new user",
},
cli.StringFlag{
Name: "username, u",
Usage: "unique username",
},
cli.StringFlag{
Name: "password, p",
Usage: "sets the users password",
Usage: UsernameUsage,
},
cli.StringFlag{
Name: "email, m",
Usage: "sets the users email",
Usage: EmailUsage,
},
cli.StringFlag{
Name: "password, p",
Usage: PasswordUsage,
},
},
},
{
Name: "update",
Usage: "Updates user information",
Action: usersUpdateAction,
Name: "mod",
Aliases: []string{"update"},
Usage: "Updates a user account",
Action: usersUpdateAction,
Flags: []cli.Flag{
cli.StringFlag{
Name: "fullname, n",
Usage: "full name of the new user",
},
cli.StringFlag{
Name: "password, p",
Usage: "sets the users password",
Name: "username, u",
Usage: UsernameUsage,
},
cli.StringFlag{
Name: "email, m",
Usage: "sets the users email",
Usage: EmailUsage,
},
cli.StringFlag{
Name: "password, p",
Usage: PasswordUsage,
},
},
},
{
Name: "delete",
Usage: "Removes an existing user",
Name: "rm",
Aliases: []string{"delete"},
Usage: "Removes a user account",
Action: usersDeleteAction,
ArgsUsage: "[username]",
},
@@ -82,49 +88,43 @@ func usersAddAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) error {
uc := form.UserCreate{
UserName: strings.TrimSpace(ctx.String("username")),
FullName: strings.TrimSpace(ctx.String("fullname")),
Username: strings.TrimSpace(ctx.String("username")),
Email: strings.TrimSpace(ctx.String("email")),
Password: strings.TrimSpace(ctx.String("password")),
}
interactive := true
if uc.UserName != "" && uc.Password != "" {
if uc.Username != "" && uc.Password != "" {
log.Debugf("creating user in non-interactive mode")
interactive = false
}
if interactive && uc.FullName == "" {
prompt := promptui.Prompt{
Label: "Full Name",
}
res, err := prompt.Run()
if err != nil {
return err
}
uc.FullName = strings.TrimSpace(res)
}
if interactive && uc.UserName == "" {
if interactive && uc.Username == "" {
prompt := promptui.Prompt{
Label: "Username",
}
res, err := prompt.Run()
if err != nil {
return err
}
uc.UserName = strings.TrimSpace(res)
uc.Username = strings.TrimSpace(res)
}
if interactive && uc.Email == "" {
prompt := promptui.Prompt{
Label: "E-Mail",
Label: "Email",
}
res, err := prompt.Run()
if err != nil {
return err
}
uc.Email = strings.TrimSpace(res)
}
@@ -176,24 +176,24 @@ func usersAddAction(ctx *cli.Context) error {
func usersDeleteAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) error {
userName := strings.TrimSpace(ctx.Args().First())
login := strings.TrimSpace(ctx.Args().First())
if userName == "" {
if login == "" {
return errors.New("please provide a username")
}
actionPrompt := promptui.Prompt{
Label: fmt.Sprintf("Delete %s?", clean.Log(userName)),
Label: fmt.Sprintf("Delete %s?", clean.Log(login)),
IsConfirm: true,
}
if _, err := actionPrompt.Run(); err == nil {
if m := entity.FindUserByName(userName); m == nil {
return errors.New("user not found")
if m := entity.FindUserByLogin(login); m == nil {
return errors.New("login name not found")
} else if err := m.Delete(); err != nil {
return err
} else {
log.Infof("%s deleted", clean.Log(userName))
log.Infof("%s deleted", clean.LogQuote(login))
}
} else {
log.Infof("keeping user")
@@ -205,52 +205,54 @@ func usersDeleteAction(ctx *cli.Context) error {
func usersListAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"UID", "Role", "Username", "Email", "Display Name"}
users := query.RegisteredUsers()
rows := make([][]string, len(users))
log.Infof("found %s", english.Plural(len(users), "user", "users"))
fmt.Printf("%-4s %-16s %-16s %-16s\n", "ID", "LOGIN", "NAME", "EMAIL")
for _, user := range users {
fmt.Printf("%-4d %-16s %-16s %-16s", user.ID, user.Username(), user.FullName, user.PrimaryEmail)
fmt.Printf("\n")
for i, user := range users {
rows[i] = []string{user.UserUID, user.AclRole().String(), user.UserName(), user.UserEmail(), user.RealName()}
}
return nil
result, err := report.Render(rows, cols, report.CliFormat(ctx))
fmt.Println(result)
return err
})
}
func usersUpdateAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) error {
username := ctx.Args().First()
if username == "" {
return errors.New("pass username as argument")
login := ctx.Args().First()
if login == "" {
return errors.New("please provide the username as argument")
}
u := entity.FindUserByName(username)
u := entity.FindUserByLogin(login)
if u == nil {
return errors.New("user not found")
return errors.New("username not found")
}
uc := form.UserCreate{
FullName: strings.TrimSpace(ctx.String("fullname")),
Username: strings.TrimSpace(ctx.String("username")),
Email: strings.TrimSpace(ctx.String("email")),
Password: strings.TrimSpace(ctx.String("password")),
}
if ctx.IsSet("email") && len(uc.Email) > 0 {
u.Email = uc.Email
}
if ctx.IsSet("password") {
err := u.SetPassword(uc.Password)
if err != nil {
return err
}
fmt.Printf("password successfully changed: %s\n", clean.Log(u.Username()))
}
if ctx.IsSet("fullname") {
u.FullName = uc.FullName
}
if ctx.IsSet("email") && len(uc.Email) > 0 {
u.PrimaryEmail = uc.Email
fmt.Printf("password successfully changed: %s\n", clean.Log(u.UserName()))
}
if err := u.Validate(); err != nil {
@@ -261,7 +263,7 @@ func usersUpdateAction(ctx *cli.Context) error {
return err
}
fmt.Printf("user successfully updated: %s\n", clean.Log(u.Username()))
fmt.Printf("user account successfully updated: %s\n", clean.Log(u.UserName()))
return nil
})

View File

@@ -112,10 +112,12 @@ func NewConfig(ctx *cli.Context) *Config {
// Initialize package extensions.
for _, ext := range Extensions() {
start := time.Now()
if err := ext.init(c); err != nil {
log.Warnf("config: failed to initialize extension %s (%s)", clean.Log(ext.name), err)
log.Warnf("config: %s in %s extension[%s]", err, clean.Log(ext.name), time.Since(start))
} else {
log.Debugf("config: extension %s initialized", clean.Log(ext.name))
log.Debugf("config: %s extension loaded [%s]", clean.Log(ext.name), time.Since(start))
}
}

View File

@@ -3,6 +3,7 @@ package config
import (
"regexp"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"golang.org/x/crypto/bcrypt"
)
@@ -20,9 +21,20 @@ func isBcrypt(s string) bool {
return b
}
// AdminUser returns the admin username.
func (c *Config) AdminUser() string {
c.options.AdminUser = clean.Login(c.options.AdminUser)
if c.options.AdminUser == "" {
c.options.AdminUser = "admin"
}
return c.options.AdminUser
}
// AdminPassword returns the initial admin password.
func (c *Config) AdminPassword() string {
return c.options.AdminPassword
return clean.Password(c.options.AdminPassword)
}
// Public checks if app runs in public mode and requires no authentication.

View File

@@ -8,6 +8,9 @@ const ApiUri = "/api/v1"
// StaticUri is the relative path for serving static content.
const StaticUri = "/static"
// CustomStaticUri is the relative path for serving custom static content.
const CustomStaticUri = "/c/static"
// MsgSponsor and MsgSignUp provide sponsorship info messages;
// SignUpURL a signup link.
const MsgSponsor = "PhotoPrism® needs your support!"

View File

@@ -108,8 +108,10 @@ func (c *Config) WallpaperUri() string {
// Valid URI? Local file?
if p := clean.Path(c.options.WallpaperUri); p == "" {
c.options.WallpaperUri = ""
} else if fs.FileExists(filepath.Join(c.StaticPath(), assetPath, p)) {
} else if fs.FileExists(path.Join(c.StaticPath(), assetPath, p)) {
c.options.WallpaperUri = path.Join(c.StaticUri(), assetPath, p)
} else if fs.FileExists(c.CustomStaticFile(path.Join(assetPath, p))) {
c.options.WallpaperUri = path.Join(c.CustomStaticUri(), assetPath, p)
} else {
c.options.WallpaperUri = ""
}

View File

@@ -11,6 +11,8 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite"
@@ -270,7 +272,12 @@ func (c *Config) MigrateDb(runFailed bool, ids []string) {
entity.SetDbProvider(c)
entity.InitDb(true, runFailed, ids)
entity.Admin.InitPassword(c.AdminPassword())
// Init admin account?
if c.AdminPassword() == "" {
log.Warnf("config: password required to initialize %s account", clean.LogQuote(c.AdminUser()))
} else if entity.Admin.InitAccount(c.AdminUser(), c.AdminPassword()) {
log.Infof("config: %s account has been initialized", clean.LogQuote(c.AdminUser()))
}
go entity.SaveErrorMessages()
}
@@ -281,7 +288,11 @@ func (c *Config) InitTestDb() {
entity.SetDbProvider(c)
entity.ResetTestFixtures()
entity.Admin.InitPassword(c.AdminPassword())
if c.AdminPassword() == "" {
// Do nothing.
} else if entity.Admin.InitAccount(c.AdminUser(), c.AdminPassword()) {
log.Debugf("config: %s account has been initialized", clean.LogQuote(c.AdminUser()))
}
go entity.SaveErrorMessages()
}

View File

@@ -424,6 +424,35 @@ func (c *Config) CustomAssetsPath() string {
return ""
}
// CustomStaticPath returns the custom static assets' path.
func (c *Config) CustomStaticPath() string {
if dir := c.CustomAssetsPath(); dir == "" {
return ""
} else if dir = filepath.Join(dir, "static"); !fs.PathExists(dir) {
return ""
} else {
return dir
}
}
// CustomStaticFile returns the path to a custom static file.
func (c *Config) CustomStaticFile(fileName string) string {
if dir := c.CustomStaticPath(); dir == "" {
return ""
} else {
return filepath.Join(dir, fileName)
}
}
// CustomStaticUri returns the URI to a custom static resource.
func (c *Config) CustomStaticUri() string {
if dir := c.CustomAssetsPath(); dir == "" {
return ""
} else {
return c.CdnUrl(c.BaseUri(CustomStaticUri))
}
}
// LocalesPath returns the translation locales path.
func (c *Config) LocalesPath() string {
return filepath.Join(c.AssetsPath(), "locales")

View File

@@ -14,6 +14,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
rows = [][]string{
// Authentication.
{"auth-mode", fmt.Sprintf("%s", c.AuthMode())},
{"admin-user", c.AdminUser()},
{"admin-password", strings.Repeat("*", utf8.RuneCountInString(c.AdminPassword()))},
{"public", fmt.Sprintf("%t", c.Public())},
@@ -167,8 +168,12 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"log-filename", c.LogFilename()},
}
if p := c.CustomAssetsPath(); p != "" {
rows = append(rows, []string{"custom-assets-path", p})
if v := c.CustomAssetsPath(); v != "" {
rows = append(rows, []string{"custom-assets-path", v})
}
if v := c.CustomStaticUri(); v != "" {
rows = append(rows, []string{"custom-static-uri", v})
}
return rows, cols

View File

@@ -211,6 +211,15 @@ func TestConfig_DetectNSFW(t *testing.T) {
assert.Equal(t, true, result)
}
func TestConfig_AdminUser(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.AdminUser = "foo "
assert.Equal(t, "foo", c.AdminUser())
c.options.AdminUser = " Admin"
assert.Equal(t, "admin", c.AdminUser())
}
func TestConfig_AdminPassword(t *testing.T) {
c := NewConfig(CliTestContext())

View File

@@ -26,6 +26,7 @@ type Options struct {
PartnerID string `yaml:"-" json:"-" flag:"partner-id"`
AuthMode string `yaml:"AuthMode" json:"-" flag:"auth-mode"`
Public bool `yaml:"Public" json:"-" flag:"public"`
AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"`
AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"`
Prod bool `yaml:"Prod" json:"Prod" flag:"prod"`

View File

@@ -18,6 +18,13 @@ var Flags = CliFlags{
Value: "password",
EnvVar: "PHOTOPRISM_AUTH_MODE",
}},
CliFlag{
Flag: cli.StringFlag{
Name: "admin-user, login",
Usage: "admin login `USERNAME`",
EnvVar: "PHOTOPRISM_ADMIN_USER",
Value: "admin",
}},
CliFlag{
Flag: cli.StringFlag{
Name: "admin-password, pw",

View File

@@ -1,83 +0,0 @@
package entity
import (
"fmt"
"time"
"github.com/photoprism/photoprism/pkg/clean"
)
type Addresses []Address
// Address represents a postal address.
type Address struct {
ID int `gorm:"primary_key" json:"ID" yaml:"ID"`
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"CellID"`
AddressSrc string `gorm:"type:VARBINARY(8);" json:"Src" yaml:"Src"`
AddressLat float32 `gorm:"type:FLOAT;index;" json:"Lat" yaml:"Lat,omitempty"`
AddressLng float32 `gorm:"type:FLOAT;index;" json:"Lng" yaml:"Lng,omitempty"`
AddressLine1 string `gorm:"size:255;" json:"Line1" yaml:"Line1,omitempty"`
AddressLine2 string `gorm:"size:255;" json:"Line2" yaml:"Line2,omitempty"`
AddressZip string `gorm:"size:32;" json:"Zip" yaml:"Zip,omitempty"`
AddressCity string `gorm:"size:128;" json:"City" yaml:"City,omitempty"`
AddressState string `gorm:"size:128;" json:"State" yaml:"State,omitempty"`
AddressCountry string `gorm:"type:VARBINARY(2);default:'zz'" json:"Country" yaml:"Country,omitempty"`
AddressNotes string `gorm:"type:VARCHAR(1024);" json:"Notes" yaml:"Notes,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
}
// TableName returns the entity database table name.
func (Address) TableName() string {
return "addresses"
}
// UnknownAddress may be used as placeholder for an unknown address.
var UnknownAddress = Address{
CellID: UnknownLocation.ID,
AddressSrc: SrcDefault,
AddressCountry: UnknownCountry.ID,
}
// CreateUnknownAddress creates the default address if not exists.
func CreateUnknownAddress() {
UnknownAddress = *FirstOrCreateAddress(&UnknownAddress)
}
// FirstOrCreateAddress returns the existing row, inserts a new row or nil in case of errors.
func FirstOrCreateAddress(m *Address) *Address {
result := Address{}
if err := Db().Where("cell_id = ?", m.CellID).First(&result).Error; err == nil {
return &result
} else if createErr := m.Create(); createErr == nil {
return m
} else if err := Db().Where("cell_id = ?", m.CellID).First(&result).Error; err == nil {
return &result
} else {
log.Errorf("address: %s (find or create %s)", createErr, m.String())
}
return nil
}
// String returns an identifier that can be used in logs.
func (m *Address) String() string {
return clean.Log(fmt.Sprintf("%s, %s %s, %s", m.AddressLine1, m.AddressZip, m.AddressCity, m.AddressCountry))
}
// Unknown returns true if the address is unknown.
func (m *Address) Unknown() bool {
return m.ID < 0
}
// Create inserts a new row to the database.
func (m *Address) Create() error {
return Db().Create(m).Error
}
// Saves the new row to the database.
func (m *Address) Save() error {
return Db().Save(m).Error
}

View File

@@ -1,50 +0,0 @@
package entity
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFirstOrCreateAddress(t *testing.T) {
t.Run("existing address", func(t *testing.T) {
address := Address{ID: 1234567, AddressLine1: "Line 1", AddressCountry: "DE"}
result := FirstOrCreateAddress(&address)
if result == nil {
t.Fatal("result should not be nil")
}
t.Log(result)
assert.Equal(t, 1234567, result.ID)
})
t.Run("not existing address", func(t *testing.T) {
address := &Address{}
result := FirstOrCreateAddress(address)
if result == nil {
t.Fatal("result should not be nil")
}
t.Log(result)
assert.Equal(t, 1234568, result.ID)
})
}
func TestAddress_String(t *testing.T) {
t.Run("success", func(t *testing.T) {
address := Address{ID: 1234567, AddressLine1: "Line 1", AddressLine2: "Line 2", AddressCity: "Berlin", AddressCountry: "DE"}
addressString := address.String()
assert.Equal(t, "'Line 1, Berlin, DE'", addressString)
})
}
func TestAddress_Unknown(t *testing.T) {
t.Run("success", func(t *testing.T) {
address := Address{ID: 1234567, AddressLine1: "Line 1", AddressLine2: "Line 2", AddressCity: "Berlin", AddressCountry: "DE"}
assert.False(t, address.Unknown())
})
}

View File

@@ -0,0 +1,71 @@
package entity
import (
"time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Tokens represents a list of auth tokens.
type Tokens []Token
// Token represents a set of authentication tokens.
// Lengths for OAuth 2.0 fields, see https://blogs.intuit.com/blog/2020/03/23/increased-lengths-for-oauth-2-0-fields/.
type Token struct {
ID int `gorm:"primary_key" json:"-" yaml:"-"`
TokenUID string `gorm:"type:VARBINARY(42);column:token_uid;unique_index;" json:"UID" yaml:"UID"`
TokenName string `gorm:"type:VARCHAR(64);column:token_name;" json:"Name,omitempty" yaml:"Name,omitempty"`
TokenType string `gorm:"type:VARCHAR(64);column:token_type;" json:"Type,omitempty" yaml:"Type,omitempty"`
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;index;" json:"UserUID" yaml:"UserUID"`
Version string `gorm:"type:VARCHAR(32);column:version;default:'oauth20';" json:"Version,omitempty" yaml:"Version,omitempty"`
Auth string `gorm:"type:VARBINARY(512);column:auth;" json:"Auth,omitempty" yaml:"Auth,omitempty"`
Access string `gorm:"type:VARBINARY(4096);column:access;" json:"Access,omitempty" yaml:"Access,omitempty"`
Refresh string `gorm:"type:VARBINARY(512);column:refresh;" json:"Refresh,omitempty" yaml:"Refresh,omitempty"`
FileRoot string `gorm:"type:VARBINARY(16);column:file_root;" json:"FileRoot" yaml:"FileRoot,omitempty"`
FilePath string `gorm:"type:VARBINARY(500);column:file_path;" json:"FilePath" yaml:"FilePath,omitempty"`
CanIndex bool `gorm:"default:false;" json:"CanIndex" yaml:"CanIndex,omitempty"`
CanImport bool `gorm:"default:false;" json:"CanImport" yaml:"CanImport,omitempty"`
CanUpload bool `gorm:"default:false;" json:"CanUpload" yaml:"CanUpload,omitempty"`
CanDownload bool `gorm:"default:false;" json:"CanDownload" yaml:"CanDownload,omitempty"`
CanSearch bool `gorm:"default:false;" json:"CanSearch" yaml:"CanSearch,omitempty"`
CanShare bool `gorm:"default:false;" json:"CanShare" yaml:"CanShare,omitempty"`
CanEdit bool `gorm:"default:false;" json:"CanEdit" yaml:"CanEdit,omitempty"`
CanComment bool `gorm:"default:false;" json:"CanComment" yaml:"CanComment,omitempty"`
CanDelete bool `gorm:"default:false;" json:"CanDelete" yaml:"CanDelete,CanCreate"`
Notes string `gorm:"type:VARCHAR(512);column:notes;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
ExpiresAt *time.Time `json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
}
// TableName returns the entity database table name.
func (Token) TableName() string {
return "auth_tokens"
}
// Create new entity in the database.
func (m *Token) Create() error {
return Db().Create(m).Error
}
// Save entity properties.
func (m *Token) Save() error {
return Db().Save(m).Error
}
// Updates multiple properties in the database.
func (m *Token) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Token) BeforeCreate(tx *gorm.DB) error {
if rnd.ValidID(m.TokenUID, 'u') {
return nil
}
m.TokenUID = rnd.GenerateUID('u')
return nil
}

View File

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

View File

@@ -0,0 +1,68 @@
package entity
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/rnd"
)
const DefaultAdminUserName = "admin"
const DefaultAdminFullName = "Admin"
const DisplayNameUnknown = "Public"
const DisplayNameGuest = "Guest"
// Admin is the default admin user.
var Admin = User{
ID: 1,
UserSlug: "admin",
Username: DefaultAdminUserName,
UserRole: acl.RoleAdmin.String(),
DisplayName: DefaultAdminFullName,
SuperAdmin: true,
CanLogin: true,
CanInvite: true,
InviteToken: rnd.GenerateToken(8),
}
// UnknownUser is an anonymous, public user without own account.
var UnknownUser = User{
ID: -1,
UserSlug: "1",
UserUID: "u000000000000001",
UserRole: "",
Username: "",
DisplayName: DisplayNameUnknown,
SuperAdmin: false,
CanLogin: false,
CanInvite: false,
InviteToken: "",
}
// Guest is a user without own account e.g. for link sharing.
var Guest = User{
ID: -2,
UserSlug: "2",
UserUID: "u000000000000002",
UserRole: acl.RoleGuest.String(),
Username: "",
DisplayName: DisplayNameGuest,
SuperAdmin: false,
CanLogin: false,
CanInvite: false,
InviteToken: "",
}
// CreateDefaultUsers initializes the database with default user accounts.
func CreateDefaultUsers() {
if user := FirstOrCreateUser(&Admin); user != nil {
Admin = *user
}
if user := FirstOrCreateUser(&UnknownUser); user != nil {
UnknownUser = *user
}
if user := FirstOrCreateUser(&Guest); user != nil {
Guest = *user
}
}

View File

@@ -0,0 +1,84 @@
package entity
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/rnd"
)
type UserMap map[string]User
func (m UserMap) Get(name string) User {
if result, ok := m[name]; ok {
return result
}
return User{}
}
func (m UserMap) Pointer(name string) *User {
if result, ok := m[name]; ok {
return &result
}
return &User{}
}
var UserFixtures = UserMap{
"alice": {
ID: 5,
UserUID: "uqxetse3cy5eo9z2",
UserSlug: "alice",
Username: "alice",
Email: "alice@example.com",
UserRole: acl.RoleAdmin.String(),
SuperAdmin: true,
DisplayName: "Alice",
CanLogin: true,
CanInvite: true,
InviteToken: rnd.GenerateToken(8),
},
"bob": {
ID: 7,
UserUID: "uqxc08w3d0ej2283",
UserSlug: "bob",
Username: "bob",
Email: "bob@example.com",
UserRole: acl.RoleEditor.String(),
SuperAdmin: false,
DisplayName: "Bob",
CanLogin: true,
CanInvite: false,
},
"friend": {
ID: 8,
UserUID: "uqxqg7i1kperxvu7",
UserSlug: "friend",
Username: "friend",
Email: "friend@example.com",
UserRole: acl.RoleViewer.String(),
SuperAdmin: false,
DisplayName: "Guy Friend",
CanLogin: true,
CanInvite: false,
},
"deleted": {
ID: 10000008,
UserUID: "uqxqg7i1kperxvu8",
UserSlug: "deleted",
Username: "deleted",
Email: "",
DisplayName: "Deleted User",
SuperAdmin: false,
UserRole: acl.RoleGuest.String(),
CanLogin: false,
CanInvite: false,
DeletedAt: &deleteTime,
},
}
// CreateUserFixtures inserts known entities into the database for testing.
func CreateUserFixtures() {
for _, entity := range UserFixtures {
Db().Create(&entity)
}
}

View File

@@ -9,14 +9,14 @@ import (
func TestUserMap_Get(t *testing.T) {
t.Run("get existing user", func(t *testing.T) {
r := UserFixtures.Get("alice")
assert.Equal(t, "alice", r.UserName)
assert.Equal(t, "alice", r.Username())
assert.Equal(t, "alice", r.Username)
assert.Equal(t, "alice", r.UserName())
assert.IsType(t, User{}, r)
})
t.Run("get not existing user", func(t *testing.T) {
r := UserFixtures.Get("monstera")
assert.Equal(t, "", r.UserName)
assert.Equal(t, "", r.Username())
assert.Equal(t, "", r.Username)
assert.Equal(t, "", r.UserName())
assert.IsType(t, User{}, r)
})
}
@@ -24,12 +24,12 @@ func TestUserMap_Get(t *testing.T) {
func TestUserMap_Pointer(t *testing.T) {
t.Run("get existing user", func(t *testing.T) {
r := UserFixtures.Pointer("alice")
assert.Equal(t, "alice", r.UserName)
assert.Equal(t, "alice", r.Username)
assert.IsType(t, &User{}, r)
})
t.Run("get not existing user", func(t *testing.T) {
r := UserFixtures.Pointer("monstera")
assert.Equal(t, "", r.UserName)
assert.Equal(t, "", r.Username)
assert.IsType(t, &User{}, r)
})
}

View File

@@ -11,7 +11,7 @@ import (
func TestFindUserByName(t *testing.T) {
t.Run("admin", func(t *testing.T) {
m := FindUserByName("admin")
m := FindUserByLogin("admin")
if m == nil {
t.Fatal("result should not be nil")
@@ -19,21 +19,25 @@ 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.Username())
m.UserName = "Admin "
assert.Equal(t, "admin", m.Username())
assert.Equal(t, "Admin ", m.UserName)
assert.Equal(t, "Admin", m.FullName)
assert.True(t, m.RoleAdmin)
assert.False(t, m.RoleGuest)
assert.False(t, m.UserDisabled)
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.False(t, m.IsEditor())
assert.False(t, m.IsViewer())
assert.False(t, m.IsGuest())
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 := FindUserByName("alice")
m := FindUserByLogin("alice")
if m == nil {
t.Fatal("result should not be nil")
@@ -41,18 +45,22 @@ func TestFindUserByName(t *testing.T) {
assert.Equal(t, 5, m.ID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
assert.Equal(t, "alice", m.UserName)
assert.Equal(t, "Alice", m.FullName)
assert.Equal(t, "alice@example.com", m.PrimaryEmail)
assert.True(t, m.RoleAdmin)
assert.False(t, m.RoleGuest)
assert.False(t, m.UserDisabled)
assert.Equal(t, "alice", m.Username)
assert.Equal(t, "Alice", m.DisplayName)
assert.Equal(t, "alice@example.com", m.Email)
assert.True(t, m.SuperAdmin)
assert.Equal(t, acl.RoleAdmin, m.AclRole())
assert.NotEqual(t, acl.RoleGuest, m.AclRole())
assert.False(t, m.IsEditor())
assert.False(t, m.IsViewer())
assert.False(t, m.IsGuest())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("bob", func(t *testing.T) {
m := FindUserByName("bob")
m := FindUserByLogin("bob")
if m == nil {
t.Fatal("result should not be nil")
@@ -60,18 +68,20 @@ func TestFindUserByName(t *testing.T) {
assert.Equal(t, 7, m.ID)
assert.Equal(t, "uqxc08w3d0ej2283", m.UserUID)
assert.Equal(t, "bob", m.UserName)
assert.Equal(t, "Bob", m.FullName)
assert.Equal(t, "bob@example.com", m.PrimaryEmail)
assert.False(t, m.RoleAdmin)
assert.False(t, m.RoleGuest)
assert.False(t, m.UserDisabled)
assert.Equal(t, "bob", m.Username)
assert.Equal(t, "Bob", m.DisplayName)
assert.Equal(t, "bob@example.com", m.Email)
assert.False(t, m.SuperAdmin)
assert.True(t, m.IsEditor())
assert.False(t, m.IsViewer())
assert.False(t, m.IsGuest())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("unknown", func(t *testing.T) {
m := FindUserByName("")
m := FindUserByLogin("")
if m != nil {
t.Fatal("result should be nil")
@@ -79,7 +89,7 @@ func TestFindUserByName(t *testing.T) {
})
t.Run("not found", func(t *testing.T) {
m := FindUserByName("xxx")
m := FindUserByLogin("xxx")
if m != nil {
t.Fatal("result should be nil")
@@ -87,9 +97,55 @@ func TestFindUserByName(t *testing.T) {
})
}
func TestUser_Create(t *testing.T) {
t.Run("Slug", func(t *testing.T) {
var m = User{
Username: "example",
UserRole: acl.RoleEditor.String(),
DisplayName: "Example",
SuperAdmin: false,
CanLogin: true,
}
if err := m.Create(); err != nil {
t.Fatal(err)
}
assert.Equal(t, "example", m.UserName())
assert.Equal(t, "example", m.Username)
assert.Equal(t, m.Username, m.UserSlug)
if err := m.UpdateName("example-editor"); err != nil {
t.Fatal(err)
}
assert.Equal(t, "example-editor", m.UserName())
assert.Equal(t, "example-editor", m.Username)
assert.Equal(t, m.Username, m.UserSlug)
})
}
func TestUser_SetName(t *testing.T) {
t.Run("photoprism", func(t *testing.T) {
m := FindUserByLogin("admin")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, "admin", m.UserName())
assert.Equal(t, "admin", m.Username)
m.SetUsername("photoprism")
assert.Equal(t, "photoprism", m.UserName())
assert.Equal(t, "photoprism", m.Username)
})
}
func TestUser_InvalidPassword(t *testing.T) {
t.Run("admin", func(t *testing.T) {
m := FindUserByName("admin")
m := FindUserByLogin("admin")
if m == nil {
t.Fatal("result should not be nil")
@@ -98,7 +154,7 @@ func TestUser_InvalidPassword(t *testing.T) {
assert.False(t, m.InvalidPassword("photoprism"))
})
t.Run("admin invalid password", func(t *testing.T) {
m := FindUserByName("admin")
m := FindUserByLogin("admin")
if m == nil {
t.Fatal("result should not be nil")
@@ -107,7 +163,7 @@ func TestUser_InvalidPassword(t *testing.T) {
assert.True(t, m.InvalidPassword("wrong-password"))
})
t.Run("no password existing", func(t *testing.T) {
p := User{UserUID: "u000000000000010", UserName: "Hans", FullName: ""}
p := User{UserUID: "u000000000000010", Username: "Hans", DisplayName: ""}
err := p.Save()
if err != nil {
t.Fatal(err)
@@ -116,11 +172,11 @@ func TestUser_InvalidPassword(t *testing.T) {
})
t.Run("not registered", func(t *testing.T) {
p := User{UserUID: "u12", UserName: "", FullName: ""}
p := User{UserUID: "u12", Username: "", DisplayName: ""}
assert.True(t, p.InvalidPassword("abcdef"))
})
t.Run("password empty", func(t *testing.T) {
p := User{UserUID: "u000000000000011", UserName: "User", FullName: ""}
p := User{UserUID: "u000000000000011", Username: "User", DisplayName: ""}
assert.True(t, p.InvalidPassword(""))
})
}
@@ -176,8 +232,8 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, -2, m.ID)
assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "", m.UserName)
assert.Equal(t, "Guest", m.FullName)
assert.Equal(t, "", m.Username)
assert.Equal(t, "Guest", m.DisplayName)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
@@ -207,12 +263,15 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, 5, m.ID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
assert.Equal(t, "alice", m.Username())
assert.Equal(t, "Alice", m.FullName)
assert.Equal(t, "alice@example.com", m.PrimaryEmail)
assert.True(t, m.RoleAdmin)
assert.False(t, m.RoleGuest)
assert.False(t, m.UserDisabled)
assert.Equal(t, "alice", m.UserName())
assert.Equal(t, "Alice", m.DisplayName)
assert.Equal(t, "alice@example.com", m.Email)
assert.True(t, m.SuperAdmin)
assert.True(t, m.IsAdmin())
assert.False(t, m.IsEditor())
assert.False(t, m.IsViewer())
assert.False(t, m.IsGuest())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
@@ -226,12 +285,15 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, 7, m.ID)
assert.Equal(t, "uqxc08w3d0ej2283", m.UserUID)
assert.Equal(t, "bob", m.UserName)
assert.Equal(t, "Bob", m.FullName)
assert.Equal(t, "bob@example.com", m.PrimaryEmail)
assert.False(t, m.RoleAdmin)
assert.False(t, m.RoleGuest)
assert.False(t, m.UserDisabled)
assert.Equal(t, "bob", m.Username)
assert.Equal(t, "Bob", m.DisplayName)
assert.Equal(t, "bob@example.com", m.Email)
assert.False(t, m.SuperAdmin)
assert.False(t, m.IsAdmin())
assert.True(t, m.IsEditor())
assert.False(t, m.IsViewer())
assert.False(t, m.IsGuest())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
@@ -244,10 +306,12 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, 8, m.ID)
assert.Equal(t, "uqxqg7i1kperxvu7", m.UserUID)
assert.False(t, m.RoleAdmin)
assert.False(t, m.RoleGuest)
assert.True(t, m.RoleFriend)
assert.True(t, m.UserDisabled)
assert.False(t, m.SuperAdmin)
assert.False(t, m.IsAdmin())
assert.False(t, m.IsEditor())
assert.True(t, m.IsViewer())
assert.False(t, m.IsGuest())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
@@ -256,75 +320,79 @@ func TestFindUserByUID(t *testing.T) {
func TestUser_String(t *testing.T) {
t.Run("UID", func(t *testing.T) {
p := User{UserUID: "abc123", UserName: "", FullName: ""}
p := User{UserUID: "abc123", Username: "", DisplayName: ""}
assert.Equal(t, "abc123", p.String())
})
t.Run("DisplayName", func(t *testing.T) {
p := User{UserUID: "abc123", UserName: "", FullName: "Test"}
t.Run("FullName", func(t *testing.T) {
p := User{UserUID: "abc123", Username: "", DisplayName: "Test"}
assert.Equal(t, "Test", p.String())
})
t.Run("UserName", func(t *testing.T) {
p := User{UserUID: "abc123", UserName: "Super-User ", FullName: "Test"}
t.Run("Username", func(t *testing.T) {
p := User{UserUID: "abc123", Username: "Super-User ", DisplayName: "Test"}
assert.Equal(t, "super-user", p.String())
})
}
func TestUser_Admin(t *testing.T) {
t.Run("true", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleAdmin: true}
assert.True(t, p.Admin())
t.Run("SuperAdmin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: "", SuperAdmin: true}
assert.True(t, p.IsAdmin())
})
t.Run("RoleAdmin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.True(t, p.IsAdmin())
})
t.Run("false", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleAdmin: false}
assert.False(t, p.Admin())
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: ""}
assert.False(t, p.IsAdmin())
})
}
func TestUser_Anonymous(t *testing.T) {
t.Run("true", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", FullName: ""}
assert.True(t, p.Anonymous())
p := User{UserUID: "", Username: "Hanna", DisplayName: ""}
assert.True(t, p.IsAnonymous())
})
t.Run("false", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleAdmin: true}
assert.False(t, p.Anonymous())
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: "", SuperAdmin: true}
assert.False(t, p.IsAnonymous())
})
}
func TestUser_Guest(t *testing.T) {
t.Run("true", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", FullName: "", RoleGuest: true}
assert.True(t, p.Guest())
p := User{UserUID: "", Username: "Hanna", DisplayName: "", UserRole: "guest"}
assert.True(t, p.IsGuest())
})
t.Run("false", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", FullName: ""}
assert.False(t, p.Guest())
p := User{UserUID: "", Username: "Hanna", DisplayName: ""}
assert.False(t, p.IsGuest())
})
}
func TestUser_SetPassword(t *testing.T) {
t.Run("success", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: ""}
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: ""}
if err := p.SetPassword("insecure"); err != nil {
t.Fatal(err)
}
})
t.Run("not registered", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", FullName: ""}
p := User{UserUID: "", Username: "Hanna", DisplayName: ""}
assert.Error(t, p.SetPassword("insecure"))
})
t.Run("password too short", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: ""}
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: ""}
assert.Error(t, p.SetPassword("cat"))
})
}
func TestUser_InitPassword(t *testing.T) {
func TestUser_InitLogin(t *testing.T) {
t.Run("success", func(t *testing.T) {
p := User{UserUID: "u000000000000009", UserName: "Hanna", FullName: ""}
p := User{UserUID: "u000000000000009", Username: "Hanna", DisplayName: ""}
assert.Nil(t, FindPassword("u000000000000009"))
p.InitPassword("insecure")
p.InitAccount("admin", "insecure")
m := FindPassword("u000000000000009")
if m == nil {
@@ -332,7 +400,7 @@ func TestUser_InitPassword(t *testing.T) {
}
})
t.Run("already existing", func(t *testing.T) {
p := User{UserUID: "u000000000000010", UserName: "Hans", FullName: ""}
p := User{UserUID: "u000000000000010", Username: "Hans", DisplayName: ""}
if err := p.Save(); err != nil {
t.Logf("cannot user %s: ", err)
@@ -343,7 +411,7 @@ func TestUser_InitPassword(t *testing.T) {
}
assert.NotNil(t, FindPassword("u000000000000010"))
p.InitPassword("insecure")
p.InitAccount("admin", "insecure")
m := FindPassword("u000000000000010")
if m == nil {
@@ -351,129 +419,112 @@ func TestUser_InitPassword(t *testing.T) {
}
})
t.Run("not registered", func(t *testing.T) {
p := User{UserUID: "u12", UserName: "", FullName: ""}
p := User{UserUID: "u12", Username: "", DisplayName: ""}
assert.Nil(t, FindPassword("u12"))
p.InitPassword("insecure")
p.InitAccount("admin", "insecure")
assert.Nil(t, FindPassword("u12"))
})
t.Run("password empty", func(t *testing.T) {
p := User{UserUID: "u000000000000011", UserName: "User", FullName: ""}
p := User{UserUID: "u000000000000011", Username: "User", DisplayName: ""}
assert.Nil(t, FindPassword("u000000000000011"))
p.InitPassword("")
p.InitAccount("admin", "")
assert.Nil(t, FindPassword("u000000000000011"))
})
}
func TestUser_Role(t *testing.T) {
t.Run("admin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleAdmin: true}
assert.Equal(t, acl.Role("admin"), p.Role())
})
t.Run("child", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleChild: true}
assert.Equal(t, acl.Role("child"), p.Role())
})
t.Run("family", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleFamily: true}
assert.Equal(t, acl.Role("family"), p.Role())
})
t.Run("friend", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleFriend: true}
assert.Equal(t, acl.Role("friend"), p.Role())
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: "", SuperAdmin: true, UserRole: acl.RoleAdmin.String()}
assert.Equal(t, acl.Role("admin"), p.AclRole())
assert.True(t, p.IsAdmin())
assert.False(t, p.IsGuest())
})
t.Run("guest", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: "", RoleGuest: true}
assert.Equal(t, acl.Role("guest"), p.Role())
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: "", UserRole: acl.RoleGuest.String()}
assert.Equal(t, acl.Role("guest"), p.AclRole())
assert.False(t, p.IsAdmin())
assert.True(t, p.IsGuest())
})
t.Run("default", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", FullName: ""}
assert.Equal(t, acl.Role("*"), p.Role())
p := User{UserUID: "u000000000000008", Username: "Hanna", DisplayName: ""}
assert.Equal(t, acl.Role("*"), p.AclRole())
assert.False(t, p.IsAdmin())
assert.False(t, p.IsGuest())
})
}
func TestUser_Validate(t *testing.T) {
t.Run("valid", func(t *testing.T) {
u := &User{
AddressID: 1,
UserName: "validate",
FullName: "Validate",
PrimaryEmail: "validate@example.com",
Username: "validate",
DisplayName: "Validate",
Email: "validate@example.com",
}
err := u.Validate()
assert.Nil(t, err)
})
t.Run("username empty", func(t *testing.T) {
u := &User{
AddressID: 1,
UserName: "",
FullName: "Validate",
PrimaryEmail: "validate@example.com",
Username: "",
DisplayName: "Validate",
Email: "validate@example.com",
}
err := u.Validate()
assert.Error(t, err)
})
t.Run("username too short", func(t *testing.T) {
u := &User{
AddressID: 1,
UserName: "va",
FullName: "Validate",
PrimaryEmail: "validate@example.com",
Username: "va",
DisplayName: "Validate",
Email: "validate@example.com",
}
err := u.Validate()
assert.Error(t, err)
})
t.Run("username not unique", func(t *testing.T) {
FirstOrCreateUser(&User{
AddressID: 1,
UserName: "notunique1",
Username: "notunique1",
})
u := &User{
AddressID: 1,
UserName: "notunique1",
FullName: "Not Unique",
PrimaryEmail: "notunique1@example.com",
Username: "notunique1",
DisplayName: "Not Unique",
Email: "notunique1@example.com",
}
assert.Error(t, u.Validate())
})
t.Run("email not unique", func(t *testing.T) {
FirstOrCreateUser(&User{
AddressID: 1,
PrimaryEmail: "notunique2@example.com",
Email: "notunique2@example.com",
})
u := &User{
AddressID: 1,
UserName: "notunique2",
FullName: "Not Unique",
PrimaryEmail: "notunique2@example.com",
Username: "notunique2",
Email: "notunique2@example.com",
DisplayName: "Not Unique",
}
assert.Error(t, u.Validate())
})
t.Run("update user - email not unique", func(t *testing.T) {
FirstOrCreateUser(&User{
AddressID: 1,
UserName: "notunique3",
FullName: "Not Unique",
PrimaryEmail: "notunique3@example.com",
Username: "notunique3",
Email: "notunique3@example.com",
DisplayName: "Not Unique",
})
u := FirstOrCreateUser(&User{
AddressID: 1,
UserName: "notunique30",
FullName: "Not Unique",
PrimaryEmail: "notunique3@example.com",
Username: "notunique30",
Email: "notunique3@example.com",
DisplayName: "Not Unique",
})
u.UserName = "notunique3"
u.Username = "notunique3"
assert.Error(t, u.Validate())
})
t.Run("primary email empty", func(t *testing.T) {
FirstOrCreateUser(&User{
AddressID: 1,
UserName: "nnomail",
Username: "nnomail",
})
u := &User{
AddressID: 1,
UserName: "nomail",
FullName: "No Mail",
PrimaryEmail: "",
Username: "nomail",
Email: "",
DisplayName: "No Mail",
}
assert.Nil(t, u.Validate())
})
@@ -482,21 +533,21 @@ func TestUser_Validate(t *testing.T) {
func TestCreateWithPassword(t *testing.T) {
t.Run("password too short", func(t *testing.T) {
u := form.UserCreate{
UserName: "thomas1",
FullName: "Thomas One",
Username: "thomas1",
Email: "thomas1@example.com",
Password: "hel",
}
err := CreateWithPassword(u)
assert.Error(t, err)
})
t.Run("valid", func(t *testing.T) {
u := form.UserCreate{
UserName: "thomas2",
FullName: "Thomas Two",
Username: "thomas2",
Email: "thomas2@example.com",
Password: "helloworld",
}
err := CreateWithPassword(u)
assert.Nil(t, err)
})
@@ -505,22 +556,22 @@ func TestCreateWithPassword(t *testing.T) {
func TestDeleteUser(t *testing.T) {
t.Run("delete user - success", func(t *testing.T) {
u := &User{
AddressID: 1,
UserName: "thomasdel",
FullName: "Thomas Delete",
PrimaryEmail: "thomasdel@example.com",
Username: "thomasdel",
Email: "thomasdel@example.com",
DisplayName: "Thomas Delete",
}
u = FirstOrCreateUser(u)
err := u.Delete()
assert.NoError(t, err)
})
t.Run("delete user - not in db", func(t *testing.T) {
u := &User{
AddressID: 1,
UserName: "thomasdel2",
FullName: "Thomas Delete 2",
PrimaryEmail: "thomasdel2@example.com",
Username: "thomasdel2",
Email: "thomasdel2@example.com",
DisplayName: "Thomas Delete 2",
}
err := u.Delete()
assert.Error(t, err)
})

View File

@@ -18,6 +18,11 @@ type Duplicate struct {
ModTime int64 `json:"ModTime" yaml:"-"`
}
// TableName returns the entity database table name.
func (Duplicate) TableName() string {
return "duplicates"
}
// AddDuplicate adds a duplicate.
func AddDuplicate(fileName, fileRoot, fileHash string, fileSize, modTime int64) error {
if fileName == "" {

View File

@@ -69,3 +69,11 @@ const (
SortOrderCategory = "category"
SortOrderSimilar = "similar"
)
// User feature flags.
const (
IsEnabled = "enabled"
IsDisabled = "disabled"
CanUpload = "upload"
CanDownload = "download"
)

View File

@@ -6,7 +6,6 @@ import (
// CreateDefaultFixtures inserts default fixtures for test and production.
func CreateDefaultFixtures() {
CreateUnknownAddress()
CreateDefaultUsers()
CreateUnknownPlace()
CreateUnknownLocation()

View File

@@ -15,31 +15,31 @@ type Tables map[string]interface{}
// Entities contains database entities and their table names.
var Entities = Tables{
migrate.Migration{}.TableName(): &migrate.Migration{},
"errors": &Error{},
"addresses": &Address{},
"users": &User{},
"accounts": &Account{},
"folders": &Folder{},
"duplicates": &Duplicate{},
Error{}.TableName(): &Error{},
Password{}.TableName(): &Password{},
User{}.TableName(): &User{},
Token{}.TableName(): &Token{},
Account{}.TableName(): &Account{},
Folder{}.TableName(): &Folder{},
Duplicate{}.TableName(): &Duplicate{},
File{}.TableName(): &File{},
"files_share": &FileShare{},
"files_sync": &FileSync{},
FileShare{}.TableName(): &FileShare{},
FileSync{}.TableName(): &FileSync{},
Photo{}.TableName(): &Photo{},
"details": &Details{},
Details{}.TableName(): &Details{},
Place{}.TableName(): &Place{},
Cell{}.TableName(): &Cell{},
"cameras": &Camera{},
"lenses": &Lens{},
"countries": &Country{},
"albums": &Album{},
"photos_albums": &PhotoAlbum{},
"labels": &Label{},
"categories": &Category{},
"photos_labels": &PhotoLabel{},
"keywords": &Keyword{},
"photos_keywords": &PhotoKeyword{},
"passwords": &Password{},
"links": &Link{},
Camera{}.TableName(): &Camera{},
Lens{}.TableName(): &Lens{},
Country{}.TableName(): &Country{},
Album{}.TableName(): &Album{},
PhotoAlbum{}.TableName(): &PhotoAlbum{},
Label{}.TableName(): &Label{},
Category{}.TableName(): &Category{},
PhotoLabel{}.TableName(): &PhotoLabel{},
Keyword{}.TableName(): &Keyword{},
PhotoKeyword{}.TableName(): &PhotoKeyword{},
Link{}.TableName(): &Link{},
Subject{}.TableName(): &Subject{},
Face{}.TableName(): &Face{},
Marker{}.TableName(): &Marker{},
@@ -73,7 +73,15 @@ func (list Tables) WaitForMigration(db *gorm.DB) {
// Truncate removes all data from tables without dropping them.
func (list Tables) Truncate(db *gorm.DB) {
for name := range list {
var name string
defer func() {
if r := recover(); r != nil {
log.Errorf("migrate: %s in %s (truncate)", r, name)
}
}()
for name = range list {
if err := db.Exec(fmt.Sprintf("DELETE FROM %s WHERE 1", name)).Error; err == nil {
// log.Debugf("entity: removed all data from %s", name)
break
@@ -85,14 +93,23 @@ func (list Tables) Truncate(db *gorm.DB) {
// Migrate migrates all database tables of registered entities.
func (list Tables) Migrate(db *gorm.DB, runFailed bool, ids []string) {
var name string
var entity interface{}
defer func() {
if r := recover(); r != nil {
log.Errorf("migrate: %s in %s (panic)", r, name)
}
}()
if len(ids) == 0 {
for name, entity := range list {
for name, entity = range list {
if err := db.AutoMigrate(entity).Error; err != nil {
log.Debugf("migrate: %s (waiting 1s)", err.Error())
time.Sleep(time.Second)
if err := db.AutoMigrate(entity).Error; err != nil {
if err = db.AutoMigrate(entity).Error; err != nil {
log.Errorf("migrate: failed migrating %s", clean.Log(name))
panic(err)
}

View File

@@ -16,6 +16,11 @@ type Keyword struct {
Skip bool
}
// TableName returns the entity database table name.
func (Keyword) TableName() string {
return "keywords"
}
// NewKeyword registers a new keyword in database
func NewKeyword(keyword string) *Keyword {
keyword = strings.ToLower(txt.Clip(keyword, txt.ClipKeyword))

View File

@@ -29,6 +29,11 @@ type Link struct {
ModifiedAt time.Time `deepcopier:"skip" json:"ModifiedAt" yaml:"ModifiedAt"`
}
// TableName returns the entity database table name.
func (Link) TableName() string {
return "links"
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Link) BeforeCreate(scope *gorm.Scope) error {
if rnd.ValidID(m.LinkUID, 's') {

View File

@@ -14,6 +14,11 @@ type Password struct {
UpdatedAt time.Time `deepcopier:"skip" json:"UpdatedAt"`
}
// TableName returns the entity database table name.
func (Password) TableName() string {
return "passwords"
}
// NewPassword creates a new password instance.
func NewPassword(uid, password string) Password {
if uid == "" {

View File

@@ -1,428 +0,0 @@
package entity
import (
"errors"
"fmt"
"net/mail"
"time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
var UsernameLength = 3
var PasswordLength = 4
type Users []User
// User represents a person that may optionally log in as user.
type User struct {
ID int `gorm:"primary_key" json:"-" yaml:"-"`
Address *Address `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false;PRELOAD:true;" json:"Address,omitempty" yaml:"Address,omitempty"`
AddressID int `gorm:"default:1" json:"-" yaml:"-"`
UserUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
MotherUID string `gorm:"type:VARBINARY(42);" json:"MotherUID" yaml:"MotherUID,omitempty"`
FatherUID string `gorm:"type:VARBINARY(42);" json:"FatherUID" yaml:"FatherUID,omitempty"`
GlobalUID string `gorm:"type:VARBINARY(42);index;" json:"GlobalUID" yaml:"GlobalUID,omitempty"`
FullName string `gorm:"size:128;" json:"FullName" yaml:"FullName,omitempty"`
NickName string `gorm:"size:64;" json:"NickName" yaml:"NickName,omitempty"`
MaidenName string `gorm:"size:64;" json:"MaidenName" yaml:"MaidenName,omitempty"`
ArtistName string `gorm:"size:64;" json:"ArtistName" yaml:"ArtistName,omitempty"`
UserName string `gorm:"size:64;" json:"UserName" yaml:"UserName,omitempty"`
UserStatus string `gorm:"size:32;" json:"UserStatus" yaml:"UserStatus,omitempty"`
UserDisabled bool `json:"UserDisabled" yaml:"UserDisabled,omitempty"`
UserSettings string `gorm:"type:LONGTEXT;" json:"-" yaml:"-"`
PrimaryEmail string `gorm:"size:255;index;" json:"PrimaryEmail" yaml:"PrimaryEmail,omitempty"`
EmailConfirmed bool `json:"EmailConfirmed" yaml:"EmailConfirmed,omitempty"`
BackupEmail string `gorm:"size:255;" json:"BackupEmail" yaml:"BackupEmail,omitempty"`
PersonURL string `gorm:"type:VARBINARY(255);" json:"PersonURL" yaml:"PersonURL,omitempty"`
PersonPhone string `gorm:"size:32;" json:"PersonPhone" yaml:"PersonPhone,omitempty"`
PersonStatus string `gorm:"size:32;" json:"PersonStatus" yaml:"PersonStatus,omitempty"`
PersonAvatar string `gorm:"type:VARBINARY(255);" json:"PersonAvatar" yaml:"PersonAvatar,omitempty"`
PersonLocation string `gorm:"size:128;" json:"PersonLocation" yaml:"PersonLocation,omitempty"`
PersonBio string `gorm:"type:TEXT;" json:"PersonBio" yaml:"PersonBio,omitempty"`
PersonAccounts string `gorm:"type:LONGTEXT;" json:"-" yaml:"-"`
BusinessURL string `gorm:"type:VARBINARY(255);" json:"BusinessURL" yaml:"BusinessURL,omitempty"`
BusinessPhone string `gorm:"size:32;" json:"BusinessPhone" yaml:"BusinessPhone,omitempty"`
BusinessEmail string `gorm:"size:255;" json:"BusinessEmail" yaml:"BusinessEmail,omitempty"`
CompanyName string `gorm:"size:128;" json:"CompanyName" yaml:"CompanyName,omitempty"`
DepartmentName string `gorm:"size:128;" json:"DepartmentName" yaml:"DepartmentName,omitempty"`
JobTitle string `gorm:"size:64;" json:"JobTitle" yaml:"JobTitle,omitempty"`
BirthYear int `json:"BirthYear" yaml:"BirthYear,omitempty"`
BirthMonth int `json:"BirthMonth" yaml:"BirthMonth,omitempty"`
BirthDay int `json:"BirthDay" yaml:"BirthDay,omitempty"`
TermsAccepted bool `json:"TermsAccepted" yaml:"TermsAccepted,omitempty"`
IsArtist bool `json:"IsArtist" yaml:"IsArtist,omitempty"`
IsSubject bool `json:"IsSubject" yaml:"IsSubject,omitempty"`
RoleAdmin bool `json:"RoleAdmin" yaml:"RoleAdmin,omitempty"`
RoleGuest bool `json:"RoleGuest" yaml:"RoleGuest,omitempty"`
RoleChild bool `json:"RoleChild" yaml:"RoleChild,omitempty"`
RoleFamily bool `json:"RoleFamily" yaml:"RoleFamily,omitempty"`
RoleFriend bool `json:"RoleFriend" yaml:"RoleFriend,omitempty"`
WebDAV bool `gorm:"column:webdav" json:"WebDAV" yaml:"WebDAV,omitempty"`
StoragePath string `gorm:"column:storage_path;type:VARBINARY(500);" json:"StoragePath" yaml:"StoragePath,omitempty"`
CanInvite bool `json:"CanInvite" yaml:"CanInvite,omitempty"`
InviteToken string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
InvitedBy string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
ConfirmToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
ResetToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
ApiToken string `gorm:"column:api_token;type:VARBINARY(128);" json:"-" yaml:"-"`
ApiSecret string `gorm:"column:api_secret;type:VARBINARY(128);" json:"-" yaml:"-"`
LoginAttempts int `json:"-" yaml:"-"`
LoginAt *time.Time `json:"-" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
}
// TableName returns the entity database table name.
func (User) TableName() string {
return "users"
}
// Admin is the default admin user.
var Admin = User{
ID: 1,
AddressID: 1,
UserName: "admin",
FullName: "Admin",
RoleAdmin: true,
UserDisabled: false,
}
// UnknownUser is an anonymous, public user without own account.
var UnknownUser = User{
ID: -1,
AddressID: 1,
UserUID: "u000000000000001",
UserName: "",
FullName: "Anonymous",
RoleAdmin: false,
RoleGuest: false,
UserDisabled: true,
}
// Guest is a user without own account e.g. for link sharing.
var Guest = User{
ID: -2,
AddressID: 1,
UserUID: "u000000000000002",
UserName: "",
FullName: "Guest",
RoleAdmin: false,
RoleGuest: true,
UserDisabled: true,
}
// CreateDefaultUsers initializes the database with default user accounts.
func CreateDefaultUsers() {
if user := FirstOrCreateUser(&Admin); user != nil {
Admin = *user
}
if user := FirstOrCreateUser(&UnknownUser); user != nil {
UnknownUser = *user
}
if user := FirstOrCreateUser(&Guest); user != nil {
Guest = *user
}
}
// Create new entity in the database.
func (m *User) Create() error {
return Db().Create(m).Error
}
// Save entity properties.
func (m *User) Save() error {
return Db().Save(m).Error
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *User) BeforeCreate(tx *gorm.DB) error {
if rnd.ValidID(m.UserUID, 'u') {
return nil
}
m.UserUID = rnd.GenerateUID('u')
return nil
}
// FirstOrCreateUser returns an existing row, inserts a new row, or nil in case of errors.
func FirstOrCreateUser(m *User) *User {
result := User{}
if err := Db().Preload("Address").Where("id = ? OR user_uid = ?", m.ID, m.UserUID).First(&result).Error; err == nil {
return &result
} else if err := m.Create(); err != nil {
log.Debugf("user: %s", err)
return nil
}
return m
}
// FindUserByName returns an existing user or nil if not found.
func FindUserByName(userName string) *User {
userName = clean.Username(userName)
if userName == "" {
return nil
}
result := User{}
if err := Db().Preload("Address").Where("user_name = ?", userName).First(&result).Error; err == nil {
return &result
} else {
log.Debugf("user %s not found", clean.Log(userName))
return nil
}
}
// FindUserByUID returns an existing user or nil if not found.
func FindUserByUID(uid string) *User {
if uid == "" {
return nil
}
result := User{}
if err := Db().Preload("Address").Where("user_uid = ?", uid).First(&result).Error; err == nil {
return &result
} else {
log.Debugf("user %s not found", clean.Log(uid))
return nil
}
}
// Delete marks the entity as deleted.
func (m *User) Delete() error {
if m.ID <= 1 {
return fmt.Errorf("cannot delete system user")
}
return Db().Delete(m).Error
}
// Deleted tests if the entity is marked as deleted.
func (m *User) Deleted() bool {
if m.DeletedAt == nil {
return false
}
return !m.DeletedAt.IsZero()
}
// String returns an identifier that can be used in logs.
func (m *User) String() string {
if n := m.Username(); n != "" {
return clean.Log(n)
}
if m.FullName != "" {
return clean.Log(m.FullName)
}
return clean.Log(m.UserUID)
}
// Username returns the normalized username.
func (m *User) Username() string {
return clean.Username(m.UserName)
}
// Registered tests if the user is registered e.g. has a username.
func (m *User) Registered() bool {
return m.Username() != "" && rnd.EntityUID(m.UserUID, 'u')
}
// Admin returns true if the user is an admin with user name.
func (m *User) Admin() bool {
return m.Registered() && m.RoleAdmin
}
// Anonymous returns true if the user is unknown.
func (m *User) Anonymous() bool {
return !rnd.EntityUID(m.UserUID, 'u') || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID
}
// Guest returns true if the user is a guest.
func (m *User) Guest() bool {
return m.RoleGuest
}
// SetPassword sets a new password stored as hash.
func (m *User) SetPassword(password string) error {
if !m.Registered() {
return fmt.Errorf("only registered users can change their password")
}
if len(password) < PasswordLength {
return fmt.Errorf("password must have at least %d characters", PasswordLength)
}
pw := NewPassword(m.UserUID, password)
return pw.Save()
}
// InitPassword sets the initial user password stored as hash.
func (m *User) InitPassword(password string) {
if !m.Registered() {
log.Warn("only registered users can change their password")
return
}
if password == "" {
return
}
existing := FindPassword(m.UserUID)
if existing != nil {
return
}
pw := NewPassword(m.UserUID, password)
if err := pw.Save(); err != nil {
log.Error(err)
}
}
// InvalidPassword returns true if the given password does not match the hash.
func (m *User) InvalidPassword(password string) bool {
if !m.Registered() {
log.Warn("only registered users can change their password")
return true
}
if password == "" {
return true
}
time.Sleep(time.Second * 5 * time.Duration(m.LoginAttempts))
pw := FindPassword(m.UserUID)
if pw == nil {
return true
}
if pw.InvalidPassword(password) {
if err := Db().Model(m).UpdateColumn("login_attempts", gorm.Expr("login_attempts + ?", 1)).Error; err != nil {
log.Errorf("user: %s (update login attempts)", err)
}
return true
}
if err := Db().Model(m).Updates(map[string]interface{}{"login_attempts": 0, "login_at": TimeStamp()}).Error; err != nil {
log.Errorf("user: %s (update last login)", err)
}
return false
}
// Role returns the user role for ACL permission checks.
func (m *User) Role() acl.Role {
if m.RoleAdmin {
return acl.RoleAdmin
}
if m.RoleChild {
return acl.RoleChild
}
if m.RoleFamily {
return acl.RoleFamily
}
if m.RoleFriend {
return acl.RoleFriend
}
if m.RoleGuest {
return acl.RoleGuest
}
return acl.RoleDefault
}
// Validate Makes sure username and email are unique and meet requirements. Returns error if any property is invalid
func (m *User) Validate() error {
if m.Username() == "" {
return errors.New("username must not be empty")
}
if len(m.Username()) < UsernameLength {
return fmt.Errorf("username must have at least %d characters", UsernameLength)
}
var err error
var resultName = User{}
if err = Db().Where("user_name = ? AND id <> ?", m.Username(), m.ID).First(&resultName).Error; err == nil {
return errors.New("username already exists")
} else if err != gorm.ErrRecordNotFound {
return err
}
// stop here if no email is provided
if m.PrimaryEmail == "" {
return nil
}
// validate email address
if a, err := mail.ParseAddress(m.PrimaryEmail); err != nil {
return err
} else {
m.PrimaryEmail = a.Address // make sure email address will be used without name
}
var resultMail = User{}
if err = Db().Where("primary_email = ? AND id <> ?", m.PrimaryEmail, m.ID).First(&resultMail).Error; err == nil {
return errors.New("email already exists")
} else if err != gorm.ErrRecordNotFound {
return err
}
return nil
}
// CreateWithPassword Creates User with Password in db transaction.
func CreateWithPassword(uc form.UserCreate) error {
u := &User{
FullName: uc.FullName,
UserName: uc.UserName,
PrimaryEmail: uc.Email,
RoleAdmin: true,
}
if len(uc.Password) < PasswordLength {
return fmt.Errorf("password must have at least %d characters", PasswordLength)
}
err := u.Validate()
if err != nil {
return err
}
return Db().Transaction(func(tx *gorm.DB) error {
if err := tx.Create(u).Error; err != nil {
return err
}
pw := NewPassword(u.UserUID, uc.Password)
if err := tx.Create(&pw).Error; err != nil {
return err
}
log.Infof("created user %s with uid %s", clean.Log(u.Username()), clean.Log(u.UserUID))
return nil
})
}

View File

@@ -1,76 +0,0 @@
package entity
type UserMap map[string]User
func (m UserMap) Get(name string) User {
if result, ok := m[name]; ok {
return result
}
return User{}
}
func (m UserMap) Pointer(name string) *User {
if result, ok := m[name]; ok {
return &result
}
return &User{}
}
var UserFixtures = UserMap{
"alice": {
ID: 5,
AddressID: 1,
UserUID: "uqxetse3cy5eo9z2",
UserName: "alice",
FullName: "Alice",
RoleAdmin: true,
RoleGuest: false,
UserDisabled: false,
PrimaryEmail: "alice@example.com",
},
"bob": {
ID: 7,
AddressID: 1,
UserUID: "uqxc08w3d0ej2283",
UserName: "bob",
FullName: "Bob",
RoleAdmin: false,
RoleGuest: false,
UserDisabled: false,
PrimaryEmail: "bob@example.com",
},
"friend": {
ID: 8,
AddressID: 1,
UserUID: "uqxqg7i1kperxvu7",
UserName: "friend",
FullName: "Guy Friend",
RoleAdmin: false,
RoleGuest: false,
RoleFriend: true,
UserDisabled: true,
PrimaryEmail: "friend@example.com",
},
"deleted": {
ID: 10000008,
AddressID: 1000000,
UserUID: "uqxqg7i1kperxvu8",
UserName: "deleted",
FullName: "Deleted User",
RoleAdmin: false,
RoleGuest: true,
RoleFriend: false,
UserDisabled: false,
PrimaryEmail: "",
DeletedAt: &deleteTime,
},
}
// CreateUserFixtures inserts known entities into the database for testing.
func CreateUserFixtures() {
for _, entity := range UserFixtures {
Db().Create(&entity)
}
}

View File

@@ -1,8 +1,8 @@
package form
type Login struct {
Username string `json:"username"`
Email string `json:"email"`
UserName string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
}
@@ -11,8 +11,8 @@ func (f Login) HasToken() bool {
return f.Token != ""
}
func (f Login) HasUserName() bool {
return f.UserName != "" && len(f.UserName) <= 255
func (f Login) HasUsername() bool {
return f.Username != "" && len(f.Username) <= 255
}
func (f Login) HasPassword() bool {
@@ -20,5 +20,5 @@ func (f Login) HasPassword() bool {
}
func (f Login) HasCredentials() bool {
return f.HasUserName() && f.HasPassword()
return f.HasUsername() && f.HasPassword()
}

View File

@@ -8,23 +8,23 @@ import (
func TestLogin_HasToken(t *testing.T) {
t.Run("false", func(t *testing.T) {
form := &Login{Email: "test@test.com", UserName: "John", Password: "passwd", Token: ""}
form := &Login{Email: "test@test.com", Username: "John", Password: "passwd", Token: ""}
assert.Equal(t, false, form.HasToken())
})
t.Run("true", func(t *testing.T) {
form := &Login{Email: "test@test.com", UserName: "John", Password: "passwd", Token: "123"}
form := &Login{Email: "test@test.com", Username: "John", Password: "passwd", Token: "123"}
assert.Equal(t, true, form.HasToken())
})
}
func TestLogin_HasUserName(t *testing.T) {
func TestLogin_HasUsername(t *testing.T) {
t.Run("false", func(t *testing.T) {
form := &Login{Email: "test@test.com", Password: "passwd", Token: ""}
assert.Equal(t, false, form.HasUserName())
assert.Equal(t, false, form.HasUsername())
})
t.Run("true", func(t *testing.T) {
form := &Login{Email: "test@test.com", UserName: "John", Password: "passwd", Token: "123"}
assert.Equal(t, true, form.HasUserName())
form := &Login{Email: "test@test.com", Username: "John", Password: "passwd", Token: "123"}
assert.Equal(t, true, form.HasUsername())
})
}
@@ -34,7 +34,7 @@ func TestLogin_HasPassword(t *testing.T) {
assert.Equal(t, false, form.HasPassword())
})
t.Run("true", func(t *testing.T) {
form := &Login{Email: "test@test.com", UserName: "John", Password: "passwd", Token: "123"}
form := &Login{Email: "test@test.com", Username: "John", Password: "passwd", Token: "123"}
assert.Equal(t, true, form.HasPassword())
})
}
@@ -45,7 +45,7 @@ func TestLogin_HasCredentials(t *testing.T) {
assert.Equal(t, false, form.HasCredentials())
})
t.Run("true", func(t *testing.T) {
form := &Login{Email: "test@test.com", UserName: "John", Password: "passwd", Token: "123"}
form := &Login{Email: "test@test.com", Username: "John", Password: "passwd", Token: "123"}
assert.Equal(t, true, form.HasCredentials())
})
}

View File

@@ -4,13 +4,12 @@ import "github.com/photoprism/photoprism/pkg/clean"
// UserCreate represents a User with a new password.
type UserCreate struct {
UserName string `json:"username"`
FullName string `json:"fullname"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
// Username returns the normalized username in lowercase and without whitespace padding.
func (f UserCreate) Username() string {
return clean.Username(f.UserName)
// UsernameClean returns the username in lowercase and with whitespace trimmed.
func (f UserCreate) UsernameClean() string {
return clean.Login(f.Username)
}

View File

@@ -31,7 +31,7 @@ var DialectMySQL = Migrations{
{
ID: "20220329-060000",
Dialect: "mysql",
Statements: []string{"ALTER TABLE accounts MODIFY acc_url VARCHAR(255);", "ALTER TABLE addresses MODIFY address_notes VARCHAR(1024);", "ALTER TABLE albums MODIFY album_caption VARCHAR(1024);", "ALTER TABLE albums MODIFY album_description VARCHAR(2048);", "ALTER TABLE albums MODIFY album_notes VARCHAR(1024);", "ALTER TABLE cameras MODIFY camera_description VARCHAR(2048);", "ALTER TABLE cameras MODIFY camera_notes VARCHAR(1024);", "ALTER TABLE countries MODIFY country_description VARCHAR(2048);", "ALTER TABLE countries MODIFY country_notes VARCHAR(1024);", "ALTER TABLE details MODIFY keywords VARCHAR(2048);", "ALTER TABLE details MODIFY notes VARCHAR(2048);", "ALTER TABLE details MODIFY subject VARCHAR(1024);", "ALTER TABLE details MODIFY artist VARCHAR(1024);", "ALTER TABLE details MODIFY copyright VARCHAR(1024);", "ALTER TABLE details MODIFY license VARCHAR(1024);", "ALTER TABLE folders MODIFY folder_description VARCHAR(2048);", "ALTER TABLE labels MODIFY label_description VARCHAR(2048);", "ALTER TABLE labels MODIFY label_notes VARCHAR(1024);", "ALTER TABLE lenses MODIFY lens_description VARCHAR(2048);", "ALTER TABLE lenses MODIFY lens_notes VARCHAR(1024);", "ALTER TABLE subjects MODIFY subj_bio VARCHAR(2048);", "ALTER TABLE subjects MODIFY subj_notes VARCHAR(1024);"},
Statements: []string{"ALTER TABLE accounts MODIFY acc_url VARCHAR(255);", "ALTER TABLE albums MODIFY album_caption VARCHAR(1024);", "ALTER TABLE albums MODIFY album_description VARCHAR(2048);", "ALTER TABLE albums MODIFY album_notes VARCHAR(1024);", "ALTER TABLE cameras MODIFY camera_description VARCHAR(2048);", "ALTER TABLE cameras MODIFY camera_notes VARCHAR(1024);", "ALTER TABLE countries MODIFY country_description VARCHAR(2048);", "ALTER TABLE countries MODIFY country_notes VARCHAR(1024);", "ALTER TABLE details MODIFY keywords VARCHAR(2048);", "ALTER TABLE details MODIFY notes VARCHAR(2048);", "ALTER TABLE details MODIFY subject VARCHAR(1024);", "ALTER TABLE details MODIFY artist VARCHAR(1024);", "ALTER TABLE details MODIFY copyright VARCHAR(1024);", "ALTER TABLE details MODIFY license VARCHAR(1024);", "ALTER TABLE folders MODIFY folder_description VARCHAR(2048);", "ALTER TABLE labels MODIFY label_description VARCHAR(2048);", "ALTER TABLE labels MODIFY label_notes VARCHAR(1024);", "ALTER TABLE lenses MODIFY lens_description VARCHAR(2048);", "ALTER TABLE lenses MODIFY lens_notes VARCHAR(1024);", "ALTER TABLE subjects MODIFY subj_bio VARCHAR(2048);", "ALTER TABLE subjects MODIFY subj_notes VARCHAR(1024);"},
},
{
ID: "20220329-061000",
@@ -98,4 +98,9 @@ var DialectMySQL = Migrations{
Dialect: "mysql",
Statements: []string{"ALTER TABLE files MODIFY file_chroma SMALLINT DEFAULT -1;"},
},
{
ID: "20220901-000100",
Dialect: "mysql",
Statements: []string{"INSERT IGNORE INTO auth_users (id, user_uid, super_admin, user_role, display_name, user_slug, username, email, login_attempts, login_at, created_at, updated_at) SELECT id, user_uid, role_admin, 'admin', full_name, user_name, user_name, primary_email, login_attempts, login_at, created_at, updated_at FROM users WHERE user_name <> '' AND user_name IS NOT NULL AND role_admin = 1 AND user_disabled = 0;"},
},
}

View File

@@ -58,4 +58,9 @@ var DialectSQLite3 = Migrations{
Dialect: "sqlite3",
Statements: []string{"CREATE INDEX IF NOT EXISTS idx_files_missing_root ON files (file_missing, file_root);"},
},
{
ID: "20220901-000100",
Dialect: "sqlite3",
Statements: []string{"INSERT OR IGNORE INTO auth_users (id, user_uid, super_admin, user_role, display_name, user_slug, username, email, login_attempts, login_at, created_at, updated_at) SELECT id, user_uid, 1, 'admin', full_name, user_name, user_name, primary_email, login_attempts, login_at, created_at, updated_at FROM users WHERE user_name <> '' AND user_name IS NOT NULL AND user_uid <> '' AND user_uid IS NOT NULL AND role_admin = 1 AND user_disabled = 0 ON CONFLICT DO NOTHING;"},
},
}

View File

@@ -110,7 +110,10 @@ func (m *Migration) Execute(db *gorm.DB) error {
log.Tracef("migrate: %s (ignored, probably didn't exist anymore)", err)
} else if strings.HasPrefix(q, "DROP TABLE ") &&
strings.Contains(e, "DROP") {
log.Tracef("migrate: %s (ignored, probably didn't exist anymored)", err)
log.Tracef("migrate: %s (ignored, probably didn't exist anymore)", err)
} else if strings.Contains(q, " IGNORE ") &&
(strings.Contains(e, "NO SUCH TABLE") || strings.Contains(e, "DOESN'T EXIST")) {
log.Tracef("migrate: %s (ignored, old table does not exist anymore)", err)
} else {
return err
}

View File

@@ -1,5 +1,4 @@
ALTER TABLE accounts MODIFY acc_url VARCHAR(255);
ALTER TABLE addresses MODIFY address_notes VARCHAR(1024);
ALTER TABLE albums MODIFY album_caption VARCHAR(1024);
ALTER TABLE albums MODIFY album_description VARCHAR(2048);
ALTER TABLE albums MODIFY album_notes VARCHAR(1024);

View File

@@ -0,0 +1 @@
INSERT IGNORE INTO auth_users (id, user_uid, super_admin, user_role, display_name, user_slug, username, email, login_attempts, login_at, created_at, updated_at) SELECT id, user_uid, role_admin, 'admin', full_name, user_name, user_name, primary_email, login_attempts, login_at, created_at, updated_at FROM users WHERE user_name <> '' AND user_name IS NOT NULL AND role_admin = 1 AND user_disabled = 0;

View File

@@ -0,0 +1 @@
INSERT OR IGNORE INTO auth_users (id, user_uid, super_admin, user_role, display_name, user_slug, username, email, login_attempts, login_at, created_at, updated_at) SELECT id, user_uid, 1, 'admin', full_name, user_name, user_name, primary_email, login_attempts, login_at, created_at, updated_at FROM users WHERE user_name <> '' AND user_name IS NOT NULL AND user_uid <> '' AND user_uid IS NOT NULL AND role_admin = 1 AND user_disabled = 0 ON CONFLICT DO NOTHING;

View File

@@ -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.Username(), user.FullName)
t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.UserName(), user.DisplayName)
}
t.Logf("user count: %v", len(users))

View File

@@ -57,7 +57,7 @@ func BasicAuth() gin.HandlerFunc {
return
}
user := entity.FindUserByName(username)
user := entity.FindUserByLogin(username)
if user != nil {
invalid = user.InvalidPassword(password)

View File

@@ -44,7 +44,7 @@ func (s Data) Valid() bool {
}
func (s Data) Guest() bool {
return s.User.Guest()
return s.User.IsGuest()
}
func (s Data) NoShares() bool {