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() { getEmail() {
if (this.isUser()) { if (this.isUser()) {
return this.user.PrimaryEmail; return this.user.Email;
} }
return ""; return "";
} }
getNickName() { getUsername() {
if (this.isUser()) { if (this.isUser()) {
return this.user.NickName; if (this.user.Username) {
return this.user.Username;
}
} }
return ""; return "";
} }
getFullName() { getDisplayName() {
if (this.isUser()) { 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 ""; return "";
@@ -158,7 +171,7 @@ export default class Session {
} }
isAdmin() { isAdmin() {
return this.user && this.user.hasId() && this.user.RoleAdmin; return this.user && this.user.hasId() && (this.user.Role === "admin" || this.user.SuperAdmin);
} }
isAnonymous() { isAnonymous() {

View File

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

View File

@@ -32,56 +32,72 @@ export class User extends RestModel {
getDefaults() { getDefaults() {
return { return {
UID: "", UID: "",
Address: {}, Slug: "",
MotherUID: "", Username: "",
FatherUID: "", Email: "",
GlobalUID: "", 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: "", FullName: "",
NickName: "", Alias: "",
MaidenName: "",
ArtistName: "", ArtistName: "",
UserName: "", Artist: false,
UserStatus: "", Favorite: false,
UserDisabled: false, Hidden: false,
UserSettings: "", Private: false,
PrimaryEmail: "", Excluded: false,
EmailConfirmed: false,
BackupEmail: "",
PersonURL: "",
PersonPhone: "",
PersonStatus: "",
PersonAvatar: "",
PersonLocation: "",
PersonBio: "",
BusinessURL: "",
BusinessPhone: "",
BusinessEmail: "",
CompanyName: "", CompanyName: "",
DepartmentName: "", DepartmentName: "",
JobTitle: "", JobTitle: "",
BusinessURL: "",
BusinessPhone: "",
BusinessEmail: "",
BackupEmail: "",
BirthYear: -1, BirthYear: -1,
BirthMonth: -1, BirthMonth: -1,
BirthDay: -1, BirthDay: -1,
TermsAccepted: false, FileRoot: "",
IsArtist: false, FilePath: "",
IsSubject: false,
RoleAdmin: false,
RoleGuest: false,
RoleChild: false,
RoleFamily: false,
RoleFriend: false,
WebDAV: false,
StoragePath: "",
CanInvite: false,
InviteToken: "", InviteToken: "",
InvitedBy: "", InvitedBy: "",
DownloadToken: "",
PreviewToken: "",
ConfirmedAt: "",
TermsAccepted: "",
CreatedAt: "", CreatedAt: "",
UpdatedAt: "", UpdatedAt: "",
DeletedAt: "",
}; };
} }
getEntityName() { getEntityName() {
return this.FullName ? this.FullName : this.UserName; if (this.DisplayName) {
return this.DisplayName;
}
if (this.FullName) {
return this.FullName;
}
return this.Name;
} }
getRegisterForm() { getRegisterForm() {

View File

@@ -1,5 +1,5 @@
<template> <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-layout align-center justify-center>
<v-flex xs12 sm8 md4 xl3 xxl2> <v-flex xs12 sm8 md4 xl3 xxl2>
<v-form ref="form" dense class="auth-login-form" accept-charset="UTF-8" @submit.prevent="login"> <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 = { const values = {
user: { user: {
ID: 5, ID: 5,
NickName: "Foo", DisplayName: "Foo",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values); session.setData(values);
@@ -72,10 +72,10 @@ describe("common/session", () => {
assert.equal(result, "test@test.com"); assert.equal(result, "test@test.com");
const values2 = { const values2 = {
user: { user: {
NickName: "Foo", DisplayName: "Foo",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values2); session.setData(values2);
@@ -90,25 +90,25 @@ describe("common/session", () => {
const values = { const values = {
user: { user: {
ID: 5, ID: 5,
NickName: "Foo", DisplayName: "Foo",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values); session.setData(values);
const result = session.getNickName(); const result = session.getDisplayName();
assert.equal(result, "Foo"); assert.equal(result, "Foo");
const values2 = { const values2 = {
user: { user: {
NickName: "Bar", DisplayName: "Bar",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values2); session.setData(values2);
const result2 = session.getNickName(); const result2 = session.getDisplayName();
assert.equal(result2, ""); assert.equal(result2, "");
session.deleteData(); session.deleteData();
}); });
@@ -119,25 +119,25 @@ describe("common/session", () => {
const values = { const values = {
user: { user: {
ID: 5, ID: 5,
NickName: "Foo", DisplayName: "Foo",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values); session.setData(values);
const result = session.getFullName(); const result = session.getDisplayName();
assert.equal(result, "Max Last"); assert.equal(result, "Foo");
const values2 = { const values2 = {
user: { user: {
NickName: "Bar", DisplayName: "Bar",
FullName: "Max New", FullName: "Max New",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values2); session.setData(values2);
const result2 = session.getFullName(); const result2 = session.getDisplayName();
assert.equal(result2, ""); assert.equal(result2, "");
session.deleteData(); session.deleteData();
}); });
@@ -148,10 +148,10 @@ describe("common/session", () => {
const values = { const values = {
user: { user: {
ID: 5, ID: 5,
NickName: "Foo", DisplayName: "Foo",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values); session.setData(values);
@@ -166,10 +166,10 @@ describe("common/session", () => {
const values = { const values = {
user: { user: {
ID: 5, ID: 5,
NickName: "Foo", DisplayName: "Foo",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values); session.setData(values);
@@ -184,10 +184,10 @@ describe("common/session", () => {
const values = { const values = {
user: { user: {
ID: 5, ID: 5,
NickName: "Foo", DisplayName: "Foo",
FullName: "Max Last", FullName: "Max Last",
PrimaryEmail: "test@test.com", Email: "test@test.com",
RoleAdmin: true, Role: "admin",
}, },
}; };
session.setData(values); session.setData(values);

View File

@@ -6,14 +6,14 @@ let assert = chai.assert;
describe("model/user", () => { describe("model/user", () => {
it("should get entity name", () => { 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 user = new User(values);
const result = user.getEntityName(); const result = user.getEntityName();
assert.equal(result, "Max Last"); assert.equal(result, "Max Last");
}); });
it("should get id", () => { 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 user = new User(values);
const result = user.getId(); const result = user.getId();
assert.equal(result, 5); assert.equal(result, 5);
@@ -30,28 +30,28 @@ describe("model/user", () => {
}); });
it("should get register form", async () => { 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 user = new User(values);
const result = await user.getRegisterForm(); const result = await user.getRegisterForm();
assert.equal(result.definition.foo, "register"); assert.equal(result.definition.foo, "register");
}); });
it("should get profile form", async () => { 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 user = new User(values);
const result = await user.getProfileForm(); const result = await user.getProfileForm();
assert.equal(result.definition.foo, "profile"); assert.equal(result.definition.foo, "profile");
}); });
it("should get change password", async () => { 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 user = new User(values);
const result = await user.changePassword("old", "new"); const result = await user.changePassword("old", "new");
assert.equal(result.new_password, "new"); assert.equal(result.new_password, "new");
}); });
it("should save profile", async () => { 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); const user = new User(values);
assert.equal(user.FullName, "Max Last"); assert.equal(user.FullName, "Max Last");
await user.saveProfile(); await user.saveProfile();

View File

@@ -8,12 +8,12 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/photoprism/photoprism/internal/form"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/sirupsen/logrus" "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. // 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) { func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, username string, password string) (sessId string) {
CreateSession(router) CreateSession(router)
f := form.Login{ f := form.Login{
UserName: username, Username: username,
Password: password, Password: password,
} }
loginStr, err := json.Marshal(f) loginStr, err := json.Marshal(f)

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "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 var clientConfig config.ClientConfig
if sess.User.Guest() { if sess.User.IsGuest() {
clientConfig = conf.GuestConfig() clientConfig = conf.GuestConfig()
} else if sess.User.Registered() { } else if sess.User.IsRegistered() {
clientConfig = conf.UserConfig() clientConfig = conf.UserConfig()
} else { } else {
clientConfig = conf.PublicConfig() clientConfig = conf.PublicConfig()
@@ -150,7 +151,7 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
wsAuth.mutex.RUnlock() wsAuth.mutex.RUnlock()
if user.Registered() { if user.IsRegistered() {
writeMutex.Lock() writeMutex.Lock()
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil { 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" "time"
"github.com/leandro-lugaresi/hub" "github.com/leandro-lugaresi/hub"
"github.com/photoprism/photoprism/internal/event"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/capture" "github.com/photoprism/photoprism/pkg/capture"
) )
@@ -20,13 +19,14 @@ func TestIndexCommand(t *testing.T) {
s := event.Subscribe("log.info") s := event.Subscribe("log.info")
defer event.Unsubscribe(s) defer event.Unsubscribe(s)
logs := ""
var l string
assert.IsType(t, hub.Subscription{}, s) assert.IsType(t, hub.Subscription{}, s)
go func() { go func() {
for msg := range s.Receiver { 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 != "" { if stdout != "" {
t.Errorf("unexpected stdout output: %s", stdout) t.Logf("stdout: %s", stdout)
} }
time.Sleep(time.Second) time.Sleep(time.Second)
if output := logs; output != "" { if l != "" {
// Expected index command output. // Expected index command output.
assert.Contains(t, output, "indexing originals") assert.Contains(t, l, "classify: loading labels")
assert.Contains(t, output, "indexed") assert.NotContains(t, l, "error")
assert.Contains(t, output, "files") assert.Contains(t, l, "found no .ppignore file")
} else { } else {
t.Fatal("log output missing") t.Fatal("log output missing")
} }

View File

@@ -39,7 +39,7 @@ func passwdAction(ctx *cli.Context) error {
user := entity.Admin 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: ") newPassword := getPassword("New Password: ")
@@ -57,7 +57,7 @@ func passwdAction(ctx *cli.Context) error {
return err 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() conf.Shutdown()

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "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. // 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. // resetIndexDb resets the index database schema.
func resetIndexDb(conf *config.Config) { func resetIndexDb(c *config.Config) {
start := time.Now() start := time.Now()
tables := entity.Entities tables := entity.Entities
log.Infoln("dropping existing tables") log.Infoln("dropping existing tables")
tables.Drop(conf.Db()) tables.Drop(c.Db())
log.Infoln("restoring default schema") log.Infoln("restoring default schema")
entity.InitDb(true, false, nil) entity.InitDb(true, false, nil)
if conf.AdminPassword() != "" { // Reset admin account?
log.Infoln("restoring initial admin password") if c.AdminPassword() == "" {
entity.Admin.InitPassword(conf.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)) log.Infof("database reset completed in %s", time.Since(start))
} }
// resetCache removes all cache files and folders. // resetCache removes all cache files and folders.
func resetCache(conf *config.Config) { func resetCache(c *config.Config) {
start := time.Now() start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.CachePath()) + "/**") matches, err := filepath.Glob(regexp.QuoteMeta(c.CachePath()) + "/**")
if err != nil { if err != nil {
log.Errorf("reset: %s (find cache files)", err) log.Errorf("reset: %s (find cache files)", err)
@@ -216,10 +219,10 @@ func resetCache(conf *config.Config) {
} }
// resetSidecarJson removes generated *.json sidecar files. // resetSidecarJson removes generated *.json sidecar files.
func resetSidecarJson(conf *config.Config) { func resetSidecarJson(c *config.Config) {
start := time.Now() start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.json") matches, err := filepath.Glob(regexp.QuoteMeta(c.SidecarPath()) + "/**/*.json")
if err != nil { if err != nil {
log.Errorf("reset: %s (find *.json sidecar files)", err) 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)) log.Infof("removing %d *.json sidecar files", len(matches))
for _, name := range matches { for _, name := range matches {
if err := os.Remove(name); err != nil { if err = os.Remove(name); err != nil {
fmt.Print("E") fmt.Print("E")
} else { } else {
fmt.Print(".") fmt.Print(".")
@@ -246,10 +249,10 @@ func resetSidecarJson(conf *config.Config) {
} }
// resetSidecarYaml removes generated *.yml files. // resetSidecarYaml removes generated *.yml files.
func resetSidecarYaml(conf *config.Config) { func resetSidecarYaml(c *config.Config) {
start := time.Now() start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.yml") matches, err := filepath.Glob(regexp.QuoteMeta(c.SidecarPath()) + "/**/*.yml")
if err != nil { if err != nil {
log.Errorf("reset: %s (find *.yml metadata files)", err) 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/form"
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean" "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. // UsersCommand registers user management subcommands.
var UsersCommand = cli.Command{ var UsersCommand = cli.Command{
Name: "users", Name: "users",
Usage: "User management subcommands", Usage: "User management subcommands",
Subcommands: []cli.Command{ Subcommands: []cli.Command{
{ {
Name: "list", Name: "ls",
Usage: "Lists registered users", Aliases: []string{"list"},
Action: usersListAction, Usage: "Shows registered users",
Flags: report.CliFlags,
Action: usersListAction,
}, },
{ {
Name: "add", Name: "add",
Usage: "Adds a new user", Aliases: []string{"create"},
Action: usersAddAction, Usage: "Adds a new user account",
Action: usersAddAction,
Flags: []cli.Flag{ Flags: []cli.Flag{
cli.StringFlag{
Name: "fullname, n",
Usage: "full name of the new user",
},
cli.StringFlag{ cli.StringFlag{
Name: "username, u", Name: "username, u",
Usage: "unique username", Usage: UsernameUsage,
},
cli.StringFlag{
Name: "password, p",
Usage: "sets the users password",
}, },
cli.StringFlag{ cli.StringFlag{
Name: "email, m", Name: "email, m",
Usage: "sets the users email", Usage: EmailUsage,
},
cli.StringFlag{
Name: "password, p",
Usage: PasswordUsage,
}, },
}, },
}, },
{ {
Name: "update", Name: "mod",
Usage: "Updates user information", Aliases: []string{"update"},
Action: usersUpdateAction, Usage: "Updates a user account",
Action: usersUpdateAction,
Flags: []cli.Flag{ Flags: []cli.Flag{
cli.StringFlag{ cli.StringFlag{
Name: "fullname, n", Name: "username, u",
Usage: "full name of the new user", Usage: UsernameUsage,
},
cli.StringFlag{
Name: "password, p",
Usage: "sets the users password",
}, },
cli.StringFlag{ cli.StringFlag{
Name: "email, m", Name: "email, m",
Usage: "sets the users email", Usage: EmailUsage,
},
cli.StringFlag{
Name: "password, p",
Usage: PasswordUsage,
}, },
}, },
}, },
{ {
Name: "delete", Name: "rm",
Usage: "Removes an existing user", Aliases: []string{"delete"},
Usage: "Removes a user account",
Action: usersDeleteAction, Action: usersDeleteAction,
ArgsUsage: "[username]", ArgsUsage: "[username]",
}, },
@@ -82,49 +88,43 @@ func usersAddAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) error { return callWithDependencies(ctx, func(conf *config.Config) error {
uc := form.UserCreate{ uc := form.UserCreate{
UserName: strings.TrimSpace(ctx.String("username")), Username: strings.TrimSpace(ctx.String("username")),
FullName: strings.TrimSpace(ctx.String("fullname")),
Email: strings.TrimSpace(ctx.String("email")), Email: strings.TrimSpace(ctx.String("email")),
Password: strings.TrimSpace(ctx.String("password")), Password: strings.TrimSpace(ctx.String("password")),
} }
interactive := true interactive := true
if uc.UserName != "" && uc.Password != "" { if uc.Username != "" && uc.Password != "" {
log.Debugf("creating user in non-interactive mode") log.Debugf("creating user in non-interactive mode")
interactive = false interactive = false
} }
if interactive && uc.FullName == "" { if interactive && uc.Username == "" {
prompt := promptui.Prompt{
Label: "Full Name",
}
res, err := prompt.Run()
if err != nil {
return err
}
uc.FullName = strings.TrimSpace(res)
}
if interactive && uc.UserName == "" {
prompt := promptui.Prompt{ prompt := promptui.Prompt{
Label: "Username", Label: "Username",
} }
res, err := prompt.Run() res, err := prompt.Run()
if err != nil { if err != nil {
return err return err
} }
uc.UserName = strings.TrimSpace(res)
uc.Username = strings.TrimSpace(res)
} }
if interactive && uc.Email == "" { if interactive && uc.Email == "" {
prompt := promptui.Prompt{ prompt := promptui.Prompt{
Label: "E-Mail", Label: "Email",
} }
res, err := prompt.Run() res, err := prompt.Run()
if err != nil { if err != nil {
return err return err
} }
uc.Email = strings.TrimSpace(res) uc.Email = strings.TrimSpace(res)
} }
@@ -176,24 +176,24 @@ func usersAddAction(ctx *cli.Context) error {
func usersDeleteAction(ctx *cli.Context) error { func usersDeleteAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) 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") return errors.New("please provide a username")
} }
actionPrompt := promptui.Prompt{ actionPrompt := promptui.Prompt{
Label: fmt.Sprintf("Delete %s?", clean.Log(userName)), Label: fmt.Sprintf("Delete %s?", clean.Log(login)),
IsConfirm: true, IsConfirm: true,
} }
if _, err := actionPrompt.Run(); err == nil { if _, err := actionPrompt.Run(); err == nil {
if m := entity.FindUserByName(userName); m == nil { if m := entity.FindUserByLogin(login); m == nil {
return errors.New("user not found") return errors.New("login name not found")
} else if err := m.Delete(); err != nil { } else if err := m.Delete(); err != nil {
return err return err
} else { } else {
log.Infof("%s deleted", clean.Log(userName)) log.Infof("%s deleted", clean.LogQuote(login))
} }
} else { } else {
log.Infof("keeping user") log.Infof("keeping user")
@@ -205,52 +205,54 @@ func usersDeleteAction(ctx *cli.Context) error {
func usersListAction(ctx *cli.Context) error { func usersListAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) error { return callWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"UID", "Role", "Username", "Email", "Display Name"}
users := query.RegisteredUsers() users := query.RegisteredUsers()
rows := make([][]string, len(users))
log.Infof("found %s", english.Plural(len(users), "user", "users")) log.Infof("found %s", english.Plural(len(users), "user", "users"))
fmt.Printf("%-4s %-16s %-16s %-16s\n", "ID", "LOGIN", "NAME", "EMAIL") for i, user := range users {
rows[i] = []string{user.UserUID, user.AclRole().String(), user.UserName(), user.UserEmail(), user.RealName()}
for _, user := range users {
fmt.Printf("%-4d %-16s %-16s %-16s", user.ID, user.Username(), user.FullName, user.PrimaryEmail)
fmt.Printf("\n")
} }
return nil result, err := report.Render(rows, cols, report.CliFormat(ctx))
fmt.Println(result)
return err
}) })
} }
func usersUpdateAction(ctx *cli.Context) error { func usersUpdateAction(ctx *cli.Context) error {
return callWithDependencies(ctx, func(conf *config.Config) error { return callWithDependencies(ctx, func(conf *config.Config) error {
username := ctx.Args().First() login := ctx.Args().First()
if username == "" {
return errors.New("pass username as argument") if login == "" {
return errors.New("please provide the username as argument")
} }
u := entity.FindUserByName(username) u := entity.FindUserByLogin(login)
if u == nil { if u == nil {
return errors.New("user not found") return errors.New("username not found")
} }
uc := form.UserCreate{ uc := form.UserCreate{
FullName: strings.TrimSpace(ctx.String("fullname")), Username: strings.TrimSpace(ctx.String("username")),
Email: strings.TrimSpace(ctx.String("email")), Email: strings.TrimSpace(ctx.String("email")),
Password: strings.TrimSpace(ctx.String("password")), Password: strings.TrimSpace(ctx.String("password")),
} }
if ctx.IsSet("email") && len(uc.Email) > 0 {
u.Email = uc.Email
}
if ctx.IsSet("password") { if ctx.IsSet("password") {
err := u.SetPassword(uc.Password) err := u.SetPassword(uc.Password)
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("password successfully changed: %s\n", clean.Log(u.Username())) 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
} }
if err := u.Validate(); err != nil { if err := u.Validate(); err != nil {
@@ -261,7 +263,7 @@ func usersUpdateAction(ctx *cli.Context) error {
return err 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 return nil
}) })

View File

@@ -112,10 +112,12 @@ func NewConfig(ctx *cli.Context) *Config {
// Initialize package extensions. // Initialize package extensions.
for _, ext := range Extensions() { for _, ext := range Extensions() {
start := time.Now()
if err := ext.init(c); err != nil { 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 { } 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 ( import (
"regexp" "regexp"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -20,9 +21,20 @@ func isBcrypt(s string) bool {
return b 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. // AdminPassword returns the initial admin password.
func (c *Config) AdminPassword() string { 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. // 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. // StaticUri is the relative path for serving static content.
const StaticUri = "/static" const StaticUri = "/static"
// CustomStaticUri is the relative path for serving custom static content.
const CustomStaticUri = "/c/static"
// MsgSponsor and MsgSignUp provide sponsorship info messages; // MsgSponsor and MsgSignUp provide sponsorship info messages;
// SignUpURL a signup link. // SignUpURL a signup link.
const MsgSponsor = "PhotoPrism® needs your support!" const MsgSponsor = "PhotoPrism® needs your support!"

View File

@@ -108,8 +108,10 @@ func (c *Config) WallpaperUri() string {
// Valid URI? Local file? // Valid URI? Local file?
if p := clean.Path(c.options.WallpaperUri); p == "" { if p := clean.Path(c.options.WallpaperUri); p == "" {
c.options.WallpaperUri = "" 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) 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 { } else {
c.options.WallpaperUri = "" c.options.WallpaperUri = ""
} }

View File

@@ -11,6 +11,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql" _ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "github.com/jinzhu/gorm/dialects/sqlite"
@@ -270,7 +272,12 @@ func (c *Config) MigrateDb(runFailed bool, ids []string) {
entity.SetDbProvider(c) entity.SetDbProvider(c)
entity.InitDb(true, runFailed, ids) 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() go entity.SaveErrorMessages()
} }
@@ -281,7 +288,11 @@ func (c *Config) InitTestDb() {
entity.SetDbProvider(c) entity.SetDbProvider(c)
entity.ResetTestFixtures() 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() go entity.SaveErrorMessages()
} }

View File

@@ -424,6 +424,35 @@ func (c *Config) CustomAssetsPath() string {
return "" 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. // LocalesPath returns the translation locales path.
func (c *Config) LocalesPath() string { func (c *Config) LocalesPath() string {
return filepath.Join(c.AssetsPath(), "locales") return filepath.Join(c.AssetsPath(), "locales")

View File

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

View File

@@ -211,6 +211,15 @@ func TestConfig_DetectNSFW(t *testing.T) {
assert.Equal(t, true, result) 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) { func TestConfig_AdminPassword(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())

View File

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

View File

@@ -18,6 +18,13 @@ var Flags = CliFlags{
Value: "password", Value: "password",
EnvVar: "PHOTOPRISM_AUTH_MODE", EnvVar: "PHOTOPRISM_AUTH_MODE",
}}, }},
CliFlag{
Flag: cli.StringFlag{
Name: "admin-user, login",
Usage: "admin login `USERNAME`",
EnvVar: "PHOTOPRISM_ADMIN_USER",
Value: "admin",
}},
CliFlag{ CliFlag{
Flag: cli.StringFlag{ Flag: cli.StringFlag{
Name: "admin-password, pw", 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) { func TestUserMap_Get(t *testing.T) {
t.Run("get existing user", func(t *testing.T) { t.Run("get existing user", func(t *testing.T) {
r := UserFixtures.Get("alice") r := UserFixtures.Get("alice")
assert.Equal(t, "alice", r.UserName) assert.Equal(t, "alice", r.Username)
assert.Equal(t, "alice", r.Username()) assert.Equal(t, "alice", r.UserName())
assert.IsType(t, User{}, r) assert.IsType(t, User{}, r)
}) })
t.Run("get not existing user", func(t *testing.T) { t.Run("get not existing user", func(t *testing.T) {
r := UserFixtures.Get("monstera") r := UserFixtures.Get("monstera")
assert.Equal(t, "", r.UserName) assert.Equal(t, "", r.Username)
assert.Equal(t, "", r.Username()) assert.Equal(t, "", r.UserName())
assert.IsType(t, User{}, r) assert.IsType(t, User{}, r)
}) })
} }
@@ -24,12 +24,12 @@ func TestUserMap_Get(t *testing.T) {
func TestUserMap_Pointer(t *testing.T) { func TestUserMap_Pointer(t *testing.T) {
t.Run("get existing user", func(t *testing.T) { t.Run("get existing user", func(t *testing.T) {
r := UserFixtures.Pointer("alice") r := UserFixtures.Pointer("alice")
assert.Equal(t, "alice", r.UserName) assert.Equal(t, "alice", r.Username)
assert.IsType(t, &User{}, r) assert.IsType(t, &User{}, r)
}) })
t.Run("get not existing user", func(t *testing.T) { t.Run("get not existing user", func(t *testing.T) {
r := UserFixtures.Pointer("monstera") r := UserFixtures.Pointer("monstera")
assert.Equal(t, "", r.UserName) assert.Equal(t, "", r.Username)
assert.IsType(t, &User{}, r) assert.IsType(t, &User{}, r)
}) })
} }

View File

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

View File

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

View File

@@ -69,3 +69,11 @@ const (
SortOrderCategory = "category" SortOrderCategory = "category"
SortOrderSimilar = "similar" 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. // CreateDefaultFixtures inserts default fixtures for test and production.
func CreateDefaultFixtures() { func CreateDefaultFixtures() {
CreateUnknownAddress()
CreateDefaultUsers() CreateDefaultUsers()
CreateUnknownPlace() CreateUnknownPlace()
CreateUnknownLocation() CreateUnknownLocation()

View File

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

View File

@@ -16,6 +16,11 @@ type Keyword struct {
Skip bool Skip bool
} }
// TableName returns the entity database table name.
func (Keyword) TableName() string {
return "keywords"
}
// NewKeyword registers a new keyword in database // NewKeyword registers a new keyword in database
func NewKeyword(keyword string) *Keyword { func NewKeyword(keyword string) *Keyword {
keyword = strings.ToLower(txt.Clip(keyword, txt.ClipKeyword)) 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"` 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. // BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Link) BeforeCreate(scope *gorm.Scope) error { func (m *Link) BeforeCreate(scope *gorm.Scope) error {
if rnd.ValidID(m.LinkUID, 's') { if rnd.ValidID(m.LinkUID, 's') {

View File

@@ -14,6 +14,11 @@ type Password struct {
UpdatedAt time.Time `deepcopier:"skip" json:"UpdatedAt"` 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. // NewPassword creates a new password instance.
func NewPassword(uid, password string) Password { func NewPassword(uid, password string) Password {
if uid == "" { 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 package form
type Login struct { type Login struct {
Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
UserName string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Token string `json:"token"` Token string `json:"token"`
} }
@@ -11,8 +11,8 @@ func (f Login) HasToken() bool {
return f.Token != "" return f.Token != ""
} }
func (f Login) HasUserName() bool { func (f Login) HasUsername() bool {
return f.UserName != "" && len(f.UserName) <= 255 return f.Username != "" && len(f.Username) <= 255
} }
func (f Login) HasPassword() bool { func (f Login) HasPassword() bool {
@@ -20,5 +20,5 @@ func (f Login) HasPassword() bool {
} }
func (f Login) HasCredentials() 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) { func TestLogin_HasToken(t *testing.T) {
t.Run("false", func(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()) assert.Equal(t, false, form.HasToken())
}) })
t.Run("true", func(t *testing.T) { 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()) 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) { t.Run("false", func(t *testing.T) {
form := &Login{Email: "test@test.com", Password: "passwd", Token: ""} 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) { 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.HasUserName()) assert.Equal(t, true, form.HasUsername())
}) })
} }
@@ -34,7 +34,7 @@ func TestLogin_HasPassword(t *testing.T) {
assert.Equal(t, false, form.HasPassword()) assert.Equal(t, false, form.HasPassword())
}) })
t.Run("true", func(t *testing.T) { 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()) assert.Equal(t, true, form.HasPassword())
}) })
} }
@@ -45,7 +45,7 @@ func TestLogin_HasCredentials(t *testing.T) {
assert.Equal(t, false, form.HasCredentials()) assert.Equal(t, false, form.HasCredentials())
}) })
t.Run("true", func(t *testing.T) { 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()) 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. // UserCreate represents a User with a new password.
type UserCreate struct { type UserCreate struct {
UserName string `json:"username"` Username string `json:"username"`
FullName string `json:"fullname"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
} }
// Username returns the normalized username in lowercase and without whitespace padding. // UsernameClean returns the username in lowercase and with whitespace trimmed.
func (f UserCreate) Username() string { func (f UserCreate) UsernameClean() string {
return clean.Username(f.UserName) return clean.Login(f.Username)
} }

View File

@@ -31,7 +31,7 @@ var DialectMySQL = Migrations{
{ {
ID: "20220329-060000", ID: "20220329-060000",
Dialect: "mysql", 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", ID: "20220329-061000",
@@ -98,4 +98,9 @@ var DialectMySQL = Migrations{
Dialect: "mysql", Dialect: "mysql",
Statements: []string{"ALTER TABLE files MODIFY file_chroma SMALLINT DEFAULT -1;"}, 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", Dialect: "sqlite3",
Statements: []string{"CREATE INDEX IF NOT EXISTS idx_files_missing_root ON files (file_missing, file_root);"}, 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) log.Tracef("migrate: %s (ignored, probably didn't exist anymore)", err)
} else if strings.HasPrefix(q, "DROP TABLE ") && } else if strings.HasPrefix(q, "DROP TABLE ") &&
strings.Contains(e, "DROP") { 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 { } else {
return err return err
} }

View File

@@ -1,5 +1,4 @@
ALTER TABLE accounts MODIFY acc_url VARCHAR(255); 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_caption VARCHAR(1024);
ALTER TABLE albums MODIFY album_description VARCHAR(2048); ALTER TABLE albums MODIFY album_description VARCHAR(2048);
ALTER TABLE albums MODIFY album_notes VARCHAR(1024); 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() users := RegisteredUsers()
for _, user := range users { 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)) t.Logf("user count: %v", len(users))

View File

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

View File

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