2024-07-10 16:10:33 +02:00
< ? php
namespace Kirby\Cms ;
use Closure ;
use Exception ;
use Kirby\Content\Field ;
use Kirby\Exception\InvalidArgumentException ;
use Kirby\Exception\NotFoundException ;
use Kirby\Exception\PermissionException ;
use Kirby\Filesystem\Dir ;
use Kirby\Filesystem\F ;
use Kirby\Panel\User as Panel ;
use Kirby\Session\Session ;
use Kirby\Toolkit\Str ;
use SensitiveParameter ;
/**
* The `$user` object represents a
* single Panel or frontend user .
*
* @ package Kirby Cms
* @ author Bastian Allgeier < bastian @ getkirby . com >
* @ link https :// getkirby . com
* @ copyright Bastian Allgeier
* @ license https :// getkirby . com / license
*/
class User extends ModelWithContent
{
use HasFiles ;
use HasMethods ;
use HasSiblings ;
use UserActions ;
public const CLASS_ALIAS = 'user' ;
/**
* All registered user methods
* @ todo Remove when support for PHP 8.2 is dropped
*/
public static array $methods = [];
/**
* Registry with all User models
*/
public static array $models = [];
protected UserBlueprint | null $blueprint = null ;
protected array $credentials ;
protected string | null $email ;
protected string $hash ;
protected string $id ;
protected array | null $inventory = null ;
protected string | null $language ;
protected Field | string | null $name ;
protected string | null $password ;
protected Role | string | null $role ;
/**
* Creates a new User object
*/
public function __construct ( array $props )
{
// helper function to easily edit values (if not null)
// before assigning them to their properties
$set = function ( string $key , Closure $callback ) use ( $props ) {
if ( $value = $props [ $key ] ? ? null ) {
$value = $callback ( $value );
}
return $value ;
};
// if no ID passed, generate one;
// do so before calling parent constructor
// so it also gets stored in propertyData prop
$props [ 'id' ] ? ? = $this -> createId ();
parent :: __construct ( $props );
$this -> id = $props [ 'id' ];
$this -> email = $set ( 'email' , fn ( $email ) => Str :: lower ( trim ( $email )));
$this -> language = $set ( 'language' , fn ( $language ) => trim ( $language ));
$this -> name = $set ( 'name' , fn ( $name ) => trim ( strip_tags ( $name )));
$this -> password = $props [ 'password' ] ? ? null ;
$this -> role = $set ( 'role' , fn ( $role ) => Str :: lower ( trim ( $role )));
$this -> setBlueprint ( $props [ 'blueprint' ] ? ? null );
$this -> setFiles ( $props [ 'files' ] ? ? null );
}
/**
* Modified getter to also return fields
* from the content
*/
public function __call ( string $method , array $arguments = []) : mixed
{
// public property access
if ( isset ( $this -> $method ) === true ) {
return $this -> $method ;
}
// user methods
if ( $this -> hasMethod ( $method )) {
return $this -> callMethod ( $method , $arguments );
}
// return site content otherwise
return $this -> content () -> get ( $method );
}
/**
* Improved `var_dump` output
* @ codeCoverageIgnore
*/
public function __debugInfo () : array
{
return array_merge ( $this -> toArray (), [
'avatar' => $this -> avatar (),
'content' => $this -> content (),
'role' => $this -> role ()
]);
}
/**
* Returns the url to the api endpoint
* @ internal
*/
public function apiUrl ( bool $relative = false ) : string
{
if ( $relative === true ) {
return 'users/' . $this -> id ();
}
return $this -> kirby () -> url ( 'api' ) . '/users/' . $this -> id ();
}
/**
* Returns the File object for the avatar or null
*/
public function avatar () : File | null
{
return $this -> files () -> template ( 'avatar' ) -> first ();
}
/**
* Returns the UserBlueprint object
*/
public function blueprint () : UserBlueprint
{
try {
return $this -> blueprint ? ? = UserBlueprint :: factory ( 'users/' . $this -> role (), 'users/default' , $this );
} catch ( Exception ) {
return $this -> blueprint ? ? = new UserBlueprint ([
'model' => $this ,
'name' => 'default' ,
'title' => 'Default' ,
]);
}
}
/**
* Prepares the content for the write method
* @ internal
* @ param string $languageCode | null Not used so far
*/
public function contentFileData (
array $data ,
string | null $languageCode = null
) : array {
// remove stuff that has nothing to do in the text files
unset (
$data [ 'email' ],
$data [ 'language' ],
$data [ 'name' ],
$data [ 'password' ],
$data [ 'role' ]
);
return $data ;
}
/**
* Filename for the content file
*
* @ internal
* @ deprecated 4.0 . 0
* @ todo Remove in v5
* @ codeCoverageIgnore
*/
public function contentFileName () : string
{
Helpers :: deprecated ( 'The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.' , 'model-content-file' );
return 'user' ;
}
protected function credentials () : array
{
return $this -> credentials ? ? = $this -> readCredentials ();
}
/**
* Returns the user email address
*/
public function email () : string | null
{
return $this -> email ? ? = $this -> credentials ()[ 'email' ] ? ? null ;
}
/**
* Checks if the user exists
*/
public function exists () : bool
{
return $this -> storage () -> exists (
'published' ,
'default'
);
}
/**
* Constructs a User object and also
* takes User models into account .
* @ internal
*/
public static function factory ( mixed $props ) : static
{
if ( empty ( $props [ 'model' ]) === false ) {
return static :: model ( $props [ 'model' ], $props );
}
return new static ( $props );
}
/**
* Hashes the user ' s password unless it is `null` ,
* which will leave it as `null`
* @ internal
*/
public static function hashPassword (
#[SensitiveParameter]
2025-02-07 17:11:37 +01:00
string | null $password = null
2024-07-10 16:10:33 +02:00
) : string | null {
if ( $password !== null ) {
$password = password_hash ( $password , PASSWORD_DEFAULT );
}
return $password ;
}
/**
* Returns the user id
*/
public function id () : string
{
return $this -> id ;
}
/**
* Returns the inventory of files
* children and content files
*/
public function inventory () : array
{
if ( $this -> inventory !== null ) {
return $this -> inventory ;
}
$kirby = $this -> kirby ();
return $this -> inventory = Dir :: inventory (
$this -> root (),
$kirby -> contentExtension (),
$kirby -> contentIgnore (),
$kirby -> multilang ()
);
}
/**
* Compares the current object with the given user object
*/
2025-02-07 17:11:37 +01:00
public function is ( User | null $user = null ) : bool
2024-07-10 16:10:33 +02:00
{
if ( $user === null ) {
return false ;
}
return $this -> id () === $user -> id ();
}
/**
* Checks if this user has the admin role
*/
public function isAdmin () : bool
{
return $this -> role () -> id () === 'admin' ;
}
/**
* Checks if the current user is the virtual
* Kirby user
*/
public function isKirby () : bool
{
return $this -> isAdmin () && $this -> id () === 'kirby' ;
}
/**
* Checks if the current user is this user
*/
public function isLoggedIn () : bool
{
return $this -> is ( $this -> kirby () -> user ());
}
/**
* Checks if the user is the last one
* with the admin role
*/
public function isLastAdmin () : bool
{
return
$this -> role () -> isAdmin () === true &&
$this -> kirby () -> users () -> filter ( 'role' , 'admin' ) -> count () <= 1 ;
}
/**
* Checks if the user is the last user
*/
public function isLastUser () : bool
{
return $this -> kirby () -> users () -> count () === 1 ;
}
/**
* Checks if the current user is the virtual
* Nobody user
*/
public function isNobody () : bool
{
return $this -> role () -> id () === 'nobody' && $this -> id () === 'nobody' ;
}
/**
* Returns the user language
*/
public function language () : string
{
return $this -> language ? ? =
$this -> credentials ()[ 'language' ] ? ?
$this -> kirby () -> panelLanguage ();
}
/**
* Logs the user in
*
* @ param \Kirby\Session\Session | array | null $session Session options or session object to set the user in
*/
public function login (
#[SensitiveParameter]
string $password ,
$session = null
) : bool {
$this -> validatePassword ( $password );
$this -> loginPasswordless ( $session );
return true ;
}
/**
* Logs the user in without checking the password
*
* @ param \Kirby\Session\Session | array | null $session Session options or session object to set the user in
*/
public function loginPasswordless (
Session | array | null $session = null
) : void {
if ( $this -> id () === 'kirby' ) {
throw new PermissionException ( 'The almighty user "kirby" cannot be used for login, only for raising permissions in code via `$kirby->impersonate()`' );
}
$kirby = $this -> kirby ();
$session = $this -> sessionFromOptions ( $session );
$kirby -> trigger (
'user.login:before' ,
[ 'user' => $this , 'session' => $session ]
);
$session -> regenerateToken (); // privilege change
$session -> data () -> set ( 'kirby.userId' , $this -> id ());
if ( $this -> passwordTimestamp () !== null ) {
$session -> data () -> set ( 'kirby.loginTimestamp' , time ());
}
$kirby -> auth () -> setUser ( $this );
$kirby -> trigger (
'user.login:after' ,
[ 'user' => $this , 'session' => $session ]
);
}
/**
* Logs the user out
*
* @ param \Kirby\Session\Session | array | null $session Session options or session object to unset the user in
*/
public function logout ( Session | array | null $session = null ) : void
{
$kirby = $this -> kirby ();
$session = $this -> sessionFromOptions ( $session );
$kirby -> trigger ( 'user.logout:before' , [ 'user' => $this , 'session' => $session ]);
// remove the user from the session for future requests
$session -> data () -> remove ( 'kirby.userId' );
$session -> data () -> remove ( 'kirby.loginTimestamp' );
// clear the cached user object from the app state of the current request
$this -> kirby () -> auth () -> flush ();
if ( $session -> data () -> get () === []) {
// session is now empty, we might as well destroy it
$session -> destroy ();
$kirby -> trigger ( 'user.logout:after' , [ 'user' => $this , 'session' => null ]);
} else {
// privilege change
$session -> regenerateToken ();
$kirby -> trigger ( 'user.logout:after' , [ 'user' => $this , 'session' => $session ]);
}
}
/**
* Returns the root to the media folder for the user
* @ internal
*/
public function mediaRoot () : string
{
return $this -> kirby () -> root ( 'media' ) . '/users/' . $this -> id ();
}
/**
* Returns the media url for the user object
* @ internal
*/
public function mediaUrl () : string
{
return $this -> kirby () -> url ( 'media' ) . '/users/' . $this -> id ();
}
/**
* Creates a user model if it has been registered
* @ internal
*/
public static function model ( string $name , array $props = []) : static
{
if ( $class = ( static :: $models [ $name ] ? ? null )) {
$object = new $class ( $props );
if ( $object instanceof self ) {
return $object ;
}
}
return new static ( $props );
}
/**
* Returns the last modification date of the user
*/
public function modified (
string $format = 'U' ,
string | null $handler = null ,
string | null $languageCode = null
) : int | string | false {
$modifiedContent = $this -> storage () -> modified ( 'published' , $languageCode );
$modifiedIndex = F :: modified ( $this -> root () . '/index.php' );
$modifiedTotal = max ([ $modifiedContent , $modifiedIndex ]);
return Str :: date ( $modifiedTotal , $format , $handler );
}
/**
* Returns the user ' s name
*/
public function name () : Field
{
if ( is_string ( $this -> name ) === true ) {
return new Field ( $this , 'name' , $this -> name );
}
return $this -> name ? ? = new Field ( $this , 'name' , $this -> credentials ()[ 'name' ] ? ? null );
}
/**
* Returns the user ' s name or ,
* if empty , the email address
*/
public function nameOrEmail () : Field
{
$name = $this -> name ();
return $name -> isNotEmpty () ? $name : new Field ( $this , 'email' , $this -> email ());
}
/**
* Create a dummy nobody
* @ internal
*/
public static function nobody () : static
{
return new static ([
'email' => 'nobody@getkirby.com' ,
'role' => 'nobody'
]);
}
/**
* Returns the panel info object
*/
public function panel () : Panel
{
return new Panel ( $this );
}
/**
* Returns the encrypted user password
*/
public function password () : string | null
{
return $this -> password ? ? = $this -> readPassword ();
}
/**
* Returns the timestamp when the password
* was last changed
*/
public function passwordTimestamp () : int | null
{
$file = $this -> secretsFile ();
// ensure we have the latest information
// to prevent cache attacks
clearstatcache ();
// user does not have a password
if ( is_file ( $file ) === false ) {
return null ;
}
return filemtime ( $file );
}
public function permissions () : UserPermissions
{
return new UserPermissions ( $this );
}
/**
* Returns the user role
*/
public function role () : Role
{
if ( $this -> role instanceof Role ) {
return $this -> role ;
}
$name = $this -> role ? ? $this -> credentials ()[ 'role' ] ? ? 'visitor' ;
return $this -> role = $this -> kirby () -> roles () -> find ( $name ) ? ? Role :: nobody ();
}
/**
2025-01-07 16:59:31 +01:00
* Returns all available roles for this user ,
* that the authenticated user can change to .
*
* For all roles the current user can create
* use `$kirby->roles()->canBeCreated()` .
2024-07-10 16:10:33 +02:00
*/
public function roles () : Roles
{
$kirby = $this -> kirby ();
$roles = $kirby -> roles ();
2025-01-07 16:59:31 +01:00
// if the authenticated user doesn't have the permission to change
// the role of this user, only the current role is available
if ( $this -> permissions () -> can ( 'changeRole' ) === false ) {
return $roles -> filter ( 'id' , $this -> role () -> id ());
2024-07-10 16:10:33 +02:00
}
2025-01-07 16:59:31 +01:00
return $roles -> canBeCreated ();
2024-07-10 16:10:33 +02:00
}
/**
* The absolute path to the user directory
*/
public function root () : string
{
return $this -> kirby () -> root ( 'accounts' ) . '/' . $this -> id ();
}
/**
* Returns the UserRules class to
* validate any important action .
*/
protected function rules () : UserRules
{
return new UserRules ();
}
/**
* Reads a specific secret from the user secrets file on disk
* @ since 4.0 . 0
*/
public function secret ( string $key ) : mixed
{
return $this -> readSecrets ()[ $key ] ? ? null ;
}
/**
* Sets the Blueprint object
*
* @ return $this
*/
2025-02-07 17:11:37 +01:00
protected function setBlueprint ( array | null $blueprint = null ) : static
2024-07-10 16:10:33 +02:00
{
if ( $blueprint !== null ) {
$blueprint [ 'model' ] = $this ;
$this -> blueprint = new UserBlueprint ( $blueprint );
}
return $this ;
}
/**
* Converts session options into a session object
*
* @ param \Kirby\Session\Session | array $session Session options or session object to unset the user in
*/
protected function sessionFromOptions ( Session | array | null $session ) : Session
{
// use passed session options or session object if set
if ( is_array ( $session ) === true ) {
$session = $this -> kirby () -> session ( $session );
} elseif ( $session instanceof Session === false ) {
$session = $this -> kirby () -> session ([ 'detect' => true ]);
}
return $session ;
}
/**
* Returns the parent Users collection
*/
protected function siblingsCollection () : Users
{
2025-01-07 16:59:31 +01:00
return $this -> kirby () -> users () -> sortBy ( 'username' , 'asc' );
2024-07-10 16:10:33 +02:00
}
/**
* Converts the most important user properties
* to an array
*/
public function toArray () : array
{
return array_merge ( parent :: toArray (), [
'avatar' => $this -> avatar () ? -> toArray (),
'email' => $this -> email (),
'id' => $this -> id (),
'language' => $this -> language (),
'role' => $this -> role () -> name (),
'username' => $this -> username ()
]);
}
/**
* String template builder
*
* @ param string | null $fallback Fallback for tokens in the template that cannot be replaced
* ( `null` to keep the original token )
*/
public function toString (
2025-02-07 17:11:37 +01:00
string | null $template = null ,
2024-07-10 16:10:33 +02:00
array $data = [],
string | null $fallback = '' ,
string $handler = 'template'
) : string {
$template ? ? = $this -> email ();
return parent :: toString ( $template , $data , $fallback , $handler );
}
/**
* Returns the username
* which is the given name or the email
* as a fallback
*/
public function username () : string | null
{
return $this -> name () -> or ( $this -> email ()) -> value ();
}
/**
* Compares the given password with the stored one
*
* @ throws \Kirby\Exception\NotFoundException If the user has no password
* @ throws \Kirby\Exception\InvalidArgumentException If the entered password is not valid
* or does not match the user password
*/
public function validatePassword (
#[SensitiveParameter]
2025-02-07 17:11:37 +01:00
string | null $password = null
2024-07-10 16:10:33 +02:00
) : bool {
if ( empty ( $this -> password ()) === true ) {
throw new NotFoundException ([ 'key' => 'user.password.undefined' ]);
}
// `UserRules` enforces a minimum length of 8 characters,
// so everything below that is a typo
if ( Str :: length ( $password ) < 8 ) {
throw new InvalidArgumentException ([ 'key' => 'user.password.invalid' ]);
}
// too long passwords can cause DoS attacks
if ( Str :: length ( $password ) > 1000 ) {
throw new InvalidArgumentException ([ 'key' => 'user.password.excessive' ]);
}
if ( password_verify ( $password , $this -> password ()) !== true ) {
throw new InvalidArgumentException ([ 'key' => 'user.password.wrong' , 'httpCode' => 401 ]);
}
return true ;
}
/**
* @ deprecated 4.0 . 0 Use `->secretsFile()` instead
* @ codeCoverageIgnore
*/
protected function passwordFile () : string
{
return $this -> secretsFile ();
}
/**
* Returns the path to the file containing
* all user secrets , including the password
* @ since 4.0 . 0
*/
protected function secretsFile () : string
{
return $this -> root () . '/.htpasswd' ;
}
}