From bac6ae0cbdc155219ab9ff1eb67c7d694ffb6c19 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Mon, 3 Oct 2022 22:59:29 +0200 Subject: [PATCH] Sessions: Add max age and timeout config options #98 #782 Signed-off-by: Michael Mayer --- internal/api/accounts.go | 4 +- internal/commands/convert.go | 2 +- internal/commands/copy.go | 2 +- internal/commands/faces.go | 2 +- internal/commands/import.go | 2 +- internal/commands/index.go | 2 +- internal/commands/migrations.go | 4 +- internal/commands/reset.go | 8 +- internal/commands/start.go | 3 + internal/commands/users.go | 5 +- internal/config/config_auth.go | 22 ++++ internal/config/config_auth_test.go | 18 +++ internal/config/config_const.go | 15 +++ internal/config/config_report.go | 2 + internal/config/options.go | 2 + internal/config/options_cli.go | 24 +++- internal/entity/auth_session.go | 113 +++++++++++++----- internal/entity/auth_session_cache.go | 17 ++- internal/entity/auth_session_fixtures.go | 64 +++++----- internal/entity/auth_session_report.go | 31 +++++ internal/entity/auth_session_test.go | 106 ++++++++++++++-- internal/entity/db.go | 13 +- internal/entity/{time.go => entity_time.go} | 19 ++- .../{time_test.go => entity_time_test.go} | 31 +++++ internal/mutex/activities.go | 4 +- internal/mutex/mutex_test.go | 2 +- internal/query/sessions.go | 59 ++++++--- internal/query/sessions_test.go | 97 +++++++++++++++ internal/query/users.go | 40 +++++++ internal/query/users_test.go | 53 +++++++- internal/session/monitor.go | 45 +++++++ internal/session/monitor_test.go | 11 ++ internal/session/new.go | 16 --- internal/session/session.go | 13 +- internal/session/session_new.go | 2 +- internal/session/session_public.go | 2 +- internal/session/session_save.go | 6 +- internal/session/session_test.go | 8 +- internal/workers/share.go | 21 ++-- internal/workers/sync.go | 16 +-- internal/workers/sync_download.go | 30 ++--- internal/workers/sync_refresh.go | 6 +- internal/workers/sync_upload.go | 6 +- internal/workers/workers.go | 20 ++-- internal/workers/workers_test.go | 2 + 45 files changed, 779 insertions(+), 191 deletions(-) create mode 100644 internal/entity/auth_session_report.go rename internal/entity/{time.go => entity_time.go} (62%) rename internal/entity/{time_test.go => entity_time_test.go} (75%) create mode 100644 internal/query/sessions_test.go create mode 100644 internal/session/monitor.go create mode 100644 internal/session/monitor_test.go delete mode 100644 internal/session/new.go diff --git a/internal/api/accounts.go b/internal/api/accounts.go index 492d53b4b..79aeb093f 100644 --- a/internal/api/accounts.go +++ b/internal/api/accounts.go @@ -173,7 +173,7 @@ func ShareWithAccount(router *gin.RouterGroup) { entity.FirstOrCreateFileShare(entity.NewFileShare(file.ID, m.ID, alias)) } - workers.StartShare(service.Config()) + workers.RunShare(service.Config()) c.JSON(http.StatusOK, files) }) @@ -288,7 +288,7 @@ func UpdateAccount(router *gin.RouterGroup) { } if m.AccSync { - workers.StartSync(service.Config()) + workers.RunSync(service.Config()) } c.JSON(http.StatusOK, m) diff --git a/internal/commands/convert.go b/internal/commands/convert.go index 212fcf7b7..c6e9a6073 100644 --- a/internal/commands/convert.go +++ b/internal/commands/convert.go @@ -17,7 +17,7 @@ import ( var ConvertCommand = cli.Command{ Name: "convert", Usage: "Converts files in other formats to JPEG and AVC as needed", - ArgsUsage: "[SUB-FOLDER]", + ArgsUsage: "[sub-folder]", Flags: []cli.Flag{ cli.BoolFlag{ Name: "force, f", diff --git a/internal/commands/copy.go b/internal/commands/copy.go index f2ebbc463..1478f1fbf 100644 --- a/internal/commands/copy.go +++ b/internal/commands/copy.go @@ -20,7 +20,7 @@ var CopyCommand = cli.Command{ Name: "cp", Aliases: []string{"copy"}, Usage: "Copies media files to originals", - ArgsUsage: "[IMPORT PATH]", + ArgsUsage: "[source]", Flags: []cli.Flag{ cli.BoolFlag{ Name: "dest, d", diff --git a/internal/commands/faces.go b/internal/commands/faces.go index a04030f3d..f8bed0c89 100644 --- a/internal/commands/faces.go +++ b/internal/commands/faces.go @@ -53,7 +53,7 @@ var FacesCommand = cli.Command{ { Name: "index", Usage: "Searches originals for faces", - ArgsUsage: "[SUB-FOLDER]", + ArgsUsage: "[sub-folder]", Action: facesIndexAction, }, { diff --git a/internal/commands/import.go b/internal/commands/import.go index c60d8e9e6..470ab4fe3 100644 --- a/internal/commands/import.go +++ b/internal/commands/import.go @@ -20,7 +20,7 @@ var ImportCommand = cli.Command{ Name: "mv", Aliases: []string{"import"}, Usage: "Moves media files to originals", - ArgsUsage: "[SOURCE PATH]", + ArgsUsage: "[source]", Flags: []cli.Flag{ cli.BoolFlag{ Name: "dest, d", diff --git a/internal/commands/index.go b/internal/commands/index.go index a602b7a29..00312543f 100644 --- a/internal/commands/index.go +++ b/internal/commands/index.go @@ -20,7 +20,7 @@ import ( var IndexCommand = cli.Command{ Name: "index", Usage: "Indexes original media files", - ArgsUsage: "[SUB-FOLDER]", + ArgsUsage: "[sub-folder]", Flags: indexFlags, Action: indexAction, } diff --git a/internal/commands/migrations.go b/internal/commands/migrations.go index e291fcada..498e7c08e 100644 --- a/internal/commands/migrations.go +++ b/internal/commands/migrations.go @@ -18,7 +18,7 @@ var MigrationsStatusCommand = cli.Command{ Name: "ls", Aliases: []string{"status", "show"}, Usage: "Lists the status of schema migrations", - ArgsUsage: "[MIGRATIONS...]", + ArgsUsage: "[migrations...]", Flags: report.CliFlags, Action: migrationsStatusAction, } @@ -27,7 +27,7 @@ var MigrationsRunCommand = cli.Command{ Name: "run", Aliases: []string{"execute", "migrate"}, Usage: "Executes database schema migrations", - ArgsUsage: "[MIGRATIONS...]", + ArgsUsage: "[migrations...]", Flags: []cli.Flag{ cli.BoolFlag{ Name: "failed, f", diff --git a/internal/commands/reset.go b/internal/commands/reset.go index d1d38e71c..baf5e4421 100644 --- a/internal/commands/reset.go +++ b/internal/commands/reset.go @@ -64,24 +64,24 @@ func resetAction(ctx *cli.Context) error { log.Infoln("reset: enabled trace mode") } - resetIndex := ctx.Bool("yes") + confirmed := ctx.Bool("yes") // Show prompt? - if !resetIndex { + if !confirmed { removeIndexPrompt := promptui.Prompt{ Label: "Delete and recreate index database?", IsConfirm: true, } if _, err := removeIndexPrompt.Run(); err == nil { - resetIndex = true + confirmed = true } else { log.Infof("keeping index database") } } // Reset index? - if resetIndex { + if confirmed { resetIndexDb(conf) } diff --git a/internal/commands/start.go b/internal/commands/start.go index 4fe6c31d7..86f19cc7f 100644 --- a/internal/commands/start.go +++ b/internal/commands/start.go @@ -18,6 +18,7 @@ import ( "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/server" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/internal/session" "github.com/photoprism/photoprism/internal/workers" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" @@ -119,6 +120,7 @@ func startAction(ctx *cli.Context) error { } // Start background workers. + session.Monitor(time.Hour) workers.Start(conf) auto.Start(conf) @@ -131,6 +133,7 @@ func startAction(ctx *cli.Context) error { // Stop all background activity. auto.Stop() workers.Stop() + session.Shutdown() mutex.CancelAll() log.Info("shutting down...") diff --git a/internal/commands/users.go b/internal/commands/users.go index 679a5d04b..0a8b88218 100644 --- a/internal/commands/users.go +++ b/internal/commands/users.go @@ -20,8 +20,9 @@ const ( // UsersCommand registers the user management subcommands. var UsersCommand = cli.Command{ - Name: "users", - Usage: "User management subcommands", + Name: "users", + Aliases: []string{"user"}, + Usage: "User management subcommands", Subcommands: []cli.Command{ UsersListCommand, UsersAddCommand, diff --git a/internal/config/config_auth.go b/internal/config/config_auth.go index 1adb2e873..1cadb0393 100644 --- a/internal/config/config_auth.go +++ b/internal/config/config_auth.go @@ -38,6 +38,28 @@ func (c *Config) AdminPassword() string { return clean.Password(c.options.AdminPassword) } +// SessMaxAge returns the time in seconds until browser sessions expire automatically. +func (c *Config) SessMaxAge() int64 { + if c.options.SessMaxAge < 0 { + return 0 + } else if c.options.SessMaxAge == 0 { + return DefaultSessMaxAge + } + + return c.options.SessMaxAge +} + +// SessTimeout returns the time in seconds until browser sessions expire due to inactivity +func (c *Config) SessTimeout() int64 { + if c.options.SessTimeout < 0 { + return 0 + } else if c.options.SessTimeout == 0 { + return DefaultSessTimeout + } + + return c.options.SessTimeout +} + // Public checks if app runs in public mode and requires no authentication. func (c *Config) Public() bool { return c.AuthMode() == AuthModePublic diff --git a/internal/config/config_auth_test.go b/internal/config/config_auth_test.go index bb7fd12ce..88d14c49a 100644 --- a/internal/config/config_auth_test.go +++ b/internal/config/config_auth_test.go @@ -37,6 +37,24 @@ func TestAuth(t *testing.T) { assert.False(t, c.Auth()) } +func TestSessMaxAge(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, DefaultSessMaxAge, c.SessMaxAge()) + c.options.SessMaxAge = -1 + assert.Equal(t, int64(0), c.SessMaxAge()) + c.options.SessMaxAge = 0 + assert.Equal(t, DefaultSessMaxAge, c.SessMaxAge()) +} + +func TestSessTimeout(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, DefaultSessTimeout, c.SessTimeout()) + c.options.SessTimeout = -1 + assert.Equal(t, int64(0), c.SessTimeout()) + c.options.SessTimeout = 0 + assert.Equal(t, DefaultSessTimeout, c.SessTimeout()) +} + func TestUtils_CheckPassword(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/config_const.go b/internal/config/config_const.go index 1fed4ecf9..3ebc76955 100644 --- a/internal/config/config_const.go +++ b/internal/config/config_const.go @@ -48,3 +48,18 @@ const DefaultResolutionLimit = 150 // 150 Megapixels // serialName is the name of the unique storage serial. const serialName = "serial" + +// UnixHour is one hour in UnixTime. +const UnixHour int64 = 3600 + +// UnixDay is one day in UnixTime. +const UnixDay = UnixHour * 24 + +// UnixWeek is one week in UnixTime. +const UnixWeek = UnixDay * 7 + +// DefaultSessMaxAge is the default session expiration time in seconds. +const DefaultSessMaxAge = UnixWeek * 2 + +// DefaultSessTimeout is the default session timeout time in seconds. +const DefaultSessTimeout = UnixWeek diff --git a/internal/config/config_report.go b/internal/config/config_report.go index f15cf9c2c..35e263a50 100644 --- a/internal/config/config_report.go +++ b/internal/config/config_report.go @@ -25,6 +25,8 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"admin-user", c.AdminUser()}, {"admin-password", strings.Repeat("*", utf8.RuneCountInString(c.AdminPassword()))}, {"public", fmt.Sprintf("%t", c.Public())}, + {"sess-maxage", fmt.Sprintf("%d", c.SessMaxAge())}, + {"sess-timeout", fmt.Sprintf("%d", c.SessTimeout())}, // Logging. {"log-level", c.LogLevel().String()}, diff --git a/internal/config/options.go b/internal/config/options.go index 99cf6a47c..9cefa3866 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -28,6 +28,8 @@ type Options struct { Public bool `yaml:"Public" json:"-" flag:"public"` AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"` AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"` + SessMaxAge int64 `yaml:"SessMaxAge" json:"-" flag:"sess-maxage"` + SessTimeout int64 `yaml:"SessTimeout" json:"-" flag:"sess-timeout"` LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"` Prod bool `yaml:"Prod" json:"Prod" flag:"prod"` Debug bool `yaml:"Debug" json:"Debug" flag:"debug"` diff --git a/internal/config/options_cli.go b/internal/config/options_cli.go index f3538c7fa..3c7eb4c6c 100644 --- a/internal/config/options_cli.go +++ b/internal/config/options_cli.go @@ -18,6 +18,13 @@ var Flags = CliFlags{ Value: "password", EnvVar: "PHOTOPRISM_AUTH_MODE", }}, + CliFlag{ + Flag: cli.BoolFlag{ + Name: "public, p", + Hidden: true, + Usage: "disable authentication, advanced settings, and WebDAV remote access", + EnvVar: "PHOTOPRISM_PUBLIC", + }}, CliFlag{ Flag: cli.StringFlag{ Name: "admin-user, login", @@ -32,11 +39,18 @@ var Flags = CliFlags{ EnvVar: "PHOTOPRISM_ADMIN_PASSWORD", }}, CliFlag{ - Flag: cli.BoolFlag{ - Name: "public, p", - Hidden: true, - Usage: "disable authentication, advanced settings, and WebDAV remote access", - EnvVar: "PHOTOPRISM_PUBLIC", + Flag: cli.Int64Flag{ + Name: "sess-maxage", + Value: DefaultSessMaxAge, + Usage: "time in `SECONDS` until user sessions expire automatically (-1 to disable)", + EnvVar: "PHOTOPRISM_SESS_MAXAGE", + }}, + CliFlag{ + Flag: cli.Int64Flag{ + Name: "sess-timeout", + Value: DefaultSessTimeout, + Usage: "time in `SECONDS` until user sessions expire due to inactivity (-1 to disable)", + EnvVar: "PHOTOPRISM_SESS_TIMEOUT", }}, CliFlag{ Flag: cli.StringFlag{ diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 846f65b0a..fe6add33f 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -28,31 +28,32 @@ type Sessions []Session // Session represents a User session. type Session struct { ID string `gorm:"type:VARBINARY(2048);primary_key;auto_increment:false;" json:"ID" yaml:"ID"` - Status int `json:"Status,omitempty" yaml:"-"` - AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthMethod,omitempty"` - AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthProvider,omitempty"` - AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"-" yaml:"AuthDomain,omitempty"` - AuthScope string `gorm:"size:1024;default:'';" json:"-" yaml:"AuthScope,omitempty"` - AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"-" yaml:"AuthID,omitempty"` + ClientIP string `gorm:"size:64;column:client_ip;index" json:"-" yaml:"ClientIP,omitempty"` UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID,omitempty" yaml:"UserUID,omitempty"` UserName string `gorm:"size:64;index;" json:"UserName,omitempty" yaml:"UserName,omitempty"` user *User `gorm:"-"` + AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthProvider,omitempty"` + AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthMethod,omitempty"` + AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"-" yaml:"AuthDomain,omitempty"` + AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"-" yaml:"AuthID,omitempty"` + AuthScope string `gorm:"size:1024;default:'';" json:"-" yaml:"AuthScope,omitempty"` + LastActive int64 `json:"LastActive,omitempty" yaml:"LastActive,omitempty"` + SessExpires int64 `gorm:"index" json:"Expires,omitempty" yaml:"Expires,omitempty"` + SessTimeout int64 `json:"Timeout,omitempty" yaml:"Timeout,omitempty"` PreviewToken string `gorm:"type:VARBINARY(64);column:preview_token;default:'';" json:"-" yaml:"-"` DownloadToken string `gorm:"type:VARBINARY(64);column:download_token;default:'';" json:"-" yaml:"-"` AccessToken string `gorm:"type:VARBINARY(4096);column:access_token;default:'';" json:"-" yaml:"-"` RefreshToken string `gorm:"type:VARBINARY(512);column:refresh_token;default:'';" json:"-" yaml:"-"` IdToken string `gorm:"type:VARBINARY(1024);column:id_token;default:'';" json:"IdToken,omitempty" yaml:"IdToken,omitempty"` UserAgent string `gorm:"size:512;" json:"-" yaml:"UserAgent,omitempty"` - ClientIP string `gorm:"size:64;column:client_ip;" json:"-" yaml:"ClientIP,omitempty"` - LoginIP string `gorm:"size:64;column:login_ip" json:"-" yaml:"-"` - LoginAt time.Time `json:"-" yaml:"-"` DataJSON json.RawMessage `gorm:"type:VARBINARY(4096);" json:"Data,omitempty" yaml:"Data,omitempty"` data *SessionData `gorm:"-"` RefID string `gorm:"type:VARBINARY(16);default:'';" json:"-" yaml:"-"` + LoginIP string `gorm:"size:64;column:login_ip" json:"-" yaml:"-"` + LoginAt time.Time `json:"-" yaml:"-"` CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt"` - MaxAge time.Duration `json:"MaxAge,omitempty" yaml:"MaxAge,omitempty"` UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt"` - Timeout time.Duration `json:"Timeout,omitempty" yaml:"Timeout,omitempty"` + Status int `gorm:"-" json:"Status,omitempty" yaml:"-"` } // TableName returns the entity table name. @@ -60,31 +61,48 @@ func (Session) TableName() string { return "auth_sessions" } -// NewSession creates a new session and returns it. -func NewSession(maxAge, timeout time.Duration) (m *Session) { +// NewSession creates a new session using the maxAge and timeout in seconds. +func NewSession(maxAge, timeout int64) (m *Session) { created := TimeStamp() - // Makes no sense for the timeout to be longer than the max age. - if timeout >= maxAge { - maxAge = timeout - timeout = 0 - } else if maxAge == 0 { - // Set maxAge to default if not specified. - maxAge = time.Hour * 24 * 7 - } - m = &Session{ ID: rnd.SessionID(), - MaxAge: maxAge, - Timeout: timeout, RefID: rnd.RefID(SessionPrefix), CreatedAt: created, UpdatedAt: created, } + if maxAge > 0 { + m.SessExpires = created.Unix() + maxAge + } + + if timeout > 0 { + m.SessTimeout = timeout + } + return m } +// DeleteExpiredSessions deletes expired sessions. +func DeleteExpiredSessions() (deleted int) { + expired := Sessions{} + + if err := Db().Where("sess_expires > 0 AND sess_expires < ?", UnixTime()).Find(&expired).Error; err != nil { + event.AuditErr([]string{"failed to fetch sessions sessions", "%s"}, err) + return deleted + } + + for _, s := range expired { + if err := s.Delete(); err != nil { + event.AuditErr([]string{s.IP(), "session %s", "failed to delete", "%s"}, s.RefID, err) + } else { + deleted++ + } + } + + return deleted +} + // SessionStatusUnauthorized returns a session with status unauthorized (401). func SessionStatusUnauthorized() *Session { return &Session{Status: http.StatusUnauthorized} @@ -363,16 +381,57 @@ func (m *Session) RedeemToken(token string) (n int) { // ExpiresAt returns the time when the session expires. func (m *Session) ExpiresAt() time.Time { - return m.CreatedAt.Add(m.MaxAge) + if m.SessExpires <= 0 { + return time.Time{} + } + + return time.Unix(m.SessExpires, 0) +} + +// TimeoutAt returns the time at which the session will expire due to inactivity. +func (m *Session) TimeoutAt() time.Time { + if m.SessTimeout <= 0 || m.LastActive <= 0 { + return m.ExpiresAt() + } else if t := m.LastActive + m.SessTimeout; t <= m.SessExpires || m.SessExpires <= 0 { + return time.Unix(m.LastActive+m.SessTimeout, 0) + } else { + return m.ExpiresAt() + } +} + +// TimedOut checks if the session has expired due to inactivity.. +func (m *Session) TimedOut() bool { + if at := m.TimeoutAt(); at.IsZero() { + return false + } else { + return at.Before(UTC()) + } } // Expired checks if the session has expired. func (m *Session) Expired() bool { - if m.MaxAge <= 0 { + if m.SessExpires <= 0 { + return m.TimedOut() + } else if at := m.ExpiresAt(); at.IsZero() { return false + } else { + return at.Before(UTC()) + } +} + +// UpdateLastActive sets the last activity of the session to now. +func (m *Session) UpdateLastActive() *Session { + if m.Invalid() { + return m } - return m.ExpiresAt().Before(UTC()) + m.LastActive = UnixTime() + + if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil { + event.AuditWarn([]string{m.IP(), "session %s", "failed to update last active time", "%s"}, m.RefID, err) + } + + return m } // Invalid checks if the session does not belong to a registered user or a visitor with shares. diff --git a/internal/entity/auth_session_cache.go b/internal/entity/auth_session_cache.go index 2f93b5d57..75bd8b373 100644 --- a/internal/entity/auth_session_cache.go +++ b/internal/entity/auth_session_cache.go @@ -6,6 +6,7 @@ import ( gc "github.com/patrickmn/go-cache" + "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" ) @@ -38,17 +39,25 @@ func FindSession(id string) (s Session, err error) { // Find cached session. if cacheData, ok := sessionCache.Get(id); ok { - return cacheData.(Session), nil + s = cacheData.(Session) + s.LastActive = UnixTime() + return s, nil } // Search database and return session if found. if r := Db().First(&s, "id = ?", id); r.RecordNotFound() { - err = fmt.Errorf("not found") + return s, fmt.Errorf("not found") } else if r.Error != nil { - err = r.Error + return s, r.Error } else if !rnd.IsSessionID(s.ID) { - err = fmt.Errorf("has invalid id %s", clean.LogQuote(s.ID)) + return s, fmt.Errorf("has invalid id %s", clean.LogQuote(s.ID)) + } else if s.Expired() { + if err = s.Delete(); err != nil { + event.AuditErr([]string{s.IP(), "session %s", "failed to delete after expiration", "%s"}, s.RefID, err) + } + return s, fmt.Errorf("expired") } else { + s.UpdateLastActive() sessionCache.SetDefault(s.ID, s) } diff --git a/internal/entity/auth_session_fixtures.go b/internal/entity/auth_session_fixtures.go index 74d549b99..4969bdfea 100644 --- a/internal/entity/auth_session_fixtures.go +++ b/internal/entity/auth_session_fixtures.go @@ -1,7 +1,5 @@ package entity -import "time" - type SessionMap map[string]Session func (m SessionMap) Get(name string) Session { @@ -22,49 +20,49 @@ func (m SessionMap) Pointer(name string) *Session { var SessionFixtures = SessionMap{ "alice": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", - Timeout: time.Hour * 24 * 3, - MaxAge: time.Hour * 24 * 7, - user: UserFixtures.Pointer("alice"), - UserUID: UserFixtures.Pointer("alice").UserUID, - UserName: UserFixtures.Pointer("alice").UserName, + ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", + SessTimeout: UnixDay * 3, + SessExpires: UnixTime() + UnixWeek, + user: UserFixtures.Pointer("alice"), + UserUID: UserFixtures.Pointer("alice").UserUID, + UserName: UserFixtures.Pointer("alice").UserName, }, "bob": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", - Timeout: time.Hour * 24 * 3, - MaxAge: time.Hour * 24 * 7, - user: UserFixtures.Pointer("bob"), - UserUID: UserFixtures.Pointer("bob").UserUID, - UserName: UserFixtures.Pointer("bob").UserName, + ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", + SessTimeout: UnixDay * 3, + SessExpires: UnixTime() + UnixWeek, + user: UserFixtures.Pointer("bob"), + UserUID: UserFixtures.Pointer("bob").UserUID, + UserName: UserFixtures.Pointer("bob").UserName, }, "unauthorized": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", - Timeout: time.Hour * 24 * 3, - MaxAge: time.Hour * 24 * 7, - user: UserFixtures.Pointer("unauthorized"), - UserUID: UserFixtures.Pointer("unauthorized").UserUID, - UserName: UserFixtures.Pointer("unauthorized").UserName, + ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", + SessTimeout: UnixDay * 3, + SessExpires: UnixTime() + UnixWeek, + user: UserFixtures.Pointer("unauthorized"), + UserUID: UserFixtures.Pointer("unauthorized").UserUID, + UserName: UserFixtures.Pointer("unauthorized").UserName, }, "visitor": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", - Timeout: time.Hour * 24 * 3, - MaxAge: time.Hour * 24 * 7, - user: &Visitor, - UserUID: Visitor.UserUID, - UserName: Visitor.UserName, - DataJSON: []byte(`{"tokens":["1jxf3jfn2k"],"shares":["at9lxuqxpogaaba8"]}`), + ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", + SessTimeout: UnixDay * 3, + SessExpires: UnixTime() + UnixWeek, + user: &Visitor, + UserUID: Visitor.UserUID, + UserName: Visitor.UserName, + DataJSON: []byte(`{"tokens":["1jxf3jfn2k"],"shares":["at9lxuqxpogaaba8"]}`), data: &SessionData{ Tokens: []string{"1jxf3jfn2k"}, Shares: UIDs{"at9lxuqxpogaaba8"}, }, }, "friend": { - ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4", - Timeout: time.Hour * 24 * 3, - MaxAge: time.Hour * 24 * 7, - user: UserFixtures.Pointer("friend"), - UserUID: UserFixtures.Pointer("friend").UserUID, - UserName: UserFixtures.Pointer("friend").UserName, + ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4", + SessTimeout: UnixDay * 3, + SessExpires: UnixTime() + UnixWeek, + user: UserFixtures.Pointer("friend"), + UserUID: UserFixtures.Pointer("friend").UserUID, + UserName: UserFixtures.Pointer("friend").UserName, }, } diff --git a/internal/entity/auth_session_report.go b/internal/entity/auth_session_report.go new file mode 100644 index 000000000..e59aa2546 --- /dev/null +++ b/internal/entity/auth_session_report.go @@ -0,0 +1,31 @@ +package entity + +import ( + "fmt" +) + +// Report returns the entity values as rows. +func (m *Session) Report(skipEmpty bool) (rows [][]string, cols []string) { + cols = []string{"Name", "Value"} + + // Extract model values. + values, _, err := ModelValues(m, "ID") + + // Ok? + if err != nil { + return rows, cols + } + + rows = make([][]string, 0, len(values)) + + for k, v := range values { + s := fmt.Sprintf("%v", v) + + // Skip empty values? + if !skipEmpty || s != "" { + rows = append(rows, []string{k, s}) + } + } + + return rows, cols +} diff --git a/internal/entity/auth_session_test.go b/internal/entity/auth_session_test.go index 652879520..331198eba 100644 --- a/internal/entity/auth_session_test.go +++ b/internal/entity/auth_session_test.go @@ -2,7 +2,6 @@ package entity import ( "testing" - "time" "github.com/stretchr/testify/assert" @@ -11,7 +10,7 @@ import ( func TestNewSession(t *testing.T) { t.Run("NoSessionData", func(t *testing.T) { - m := NewSession(time.Hour*24, time.Hour*6) + m := NewSession(UnixDay, UnixHour*6) assert.True(t, rnd.IsSessionID(m.ID)) assert.False(t, m.CreatedAt.IsZero()) @@ -22,7 +21,7 @@ func TestNewSession(t *testing.T) { assert.Equal(t, 0, len(m.Data().Tokens)) }) t.Run("EmptySessionData", func(t *testing.T) { - m := NewSession(time.Hour*24, time.Hour*6) + m := NewSession(UnixDay, UnixHour*6) m.SetData(NewSessionData()) assert.True(t, rnd.IsSessionID(m.ID)) @@ -36,7 +35,7 @@ func TestNewSession(t *testing.T) { t.Run("WithSessionData", func(t *testing.T) { data := NewSessionData() data.Tokens = []string{"foo", "bar"} - m := NewSession(time.Hour*24, time.Hour*6) + m := NewSession(UnixDay, UnixHour*6) m.SetData(data) assert.True(t, rnd.IsSessionID(m.ID)) @@ -48,14 +47,12 @@ func TestNewSession(t *testing.T) { assert.Len(t, m.Data().Tokens, 2) assert.Equal(t, "foo", m.Data().Tokens[0]) assert.Equal(t, "bar", m.Data().Tokens[1]) - - // t.Logf("Session: %#v", m) }) } func TestSession_SetData(t *testing.T) { t.Run("Nil", func(t *testing.T) { - m := NewSession(time.Hour*24, time.Hour*6) + m := NewSession(UnixDay, UnixHour*6) assert.NotNil(t, m) @@ -66,3 +63,98 @@ func TestSession_SetData(t *testing.T) { assert.Equal(t, sess.ID, m.ID) }) } + +func TestSession_TimedOut(t *testing.T) { + t.Run("NewSession", func(t *testing.T) { + m := NewSession(UnixDay, UnixHour) + assert.False(t, m.TimeoutAt().IsZero()) + assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) + assert.False(t, m.TimedOut()) + }) + t.Run("NoExpiration", func(t *testing.T) { + m := NewSession(0, UnixHour) + t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) + assert.True(t, m.TimeoutAt().IsZero()) + assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) + assert.False(t, m.TimedOut()) + assert.True(t, m.ExpiresAt().IsZero()) + }) + t.Run("NoTimeout", func(t *testing.T) { + m := NewSession(UnixDay, 0) + t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) + assert.False(t, m.TimeoutAt().IsZero()) + assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) + assert.False(t, m.TimedOut()) + assert.False(t, m.ExpiresAt().IsZero()) + }) + t.Run("TimedOut", func(t *testing.T) { + m := NewSession(UnixDay, UnixHour) + utc := UnixTime() + + m.LastActive = utc - (UnixHour + 1) + + assert.False(t, m.TimeoutAt().IsZero()) + assert.True(t, m.TimedOut()) + }) + t.Run("NotTimedOut", func(t *testing.T) { + m := NewSession(UnixDay, UnixHour) + utc := UnixTime() + + m.LastActive = utc - (UnixHour - 10) + + assert.False(t, m.TimeoutAt().IsZero()) + assert.False(t, m.TimedOut()) + }) +} + +func TestSession_Expired(t *testing.T) { + t.Run("NewSession", func(t *testing.T) { + m := NewSession(UnixDay, UnixHour) + t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) + assert.False(t, m.ExpiresAt().IsZero()) + assert.False(t, m.Expired()) + assert.False(t, m.TimeoutAt().IsZero()) + assert.False(t, m.TimedOut()) + }) + t.Run("NoExpiration", func(t *testing.T) { + m := NewSession(0, 0) + t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) + assert.True(t, m.ExpiresAt().IsZero()) + assert.False(t, m.Expired()) + assert.True(t, m.TimeoutAt().IsZero()) + assert.False(t, m.TimedOut()) + }) + t.Run("NoExpiration", func(t *testing.T) { + m := NewSession(0, 0) + t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) + assert.True(t, m.ExpiresAt().IsZero()) + assert.False(t, m.Expired()) + assert.True(t, m.TimeoutAt().IsZero()) + assert.False(t, m.TimedOut()) + }) + t.Run("Expired", func(t *testing.T) { + m := NewSession(UnixDay, UnixHour) + t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) + utc := UnixTime() + + m.SessExpires = utc - 10 + + assert.False(t, m.ExpiresAt().IsZero()) + assert.True(t, m.Expired()) + assert.False(t, m.TimeoutAt().IsZero()) + assert.True(t, m.TimedOut()) + assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) + }) + t.Run("NotExpired", func(t *testing.T) { + m := NewSession(UnixDay, UnixHour) + utc := UnixTime() + + m.SessExpires = utc + 10 + + assert.False(t, m.ExpiresAt().IsZero()) + assert.False(t, m.Expired()) + assert.False(t, m.TimeoutAt().IsZero()) + assert.False(t, m.TimedOut()) + assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) + }) +} diff --git a/internal/entity/db.go b/internal/entity/db.go index f31adc846..0589623f5 100644 --- a/internal/entity/db.go +++ b/internal/entity/db.go @@ -1,6 +1,17 @@ package entity -import "github.com/jinzhu/gorm" +import ( + "time" + + "github.com/jinzhu/gorm" +) + +// Set UTC as the default for created and updated timestamps. +func init() { + gorm.NowFunc = func() time.Time { + return UTC() + } +} // Db returns the default *gorm.DB connection. func Db() *gorm.DB { diff --git a/internal/entity/time.go b/internal/entity/entity_time.go similarity index 62% rename from internal/entity/time.go rename to internal/entity/entity_time.go index 33c217bc8..08bade372 100644 --- a/internal/entity/time.go +++ b/internal/entity/entity_time.go @@ -1,14 +1,31 @@ package entity -import "time" +import ( + "time" +) +// Day specified as time.Duration to improve readability. const Day = time.Hour * 24 +// UnixHour is one hour in UnixTime. +const UnixHour int64 = 3600 + +// UnixDay is one day in UnixTime. +const UnixDay = UnixHour * 24 + +// UnixWeek is one week in UnixTime. +const UnixWeek = UnixDay * 7 + // UTC returns the current Coordinated Universal Time (UTC). func UTC() time.Time { return time.Now().UTC() } +// UnixTime returns the current time in seconds since January 1, 1970 UTC. +func UnixTime() int64 { + return UTC().Unix() +} + // TimeStamp returns the current timestamp in UTC rounded to seconds. func TimeStamp() time.Time { return UTC().Truncate(time.Second) diff --git a/internal/entity/time_test.go b/internal/entity/entity_time_test.go similarity index 75% rename from internal/entity/time_test.go rename to internal/entity/entity_time_test.go index 33da7d3ce..c6d46b6cc 100644 --- a/internal/entity/time_test.go +++ b/internal/entity/entity_time_test.go @@ -3,8 +3,39 @@ package entity import ( "testing" "time" + + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/assert" ) +func TestUTC(t *testing.T) { + t.Run("Zone", func(t *testing.T) { + utc := UTC() + + if zone, offset := utc.Zone(); zone != time.UTC.String() { + t.Error("should be utc") + } else if offset != 0 { + t.Error("offset should be 0") + } + }) + t.Run("Gorm", func(t *testing.T) { + utc := UTC() + utcGorm := gorm.NowFunc() + + t.Logf("NOW: %s, %s", utc.String(), utcGorm.String()) + + assert.True(t, utcGorm.After(utc)) + + if zone, offset := utcGorm.Zone(); zone != time.UTC.String() { + t.Error("gorm time should be utc") + } else if offset != 0 { + t.Error("gorm time offset should be 0") + } + + assert.InEpsilon(t, utc.Unix(), utcGorm.Unix(), 2) + }) +} + func TestTimeStamp(t *testing.T) { t.Run("UTC", func(t *testing.T) { if TimeStamp().Location() != time.UTC { diff --git a/internal/mutex/activities.go b/internal/mutex/activities.go index 2d10a5dd6..09dfb6596 100644 --- a/internal/mutex/activities.go +++ b/internal/mutex/activities.go @@ -20,7 +20,7 @@ func CancelAll() { FacesWorker.Cancel() } -// WorkersRunning checks if a worker is currently running. -func WorkersRunning() bool { +// IndexWorkersRunning checks if a worker is currently running. +func IndexWorkersRunning() bool { return MainWorker.Running() || SyncWorker.Running() || ShareWorker.Running() || MetaWorker.Running() || FacesWorker.Running() } diff --git a/internal/mutex/mutex_test.go b/internal/mutex/mutex_test.go index fa55cd07a..77b1b7817 100644 --- a/internal/mutex/mutex_test.go +++ b/internal/mutex/mutex_test.go @@ -7,5 +7,5 @@ import ( ) func TestWorkersBusy(t *testing.T) { - assert.False(t, WorkersRunning()) + assert.False(t, IndexWorkersRunning()) } diff --git a/internal/query/sessions.go b/internal/query/sessions.go index 9394dad6d..77096ec06 100644 --- a/internal/query/sessions.go +++ b/internal/query/sessions.go @@ -1,25 +1,56 @@ package query import ( - "time" + "fmt" + "strings" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" ) -// Sessions returns stored sessions. -func Sessions() (result entity.Sessions, err error) { - err = Db(). - Table(entity.Session{}.TableName()). - Select("*"). - Where("expires_at > ?", time.Now()). - Scan(&result).Error - - return result, err -} - -// Session finds an existing session by id. +// Session finds an existing session by its id. func Session(id string) (result entity.Session, err error) { - err = Db().Where("id = ?", id).First(&result).Error + if l := len(id); l < 6 && l > 2048 { + return result, fmt.Errorf("invalid session id") + } else if rnd.IsRefID(id) { + err = Db().Where("ref_id = ?", id).First(&result).Error + } else { + err = Db().Where("id LIKE ?", id).First(&result).Error + } + + return result, err +} + +// Sessions finds user sessions and returns them. +func Sessions(limit, offset int, sortOrder, search string) (result entity.Sessions, err error) { + result = entity.Sessions{} + stmt := Db() + + search = strings.TrimSpace(search) + + if search == "expired" { + stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime()) + } else if rnd.IsSessionID(search) { + stmt = stmt.Where("id = ?", search) + } else if rnd.IsUID(search, entity.UserUID) { + stmt = stmt.Where("user_uid = ?", search) + } else if search != "" { + stmt = stmt.Where("user_name LIKE ?", search+"%") + } + + if sortOrder == "" { + sortOrder = "last_active, user_name" + } + + if limit > 0 { + stmt = stmt.Limit(limit) + + if offset > 0 { + stmt = stmt.Offset(offset) + } + } + + err = stmt.Order(sortOrder).Find(&result).Error return result, err } diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go new file mode 100644 index 000000000..0eb99b490 --- /dev/null +++ b/internal/query/sessions_test.go @@ -0,0 +1,97 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSession(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + result, err := Session("") + t.Logf("session: %#v", result) + assert.Error(t, err) + assert.NotNil(t, result) + assert.Equal(t, "", result.ID) + assert.Equal(t, "", result.UserUID) + assert.Equal(t, "", result.UserName) + }) + t.Run("Alice", func(t *testing.T) { + if result, err := Session("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"); err != nil { + t.Fatal(err) + } else { + t.Logf("session: %#v", result) + assert.NotNil(t, result) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", result.ID) + assert.Equal(t, "uqxetse3cy5eo9z2", result.UserUID) + assert.Equal(t, "alice", result.UserName) + } + }) + t.Run("Bob", func(t *testing.T) { + if result, err := Session("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"); err != nil { + t.Fatal(err) + } else { + t.Logf("session: %#v", result) + assert.NotNil(t, result) + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", result.ID) + assert.Equal(t, "uqxc08w3d0ej2283", result.UserUID) + assert.Equal(t, "bob", result.UserName) + } + }) +} + +func TestSessions(t *testing.T) { + t.Run("Default", func(t *testing.T) { + if results, err := Sessions(0, 0, "", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 2, len(results)) + //t.Logf("sessions: %#v", results) + } + }) + t.Run("Limit", func(t *testing.T) { + if results, err := Sessions(1, 0, "", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 1, len(results)) + //t.Logf("sessions: %#v", results) + } + }) + t.Run("Offset", func(t *testing.T) { + if results, err := Sessions(0, 1, "", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 2, len(results)) + //t.Logf("sessions: %#v", results) + } + }) + t.Run("SearchAlice", func(t *testing.T) { + if results, err := Sessions(100, 0, "", "alice"); err != nil { + t.Fatal(err) + } else { + t.Logf("sessions: %#v", results) + assert.LessOrEqual(t, 1, len(results)) + if len(results) > 0 { + assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", results[0].ID) + assert.Equal(t, "uqxetse3cy5eo9z2", results[0].UserUID) + assert.Equal(t, "alice", results[0].UserName) + } + } + }) + t.Run("SortByID", func(t *testing.T) { + if results, err := Sessions(100, 0, "id", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 2, len(results)) + //t.Logf("sessions: %#v", results) + } + }) + t.Run("SearchAliceSortByID", func(t *testing.T) { + if results, err := Sessions(100, 0, "id", "alice"); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 1, len(results)) + //t.Logf("sessions: %#v", results) + } + }) +} diff --git a/internal/query/users.go b/internal/query/users.go index de69b3335..0d659e2f9 100644 --- a/internal/query/users.go +++ b/internal/query/users.go @@ -1,7 +1,11 @@ package query import ( + "strings" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/txt" ) // RegisteredUsers finds all registered users. @@ -12,3 +16,39 @@ func RegisteredUsers() (result entity.Users) { return result } + +// Users finds users and returns them. +func Users(limit, offset int, sortOrder, search string) (result entity.Users, err error) { + result = entity.Users{} + stmt := Db() + + search = strings.TrimSpace(search) + + if search == "all" { + stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime()) + } else if id := txt.Int(search); id != 0 { + stmt = stmt.Where("id = ?", id) + } else if rnd.IsUID(search, entity.UserUID) { + stmt = stmt.Where("user_uid = ?", search) + } else if search != "" { + stmt = stmt.Where("user_name LIKE ? OR user_email LIKE ? OR display_name LIKE ?", search+"%", search+"%", search+"%") + } else { + stmt = stmt.Where("id > 0") + } + + if sortOrder == "" { + sortOrder = "id" + } + + if limit > 0 { + stmt = stmt.Limit(limit) + + if offset > 0 { + stmt = stmt.Offset(offset) + } + } + + err = stmt.Order(sortOrder).Find(&result).Error + + return result, err +} diff --git a/internal/query/users_test.go b/internal/query/users_test.go index c9ae8d8d6..6fe4e4cf1 100644 --- a/internal/query/users_test.go +++ b/internal/query/users_test.go @@ -12,10 +12,59 @@ func TestRegisteredUsers(t *testing.T) { for _, user := range users { t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.Name(), user.DisplayName) + assert.NotEmpty(t, user.UserUID) } - t.Logf("user count: %v", len(users)) - assert.GreaterOrEqual(t, len(users), 3) }) } + +func TestUsers(t *testing.T) { + t.Run("Default", func(t *testing.T) { + if results, err := Users(0, 0, "", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 2, len(results)) + } + }) + t.Run("Limit", func(t *testing.T) { + if results, err := Users(1, 0, "", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 1, len(results)) + } + }) + t.Run("Offset", func(t *testing.T) { + if results, err := Users(0, 1, "", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 2, len(results)) + } + }) + t.Run("SearchAlice", func(t *testing.T) { + if results, err := Users(100, 0, "", "alice"); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 1, len(results)) + if len(results) > 0 { + assert.Equal(t, 5, results[0].ID) + assert.Equal(t, "uqxetse3cy5eo9z2", results[0].UserUID) + assert.Equal(t, "alice", results[0].UserName) + } + } + }) + t.Run("SortByID", func(t *testing.T) { + if results, err := Users(100, 0, "id", ""); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 2, len(results)) + } + }) + t.Run("SearchAliceSortByID", func(t *testing.T) { + if results, err := Users(100, 0, "id", "alice"); err != nil { + t.Fatal(err) + } else { + assert.LessOrEqual(t, 1, len(results)) + } + }) +} diff --git a/internal/session/monitor.go b/internal/session/monitor.go new file mode 100644 index 000000000..154fb178d --- /dev/null +++ b/internal/session/monitor.go @@ -0,0 +1,45 @@ +package session + +import ( + "time" + + "github.com/dustin/go-humanize/english" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/event" +) + +var stop = make(chan bool, 1) + +// MonitorAction deletes expired sessions. +var MonitorAction = func() { + if n := entity.DeleteExpiredSessions(); n > 0 { + event.AuditInfo([]string{"deleted %s"}, english.Plural(n, "expired session", "expired sessions")) + } else { + event.AuditDebug([]string{"found no expired sessions"}) + } +} + +// Monitor starts a background worker that periodically deletes expired sessions. +func Monitor(interval time.Duration) { + ticker := time.NewTicker(interval) + + MonitorAction() + + go func() { + for { + select { + case <-stop: + ticker.Stop() + return + case <-ticker.C: + MonitorAction() + } + } + }() +} + +// Shutdown shuts down the session watcher. +func Shutdown() { + stop <- true +} diff --git a/internal/session/monitor_test.go b/internal/session/monitor_test.go new file mode 100644 index 000000000..cf18ba347 --- /dev/null +++ b/internal/session/monitor_test.go @@ -0,0 +1,11 @@ +package session + +import ( + "testing" + "time" +) + +func TestWatch(t *testing.T) { + Monitor(time.Minute) + Shutdown() +} diff --git a/internal/session/new.go b/internal/session/new.go deleted file mode 100644 index 0017f72f7..000000000 --- a/internal/session/new.go +++ /dev/null @@ -1,16 +0,0 @@ -package session - -import ( - "time" - - "github.com/photoprism/photoprism/internal/config" -) - -// MaxAge is the maximum duration after which a session expires. -var MaxAge = 168 * time.Hour * 24 * 7 -var Timeout = 168 * time.Hour * 24 * 3 - -// New creates a new session store with default values. -func New(conf *config.Config) *Session { - return &Session{MaxAge: MaxAge, Timeout: Timeout, conf: conf} -} diff --git a/internal/session/session.go b/internal/session/session.go index 1a2591213..19de5b463 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -25,8 +25,6 @@ Additional information can be found in our Developer Guide: package session import ( - "time" - gc "github.com/patrickmn/go-cache" "github.com/photoprism/photoprism/internal/config" @@ -37,8 +35,11 @@ var log = event.Log // Session represents a session store. type Session struct { - conf *config.Config - cache *gc.Cache - MaxAge time.Duration - Timeout time.Duration + conf *config.Config + cache *gc.Cache +} + +// New creates a new session store with default values. +func New(conf *config.Config) *Session { + return &Session{conf: conf} } diff --git a/internal/session/session_new.go b/internal/session/session_new.go index 303ae35c6..82f6a633f 100644 --- a/internal/session/session_new.go +++ b/internal/session/session_new.go @@ -8,5 +8,5 @@ import ( // New creates a session with a context if it is specified. func (s *Session) New(c *gin.Context) (m *entity.Session) { - return entity.NewSession(s.MaxAge, s.Timeout).SetContext(c) + return entity.NewSession(s.conf.SessMaxAge(), s.conf.SessTimeout()).SetContext(c) } diff --git a/internal/session/session_public.go b/internal/session/session_public.go index 6b1c6b888..3d9ce6023 100644 --- a/internal/session/session_public.go +++ b/internal/session/session_public.go @@ -15,7 +15,7 @@ func (s *Session) Public() *entity.Session { return Public } - Public = entity.NewSession(s.MaxAge, s.Timeout) + Public = entity.NewSession(0, 0) Public.ID = PublicID Public.AuthMethod = "public" Public.SetUser(&entity.Admin) diff --git a/internal/session/session_save.go b/internal/session/session_save.go index a2a493555..12226cf46 100644 --- a/internal/session/session_save.go +++ b/internal/session/session_save.go @@ -15,7 +15,7 @@ func (s *Session) Save(m *entity.Session) (*entity.Session, error) { } // Save session. - return m, m.Save() + return m.UpdateLastActive(), m.Save() } // Create initializes a new client session and returns it. @@ -24,7 +24,9 @@ func (s *Session) Create(u *entity.User, c *gin.Context, data *entity.SessionDat m = s.New(c).SetUser(u).SetData(data) // Create session. - err = m.Create() + if err = m.Create(); err != nil { + m.UpdateLastActive() + } return m, err } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 61eb95def..84c1e3443 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -6,15 +6,17 @@ import ( "github.com/sirupsen/logrus" - "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/event" ) func TestMain(m *testing.M) { log = logrus.StandardLogger() log.SetLevel(logrus.TraceLevel) + event.AuditLog = log - db := entity.InitTestDb(os.Getenv("PHOTOPRISM_TEST_DRIVER"), os.Getenv("PHOTOPRISM_TEST_DSN")) - defer db.Close() + c := config.TestConfig() + defer c.CloseDb() code := m.Run() diff --git a/internal/workers/share.go b/internal/workers/share.go index 83d395eb3..048559c90 100644 --- a/internal/workers/share.go +++ b/internal/workers/share.go @@ -5,8 +5,6 @@ import ( "path/filepath" "runtime/debug" - "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" @@ -17,6 +15,7 @@ import ( "github.com/photoprism/photoprism/internal/remote/webdav" "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/fs" ) // Share represents a share worker. @@ -30,14 +29,14 @@ func NewShare(conf *config.Config) *Share { } // logError logs an error message if err is not nil. -func (worker *Share) logError(err error) { +func (w *Share) logError(err error) { if err != nil { log.Errorf("share: %s", err.Error()) } } // Start starts the share worker. -func (worker *Share) Start() (err error) { +func (w *Share) Start() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("share: %s (panic)\nstack: %s", r, debug.Stack()) @@ -71,7 +70,7 @@ func (worker *Share) Start() (err error) { files, err := query.FileShares(a.ID, entity.FileShareNew) if err != nil { - worker.logError(err) + w.logError(err) continue } @@ -110,16 +109,16 @@ func (worker *Share) Start() (err error) { srcFileName := photoprism.FileName(file.File.FileRoot, file.File.FileName) if fs.ImageJPEG.Equal(file.File.FileType) && size.Width > 0 && size.Height > 0 { - srcFileName, err = thumb.FromFile(srcFileName, file.File.FileHash, worker.conf.ThumbCachePath(), size.Width, size.Height, file.File.FileOrientation, size.Options...) + srcFileName, err = thumb.FromFile(srcFileName, file.File.FileHash, w.conf.ThumbCachePath(), size.Width, size.Height, file.File.FileOrientation, size.Options...) if err != nil { - worker.logError(err) + w.logError(err) continue } } if err := client.Upload(srcFileName, file.RemoteName); err != nil { - worker.logError(err) + w.logError(err) file.Errors++ file.Error = err.Error() } else { @@ -138,7 +137,7 @@ func (worker *Share) Start() (err error) { return nil } - worker.logError(entity.Db().Save(&file).Error) + w.logError(entity.Db().Save(&file).Error) } } @@ -155,7 +154,7 @@ func (worker *Share) Start() (err error) { files, err := query.ExpiredFileShares(a) if err != nil { - worker.logError(err) + w.logError(err) continue } @@ -182,7 +181,7 @@ func (worker *Share) Start() (err error) { } if err := entity.Db().Save(&file).Error; err != nil { - worker.logError(err) + w.logError(err) } } } diff --git a/internal/workers/sync.go b/internal/workers/sync.go index 58f597148..f3369c364 100644 --- a/internal/workers/sync.go +++ b/internal/workers/sync.go @@ -27,21 +27,21 @@ func NewSync(conf *config.Config) *Sync { } // logError logs an error message if err is not nil. -func (worker *Sync) logError(err error) { +func (w *Sync) logError(err error) { if err != nil { log.Errorf("sync: %s", err.Error()) } } // logWarn logs a warning message if err is not nil. -func (worker *Sync) logWarn(err error) { +func (w *Sync) logWarn(err error) { if err != nil { log.Warnf("sync: %s", err.Error()) } } // Start starts the sync worker. -func (worker *Sync) Start() (err error) { +func (w *Sync) Start() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("sync: %s (panic)\nstack: %s", r, debug.Stack()) @@ -71,7 +71,7 @@ func (worker *Sync) Start() (err error) { a.AccSync = false if err := entity.Db().Save(&a).Error; err != nil { - worker.logError(err) + w.logError(err) } else { log.Warnf("sync: disabled sync, %s failed more than %d times", a.AccName, a.RetryLimit) } @@ -88,7 +88,7 @@ func (worker *Sync) Start() (err error) { switch a.SyncStatus { case entity.AccountSyncStatusRefresh: - if complete, err := worker.refresh(a); err != nil { + if complete, err := w.refresh(a); err != nil { accErrors++ accError = err.Error() } else if complete { @@ -106,7 +106,7 @@ func (worker *Sync) Start() (err error) { } } case entity.AccountSyncStatusDownload: - if complete, err := worker.download(a); err != nil { + if complete, err := w.download(a); err != nil { accErrors++ accError = err.Error() syncStatus = entity.AccountSyncStatusRefresh @@ -121,7 +121,7 @@ func (worker *Sync) Start() (err error) { } } case entity.AccountSyncStatusUpload: - if complete, err := worker.upload(a); err != nil { + if complete, err := w.upload(a); err != nil { accErrors++ accError = err.Error() syncStatus = entity.AccountSyncStatusRefresh @@ -149,7 +149,7 @@ func (worker *Sync) Start() (err error) { "AccErrors": accErrors, "SyncStatus": syncStatus, "SyncDate": syncDate}); err != nil { - worker.logError(err) + w.logError(err) } else if synced { event.Publish("sync.synced", event.Data{"account": a}) } diff --git a/internal/workers/sync_download.go b/internal/workers/sync_download.go index 2b61830c9..c75eb51f1 100644 --- a/internal/workers/sync_download.go +++ b/internal/workers/sync_download.go @@ -17,12 +17,12 @@ import ( type Downloads map[string][]entity.FileSync // downloadPath returns a temporary download path. -func (worker *Sync) downloadPath() string { - return worker.conf.TempPath() + "/sync" +func (w *Sync) downloadPath() string { + return w.conf.TempPath() + "/sync" } // relatedDownloads returns files to be downloaded grouped by prefix. -func (worker *Sync) relatedDownloads(a entity.Account) (result Downloads, err error) { +func (w *Sync) relatedDownloads(a entity.Account) (result Downloads, err error) { result = make(Downloads) maxResults := 1000 @@ -35,7 +35,7 @@ func (worker *Sync) relatedDownloads(a entity.Account) (result Downloads, err er // Group results by directory and base name for i, file := range files { - k := fs.AbsPrefix(file.RemoteName, worker.conf.Settings().StackSequences()) + k := fs.AbsPrefix(file.RemoteName, w.conf.Settings().StackSequences()) result[k] = append(result[k], file) @@ -49,7 +49,7 @@ func (worker *Sync) relatedDownloads(a entity.Account) (result Downloads, err er } // Downloads remote files in batches and imports / indexes them -func (worker *Sync) download(a entity.Account) (complete bool, err error) { +func (w *Sync) download(a entity.Account) (complete bool, err error) { // Set up index worker indexJobs := make(chan photoprism.IndexJob) @@ -61,10 +61,10 @@ func (worker *Sync) download(a entity.Account) (complete bool, err error) { go photoprism.ImportWorker(importJobs) defer close(importJobs) - relatedFiles, err := worker.relatedDownloads(a) + relatedFiles, err := w.relatedDownloads(a) if err != nil { - worker.logError(err) + w.logError(err) return false, err } @@ -81,9 +81,9 @@ func (worker *Sync) download(a entity.Account) (complete bool, err error) { var baseDir string if a.SyncFilenames { - baseDir = worker.conf.OriginalsPath() + baseDir = w.conf.OriginalsPath() } else { - baseDir = fmt.Sprintf("%s/%d", worker.downloadPath(), a.ID) + baseDir = fmt.Sprintf("%s/%d", w.downloadPath(), a.ID) } done := make(map[string]bool) @@ -124,7 +124,7 @@ func (worker *Sync) download(a entity.Account) (complete bool, err error) { } if err := entity.Db().Save(&file).Error; err != nil { - worker.logError(err) + w.logError(err) } else { files[i] = file } @@ -141,10 +141,10 @@ func (worker *Sync) download(a entity.Account) (complete bool, err error) { continue } - related, err := mf.RelatedFiles(worker.conf.Settings().StackSequences()) + related, err := mf.RelatedFiles(w.conf.Settings().StackSequences()) if err != nil { - worker.logWarn(err) + w.logWarn(err) continue } @@ -176,7 +176,7 @@ func (worker *Sync) download(a entity.Account) (complete bool, err error) { FileName: mf.FileName(), Related: related, IndexOpt: photoprism.IndexOptionsAll(), - ImportOpt: photoprism.ImportOptionsMove(baseDir, worker.conf.ImportDest()), + ImportOpt: photoprism.ImportOptionsMove(baseDir, w.conf.ImportDest()), Imp: service.Import(), } } @@ -186,10 +186,10 @@ func (worker *Sync) download(a entity.Account) (complete bool, err error) { // Any files downloaded? if len(done) > 0 { // Update precalculated photo and file counts. - worker.logWarn(entity.UpdateCounts()) + w.logWarn(entity.UpdateCounts()) // Update album, subject, and label cover thumbs. - worker.logWarn(query.UpdateCovers()) + w.logWarn(query.UpdateCovers()) } return false, nil diff --git a/internal/workers/sync_refresh.go b/internal/workers/sync_refresh.go index 8b1c25874..233d99bcb 100644 --- a/internal/workers/sync_refresh.go +++ b/internal/workers/sync_refresh.go @@ -9,7 +9,7 @@ import ( ) // Updates the local list of remote files so that they can be downloaded in batches -func (worker *Sync) refresh(a entity.Account) (complete bool, err error) { +func (w *Sync) refresh(a entity.Account) (complete bool, err error) { if a.AccType != remote.ServiceWebDAV { return false, nil } @@ -67,11 +67,11 @@ func (worker *Sync) refresh(a entity.Account) (complete bool, err error) { } if f.Status == entity.FileSyncIgnore && a.SyncRaw && (content == media.Raw || content == media.Video) { - worker.logError(f.Update("Status", entity.FileSyncNew)) + w.logError(f.Update("Status", entity.FileSyncNew)) } if f.Status == entity.FileSyncDownloaded && !f.RemoteDate.Equal(file.Date) { - worker.logError(f.Updates(map[string]interface{}{ + w.logError(f.Updates(map[string]interface{}{ "Status": entity.FileSyncNew, "RemoteDate": file.Date, "RemoteSize": file.Size, diff --git a/internal/workers/sync_upload.go b/internal/workers/sync_upload.go index ab4b32e8e..7c892d776 100644 --- a/internal/workers/sync_upload.go +++ b/internal/workers/sync_upload.go @@ -15,7 +15,7 @@ import ( ) // Uploads local files to a remote account -func (worker *Sync) upload(a entity.Account) (complete bool, err error) { +func (w *Sync) upload(a entity.Account) (complete bool, err error) { maxResults := 250 // Get upload file list from database @@ -51,7 +51,7 @@ func (worker *Sync) upload(a entity.Account) (complete bool, err error) { } if err := client.Upload(fileName, remoteName); err != nil { - worker.logError(err) + w.logError(err) continue // try again next time } @@ -69,7 +69,7 @@ func (worker *Sync) upload(a entity.Account) (complete bool, err error) { return false, nil } - worker.logError(entity.Db().Save(&fileSync).Error) + w.logError(entity.Db().Save(&fileSync).Error) } return false, nil diff --git a/internal/workers/workers.go b/internal/workers/workers.go index 7e539174b..5f07a75f4 100644 --- a/internal/workers/workers.go +++ b/internal/workers/workers.go @@ -59,9 +59,9 @@ func Start(conf *config.Config) { mutex.SyncWorker.Cancel() return case <-ticker.C: - StartMeta(conf) - StartShare(conf) - StartSync(conf) + RunMeta(conf) + RunShare(conf) + RunSync(conf) } } }() @@ -72,9 +72,9 @@ func Stop() { stop <- true } -// StartMeta runs the metadata worker once. -func StartMeta(conf *config.Config) { - if !mutex.WorkersRunning() { +// RunMeta runs the metadata worker once. +func RunMeta(conf *config.Config) { + if !mutex.IndexWorkersRunning() { go func() { worker := NewMeta(conf) @@ -88,8 +88,8 @@ func StartMeta(conf *config.Config) { } } -// StartShare runs the share worker once. -func StartShare(conf *config.Config) { +// RunShare runs the share worker once. +func RunShare(conf *config.Config) { if !mutex.ShareWorker.Running() { go func() { worker := NewShare(conf) @@ -100,8 +100,8 @@ func StartShare(conf *config.Config) { } } -// StartSync runs the sync worker once. -func StartSync(conf *config.Config) { +// RunSync runs the sync worker once. +func RunSync(conf *config.Config) { if !mutex.SyncWorker.Running() { go func() { worker := NewSync(conf) diff --git a/internal/workers/workers_test.go b/internal/workers/workers_test.go index 0ecf53525..b48726271 100644 --- a/internal/workers/workers_test.go +++ b/internal/workers/workers_test.go @@ -7,11 +7,13 @@ import ( "github.com/sirupsen/logrus" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/event" ) func TestMain(m *testing.M) { log = logrus.StandardLogger() log.SetLevel(logrus.TraceLevel) + event.AuditLog = log c := config.TestConfig() defer c.CloseDb()