icingadb-web/library/Icingadb/Web/Controller.php

838 lines
31 KiB
PHP

<?php
/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Web;
use Exception;
use Generator;
use GuzzleHttp\Psr7\ServerRequest;
use Icinga\Application\Config;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Data\ConfigObject;
use Icinga\Date\DateFormatter;
use Icinga\Exception\ConfigurationError;
use Icinga\Exception\Json\JsonDecodeException;
use Icinga\Module\Icingadb\Common\Auth;
use Icinga\Module\Icingadb\Common\Database;
use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
use Icinga\Module\Icingadb\Common\BaseItemList;
use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
use Icinga\Module\Pdfexport\PrintableHtmlDocument;
use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport;
use Icinga\Security\SecurityException;
use Icinga\User\Preferences;
use Icinga\User\Preferences\PreferencesStore;
use Icinga\Util\Environment;
use Icinga\Util\Json;
use ipl\Html\Html;
use ipl\Html\ValidHtml;
use ipl\Orm\Common\SortUtil;
use ipl\Orm\Exception\InvalidRelationException;
use ipl\Orm\Query;
use ipl\Orm\UnionQuery;
use ipl\Stdlib\Contract\Paginatable;
use ipl\Stdlib\Filter;
use ipl\Stdlib\Seq;
use ipl\Web\Compat\CompatController;
use ipl\Web\Control\LimitControl;
use ipl\Web\Control\PaginationControl;
use ipl\Web\Control\SearchBar;
use ipl\Web\Control\SearchEditor;
use ipl\Web\Control\SortControl;
use ipl\Web\Filter\QueryString;
use ipl\Web\Url;
use ipl\Web\Widget\ContinueWith;
class Controller extends CompatController
{
use Auth;
use Database;
/** @var Filter\Rule Filter from query string parameters */
private $filter;
/** @var string|null */
private $format;
/** @var bool */
private $formatProcessed = false;
/**
* Get the filter created from query string parameters
*
* @return Filter\Rule
*/
public function getFilter(): Filter\Rule
{
if ($this->filter === null) {
$this->filter = QueryString::parse((string) $this->params);
}
return $this->filter;
}
/**
* Create and return the LimitControl
*
* This automatically shifts the limit URL parameter from {@link $params}.
*
* @return LimitControl
*/
public function createLimitControl(): LimitControl
{
$limitControl = new LimitControl(Url::fromRequest());
$limitControl->setDefaultLimit($this->getPageSize(null));
$this->params->shift($limitControl->getLimitParam());
return $limitControl;
}
/**
* Create and return the PaginationControl
*
* This automatically shifts the pagination URL parameters from {@link $params}.
*
* @return PaginationControl
*/
public function createPaginationControl(Paginatable $paginatable): PaginationControl
{
$paginationControl = new PaginationControl($paginatable, Url::fromRequest());
$paginationControl->setDefaultPageSize($this->getPageSize(null));
$paginationControl->setAttribute('id', $this->getRequest()->protectId('pagination-control'));
$this->params->shift($paginationControl->getPageParam());
$this->params->shift($paginationControl->getPageSizeParam());
return $paginationControl->apply();
}
/**
* Create and return the SortControl
*
* This automatically shifts the sort URL parameter from {@link $params}.
*
* @param Query $query
* @param array $columns Possible sort columns as sort string-label pairs
*
* @return SortControl
*/
public function createSortControl(Query $query, array $columns): SortControl
{
$default = (array) $query->getModel()->getDefaultSort();
$normalized = [];
foreach ($columns as $key => $value) {
$normalized[SortUtil::normalizeSortSpec($key)] = $value;
}
$sortControl = (new SortControl(Url::fromRequest()))
->setColumns($normalized);
if (! empty($default)) {
$sortControl->setDefault(SortUtil::normalizeSortSpec($default));
}
$sort = $sortControl->getSort();
if (! empty($sort)) {
$query->orderBy(SortUtil::createOrderBy($sort));
}
$this->params->shift($sortControl->getSortParam());
return $sortControl;
}
/**
* Create and return the SearchBar
*
* @param Query $query The query being filtered
* @param array $preserveParams Query params to preserve when redirecting
*
* @return SearchBar
*/
public function createSearchBar(Query $query, array $preserveParams = null): SearchBar
{
$requestUrl = Url::fromRequest();
$redirectUrl = $preserveParams !== null
? $requestUrl->onlyWith($preserveParams)
: (clone $requestUrl)->setParams([]);
$filter = QueryString::fromString((string) $this->params)
->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) {
$this->enrichFilterCondition($condition, $query);
})
->parse();
$searchBar = new SearchBar();
$searchBar->setFilter($filter);
$searchBar->setAction($requestUrl->getAbsoluteUrl());
$searchBar->setIdProtector([$this->getRequest(), 'protectId']);
if (method_exists($this, 'completeAction')) {
$searchBar->setSuggestionUrl(Url::fromPath(
'icingadb/' . $this->getRequest()->getControllerName() . '/complete',
['_disableLayout' => true, 'showCompact' => true]
));
}
if (method_exists($this, 'searchEditorAction')) {
$searchBar->setEditorUrl(Url::fromPath(
'icingadb/' . $this->getRequest()->getControllerName() . '/search-editor'
)->setParams($redirectUrl->getParams()));
}
$metaData = iterator_to_array(
ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver())
);
$columnValidator = function (SearchBar\ValidatedColumn $column) use ($query, $metaData) {
$columnPath = $column->getSearchValue();
if (($pos = strpos($columnPath, '.vars.')) !== false) {
try {
$relationPath = $query->getResolver()->qualifyPath(
substr($columnPath, 0, $pos + 5),
$query->getModel()->getTableName()
);
$query->getResolver()->resolveRelation($relationPath);
} catch (InvalidRelationException $e) {
$column->setMessage(sprintf(
t('"%s" is not a valid relation'),
substr($e->getRelation(), 0, $pos)
));
}
} else {
if (strpos($columnPath, '.') === false) {
$columnPath = $query->getResolver()->qualifyPath($columnPath, $query->getModel()->getTableName());
}
if (! isset($metaData[$columnPath])) {
list($columnPath, $columnLabel) = Seq::find($metaData, $column->getSearchValue(), false);
if ($columnPath === null) {
$column->setMessage(t('Is not a valid column'));
} else {
$column->setSearchValue($columnPath);
$column->setLabel($columnLabel);
}
} else {
$column->setLabel($metaData[$columnPath]);
}
}
};
$searchBar->on(SearchBar::ON_ADD, $columnValidator)
->on(SearchBar::ON_INSERT, $columnValidator)
->on(SearchBar::ON_SAVE, $columnValidator)
->on(SearchBar::ON_SENT, function (SearchBar $form) use ($redirectUrl) {
$existingParams = $redirectUrl->getParams();
$redirectUrl->setQueryString(QueryString::render($form->getFilter()));
foreach ($existingParams->toArray(false) as $name => $value) {
if (is_int($name)) {
$name = $value;
$value = true;
}
$redirectUrl->getParams()->addEncoded($name, $value);
}
$form->setRedirectUrl($redirectUrl);
})->on(SearchBar::ON_SUCCESS, function (SearchBar $form) {
$this->getResponse()->redirectAndExit($form->getRedirectUrl());
})->handleRequest(ServerRequest::fromGlobals());
Html::tag('div', ['class' => 'filter'])->wrap($searchBar);
return $searchBar;
}
/**
* Create and return the SearchEditor
*
* @param Query $query The query being filtered
* @param array $preserveParams Query params to preserve when redirecting
*
* @return SearchEditor
*/
public function createSearchEditor(Query $query, array $preserveParams = null): SearchEditor
{
$requestUrl = Url::fromRequest();
$redirectUrl = Url::fromPath('icingadb/' . $this->getRequest()->getControllerName());
if (! empty($preserveParams)) {
$redirectUrl->setParams($requestUrl->onlyWith($preserveParams)->getParams());
}
$editor = new SearchEditor();
$editor->setQueryString((string) $this->params->without($preserveParams));
$editor->setAction($requestUrl->getAbsoluteUrl());
if (method_exists($this, 'completeAction')) {
$editor->setSuggestionUrl(Url::fromPath(
'icingadb/' . $this->getRequest()->getControllerName() . '/complete',
['_disableLayout' => true, 'showCompact' => true]
));
}
$editor->getParser()->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) {
if ($condition->getColumn()) {
$this->enrichFilterCondition($condition, $query);
}
});
$metaData = iterator_to_array(
ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver())
);
$editor->on(SearchEditor::ON_VALIDATE_COLUMN, function (Filter\Condition $condition) use ($query, $metaData) {
$column = $condition->getColumn();
if (($pos = strpos($column, '.vars.')) !== false) {
try {
$query->getResolver()->resolveRelation(substr($column, 0, $pos + 5));
} catch (InvalidRelationException $e) {
throw new SearchBar\SearchException(sprintf(
t('"%s" is not a valid relation'),
substr($e->getRelation(), 0, $pos)
));
}
} else {
if (! isset($metaData[$column])) {
$path = Seq::findKey($metaData, $condition->metaData()->get('columnLabel', $column), false);
if ($path === null) {
throw new SearchBar\SearchException(t('Is not a valid column'));
} else {
$condition->setColumn($path);
}
}
}
})->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) use ($redirectUrl) {
$existingParams = $redirectUrl->getParams();
$redirectUrl->setQueryString(QueryString::render($form->getFilter()));
foreach ($existingParams->toArray(false) as $name => $value) {
if (is_int($name)) {
$name = $value;
$value = true;
}
$redirectUrl->getParams()->addEncoded($name, $value);
}
$this->getResponse()
->setHeader('X-Icinga-Container', '_self')
->redirectAndExit($redirectUrl);
})->handleRequest(ServerRequest::fromGlobals());
return $editor;
}
/**
* Create and return a ContinueWith
*
* This will automatically be appended to the SearchBar's wrapper. It's not necessary
* to add it separately as control or content!
*
* @param Url $detailsUrl
* @param SearchBar $searchBar
*
* @return ContinueWith
*/
public function createContinueWith(Url $detailsUrl, SearchBar $searchBar): ContinueWith
{
$continueWith = new ContinueWith($detailsUrl, [$searchBar, 'getFilter']);
$continueWith->setTitle(t('Show bulk processing actions for all filtered results'));
$continueWith->setBaseTarget('_next');
$continueWith->getAttributes()
->set('id', $this->getRequest()->protectId('continue-with'));
$searchBar->getWrapper()->add($continueWith);
return $continueWith;
}
/**
* Create and return the ViewModeSwitcher
*
* This automatically shifts the view mode URL parameter from {@link $params}.
*
* @param PaginationControl $paginationControl
* @param LimitControl $limitControl
* @param bool $verticalPagination
*
* @return ViewModeSwitcher
*/
public function createViewModeSwitcher(
PaginationControl $paginationControl,
LimitControl $limitControl,
bool $verticalPagination = false
): ViewModeSwitcher {
$viewModeSwitcher = new ViewModeSwitcher();
$viewModeSwitcher->setIdProtector([$this->getRequest(), 'protectId']);
$user = $this->Auth()->getUser();
if (($preferredModes = $user->getAdditional('icingadb.view_modes')) === null) {
try {
$preferredModes = Json::decode(
$user->getPreferences()->getValue('icingadb', 'view_modes', '[]'),
true
);
} catch (JsonDecodeException $e) {
Logger::error('Failed to load preferred view modes for user "%s": %s', $user->getUsername(), $e);
$preferredModes = [];
}
$user->setAdditional('icingadb.view_modes', $preferredModes);
}
$requestRoute = $this->getRequest()->getUrl()->getPath();
if (isset($preferredModes[$requestRoute])) {
$viewModeSwitcher->setDefaultViewMode($preferredModes[$requestRoute]);
}
$viewModeSwitcher->populate([
$viewModeSwitcher->getViewModeParam() => $this->params->shift($viewModeSwitcher->getViewModeParam())
]);
$session = $this->Window()->getSessionNamespace(
'icingadb-viewmode-' . $this->Window()->getContainerId()
);
$viewModeSwitcher->on(
ViewModeSwitcher::ON_SUCCESS,
function (ViewModeSwitcher $viewModeSwitcher) use (
$user,
$preferredModes,
$paginationControl,
$verticalPagination,
&$session
) {
$viewMode = $viewModeSwitcher->getValue($viewModeSwitcher->getViewModeParam());
$requestUrl = Url::fromRequest();
$preferredModes[$requestUrl->getPath()] = $viewMode;
$user->setAdditional('icingadb.view_modes', $preferredModes);
try {
$preferencesStore = PreferencesStore::create(new ConfigObject([
'store' => Config::app()->get('global', 'config_backend', 'db'),
'resource' => Config::app()->get('global', 'config_resource')
]), $user);
$preferencesStore->load();
$preferencesStore->save(
new Preferences(['icingadb' => ['view_modes' => Json::encode($preferredModes)]])
);
} catch (Exception $e) {
Logger::error('Failed to save preferred view mode for user "%s": %s', $user->getUsername(), $e);
}
$pageParam = $paginationControl->getPageParam();
$limitParam = LimitControl::DEFAULT_LIMIT_PARAM;
$currentPage = $paginationControl->getCurrentPageNumber();
$requestUrl->setParam($viewModeSwitcher->getViewModeParam(), $viewMode);
if (! $requestUrl->hasParam($limitParam)) {
if ($viewMode === 'minimal') {
$session->set('previous_page', $currentPage);
$session->set('request_path', $requestUrl->getPath());
$limit = $paginationControl->getLimit();
if (! $verticalPagination) {
// We are computing it based on the first element being rendered on this current page
$currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit * 2)) + 1);
} else {
$currentPage = (int) (round($currentPage * $limit / ($limit * 2)));
}
$session->set('current_page', $currentPage);
} elseif ($viewModeSwitcher->getDefaultViewMode() === 'minimal') {
$limit = $paginationControl->getLimit();
if ($currentPage === $session->get('current_page')) {
// No other page numbers have been selected, i.e the user only
// switches back and forth without changing the page numbers
$currentPage = $session->get('previous_page');
} elseif (! $verticalPagination) {
$currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit / 2)) + 1);
} else {
$currentPage = (int) (floor($currentPage * $limit / ($limit / 2)));
}
$session->clear();
}
if (($requestUrl->hasParam($pageParam) && $currentPage > 1) || $currentPage > 1) {
$requestUrl->setParam($pageParam, $currentPage);
} else {
$requestUrl->remove($pageParam);
}
}
$this->redirectNow($requestUrl);
}
)->handleRequest(ServerRequest::fromGlobals());
if ($viewModeSwitcher->getViewMode() === 'minimal') {
$hasLimitParam = Url::fromRequest()->hasParam($limitControl->getLimitParam());
if ($paginationControl->getDefaultPageSize() <= LimitControl::DEFAULT_LIMIT && ! $hasLimitParam) {
$paginationControl->setDefaultPageSize($paginationControl->getDefaultPageSize() * 2);
$limitControl->setDefaultLimit($limitControl->getDefaultLimit() * 2);
$paginationControl->apply();
}
}
$requestPath = $session->get('request_path');
if ($requestPath && $requestPath !== $requestRoute) {
$session->clear();
}
return $viewModeSwitcher;
}
/**
* Process a search request
*
* @param Query $query
*
* @return void
*/
public function handleSearchRequest(Query $query)
{
$q = trim($this->params->shift('q', ''), ' *');
if (! $q) {
return;
}
$filter = Filter::any();
foreach ($query->getModel()->getSearchColumns() as $column) {
$filter->add(Filter::equal($column, "*$q*"));
}
$requestUrl = Url::fromRequest();
$existingParams = $requestUrl->getParams()->without('q');
$requestUrl->setQueryString(QueryString::render($filter));
foreach ($existingParams->toArray(false) as $name => $value) {
$requestUrl->getParams()->addEncoded($name, $value);
}
$this->getResponse()->redirectAndExit($requestUrl);
}
/**
* Require permission to access the given route
*
* @param string $name If NULL, the current controller name is used
*
* @throws SecurityException
*/
public function assertRouteAccess(string $name = null)
{
if (! $name) {
$name = $this->getRequest()->getControllerName();
}
if (! $this->isPermittedRoute($name)) {
throw new SecurityException('No permission to access this route');
}
}
public function export(Query ...$queries)
{
if ($this->format === 'sql') {
foreach ($queries as $query) {
list($sql, $values) = $query->getDb()->getQueryBuilder()->assembleSelect($query->assembleSelect());
$unused = [];
foreach ($values as $value) {
$pos = strpos($sql, '?');
if ($pos !== false) {
if (is_string($value)) {
$value = "'" . $value . "'";
}
$sql = substr_replace($sql, $value, $pos, 1);
} else {
$unused[] = $value;
}
}
if (!empty($unused)) {
$sql .= ' /* Unused values: "' . join('", "', $unused) . '" */';
}
$this->content->add(Html::tag('pre', $sql));
}
return true;
}
if ($this->format === 'json' || $this->format === 'csv') {
$isJsonFormat = $this->format === 'json';
$response = $this->getResponse();
$fileName = $this->view->title;
if ($isJsonFormat) {
$response
->setHeader('Content-Type', 'application/json')
->setHeader('Cache-Control', 'no-store')
->setHeader(
'Content-Disposition',
'attachment; filename=' . $fileName . '.json'
)
->sendResponse();
} else {
$response
->setHeader('Content-Type', 'text/csv')
->setHeader('Cache-Control', 'no-store')
->setHeader(
'Content-Disposition',
'attachment; filename=' . $fileName . '.csv'
)
->sendResponse();
}
ob_end_clean();
Environment::raiseExecutionTime();
$query = $queries[0];
$query->limit(Url::fromRequest()->getParam('limit'));
$col = array_merge((array) $query->getModel()->getKeyName(), $query->getModel()->getColumns());
$tableName = $query->getModel()->getTableName();
foreach ($query->getWith() as $relationPath => $relation) {
$relatedCols = $relation->getTarget()->getColumns();
foreach ($relatedCols as $alias => $name) {
if (is_int($alias)) {
$alias = $name;
}
$col[] = $relationPath . '.' . $alias;
}
}
if ($isJsonFormat) {
echo '[';
}
$rs = $query->execute()->disableCache();
foreach ($rs as $i => $row) {
$result = [];
if ($i > 0) {
$separator = $isJsonFormat ? ',' : "\r\n";
echo $separator;
}
foreach ($col as $alias => $name) {
if (is_int($alias)) {
$alias = $name;
}
if (strpos($alias, '.') !== false) {
$properties = array_slice(explode('.', $alias), 1);
$alias = implode('.', $properties);
$val = $row;
do {
$column = array_shift($properties);
$val = $val->$column;
} while (! empty($properties) && $val !== null);
} else {
$val = $row->$alias;
}
if (
$alias === 'id' || substr($alias, -3) === '_id'
|| substr($alias, -9) === '_checksum' || substr($alias, -4) === '_bin'
) {
$val = base64_encode($val);
}
if ($isJsonFormat) { // Json
$result[$alias] = $val;
} elseif (is_bool($val)) { // CSV
$result[$alias] = $val ? 'true' : 'false';
} elseif (is_string($val)) {
$result[$alias] = '"' . str_replace('"', '""', $val) . '"';
} else {
$result[$alias] = $val;
}
}
if ($i === 0 && ! $isJsonFormat) {
echo implode(',', array_keys($result)) . "\r\n";
}
$data = $isJsonFormat ? Json::sanitize($result) : implode(',', array_values($result));
echo $data;
}
if ($isJsonFormat) {
echo ']';
}
exit;
}
$this->getTabs()->enableDataExports();
}
/**
* @todo Consider making this the default in Icinga Web 2
*/
protected function sendAsPdf()
{
if (! Icinga::app()->getModuleManager()->has('pdfexport')) {
throw new ConfigurationError('The pdfexport module is required for exports to PDF');
}
putenv('ICINGAWEB_EXPORT_FORMAT=pdf');
Environment::raiseMemoryLimit('512M');
Environment::raiseExecutionTime(300);
$time = DateFormatter::formatDateTime(time());
$doc = (new PrintableHtmlDocument())
->setTitle($this->view->title)
->setHeader(Html::wantHtml([
Html::tag('span', ['class' => 'title']),
Html::tag('time', null, $time)
]))
->setFooter(Html::wantHtml([
Html::tag('span', null, [
t('Page') . ' ',
Html::tag('span', ['class' => 'pageNumber']),
' / ',
Html::tag('span', ['class' => 'totalPages'])
]),
Html::tag('p', null, Url::fromRequest()->setParams($this->params))
]))
->addHtml($this->content);
$doc->getAttributes()->add('class', 'icinga-module module-icingadb');
Pdfexport::first()->streamPdfFromHtml($doc, sprintf(
'%s-%s',
$this->view->title ?: $this->getRequest()->getActionName(),
$time
));
}
public function dispatch($action)
{
// Notify helpers of action preDispatch state
$this->_helper->notifyPreDispatch();
$this->preDispatch();
if ($this->getRequest()->isDispatched()) {
// If pre-dispatch hooks introduced a redirect then stop dispatch
// @see ZF-7496
if (! $this->getResponse()->isRedirect()) {
$interceptable = $this->$action();
if ($interceptable instanceof Generator) {
foreach ($interceptable as $stopSignal) {
if ($stopSignal === true) {
$this->formatProcessed = true;
break;
}
}
}
}
$this->postDispatch();
}
// whats actually important here is that this action controller is
// shutting down, regardless of dispatching; notify the helpers of this
// state
$this->_helper->notifyPostDispatch();
}
/**
* Enrich the filter condition with meta data from the query
*
* @param Filter\Condition $condition
* @param Query $query
*
* @return void
*/
protected function enrichFilterCondition(Filter\Condition $condition, Query $query)
{
$path = $condition->getColumn();
if (strpos($path, '.') === false) {
$path = $query->getResolver()->qualifyPath($path, $query->getModel()->getTableName());
$condition->setColumn($path);
}
if (strpos($path, '.vars.') !== false) {
list($target, $varName) = explode('.vars.', $path);
if (strpos($target, '.') === false) {
// Programmatically translated since the full definition is available in class ObjectSuggestions
$condition->metaData()->set(
'columnLabel',
sprintf(t(ucfirst($target) . ' %s', '..<customvar-name>'), $varName)
);
}
} else {
$label = Seq::findValue(
ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver()),
$path
);
if ($label !== null) {
$condition->metaData()->set('columnLabel', $label);
}
}
}
protected function addContent(ValidHtml $content)
{
if ($content instanceof BaseItemList) {
$this->content->getAttributes()->add('class', 'full-width');
}
return parent::addContent($content);
}
public function filter(Query $query, Filter\Rule $filter = null): self
{
if ($this->format !== 'sql' || $this->hasPermission('config/authentication/roles/show')) {
$this->applyRestrictions($query);
}
if ($query instanceof UnionQuery) {
foreach ($query->getUnions() as $query) {
$query->filter($filter ?: $this->getFilter());
}
} else {
$query->filter($filter ?: $this->getFilter());
}
return $this;
}
public function preDispatch()
{
parent::preDispatch();
$this->format = $this->params->shift('format');
}
public function postDispatch()
{
if (! $this->formatProcessed && $this->format !== null && $this->format !== 'pdf') {
// The purpose of this is not only to show that a requested format isn't supported.
// It's main purpose is to not allow to bypass restrictions with `?format=sql` as
// it may be possible that an action applies restrictions, but doesn't support any
// output formats. Since the restrictions are bypassed in method `$this->filter()`
// for the SQL output format and the actual format processing is part of a different
// method (`$this->export()`) which needs to be called explicitly by an action,
// it's otherwise possible for bad individuals to access unrestricted data.
$this->httpBadRequest(t('This route does not support the requested output format'));
}
parent::postDispatch();
}
protected function moduleInit()
{
Icinga::app()->getFrontController()
->getPlugin('Zend_Controller_Plugin_ErrorHandler')
->setErrorHandlerModule('icingadb');
}
}