First pass at wiki forms to CSV, a=chris

Chris Pollett [2022-09-09 04:Sep:th]
First pass at wiki forms to CSV, a=chris
Filename
src/configs/Config.php
src/controllers/Controller.php
src/controllers/RegisterController.php
src/controllers/components/SocialComponent.php
src/css/search.css
src/library/FetchUrl.php
src/library/WikiParser.php
src/models/GroupModel.php
src/scripts/spreadsheet.js
src/views/elements/WikiElement.php
diff --git a/src/configs/Config.php b/src/configs/Config.php
index 53ccfbb06..adb96f771 100755
--- a/src/configs/Config.php
+++ b/src/configs/Config.php
@@ -1264,6 +1264,15 @@ nsdefine('NUM_FIELD_LEN', 4);
 nsdefine('WRITING_MODE_LEN', 5);
 /** Max user session size */
 nsdefine('MAX_USER_SESSION_SIZE', 16384);
+/*
+ * Wiki forms related
+ */
+/** Max size of CSV associated with a  user created wiki form */
+nsconddefine('MAX_WIKI_FORM_CSV_SIZE', 5000000);
+/** Max number of fields that a user created wiki form can have */
+nsconddefine('MAX_WIKI_FORM_FIELDS', 50);
+/** Name to use for csv resource associated with a wiki form page */
+nsconddefine('WIKI_FORM_CSV_FILE', "form_data.csv");
 /*
  * Adjustable CHAT BOT RELATED defines
  */
diff --git a/src/controllers/Controller.php b/src/controllers/Controller.php
index 147d559b7..5d2e1a439 100755
--- a/src/controllers/Controller.php
+++ b/src/controllers/Controller.php
@@ -634,18 +634,22 @@ abstract class Controller
      * matches the given user and has not expired (1 hour till expires)
      *
      * @param string $token_name attribute of $_REQUEST containing CSRFToken
-     * @param string $user  user id
+     * @param string $user_id  user id of the user to check the token for
+     * @param bool $use_name_as_passed whether to use $token_name as the
+     *  token (if true) or to use $_REQUEST[$token_name]
      * @return bool  whether the CSRF token was valid
      */
-    public function checkCSRFToken($token_name, $user)
+    public function checkCSRFToken($token_name, $user_id,
+        $use_name_as_passed = false)
     {
         $token_okay = false;
-        if (isset($_REQUEST[$token_name]) &&
-            strlen($_REQUEST[$token_name]) == 22) {
-            $token_parts = explode("*", $_REQUEST[$token_name]);
+        $token_value = ($use_name_as_passed) ? $token_name :
+            $_REQUEST[$token_name] ?? "";
+        if (strlen($token_value) == 22) {
+            $token_parts = explode("*", $token_value);
             if (isset($token_parts[1]) &&
                 $token_parts[1] + C\ONE_HOUR > time() &&
-                L\crawlHash($user . $token_parts[1] . C\AUTH_KEY) ==
+                L\crawlHash($user_id . $token_parts[1] . C\AUTH_KEY) ==
                 $token_parts[0]) {
                 $token_okay = true;
             }
@@ -715,6 +719,37 @@ abstract class Controller
         }
         return $timestamp;
     }
+    /**
+     * Sets up the graphical captcha view
+     * Draws the string for graphical captcha
+     *
+     * @param array &$data used by view to draw any dynamic content
+     *     in this case we append a field "CAPTCHA_IMAGE" with a data
+     *     url of the captcha to draw.
+     */
+    public function setupGraphicalCaptchaViewData(&$data)
+    {
+        if (empty($_SESSION)) {
+            $_SESSION = [];
+        }
+        unset($_SESSION["captcha_text"]);
+        // defines captcha text
+        $characters_for_captcha = '123456789abcdefghijklmnpqrstuvwxyz'.
+            'ABCDEFGHIJKLMNPQRSTUVWXYZ';
+        $len = strlen($characters_for_captcha);
+        // selecting letters for captcha
+        $captcha_letter = $characters_for_captcha[rand(0, $len - 1)];
+        $word = "";
+        for ($i = 0; $i < C\CAPTCHA_LEN; $i++) {
+            // selecting letters for captcha
+            $captcha_letter = $characters_for_captcha[rand(0, $len - 1)];
+            $word = $word . $captcha_letter;
+         }
+         // stores the captcha in a session variable 'captcha_text'
+        $_SESSION['captcha_text'] = $word;
+        $data["CAPTCHA_IMAGE"] =
+            $this->model("captcha")->makeGraphicalCaptcha($word);
+    }
     /**
      * Used to clean strings that might be tainted as originate from the user
      *
diff --git a/src/controllers/RegisterController.php b/src/controllers/RegisterController.php
index 0dc1d5df9..6e8e34128 100755
--- a/src/controllers/RegisterController.php
+++ b/src/controllers/RegisterController.php
@@ -826,37 +826,6 @@ class RegisterController extends Controller implements CrawlConstants
         }
         return $data;
     }
-    /**
-     * Sets up the graphical captcha view
-     * Draws the string for graphical captcha
-     *
-     * @param array &$data used by view to draw any dynamic content
-     *     in this case we append a field "CAPTCHA_IMAGE" with a data
-     *     url of the captcha to draw.
-     */
-    public function setupGraphicalCaptchaViewData(&$data)
-    {
-        if (empty($_SESSION)) {
-            $_SESSION = [];
-        }
-        unset($_SESSION["captcha_text"]);
-        // defines captcha text
-        $characters_for_captcha = '123456789abcdefghijklmnpqrstuvwxyz'.
-            'ABCDEFGHIJKLMNPQRSTUVWXYZ';
-        $len = strlen($characters_for_captcha);
-        // selecting letters for captcha
-        $captcha_letter = $characters_for_captcha[rand(0, $len - 1)];
-        $word = "";
-        for ($i = 0; $i < C\CAPTCHA_LEN; $i++) {
-            // selecting letters for captcha
-            $captcha_letter = $characters_for_captcha[rand(0, $len - 1)];
-            $word = $word . $captcha_letter;
-         }
-         // stores the captcha in a session variable 'captcha_text'
-        $_SESSION['captcha_text'] = $word;
-        $data["CAPTCHA_IMAGE"] =
-            $this->model("captcha")->makeGraphicalCaptcha($word);
-    }
     /**
      * Gets a list of translated recovery questions from the register view.
      * If insufficiently many questions have been translated for the current
diff --git a/src/controllers/components/SocialComponent.php b/src/controllers/components/SocialComponent.php
index bafd90b64..b17b77966 100644
--- a/src/controllers/components/SocialComponent.php
+++ b/src/controllers/components/SocialComponent.php
@@ -3438,6 +3438,11 @@ class SocialComponent extends Component implements CrawlConstants
             if ($data['MODE'] == "read") {
                 $data['GROUP_STATUS'] = $group['STATUS'];
                 $data['JUST_THREAD'] = true;
+                if ((!empty($_POST['RCSVFORM']) || !empty($_POST['CSVFORM']))
+                    && !empty($_POST[C\CSRF_TOKEN])) {
+                    $this->processWikiFormData($data, $user_id, $group_id,
+                        $sub_path);
+                }
                 $this->initializeReadMode($data, $user_id, $group_id,
                     $sub_path);
             } else if (in_array($data['MODE'], ['edit', 'source'])) {
@@ -3461,7 +3466,8 @@ class SocialComponent extends Component implements CrawlConstants
                 $data['settings'] = "false";
                 if (!empty($data['RESOURCE_NAME'])) {
                     $name_parts = pathinfo($data['RESOURCE_NAME']);
-                    if (!empty($name_parts['extension'])) {
+                    if (!empty($name_parts['extension']) &&
+                        empty($data['RAW'])) {
                         switch ($name_parts['extension']) {
                             case 'csv':
                                 $user_config = "";
@@ -3560,6 +3566,118 @@ class SocialComponent extends Component implements CrawlConstants
         $this->updateGetWikiImpressionInfo($data, $user_id, $group_id);
         return $data;
     }
+    /**
+     *
+     * @param array &$data associative array of values to be echoed by the view
+     * @param int $user_id id of user requesting a wiki page
+     * @param int $group_id group in which wiki page belongs
+     * @param string $sub_path any path within wiki page folder for resources
+     */
+    private function processWikiFormData($data, $user_id, $group_id,
+        $sub_path)
+    {
+        $parent = $this->parent;
+        $group_model = $parent->model("group");
+        if (empty($data['PAGE_ID']) || empty($group_id)) {
+            return;
+        }
+        $user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
+        $default_folders = $group_model->getGroupPageResourcesFolders($group_id,
+            $data['PAGE_ID']);
+        $csv_filepath = $default_folders[0] . '/' . C\WIKI_FORM_CSV_FILE;
+        if (!$parent->checkCSRFToken($_POST[C\CSRF_TOKEN], $user_id, true)) {
+            return $parent->redirectWithMessage(
+                tl('social_component_page_data_expired'),
+                ['arg', 'page_name', 'settings', 'caret', 'scroll_top', 'sf']);
+        }
+        $tmp_page = preg_replace("/\[{form\-hash(.+?)}\]/", "[{form-hash}]",
+            $data['PAGE']);
+        $csv_form_hash = L\crawlHash(C\AUTH_KEY . $_POST[C\CSRF_TOKEN] .
+            L\crawlHash($tmp_page));
+        if ($csv_form_hash != $_POST['CSV_FORM_HASH']) {
+            return $parent->redirectWithMessage(
+                tl('social_component_page_integrity_issue'),
+                ['arg', 'page_name', 'settings', 'caret', 'scroll_top', 'sf']);
+        }
+        $csv_headers = [];
+        if (empty($_POST['CSVFORM']['user_captcha_text'])) {
+            return $parent->redirectWithMessage(
+                tl('social_component_form_needs_captcha'),
+                ['arg', 'page_name', 'settings', 'caret', 'scroll_top', 'sf']);
+        }
+        $num_fields = count($_POST['CSVFORM'] ?? []) +
+            count($_POST['RCSVFORM'] ?? []);
+        if ($num_fields > C\MAX_WIKI_FORM_FIELDS) {
+            return $parent->redirectWithMessage(
+                tl('social_component_too_many_fields_form'),
+                ['arg', 'page_name', 'settings', 'caret', 'scroll_top', 'sf']);
+        }
+        $csv_form_info = [$_POST['CSVFORM'] ?? [] , $_POST['RCSVFORM'] ??[]];
+        foreach ($csv_form_info as $csv_form_fields) {
+            foreach ($csv_form_fields as $form_field => $field_type) {
+                $form_field = substr(
+                    $parent->clean($form_field, 'string'), 0, C\NAME_LEN);
+                if ($field_type == 'submit') {
+                } else if (in_array($form_field, $csv_headers)) {
+                    continue;
+                } else {
+                    $csv_headers[] = $form_field;
+                }
+            }
+        }
+        if (file_exists($csv_filepath)) {
+            if (filesize($csv_filepath) > C\MAX_WIKI_FORM_CSV_SIZE) {
+                return $parent->redirectWithMessage(
+                    tl('social_component_csv_too_big'), ['arg', 'page_name',
+                    'settings', 'caret', 'scroll_top', 'sf']);
+            }
+            $fh = fopen($csv_filepath, "a+");
+        } else {
+            $fh = fopen($csv_filepath, "w+");
+            fputcsv($fh, $csv_headers);
+        }
+        $out_row = [];
+        foreach($csv_headers as $csv_header) {
+            $is_required = (isset($_POST['RCSVFORM'][$csv_header])) ? 1 : 0;
+            if ($is_required && empty($_POST[$csv_header])) {
+                return $parent->redirectWithMessage(
+                    tl('social_component_fill_required_fields'), array_merge(
+                    ['arg', 'page_name', 'settings', 'caret', 'scroll_top',
+                    'sf'], $csv_headers));
+            }
+            if ($csv_header == 'user_captcha_text' &&
+                (empty($_SESSION['captcha_text']) ||
+                $_POST[$csv_header] != $_SESSION['captcha_text'])) {
+                $parent->model("visitor")->updateVisitor(
+                    L\remoteAddress(), "captcha_time_out");
+                return $parent->redirectWithMessage(
+                    tl('social_component_captcha_failed'), array_merge(
+                    ['arg', 'page_name', 'settings', 'caret', 'scroll_top',
+                    'sf'], $csv_headers));
+            }
+            $header_type = $_POST['CSVFORM'][$csv_header] ?? "textfield";
+            $clean_field = $parent->clean($_POST[$csv_header] ?? "", "string");
+            if ($header_type == "submit") {
+                continue;
+            } else if ($header_type == "checkbox") {
+                $clean_field = empty($clean_field) ? false : true;
+            } else {
+                $max_lengths = [
+                    "radio" => C\NAME_LEN,
+                    "textfield" => C\LONG_NAME_LEN,
+                    "textarea" => C\TITLE_LEN
+                ];
+                $clean_field = substr($clean_field, 0,
+                    $max_lengths[$header_type]);
+            }
+            $out_row[] = $clean_field;
+        }
+        fputcsv($fh, $out_row);
+        fclose($fh);
+        return $parent->redirectWithMessage(
+            tl('social_component_choices_recorded'), ['arg', 'page_name',
+            'settings', 'caret', 'scroll_top', 'sf']);
+    }
     /**
      * Sets up view variables for wiki pages when in read mode. If
      * a user send a command to indicate a media resource on a media list
@@ -3654,6 +3772,9 @@ class SocialComponent extends Component implements CrawlConstants
 EOD;
             }
         }
+        if (strpos($data["PAGE"], "[{image-captcha}]") !== false) {
+            $parent->setupGraphicalCaptchaViewData($data);
+        }
         if (strpos($data["PAGE"], "spreadsheet_data") !== false) {
             if (!in_array("spreadsheet", $data["INCLUDE_SCRIPTS"])) {
                 $data["INCLUDE_SCRIPTS"][] = "spreadsheet";
@@ -4057,9 +4178,12 @@ EOD;
                 in_array($name_parts['extension'], ['csv', 'txt',
                     'tex', 'php', 'sql', 'html', 'java', 'py',
                     'pl', 'P', 'srt'])) {
+                $extension = $name_parts['extension'];
+                $data['RAW'] = !empty($_REQUEST['download']);
                 $data['PAGE'] = $group_model->getPageResource(
-                     $file_name, $group_id, $page_info['ID'], $sub_path);
-                if ($name_parts['extension'] != 'csv') {
+                    $file_name, $group_id, $page_info['ID'], $sub_path,
+                    $data['RAW']);
+                if (empty($data['RAW']) && $name_parts['extension'] != 'csv') {
                     $data['PAGE'] = htmlentities($data['PAGE']);
                 }
             } else {
@@ -4230,6 +4354,12 @@ EOD;
                     tl('social_component_page_created', $page_name),
                     tl('social_component_page_discuss_here'),
                     $read_address, $additional_substitutions);
+                if (empty($page_info['ID'])) {
+                    return $parent->redirectWithMessage(
+                        tl('social_component_page_not_saved'),
+                        ['arg', 'page_name', 'settings',
+                        'caret', 'scroll_top', 'sf', "n"]);
+                }
                 if ($set_path && !empty($page_info['ID'])) {
                     $tmp = $group_model->getGroupPageResourcesFolders(
                         $group_id, $page_info['ID'], "", true, false);
@@ -4377,7 +4507,7 @@ EOD;
         } else if (isset($_REQUEST['resource_description'])) {
             $resource_description = $parent->clean(
                 $_REQUEST['resource_description'], "string");
-            if (!($resource_name = $parent->clean(urldecode($_REQUEST['n']?? ""),
+            if (!($resource_name = $parent->clean(urldecode($_REQUEST['n']??""),
                 "file_name"))) {
                 return $parent->redirectWithMessage(
                     tl('social_component_resource_description_file_error'),
@@ -4743,7 +4873,7 @@ EOD;
         if (substr($mime_type, 0, 4) == 'text') {
             $this->parent->recordViewSession($page_id, $sub_path, $media_name);
         }
-        if ($mime_type == "text/csv") {
+        if ($mime_type == "text/csv" && empty($data['RAW'])) {
             $data['INCLUDE_SCRIPTS'][] = 'spreadsheet';
             $data['SPREADSHEET'] = true;
         }
diff --git a/src/css/search.css b/src/css/search.css
index aafa388a3..9be5756f4 100755
--- a/src/css/search.css
+++ b/src/css/search.css
@@ -3923,6 +3923,22 @@ td.instruct
 {
     max-width:100%;
 }
+.html-ltr .wiki-resource-download
+{
+    height: 0;
+    float: right;
+    position:relative;
+    right: 30px;
+    top: -50px;
+}
+.html-rtl .wiki-resource-download
+{
+    height: 0;
+    float: left;
+    position:relative;
+    left: 30px;
+    top: -50px;
+}
 .ebook,
 .wiki-resource-object
 {
diff --git a/src/library/FetchUrl.php b/src/library/FetchUrl.php
index 725b78556..0b09e3ea2 100755
--- a/src/library/FetchUrl.php
+++ b/src/library/FetchUrl.php
@@ -149,6 +149,9 @@ class FetchUrl implements CrawlConstants
             list($sites[$i][$key], $url, $headers, $dns_resolve, $referer) =
                 self::prepareUrlHeaders($sites[$i][$key], $proxy_servers,
                 $temp_dir, !$minimal);
+            if (empty($url)) {
+                continue;
+            }
             if ($headers == "gopher") {
                 $is_gopher = true;
                 $sites[$i][CrawlConstants::IS_GOPHER_URL] = $is_gopher;
diff --git a/src/library/WikiParser.php b/src/library/WikiParser.php
index 7af8cf546..d410c35d9 100644
--- a/src/library/WikiParser.php
+++ b/src/library/WikiParser.php
@@ -191,6 +191,51 @@ class WikiParser implements CrawlConstants
             ['/\r/', ""],
         ];
         $braces_substitutions = [
+            ["/{{username}}/si",
+                "<input type='hidden' name='username' value='[{username}]' />"],
+            ["/{{image-captcha\|(.+?)}}/si", "<div class='csv-captcha'>".
+                "<label for='captcha-id'>$1</label> [{image-captcha}]".
+                "<input id='captcha-id' type='text' name='user_captcha_text'/>".
+                "*</div><input type='hidden' name='CSVFORM[user_captcha_text]'.
+                value='textfield' />"],
+            ["/{{textfield\|(.+?)\|(.+?)}}/si",
+                "<label for='$2-id'>$1</label> <input id='$2-id' ".
+                "type='text' name='$2' value='[{csv-$2}]'>".
+                "<input type='hidden' name='CSVFORM[$2]' value='textfield' />"],
+            ["/{{r(?:equired)?-textfield\|(.+?)\|(.+?)}}/si",
+                "<label for='$2-id'>$1</label> <input id='$2-id' ".
+                "type='text' name='$2' value='[{csv-$2}]'>".
+                "<input type='hidden' name='CSVFORM[$2]' ".
+                "value='textfield' /><span clas='csv-star'>*</span>"],
+            ["/{{textarea\|(.+?)\|(.+?)}}/si",
+                "<label for='$2-id'>$1</label><br /><textarea id='$2-id' ".
+                "name='$2' class='short-text-area'>[{csv-$2}]</textarea>" .
+                "<input type='hidden' name='CSVFORM[$2]' value='textarea' />"],
+            ['/{{r(?:equired)?-textarea\|(.+?)\|(.+?)}}/si',
+                "<label for='$2-id'>$1</label><textarea id='$2-id' ".
+                "name='$2' class='short-text-area'>[{csv-$2}]</textarea>" .
+                "<input type='hidden' name='RCSVFORM[$2]' value='textarea' />".
+                "<span clas='csv-star'>*</span>"],
+            ["/{{checkbox\|(.+?)\|(.+?)}}/si",
+                "<label for='$2-id'>$1</label> <input id='$2-id' ".
+                "type='checkbox' name='$2' [{csv-checked-$2}] />".
+                "<input type='hidden' name='CSVFORM[$2]' value='checkbox' />"],
+            ["/{{r(?:equired)?-checkbox\|(.+?)\|(.+?)}}/si",
+                "<label for='$2-id'>$1</label> <input id='$2-id' ".
+                "type='checkbox' name='$2' [{csv-checked-$2}] />".
+                "<input type='hidden' name='RCSVFORM[$2]' value='checkbox' />"],
+            ["/{{radio\|(.+?)\|(.+?)\|(.+?)}}/si",
+                "<label for='$2-$3-id'>$1</label> <input id='$2-$3-id' ".
+                "type='radio' name='$2' value='$3' [{csv-checked-$2}] />".
+                "<input type='hidden' name='CSVFORM[$2]' value='radio' />"],
+            ["/{{r(?:equired)?-radio\|(.+?)\|(.+?)\|(.+?)}}/si",
+                "<label for='$2-$3-id'>$1</label> <input id='$2-$3-id' ".
+                "type='radio' name='$2' value='$3' [{csv-checked-$2}] />".
+                "<input type='hidden' name='RCSVFORM[$2]' value='radio' />".
+                "<span clas='csv-star'>*</span>"],
+            ["/{{submit\|(.+?)\|(.+?)}}/si",
+                "<input id='$2-id' type='submit' name='$1' value='$2' >".
+                "<input type='hidden' name='CSVFORM[$2]' value='submit' />"],
             ['/'.$not_paragraph.'{{\s*class\s*\=\s*'.
                 "&quot;([$class_or_id]+)&quot;\s+(".$not_braces .")}}/",
                 "$esc<span class=\"$1\" >\t\n$2$esc</span>\t"],
diff --git a/src/models/GroupModel.php b/src/models/GroupModel.php
index 28bf96d94..a53d27f99 100644
--- a/src/models/GroupModel.php
+++ b/src/models/GroupModel.php
@@ -1431,10 +1431,29 @@ class GroupModel extends Model implements MediaConstants
         if ($add_relationship_data) {
             $links_relationships = $parser->fetchLinks($page);
         }
+        $is_form = false;
         if ($this->getPageType($page) == 'share') {
             $parsed_page = $page;
         } else {
+            if (strstr($page, '{{submit|') !== false) {
+                $is_form = true;
+            }
             $parsed_page = $parser->parse($page);
+            if ($is_form && strstr($page, '<form ') !== false) {
+                return null;
+            } else if ($is_form) {
+                $end_head = "END_HEAD_VARS";
+                $parsed_page = str_replace($end_head, $end_head .
+                    "\n<form method='post' >\n<input type='hidden' name='" .
+                    C\CSRF_TOKEN . "' value='[{just-token}]' />" .
+                    "<input type='hidden' name='CSV_FORM_HASH' ".
+                    "value='[{form-hash}]' />", $parsed_page);
+                $parsed_page .= "\n</form>\n";
+                $page_body = explode("END_HEAD_VARS", $parsed_page, 2)[1];
+                $parsed_page = preg_replace("/\[{form\-hash}\]/",
+                    "[{form-hash". L\crawlHash($page_body) . "}]",
+                    $parsed_page);
+            }
         }
         if ($page_id = $this->getPageId($group_id, $page_name, $locale_tag)) {
             //can only add and use resources for a page that exists
@@ -2066,12 +2085,19 @@ class GroupModel extends Model implements MediaConstants
         for ($i = 0; $i < $num_matches; $i++) {
             $match_string = $matches[0][$i];
             $resource_namespace_name = $matches[2][$i];
+            if (empty($matches[1][$i])) {
+                $resource_namespace_name = urldecode($resource_namespace_name);
+            }
             $namespace_parts = explode(":", $resource_namespace_name, 2);
             if (count($namespace_parts) > 1 && $matches[1][$i] != "-qr") {
-                list($current_namespace, $resource_namespace_name)
-                    = $namespace_parts;
-                $current_page_id = $this->getPageId($group_id,
-                    $current_namespace, $locale_tag);
+                list($current_namespace, $resource_namespace_name) =
+                    $namespace_parts;
+                if (empty($current_namespace)) {
+                    $current_page_id = $page_id;
+                } else {
+                    $current_page_id = $this->getPageId($group_id,
+                        $current_namespace, $locale_tag);
+                }
                 if ($current_page_id === false || $current_page_id === null) {
                     continue;
                 }
@@ -2255,13 +2281,17 @@ class GroupModel extends Model implements MediaConstants
                 "/scripts/epubjs-reader/reader/index.html")) {
                 $epub_reader_url = C\SHORT_BASE_URL .
                     "wd/scripts/epubjs-reader/reader/index.html";
+                $replace_string = "<div class='wiki-resource-download'>".
+                    "<a href='$resource_url' >⤓</a></div>";
                 $resource_url = urlencode($resource_url);
                 $resource_url = "$epub_reader_url?bookPath=$resource_url";
-                $replace_string = "<iframe class='wiki-resource-object' ".
+                $replace_string .= "<iframe class='wiki-resource-object' ".
                     "src='$resource_url' >$resource_description</iframe>";
                 $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                     .'/u',  $replace_string, $parsed_page);
             } else if (in_array($mime_type, ['text/html', 'application/pdf'])) {
+                $replace_string = "<div class='wiki-resource-download'>".
+                    "<a href='$resource_url' >⤓</a></div>";
                 $replace_string = "<iframe class='wiki-resource-object' ".
                     "src='$resource_url' >$resource_description</iframe>";
                 $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
@@ -2328,7 +2358,10 @@ class GroupModel extends Model implements MediaConstants
                         "    spreadsheet_config = [];\n}\n".
                         "spreadsheet_data[$i] = $resource_data;" .
                         $spread_config .
-                        "\n</script><div id='spreadsheet_$i'> </div>";
+                        "\n</script>".
+                        "<div class='wiki-resource-download'><a href='$resource_url' >".
+                            "⤓</a></div>".
+                        "<div id='spreadsheet_$i'></div>";
                     $parsed_page = preg_replace('/' .
                         preg_quote($match_string, '/')
                         .'/u',  $replace_string, $parsed_page, 1);
@@ -3504,10 +3537,11 @@ EOD;
      * @param int $page_id identifier for page want copy a page resource for
      * @param string $sub_path subpath with the resource folder that should be
      *  used to look up filename in
+     * @param bool $raw if csv file don't content to array of rows
      * @return string desired page resource
      */
     public function getPageResource($file_name, $group_id, $page_id,
-        $sub_path = "")
+        $sub_path = "", $raw = false)
     {
         $folders = $this->getGroupPageResourcesFolders($group_id, $page_id,
             $sub_path);
@@ -3517,7 +3551,7 @@ EOD;
         list($folder, $thumb_folder) = $folders;
         $contents = file_get_contents("$folder/$file_name");
         $name_parts = pathinfo($file_name);
-        if (!empty($name_parts['extension']) &&
+        if (!$raw && !empty($name_parts['extension']) &&
             $name_parts['extension'] == 'csv') {
             $contents = json_encode(L\parseCsv($contents));
         }
diff --git a/src/scripts/spreadsheet.js b/src/scripts/spreadsheet.js
index d640437d0..7ec668402 100644
--- a/src/scripts/spreadsheet.js
+++ b/src/scripts/spreadsheet.js
@@ -122,7 +122,7 @@ function Spreadsheet(spreadsheet_id, supplied_data)
             add_button = "";
             pre_delete_button = "";
         }
-        table += "<table border='1' >";
+        table += "<table class='wikitable' >";
         if (self.headings || self.mode == 'write') {
             table += "<tr><th></th>";
             for (var i = 0; i < width; i++) {
diff --git a/src/views/elements/WikiElement.php b/src/views/elements/WikiElement.php
index 7b3acbf0a..699efac25 100644
--- a/src/views/elements/WikiElement.php
+++ b/src/views/elements/WikiElement.php
@@ -2115,6 +2115,37 @@ class WikiElement extends Element implements CrawlConstants
             $pre_page);
         $pre_page = preg_replace('/\[{token}\]/', $csrf_token,
             $pre_page);
+        $pre_page = preg_replace('/\[{just\-token}\]/', $data[C\CSRF_TOKEN],
+            $pre_page);
+        $pre_page = preg_replace('/\[{image-captcha}\]/',
+            "<img src='". ($data["CAPTCHA_IMAGE"] ?? "") ."' alt='captcha' />",
+            $pre_page);
+        $pre_page = preg_replace('/\[{username}\]/',
+            $_SESSION['USER_NAME'] ?? "PUBLIC_USER", $pre_page);
+        if (preg_match('/\[{form\-hash([^}]+)}\]/', $pre_page,
+            $hash_matches) != false) {
+            $page_hash = L\crawlHash(C\AUTH_KEY . $data[C\CSRF_TOKEN] .
+                $hash_matches[1]);
+            $pre_page = preg_replace('/\[{form-hash([^}]+)}\]/', $page_hash,
+                $pre_page);
+            if (preg_match_all('/\[{csv\-(.+)}\]/', $pre_page,
+                $csv_field_matches, PREG_SET_ORDER) > 0) {
+                foreach ($csv_field_matches as $csv_field_match) {
+                    if (empty($csv_field_match[1])) {
+                        continue;
+                    } else if (substr($csv_field_match[1], 0, 8) == "checked-"){
+                        $csv_field_name = substr($csv_field_match[1], 8);
+                        $replace = (empty($_REQUEST[$csv_field_name])) ? "" :
+                            ' checked="checked" ';
+                    } else {
+                        $csv_field_name = $csv_field_match[1];
+                        $replace = $_REQUEST[$csv_field_name] ?? "";
+                    }
+                    $pre_page = str_replace($csv_field_match[0],
+                        $replace, $pre_page);
+                }
+            }
+        }
         if (stripos($pre_page, "[{recent_places}]") !== false) {
             ob_start();
             $this->renderPath("", $data, [], "", "",
ViewGit