diff --git a/doc/Addons.md b/doc/Addons.md index 47d16085a..0382cee49 100644 --- a/doc/Addons.md +++ b/doc/Addons.md @@ -533,7 +533,7 @@ Here is a complete list of all hook callbacks with file locations (as of 24-Sep- Hook::callAll("parse_link", $arr); -### mod/manage.php +### src/Module/Delegation.php Hook::callAll('home_init', $ret); diff --git a/doc/de/Addons.md b/doc/de/Addons.md index 35ce0e28b..3cbbb4b0b 100644 --- a/doc/de/Addons.md +++ b/doc/de/Addons.md @@ -256,7 +256,7 @@ Eine komplette Liste aller Hook-Callbacks mit den zugehörigen Dateien (am 01-Ap Hook::callAll("parse_link", $arr); -### mod/manage.php +### src/Module/Delegation.php Hook::callAll('home_init', $ret); diff --git a/mod/manage.php b/mod/manage.php deleted file mode 100644 index a1ca87e96..000000000 --- a/mod/manage.php +++ /dev/null @@ -1,137 +0,0 @@ -user; - - if(!empty($_SESSION['submanage'])) { - $user = DBA::selectFirst('user', [], ['uid' => $_SESSION['submanage']]); - if (DBA::isResult($user)) { - $uid = intval($user['uid']); - $orig_record = $user; - } - } - - $identity = (!empty($_POST['identity']) ? intval($_POST['identity']) : 0); - if (!$identity) { - return; - } - - $limited_id = 0; - $original_id = $uid; - - $manage = DBA::select('manage', ['mid'], ['uid' => $uid]); - while ($m = DBA::fetch($manage)) { - if ($identity == $m['mid']) { - $limited_id = $m['mid']; - break; - } - } - DBA::close($manage); - - if ($limited_id) { - $user = DBA::selectFirst('user', [], ['uid' => $limited_id]); - } else { - // Check if the target user is one of our children - $user = DBA::selectFirst('user', [], ['uid' => $identity, 'parent-uid' => $orig_record['uid']]); - - // Check if the target user is one of our siblings - if (!DBA::isResult($user) && ($orig_record['parent-uid'] != 0)) { - $user = DBA::selectFirst('user', [], ['uid' => $identity, 'parent-uid' => $orig_record['parent-uid']]); - } - - // Check if it's our parent - if (!DBA::isResult($user) && ($orig_record['parent-uid'] != 0) && ($orig_record['parent-uid'] == $identity)) { - $user = DBA::selectFirst('user', [], ['uid' => $identity]); - } - - // Finally check if it's out own user - if (!DBA::isResult($user) && ($orig_record['uid'] != 0) && ($orig_record['uid'] == $identity)) { - $user = DBA::selectFirst('user', [], ['uid' => $identity]); - } - - } - - if (!DBA::isResult($user)) { - return; - } - - Session::clear(); - - Session::setAuthenticatedForUser($a, $user, true, true); - - if ($limited_id) { - $_SESSION['submanage'] = $original_id; - } - - $ret = []; - Hook::callAll('home_init', $ret); - - $a->internalRedirect('profile/' . $a->user['nickname']); - // NOTREACHED -} - -function manage_content(App $a) { - - if (!local_user()) { - notice(L10n::t('Permission denied.') . EOL); - return; - } - - if (!empty($_GET['identity'])) { - $_POST['identity'] = $_GET['identity']; - manage_post($a); - return; - } - - $identities = $a->identities; - - //getting additinal information for each identity - foreach ($identities as $key => $id) { - $thumb = DBA::selectFirst('contact', ['thumb'], ['uid' => $id['uid'] , 'self' => true]); - if (!DBA::isResult($thumb)) { - continue; - } - - $identities[$key]['thumb'] = $thumb['thumb']; - - $identities[$key]['selected'] = ($id['nickname'] === $a->user['nickname']); - - $condition = ["`uid` = ? AND `msg` != '' AND NOT (`type` IN (?, ?)) AND NOT `seen`", $id['uid'], NOTIFY_INTRO, NOTIFY_MAIL]; - $params = ['distinct' => true, 'expression' => 'parent']; - $notifications = DBA::count('notify', $condition, $params); - - $params = ['distinct' => true, 'expression' => 'convid']; - $notifications += DBA::count('mail', ['uid' => $id['uid'], 'seen' => false], $params); - - $notifications += DBA::count('intro', ['blocked' => false, 'ignore' => false, 'uid' => $id['uid']]); - - $identities[$key]['notifications'] = $notifications; - } - - $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('manage.tpl'), [ - '$title' => L10n::t('Manage Identities and/or Pages'), - '$desc' => L10n::t('Toggle between different identities or community/group pages which share your account details or which you have been granted "manage" permissions'), - '$choose' => L10n::t('Select an identity to manage: '), - '$identities' => $identities, - '$submit' => L10n::t('Submit'), - ]); - - return $o; - -} diff --git a/src/App/Module.php b/src/App/Module.php index 9a24c5554..33a9b2fc2 100644 --- a/src/App/Module.php +++ b/src/App/Module.php @@ -7,7 +7,10 @@ use Friendica\BaseObject; use Friendica\Core; use Friendica\LegacyModule; use Friendica\Module\Home; -use Friendica\Module\PageNotFound; +use Friendica\Module\HTTPException\MethodNotAllowed; +use Friendica\Module\HTTPException\PageNotFound; +use Friendica\Network\HTTPException\MethodNotAllowedException; +use Friendica\Network\HTTPException\NotFoundException; use Psr\Log\LoggerInterface; /** @@ -144,38 +147,43 @@ class Module { $printNotAllowedAddon = false; + $module_class = null; /** * ROUTING * * From the request URL, routing consists of obtaining the name of a BaseModule-extending class of which the * post() and/or content() static methods can be respectively called to produce a data change or an output. **/ - $module_class = $router->getModuleClass($args->getCommand()); - - // Then we try addon-provided modules that we wrap in the LegacyModule class - if (!$module_class && Core\Addon::isEnabled($this->module) && file_exists("addon/{$this->module}/{$this->module}.php")) { - //Check if module is an app and if public access to apps is allowed or not - $privateapps = $config->get('config', 'private_addons', false); - if ((!local_user()) && Core\Hook::isAddonApp($this->module) && $privateapps) { - $printNotAllowedAddon = true; - } else { - include_once "addon/{$this->module}/{$this->module}.php"; - if (function_exists($this->module . '_module')) { - LegacyModule::setModuleFile("addon/{$this->module}/{$this->module}.php"); - $module_class = LegacyModule::class; + try { + $module_class = $router->getModuleClass($args->getCommand()); + } catch (MethodNotAllowedException $e) { + $module_class = MethodNotAllowed::class; + } catch (NotFoundException $e) { + // Then we try addon-provided modules that we wrap in the LegacyModule class + if (Core\Addon::isEnabled($this->module) && file_exists("addon/{$this->module}/{$this->module}.php")) { + //Check if module is an app and if public access to apps is allowed or not + $privateapps = $config->get('config', 'private_addons', false); + if ((!local_user()) && Core\Hook::isAddonApp($this->module) && $privateapps) { + $printNotAllowedAddon = true; + } else { + include_once "addon/{$this->module}/{$this->module}.php"; + if (function_exists($this->module . '_module')) { + LegacyModule::setModuleFile("addon/{$this->module}/{$this->module}.php"); + $module_class = LegacyModule::class; + } } } - } - /* Finally, we look for a 'standard' program module in the 'mod' directory - * We emulate a Module class through the LegacyModule class - */ - if (!$module_class && file_exists("mod/{$this->module}.php")) { - LegacyModule::setModuleFile("mod/{$this->module}.php"); - $module_class = LegacyModule::class; - } + /* Finally, we look for a 'standard' program module in the 'mod' directory + * We emulate a Module class through the LegacyModule class + */ + if (!$module_class && file_exists("mod/{$this->module}.php")) { + LegacyModule::setModuleFile("mod/{$this->module}.php"); + $module_class = LegacyModule::class; + } - $module_class = !isset($module_class) ? PageNotFound::class : $module_class; + $module_class = $module_class ?: PageNotFound::class; + } return new Module($this->module, $module_class, $this->isBackend, $printNotAllowedAddon); } diff --git a/src/App/Router.php b/src/App/Router.php index 1bdfdcab1..f723321ac 100644 --- a/src/App/Router.php +++ b/src/App/Router.php @@ -8,7 +8,8 @@ use FastRoute\Dispatcher; use FastRoute\RouteCollector; use FastRoute\RouteParser\Std; use Friendica\Core\Hook; -use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Core\L10n; +use Friendica\Network\HTTPException; /** * Wrapper for FastRoute\Router @@ -57,7 +58,7 @@ class Router * * @return self The router instance with the loaded routes * - * @throws InternalServerErrorException In case of invalid configs + * @throws HTTPException\InternalServerErrorException In case of invalid configs */ public function addRoutes(array $routes) { @@ -71,7 +72,7 @@ class Router } elseif ($this->isRoute($config)) { $routeCollector->addRoute($config[1], $route, $config[0]); } else { - throw new InternalServerErrorException("Wrong route config for route '" . print_r($route, true) . "'"); + throw new HTTPException\InternalServerErrorException("Wrong route config for route '" . print_r($route, true) . "'"); } } @@ -96,7 +97,7 @@ class Router } elseif ($this->isRoute($config)) { $routeCollector->addRoute($config[1], $route, $config[0]); }else { - throw new InternalServerErrorException("Wrong route config for route '" . print_r($route, true) . "'"); + throw new HTTPException\InternalServerErrorException("Wrong route config for route '" . print_r($route, true) . "'"); } } }); @@ -155,7 +156,11 @@ class Router * * @param string $cmd The path component of the request URL without the query string * - * @return string|null A Friendica\BaseModule-extending class name if a route rule matched + * @return string A Friendica\BaseModule-extending class name if a route rule matched + * + * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\MethodNotAllowedException If a rule matched but the method didn't + * @throws HTTPException\NotFoundException If no rule matched */ public function getModuleClass($cmd) { @@ -171,6 +176,10 @@ class Router $routeInfo = $dispatcher->dispatch($this->httpMethod, $cmd); if ($routeInfo[0] === Dispatcher::FOUND) { $moduleClass = $routeInfo[1]; + } elseif ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) { + throw new HTTPException\MethodNotAllowedException(L10n::t('Method not allowed for this module. Allowed method(s): %s', implode(', ', $routeInfo[1]))); + } else { + throw new HTTPException\NotFoundException(L10n::t('Page not found.')); } return $moduleClass; diff --git a/src/Content/Nav.php b/src/Content/Nav.php index e81214cee..ff1680ab3 100644 --- a/src/Content/Nav.php +++ b/src/Content/Nav.php @@ -29,7 +29,7 @@ class Nav 'directory' => null, 'settings' => null, 'contacts' => null, - 'manage' => null, + 'delegation'=> null, 'events' => null, 'register' => null ]; @@ -257,11 +257,9 @@ class Nav $nav['messages']['new'] = ['message/new', L10n::t('New Message'), '', L10n::t('New Message')]; if (is_array($a->identities) && count($a->identities) > 1) { - $nav['manage'] = ['manage', L10n::t('Manage'), '', L10n::t('Manage other pages')]; + $nav['delegation'] = ['delegation', L10n::t('Delegation'), '', L10n::t('Manage other pages')]; } - $nav['delegations'] = ['settings/delegation', L10n::t('Delegations'), '', L10n::t('Delegate Page Management')]; - $nav['settings'] = ['settings', L10n::t('Settings'), '', L10n::t('Account settings')]; if (Feature::isEnabled(local_user(), 'multi_profiles')) { diff --git a/src/Module/Delegation.php b/src/Module/Delegation.php new file mode 100644 index 000000000..77baefeaa --- /dev/null +++ b/src/Module/Delegation.php @@ -0,0 +1,136 @@ +user; + + if (Session::get('submanage')) { + $user = User::getById(Session::get('submanage')); + if (DBA::isResult($user)) { + $uid = intval($user['uid']); + $orig_record = $user; + } + } + + $identity = intval($_POST['identity'] ?? 0); + if (!$identity) { + return; + } + + $limited_id = 0; + $original_id = $uid; + + $manages = DBA::selectToArray('manage', ['mid'], ['uid' => $uid]); + foreach ($manages as $manage) { + if ($identity == $manage['mid']) { + $limited_id = $manage['mid']; + break; + } + } + + if ($limited_id) { + $user = User::getById($limited_id); + } else { + // Check if the target user is one of our children + $user = DBA::selectFirst('user', [], ['uid' => $identity, 'parent-uid' => $orig_record['uid']]); + + // Check if the target user is one of our siblings + if (!DBA::isResult($user) && ($orig_record['parent-uid'] != 0)) { + $user = DBA::selectFirst('user', [], ['uid' => $identity, 'parent-uid' => $orig_record['parent-uid']]); + } + + // Check if it's our parent or our own user + if (!DBA::isResult($user) + && ( + $orig_record['parent-uid'] != 0 && $orig_record['parent-uid'] == $identity + || + $orig_record['uid'] != 0 && $orig_record['uid'] == $identity + ) + ) { + $user = User::getById($identity); + } + } + + if (!DBA::isResult($user)) { + return; + } + + Session::clear(); + + Session::setAuthenticatedForUser(self::getApp(), $user, true, true); + + if ($limited_id) { + Session::set('submanage', $original_id); + } + + $ret = []; + Hook::callAll('home_init', $ret); + + self::getApp()->internalRedirect('profile/' . self::getApp()->user['nickname']); + // NOTREACHED + } + + public static function content() + { + if (!local_user()) { + throw new ForbiddenException(L10n::t('Permission denied.')); + } + + $identities = self::getApp()->identities; + + //getting additinal information for each identity + foreach ($identities as $key => $identity) { + $thumb = Contact::selectFirst(['thumb'], ['uid' => $identity['uid'], 'self' => true]); + if (!DBA::isResult($thumb)) { + continue; + } + + $identities[$key]['thumb'] = $thumb['thumb']; + + $identities[$key]['selected'] = ($identity['nickname'] === self::getApp()->user['nickname']); + + $condition = ["`uid` = ? AND `msg` != '' AND NOT (`type` IN (?, ?)) AND NOT `seen`", $identity['uid'], NOTIFY_INTRO, NOTIFY_MAIL]; + $params = ['distinct' => true, 'expression' => 'parent']; + $notifications = DBA::count('notify', $condition, $params); + + $params = ['distinct' => true, 'expression' => 'convid']; + $notifications += DBA::count('mail', ['uid' => $identity['uid'], 'seen' => false], $params); + + $notifications += DBA::count('intro', ['blocked' => false, 'ignore' => false, 'uid' => $identity['uid']]); + + $identities[$key]['notifications'] = $notifications; + } + + $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('delegation.tpl'), [ + '$title' => L10n::t('Manage Identities and/or Pages'), + '$desc' => L10n::t('Toggle between different identities or community/group pages which share your account details or which you have been granted "manage" permissions'), + '$choose' => L10n::t('Select an identity to manage: '), + '$identities' => $identities, + '$submit' => L10n::t('Submit'), + ]); + + return $o; + } +} diff --git a/src/Module/HTTPException/MethodNotAllowed.php b/src/Module/HTTPException/MethodNotAllowed.php new file mode 100644 index 000000000..8d2d280a5 --- /dev/null +++ b/src/Module/HTTPException/MethodNotAllowed.php @@ -0,0 +1,15 @@ + [Module\Contact::class, [R::GET]], ], '/credits' => [Module\Credits::class, [R::GET]], + '/delegation'=> [Module\Delegation::class, [R::GET, R::POST]], '/dirfind' => [Module\Search\Directory::class, [R::GET]], '/directory' => [Module\Directory::class, [R::GET]], diff --git a/tests/src/App/ModuleTest.php b/tests/src/App/ModuleTest.php index e43f22d0c..8327bc706 100644 --- a/tests/src/App/ModuleTest.php +++ b/tests/src/App/ModuleTest.php @@ -5,7 +5,7 @@ namespace Friendica\Test\src\App; use Friendica\App; use Friendica\Core\Config\Configuration; use Friendica\LegacyModule; -use Friendica\Module\PageNotFound; +use Friendica\Module\HTTPException\PageNotFound; use Friendica\Module\WellKnown\HostMeta; use Friendica\Test\DatabaseTest; @@ -166,7 +166,7 @@ class ModuleTest extends DatabaseTest { $module = new App\Module(); - $moduleNew = $module->determineModule(new App\Arguments(), []); + $moduleNew = $module->determineModule(new App\Arguments()); $this->assertNotSame($moduleNew, $module); } diff --git a/tests/src/App/RouterTest.php b/tests/src/App/RouterTest.php index 2ed8574c4..b2dbaed20 100644 --- a/tests/src/App/RouterTest.php +++ b/tests/src/App/RouterTest.php @@ -4,41 +4,127 @@ namespace Friendica\Test\src\App; use Friendica\App\Router; use Friendica\Module; +use Friendica\Network\HTTPException\MethodNotAllowedException; +use Friendica\Network\HTTPException\NotFoundException; use PHPUnit\Framework\TestCase; class RouterTest extends TestCase { public function testGetModuleClass() { - $router = new Router(['GET']); + $router = new Router(['REQUEST_METHOD' => Router::GET]); $routeCollector = $router->getRouteCollector(); - $routeCollector->addRoute(['GET'], '/', 'IndexModuleClassName'); - $routeCollector->addRoute(['GET'], '/test', 'TestModuleClassName'); - $routeCollector->addRoute(['GET'], '/test/sub', 'TestSubModuleClassName'); - $routeCollector->addRoute(['GET'], '/optional[/option]', 'OptionalModuleClassName'); - $routeCollector->addRoute(['GET'], '/variable/{var}', 'VariableModuleClassName'); - $routeCollector->addRoute(['GET'], '/optionalvariable[/{option}]', 'OptionalVariableModuleClassName'); - $routeCollector->addRoute(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'], '/unsupported', 'UnsupportedMethodModuleClassName'); + $routeCollector->addRoute([Router::GET], '/', 'IndexModuleClassName'); + $routeCollector->addRoute([Router::GET], '/test', 'TestModuleClassName'); + $routeCollector->addRoute([Router::GET, Router::POST], '/testgetpost', 'TestGetPostModuleClassName'); + $routeCollector->addRoute([Router::GET], '/test/sub', 'TestSubModuleClassName'); + $routeCollector->addRoute([Router::GET], '/optional[/option]', 'OptionalModuleClassName'); + $routeCollector->addRoute([Router::GET], '/variable/{var}', 'VariableModuleClassName'); + $routeCollector->addRoute([Router::GET], '/optionalvariable[/{option}]', 'OptionalVariableModuleClassName'); $this->assertEquals('IndexModuleClassName', $router->getModuleClass('/')); - $this->assertEquals('TestModuleClassName', $router->getModuleClass('/test')); - $this->assertNull($router->getModuleClass('/tes')); - + $this->assertEquals('TestGetPostModuleClassName', $router->getModuleClass('/testgetpost')); $this->assertEquals('TestSubModuleClassName', $router->getModuleClass('/test/sub')); - $this->assertEquals('OptionalModuleClassName', $router->getModuleClass('/optional')); $this->assertEquals('OptionalModuleClassName', $router->getModuleClass('/optional/option')); - $this->assertNull($router->getModuleClass('/optional/opt')); - $this->assertEquals('VariableModuleClassName', $router->getModuleClass('/variable/123abc')); - $this->assertNull($router->getModuleClass('/variable')); - $this->assertEquals('OptionalVariableModuleClassName', $router->getModuleClass('/optionalvariable')); $this->assertEquals('OptionalVariableModuleClassName', $router->getModuleClass('/optionalvariable/123abc')); + } - $this->assertNull($router->getModuleClass('/unsupported')); + public function testPostModuleClass() + { + $router = new Router(['REQUEST_METHOD' => Router::POST]); + + $routeCollector = $router->getRouteCollector(); + $routeCollector->addRoute([Router::POST], '/', 'IndexModuleClassName'); + $routeCollector->addRoute([Router::POST], '/test', 'TestModuleClassName'); + $routeCollector->addRoute([Router::GET, Router::POST], '/testgetpost', 'TestGetPostModuleClassName'); + $routeCollector->addRoute([Router::POST], '/test/sub', 'TestSubModuleClassName'); + $routeCollector->addRoute([Router::POST], '/optional[/option]', 'OptionalModuleClassName'); + $routeCollector->addRoute([Router::POST], '/variable/{var}', 'VariableModuleClassName'); + $routeCollector->addRoute([Router::POST], '/optionalvariable[/{option}]', 'OptionalVariableModuleClassName'); + + $this->assertEquals('IndexModuleClassName', $router->getModuleClass('/')); + $this->assertEquals('TestModuleClassName', $router->getModuleClass('/test')); + $this->assertEquals('TestGetPostModuleClassName', $router->getModuleClass('/testgetpost')); + $this->assertEquals('TestSubModuleClassName', $router->getModuleClass('/test/sub')); + $this->assertEquals('OptionalModuleClassName', $router->getModuleClass('/optional')); + $this->assertEquals('OptionalModuleClassName', $router->getModuleClass('/optional/option')); + $this->assertEquals('VariableModuleClassName', $router->getModuleClass('/variable/123abc')); + $this->assertEquals('OptionalVariableModuleClassName', $router->getModuleClass('/optionalvariable')); + $this->assertEquals('OptionalVariableModuleClassName', $router->getModuleClass('/optionalvariable/123abc')); + } + + public function testGetModuleClassNotFound() + { + $this->expectException(NotFoundException::class); + + $router = new Router(['REQUEST_METHOD' => Router::GET]); + + $router->getModuleClass('/unsupported'); + } + + public function testGetModuleClassNotFoundTypo() + { + $this->expectException(NotFoundException::class); + + $router = new Router(['REQUEST_METHOD' => Router::GET]); + + $routeCollector = $router->getRouteCollector(); + $routeCollector->addRoute([Router::GET], '/test', 'TestModuleClassName'); + + $router->getModuleClass('/tes'); + } + + public function testGetModuleClassNotFoundOptional() + { + $this->expectException(NotFoundException::class); + + $router = new Router(['REQUEST_METHOD' => Router::GET]); + + $routeCollector = $router->getRouteCollector(); + $routeCollector->addRoute([Router::GET], '/optional[/option]', 'OptionalModuleClassName'); + + $router->getModuleClass('/optional/opt'); + } + + public function testGetModuleClassNotFoundVariable() + { + $this->expectException(NotFoundException::class); + + $router = new Router(['REQUEST_METHOD' => Router::GET]); + + $routeCollector = $router->getRouteCollector(); + $routeCollector->addRoute([Router::GET], '/variable/{var}', 'VariableModuleClassName'); + + $router->getModuleClass('/variable'); + } + + public function testGetModuleClassMethodNotAllowed() + { + $this->expectException(MethodNotAllowedException::class); + + $router = new Router(['REQUEST_METHOD' => Router::POST]); + + $routeCollector = $router->getRouteCollector(); + $routeCollector->addRoute([Router::GET], '/test', 'TestModuleClassName'); + + $router->getModuleClass('/test'); + } + + public function testPostModuleClassMethodNotAllowed() + { + $this->expectException(MethodNotAllowedException::class); + + $router = new Router(['REQUEST_METHOD' => Router::GET]); + + $routeCollector = $router->getRouteCollector(); + $routeCollector->addRoute([Router::POST], '/test', 'TestModuleClassName'); + + $router->getModuleClass('/test'); } public function dataRoutes() @@ -78,7 +164,6 @@ class RouterTest extends TestCase $this->assertEquals(Module\Home::class, $router->getModuleClass('/')); $this->assertEquals(Module\Friendica::class, $router->getModuleClass('/group/route')); $this->assertEquals(Module\Xrd::class, $router->getModuleClass('/group2/group3/route')); - $this->assertNull($router->getModuleClass('/post/it')); $this->assertEquals(Module\Profile::class, $router->getModuleClass('/double')); } @@ -92,9 +177,6 @@ class RouterTest extends TestCase ]))->addRoutes($routes); // Don't find GET - $this->assertNull($router->getModuleClass('/')); - $this->assertNull($router->getModuleClass('/group/route')); - $this->assertNull($router->getModuleClass('/group2/group3/route')); $this->assertEquals(Module\NodeInfo::class, $router->getModuleClass('/post/it')); $this->assertEquals(Module\Profile::class, $router->getModuleClass('/double')); } diff --git a/view/templates/delegation.tpl b/view/templates/delegation.tpl new file mode 100644 index 000000000..0e8e406d0 --- /dev/null +++ b/view/templates/delegation.tpl @@ -0,0 +1,37 @@ + +