Revision [10581]
This is an old revision of InstallableActions made by DennyShimkoski on 2005-08-10 05:56:36.
An Installation System for Wikka Actions
There are a certain number of patterns popping out of the action development routine -- sometimes there is MySQL, sometimes there is CSS, sometimes there are changes to the configuration file, and there is always PHP code.
It would be nice if we could define all of these things in a single wiki page and then use it as a type of "installation file." That is what this action does.
To start off, we're going to need to make some serious changes to the show handler, the Run() and Action() methods in wikka.php, wikka.config.php, and the header action.
Let's start with the show handler...
<?php
$edit = (!$user || ($user["doubleclickedit"] == 'Y')) && ($this->GetMethod() == "show") ? ' ondblclick="document.location=\'' . $this->href('edit') . '\';"' : '';
$body = "<div class=\"page\" $edit>";
if (!$this->HasAccess("read"))
{
$body .= "<p><em>You aren't allowed to read this page.</em></p></div>";
}
else
{
if (!$this->page)
{
$body .= "<p>This page doesn't exist yet. Maybe you want to <a href=\"".$this->Href("edit")."\">create</a> it?</p></div>";
}
else
{
if ($this->page["latest"] == "N")
{
$body .= "<div class=\"revisioninfo\">This is an old revision of <a href=\"".$this->Href()."\">".$this->GetPageTag()."</a> from ".$this->page["time"].".</div>";
}
if (preg_match_all('/\{\{(.*)\}\}/U', $this->page['body'], $matches))
{
foreach ($matches[1] as $match)
{
if ($space = strpos($match, ' ')) $match = substr($match, 0, $space);
$lc_action = strtolower($match);
if (!isset($this->config['css_files'][$lc_action]) && isset($this->config["{$lc_action}_version"]))
{
$css_file = $this->config['installer_base_dir'] . "/actioncss/$lc_action.css";
if (file_exists($css_file))
$this->config['css_files'][$lc_action] .= $css_file;
}
}
}
$body .= $this->Format($this->page['body'], 'wakka');
// if this is an old revision, display some buttons
if ($this->page["latest"] == "N" && $this->HasAccess("write"))
{
// dotmg modifications : contact m.randimbisoa@dotmg.net
// #dotmg [1 line modified, 2 lines added, 7 lines indented]: added if encapsulation : in case where some pages were brutally deleted from database
if ($latest = $this->LoadPage($this->tag))
{
$body .= '<br />';
$body .= $this->FormOpen('edit');
$body .= '<input type="hidden" name="previous" value="' . $latest['id'] . '" />';
$body .= '<input type="hidden" name="body" value="' . $this->htmlspecialchars_ent($this->page['body']) . '" />';
$body .= '<input type="submit" value="Re-edit this old revision" />';
$body .= $this->FormClose();
}
}
$body .= '</div>';
if ($this->GetConfigValue('hide_comments') != 1)
{
// load comments for this page
$comments = $this->LoadComments($this->tag);
// store comments display in session
$tag = $this->GetPageTag();
if (!isset($_SESSION["show_comments"][$tag]))
$_SESSION["show_comments"][$tag] = ($this->UserWantsComments() ? "1" : "0");
if (isset($_REQUEST["show_comments"])){
switch($_REQUEST["show_comments"])
{
case "0":
$_SESSION["show_comments"][$tag] = 0;
break;
case "1":
$_SESSION["show_comments"][$tag] = 1;
break;
}
}
// display comments!
if ($_SESSION["show_comments"][$tag])
{
// display comments header
$body .= '<div class="commentsheader">';
$body .= '<span id="comments"> </span>Comments [<a href="' . $this->Href("", "", "show_comments=0") . '">Hide comments/form</a>]';
$body .= '</div>';
// display comments themselves
if ($comments)
{
$current_user = $this->GetUserName();
foreach ($comments as $comment)
{
$body .= "<div class=\"comment\"> \n";
$body .= "<span id=\"comment_" . $comment['id'] . '"></span>' . $comment["comment"] . "\n";
$body .= "\t<div class=\"commentinfo\">\n-- " . $this->Format($comment["user"]) . ' (' . $comment['time'] . ")\n";
$current_user = $this->GetUserName();
if ($this->UserIsOwner() || $current_user == $comment["user"] || ($this->config['anony_delete_own_comments'] && $current_user == $comment["user"]) )
{
$body .= $this->FormOpen('delcomment');
$body .= '<input type="hidden" name="comment_id" value="' . $comment['id'] . '" />';
$body .= '<input type="submit" value="Delete Comment" accesskey="d" />';
$body .= $this->FormClose();
}
$body .= "\n\t</div>\n";
$body .= "</div>\n";
}
}
// display comment form
$body .= "<div class=\"commentform\">\n";
if ($this->HasAccess('comment'))
{
$body .= $this->FormOpen("addcomment");
$body .= '<label for="commentbox">Add a comment to this page:<br />';
$body .= '<textarea id="commentbox" name="body" rows="6" cols="78"></textarea><br />';
$body .= '<input type="submit" value="Add Comment" accesskey="s" />';
$body .= '</label>';
$body .= $this->FormClose();
}
$body .= "</div>\n";
}
else
{
$body .= '<div class="commentsheader">';
switch (count($comments))
{
case 0:
$body .= '<p>There are no comments on this page. ';
$showcomments_text = 'Add comment';
break;
case 1:
$body .= '<p>There is one comment on this page. ';
$showcomments_text = 'Display comment';
break;
default:
$body .= '<p>There are ' . count($comments) . ' comments on this page. ';
$showcomments_text = 'Display comments';
}
$body .= '[<a href="' . $this->Href('', '', 'show_comments=1#comments') . "\">$showcomments_text</a>]</p></div>";
}
}
}
}
$page = $this->Header() . $body . $this->Footer();
echo $page;
?>
$edit = (!$user || ($user["doubleclickedit"] == 'Y')) && ($this->GetMethod() == "show") ? ' ondblclick="document.location=\'' . $this->href('edit') . '\';"' : '';
$body = "<div class=\"page\" $edit>";
if (!$this->HasAccess("read"))
{
$body .= "<p><em>You aren't allowed to read this page.</em></p></div>";
}
else
{
if (!$this->page)
{
$body .= "<p>This page doesn't exist yet. Maybe you want to <a href=\"".$this->Href("edit")."\">create</a> it?</p></div>";
}
else
{
if ($this->page["latest"] == "N")
{
$body .= "<div class=\"revisioninfo\">This is an old revision of <a href=\"".$this->Href()."\">".$this->GetPageTag()."</a> from ".$this->page["time"].".</div>";
}
if (preg_match_all('/\{\{(.*)\}\}/U', $this->page['body'], $matches))
{
foreach ($matches[1] as $match)
{
if ($space = strpos($match, ' ')) $match = substr($match, 0, $space);
$lc_action = strtolower($match);
if (!isset($this->config['css_files'][$lc_action]) && isset($this->config["{$lc_action}_version"]))
{
$css_file = $this->config['installer_base_dir'] . "/actioncss/$lc_action.css";
if (file_exists($css_file))
$this->config['css_files'][$lc_action] .= $css_file;
}
}
}
$body .= $this->Format($this->page['body'], 'wakka');
// if this is an old revision, display some buttons
if ($this->page["latest"] == "N" && $this->HasAccess("write"))
{
// dotmg modifications : contact m.randimbisoa@dotmg.net
// #dotmg [1 line modified, 2 lines added, 7 lines indented]: added if encapsulation : in case where some pages were brutally deleted from database
if ($latest = $this->LoadPage($this->tag))
{
$body .= '<br />';
$body .= $this->FormOpen('edit');
$body .= '<input type="hidden" name="previous" value="' . $latest['id'] . '" />';
$body .= '<input type="hidden" name="body" value="' . $this->htmlspecialchars_ent($this->page['body']) . '" />';
$body .= '<input type="submit" value="Re-edit this old revision" />';
$body .= $this->FormClose();
}
}
$body .= '</div>';
if ($this->GetConfigValue('hide_comments') != 1)
{
// load comments for this page
$comments = $this->LoadComments($this->tag);
// store comments display in session
$tag = $this->GetPageTag();
if (!isset($_SESSION["show_comments"][$tag]))
$_SESSION["show_comments"][$tag] = ($this->UserWantsComments() ? "1" : "0");
if (isset($_REQUEST["show_comments"])){
switch($_REQUEST["show_comments"])
{
case "0":
$_SESSION["show_comments"][$tag] = 0;
break;
case "1":
$_SESSION["show_comments"][$tag] = 1;
break;
}
}
// display comments!
if ($_SESSION["show_comments"][$tag])
{
// display comments header
$body .= '<div class="commentsheader">';
$body .= '<span id="comments"> </span>Comments [<a href="' . $this->Href("", "", "show_comments=0") . '">Hide comments/form</a>]';
$body .= '</div>';
// display comments themselves
if ($comments)
{
$current_user = $this->GetUserName();
foreach ($comments as $comment)
{
$body .= "<div class=\"comment\"> \n";
$body .= "<span id=\"comment_" . $comment['id'] . '"></span>' . $comment["comment"] . "\n";
$body .= "\t<div class=\"commentinfo\">\n-- " . $this->Format($comment["user"]) . ' (' . $comment['time'] . ")\n";
$current_user = $this->GetUserName();
if ($this->UserIsOwner() || $current_user == $comment["user"] || ($this->config['anony_delete_own_comments'] && $current_user == $comment["user"]) )
{
$body .= $this->FormOpen('delcomment');
$body .= '<input type="hidden" name="comment_id" value="' . $comment['id'] . '" />';
$body .= '<input type="submit" value="Delete Comment" accesskey="d" />';
$body .= $this->FormClose();
}
$body .= "\n\t</div>\n";
$body .= "</div>\n";
}
}
// display comment form
$body .= "<div class=\"commentform\">\n";
if ($this->HasAccess('comment'))
{
$body .= $this->FormOpen("addcomment");
$body .= '<label for="commentbox">Add a comment to this page:<br />';
$body .= '<textarea id="commentbox" name="body" rows="6" cols="78"></textarea><br />';
$body .= '<input type="submit" value="Add Comment" accesskey="s" />';
$body .= '</label>';
$body .= $this->FormClose();
}
$body .= "</div>\n";
}
else
{
$body .= '<div class="commentsheader">';
switch (count($comments))
{
case 0:
$body .= '<p>There are no comments on this page. ';
$showcomments_text = 'Add comment';
break;
case 1:
$body .= '<p>There is one comment on this page. ';
$showcomments_text = 'Display comment';
break;
default:
$body .= '<p>There are ' . count($comments) . ' comments on this page. ';
$showcomments_text = 'Display comments';
}
$body .= '[<a href="' . $this->Href('', '', 'show_comments=1#comments') . "\">$showcomments_text</a>]</p></div>";
}
}
}
}
$page = $this->Header() . $body . $this->Footer();
echo $page;
?>
As you can see, the entire file has changed. Instead of directly printing everything to output, we're collecting it in a variable called $page. Also, we've moved the calls to Header() and Footer() out of the Run() method in wikka.php and put them at the bottom of this file. This will allow for actions to send headers to the browser directly, rather than requiring developers to define a separate "handler" file for such cases. With this setup, actions will now be processed before anything is sent back to the browser.
The other notable change to the show handler is this snippet of code:
if (preg_match_all('/\{\{(.*)\}\}/U', $this->page['body'], $matches))
{
foreach ($matches[1] as $match)
{
if ($space = strpos($match, ' ')) $match = substr($match, 0, $space);
$lc_action = strtolower($match);
if (!isset($this->config['css_files'][$lc_action]) && isset($this->config["{$lc_action}_version"]))
{
$css_file = $this->config['installer_base_dir'] . "/actioncss/$lc_action.css";
if (file_exists($css_file))
$this->config['css_files'][$lc_action] .= $css_file;
}
}
}
{
foreach ($matches[1] as $match)
{
if ($space = strpos($match, ' ')) $match = substr($match, 0, $space);
$lc_action = strtolower($match);
if (!isset($this->config['css_files'][$lc_action]) && isset($this->config["{$lc_action}_version"]))
{
$css_file = $this->config['installer_base_dir'] . "/actioncss/$lc_action.css";
if (file_exists($css_file))
$this->config['css_files'][$lc_action] .= $css_file;
}
}
}
Here we look through the current page for any actions that are installed on the system (through the InstallableActions interface). If we find any that included a CSS file as part of their installation package, we add the corresponding CSS file to an array.
Next we have to edit the header action to include these files in the output. Open up actions/header.php and locate the line that prints out the stylesheets...
<link rel="stylesheet" type="text/css" href="css/<?php echo $this->GetConfigValue("stylesheet") ?>" />
<?php
if (is_array($this->config['css_files']))
{
foreach ($this->config['css_files'] as $css_file)
{
echo '<link rel="stylesheet" type="text/css" href="' . $css_file . "\" />\n";
}
}
?>
<?php
if (is_array($this->config['css_files']))
{
foreach ($this->config['css_files'] as $css_file)
{
echo '<link rel="stylesheet" type="text/css" href="' . $css_file . "\" />\n";
}
}
?>
The php code should be inserted after the main wikka CSS stylesheet, as indicated above.
Now it's time to replace the Action() method in wikka.php:
function Action($action, $forceLinkTracking = 0)
{
$action = trim($action);
$vars=array();
// only search for parameters if there is a space
if (is_int(strpos($action, ' ')))
{
// treat everything after the first whitespace as parameter
preg_match("/^([A-Za-z0-9]*)\s+(.*)$/", $action, $matches);
// extract $action and $vars_temp ("raw" attributes)
list(, $action, $vars_temp) = $matches;
if ($action) {
// match all attributes (key and value)
preg_match_all("/([A-Za-z0-9]*)=\"(.*)\"/U", $vars_temp, $matches);
// prepare an array for extract() to work with (in $this->IncludeBuffered())
if (is_array($matches)) {
for ($a = 0; $a < count($matches[0]); $a++) {
$vars[$matches[1][$a]] = $matches[2][$a];
}
}
$vars["wikka_vars"] = trim($vars_temp); // <<< add the buffered parameter-string to the array
} else {
return "<span class='error'><em>Unknown action; the action name must not contain special characters.</em></span>"; // <<< the pattern ([A-Za-z0-9])\s+ didn't match!
}
}
if (!preg_match("/^[a-zA-Z0-9]+$/", $action)) return "<span class='error'><em>Unknown action; the action name must not contain special characters.</em></span>";
if (!$forceLinkTracking) $this->StopLinkTracking();
$action_name = strtolower($action);
$action_path = isset($this->config["{$action_name}_version"]) ? $this->config['installer_base_dir'] . '/actions/' : $this->config['action_path'];
$result = $this->IncludeBuffered("$action_name.php", "<em>Unknown action \"$action\"</em>", $vars, $action_path);
$this->StartLinkTracking();
return $result;
}
{
$action = trim($action);
$vars=array();
// only search for parameters if there is a space
if (is_int(strpos($action, ' ')))
{
// treat everything after the first whitespace as parameter
preg_match("/^([A-Za-z0-9]*)\s+(.*)$/", $action, $matches);
// extract $action and $vars_temp ("raw" attributes)
list(, $action, $vars_temp) = $matches;
if ($action) {
// match all attributes (key and value)
preg_match_all("/([A-Za-z0-9]*)=\"(.*)\"/U", $vars_temp, $matches);
// prepare an array for extract() to work with (in $this->IncludeBuffered())
if (is_array($matches)) {
for ($a = 0; $a < count($matches[0]); $a++) {
$vars[$matches[1][$a]] = $matches[2][$a];
}
}
$vars["wikka_vars"] = trim($vars_temp); // <<< add the buffered parameter-string to the array
} else {
return "<span class='error'><em>Unknown action; the action name must not contain special characters.</em></span>"; // <<< the pattern ([A-Za-z0-9])\s+ didn't match!
}
}
if (!preg_match("/^[a-zA-Z0-9]+$/", $action)) return "<span class='error'><em>Unknown action; the action name must not contain special characters.</em></span>";
if (!$forceLinkTracking) $this->StopLinkTracking();
$action_name = strtolower($action);
$action_path = isset($this->config["{$action_name}_version"]) ? $this->config['installer_base_dir'] . '/actions/' : $this->config['action_path'];
$result = $this->IncludeBuffered("$action_name.php", "<em>Unknown action \"$action\"</em>", $vars, $action_path);
$this->StartLinkTracking();
return $result;
}
Only a few lines at the bottom have changed, but I included the whole thing so you don't have to scratch your head about where to insert the new code.
We'll do the same for the Run() method in the same file:
function Run($tag, $method = "")
{
// do our stuff!
if (!$this->method = trim($method)) $this->method = "show";
if (!$this->tag = trim($tag)) $this->Redirect($this->Href("", $this->config["root_page"]));
if ((!$this->GetUser() && isset($_COOKIE["wikka_user_name"])) && ($user = $this->LoadUser($_COOKIE["wikka_user_name"], $_COOKIE["wikka_pass"]))) $this->SetUser($user);
$this->SetPage($this->LoadPage($tag, (isset($_REQUEST["time"]) ? $_REQUEST["time"] :'')));
$this->LogReferrer();
$this->ACLs = $this->LoadAllACLs($this->tag);
$this->ReadInterWikiConfig();
if(!($this->GetMicroTime()%3)) $this->Maintenance();
if (preg_match('/\.(xml|mm)$/', $this->method))
{
header("Content-type: text/xml");
print($this->Method($this->method));
}
// raw page handler
elseif ($this->method == "raw")
{
header("Content-type: text/plain");
print($this->Method($this->method));
}
elseif (preg_match('/\.(gif|jpg|png)$/', $this->method))
{
header('Location: images/' . $this->method);
}
elseif (preg_match('/\.css$/', $this->method))
{
header('Location: css/' . $this->method);
}
elseif ($this->method == 'show')
{
// show will handle the display of headers and footers from now on.
print($this->Method('show'));
}
else
{
print($this->Header().$this->Method($this->method).$this->Footer());
}
}
}
{
// do our stuff!
if (!$this->method = trim($method)) $this->method = "show";
if (!$this->tag = trim($tag)) $this->Redirect($this->Href("", $this->config["root_page"]));
if ((!$this->GetUser() && isset($_COOKIE["wikka_user_name"])) && ($user = $this->LoadUser($_COOKIE["wikka_user_name"], $_COOKIE["wikka_pass"]))) $this->SetUser($user);
$this->SetPage($this->LoadPage($tag, (isset($_REQUEST["time"]) ? $_REQUEST["time"] :'')));
$this->LogReferrer();
$this->ACLs = $this->LoadAllACLs($this->tag);
$this->ReadInterWikiConfig();
if(!($this->GetMicroTime()%3)) $this->Maintenance();
if (preg_match('/\.(xml|mm)$/', $this->method))
{
header("Content-type: text/xml");
print($this->Method($this->method));
}
// raw page handler
elseif ($this->method == "raw")
{
header("Content-type: text/plain");
print($this->Method($this->method));
}
elseif (preg_match('/\.(gif|jpg|png)$/', $this->method))
{
header('Location: images/' . $this->method);
}
elseif (preg_match('/\.css$/', $this->method))
{
header('Location: css/' . $this->method);
}
elseif ($this->method == 'show')
{
// show will handle the display of headers and footers from now on.
print($this->Method('show'));
}
else
{
print($this->Header().$this->Method($this->method).$this->Footer());
}
}
}
A special case for the show handler has been added -- the elseif ($this->method
'show') part. That's the only change.
Now we need to open up wikka.config.php and add the following lines to the bottom of the file:
// load actions defined by InstallableActions
// wikka.config.php settings will take precedence
include('uploads/action.config.php');
$wakkaConfig = array_merge($action_config, $wakkaConfig);
As you can see, the InstallableActions action has its own config file. Whether or not this is more secure is debatable, but at least it means the Installer isn't going to trash all of your settings when it tries to write a new config file.
Speaking of writing a new config file, maybe we should go ahead and do that. Save the following code as uploads/action.config.php:
Also, create two directories in the uploads directory named "actions" and "actioncss"
Save the following in the wikka root directory as "util.php"...
<?php
function smart_title($wikka_body)
{
return preg_match('/(=){2,5}([^=]*)(=){2,5}/', $wikka_body, $matches) ? $matches[2] : '';
}
function fetch_section($wikka_body, $section)
{
return preg_match("/(=){2,5}$section(=){2,5}([^=]*)(\n\n)/ism", $wikka_body, $matches) ? $matches[3] : false;
}
function fetch_code_block($wikka_body, $language)
{
return preg_match("/
\($language\)(.*)
/ismU", $wikka_body, $matches) ? $matches[1] : false;
}
if (!function_exists('file_put_contents'))
{
function file_put_contents($filename, $data, $file_append = false)
{
$fp = fopen($filename, (!$file_append ? 'w+' : 'a+'));
if(!$fp) return false;
fputs($fp, $data);
fclose($fp);
return true;
}
}
?>
Save the following in the actions directory as installer.php:
<?php
// where will this installer put the files?
$installer_base_dir = $this->config['installer_base_dir'];
$table_pages = $this->config['table_prefix'] . 'pages';
include_once('util.php');
function parse_config_settings($config)
{
if ($config)
{
$settings = split("\n", $config);
foreach ($settings as $setting)
{
if ($setting = trim($setting))
{
list($key, $val) = split('=>', $setting);
$config_vars[trim($key)] = trim(str_replace("'", '', $val));
}
}
return $config_vars;
}
return false;
}
$sql = "SELECT tag, body FROM $table_pages WHERE MATCH (body) AGAINST ('InstallableAction') AND latest = 'Y' ORDER BY tag";
$result = mysql_query($sql);
if ($result && mysql_num_rows($result))
{
while ($row = mysql_fetch_array($result))
{
$version_tag = strtolower($row['tag']) . '_version';
$regex = "$version_tag.*'(.*)'";
$current_version = preg_match("|$regex|U", $row['body'], $match) ? $match[1] : null;
if (strstr($row['body'], '
(php)') && $current_version)
{
$currently_installing = isset($_REQUEST['installer_install']) && $_REQUEST['installer_install']
$row['tag'];
$installable_actions[$row['tag']] = $row['body'];
$action = '<a href="' . $this->Href(, $row['tag']) . "\">$row[tag]</a>";
$installed_version = isset($this->config[$version_tag]) && !$currently_installing ? $this->config[$version_tag] : '--';
if (!$summary = smart_title($row['body'])) $summary = 'None';
$mysql = strstr($row['body'], '
(css)') ? 'Yes' : 'No';
if ($installed_version
\n\n
$config_msg
\n";
// wikka.config.php settings will take precedence
include('uploads/action.config.php');
$wakkaConfig = array_merge($action_config, $wakkaConfig);
function smart_title($wikka_body)
{
return preg_match('/(=){2,5}([^=]*)(=){2,5}/', $wikka_body, $matches) ? $matches[2] : '';
}
function fetch_section($wikka_body, $section)
{
return preg_match("/(=){2,5}$section(=){2,5}([^=]*)(\n\n)/ism", $wikka_body, $matches) ? $matches[3] : false;
}
function fetch_code_block($wikka_body, $language)
{
return preg_match("/
/ismU", $wikka_body, $matches) ? $matches[1] : false; } if (!function_exists('file_put_contents')) { function file_put_contents($filename, $data, $file_append = false) { $fp = fopen($filename, (!$file_append ? 'w+' : 'a+')); if(!$fp) return false; fputs($fp, $data); fclose($fp); return true; } } ?>
// where will this installer put the files?
$installer_base_dir = $this->config['installer_base_dir'];
$table_pages = $this->config['table_prefix'] . 'pages';
include_once('util.php');
function parse_config_settings($config)
{
if ($config)
{
$settings = split("\n", $config);
foreach ($settings as $setting)
{
if ($setting = trim($setting))
{
list($key, $val) = split('=>', $setting);
$config_vars[trim($key)] = trim(str_replace("'", '', $val));
}
}
return $config_vars;
}
return false;
}
$sql = "SELECT tag, body FROM $table_pages WHERE MATCH (body) AGAINST ('InstallableAction') AND latest = 'Y' ORDER BY tag";
$result = mysql_query($sql);
if ($result && mysql_num_rows($result))
{
while ($row = mysql_fetch_array($result))
{
$version_tag = strtolower($row['tag']) . '_version';
$regex = "$version_tag.*'(.*)'";
$current_version = preg_match("|$regex|U", $row['body'], $match) ? $match[1] : null;
if (strstr($row['body'], '
{
$currently_installing = isset($_REQUEST['installer_install']) && $_REQUEST['installer_install']
$row['tag'];
$installable_actions[$row['tag']] = $row['body'];
$action = '<a href="' . $this->Href(, $row['tag']) . "\">$row[tag]</a>";
$installed_version = isset($this->config[$version_tag]) && !$currently_installing ? $this->config[$version_tag] : '--';
if (!$summary = smart_title($row['body'])) $summary = 'None';
$mysql = strstr($row['body'], '
(css)') ? 'Yes' : 'No';
if ($installed_version
\n\n
\n";
$config_msg