Merge pull request #9050 from annando/system-account

We now fetch data with an automatically generated system user
This commit is contained in:
Hypolite Petovan 2020-08-22 15:54:16 -04:00 committed by GitHub
commit d31010ca0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 255 additions and 72 deletions

View File

@ -99,6 +99,105 @@ class User
private static $owner; private static $owner;
/**
* Fetch the system account
*
* @return array system account
*/
public static function getSystemAccount()
{
$system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
if (!DBA::isResult($system)) {
self::createSystemAccount();
$system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
if (!DBA::isResult($system)) {
return [];
}
}
$system['spubkey'] = $system['uprvkey'] = $system['prvkey'];
$system['username'] = $system['name'];
$system['nickname'] = $system['nick'];
return $system;
}
/**
* Create the system account
*
* @return void
*/
private static function createSystemAccount()
{
$system_actor_name = self::getActorName();
if (empty($system_actor_name)) {
return;
}
$keys = Crypto::newKeypair(4096);
if ($keys === false) {
throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
}
$system = [];
$system['uid'] = 0;
$system['created'] = DateTimeFormat::utcNow();
$system['self'] = true;
$system['network'] = Protocol::ACTIVITYPUB;
$system['name'] = 'System Account';
$system['addr'] = $system_actor_name . '@' . DI::baseUrl()->getHostname();
$system['nick'] = $system_actor_name;
$system['avatar'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO;
$system['photo'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO;
$system['thumb'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_THUMB;
$system['micro'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_MICRO;
$system['url'] = DI::baseUrl() . '/friendica';
$system['nurl'] = Strings::normaliseLink($system['url']);
$system['pubkey'] = $keys['pubkey'];
$system['prvkey'] = $keys['prvkey'];
$system['blocked'] = 0;
$system['pending'] = 0;
$system['contact-type'] = Contact::TYPE_RELAY; // In AP this is translated to 'Application'
$system['name-date'] = DateTimeFormat::utcNow();
$system['uri-date'] = DateTimeFormat::utcNow();
$system['avatar-date'] = DateTimeFormat::utcNow();
$system['closeness'] = 0;
$system['baseurl'] = DI::baseUrl();
$system['gsid'] = GServer::getID($system['baseurl']);
DBA::insert('contact', $system);
}
/**
* Detect a usable actor name
*
* @return string actor account name
*/
public static function getActorName()
{
$system_actor_name = DI::config()->get('system', 'actor_name');
if (!empty($system_actor_name)) {
$self = Contact::selectFirst(['nick'], ['uid' => 0, 'self' => true]);
if (!empty($self['nick'])) {
if ($self['nick'] != $system_actor_name) {
// Reset the actor name to the already used name
DI::config()->set('system', 'actor_name', $self['nick']);
$system_actor_name = $self['nick'];
}
}
return $system_actor_name;
}
// List of possible actor names
$possible_accounts = ['friendica', 'actor', 'system', 'internal'];
foreach ($possible_accounts as $name) {
if (!DBA::exists('user', ['nickname' => $name, 'account_removed' => false, 'expire']) &&
!DBA::exists('userd', ['username' => $name])) {
DI::config()->set('system', 'actor_name', $name);
return $name;
}
}
return '';
}
/** /**
* Returns true if a user record exists with the provided id * Returns true if a user record exists with the provided id
* *
@ -588,15 +687,24 @@ class User
public static function isNicknameBlocked($nickname) public static function isNicknameBlocked($nickname)
{ {
$forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', ''); $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
if (!empty($forbidden_nicknames)) {
$forbidden = explode(',', $forbidden_nicknames);
$forbidden = array_map('trim', $forbidden);
} else {
$forbidden = [];
}
// if the config variable is empty return false // Add the name of the internal actor to the "forbidden" list
if (empty($forbidden_nicknames)) { $actor_name = self::getActorName();
if (!empty($actor_name)) {
$forbidden[] = $actor_name;
}
if (empty($forbidden)) {
return false; return false;
} }
// check if the nickname is in the list of blocked nicknames // check if the nickname is in the list of blocked nicknames
$forbidden = explode(',', $forbidden_nicknames);
$forbidden = array_map('trim', $forbidden);
if (in_array(strtolower($nickname), $forbidden)) { if (in_array(strtolower($nickname), $forbidden)) {
return true; return true;
} }

View File

@ -29,6 +29,7 @@ use Friendica\Core\Worker;
use Friendica\Database\DBA; use Friendica\Database\DBA;
use Friendica\DI; use Friendica\DI;
use Friendica\Model\Contact; use Friendica\Model\Contact;
use Friendica\Model\User;
use Friendica\Module\BaseAdmin; use Friendica\Module\BaseAdmin;
use Friendica\Module\Register; use Friendica\Module\Register;
use Friendica\Util\BasePath; use Friendica\Util\BasePath;
@ -148,6 +149,7 @@ class Site extends BaseAdmin
$allowed_sites = (!empty($_POST['allowed_sites']) ? Strings::escapeTags(trim($_POST['allowed_sites'])) : ''); $allowed_sites = (!empty($_POST['allowed_sites']) ? Strings::escapeTags(trim($_POST['allowed_sites'])) : '');
$allowed_email = (!empty($_POST['allowed_email']) ? Strings::escapeTags(trim($_POST['allowed_email'])) : ''); $allowed_email = (!empty($_POST['allowed_email']) ? Strings::escapeTags(trim($_POST['allowed_email'])) : '');
$forbidden_nicknames = (!empty($_POST['forbidden_nicknames']) ? strtolower(Strings::escapeTags(trim($_POST['forbidden_nicknames']))) : ''); $forbidden_nicknames = (!empty($_POST['forbidden_nicknames']) ? strtolower(Strings::escapeTags(trim($_POST['forbidden_nicknames']))) : '');
$system_actor_name = (!empty($_POST['system_actor_name']) ? Strings::escapeTags(trim($_POST['system_actor_name'])) : '');
$no_oembed_rich_content = !empty($_POST['no_oembed_rich_content']); $no_oembed_rich_content = !empty($_POST['no_oembed_rich_content']);
$allowed_oembed = (!empty($_POST['allowed_oembed']) ? Strings::escapeTags(trim($_POST['allowed_oembed'])) : ''); $allowed_oembed = (!empty($_POST['allowed_oembed']) ? Strings::escapeTags(trim($_POST['allowed_oembed'])) : '');
$block_public = !empty($_POST['block_public']); $block_public = !empty($_POST['block_public']);
@ -355,6 +357,7 @@ class Site extends BaseAdmin
DI::config()->set('system', 'allowed_sites' , $allowed_sites); DI::config()->set('system', 'allowed_sites' , $allowed_sites);
DI::config()->set('system', 'allowed_email' , $allowed_email); DI::config()->set('system', 'allowed_email' , $allowed_email);
DI::config()->set('system', 'forbidden_nicknames' , $forbidden_nicknames); DI::config()->set('system', 'forbidden_nicknames' , $forbidden_nicknames);
DI::config()->set('system', 'system_actor_name' , $system_actor_name);
DI::config()->set('system', 'no_oembed_rich_content' , $no_oembed_rich_content); DI::config()->set('system', 'no_oembed_rich_content' , $no_oembed_rich_content);
DI::config()->set('system', 'allowed_oembed' , $allowed_oembed); DI::config()->set('system', 'allowed_oembed' , $allowed_oembed);
DI::config()->set('system', 'block_public' , $block_public); DI::config()->set('system', 'block_public' , $block_public);
@ -600,6 +603,7 @@ class Site extends BaseAdmin
// name, label, value, help string, extra data... // name, label, value, help string, extra data...
'$sitename' => ['sitename', DI::l10n()->t('Site name'), DI::config()->get('config', 'sitename'), ''], '$sitename' => ['sitename', DI::l10n()->t('Site name'), DI::config()->get('config', 'sitename'), ''],
'$sender_email' => ['sender_email', DI::l10n()->t('Sender Email'), DI::config()->get('config', 'sender_email'), DI::l10n()->t('The email address your server shall use to send notification emails from.'), '', '', 'email'], '$sender_email' => ['sender_email', DI::l10n()->t('Sender Email'), DI::config()->get('config', 'sender_email'), DI::l10n()->t('The email address your server shall use to send notification emails from.'), '', '', 'email'],
'$system_actor_name' => ['system_actor_name', DI::l10n()->t('Name of the system actor'), User::getActorName(), DI::l10n()->t("Name of the internal system account that is used to perform ActivityPub requests. This must be an unused username. If set, this can't be changed again.")],
'$banner' => ['banner', DI::l10n()->t('Banner/Logo'), $banner, ''], '$banner' => ['banner', DI::l10n()->t('Banner/Logo'), $banner, ''],
'$email_banner' => ['email_banner', DI::l10n()->t('Email Banner/Logo'), $email_banner, ''], '$email_banner' => ['email_banner', DI::l10n()->t('Email Banner/Logo'), $email_banner, ''],
'$shortcut_icon' => ['shortcut_icon', DI::l10n()->t('Shortcut icon'), DI::config()->get('system', 'shortcut_icon'), DI::l10n()->t('Link to an icon that will be used for browsers.')], '$shortcut_icon' => ['shortcut_icon', DI::l10n()->t('Shortcut icon'), DI::config()->get('system', 'shortcut_icon'), DI::l10n()->t('Link to an icon that will be used for browsers.')],

View File

@ -25,8 +25,10 @@ use Friendica\BaseModule;
use Friendica\Core\Addon; use Friendica\Core\Addon;
use Friendica\Core\Hook; use Friendica\Core\Hook;
use Friendica\Core\Renderer; use Friendica\Core\Renderer;
use Friendica\Core\System;
use Friendica\DI; use Friendica\DI;
use Friendica\Model\User; use Friendica\Model\User;
use Friendica\Protocol\ActivityPub;
/** /**
* Prints information about the current node * Prints information about the current node
@ -108,6 +110,15 @@ class Friendica extends BaseModule
public static function rawContent(array $parameters = []) public static function rawContent(array $parameters = [])
{ {
if (ActivityPub::isRequest()) {
$data = ActivityPub\Transmitter::getProfile(0);
if (!empty($data)) {
header('Access-Control-Allow-Origin: *');
header('Cache-Control: max-age=23200, stale-while-revalidate=23200');
System::jsonExit($data, 'application/activity+json');
}
}
$app = DI::app(); $app = DI::app();
// @TODO: Replace with parameter from router // @TODO: Replace with parameter from router

View File

@ -24,6 +24,7 @@ namespace Friendica\Module;
use Friendica\BaseModule; use Friendica\BaseModule;
use Friendica\Core\Hook; use Friendica\Core\Hook;
use Friendica\Core\Renderer; use Friendica\Core\Renderer;
use Friendica\Core\System;
use Friendica\Database\DBA; use Friendica\Database\DBA;
use Friendica\DI; use Friendica\DI;
use Friendica\Model\Photo; use Friendica\Model\Photo;
@ -77,24 +78,30 @@ class Xrd extends BaseModule
$name = substr($local, 0, strpos($local, '@')); $name = substr($local, 0, strpos($local, '@'));
} }
$user = User::getByNickname($name); if ($name == User::getActorName()) {
$owner = User::getSystemAccount();
if (empty($owner)) {
throw new \Friendica\Network\HTTPException\NotFoundException();
}
self::printSystemJSON($owner);
} else {
$user = User::getByNickname($name);
if (empty($user)) {
throw new \Friendica\Network\HTTPException\NotFoundException();
}
if (empty($user)) { $owner = User::getOwnerDataById($user['uid']);
throw new \Friendica\Network\HTTPException\NotFoundException(); if (empty($owner)) {
DI::logger()->warning('No owner data for user id', ['uri' => $uri, 'name' => $name, 'user' => $user]);
throw new \Friendica\Network\HTTPException\NotFoundException();
}
$alias = str_replace('/profile/', '/~', $owner['url']);
$avatar = Photo::selectFirst(['type'], ['uid' => $owner['uid'], 'profile' => true]);
} }
$owner = User::getOwnerDataById($user['uid']); if (empty($avatar)) {
if (empty($owner)) {
DI::logger()->warning('No owner data for user id', ['uri' => $uri, 'name' => $name, 'user' => $user]);
throw new \Friendica\Network\HTTPException\NotFoundException();
}
$alias = str_replace('/profile/', '/~', $owner['url']);
$avatar = Photo::selectFirst(['type'], ['uid' => $owner['uid'], 'profile' => true]);
if (!DBA::isResult($avatar)) {
$avatar = ['type' => 'image/jpeg']; $avatar = ['type' => 'image/jpeg'];
} }
@ -105,6 +112,32 @@ class Xrd extends BaseModule
} }
} }
private static function printSystemJSON(array $owner)
{
$json = [
'subject' => 'acct:' . $owner['addr'],
'aliases' => [$owner['url']],
'links' => [
[
'rel' => 'http://webfinger.net/rel/profile-page',
'type' => 'text/html',
'href' => $owner['url'],
],
[
'rel' => 'self',
'type' => 'application/activity+json',
'href' => $owner['url'],
],
[
'rel' => 'http://ostatus.org/schema/1.0/subscribe',
'template' => DI::baseUrl()->get() . '/follow?url={uri}',
],
]
];
header('Access-Control-Allow-Origin: *');
System::jsonExit($json, 'application/jrd+json; charset=utf-8');
}
private static function printJSON($alias, $baseURL, $owner, $avatar) private static function printJSON($alias, $baseURL, $owner, $avatar)
{ {
$salmon_key = Salmon::salmonKey($owner['spubkey']); $salmon_key = Salmon::salmonKey($owner['spubkey']);

View File

@ -2200,7 +2200,7 @@ class Probe
$profile['gsid'] = GServer::getID($approfile['generator']['url']); $profile['gsid'] = GServer::getID($approfile['generator']['url']);
} }
$data = ['name' => $profile['name'], 'nick' => $profile['nick'], 'guid' => $approfile['diaspora:guid'], $data = ['name' => $profile['name'], 'nick' => $profile['nick'], 'guid' => $approfile['diaspora:guid'] ?? '',
'url' => $profile['url'], 'addr' => $profile['addr'], 'alias' => $profile['alias'], 'url' => $profile['url'], 'addr' => $profile['addr'], 'alias' => $profile['alias'],
'photo' => $profile['photo'], 'account-type' => $profile['contact-type'], 'photo' => $profile['photo'], 'account-type' => $profile['contact-type'],
'community' => ($profile['contact-type'] == User::ACCOUNT_TYPE_COMMUNITY), 'community' => ($profile['contact-type'] == User::ACCOUNT_TYPE_COMMUNITY),

View File

@ -90,20 +90,6 @@ class ActivityPub
*/ */
public static function fetchContent(string $url, int $uid = 0) public static function fetchContent(string $url, int $uid = 0)
{ {
if (empty($uid)) {
$user = User::getFirstAdmin(['uid']);
if (empty($user['uid'])) {
// When the system setup is missing an admin we just take the first user
$condition = ['verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false];
$user = DBA::selectFirst('user', ['uid'], $condition);
}
if (!empty($user['uid'])) {
$uid = $user['uid'];
}
}
return HTTPSignature::fetch($url, $uid); return HTTPSignature::fetch($url, $uid);
} }

View File

@ -774,6 +774,9 @@ class Processor
} }
$owner = User::getOwnerDataById($uid); $owner = User::getOwnerDataById($uid);
if (empty($owner)) {
return;
}
$cid = Contact::getIdForURL($activity['actor'], $uid); $cid = Contact::getIdForURL($activity['actor'], $uid);
if (!empty($cid)) { if (!empty($cid)) {
@ -956,6 +959,9 @@ class Processor
} }
$owner = User::getOwnerDataById($uid); $owner = User::getOwnerDataById($uid);
if (empty($owner)) {
return;
}
$cid = Contact::getIdForURL($activity['actor'], $uid); $cid = Contact::getIdForURL($activity['actor'], $uid);
if (empty($cid)) { if (empty($cid)) {

View File

@ -214,39 +214,63 @@ class Transmitter
*/ */
public static function getProfile($uid) public static function getProfile($uid)
{ {
$condition = ['uid' => $uid, 'blocked' => false, 'account_expired' => false, if ($uid != 0) {
'account_removed' => false, 'verified' => true]; $condition = ['uid' => $uid, 'blocked' => false, 'account_expired' => false,
$fields = ['guid', 'nickname', 'pubkey', 'account-type', 'page-flags']; 'account_removed' => false, 'verified' => true];
$user = DBA::selectFirst('user', $fields, $condition); $fields = ['guid', 'nickname', 'pubkey', 'account-type', 'page-flags'];
if (!DBA::isResult($user)) { $user = DBA::selectFirst('user', $fields, $condition);
return []; if (!DBA::isResult($user)) {
} return [];
}
$fields = ['locality', 'region', 'country-name']; $fields = ['locality', 'region', 'country-name'];
$profile = DBA::selectFirst('profile', $fields, ['uid' => $uid]); $profile = DBA::selectFirst('profile', $fields, ['uid' => $uid]);
if (!DBA::isResult($profile)) { if (!DBA::isResult($profile)) {
return []; return [];
} }
$fields = ['name', 'url', 'location', 'about', 'avatar', 'photo']; $fields = ['name', 'url', 'location', 'about', 'avatar', 'photo'];
$contact = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]); $contact = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]);
if (!DBA::isResult($contact)) { if (!DBA::isResult($contact)) {
return []; return [];
}
} else {
$contact = User::getSystemAccount();
$user = ['guid' => '', 'nickname' => $contact['nick'], 'pubkey' => $contact['pubkey'],
'account-type' => $contact['contact-type'], 'page-flags' => User::PAGE_FLAGS_NORMAL];
$profile = ['locality' => '', 'region' => '', 'country-name' => ''];
} }
$data = ['@context' => ActivityPub::CONTEXT]; $data = ['@context' => ActivityPub::CONTEXT];
$data['id'] = $contact['url']; $data['id'] = $contact['url'];
$data['diaspora:guid'] = $user['guid'];
if (!empty($user['guid'])) {
$data['diaspora:guid'] = $user['guid'];
}
$data['type'] = ActivityPub::ACCOUNT_TYPES[$user['account-type']]; $data['type'] = ActivityPub::ACCOUNT_TYPES[$user['account-type']];
$data['following'] = DI::baseUrl() . '/following/' . $user['nickname'];
$data['followers'] = DI::baseUrl() . '/followers/' . $user['nickname']; if ($uid != 0) {
$data['inbox'] = DI::baseUrl() . '/inbox/' . $user['nickname']; $data['following'] = DI::baseUrl() . '/following/' . $user['nickname'];
$data['outbox'] = DI::baseUrl() . '/outbox/' . $user['nickname']; $data['followers'] = DI::baseUrl() . '/followers/' . $user['nickname'];
$data['inbox'] = DI::baseUrl() . '/inbox/' . $user['nickname'];
$data['outbox'] = DI::baseUrl() . '/outbox/' . $user['nickname'];
} else {
$data['inbox'] = DI::baseUrl() . '/friendica/inbox';
}
$data['preferredUsername'] = $user['nickname']; $data['preferredUsername'] = $user['nickname'];
$data['name'] = $contact['name']; $data['name'] = $contact['name'];
$data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $profile['country-name'],
'vcard:region' => $profile['region'], 'vcard:locality' => $profile['locality']]; if (!empty($profile['country-name'] . $profile['region'] . $profile['locality'])) {
$data['summary'] = BBCode::convert($contact['about'], false); $data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $profile['country-name'],
'vcard:region' => $profile['region'], 'vcard:locality' => $profile['locality']];
}
if (!empty($contact['about'])) {
$data['summary'] = BBCode::convert($contact['about'], false);
}
$data['url'] = $contact['url']; $data['url'] = $contact['url'];
$data['manuallyApprovesFollowers'] = in_array($user['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP]); $data['manuallyApprovesFollowers'] = in_array($user['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP]);
$data['publicKey'] = ['id' => $contact['url'] . '#main-key', $data['publicKey'] = ['id' => $contact['url'] . '#main-key',
@ -652,6 +676,10 @@ class Transmitter
$item_profile = APContact::getByURL($item['owner-link'], false); $item_profile = APContact::getByURL($item['owner-link'], false);
} }
if (empty($item_profile)) {
return [];
}
$profile_uid = User::getIdForURL($item_profile['url']); $profile_uid = User::getIdForURL($item_profile['url']);
foreach (['to', 'cc', 'bto', 'bcc'] as $element) { foreach (['to', 'cc', 'bto', 'bcc'] as $element) {

View File

@ -420,23 +420,28 @@ class HTTPSignature
if (!$owner) { if (!$owner) {
return; return;
} }
} else {
if (!empty($owner['uprvkey'])) { $owner = User::getSystemAccount();
// Header data that is about to be signed. if (!$owner) {
$host = parse_url($request, PHP_URL_HOST); return;
$path = parse_url($request, PHP_URL_PATH);
$date = DateTimeFormat::utcNow(DateTimeFormat::HTTP);
$headers = ['Date: ' . $date, 'Host: ' . $host];
$signed_data = "(request-target): get " . $path . "\ndate: ". $date . "\nhost: " . $host;
$signature = base64_encode(Crypto::rsaSign($signed_data, $owner['uprvkey'], 'sha256'));
$headers[] = 'Signature: keyId="' . $owner['url'] . '#main-key' . '",algorithm="rsa-sha256",headers="(request-target) date host",signature="' . $signature . '"';
} }
} }
if (!empty($owner['uprvkey'])) {
// Header data that is about to be signed.
$host = parse_url($request, PHP_URL_HOST);
$path = parse_url($request, PHP_URL_PATH);
$date = DateTimeFormat::utcNow(DateTimeFormat::HTTP);
$headers = ['Date: ' . $date, 'Host: ' . $host];
$signed_data = "(request-target): get " . $path . "\ndate: ". $date . "\nhost: " . $host;
$signature = base64_encode(Crypto::rsaSign($signed_data, $owner['uprvkey'], 'sha256'));
$headers[] = 'Signature: keyId="' . $owner['url'] . '#main-key' . '",algorithm="rsa-sha256",headers="(request-target) date host",signature="' . $signature . '"';
}
if (!empty($opts['accept_content'])) { if (!empty($opts['accept_content'])) {
$headers[] = 'Accept: ' . $opts['accept_content']; $headers[] = 'Accept: ' . $opts['accept_content'];
} }

View File

@ -14,6 +14,7 @@
{{include file="field_input.tpl" field=$sitename}} {{include file="field_input.tpl" field=$sitename}}
{{include file="field_input.tpl" field=$sender_email}} {{include file="field_input.tpl" field=$sender_email}}
{{include file="field_input.tpl" field=$system_actor_name}}
{{include file="field_textarea.tpl" field=$banner}} {{include file="field_textarea.tpl" field=$banner}}
{{include file="field_input.tpl" field=$email_banner}} {{include file="field_input.tpl" field=$email_banner}}
{{include file="field_input.tpl" field=$shortcut_icon}} {{include file="field_input.tpl" field=$shortcut_icon}}

View File

@ -41,6 +41,7 @@
<div class="panel-body"> <div class="panel-body">
{{include file="field_input.tpl" field=$sitename}} {{include file="field_input.tpl" field=$sitename}}
{{include file="field_input.tpl" field=$sender_email}} {{include file="field_input.tpl" field=$sender_email}}
{{include file="field_input.tpl" field=$system_actor_name}}
{{include file="field_textarea.tpl" field=$banner}} {{include file="field_textarea.tpl" field=$banner}}
{{include file="field_input.tpl" field=$shortcut_icon}} {{include file="field_input.tpl" field=$shortcut_icon}}
{{include file="field_input.tpl" field=$touch_icon}} {{include file="field_input.tpl" field=$touch_icon}}