diff --git a/hook.php b/hook.php
index bda7d3c9..edd9837c 100644
--- a/hook.php
+++ b/hook.php
@@ -33,15 +33,12 @@
* Entry point for installation process
*/
function plugin_flyvemdm_install() {
- $version = plugin_version_flyvemdm();
- $migration = new Migration($version['version']);
- require_once(PLUGIN_FLYVEMDM_ROOT . "/install/install.class.php");
- spl_autoload_register([PluginFlyvemdmInstall::class, 'autoload']);
- $install = new PluginFlyvemdmInstall();
- if (!$install->isPluginInstalled()) {
- return $install->install($migration);
- }
- return $install->upgrade($migration);
+ global $DB;
+
+ require_once(PLUGIN_FLYVEMDM_ROOT . "/install/installer.class.php");
+ $installer = new PluginFlyvemdmInstaller();
+
+ return $installer->install();
}
/**
@@ -49,10 +46,10 @@ function plugin_flyvemdm_install() {
* @return boolean True if success
*/
function plugin_flyvemdm_uninstall() {
- require_once(PLUGIN_FLYVEMDM_ROOT . "/install/install.class.php");
- $install = new PluginFlyvemdmInstall();
+ require_once(PLUGIN_FLYVEMDM_ROOT . "/install/installer.class.php");
+ $installer = new PluginFlyvemdmInstaller();
- return $install->uninstall();
+ return $installer->uninstall();
}
/**
@@ -158,11 +155,9 @@ function plugin_Flyvemdm_addDefaultWhere($itemtype) {
case PluginFlyvemdmAgent::class:
return PluginFlyvemdmAgent::addDefaultWhere();
- case PluginFlyvemdmFDroidApplication::class: {
+ case PluginFlyvemdmFDroidApplication::class:
return PluginFlyvemdmFDroidApplication::addDefaultWhere();
- }
}
-
}
/**
diff --git a/install/installer.class.php b/install/installer.class.php
new file mode 100644
index 00000000..5544b88d
--- /dev/null
+++ b/install/installer.class.php
@@ -0,0 +1,1031 @@
+migration = new Migration(PLUGIN_FLYVEMDM_VERSION);
+ $this->migration->setVersion(PLUGIN_FLYVEMDM_VERSION);
+
+ // adding DB model from sql file
+ // TODO : migrate in-code DB model setup here
+ if (self::getCurrentVersion() == '') {
+ // Setup DB model
+ $dbFile = PLUGIN_FLYVEMDM_ROOT . "/install/mysql/plugin_flyvemdm_empty.sql";
+ if (!$DB->runFile($dbFile)) {
+ $this->migration->displayWarning("Error creating tables : " . $DB->error(), true);
+ return false;
+ }
+
+ $this->createInitialConfig();
+ } else {
+ if (PluginFlyvemdmCommon::endsWith(PLUGIN_FLYVEMDM_VERSION,
+ "-dev") || (version_compare(self::getCurrentVersion(),
+ PLUGIN_FLYVEMDM_VERSION) != 0)) {
+ // TODO : Upgrade (or downgrade)
+ $this->upgrade(self::getCurrentVersion());
+ }
+ }
+
+ $this->migration->executeMigration();
+
+ if (version_compare(GLPI_VERSION, '9.3.0') >= 0) {
+ $this->migrateToInnodb();
+ }
+ $this->createDirectories();
+ $this->createFirstAccess();
+ $this->createGuestProfileAccess();
+ $this->createAgentProfileAccess();
+ $this->createDefaultFleet();
+ $this->createPolicies();
+ $this->createNotificationTargetInvitation();
+ $this->createJobs();
+ $this->createRootEntityConfig();
+ $this->createDisplayPreferences();
+
+ Config::setConfigurationValues('flyvemdm', ['version' => PLUGIN_FLYVEMDM_VERSION]);
+
+ return true;
+ }
+
+ /**
+ * Find a profile having the given comment, or create it
+ * @param string $name Name of the profile
+ * @param string $comment Comment of the profile
+ * @return integer profile ID
+ */
+ protected static function getOrCreateProfile($name, $comment) {
+ global $DB;
+
+ $comment = $DB->escape($comment);
+ $profile = new Profile();
+ $profiles = $profile->find("`comment`='$comment'");
+ $row = array_shift($profiles);
+ if ($row === null) {
+ $profile->fields["name"] = $DB->escape(__($name, "flyvemdm"));
+ $profile->fields["comment"] = $comment;
+ $profile->fields["interface"] = "central";
+ if ($profile->addToDB() === false) {
+ die("Error while creating users profile : $name\n\n" . $DB->error());
+ }
+ return $profile->getID();
+ } else {
+ return $row['id'];
+ }
+ }
+
+ public function createDirectories() {
+ // Create directory for uploaded applications
+ if (!file_exists(FLYVEMDM_PACKAGE_PATH)) {
+ if (!mkdir(FLYVEMDM_PACKAGE_PATH, 0770, true)) {
+ $this->migration->displayWarning("Cannot create " . FLYVEMDM_PACKAGE_PATH . " directory");
+ } else {
+ if (!$htAccessHandler = fopen(FLYVEMDM_PACKAGE_PATH . "/.htaccess", "w")) {
+ fwrite($htAccessHandler,
+ "allow from all\n") or $this->migration->displayWarning("Cannot create .htaccess file in packages directory\n");
+ fclose($htAccessHandler);
+ }
+ }
+ }
+
+ // Create directory for uploaded files
+ if (!file_exists(FLYVEMDM_FILE_PATH)) {
+ if (!mkdir(FLYVEMDM_FILE_PATH, 0770, true)) {
+ $this->migration->displayWarning("Cannot create " . FLYVEMDM_FILE_PATH . " directory");
+ } else {
+ if (!$htAccessHandler = fopen(FLYVEMDM_FILE_PATH . "/.htaccess", "w")) {
+ fwrite($htAccessHandler,
+ "allow from all\n") or $this->migration->displayWarning("Cannot create .htaccess file in files directory\n");
+ fclose($htAccessHandler);
+ }
+ }
+ }
+
+ // Create cache directory for the template engine
+ PluginFlyvemdmCommon::recursiveRmdir(FLYVEMDM_TEMPLATE_CACHE_PATH);
+ if (!mkdir(FLYVEMDM_TEMPLATE_CACHE_PATH, 0770, true)) {
+ $this->migration->displayWarning("Cannot create " . FLYVEMDM_TEMPLATE_CACHE_PATH . " directory");
+ }
+ }
+
+ /**
+ * @return null|string
+ */
+ public static function getCurrentVersion() {
+ if (self::$currentVersion === null) {
+ $config = \Config::getConfigurationValues('flyvemdm', ['version']);
+ if (!isset($config['version'])) {
+ self::$currentVersion = '';
+ } else {
+ self::$currentVersion = $config['version'];
+ }
+ }
+ return self::$currentVersion;
+ }
+
+ protected function createRootEntityConfig() {
+ $entityConfig = new PluginFlyvemdmEntityConfig();
+ $entityConfig->getFromDBByCrit([
+ 'entities_id' => '0',
+ ]);
+ if ($entityConfig->isNewItem()) {
+ $entityConfig->add([
+ 'id' => '0',
+ 'entities_id' => '0',
+ 'download_url' => PLUGIN_FLYVEMDM_AGENT_DOWNLOAD_URL,
+ 'agent_token_life' => PluginFlyvemdmAgent::DEFAULT_TOKEN_LIFETIME,
+ ]);
+ }
+ }
+
+ /**
+ * Give all rights on the plugin to the profile of the current user
+ */
+ protected function createFirstAccess() {
+ $profileRight = new ProfileRight();
+
+ $newRights = [
+ PluginFlyvemdmAgent::$rightname => READ | UPDATE | PURGE | READNOTE | UPDATENOTE,
+ PluginFlyvemdmFleet::$rightname => ALLSTANDARDRIGHT | READNOTE | UPDATENOTE,
+ PluginFlyvemdmPackage::$rightname => ALLSTANDARDRIGHT | READNOTE | UPDATENOTE,
+ PluginFlyvemdmFile::$rightname => ALLSTANDARDRIGHT | READNOTE | UPDATENOTE,
+ PluginFlyvemdmGeolocation::$rightname => ALLSTANDARDRIGHT | READNOTE | UPDATENOTE,
+ PluginFlyvemdmPolicy::$rightname => READ,
+ PluginFlyvemdmPolicyCategory::$rightname => READ,
+ PluginFlyvemdmWellknownpath::$rightname => ALLSTANDARDRIGHT,
+ PluginFlyvemdmProfile::$rightname => PluginFlyvemdmProfile::RIGHT_FLYVEMDM_USE,
+ PluginFlyvemdmEntityConfig::$rightname => READ
+ | PluginFlyvemdmEntityConfig::RIGHT_FLYVEMDM_DEVICE_COUNT_LIMIT
+ | PluginFlyvemdmEntityConfig::RIGHT_FLYVEMDM_APP_DOWNLOAD_URL
+ | PluginFlyvemdmEntityConfig::RIGHT_FLYVEMDM_INVITATION_TOKEN_LIFE,
+ PluginFlyvemdmInvitation::$rightname => ALLSTANDARDRIGHT,
+ PluginFlyvemdmInvitationLog::$rightname => READ,
+ PluginFlyvemdmTaskstatus::$rightname => READ,
+ ];
+
+ $profileRight->updateProfileRights($_SESSION['glpiactiveprofile']['id'], $newRights);
+
+ $_SESSION['glpiactiveprofile'] = $_SESSION['glpiactiveprofile'] + $newRights;
+ }
+
+ protected function createDefaultFleet() {
+ $fleet = new PluginFlyvemdmFleet();
+ $request = [
+ 'AND' => [
+ 'is_default' => '1',
+ Entity::getForeignKeyField() => '0'
+ ]
+ ];
+ if (!$fleet->getFromDBByCrit($request)) {
+ $fleet->add([
+ 'name' => __('not managed fleet', 'flyvemdm'),
+ 'entities_id' => '0',
+ 'is_recursive' => '0',
+ 'is_default' => '1',
+ ]);
+ }
+ }
+
+ /**
+ * Create a profile for guest users
+ */
+ protected function createGuestProfileAccess() {
+ // create profile for guest users
+ $profileId = self::getOrCreateProfile(
+ __("Flyve MDM guest users", "flyvemdm"),
+ __("guest Flyve MDM users. Created by Flyve MDM - do NOT modify this comment.", "flyvemdm")
+ );
+ Config::setConfigurationValues('flyvemdm', ['guest_profiles_id' => $profileId]);
+ $profileRight = new ProfileRight();
+ $profileRight->updateProfileRights($profileId, [
+ PluginFlyvemdmAgent::$rightname => READ | CREATE,
+ PluginFlyvemdmFile::$rightname => READ,
+ PluginFlyvemdmPackage::$rightname => READ,
+ ]);
+ }
+
+ /**
+ * Create a profile for agent user accounts
+ */
+ protected function createAgentProfileAccess() {
+ // create profile for guest users
+ $profileId = self::getOrCreateProfile(
+ __("Flyve MDM device agent users", "flyvemdm"),
+ __("device agent Flyve MDM users. Created by Flyve MDM - do NOT modify this comment.",
+ "flyvemdm")
+ );
+ Config::setConfigurationValues('flyvemdm', ['agent_profiles_id' => $profileId]);
+ $profileRight = new ProfileRight();
+ $profileRight->updateProfileRights($profileId, [
+ PluginFlyvemdmAgent::$rightname => READ,
+ PluginFlyvemdmFile::$rightname => READ,
+ PluginFlyvemdmPackage::$rightname => READ,
+ PluginFlyvemdmEntityConfig::$rightname => READ,
+ ]);
+ }
+
+ /**
+ * Create policies in DB
+ */
+ protected function createPolicies() {
+ $policy = new PluginFlyvemdmPolicy();
+ foreach (self::getPolicies() as $policyData) {
+ // Import the policy category or find the existing one
+ $category = new PluginFlyvemdmPolicyCategory();
+ $categoryId = $category->import([
+ 'completename' => $policyData['plugin_flyvemdm_policycategories_id'],
+ ]);
+ $policyData['plugin_flyvemdm_policycategories_id'] = $categoryId;
+
+ $symbol = $policyData['symbol'];
+ $rows = $policy->find("`symbol`='$symbol'");
+ $policyData['type_data'] = json_encode($policyData['type_data'],
+ JSON_UNESCAPED_SLASHES
+ );
+ if (count($rows) == 0) {
+ // Create only non existing policy objects
+ $policy->add($policyData);
+ } else {
+ // Update default value and recommended value for existing policy objects
+ $policy2 = new PluginFlyvemdmPolicy();
+ $policy2->getFromDBBySymbol($symbol);
+ $policy2->update([
+ 'id' => $policy2->getID(),
+ 'default_value' => $policyData['default_value'],
+ 'recommended_value' => $policyData['recommended_value'],
+ 'type_data' => $policyData['type_data'],
+ 'android_min_version' => $policyData['android_min_version'],
+ 'android_max_version' => $policyData['android_max_version'],
+ 'apple_min_version' => $policyData['apple_min_version'],
+ 'apple_max_version' => $policyData['apple_max_version'],
+ 'plugin_flyvemdm_policycategories_id' => $categoryId,
+ ]);
+ }
+ }
+ }
+
+ /**
+ * @return array
+ */
+ protected function getNotificationTargetInvitationEvents() {
+ // Force locale for localized strings
+ $currentLocale = $_SESSION['glpilanguage'];
+ Session::loadLanguage('en_GB');
+
+ $notifications = [
+ PluginFlyvemdmNotificationTargetInvitation::EVENT_GUEST_INVITATION => [
+ 'itemtype' => PluginFlyvemdmInvitation::class,
+ 'name' => __('User invitation', 'flyvemdm'),
+ 'subject' => 'You have been invited to join Flyve MDM', 'flyvemdm',
+ 'content_text' => 'Hi,
+
+##user.firstname## ##user.realname## invited you to enroll your mobile device
+in Flyve Mobile Device Managment (Flyve MDM). Flyve MDM allows administrators
+to easily manage and administrate mobile devices. For more information,
+please contact ##user.firstname## ##user.realname## to his email address
+##user.email##.
+
+Please join the Flyve Mobile Device Management system by downloading
+and installing the Flyve MDM application for Android from the following link.
+
+##flyvemdm.download_app##
+
+If you\'re viewing this email from a computer flash the QR code you see below
+with the Flyve MDM Application.
+
+If you\'re viewing this email from your device to enroll then tap the
+following link or copy it to your browser.
+
+##flyvemdm.enroll_url##
+
+Regards,
+
+',
+ 'content_html' => 'Hi,
+
+##user.firstname## ##user.realname## invited you to enroll your mobile device
+in Flyve Mobile Device Managment (Flyve MDM). Flyve MDM allows administrators
+to easily manage and administrate mobile devices. For more information,
+please contact ##user.firstname## ##user.realname## to his email address
+
+##user.email##.
+
+Please join the Flyve Mobile Device Management system by downloading
+and installing the Flyve MDM application for Android from the following link.
+
+##flyvemdm.download_app##
+
+If you\'re viewing this email from a computer flash the QR code you see below
+with the Flyve MDM Application.
+
+If you\'re viewing this email from your device to enroll then tap the
+following link or copy it to your browser.
+
+##flyvemdm.enroll_url##
+
+
+
+Regards,
+
+',
+ ],
+ ];
+
+ // Restore user's locale
+ Session::loadLanguage($currentLocale);
+
+ return $notifications;
+ }
+
+ public function createNotificationTargetInvitation() {
+ // Create the notification template
+ $notification = new Notification();
+ $template = new NotificationTemplate();
+ $translation = new NotificationTemplateTranslation();
+ $notificationTarget = new PluginFlyvemdmNotificationTargetInvitation();
+ $notification_notificationTemplate = new Notification_NotificationTemplate();
+
+ foreach ($this->getNotificationTargetInvitationEvents() as $event => $data) {
+ $itemtype = $data['itemtype'];
+ if (count($template->find("`itemtype`='$itemtype' AND `name`='" . $data['name'] . "'")) < 1) {
+ // Add template
+ $templateId = $template->add([
+ 'name' => addcslashes($data['name'], "'\""),
+ 'comment' => '',
+ 'itemtype' => $itemtype,
+ ]);
+
+ // Add default translation
+ if (!isset($data['content_html'])) {
+ $contentHtml = self::convertTextToHtml($data['content_text']);
+ } else {
+ $contentHtml = self::convertTextToHtml($data['content_html']);
+ }
+ $translation->add([
+ 'notificationtemplates_id' => $templateId,
+ 'language' => '',
+ 'subject' => addcslashes($data['subject'], "'\""),
+ 'content_text' => addcslashes($data['content_text'], "'\""),
+ 'content_html' => addcslashes(htmlentities($contentHtml), "'\""),
+ ]);
+
+ // Create the notification
+ $notificationId = $notification->add([
+ 'name' => addcslashes($data['name'], "'\""),
+ 'comment' => '',
+ 'entities_id' => 0,
+ 'is_recursive' => 1,
+ 'is_active' => 1,
+ 'itemtype' => $itemtype,
+ 'event' => $event,
+ ]);
+
+ $notification_notificationTemplate->add([
+ 'notifications_id' => $notificationId,
+ 'notificationtemplates_id' => $templateId,
+ 'mode' => Notification_NotificationTemplate::MODE_MAIL,
+ ]);
+
+ $notificationTarget->add([
+ 'items_id' => Notification::USER,
+ 'type' => Notification::USER_TYPE,
+ 'notifications_id' => $notificationId,
+ ]);
+
+ }
+ }
+ }
+
+ /**
+ * Upgrade the plugin to the current code version
+ *
+ * @param string $fromVersion
+ */
+ protected function upgrade($fromVersion) {
+ switch ($fromVersion) {
+ case '2.0.0':
+ // Example : upgrade to version 3.0.0
+ // $this->upgradeOneStep('3.0.0');
+ case '3.0.0':
+ // Example : upgrade to version 4.0.0
+ // $this->upgradeOneStep('4.0.0');
+
+ default:
+ }
+ if (PluginFlyvemdmCommon::endsWith(PLUGIN_FLYVEMDM_VERSION, "-dev")) {
+ $this->upgradeOneStep('dev');
+ }
+ }
+
+ /**
+ * Proceed to upgrade of the plugin to the given version
+ *
+ * @param string $toVersion
+ */
+ protected function upgradeOneStep($toVersion) {
+
+ ini_set("max_execution_time", "0");
+ ini_set("memory_limit", "-1");
+
+ $suffix = str_replace('.', '_', $toVersion);
+ $includeFile = __DIR__ . "/upgrade/update_to_$suffix.php";
+ if (is_readable($includeFile) && is_file($includeFile)) {
+ include_once $includeFile;
+ $updateFunction = "plugin_flyvemdm_update_to_$suffix";
+ if (function_exists($updateFunction)) {
+ $this->migration->addNewMessageArea("Upgrade to $toVersion");
+ $updateFunction($this->migration);
+ $this->migration->executeMigration();
+ $this->migration->displayMessage('Done');
+ }
+ }
+ }
+
+ protected function createJobs() {
+ CronTask::Register(PluginFlyvemdmPackage::class, 'ParseApplication', MINUTE_TIMESTAMP,
+ [
+ 'comment' => __('Parse uploaded applications to collect metadata', 'flyvemdm'),
+ 'mode' => CronTask::MODE_EXTERNAL,
+ ]);
+ }
+
+ /**
+ * Uninstall the plugin
+ * @return boolean true (assume success, needs enhancement)
+ */
+ public function uninstall() {
+ $this->rrmdir(GLPI_PLUGIN_DOC_DIR . '/flyvemdm');
+
+ $this->deleteRelations();
+ $this->deleteNotificationTargetInvitation();
+ $this->deleteProfileRights();
+ $this->deleteProfiles();
+ $this->deleteDisplayPreferences();
+ $this->deleteTables();
+ // Cron jobs deletion handled by GLPI
+
+ $config = new Config();
+ $config->deleteByCriteria(['context' => 'flyvemdm']);
+
+ return true;
+ }
+
+ /**
+ * Cannot use the method from PluginFlyvemdmToolbox if the plugin is being uninstalled
+ * @param string $dir
+ */
+ protected function rrmdir($dir) {
+ if (file_exists($dir) && is_dir($dir)) {
+ $objects = scandir($dir);
+ foreach ($objects as $object) {
+ if ($object != "." && $object != "..") {
+ if (filetype($dir . "/" . $object) == "dir") {
+ $this->rrmdir($dir . "/" . $object);
+ } else {
+ unlink($dir . "/" . $object);
+ }
+ }
+ }
+ reset($objects);
+ rmdir($dir);
+ }
+ }
+
+ /**
+ * Generate default configuration for the plugin
+ */
+ protected function createInitialConfig() {
+ $MdmMqttPassword = PluginFlyvemdmMqttuser::getRandomPassword();
+
+ // New config management provided by GLPi
+ $crypto_strong = null;
+ $instanceId = base64_encode(openssl_random_pseudo_bytes(64, $crypto_strong));
+ $newConfig = [
+ 'mqtt_broker_address' => '',
+ 'mqtt_broker_internal_address' => '127.0.0.1',
+ 'mqtt_broker_port' => '1883',
+ 'mqtt_broker_tls_port' => '8883',
+ 'mqtt_tls_for_clients' => '0',
+ 'mqtt_tls_for_backend' => '0',
+ 'mqtt_use_client_cert' => '0',
+ 'mqtt_broker_tls_ciphers' => self::DEFAULT_CIPHERS_LIST,
+ 'mqtt_user' => self::BACKEND_MQTT_USER,
+ 'mqtt_passwd' => $MdmMqttPassword,
+ 'instance_id' => $instanceId,
+ 'registered_profiles_id' => '',
+ 'guest_profiles_id' => '',
+ 'agent_profiles_id' => '',
+ 'service_profiles_id' => '',
+ 'debug_enrolment' => '0',
+ 'debug_noexpire' => '0',
+ 'debug_save_inventory' => '0',
+ 'ssl_cert_url' => '',
+ 'default_device_limit' => '0',
+ 'default_agent_url' => PLUGIN_FLYVEMDM_AGENT_DOWNLOAD_URL,
+ 'android_bugcollecctor_url' => '',
+ 'android_bugcollector_login' => '',
+ 'android_bugcollector_passwd' => '',
+ 'webapp_url' => '',
+ 'demo_mode' => '0',
+ 'demo_time_limit' => '0',
+ 'inactive_registered_profiles_id' => '',
+ 'computertypes_id' => '0',
+ 'agentusercategories_id' => '0',
+ 'invitation_deeplink' => PLUGIN_FLYVEMDM_DEEPLINK,
+ 'show_wizard' => PluginFlyvemdmConfig::WIZARD_WELCOME_BEGIN,
+ ];
+ Config::setConfigurationValues('flyvemdm', $newConfig);
+ $this->createBackendMqttUser(self::BACKEND_MQTT_USER, $MdmMqttPassword);
+ }
+
+ /**
+ * Create MQTT user for the backend and save credentials
+ * @param string $MdmMqttUser
+ * @param string $MdmMqttPassword
+ */
+ protected function createBackendMqttUser($MdmMqttUser, $MdmMqttPassword) {
+ global $DB;
+
+ // Create mqtt credentials for the plugin
+ $mqttUser = new PluginFlyvemdmMqttuser();
+
+ // Check the MQTT user account for the plugin exists
+ if ($mqttUser->getFromDBByCrit(['user' => $MdmMqttUser])) {
+ return;
+ }
+ // Create the MQTT user account for the plugin
+ if (!$mqttUser->add([
+ 'user' => $MdmMqttUser,
+ 'password' => $MdmMqttPassword,
+ 'enabled' => '1',
+ '_acl' => [
+ [
+ 'topic' => '#',
+ 'access_level' => PluginFlyvemdmMqttacl::MQTTACL_READ_WRITE,
+ ],
+ ],
+ ])) {
+ // Failed to create the account
+ $this->migration->displayWarning('Unable to create the MQTT account for FlyveMDM : ' . $DB->error());
+ } else {
+ // Check the ACL has been created
+ $aclList = $mqttUser->getACLs();
+ $mqttAcl = array_shift($aclList);
+ if ($mqttAcl === null) {
+ $this->migration->displayWarning('Unable to create the MQTT ACL for FlyveMDM : ' . $DB->error());
+ }
+
+ // Save MQTT credentials in configuration
+ Config::setConfigurationValues('flyvemdm',
+ ['mqtt_user' => $MdmMqttUser, 'mqtt_passwd' => $MdmMqttPassword]);
+ }
+ }
+
+
+ /**
+ * Generate HTML version of a text
+ * Replaces \n by
+ * Encloses the text un
...
+ * Add anchor to URLs + * @param string $text + * @return string + */ + protected static function convertTextToHtml($text) { + $text = '' . str_replace("\n\n", '
', $text) . '
'; + $text = '' . str_replace("\n", '
', $text) . '