mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Merge branch '5.1' into 5
This commit is contained in:
commit
98951c088e
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace SilverStripe\Dev;
|
namespace SilverStripe\Dev;
|
||||||
|
|
||||||
|
use SilverStripe\Control\HTTPResponse_Exception;
|
||||||
use SilverStripe\Core\Environment;
|
use SilverStripe\Core\Environment;
|
||||||
use SilverStripe\ORM\DataObject;
|
use SilverStripe\ORM\DataObject;
|
||||||
use SilverStripe\View\ViewableData;
|
use SilverStripe\View\ViewableData;
|
||||||
@ -17,6 +18,7 @@ use SilverStripe\View\ViewableData;
|
|||||||
*/
|
*/
|
||||||
abstract class BulkLoader extends ViewableData
|
abstract class BulkLoader extends ViewableData
|
||||||
{
|
{
|
||||||
|
private bool $checkPermissions = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each row in the imported dataset should map to one instance
|
* Each row in the imported dataset should map to one instance
|
||||||
@ -131,6 +133,23 @@ abstract class BulkLoader extends ViewableData
|
|||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, this bulk loader will respect create/edit/delete permissions.
|
||||||
|
*/
|
||||||
|
public function getCheckPermissions(): bool
|
||||||
|
{
|
||||||
|
return $this->checkPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether this bulk loader should respect create/edit/delete permissions.
|
||||||
|
*/
|
||||||
|
public function setCheckPermissions(bool $value): self
|
||||||
|
{
|
||||||
|
$this->checkPermissions = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Load the given file via {@link self::processAll()} and {@link self::processRecord()}.
|
* Load the given file via {@link self::processAll()} and {@link self::processRecord()}.
|
||||||
* Optionally truncates (clear) the table before it imports.
|
* Optionally truncates (clear) the table before it imports.
|
||||||
@ -144,6 +163,21 @@ abstract class BulkLoader extends ViewableData
|
|||||||
|
|
||||||
//get all instances of the to be imported data object
|
//get all instances of the to be imported data object
|
||||||
if ($this->deleteExistingRecords) {
|
if ($this->deleteExistingRecords) {
|
||||||
|
if ($this->getCheckPermissions()) {
|
||||||
|
// We need to check each record, in case there's some fancy conditional logic in the canDelete method.
|
||||||
|
// If we can't delete even a single record, we should bail because otherwise the result would not be
|
||||||
|
// what the user expects.
|
||||||
|
/** @var DataObject $record */
|
||||||
|
foreach (DataObject::get($this->objectClass) as $record) {
|
||||||
|
if (!$record->canDelete()) {
|
||||||
|
$type = $record->i18n_singular_name();
|
||||||
|
throw new HTTPResponse_Exception(
|
||||||
|
_t(__CLASS__ . '.CANNOT_DELETE', "Not allowed to delete '$type' records"),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
DataObject::get($this->objectClass)->removeAll();
|
DataObject::get($this->objectClass)->removeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ namespace SilverStripe\Dev;
|
|||||||
use League\Csv\MapIterator;
|
use League\Csv\MapIterator;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
use SilverStripe\Control\Director;
|
use SilverStripe\Control\Director;
|
||||||
|
use SilverStripe\Control\HTTPResponse_Exception;
|
||||||
use SilverStripe\ORM\DataObject;
|
use SilverStripe\ORM\DataObject;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,6 +41,10 @@ class CsvBulkLoader extends BulkLoader
|
|||||||
*/
|
*/
|
||||||
public $hasHeaderRow = true;
|
public $hasHeaderRow = true;
|
||||||
|
|
||||||
|
public $duplicateChecks = [
|
||||||
|
'ID' => 'ID',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of lines to split large CSV files into.
|
* Number of lines to split large CSV files into.
|
||||||
*
|
*
|
||||||
@ -126,6 +131,10 @@ class CsvBulkLoader extends BulkLoader
|
|||||||
$this->processRecord($row, $this->columnMap, $result, $preview);
|
$this->processRecord($row, $this->columnMap, $result, $preview);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
if ($e instanceof HTTPResponse_Exception) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
$failedMessage = sprintf("Failed to parse %s", $filepath);
|
$failedMessage = sprintf("Failed to parse %s", $filepath);
|
||||||
if (Director::isDev()) {
|
if (Director::isDev()) {
|
||||||
$failedMessage = sprintf($failedMessage . " because %s", $e->getMessage());
|
$failedMessage = sprintf($failedMessage . " because %s", $e->getMessage());
|
||||||
@ -170,8 +179,29 @@ class CsvBulkLoader extends BulkLoader
|
|||||||
|
|
||||||
// find existing object, or create new one
|
// find existing object, or create new one
|
||||||
$existingObj = $this->findExistingObject($record, $columnMap);
|
$existingObj = $this->findExistingObject($record, $columnMap);
|
||||||
|
$alreadyExists = (bool) $existingObj;
|
||||||
|
|
||||||
|
// If we can't edit the existing object, bail early.
|
||||||
|
if ($this->getCheckPermissions() && !$preview && $alreadyExists && !$existingObj->canEdit()) {
|
||||||
|
$type = $existingObj->i18n_singular_name();
|
||||||
|
throw new HTTPResponse_Exception(
|
||||||
|
_t(BulkLoader::class . '.CANNOT_EDIT', "Not allowed to edit '$type' records"),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @var DataObject $obj */
|
/** @var DataObject $obj */
|
||||||
$obj = ($existingObj) ? $existingObj : new $class();
|
$obj = $alreadyExists ? $existingObj : new $class();
|
||||||
|
|
||||||
|
// If we can't create a new record, bail out early.
|
||||||
|
if ($this->getCheckPermissions() && !$preview && !$alreadyExists && !$obj->canCreate()) {
|
||||||
|
$type = $obj->i18n_singular_name();
|
||||||
|
throw new HTTPResponse_Exception(
|
||||||
|
_t(BulkLoader::class . '.CANNOT_CREATE', "Not allowed to create '$type' records"),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$schema = DataObject::getSchema();
|
$schema = DataObject::getSchema();
|
||||||
|
|
||||||
// first run: find/create any relations and store them on the object
|
// first run: find/create any relations and store them on the object
|
||||||
@ -196,9 +226,17 @@ class CsvBulkLoader extends BulkLoader
|
|||||||
}
|
}
|
||||||
if (!$relationObj || !$relationObj->exists()) {
|
if (!$relationObj || !$relationObj->exists()) {
|
||||||
$relationClass = $schema->hasOneComponent(get_class($obj), $relationName);
|
$relationClass = $schema->hasOneComponent(get_class($obj), $relationName);
|
||||||
|
/** @var DataObject $relationObj */
|
||||||
$relationObj = new $relationClass();
|
$relationObj = new $relationClass();
|
||||||
//write if we aren't previewing
|
//write if we aren't previewing
|
||||||
if (!$preview) {
|
if (!$preview) {
|
||||||
|
if ($this->getCheckPermissions() && !$relationObj->canCreate()) {
|
||||||
|
$type = $relationObj->i18n_singular_name();
|
||||||
|
throw new HTTPResponse_Exception(
|
||||||
|
_t(BulkLoader::class . '.CANNOT_CREATE', "Not allowed to create '$type' records"),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
$relationObj->write();
|
$relationObj->write();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -214,6 +252,13 @@ class CsvBulkLoader extends BulkLoader
|
|||||||
// always gives us an component (either empty or existing)
|
// always gives us an component (either empty or existing)
|
||||||
$relationObj = $obj->getComponent($relationName);
|
$relationObj = $obj->getComponent($relationName);
|
||||||
if (!$preview) {
|
if (!$preview) {
|
||||||
|
if ($this->getCheckPermissions() && !$relationObj->canEdit()) {
|
||||||
|
$type = $relationObj->i18n_singular_name();
|
||||||
|
throw new HTTPResponse_Exception(
|
||||||
|
_t(BulkLoader::class . '.CANNOT_EDIT', "Not allowed to edit '$type' records"),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
$relationObj->write();
|
$relationObj->write();
|
||||||
}
|
}
|
||||||
$obj->{"{$relationName}ID"} = $relationObj->ID;
|
$obj->{"{$relationName}ID"} = $relationObj->ID;
|
||||||
|
@ -16,6 +16,7 @@ use SilverStripe\ORM\Filters\SearchFilter;
|
|||||||
use SilverStripe\View\ArrayData;
|
use SilverStripe\View\ArrayData;
|
||||||
use SilverStripe\View\SSViewer;
|
use SilverStripe\View\SSViewer;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
|
use SilverStripe\Control\HTTPResponse_Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is is responsible for adding objects to another object's has_many
|
* This class is is responsible for adding objects to another object's has_many
|
||||||
@ -207,11 +208,14 @@ class GridFieldAddExistingAutocompleter extends AbstractGridFieldComponent imple
|
|||||||
if (empty($objectID)) {
|
if (empty($objectID)) {
|
||||||
return $dataList;
|
return $dataList;
|
||||||
}
|
}
|
||||||
|
$gridField->State->GridFieldAddRelation = null;
|
||||||
$object = DataObject::get_by_id($dataClass, $objectID);
|
$object = DataObject::get_by_id($dataClass, $objectID);
|
||||||
if ($object) {
|
if ($object) {
|
||||||
|
if (!$object->canView()) {
|
||||||
|
throw new HTTPResponse_Exception(null, 403);
|
||||||
|
}
|
||||||
$dataList->add($object);
|
$dataList->add($object);
|
||||||
}
|
}
|
||||||
$gridField->State->GridFieldAddRelation = null;
|
|
||||||
return $dataList;
|
return $dataList;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -281,6 +285,9 @@ class GridFieldAddExistingAutocompleter extends AbstractGridFieldComponent imple
|
|||||||
SSViewer::config()->set('source_file_comments', false);
|
SSViewer::config()->set('source_file_comments', false);
|
||||||
$viewer = SSViewer::fromString($this->resultsFormat);
|
$viewer = SSViewer::fromString($this->resultsFormat);
|
||||||
foreach ($results as $result) {
|
foreach ($results as $result) {
|
||||||
|
if (!$result->canView()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$title = Convert::html2raw($viewer->process($result));
|
$title = Convert::html2raw($viewer->process($result));
|
||||||
$json[] = [
|
$json[] = [
|
||||||
'label' => $title,
|
'label' => $title,
|
||||||
|
@ -9,6 +9,7 @@ class GroupCsvBulkLoader extends CsvBulkLoader
|
|||||||
{
|
{
|
||||||
|
|
||||||
public $duplicateChecks = [
|
public $duplicateChecks = [
|
||||||
|
'ID' => 'ID',
|
||||||
'Code' => 'Code',
|
'Code' => 'Code',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ class MemberCsvBulkLoader extends CsvBulkLoader
|
|||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
public $duplicateChecks = [
|
public $duplicateChecks = [
|
||||||
|
'ID' => 'ID',
|
||||||
'Email' => 'Email',
|
'Email' => 'Email',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
namespace SilverStripe\Dev\Tests;
|
namespace SilverStripe\Dev\Tests;
|
||||||
|
|
||||||
use League\Csv\Writer;
|
use League\Csv\Writer;
|
||||||
|
use SilverStripe\Control\HTTPResponse_Exception;
|
||||||
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\CustomLoader;
|
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\CustomLoader;
|
||||||
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\Player;
|
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\Player;
|
||||||
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\PlayerContract;
|
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\PlayerContract;
|
||||||
@ -12,6 +13,10 @@ use SilverStripe\ORM\FieldType\DBField;
|
|||||||
use SilverStripe\Core\Config\Config;
|
use SilverStripe\Core\Config\Config;
|
||||||
use SilverStripe\Dev\CsvBulkLoader;
|
use SilverStripe\Dev\CsvBulkLoader;
|
||||||
use SilverStripe\Dev\SapphireTest;
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\CanModifyModel;
|
||||||
|
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\CantCreateModel;
|
||||||
|
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\CantDeleteModel;
|
||||||
|
use SilverStripe\Dev\Tests\CsvBulkLoaderTest\CantEditModel;
|
||||||
|
|
||||||
class CsvBulkLoaderTest extends SapphireTest
|
class CsvBulkLoaderTest extends SapphireTest
|
||||||
{
|
{
|
||||||
@ -22,6 +27,10 @@ class CsvBulkLoaderTest extends SapphireTest
|
|||||||
Team::class,
|
Team::class,
|
||||||
Player::class,
|
Player::class,
|
||||||
PlayerContract::class,
|
PlayerContract::class,
|
||||||
|
CanModifyModel::class,
|
||||||
|
CantCreateModel::class,
|
||||||
|
CantEditModel::class,
|
||||||
|
CantDeleteModel::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -335,4 +344,65 @@ class CsvBulkLoaderTest extends SapphireTest
|
|||||||
|
|
||||||
$this->assertCount(10, $results);
|
$this->assertCount(10, $results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideCheckPermissions
|
||||||
|
*/
|
||||||
|
public function testCheckPermissions(string $class, string $file, bool $respectPerms, string $exceptionMessage)
|
||||||
|
{
|
||||||
|
$loader = new CsvBulkLoader($class);
|
||||||
|
$loader->setCheckPermissions($respectPerms);
|
||||||
|
// Don't delete CantEditModel records, 'cause we need to explicitly edit them
|
||||||
|
$loader->deleteExistingRecords = $class !== CantEditModel::class;
|
||||||
|
// We can't rely on IDs in unit tests, so use Title as the unique field
|
||||||
|
$loader->duplicateChecks['Title'] = 'Title';
|
||||||
|
|
||||||
|
if ($exceptionMessage) {
|
||||||
|
$this->expectException(HTTPResponse_Exception::class);
|
||||||
|
$this->expectExceptionMessage($exceptionMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $loader->load($this->csvPath . $file);
|
||||||
|
|
||||||
|
// If there's no permission exception, we should get some valid results.
|
||||||
|
if (!$exceptionMessage) {
|
||||||
|
$this->assertCount(3, $results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideCheckPermissions()
|
||||||
|
{
|
||||||
|
$scenarios = [
|
||||||
|
'Has all permissions' => [
|
||||||
|
'class' => CanModifyModel::class,
|
||||||
|
'file' => 'PermissionCheck.csv',
|
||||||
|
'respectPerms' => true,
|
||||||
|
'exceptionMessage' => '',
|
||||||
|
],
|
||||||
|
'No create permissions' => [
|
||||||
|
'class' => CantCreateModel::class,
|
||||||
|
'file' => 'PermissionCheck.csv',
|
||||||
|
'respectPerms' => true,
|
||||||
|
'exceptionMessage' => "Not allowed to create 'Cant Create Model' records",
|
||||||
|
],
|
||||||
|
'No edit permissions' => [
|
||||||
|
'class' => CantEditModel::class,
|
||||||
|
'file' => 'PermissionCheck.csv',
|
||||||
|
'respectPerms' => true,
|
||||||
|
'exceptionMessage' => "Not allowed to edit 'Cant Edit Model' records",
|
||||||
|
],
|
||||||
|
'No delete permissions' => [
|
||||||
|
'class' => CantDeleteModel::class,
|
||||||
|
'file' => 'PermissionCheck.csv',
|
||||||
|
'respectPerms' => true,
|
||||||
|
'exceptionMessage' => "Not allowed to delete 'Cant Delete Model' records",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
foreach ($scenarios as $name => $scenario) {
|
||||||
|
$scenario['respectPerms'] = false;
|
||||||
|
$scenario['exceptionMessage'] = '';
|
||||||
|
$scenarios[$name . ' but no perm checks'] = $scenario;
|
||||||
|
}
|
||||||
|
return $scenarios;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,19 @@
|
|||||||
SilverStripe\Dev\Tests\CsvBulkLoaderTest\Team:
|
SilverStripe\Dev\Tests\CsvBulkLoaderTest\Team:
|
||||||
team1:
|
team1:
|
||||||
Title: My Team
|
Title: My Team
|
||||||
|
|
||||||
|
SilverStripe\Dev\Tests\CsvBulkLoaderTest\CantDeleteModel:
|
||||||
|
model1:
|
||||||
|
Title: Record 1
|
||||||
|
model2:
|
||||||
|
Title: Record 2
|
||||||
|
model3:
|
||||||
|
Title: Record 3
|
||||||
|
|
||||||
|
SilverStripe\Dev\Tests\CsvBulkLoaderTest\CantEditModel:
|
||||||
|
model1:
|
||||||
|
Title: Record 1
|
||||||
|
model2:
|
||||||
|
Title: Record 2
|
||||||
|
model3:
|
||||||
|
Title: Record 3
|
||||||
|
31
tests/php/Dev/CsvBulkLoaderTest/CanModifyModel.php
Normal file
31
tests/php/Dev/CsvBulkLoaderTest/CanModifyModel.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Tests\CsvBulkLoaderTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
|
||||||
|
class CanModifyModel extends DataObject implements TestOnly
|
||||||
|
{
|
||||||
|
private static $table_name = 'CsvBulkLoaderTest_CanModifyModel';
|
||||||
|
|
||||||
|
private static array $db = [
|
||||||
|
'Title' => 'Varchar',
|
||||||
|
'AnotherField' => 'Varchar',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function canCreate($member = null, $context = [])
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canEdit($member = null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canDelete($member = null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
13
tests/php/Dev/CsvBulkLoaderTest/CantCreateModel.php
Normal file
13
tests/php/Dev/CsvBulkLoaderTest/CantCreateModel.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Tests\CsvBulkLoaderTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
|
||||||
|
class CantCreateModel extends CanModifyModel implements TestOnly
|
||||||
|
{
|
||||||
|
public function canCreate($member = null, $context = [])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
31
tests/php/Dev/CsvBulkLoaderTest/CantDeleteModel.php
Normal file
31
tests/php/Dev/CsvBulkLoaderTest/CantDeleteModel.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Tests\CsvBulkLoaderTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
|
||||||
|
class CantDeleteModel extends DataObject implements TestOnly
|
||||||
|
{
|
||||||
|
private static $table_name = 'CsvBulkLoaderTest_CantDeleteModel';
|
||||||
|
|
||||||
|
private static array $db = [
|
||||||
|
'Title' => 'Varchar',
|
||||||
|
'AnotherField' => 'Varchar',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function canCreate($member = null, $context = [])
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canEdit($member = null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canDelete($member = null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
13
tests/php/Dev/CsvBulkLoaderTest/CantEditModel.php
Normal file
13
tests/php/Dev/CsvBulkLoaderTest/CantEditModel.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Tests\CsvBulkLoaderTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
|
||||||
|
class CantEditModel extends CanModifyModel implements TestOnly
|
||||||
|
{
|
||||||
|
public function canEdit($member = null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
4
tests/php/Dev/CsvBulkLoaderTest/csv/PermissionCheck.csv
Normal file
4
tests/php/Dev/CsvBulkLoaderTest/csv/PermissionCheck.csv
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Title, AnotherField
|
||||||
|
Record 1, value 1
|
||||||
|
Record 2, value 2
|
||||||
|
Record 3, value 3
|
|
Loading…
Reference in New Issue
Block a user