<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\controllers\components;
use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail as ML;
use seekquarry\yioop\library\av_processing\VideoExtractor;
use seekquarry\yioop\models\MailAccountModel;
use seekquarry\yioop\models\SigninModel;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\mail\MailScheduledDispatcher;
use seekquarry\yioop\library\mail\MailHeaderParser;
use seekquarry\yioop\library\mail\MailSiteFactory;
use seekquarry\yioop\library\mail\SmtpClient;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\library\wiki\WikiParser;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\language_processing\PhraseParser;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\library\mail\ImapEnvelopeParser;
use seekquarry\yioop\library\mail\ImapListing;
use seekquarry\yioop\library\mail\ImapFolderListParser;
use seekquarry\yioop\library\mail\ImapResponseParser;
use seekquarry\yioop\library\mail\MimeMessage;
use seekquarry\yioop\library\mail\MailComposeBuilder;
use seekquarry\yioop\views\elements\MailElement;
use seekquarry\yioop\library\media_jobs as LMJ;
use seekquarry\yioop\library\version_control as LVC;
use seekquarry\yioop\library\wiki as LW;
/**
* WikiComponent draws the wiki screens of Yioop and saves what a user
* writes on them. A wiki page is a page the members of a group write
* together.
*
* It draws a page for reading and the editor for changing one. It keeps
* the older versions of a page, so a reader can see what changed and
* compare two of them. It files a page under the categories it belongs
* to, and draws the list of pages a group holds.
*
* This component extends SocialComponent, the base every component
* about a group extends. The base holds what more than one of them
* needs, so no component calls another.
*
* AdminController, GroupController and ApiController offer its wiki
* activity.
*
* @author Chris Pollett
*/
class WikiComponent extends SocialComponent
{
/**
* PAGES_OFFERED how many of a group's pages are offered to a writer naming
* what a place on a front page holds
* @var int
*/
const PAGES_OFFERED = 200;
/**
* LEAD_PARAGRAPHS how many paragraphs of a story a front page shows before
* offering the rest of it
* @var int
*/
const LEAD_PARAGRAPHS = 2;
/**
* CATEGORY_TAG how a tag saying what an article is filed under begins,
* before the category's name and the closing braces
* @var string
*/
const CATEGORY_TAG = "{{category|";
/**
* wiki handles what somebody asks of a wiki page: reading it,
* editing it, looking through its history, and putting an
* earlier version of it back
* @return array $data an associative array of form variables used to draw
* the appropriate wiki page
*/
public function wiki()
{
$parent = $this->parent;
$controller_name =
(get_class($parent) == C\NS_CONTROLLERS . "AdminController") ?
"admin" : "group";
list($data, $sub_path, $clean_array,
$strings_array) = $this->initCommonWikiArrays(
$controller_name);
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
if (isset($_SESSION['USER_ID'])) {
$user_id = $_SESSION['USER_ID'];
$data['ADMIN'] = 1;
} else {
$user_id = C\PUBLIC_USER_ID;
}
list($fields, $missing_fields) = $this->cleanWikiRequestFields(
$clean_array, $strings_array, $controller_name, $user_id);
$group_id = $fields['group_id'];
$page_name = $fields['page_name'];
$page_id = $fields['page_id'] ?? 0;
$page = $fields['page'] ?? null;
$data['RESOURCE_FILTER'] = $fields['resource_filter'] ?? "";
$data['OPEN_IN_TABS'] = empty($_SESSION['OPEN_IN_TABS']) ? false :
true;
$data["SHARE_WALL_EDIT"] = false;
$data['TARGET'] = $fields['target'] ?? "";
$data["CAN_DELETE"] = false;
if (!empty($group_id)) {
if (isset($fields['share_wall_data']) && !empty($page_name)) {
$page_info = $wiki_model->getPageInfoByName($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'], "edit");
if (!empty($page_info['PAGE']) &&
$wiki_model->getPageType($page_info['PAGE']) ==
'share') {
$page = $fields['share_wall_data'];
$page_id = $page_info['ID'];
$data["CAN_EDIT"] = true;
$data["SHARE_WALL_EDIT"] = true;
}
}
} else if (!empty($page_id)) {
$page_info = $wiki_model->getPageInfoByPageId($page_id);
if (isset($page_info["GROUP_ID"])) {
$group_id = $page_info["GROUP_ID"];
unset($page_info);
} else {
$group_id = C\PUBLIC_GROUP_ID;
}
} else {
$group_id = C\PUBLIC_GROUP_ID;
}
if ($group_model->checkUserGroup($user_id,
$group_id, C\EDITOR_STATUS)) {
$data['CAN_EDIT'] = true;
$data['CAN_DELETE'] = true;
}
$group = $group_model->getGroupById($group_id, $user_id, true);
if (!$group) {
$group = $group_model->getGroupById($group_id, $user_id);
$data['CAN_EDIT'] = false;
}
if (!$group || !isset($group["OWNER_ID"])) {
if ($data['MODE'] !== 'api') {
if ($user_id == C\PUBLIC_USER_ID) {
$_REQUEST = ['c' => "admin", 'a' => '',
C\p('CSRF_TOKEN') => ''];
return $parent->redirectWithMessage(
tl("social_component_login_first"));
}
unset($_REQUEST["route"]);
$_REQUEST['group_id'] = C\PUBLIC_GROUP_ID;
return $parent->redirectWithMessage(
tl("social_component_no_group_access"), false, false,
true);
} else {
$data['errors'] = [];
$data['errors'][] = tl("social_component_no_group_access");
}
$group_id = C\PUBLIC_GROUP_ID;
$group = $group_model->getGroupById($group_id, $user_id);
} else {
if ($group["OWNER_ID"] == $user_id ||
$group["STATUS"] == C\EDITOR_STATUS ||
($group["STATUS"] == C\ACTIVE_STATUS &&
$group["MEMBER_ACCESS"] == C\GROUP_READ_WIKI)
&& $user_id != C\PUBLIC_USER_ID) {
$data["CAN_EDIT"] = true;
}
if ($group["OWNER_ID"] == $user_id ||
$group["STATUS"] == C\EDITOR_STATUS) {
$data["CAN_DELETE"] = true;
}
}
if ($group_id == C\PUBLIC_GROUP_ID) {
$read_address = "[{controller_and_page}]";
} else {
$read_address = htmlentities(B\wikiUrl("", true, '[{controller}]',
$group_id)) . "[{token}]&page_name=";
}
$search_page_info = false;
if (isset($_REQUEST["arg"])) {
/* Both podcast requests come in on an address that names
the page rather than numbering it, so the number is looked
up here. Built from a page id of zero, the folder key
named a folder no podcast source fills: every source was
passed over and a forced update downloaded nothing, while
the status asked after a folder nothing writes to. */
if (in_array($_REQUEST["arg"], ["podcast_status",
"podcast_update"]) && empty($page_id) &&
!empty($page_name)) {
$page_id = $wiki_model->getPageId($group_id, $page_name,
$data['CURRENT_LOCALE_TAG']);
}
switch ($_REQUEST["arg"]) {
case "podcast_status":
$this->outputPodcastStatus($group_id, $page_id,
$sub_path);
break;
case "podcast_update":
$folder_key = LMJ\PodcastDownloadJob::podcastFolderKey(
$group_id, $page_id, $sub_path);
LMJ\PodcastDownloadJob::requestPodcastUpdate($folder_key);
$this->outputPodcastStatus($group_id, $page_id,
$sub_path);
break;
case "deletepage":
return $this->deleteWikiPage($data, $group_id,
$page_name);
case "edit":
$this->editWiki($data, $user_id, $group_id, $group,
$page_id, $page_name, $page, $sub_path,
$fields['edit_reason'] ?? null, $missing_fields,
$read_address);
break;
case "history":
$answer = $this->wikiHistory($data, $user_id,
$group_id, $group, $page_id, $fields,
$read_address);
if ($answer !== null) {
return $answer;
}
break;
case "media":
$this->mediaWiki($data, $group_id, $page_id,
$sub_path);
break;
case "media-detail-edit":
case "media-detail-read":
$this->mediaWikiDetail($data, $group_id, $page_id,
$sub_path);
break;
case "ballot":
/* A witness has followed the link sent to them and
takes their part on their own, without the other
witnesses being at a form at the same moment. */
$this->takeWitnessPart($data, $user_id, $group_id);
break;
case "pages":
$search_page_info = $this->wikiPageList($data,
$user_id, $group_id, $group, $sub_path, $fields,
$page_name);
break;
case 'relationships':
$this->wikiRelationships($data, $group_id, $page_id,
$fields, $page_name);
break;
case 'source':
$this->wikiSource($data, $group_id, $page_name,
$sub_path, $fields);
break;
}
}
if (!$page_name) {
$page_name = tl('social_component_main');
}
$data["GROUP"] = $group;
$read_page_id = $wiki_model->getPageId($group_id, $page_name,
$data['CURRENT_LOCALE_TAG']);
if (!empty($read_page_id) &&
$wiki_model->resourcePathBroken($group_id, $read_page_id)) {
$data["RESOURCE_PATH_ERROR"] = true;
}
if ($data["MODE"] == "history") {
$this->addWikiHistoryPage($data, $group, $group_id,
$page_name, $page_id);
} else if (in_array($data["MODE"], ["api", "read", "edit",
"media", "source"])) {
$answer = $this->addWikiReadPage($data, $user_id, $group,
$group_id, $page_name, $sub_path, $search_page_info);
if ($answer !== null) {
return $answer;
}
}
if (!empty($data['PAGE_ID'])) {
$data['PAGE_HAS_RELATIONSHIPS'] =
$wiki_model->countPageRelationships($data['PAGE_ID']);
}
$this->updateGetWikiImpressionInfo($data, $user_id, $group_id);
return $data;
}
/**
* deleteWikiPage takes a wiki page away and sends the writer back
* to the group's list of pages with a line saying what happened.
*
* wiki calls this for the deletepage argument. A page goes only
* where the writer may delete in this group, where the request
* named a page, and where a language is settled, since a page
* belongs to one language and the others stay. The writer is sent
* to the page list either way, so a refusal is seen where the
* pages are.
*
* @param array &$data what the page will show, added to here
* @param int $group_id which group the page belongs to
* @param string $page_name name of the page to take away
* @return mixed what is handed back to the browser
*/
private function deleteWikiPage(&$data, $group_id, $page_name)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$_REQUEST["arg"] = "pages";
if ($data["CAN_DELETE"] && !empty($page_name) &&
isset($group_id) && !empty($data['CURRENT_LOCALE_TAG'])) {
if ($wiki_model->deleteGroupPage($group_id, $page_name,
$data['CURRENT_LOCALE_TAG'])) {
return $parent->redirectWithMessage(
tl('social_component_page_deleted'), ['arg']);
}
}
return $parent->redirectWithMessage(
tl('social_component_page_delete_error'), ['arg']);
}
/**
* wikiHistory draws what a wiki page has been over time: the list
* of its versions, one earlier version on its own, the difference
* between two of them, or the page put back to an earlier one.
*
* wiki calls this for the history argument. Which of the four is
* drawn is settled by the fields the request carries: show names
* one version, diff1 and diff2 name two, and revert names the one
* to go back to. A reader who is not signed in sees this only
* where the group and the page both allow their source to be
* read.
*
* @param array &$data what the page will show, added to here
* @param int $user_id which reader is asking
* @param int $group_id which group the page belongs to
* @param array $group what the model holds about that group
* @param int $page_id which page the history is of
* @param array $fields the cleaned fields the request carried
* @param string $read_address address a link in the page is built
* from
* @return mixed what is handed back to the browser, or null where
* the page is drawn as usual
*/
private function wikiHistory(&$data, $user_id, $group_id, $group,
$page_id, $fields, $read_address)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$page_name = $fields['page_name'] ?? "";
$limit = $fields['limit'] ?? 0;
$show = $fields['show'] ?? null;
$diff = $fields['diff'] ?? null;
$diff1 = $fields['diff1'] ?? null;
$diff2 = $fields['diff2'] ?? null;
$revert = $fields['revert'] ?? null;
if (!isset($page_id) || !$page_id) {
return null;
}
if ($user_id == C\PUBLIC_USER_ID) {
$page_source_allowed =
!empty($group['PAGE_SOURCE_ALLOWED']);
if ($page_source_allowed && !empty($page_name)) {
$current_page_info =
$wiki_model->getPageInfoByName($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'],
"read");
$page_head = WikiParser::parsePageHeadVars(
$current_page_info["PAGE"] ?? "");
$page_source_allowed =
empty($page_head['public_source']) ||
$page_head['public_source'] == 'true';
}
if (!$page_source_allowed) {
$parent->web_site->header(
"HTTP/1.0 404 Not Found");
$parent->displayView("nocache", $data);
\seekquarry\atto\webExit();
}
}
$data["MODE"] = "history";
$data["PAGE_NAME"] = "history";
$num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
$_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
$_SESSION["MAX_PAGES_TO_SHOW"] :
C\DEFAULT_ADMIN_PAGING_NUM;
$default_history = true;
if (isset($show)) {
$page_info = $wiki_model->getHistoryPage(
$page_id, $show);
if ($page_info) {
$data["MODE"] = "show";
$default_history = false;
$render_engine = $group_model->getRenderEngine(
$page_info['GROUP_ID']);
$data["PAGE_NAME"] = $page_info["PAGE_NAME"];
$data["PAGE_ID"] = $page_id;
$data[C\p('CSRF_TOKEN')] =
$parent->generateCSRFToken($user_id);
$history_link = "?c={$data['CONTROLLER']}&".
"a=wiki&". C\p('CSRF_TOKEN').'='.
$data[C\p('CSRF_TOKEN')].
'&arg=history&page_id='.
$data['PAGE_ID'];
$history_header =
"<div> </div>".
"<div class='black-box back-dark-gray'>".
"<div class='float-opposite'>".
"<a href='$history_link'>".
tl("social_component_back") . "</a></div>".
tl("social_component_history_page",
$data["PAGE_NAME"], date("c", $show)) .
"</div>";
/* A historical revision never changes, but every
crawler view re-parses the whole page on the
server. A revision with no resources is instead
handed to the browser, which renders it with the
ported parser in help.js, so the server does
only a string pass. A revision that names
resources still renders on the server, since
resolving a resource needs the group's files on
disk, which the browser cannot reach. */
$has_resources =
(strpos($page_info["PAGE"], "((resource")
!== false);
if ($has_resources) {
$parser = new WikiParser($read_address);
$parsed = $parser->parse(
$page_info["PAGE"],
render_engine: $render_engine);
$parsed =
$wiki_model->insertResourcesParsePage(
$group_id, $page_id,
$data['CURRENT_LOCALE_TAG'], $parsed,
$data[C\p('CSRF_TOKEN')],
$data['CONTROLLER']);
$data["PAGE"] = $history_header . $parsed;
} else {
$this->renderHistoryInBrowser($data,
$page_info, $render_engine,
$history_header, $page_id);
}
$data["DISCUSS_THREAD"] =
$page_info["DISCUSS_THREAD"];
}
} else if (!empty($diff) &&
isset($diff1) && isset($diff2)) {
$page_info1 = $wiki_model->getHistoryPage(
$page_id, $diff1);
$page_info2 = $wiki_model->getHistoryPage(
$page_id, $diff2);
$data["MODE"] = "diff";
$default_history = false;
$data["PAGE_NAME"] = $page_info2["PAGE_NAME"];
$data["PAGE_ID"] = $page_id;
$data[C\p('CSRF_TOKEN')] =
$parent->generateCSRFToken($user_id);
$history_link = htmlentities(B\controllerUrl(
$data['CONTROLLER'],true)) .
"a=wiki&".C\p('CSRF_TOKEN').'='.
$data[C\p('CSRF_TOKEN')].
'&arg=history&page_id='.
$data['PAGE_ID'];
$out_diff = "<div>+++ {$data["PAGE_NAME"]}\t".
"''$diff1''</div>\n";
$out_diff .= "<div>--- {$data["PAGE_NAME"]}\t".
"''$diff2''</div>\n";
/* The browser computes the line-by-line diff from the
two revisions, so the server builds no subsequence
table and every visitor, crawler or not, receives
the same page. */
$out_diff .= "<div id='wiki-diff-rendered'></div>";
$this->renderDiffInBrowser($data,
$page_info2["PAGE"], $page_info1["PAGE"]);
$data["PAGE"] =
"<div> </div>".
"<div class='black-box back-dark-gray'>".
"<div class='float-opposite'>".
"<a href='$history_link'>".
tl("social_component_back") . "</a></div>".
tl("social_component_diff_page",
$data["PAGE_NAME"], date("c", $diff1),
date("c", $diff2)) .
"</div>" . "$out_diff";
} else if (isset($revert) && $data["CAN_EDIT"]) {
$page_info = $wiki_model->getHistoryPage(
$page_id, $revert);
if ($page_info) {
$action = "wikiupdate_".
"group=".$group_id."&page=" .
$page_info["PAGE_NAME"];
if (!$parent->checkCSRFTime(C\p('CSRF_TOKEN'),
$action)) {
$data['SCRIPT'] .=
"doMessage('<h1 class=\"red\" >".
tl('social_component_wiki_edited_elsewhere')
. "</h1>');";
return null;
}
$wiki_model->revertResources($page_id, $group_id,
$revert);
/* A version belongs to the locale it was
written in, which need not be the one the
person reading the history is in, so the
version says which locale it goes back
to. Taking the reader's locale instead
wrote a page of one language over a page
of another. */
$wiki_model->setPageName($user_id,
$group_id, $page_info["PAGE_NAME"],
$page_info["PAGE"],
$page_info["LOCALE_TAG"] ??
$data['CURRENT_LOCALE_TAG'],
tl('social_component_page_revert_to',
date('c', $revert)), "", "", $read_address);
/* Back to the history the revert was asked
for from, in the locale it was read in. */
/* What a pretty address already carries is
left out of the query it redirects to, and
the address it goes to no longer carries
it, so the route is set aside and every
field written out. */
unset($_REQUEST['route']);
$_REQUEST['a'] = "wiki";
$_REQUEST['arg'] = "history";
$_REQUEST['page_id'] = $page_id;
$_REQUEST['page_name'] =
$page_info["PAGE_NAME"];
$_REQUEST['page_locale'] =
$page_info["LOCALE_TAG"] ??
$data['CURRENT_LOCALE_TAG'];
return $parent->redirectWithMessage(
tl("social_component_page_reverted"),
['a', 'arg', 'page_name', 'page_id',
'page_locale']);
} else {
return $parent->redirectWithMessage(
tl("social_component_revert_error"),
['arg', 'page_name', 'page_id']);
}
}
if (empty($data["DISCUSS_THREAD"])) {
$page_info = $wiki_model->getPageInfoByPageId(
$page_id);
$data["DISCUSS_THREAD"] =
(empty($page_info) ||
empty($page_info["DISCUSS_THREAD"])) ? -1 :
$page_info["DISCUSS_THREAD"];
}
if ($default_history) {
$data["LIMIT"] = $limit;
$data["RESULTS_PER_PAGE"] = $num;
list($data["TOTAL_ROWS"], $data["PAGE_NAME"],
$data["HISTORY"]) =
$wiki_model->getPageHistoryList($page_id, $limit,
$num);
if ((!isset($diff1) || !isset($diff2))) {
$data['diff1'] = $data["HISTORY"][0]["PUBDATE"]
?? 0;
$data['diff2'] = $data["HISTORY"][0]["PUBDATE"]
?? 0;
if (count($data["HISTORY"]) > 1) {
$data['diff2'] = $data["HISTORY"][1]["PUBDATE"];
}
}
}
$data['PAGE_ID'] = $page_id;
}
/**
* wikiPageList draws the list of a group's wiki pages, narrowed
* to one category or to a name being looked for where the request
* asks for that.
*
* wiki calls this for the pages argument. A group says whether a
* stranger may see the list at all, and a group may open its
* pages by category alone, which opens a category's list and
* leaves the whole list closed. Where a name being looked for
* matches one page exactly, that page is read instead of listed,
* which is why the name being read is handed back through
* $page_name.
*
* @param array &$data what the page will show, added to here
* @param int $user_id which reader is asking
* @param int $group_id which group the pages belong to
* @param array $group what the model holds about that group
* @param string $sub_path folder under the page the request names
* @param array $fields the cleaned fields the request carried
* @param string &$page_name name of the page to read, written
* here where one name matches exactly
* @return mixed what the model holds about that one page, or
* false where no one page was matched
*/
private function wikiPageList(&$data, $user_id, $group_id, $group,
$sub_path, $fields, &$page_name)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$limit = $fields['limit'] ?? 0;
$filter = empty($fields['filter']) ? "" : $fields['filter'];
if (isset($fields['page_name'])) {
$page_name = $fields['page_name'];
}
$data["MODE"] = "pages";
/* A name after the address narrows the list to one
category. It is the same listing, so it answers to
the same permission, except that a group may share
its pages by category alone, which opens this and
leaves the whole list closed. */
$data["LIST_CATEGORY"] = trim($sub_path, "/");
/* The whole list of a group's pages opens only where
the group says so outright. Saying its articles may
be read by category opens a category's page and
leaves this one closed. */
$shares = $group['PAGE_LIST_ALLOWED'] ?? 0;
$may_read = ($shares == 1) || ($shares ==
C\GROUP_OPTION_PAGE_LIST_ARTICLES_SETTING &&
$data["LIST_CATEGORY"] !== "");
if ($user_id == C\PUBLIC_USER_ID && !empty($group) &&
!$may_read) {
/* A group keeping its list of pages to itself is
not saying the list is not there, it is saying
the list is none of a stranger's business, so
the reader is sent to where the site says that
and told so in the status. A routed domain says
it in its own words where it has been given
them. */
$parent->redirectLocation(
B\directUrl("error", true) . "p=403");
\seekquarry\atto\webExit();
}
$num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
$_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
$_SESSION["MAX_PAGES_TO_SHOW"] :
C\DEFAULT_ADMIN_PAGING_NUM;
array_pop($data['sort_fields']);
array_pop($data['sort_fields']);
if (!empty($_REQUEST['sort']) && in_array($_REQUEST['sort'],
array_keys($data['sort_fields']))) {
if (!empty($_SESSION['media_sorts']) &&
count($_SESSION['media_sorts']) > 10) {
$first_key = array_key_first(
$_SESSION['media_sorts']);
unset($_SESSION['media_sorts'][$first_key]);
}
$_SESSION['media_sorts']['pages'] = $_REQUEST['sort'];
}
$data['CURRENT_SORT'] = $_SESSION['media_sorts']["pages"]
?? "";
if (isset($page_name)) {
$data['PAGE_NAME'] = $page_name;
}
$data["LIMIT"] = $limit;
$data["RESULTS_PER_PAGE"] = $num;
$data["FILTER"] = preg_replace("/\s+/u", " ", $filter);
$filter = preg_replace("/\s+/u", "_", $filter);
$search_page_info = false;
if ($filter != "") {
$search_page_info = $wiki_model->getPageInfoByName(
$group_id, $filter, $data['CURRENT_LOCALE_TAG'],
"read");
}
if ($data["LIST_CATEGORY"] !== "") {
list($data["TOTAL_ROWS"], $data["PAGES"]) =
$wiki_model->getPageList($group_id,
$data['CURRENT_LOCALE_TAG'], $filter,
$data['CURRENT_SORT'], $limit, $num,
$data["LIST_CATEGORY"]);
/* Each page is shown with the picture standing
for it, whose address is worked out here since
only this side can ask the model for it. */
$csrf_token = $parent->generateCSRFToken($user_id);
foreach ($data["PAGES"] as $at => $page) {
$data["PAGES"][$at]['ICON_URL'] =
$wiki_model->getGroupPageIconUrl(
$csrf_token, $group_id, $page['ID']);
}
} else if (!$search_page_info) {
list($data["TOTAL_ROWS"], $data["PAGES"]) =
$wiki_model->getPageList(
$group_id, $data['CURRENT_LOCALE_TAG'], $filter,
$data['CURRENT_SORT'], $limit, $num);
} else {
$data["MODE"] = "read";
$page_name = $data["FILTER"];
}
return $search_page_info;
}
/**
* wikiRelationships draws the pages that link to a wiki page and
* the pages it links to, grouped by the kind of link.
*
* wiki calls this for the relationships argument. Where the page
* has links of only one kind that kind is picked without the
* reader saying so, since there is nothing to choose between. The
* name of the page is written back through $page_name, since the
* request may name the page by its number alone.
*
* @param array &$data what the page will show, added to here
* @param int $group_id which group the page belongs to
* @param int $page_id which page the links are of
* @param array $fields the cleaned fields the request carried
* @param string &$page_name name of the page, written here where
* the request named the page by its number
*/
private function wikiRelationships(&$data, $group_id, $page_id,
$fields, &$page_name)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$limit = $fields['limit'] ?? 0;
if (isset($fields['page_name'])) {
$page_name = $fields['page_name'];
}
$data["MODE"] = "relationships";
$data["PAGE_NAME"] = "related";
if (empty($page_id)) {
return;
}
$page_info = $wiki_model->getPageInfoByPageId(
$page_id);
if (!isset($page_name)) {
$page_name = empty($page_info['PAGE_NAME']) ? "links" :
$page_info['PAGE_NAME'];
}
$num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
$_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
$_SESSION["MAX_PAGES_TO_SHOW"] :
C\DEFAULT_ADMIN_PAGING_NUM;
$data["PAGE_ID"] = $page_id;
$data["PAGE_NAME"] = $page_name;
$data["DISCUSS_THREAD"] = empty($page_info["DISCUSS_THREAD"]
) ? -1 : $page_info['DISCUSS_THREAD'];
$data["GROUP_ID"] = $page_info["GROUP_ID"];
$data["LIMIT"] = $limit;
$data["RESULTS_PER_PAGE"] = $num;
list($data["TOTAL_ROWS"], $data["RELATIONSHIPS"]) =
$wiki_model->getRelationshipsToFromPage($page_id,
$limit, $num);
//only one relationship so select
if (count($data["RELATIONSHIPS"]) == 1) {
$current = current($data["RELATIONSHIPS"]);
$_REQUEST["reltype"] =
$current["RELATIONSHIP_TYPE"];
}
if (isset($_REQUEST["reltype"])) {
$rel_type = $parent->clean($_REQUEST["reltype"],
"string");
$data["REL-TYPE"] = $rel_type;
$data["GROUP_ID"] = $group_id;
//clean up
if (!empty($page_id)) {
$page_info = $wiki_model->getPageInfoByPageId(
$page_id);
if (!isset($page_name)) {
$page_name = empty($page_info['PAGE_NAME'])
? "rel-types" : $page_info['PAGE_NAME'];
}
$num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
$_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
$_SESSION["MAX_PAGES_TO_SHOW"] :
C\DEFAULT_ADMIN_PAGING_NUM;
$data["PAGE_ID"] = $page_id;
$data["PAGE_NAME"] = $page_name;
$data["DISCUSS_THREAD"] =
empty($page_info["DISCUSS_THREAD"] ) ? -1 :
$page_info['DISCUSS_THREAD'];
$data["GROUP_ID"] = $page_info["GROUP_ID"];
$data["LIMIT"] = $limit;
$data["RESULTS_PER_PAGE"] = $num;
list($data["TOTAL_TO_PAGES"],
$data["PAGES_THAT_LINK_TO"],
$data["TOTAL_FROM_PAGES"],
$data["PAGES_THAT_LINK_FROM"]) =
$wiki_model->pagesLinkedWithRelationship(
$page_id, $data["GROUP_ID"],
$data["PAGE_NAME"], $rel_type, $limit,$num);
}
}
}
/**
* wikiSource opens a wiki page as the text it was written in,
* rather than as the page that text draws.
*
* wiki calls this for the source argument. Where the browser said
* where the writing mark and the scroll stood, both are put back,
* so returning to the source lands where the writer left it. The
* files kept beside the page are gathered too, since the source
* screen lists them.
*
* @param array &$data what the page will show, added to here
* @param int $group_id which group the page belongs to
* @param string $page_name name of the page to open
* @param string $sub_path folder under the page the request names
* @param array $fields the cleaned fields the request carried
*/
private function wikiSource(&$data, $group_id, $page_name,
$sub_path, $fields)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$page = $fields['page'] ?? null;
if (isset($_REQUEST['caret']) &&
isset($_REQUEST['scroll_top'])
&& !isset($page)) {
$caret = $parent->clean($_REQUEST['caret'],
'int');
$scroll_top = $parent->clean($_REQUEST['scroll_top'],
'int');
$data['SCRIPT'] .= "wiki = elt('wiki-page');".
"if (wiki.setSelectionRange) { " .
" wiki.focus();" .
" wiki.setSelectionRange($caret, $caret);".
"} ".
"wiki.scrollTop = $scroll_top;";
}
$data["MODE"] = "source";
$data["settings"] = (!empty($_REQUEST['settings']));
$data["resources"] = (!empty($_REQUEST['resources']));
$page_info = $wiki_model->getPageInfoByName($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'], 'resources');
/* if page not yet created than $page_info will be null
so in the below $page_info['ID'] won't be set.
*/
if (isset($page_info['ID'])) {
$data['RESOURCES_INFO'] =
$wiki_model->getGroupPageResourceUrls($group_id,
$page_info['ID'], $sub_path);
$this->addPodcastSourceStatus($data, $group_id,
$page_info['ID'], $sub_path);
} else {
$data['RESOURCES_INFO'] = [];
}
}
/**
* addWikiHistoryPage puts the page a history screen is about into
* what the view draws, along with whether its source may be read.
*
* wiki calls this once the history argument has settled which
* version to draw. The page is read for its heading values, since
* a page may say for itself whether a stranger may read its
* source, and where it says nothing the group's own answer
* stands.
*
* @param array &$data what the page will show, added to here
* @param array $group what the model holds about the group
* @param int $group_id which group the page belongs to
* @param string $page_name name of the page being read
* @param int $page_id which page the history is of
*/
private function addWikiHistoryPage(&$data, $group, $group_id,
$page_name, $page_id)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
if (!empty($page_id)) {
$page_info = $wiki_model->getPageInfoByPageId($page_id);
$page_name = $page_info['PAGE_NAME'] ?? "";
}
$page_info = $wiki_model->getPageInfoByName($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'], 'read');
$view = $parent->view($data['VIEW']);
$data["PAGE"] = $page_info["PAGE"] ?? "";
$parent->parsePageHeadVarsView($view, $data["PAGE_ID"],
$data["PAGE"]);
if ($data['MODE'] == "read" || empty($_REQUEST['n'])) {
$data["PAGE"] = $view->page_objects[$data["PAGE_ID"]];
}
$data["HEAD"] = $view->head_objects[$data["PAGE_ID"]];
$page_public_source = $data["HEAD"]['public_source'] ?? "";
if ($page_public_source === "") {
$page_public_source =
!empty($group['PAGE_SOURCE_ALLOWED']) ? 'true' : 'false';
}
if ($page_public_source !== 'true' && empty($data['CAN_EDIT'])) {
$data['NO_HISTORY_SOURCE'] = true;
}
}
/**
* addWikiReadPage puts a wiki page and its heading values into
* what the view draws, and answers a page that stands for another
* address.
*
* wiki calls this for every screen that shows a page rather than
* a list: reading, editing, the source, the files beside it, and
* the answer an address asks for. A page written in one language
* and read in another falls back to the language the site
* started in. A page that stands for another page, or for an
* address elsewhere, sends the reader on rather than drawing
* anything.
*
* @param array &$data what the page will show, added to here
* @param int $user_id which reader is asking
* @param array $group what the model holds about the group
* @param int $group_id which group the page belongs to
* @param string $page_name name of the page being read
* @param string $sub_path folder under the page the request names
* @param mixed $search_page_info what the model holds about a
* page a name being looked for matched, or false
* @return mixed what is handed back to the browser where the page
* stands for another address, or null where the page is drawn
*/
private function addWikiReadPage(&$data, $user_id, $group,
$group_id, $page_name, $sub_path, $search_page_info)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$controller_name = $data["CONTROLLER"];
// history action might set page, otherwise...
if (empty($data["PAGE"]) && empty($data['RESOURCE_NAME'])) {
$data["PAGE_NAME"] = $page_name;
if (!empty($search_page_info)) {
$page_info = $search_page_info;
} else {
$page_info = $wiki_model->getPageInfoByName($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'], $data["MODE"]);
}
$data["PAGE"] = $page_info["PAGE"] ?? "";
$data["PAGE_ID"] = $page_info["ID"] ?? "";
$data["DISCUSS_THREAD"] = $page_info["DISCUSS_THREAD"] ?? "";
}
if (empty($data["PAGE"]) &&
$data['CURRENT_LOCALE_TAG'] != C\p('DEFAULT_LOCALE')) {
//fallback to default locale for translation
$page_info = $wiki_model->getPageInfoByName(
$group_id, $page_name, C\p('DEFAULT_LOCALE'),
$data["MODE"]);
$data["PAGE"] = $page_info["PAGE"] ?? "";
$data["PAGE_ID"] = $page_info["ID"] ?? "" ;
$data["DISCUSS_THREAD"] = $page_info["DISCUSS_THREAD"] ?? "";
}
$view = $parent->view($data['VIEW']);
/* A page made on the spot, such as a category's, has no page
of its own behind it, so an empty name stands for it rather
than nothing at all. */
$page_key = $data["PAGE_ID"] ?? "";
$parent->parsePageHeadVarsView($view, $page_key,
$data["PAGE"]);
$data['page_icon'] = $wiki_model->getGroupPageIconUrl(
$parent->generateCSRFToken($user_id), $group_id,
$page_key);
if ($data['MODE'] == "read" || empty($_REQUEST['n'])) {
$data["PAGE"] = $view->page_objects[$page_key] ?? "";
}
$data["HEAD"] = $view->head_objects[$page_key] ??
WikiParser::PAGE_DEFAULTS;
$page_public_source = $data["HEAD"]['public_source'] ?? "";
if ($page_public_source === "") {
$page_public_source =
!empty($group['PAGE_SOURCE_ALLOWED']) ? 'true' : 'false';
}
if ($page_public_source !== 'true' && empty($data['CAN_EDIT'])) {
$data['NO_HISTORY_SOURCE'] = true;
}
$data['RENDER_ENGINE'] =
($group['RENDER_ENGINE'] == C\MARKDOWN_ENGINE) ?
"markdown" : "mediawiki";
if (isset($data["HEAD"]['page_type']) &&
$data["HEAD"]['page_type'] == 'page_alias' &&
$data["HEAD"]['page_alias'] != '' &&
in_array($data['MODE'], ["read", 'api']) &&
!isset($_REQUEST['noredirect']) ) {
if ($data['MODE'] == 'api') {
$controller_name = "api";
}
$alias_parts = explode("@", $data["HEAD"]['page_alias']);
$alias = $alias_parts[0];
$alias_group_id = $group_id;
if (!empty($alias_parts)) {
$alias = $alias_parts[1];
$alias_group_id = $group_model->getGroupId($alias_parts[0]);
}
return $parent->redirectLocation(B\wikiUrl(
$alias,
true, $controller_name, $alias_group_id) .
C\p('CSRF_TOKEN') .
'=' . $parent->generateCSRFToken($user_id));
} else if (isset($data["HEAD"]['page_type']) &&
$data["HEAD"]['page_type'] == 'url_shortener' &&
in_array($data['MODE'], ["read"])) {
$parent->redirectLocation(
html_entity_decode($data["HEAD"]['url_shortener']));
}
/* A writer previewing a front page asks for the places on it to
be drawn, since what stands in one is answered from what the
group holds at this moment and the browser cannot know it. What
comes back is the drawn place and nothing else. */
if (!empty($_REQUEST['preview_mark'])) {
$said = $parent->clean($_REQUEST['preview_mark'], "string");
$data["FRONT_PAGE_PLACE"] = $this->drawnFrontPageMark(
$parent, $group_model, $group_id, $data, $user_id,
$said);
$data["GROUP_ID_FOR_PLACE"] = $group_id;
/* A place asked for on its own is answered before the
reading of a page has put the reader's mark in hand,
and finishing the addresses in it needs that mark. */
$data[C\p('CSRF_TOKEN')] =
$parent->generateCSRFToken($user_id);
$parent->displayView("api", $data);
\seekquarry\atto\webExit();
}
if ($data['MODE'] == "read") {
$data['GROUP_STATUS'] = $group['STATUS'];
$data['JUST_THREAD'] = true;
if (($data["HEAD"]['page_type'] ?? "") == 'git_repository') {
$this->initializeGitRepositoryReadMode($data, $group_id,
$sub_path);
} else {
$tmp_page = preg_replace("/\[{form\-hash(.+?)}\]/",
"[{form-hash}]", $data['PAGE'] ?? "");
$data['FORM_HASH'] = L\crawlAuthHash($tmp_page);
if (!empty($_POST['CSVFORM']) &&
!empty($_POST[C\p('CSRF_TOKEN')])) {
$this->processWikiFormData($data, $user_id, $group_id,
$sub_path);
}
$this->initializeReadMode($data, $user_id, $group_id,
$sub_path);
$data['SCRIPT'] .= "initCvsFormTags();" .
"initCategoryLists();";
}
} else if (in_array($data['MODE'], ['edit', 'source'])) {
$this->initializeWikiEditAndSource($data, $group_id,
$sub_path);
}
}
/**
* initializeWikiEditAndSource makes ready everything the writing
* screens need: the page's own settings, the editor, the scroll
* keeper, and whatever the kind of page being written asks for.
*
* addWikiReadPage calls this for the editing screen and for the
* source screen, which want the same set-up. A page may be one of
* several kinds, and a kind brings its own controls: a repository
* shows how to clone it, an address shortener shows how often it
* was followed, and a page built from a template starts with that
* template's boxes. It also settles PAGE_LISTS_FILES, which says
* whether the page being written is one whose contents are the
* files kept with it. A screen editing one such file has already
* set that field, since on that screen the page's own settings are
* not what was read.
*
* @param array &$data what the page will show, added to here
* @param int $group_id which group the page belongs to
* @param string $sub_path folder under the page the request names
*/
private function initializeWikiEditAndSource(&$data, $group_id,
$sub_path)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
foreach (WikiParser::PAGE_DEFAULTS as $key => $default) {
$data[$key] = $default;
if (isset($data["HEAD"][$key])) {
$data[$key] = $data["HEAD"][$key];
}
}
$data['PAGE_LISTS_FILES'] ??=
($data['page_type'] ?? "") == 'media_list';
$this->initUserResourcePreferences($data);
$scroll_id = "scroll-container-" .
L\crawlHash($data['PAGE_ID'] . $sub_path);
$data['SCROLL_CONTAINER_ID'] = $scroll_id;
$data['SCRIPT'] .= "initScrollPositionPreserver('".
$data['SCROLL_CONTAINER_ID'] . "');";
if ($data['CURRENT_LAYOUT'] == 'detail') {
$data['DETAIL_SCROLL_ID'] = "detail-$scroll_id";
$data['SCRIPT'] .= "initScrollPositionPreserver('".
$data['DETAIL_SCROLL_ID'] . "');";
}
if (!empty($data['RESOURCE_NAME'])) {
$name_parts = pathinfo($data['RESOURCE_NAME']);
if (!empty($name_parts['extension']) &&
empty($data['RAW'])) {
$extension = strtolower($name_parts['extension']);
if (in_array($extension, C\EDITABLE_IMAGE_EXTENSIONS)) {
$data['INCLUDE_SCRIPTS'][] = 'image_editor';
}
switch ($name_parts['extension']) {
case 'csv':
$user_config = "";
if (!empty($_SESSION['USER_NAME'])) {
$user_config .= ',user_name:'.
json_encode($_SESSION['USER_NAME']);
}
$data['INCLUDE_SCRIPTS'][] = 'spreadsheet';
$data['SCRIPT'] .=
'spreadsheet = new Spreadsheet(' .
'"spreadsheet",' .
$data["PAGE"] . ', {mode:"write"'.
"$user_config});".
'spreadsheet.draw();';
$data['SPREADSHEET'] = true;
break;
}
}
}
foreach (['settings', 'resources'] as $field) {
$data[$field] = "false";
if (isset($_REQUEST[$field]) &&
$_REQUEST[$field] == 'true') {
$data[$field] = "true";
}
}
/* A group may say what kind of page a new page in it
starts as, so a newsroom's writer begins with an
article rather than switching the kind by hand. A page
that already exists keeps the kind it was saved
with. */
if (!empty($data["GROUP"]["GROUP_ID"]) &&
(empty($data["page_type"]) ||
($data["page_type"] == "standard" &&
empty($data["PAGE_ID"])))) {
$starts = $wiki_model->getGroupPageSettings(
$data["GROUP"]["GROUP_ID"])['default_page_type'];
if (!empty($starts)) {
$data["page_type"] = $starts;
}
}
$data['current_page_type'] = $data["page_type"];
$data['anonymous_issue_choices'] = [
C\GIT_ISSUE_ANON_NONE =>
tl('social_component_anon_issue_none'),
C\GIT_ISSUE_ANON_MODERATED =>
tl('social_component_anon_issue_moderated'),
C\GIT_ISSUE_ANON_UNMODERATED =>
tl('social_component_anon_issue_unmoderated'),
];
$data['current_anonymous_issue'] =
$data["HEAD"]['anonymous_issue_reporting'] ??
C\GIT_ISSUE_ANON_MODERATED;
if ($data['current_page_type'] == 'git_repository') {
$clone_url = C\p('NAME_SERVER') . "group/" . $group_id .
"/" . $data["PAGE_NAME"] . ".git";
$data["GIT_CLONE_URL"] = $clone_url;
if ($data['MODE'] == 'edit' &&
!empty($data['CAN_EDIT'])) {
$this->initializeGitAppCode($data, $clone_url);
$this->initializeGitStatistics($data, $group_id,
$data["PAGE_ID"]);
} else if ($data['MODE'] == 'source' &&
empty($data['NO_HISTORY_SOURCE'])) {
$this->initializeGitStatistics($data, $group_id,
$data["PAGE_ID"]);
}
}
$data['can_set_static_html_folder'] =
(!empty($_SESSION['USER_ID']) &&
$_SESSION['USER_ID'] == C\ROOT_ID);
if ($data['current_page_type'] == 'url_shortener'
&& $data['MODE'] == 'edit') {
$this->makeImpressionChart($data, C\WIKI_IMPRESSION,
C\ONE_DAY, $data['PAGE_ID'], "day_chart",
"day-chart");
$this->makeImpressionChart($data, C\WIKI_IMPRESSION,
C\ONE_MONTH, $data['PAGE_ID'], "month_chart",
"month-chart");
$this->makeImpressionChart($data, C\WIKI_IMPRESSION,
C\ONE_YEAR, $data['PAGE_ID'], "year_chart",
"year-chart");
}
$templates = $group_model->getTemplateMap($group_id,
$data['CURRENT_LOCALE_TAG']);
/*
if the page id is not that of a template, then
we add the list of templates to the available
page_type page can be set to and we check
if page uses a template
*/
if (empty($templates["t" . $data["PAGE_ID"]])) {
$data['page_types'] = array_merge($data['page_types'],
$templates);
if (empty($_REQUEST['n']) &&
!empty($templates[$data['current_page_type']])) {
$template_name = $templates[$data['current_page_type']];
$template_info = $group_model->
getPageInfoByName($group_id, $template_name,
$data['CURRENT_LOCALE_TAG'], "read");
list(, $tmp_page) = WikiParser::parsePageHeadVars(
$template_info['PAGE'], true);
$tmp_page = preg_replace("/{{text\|(.+?)\|(.+?)}}/",
"<input type='text' class='narrow-field'" .
" name='page[$1]' placeholder='$2'" .
" value='{{field|$1}}' >", $tmp_page);
$tmp_page = preg_replace("/{{area\|(.+?)\|(.+?)}}/",
"<textarea class='short-text-area'" .
" name='page[$1]' placeholder='$2'>" .
"{{field|$1}}</textarea>", $tmp_page);
if (empty($data['PAGE'])) {
$data['PAGE'] = preg_replace(
"/{{field\|(.+?)}}/", "", $tmp_page);
} else {
set_error_handler(null);
$page_data = @unserialize(base64_decode(
$data['PAGE']));
restore_error_handler();
if (is_array($page_data)) {
foreach ($page_data as
$page_key => $page_value) {
$tmp_page = preg_replace(
"/{{field\|" .
preg_quote($page_key, "/") .
"}}/", $page_value, $tmp_page);
}
}
$data['PAGE'] = preg_replace(
"/{{field\|(.+?)}}/", "", $tmp_page);
}
}
}
$this->initializeWikiPageToggle($data);
/* A resource opened on its own is edited in the same kind
of field as a page, so it wants the same editor: without
it there is no toolbar, and with no toolbar there is
nowhere for the keeping-as-you-write control to go, so a
long edit to a text or comma separated file could be
lost. */
$this->initializeWikiEditor($data);
}
/**
* processWikiFormData used to process form data associated with a wiki page
* with a form on it. Such a form's data is stored in a CSV file
* @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
* @return mixed redirectWithMessage call result on error/completion paths;
* void on early-out (no PAGE_ID / GROUP_ID)
*/
public function processWikiFormData($data, $user_id, $group_id,
$sub_path)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$signin_model = $parent->model("signin");
if (empty($data['PAGE_ID']) || empty($group_id)) {
return;
}
$_REQUEST['SUBMIT_SUCCESSFUL'] = false; /*indicates either not
submitted or not submitted successfully */
$default_folders = $wiki_model->getGroupPageResourcesFolders($group_id,
$data['PAGE_ID']);
$csv_filepath = $default_folders[0] . '/' . C\WIKI_FORM_CSV_FILE;
$secrets_filepath = $default_folders[0] . '/' .
C\WIKI_FORM_SECRETS_FILE;
$preserve_fields =
['arg', 'page_name', 'group_name', 'settings', 'caret',
'scroll_top', 'sf'];
if (!$parent->checkCSRFToken($_POST[C\p('CSRF_TOKEN')], $user_id,
true)) {
return $parent->redirectWithMessage(
tl('social_component_page_data_expired'), $preserve_fields);
}
/* An address that has been tripping the bot checks is held in a
growing timeout (the same per-IP backoff the account forms use);
while that timeout is in force, reject the submission outright
before doing any work. */
$visitor = $parent->model("visitor")->getVisitor(
L\remoteAddress(), "captcha_time_out");
if (isset($visitor['END_TIME']) && $visitor['END_TIME'] > time()) {
return $parent->redirectWithMessage(
tl('social_component_captcha_failed'), $preserve_fields);
}
$page = $data['PAGE'];
$tmp_page = preg_replace("/\[{form\-hash(.+?)}\]/", "[{form-hash}]",
$page);
$csv_form_hash = L\crawlAuthHash($_POST[C\p('CSRF_TOKEN')] .
hash("sha256", $tmp_page));
$secret_form_hash = $data['FORM_HASH'];
if (!hash_equals($csv_form_hash, $_POST['CSV_FORM_HASH'] ?? "")) {
return $parent->redirectWithMessage(
tl('social_component_page_integrity_issue'), $preserve_fields);
}
$csv_headers = [];
if (empty($_POST['CSVFORM']['user_captcha_text']) &&
empty($_POST['CSVFORM']['require_signin']) ) {
return $parent->redirectWithMessage(
tl('social_component_form_needs_captcha'), $preserve_fields);
}
/* Two cheap, invisible bot checks. The honeypot is a decoy field
hidden from people and screen readers, so only an automated
form-filler puts anything in it. The timing check rejects a
submission that came back implausibly fast for a person who
actually read the form. Either trip feeds the per-IP backoff
and is answered with the same generic message as a failed
captcha, so a script cannot tell which check caught it. */
$tripped_bot_check = !empty($_POST[C\WIKI_FORM_HONEYPOT_FIELD]);
if (!$tripped_bot_check && !empty($_SESSION['request_time']) &&
time() - $_SESSION['request_time'] < C\MIN_WIKI_FORM_DELAY) {
$tripped_bot_check = true;
}
if ($tripped_bot_check) {
$parent->model("visitor")->updateVisitor(
L\remoteAddress(), "captcha_time_out");
return $parent->redirectWithMessage(
tl('social_component_captcha_failed'), $preserve_fields);
}
$num_fields = count($_POST['CSVFORM'] ?? []);
if ($num_fields > C\MAX_WIKI_FORM_FIELDS) {
return $parent->redirectWithMessage(
tl('social_component_too_many_fields_form'), $preserve_fields);
}
$csv_form_fields = $_POST['CSVFORM'] ?? [];
foreach ($csv_form_fields as $form_field => $field_type) {
$form_field = substr(
$parent->clean($form_field, 'string'), 0, C\NAME_LEN);
if (in_array($field_type, ['submit', 'true'])) {
} else if (in_array($form_field, $csv_headers)) {
continue;
} else {
$csv_headers[] = $form_field;
}
}
$out_row = [];
$key_col = ["username" => -1, "user_captcha_text" => -1];
$i = 0;
$captcha_hash = L\crawlHash($_SESSION['captcha_text']??
L\microTimestamp());
$is_new_hash_row = false;
$is_load_mode = true;
$new_data_hash = "";
$missing_fields = false;
foreach ($csv_headers as $csv_header) {
$is_load_col = false;
$as_sent = str_replace([" ", "."], "_", $csv_header);
if (!isset($_POST[$csv_header]) && isset($_POST[$as_sent])) {
$_POST[$csv_header] = $_POST[$as_sent];
}
$is_required = (isset($_POST['CSVFORM'][$csv_header]) &&
str_starts_with($_POST['CSVFORM'][$csv_header], "r-") ) ? 1 : 0;
if ($is_required &&
$this->formFieldIsEmpty($_POST[$csv_header] ?? "")) {
$missing_fields = true;
}
if (in_array($csv_header, ["username", "user_captcha_text"])) {
$key_col[$csv_header] = $i;
}
if ($csv_header == 'user_captcha_text') {
$is_load_col = true;
$posted_captcha = $_POST[$csv_header] ?? "";
$keyword_required =
!empty($_SESSION['captcha_keyword_required']);
$is_keyword_ok = !$keyword_required ||
(!empty($_SESSION['captcha_text']) &&
$posted_captcha === $_SESSION['captcha_text']);
$is_proof_valid = $parent->meetsProofOfWork(
$_SESSION["random_string"] ?? "",
$_SESSION["request_time"] ?? "",
$_REQUEST['nonce_for_string'] ?? "",
$_SESSION["level"] ?? 0);
$is_human = $is_proof_valid && $is_keyword_ok;
$is_reload = strlen($captcha_hash) > 0 &&
substr($posted_captcha, 0, strlen($captcha_hash)) ==
$captcha_hash;
if (!$is_human && !$is_reload) {
$parent->model("visitor")->updateVisitor(
L\remoteAddress(), "captcha_time_out");
return $parent->redirectWithMessage(
tl('social_component_captcha_failed'), array_merge(
$preserve_fields, $csv_headers));
} else if ($is_human && !$is_reload) {
$is_new_hash_row = true;
$is_load_mode = false;
}
}
$header_type = $_POST['CSVFORM'][$csv_header] ?? "textfield";
$header_type = ($is_required) ? substr($header_type, 2) :
$header_type;
if (in_array($header_type, ['sorter', 'choosek'])) {
$pre_clean = substr($_POST[$csv_header], 0,
C\CVS_FORM_TEXTAREA_LEN);
$pre_clean = json_decode($pre_clean, true, 2);
$pre_clean ??= [""];
if (is_string($pre_clean)) {
$pre_clean = [$pre_clean];
}
$out_clean = [];
foreach ($pre_clean as $field => $value) {
$out_clean[] = substr($parent->clean($value ?? "",
"string"), 0 , C\LONG_NAME_LEN);
}
$out_row[] = json_encode($out_clean);
} else {
$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\CVS_FORM_TEXTAREA_LEN
];
$clean_field = substr($clean_field, 0,
$max_lengths[$header_type]);
}
$new_data_hash = L\crawlHash($new_data_hash . $clean_field);
if (!$is_load_col &&
!$this->formFieldIsEmpty($clean_field)) {
$is_load_mode = false;
}
$out_row[] = $clean_field;
}
$i++;
}
if ($missing_fields && !$is_load_mode) {
return $parent->redirectWithMessage(
tl('social_component_fill_required_fields'), array_merge(
$preserve_fields, $csv_headers));
}
if ($is_new_hash_row) {
$_REQUEST["user_captcha_text"] = $captcha_hash .
$new_data_hash;
$out_row[$key_col["user_captcha_text"]] =
$_REQUEST["user_captcha_text"];
} else if ($is_load_mode) {
$preserve_fields[] = "user_captcha_text";
return $parent->redirectWithMessage(
tl('social_component_loading'), $preserve_fields);
}
if (file_exists($secrets_filepath) && !file_exists($csv_filepath)) {
/* A vote holds the answers and the names of what was asked,
and the two are read back side by side, so they are made
here from the one list of fields rather than worked out
twice from what the form sent. The sign-in check and the
picture puzzle are not questions and are left out of
both. */
list($questions, $answers) = $this->voteAnswers($csv_headers,
$out_row);
list($message, $receipt) = $signin_model->addVote(
$secrets_filepath, $secret_form_hash, $questions,
$answers);
unset($_REQUEST['route']);
if ($message == "FILE_CORRUPTED") {
return $parent->redirectWithMessage(
tl('social_component_poll_corrupted'),
$preserve_fields);
} else if ($message == "ALREADY_VOTED") {
return $parent->redirectWithMessage(
tl('social_component_already_voted'),
$preserve_fields);
}
$_REQUEST['VOTE_RECEIPT'] = $receipt;
$_REQUEST['SUBMIT_SUCCESSFUL'] = true;
$_REQUEST['c'] = "group";
return $parent->redirectWithMessage(
tl('social_component_vote_cast'), array_merge(
['VOTE_RECEIPT', 'SUBMIT_SUCCESSFUL'], $preserve_fields));
}
$out_rows = [];
$active_field = "";
if (!preg_match(
"/\[{(share-edited-form|user-record-form|hash-record-form)}\]/",
$page, $form_matches) && file_exists($csv_filepath)) {
if (filesize($csv_filepath) > C\MAX_WIKI_FORM_CSV_SIZE) {
return $parent->redirectWithMessage(
tl('social_component_csv_too_big'), $preserve_fields);
}
$fh = fopen($csv_filepath, "a+");
} else if (!empty($form_matches[1]) &&
$form_matches[1] != 'share-edited-form') {
$active_field = ($form_matches[1] == 'user-record-form') ?
"username" : "user_captcha_text";
if (empty($_POST['CSVFORM']['user_captcha_text']) &&
$active_field == "user_captcha_text") {
return $parent->redirectWithMessage(
tl('social_component_form_needs_captcha'),
$preserve_fields);
}
if (file_exists($csv_filepath)) {
$fh2 = fopen($csv_filepath, "r");
$csv_headers = fgetcsv($fh2, escape: "\\");
while ($row = fgetcsv($fh2, escape: "\\")) {
if (!empty($row[$key_col[$active_field]]) &&
$row[$key_col[$active_field]] ==
$_REQUEST[$active_field]) {
continue;
}
$out_rows[] = $row;
}
fclose($fh2);
}
$fh = fopen($csv_filepath, "w+");
fputcsv($fh, $csv_headers, escape: "\\");
} else if (!empty($form_matches[1]) &&
$form_matches[1] == 'share-edited-form') {
$kept = [];
if (file_exists($csv_filepath)) {
$fh2 = fopen($csv_filepath, "r");
$kept_headers = fgetcsv($fh2, escape: "\\");
$kept_row = fgetcsv($fh2, escape: "\\");
fclose($fh2);
if (!empty($kept_headers) && !empty($kept_row)) {
foreach ($kept_headers as $at => $kept_header) {
$kept[$kept_header] = $kept_row[$at] ?? "";
}
}
}
foreach ($csv_headers as $at => $csv_header) {
if (($out_row[$at] ?? "") === "" &&
isset($kept[$csv_header])) {
$out_row[$at] = $kept[$csv_header];
}
}
$fh = fopen($csv_filepath, "w+");
fputcsv($fh, $csv_headers, escape: "\\");
} else {
$fh = fopen($csv_filepath, "w+");
fputcsv($fh, $csv_headers, escape: "\\");
}
$out_rows[] = $out_row;
foreach ($out_rows as $out_row) {
fputcsv($fh, $out_row, escape: "\\");
}
fclose($fh);
$_REQUEST['SUBMIT_SUCCESSFUL'] = true;
unset($_REQUEST['route']);
$_REQUEST['c'] = "group";
/* The route that named the page is dropped just above, so the
controller, the activity, the group and the page all have to be
named outright. A page reached by its own address carries none
of them as fields: without the activity the redirect lands on
the group controller with nothing to do, and without the group
and the page it lands on the group's front page rather than on
the form that was just filled in. */
$_REQUEST['a'] = "wiki";
$_REQUEST['group_id'] = $group_id;
if (!empty($data['PAGE_NAME'])) {
$_REQUEST['page_name'] = $data['PAGE_NAME'];
}
return $parent->redirectWithMessage(
tl('social_component_choices_recorded'), array_merge(
['SUBMIT_SUCCESSFUL'], $preserve_fields, $csv_headers));
}
/**
* renderHistoryInBrowser prepares a wiki history revision to render in the
* browser instead of on the server. The revision's raw source is placed in
* a script literal and a short call renders it with the parser ported to
* help.js, so the server does only a string pass per crawler view. A
* markdown revision, which has no ported parser, is shown as its raw source
* with a note. This is used only for revisions with no resources, whose
* resolution needs the server's files on disk.
* @param array &$data view fields; PAGE, SCRIPT and INCLUDE_SCRIPTS are set
* @param array $page_info the revision row, providing PAGE and GROUP_ID
* @param int $render_engine the group's engine, mediawiki or markdown
* @param string $header the back link and date shown above the body
* @param string $page_id id of the page the revision belongs to
* @return void nothing is handed back
*/
private function renderHistoryInBrowser(&$data, $page_info, $render_engine,
$header, $page_id)
{
$is_mediawiki = ($render_engine == C\MEDIAWIKI_ENGINE);
$render_target = "wiki-history-rendered";
$source_literal = json_encode($page_info["PAGE"],
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
if (!isset($data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"] = [];
}
$data["INCLUDE_SCRIPTS"][] = "help";
$data["INCLUDE_SCRIPTS"][] = "wiki_parser";
if ($is_mediawiki) {
$data["PAGE"] = $header . "<div id='$render_target'></div>";
} else {
$notice = tl("social_component_history_no_client_render");
$data["PAGE"] = $header . "<p class='red'>" . $notice .
"</p><pre id='$render_target'></pre>";
}
if (!isset($data['SCRIPT'])) {
$data['SCRIPT'] = "";
}
$data['SCRIPT'] .= "renderWikiHistory('" . $render_target .
"', $source_literal, " . ($is_mediawiki ? "true" : "false") .
", '" . $page_info['GROUP_ID'] . "', '" . $page_id . "', '" .
$data['CONTROLLER'] . "', '" . C\p('CSRF_TOKEN') . "', '" .
$data[C\p('CSRF_TOKEN')] . "');\n";
}
/**
* renderDiffInBrowser prepares a wiki-revision diff to be computed in the
* browser rather than on the server. The two revisions' raw source ride to
* the page in script literals (angle brackets hex-escaped so a "</script>"
* inside a revision cannot end the block) and a short call runs the diff
* ported to help.js. The server emits only the coarse fallback, so it
* builds no subsequence table on each crawler visit.
* @param array &$data view fields; SCRIPT and INCLUDE_SCRIPTS are set
* @param string $source1 source of the first revision being compared
* @param string $source2 source of the second revision being compared
* @return void nothing is handed back
*/
private function renderDiffInBrowser(&$data, $source1, $source2)
{
$literal1 = json_encode($source1,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$literal2 = json_encode($source2,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
if (!isset($data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"] = [];
}
$data["INCLUDE_SCRIPTS"][] = "help";
$data["INCLUDE_SCRIPTS"][] = "wiki_parser";
if (!isset($data['SCRIPT'])) {
$data['SCRIPT'] = "";
}
$data['SCRIPT'] .= "renderWikiDiff('wiki-diff-rendered', $literal1, " .
"$literal2, " . C\MAX_DIFF_LCS_CELLS_CLIENT . ", " .
C\MAX_DIFF_LCS_BAND .
");\n";
}
/**
* updateGetWikiImpressionInfo used to populate recent page and group
* activity dropdowns for a wiki page and to update the recent page
* impressions so that this can be calculated
* @param array &$data $data data to be sent to the view, will be modified
* according to impression info.
* @param int $user_id id of the user requesting to change the given wiki
* page
* @param int $group_id id of the group the wiki page belongs to
*/
private function updateGetWikiImpressionInfo(&$data, $user_id, $group_id)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
$personal_group_id = $group_model->getPersonalGroupId($user_id);
if (!empty($data['PAGE_ID']) && $data['MODE'] != 'api') {
$parent->model("impression")->add($user_id, $data['PAGE_ID'],
C\WIKI_IMPRESSION);
$parent->model("impression")->add($user_id, $group_id,
C\GROUP_IMPRESSION);
}
if ($user_id != C\PUBLIC_USER_ID) {
$page_ids = $parent->model("impression")->recent($user_id,
C\WIKI_IMPRESSION, 6);
if (!empty($page_ids)) {
$data['RECENT_PAGES'] = [];
$i = 0;
foreach ($page_ids as $recent_page_id) {
$page_info = $wiki_model->getPageInfoByPageId(
$recent_page_id);
$group_name = empty($page_info['GROUP_ID']) ? "" :
$group_model->getGroupName($page_info['GROUP_ID']);
$len = strlen(C\PERSONAL_GROUP_PREFIX);
if (substr($group_name, 0, $len) ==
C\PERSONAL_GROUP_PREFIX) {
continue;
}
if (!empty($page_info) && (empty($data['PAGE_NAME']) ||
($page_info['PAGE_NAME'] != $data['PAGE_NAME']))) {
$data['RECENT_PAGES'][
$page_info['PAGE_NAME']. "@". $group_name] =
htmlentities(B\wikiUrl($page_info['PAGE_NAME'],
true, $data['CONTROLLER'],
$page_info['GROUP_ID']));
if ($data['MODE'] == 'edit') {
$data['RECENT_PAGES'][$page_info['PAGE_NAME']
. "@" . $group_name] .=
"&arg=edit&";
}
}
$i++;
if ($i > 5) {
break;
}
}
}
$group_ids = $parent->model("impression")->recent($user_id,
C\GROUP_IMPRESSION, 6);
if (!empty($group_ids)) {
$data['RECENT_GROUPS'] = [];
foreach ($group_ids as $recent_group_id) {
if ($recent_group_id == $personal_group_id) {
continue;
}
$group_name = $group_model->getGroupName(
$recent_group_id);
if (!empty($group_name) &&
($recent_group_id != $group_id ||
empty($data['PAGE_NAME']) ||
$data['PAGE_NAME'] != 'Main')) {
$data['RECENT_GROUPS'][$group_name] =
htmlentities(B\wikiUrl("Main" , true,
$data['CONTROLLER'], $recent_group_id));
if ($data['MODE'] == 'edit') {
$data['RECENT_GROUPS'][$group_name] .=
"&arg=edit&";
}
}
}
}
if (!empty($data['RECENT_GROUPS']) &&
count($data['RECENT_GROUPS']) > 5) {
array_pop($data['RECENT_GROUPS']);
}
}
}
/**
* editWiki used to handle edit settings and resources actions for the
* wiki() activity This method was pulled out of the giant switch case in
* wiki() and the refactoring still needs some work. Hence, the awkward
* parameter list below.
* @param array &$data $data data to be sent to the view, will be modified
* according to the edit action.
* @param int $user_id id of the user requesting to change the given wiki
* page
* @param int $group_id id of the group the wiki page belongs to
* @param array $group associative array of info about the group wiki page
* belongs to
* @param int $page_id if of wiki page being edited
* @param string $page_name string name of wiki page being edited
* @param string $page cleaned wiki page that came from $_REQUEST, if any
* @param string $sub_path sub resource folder being edited of wiki page, if
* any
* @param string $edit_reason reason for performing update on wiki page
* @param array $missing_fields fields missing from the request that might
* be needed to perform edit
* @param string $read_address url base addressed to use in performing some
* wiki substitutions to generate a html page from a wiki page.
* @return mixed redirectWithMessage call result on error/success paths;
* void on early-out (insufficient privilege or no-op caret-only update)
*/
private function editWiki(&$data, $user_id, $group_id, $group, $page_id,
$page_name, $page, $sub_path, $edit_reason,
$missing_fields, $read_address)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
if ($group_model->checkUserGroup($user_id, $group_id,
C\EDITOR_STATUS)) {
$data['CAN_EDIT'] = true;
}
if (empty($data["CAN_EDIT"])) {
return;
}
if (isset($_REQUEST['caret']) &&
isset($_REQUEST['scroll_top'])
&& !isset($page)) {
$caret = $parent->clean($_REQUEST['caret'],
'int');
$scroll_top = $parent->clean($_REQUEST['scroll_top'],
'int');
$data['SCRIPT'] .= "wiki = elt('wiki-page');" .
"if (wiki != null) { " .
" if (wiki.setSelectionRange) { " .
" wiki.focus();" .
" wiki.setSelectionRange($caret, $caret);".
" } ".
" wiki.scrollTop = $scroll_top;" .
"}";
}
$data["MODE"] = "edit";
/* The editor shows what the page will look like beside the source
it is written in. The parser ported to help.js does the
rendering, so a keystroke costs no request and no work on the
server. That port covers the mediawiki engine only, and a page
naming a resource needs the group's files on disk to resolve it,
which the browser cannot reach, so in those two cases the
preview says so rather than showing something that differs from
what saving would give. */
/* Both kinds of page have a reader in the browser now, so both
show a preview beside their source. */
$data["WIKI_PREVIEW"] = true;
/* A page reached by its name arrives without its id, and the
preview asks the site for a resource by page, so the id is
looked up here rather than left as nothing. */
/* A place on a front page may hold a whole category, so the
names this group files under are offered to choose from: those
the group has declared, and any its pages are already filed
under. */
$kept = $wiki_model->getGroupPageSettings($group_id);
$data['front_page_categories'] = array_merge($kept['categories'],
$group_model->getGroupCategories($group_id));
/* The pages a place may hold, offered as a writer types a name so
they need not remember what the group holds. */
list($found, $pages) = $wiki_model->getPageList($group_id,
$data['CURRENT_LOCALE_TAG'], "", "name_asc", 0,
self::PAGES_OFFERED);
$data['front_page_articles'] = [];
foreach ((array)$pages as $one) {
$data['front_page_articles'][] = $one['PAGE_NAME'] ??
($one['SHOW_PAGE_NAME'] ?? "");
}
/* A group may say what kind of page a new page starts as, so a
newsroom's writer begins with an article rather than switching
the kind by hand. */
if (empty($page_id) && !empty($kept['default_page_type']) &&
(empty($data['current_page_type']) ||
$data['current_page_type'] == "standard")) {
$data['current_page_type'] = $kept['default_page_type'];
$data['page_type'] = $kept['default_page_type'];
}
$preview_page_id = $page_id;
if (empty($preview_page_id) && !empty($page_name)) {
$preview_page_id = $wiki_model->getPageId($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'] ??
L\getLocaleTag());
}
$data["WIKI_PREVIEW_PAGE_ID"] = $preview_page_id;
if ($data["WIKI_PREVIEW"]) {
$data["INCLUDE_SCRIPTS"] ??= [];
$data["INCLUDE_SCRIPTS"][] = "help";
$data["INCLUDE_SCRIPTS"][] = "wiki_parser";
/* A page written in markdown is read in the browser by the
reader written for it, which travels on its own so a page
written in wiki markup need not carry it. */
if (($group['RENDER_ENGINE'] ?? C\MEDIAWIKI_ENGINE) ==
C\MARKDOWN_ENGINE) {
$data["INCLUDE_SCRIPTS"][] = "markdown_parser";
}
/* A chart written into a page is drawn by the chart script,
and at fourteen kilobytes it is not worth fetching only
when one appears. */
$data["INCLUDE_SCRIPTS"][] = "chart";
/* The square symbol is drawn from arithmetic rather than
fetched, so the editor carries the arithmetic. */
$data["INCLUDE_SCRIPTS"][] = "qr";
/* Math is set by MathJax, asked for by name the way every
other page that shows math asks for it. Fetching it from
the editor instead put its parts in the wrong order and it
failed to start. */
$data["INCLUDE_SCRIPTS"][] = "math";
}
/* The group owner and the root account get two extra resource
actions for fixing the page's resource version history when
it gets into a bad state: one clears a leftover lock file,
the other saves a fresh version snapshot of the current
resource folder. The view adds these after the File Upload
action so they sit at the end of the list. */
$data['CAN_FIX_RESOURCE_VERSIONS'] = (isset($group['OWNER_ID']) &&
$group['OWNER_ID'] == $user_id) || $user_id == C\ROOT_ID;
if ($data["SHARE_WALL_EDIT"]) {
$data["MODE"] = "read";
$_REQUEST['arg'] = 'read';
}
$page_info = $wiki_model->getPageInfoByName($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'], 'edit');
/* if page not yet created than $page_info will be null
so in the below $page_info['ID'] won't be set.
*/
/* What each place was filled in with is read back out of the
page, so a writer returning to a front page sees their own
choices. It is read from the page just fetched rather than from
the fields the form has been given, which are not filled in
until later and so were always empty here. Whether the page is
a front page is told from the page itself for the same reason.
Without this the form came up blank every time and saving wrote
those blank places back over the page. */
$held_page = $page_info['PAGE'] ?? "";
if (strpos($held_page, "front-page-") !== false) {
$data['front_page_layout'] =
$this->frontPageLayoutInBody($held_page);
$data['front_page_slots'] = $this->frontPageSlotChoicesInBody(
$held_page, $data['front_page_layout']);
/* A place may name a page that has not been written yet. The
name is kept either way, and the form says which names lead
nowhere so a writer can tell a plan from a mistake. */
foreach ($data['front_page_slots'] as $slot => $filled) {
if (($filled['kind'] ?? "") != "article") {
continue;
}
$data['front_page_slots'][$slot]['missing'] =
empty($wiki_model->getPageId($group_id,
$filled['page'], $data['CURRENT_LOCALE_TAG']));
}
}
$upload_allowed = true;
/* A page has a copy in each language, so which one is being
worked on travels through a redirect with everything else.
Without it a paste or a delete came back on whichever copy
the site picked rather than the one in front of the
writer. */
$preserve_fields = ['arg', 'page_name', 'resources', 'settings',
'caret', 'scroll_top', 'sf', 'page_locale'];
if ($missing_fields) {
return $parent->redirectWithMessage(
tl("social_component_missing_fields"));
} else if (isset($page_info['ID']) && !empty($_REQUEST['n'])
&& !isset($_REQUEST['resource_description'])) {
$answer = $this->editWikiResourceFile($data, $group_id,
$page_info, $page_name, $page, $sub_path,
$preserve_fields);
if ($answer !== null) {
return $answer;
}
} else {
$answer = $this->editWikiPageSettings($data, $user_id,
$group_id, $group, $page_name, $page, $sub_path,
$edit_reason, $read_address, $page_info,
$preserve_fields);
if ($answer !== null) {
return $answer;
}
}
$answer = $this->editWikiResourceAction($data, $user_id, $group_id,
$group, $page_info, $page_name, $sub_path, $preserve_fields,
$upload_allowed);
if ($answer !== null) {
return $answer;
}
$answer = $this->editWikiUploadResource($data, $user_id, $group_id,
$page_info, $page_name, $page, $sub_path, $edit_reason,
$read_address, $preserve_fields, $upload_allowed);
if ($answer !== null) {
return $answer;
}
if (isset($page_info['ID'])) {
$create = ($user_id == C\PUBLIC_USER_ID) ? false : true;
$data['RESOURCES_INFO'] =
$wiki_model->getGroupPageResourceUrls($group_id,
$page_info['ID'], $sub_path, $create);
$this->addPodcastSourceStatus($data, $group_id,
$page_info['ID'], $sub_path);
if ($user_id != C\PUBLIC_USER_ID) {
$data['CLIPBOARD_INFO'] =
$wiki_model->getClipboardResourceNames($user_id);
}
} else {
$data['RESOURCES_INFO'] = [];
}
}
/**
* editWikiPageSettings saves what a writer typed for a page,
* together with the settings they chose for it.
*
* editWiki calls this where the request is about the page itself
* rather than a file beside it. A save is refused where somebody
* else has written to the page since this writer opened it. An
* article files itself under the categories the writer picked,
* and a page built from a template keeps the boxes the template
* named.
*
* @param array &$data what the page will show, added to here
* @param int $user_id which writer is asking
* @param int $group_id which group the page belongs to
* @param array $group what the model holds about that group
* @param string $page_name name of the page being written
* @param string $page what the writer typed
* @param string $sub_path folder under the page the writer is in
* @param string $edit_reason what the writer said the change is
* for
* @param string $read_address address a link in the page is built
* from
* @param array $page_info what the model holds about the page
* @param array $preserve_fields fields a redirect carries so the
* writer comes back to the screen they were on
* @return mixed what is handed back to the browser, or null where
* the page is drawn as usual
*/
private function editWikiPageSettings(&$data, $user_id, $group_id,
$group, $page_name, $page, $sub_path, $edit_reason,
$read_address, $page_info, $preserve_fields)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$wiki_model = $parent->model("wiki");
list($head_object, $page_data) = WikiParser::parsePageHeadVars(
$page_info['PAGE'] ?? "", true);
$is_currently_template = (!empty($head_object["page_type"]) &&
$head_object["page_type"][0] == 't');
if (!$this->canCreateGitRepository($user_id) &&
($head_object["page_type"] ?? "") != "git_repository") {
unset($data["page_types"]["git_repository"]);
}
$is_settings = (isset($_REQUEST['settings']) &&
$_REQUEST['settings'] == 'true');
if ($is_settings && $page === null &&
$_SERVER['REQUEST_METHOD'] === 'POST') {
$page = $page_data;
}
if (isset($page) || ($is_currently_template &&
!empty($_REQUEST['page_type']))
|| isset($_REQUEST['default_sort'])) {
$action = "wikiupdate_".
"group=" . $group_id . "&page=" . $page_name;
if (!$parent->checkCSRFTime(C\p('CSRF_TOKEN'), $action) &&
$head_object["page_type"] != "share") {
$data['SCRIPT'] .= "doMessage('<h1 class=\"red\" >".
tl('social_component_wiki_edited_elsewhere').
"</h1>');";
return;
}
list($head_vars, $write_head, $set_path,
$resource_path_error) = $this->editWikiHeadVars($data,
$head_object, $page_info, $page, $sub_path);
/* The resource lookup is offered on a media list alone,
since no other kind of page reads it. A control put
away with a style still sends its value, so a page of
another kind is set to no lookup here rather than
trusting the form not to send one. */
if (($head_vars['page_type'] ?? "") != "media_list") {
$head_vars['update_description'] = "no-lookup";
}
/* An article files itself. The names a writer picked are
written into the body as tags, so that looking a
category up still finds the page, and into the head as
the open-graph properties a link preview reads. */
if (($head_vars['page_type'] ?? "") == "news_article" &&
is_string($page)) {
/* Only a screen carrying the category control can
change the filing. Saving from a screen without it,
such as the settings, left the filing empty and an
article dropped out of every category it was in. */
if (isset($_REQUEST['article_categories'])) {
/* The picker says what the writer chose, and any
tag typed into the words counts too, so writing
one by hand still files the page. */
$filed_under = array_values(array_unique(
array_merge($this->articleCategories($parent),
self::categoriesInBody($page))));
} else {
/* A screen without the picker cannot change the
filing, so what the page is already filed under
stands. */
$filed_under = $wiki_model->getPageCategories(
$wiki_model->getPageId($group_id, $page_name,
$data['CURRENT_LOCALE_TAG']));
}
$page = $this->articleBodyWithCategories($page,
$filed_under);
$head_vars['properties'] = $this->articleProperties(
$head_vars, $filed_under, $group_model, $group_id,
$page_name, $data['CURRENT_LOCALE_TAG']);
}
$head_string = WikiParser::makeWikiPageHead($head_vars);
if (is_array($page)) { //template case
$page = base64_encode(serialize($page));
}
/* A front page is laid out by choosing a shape and
naming what fills each of its places, so its body is
written from those choices rather than typed. */
if (($head_vars['page_type'] ?? "") == "front_page" &&
isset($_REQUEST['front_page_layout'])) {
$page = $this->frontPageBody($parent, $head_vars,
$group['RENDER_ENGINE'] ?? C\MEDIAWIKI_ENGINE);
}
if (!empty($page) || (!empty($head_vars['page_type']) &&
$head_vars['page_type'] != 'standard')) {
$page = $head_string . WikiParser::END_HEAD_VARS . $page;
}
$page_info = (empty($page_info)) ? [] : $page_info;
if (empty($wiki_model->getPageId($group_id, $page_name,
$data['CURRENT_LOCALE_TAG'])) &&
$this->overGroupLimit($group_id,
'MAX_GROUP_WIKI_PAGES',
$wiki_model->countGroupWikiPages($group_id))) {
return $parent->redirectWithMessage(
tl('social_component_wiki_pages_full'));
}
$page_info['ID'] = $wiki_model->setPageName($user_id,
$group_id, $page_name, $page,
$data['CURRENT_LOCALE_TAG'], $edit_reason,
tl('social_component_page_created', $page_name),
tl('social_component_page_discuss_here'),
$read_address);
$preserve_fields[] = 'n';
if (empty($page_info['ID'])) {
return $parent->redirectWithMessage(
tl('social_component_page_not_saved'),
$preserve_fields);
}
if (!empty($page_info['ID'])) {
$page_folders =
$wiki_model->getGroupPageResourcesFolders(
$group_id, $page_info['ID'], "", true, false);
if (isset($page_folders[1])) {
$page_folder = $page_folders[0];
/* Write or remove the resource-path redirect on the
page's own folder first, so the folder resolved
just below follows the new Resource Path and all
of the page's resources, the git repository
included, live there. */
if ($set_path) {
if (!empty($head_vars['alternative_path'])) {
$parent->web_site->filePutContents(
"$page_folder/redirect.txt",
$head_vars['alternative_path']);
} else if (file_exists(
"$page_folder/redirect.txt")) {
unlink("$page_folder/redirect.txt");
}
}
$tmp = $wiki_model->getGroupPageResourcesFolders(
$group_id, $page_info['ID'], "", true, true);
list($resource_path, $thumb_path,) = $tmp;
if (!empty($head_vars['page_type']) &&
$head_vars['page_type'] == 'git_repository') {
$git_repository =
new LVC\GitRepository(
$resource_path);
$default_branch = preg_replace(
"/[^A-Za-z0-9._\/-]/", "",
(string)C\p('GIT_DEFAULT_BRANCH'));
if ($default_branch === "") {
$default_branch =
LVC\GitRepository::
DEFAULT_BRANCH;
}
$git_repository->initBare($default_branch);
}
$csv_form_file = $resource_path . '/' .
C\WIKI_FORM_CSV_FILE;
if (file_exists($csv_form_file)) {
rename($csv_form_file,
str_replace( ".csv",
date("Y-m-d-H-i-s") . ".csv",
$csv_form_file));
}
$secrets_form_file = $resource_path . '/' .
C\WIKI_FORM_SECRETS_FILE;
if (file_exists($secrets_form_file)) {
rename($secrets_form_file,
str_replace( ".txt",
date("Y-m-d-H-i-s") . ".txt",
$secrets_form_file));
}
}
}
if (empty($_FILES['page_resource']['name']) &&
empty($_FILES['page_icon']['name'])) {
$saved_message = $resource_path_error ?
tl("social_component_resource_not_created") :
tl("social_component_page_saved");
return $parent->redirectWithMessage($saved_message,
$preserve_fields);
} else if (!empty($_FILES['page_icon']['name'])) {
return $this->handlePageIconUpload($group_id,
$page_info['ID'], $preserve_fields);
}
}
}
/**
* initCommonWikiArrays used to initialize arrays for dropdowns in
* WikiElement as well as various arrays for cleaning request variables
* @param string $controller_name used to set up variables for view elements
* should be either admin, api, or group depending on which controller
* is being used to handle wiki interaction
* @return array tuple [$data, $sub_path, $clean_array, ...] of values used
* by the wiki action handlers (full destructuring is at the matching
* return statement at the end of this function)
*/
private function initCommonWikiArrays($controller_name)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$data = [];
$data["CONTROLLER"] = $controller_name;
$data["ELEMENT"] = "wiki";
$data["VIEW"] = "group";
$data["SCRIPT"] = "";
$data["INCLUDE_STYLES"] = ["editor"];
$locale_tag = L\getLocaleTag();
/* A writer may work on a page in a locale other than the one
they are reading the site in, so the page carries its own
choice. The site's own language is left as it was. */
$locale_model = $parent->model("locale");
$data['LOCALE_LIST'] = $locale_model->getLocaleList();
if (!empty($_REQUEST['page_locale'])) {
$wanted = $parent->clean($_REQUEST['page_locale'], "string");
foreach ($data['LOCALE_LIST'] as $known) {
if ($known['LOCALE_TAG'] == $wanted) {
$locale_tag = $wanted;
break;
}
}
}
$data['CURRENT_LOCALE_TAG'] = $locale_tag;
$sub_path = "";
if (!empty($_REQUEST['page_name'])) {
$name_parts = explode("/", $_REQUEST['page_name']);
if (count($name_parts) > 1) {
$_REQUEST['page_name'] = array_shift($name_parts);
$sub_path = $parent->clean(implode("/", $name_parts),
'path');
$data['SUB_PATH'] = htmlentities($sub_path);
}
}
if (!empty($_REQUEST['sf'])) {
$sub_path = $parent->clean($_REQUEST['sf'], 'path');
$data['SUB_PATH'] = htmlentities($sub_path);
}
if (!empty($_REQUEST['reset_detail'])) {
if ($_REQUEST['reset_detail'] == 'true') {
$data['RESET_DETAIL'] = true;
}
}
$data['ORIGINAL_SUB_PATH'] = $sub_path;
if ((isset($_REQUEST['c'])) && $_REQUEST['c'] == "api") {
//wiki help request
$data['MODE'] = 'api';
$data['VIEW'] = 'api';
} else {
$data["MODE"] = "read";
// additional feed data on page_and_feedback page
if (!empty($_REQUEST['f']) && $_REQUEST['f'] == "api") {
$data['VIEW'] = 'api';
}
}
$data['page_types'] = [
"standard" => tl('social_component_standard_page'),
"page_and_feedback" => tl('social_component_page_and_feedback'),
"page_alias" => tl('social_component_page_alias'),
"media_list" => tl('social_component_media_list'),
"presentation" => tl('social_component_presentation'),
"url_shortener" => tl('social_component_url_shortener'),
"share" => tl('social_component_share_wall'),
"git_repository" => tl('social_component_git_repository'),
"front_page" => tl('social_component_front_page'),
"news_article" => tl('social_component_news_article')
];
/* A front page lays its feeds out in one of a few settled ways.
A writer picks one by the look of it, and the page is written
with the classes that give that look. */
/* A place on a front page holds either one article or a whole
category of them, so a writer says which before naming it. */
$data['front_page_kinds'] = [
"article" => tl('social_component_one_article'),
"category" => tl('social_component_a_category'),
"category_names" => tl('social_component_category_names')
];
$data['front_page_slot_names'] = $this->frontPageSlotNames();
/* Which arrangement is written first, so the one list of pages a
writer may choose from is drawn once rather than once per
arrangement. */
$data['front_page_layout_first'] = "lead-and-list";
$data['front_page_layouts'] = [
"lead-and-list" => tl('social_component_lead_and_list'),
"cards" => tl('social_component_cards'),
"grid" => tl('social_component_grid'),
"sections" => tl('social_component_sections')
];
$data['page_borders'] = [
"solid-border" => tl('social_component_solid'),
"dashed-border" => tl('social_component_dashed'),
"none" => tl('social_component_none')
];
$page_themes = $parent->model('profile')->getThemeNames();
$data['page_themes'] = array_merge(
["" => tl('social_component_no_auxiliary_theme')],
array_combine($page_themes , $page_themes));
$data['update_descriptions'] = [
"no-lookup" => tl('social_component_no_lookup'),
"files-only" => tl('social_component_files_only'),
"folders-only" => tl('social_component_folders_only'),
"files-and-folders" => tl('social_component_files_and_folders')
];
$data['resource_actions'] = [
tl('social_component_actions') => "",
"new-folder" => tl('social_component_new_folder'),
"new-text-file" => tl('social_component_new_text_file'),
"new-csv-file" => tl('social_component_new_csv_file'),
"new-image-file" => tl('social_component_new_image_file'),
];
$data['share_page_expires'] = [
C\FOREVER => tl('social_component_never'),
C\ONE_HOUR => tl('social_component_one_hour'),
C\ONE_DAY => tl('social_component_one_day'),
C\ONE_MONTH => tl('social_component_one_month'),
];
$data['sort_fields'] = [
tl('social_component_sort_order') => "",
"name_asc" => tl('social_component_name_ascending'),
"name_desc" => tl('social_component_name_descending'),
"modified_asc" => tl('social_component_date_ascending'),
"modified_desc" => tl('social_component_date_descending'),
"size_asc" => tl('social_component_size_ascending'),
"size_desc" => tl('social_component_size_descending'),
];
$clean_array = [
"group_id" => "int",
"page_name" => "string", //up to here are required fields
"diff" => 'int',
"diff1" => 'int',
"diff2" => 'int',
"edit_reason" => "string",
"filter" => 'string',
"group_name" => 'string',
"limit" => 'int',
"num" => 'int',
"page" => "string",
"page_id" => 'int',
'page_theme' => 'string',
'resource_description' => '',
'resource_filter' => 'file_name',
"revert" => 'int',
"share_expires" => 'string',
"share_wall_data" => 'string',
"show" => 'int',
"sort" => 'string',
"target" => "string",
];
$strings_array = [
"page_name" => C\TITLE_LEN,
"page" => C\MAX_GROUP_PAGE_LEN,
"edit_reason" => C\SHORT_TITLE_LEN,
"filter" => C\SHORT_TITLE_LEN,
"resource_filter" => C\SHORT_TITLE_LEN];
/* Check if back params need to be set. Set them if required.
the back params are usually sent when the wiki action is initiated
from within an open help article.
*/
$data["OTHER_BACK_URL"] = "";
if (isset($_REQUEST['back_params']) &&
((isset($_REQUEST['arg']) && in_array(
$parent->clean($_REQUEST['arg'],"string"), ['edit',
'read'])) || (isset($_REQUEST['page_name'])))
) {
$back_params_cleaned = $_REQUEST['back_params'];
array_walk($back_params_cleaned, [$parent, 'clean']);
foreach ($back_params_cleaned as
$back_param_key => $back_param_value) {
$data['BACK_PARAMS']["back_params[$back_param_key]"]
= $back_param_value;
$data["OTHER_BACK_URL"] .=
"&back_params[$back_param_key]" . "=" .
$back_param_value;
}
$data['BACK_URL'] = http_build_query($back_params_cleaned);
}
return [$data, $sub_path, $clean_array,
$strings_array];
}
/**
* frontPageLayoutInBody reads which arrangement of places a front page uses
* /** Reads which arrangement of places a front page uses, out of the page
* itself. The arrangement is not kept anywhere else, so without reading it
* back a writer returning to a page was offered the first arrangement
* whatever they had chosen.
* @param string $body the front page's own markup
* @return string the arrangement it uses
*/
public function frontPageLayoutInBody($body)
{
foreach (["lead-and-list", "cards", "grid", "sections"] as $one) {
if (strpos($body, "front-page-" . $one) !== false) {
return $one;
}
}
return "lead-and-list";
}
/**
* frontPageSlotChoicesInBody reads back what each place on a front page was
* filled in with, so the form shows a writer what they set rather than a
* row of empty controls. The page's own body is what says it, since that is
* where the choices were written. A place naming an article that does not
* exist keeps its name here as anywhere else: the writer meant that name,
* and losing it on every visit meant a front page could not be built before
* the pages it points at were written.
* @param string $body the front page's own markup
* @param string $layout which arrangement of places it uses
* @return array for each place, what kind it holds and what it names
*/
public function frontPageSlotChoicesInBody($body, $layout)
{
$filled = [];
foreach (array_keys($this->frontPageSlotNames($layout)) as $place) {
$classes = "front-page-" . $layout . " front-page-" . $place;
$quoted = preg_quote($classes, "/");
/* A page is kept as the markup a writer typed on the way in
and as the drawing it becomes once saved, so both are read
here: whichever the page is holding, the choices are the
same. What a place was set to may be written by name before
its classes, so anything up to the mark's close is allowed
between them. */
if (preg_match('/\[\{category-list\|([^|}]*)\|[^\}]*' .
$quoted . '[^\}]*/', $body, $found) ||
preg_match('/\{\{category-list\|([^|}]*)\|[^\}]*' .
$quoted . '[^\}]*/', $body, $found) ||
preg_match('/class=.[^\'"]*' . $quoted .
'[^\'"]*.[^>]*data-category=.([^\'"]*)./', $body,
$found)) {
$shown = "scroll";
if (preg_match('/num=(\d+)/', $found[0], $counted)) {
$shown = (int)$counted[1];
}
$filled[$layout . "-" . $place] = ["kind" => "category",
"category" => trim($found[1]), "page" => "",
"items" => $shown,
"heading" => (strpos($found[0], "heading=") !== false),
"shape" => preg_match('/shape=(\w+)/', $found[0],
$how) ? $how[1] : "standard"];
continue;
}
if (preg_match('/\[\{category-names\|[^\}]*' . $quoted .
'[^\}]*/', $body, $found) ||
preg_match('/\{\{category-names\|[^\}]*' . $quoted .
'[^\}]*/', $body, $found)) {
$filled[$layout . "-" . $place] =
["kind" => "category_names", "page" => "",
"category" => ""];
continue;
}
/* A place holding one story is kept as a mark naming it,
answered when the page is read, so the name is here even
where no page of that name has been written yet. */
if (preg_match('/\[\{lead-story\|([^|}]*)\|' . $quoted .
'[^\}]*/', $body, $found) ||
preg_match('/\{\{lead-story\|([^|}]*)\|' . $quoted .
'[^\}]*/', $body, $found)) {
$filled[$layout . "-" . $place] = ["kind" => "article",
"page" => trim($found[1]), "category" => "",
"paragraphs" => self::paragraphCountInMark($body,
$found[0])];
continue;
}
$said = "";
if (preg_match('/\{\{class="' . $quoted .
'"\s*\n(.*?)\n\}\}/s', $body, $found)) {
$said = trim($found[1]);
} else if (preg_match('/<div class="' . $quoted .
'[^"]*">(.*?)<\/div>/s', $body, $found)) {
$said = trim($found[1]);
}
if ($said === "" || strpos($said, "front-page-undefined") !==
false) {
continue;
}
$named = "";
if (preg_match('/\[\[(.*?)\]\]/', $said, $link)) {
$named = trim($link[1]);
} else if (preg_match('/\[(.*?)\]\(/', $said, $link)) {
$named = trim($link[1]);
} else if (preg_match('/<a[^>]*>(.*?)<\/a>/s', $said,
$link)) {
$named = trim(strip_tags($link[1]));
}
if ($named !== "") {
$filled[$layout . "-" . $place] = ["kind" => "article",
"page" => $named, "category" => ""];
}
}
return $filled;
}
/**
* frontPageBody writes the body of a front page from the shape a writer
* chose and the names they gave its places. A place naming a lead story
* holds a link to that page; the rest hold a list of what is filed under
* the category named, laid out by the classes that shape belongs to. The
* body stays ordinary wiki markup, so anyone can read or edit it without
* the form. The body is written in whichever markup the group reads. A
* heading and a link to a page are spelt differently in the two, and a body
* written in the other one shows its own markup to a reader rather than a
* heading: a group set to markdown was given a wiki heading and drew the
* equals signs. What wraps a place in its classes, and what asks for a
* category's articles, read the same either way.
* @param object $parent controller that called this, used to clean what was
* typed
* @param array $head_vars the page's settings, which say the layout
* @param int $render_engine which markup the group reads, wiki or markdown
* @return string the body of the page
*/
public function frontPageBody($parent, $head_vars, $render_engine)
{
$is_markdown = ($render_engine == C\MARKDOWN_ENGINE);
$layout = trim($parent->clean($_REQUEST['front_page_layout'],
"string"));
$allowed = ["lead-and-list", "cards", "grid", "sections"];
if (!in_array($layout, $allowed)) {
$layout = "lead-and-list";
}
$slots = $_REQUEST['front_page_slot'] ?? [];
$names = $this->frontPageSlotNames($layout);
$body = "";
foreach ($names as $place => $says) {
$slot = $layout . "-" . $place;
$held = $slots[$slot] ?? [];
/* A field's value arrives with the line ending that closed
it in the form, so what is compared here is trimmed: an
untrimmed kind never matched and every place fell back to
holding a category. */
$kind = trim($parent->clean($held['kind'] ?? "category",
"string"));
$said = ($kind == "article") ? ($held['page'] ?? "") :
($held['category'] ?? "");
$named = trim($parent->clean($said, "string"));
$classes = "front-page-" . $layout . " front-page-" . $place;
if ($named == "") {
/* A place left empty says so where it stands, rather
than leaving a gap a writer cannot account for. The
words are the ones beside the control they would have
filled in, so the page names the very place. */
$undefined = tl('social_component_slot_undefined',
rtrim($says, ": "));
$heading = $is_markdown ? "# " . $undefined :
"=" . $undefined . "=";
$body .= "{{class=\"" . $classes .
" front-page-undefined\"\n" . $heading . "\n}}\n\n";
continue;
}
if ($kind == "article") {
/* A place holding one article shows the beginning of it
with a way through to the rest, rather than a bare
link: a reader should be able to tell from the front
page whether the story is for them. */
$paragraphs = max(1, (int)($parent->clean(
$held['paragraphs'] ?? self::LEAD_PARAGRAPHS, "int")));
$body .= "{{lead-story|" . $named . "|" . $classes .
"|" . $paragraphs . "}}\n\n";
continue;
}
if ($kind == "category_names") {
$body .= "{{category-names|" . $classes . "}}\n\n";
continue;
}
/* How many of a category to list, and whether to head the
list with the category's name, are said by name so a place
set one way is not disturbed by the other. A count of none
means the list runs on as a reader reaches its end. */
$shown = $parent->clean($held['items'] ?? "", "string");
$asked = "classes=" . $classes;
if (trim($shown) !== "" && trim($shown) != "scroll") {
$asked .= "|num=" . max(1, (int)$shown) . "|scroll=no";
}
if (!empty($held['heading'])) {
$asked .= "|heading=" . $named;
}
$shape = trim($parent->clean($held['shape'] ?? "", "string"));
if ($shape !== "" && $shape != "standard") {
$asked .= "|shape=" . $shape;
}
$body .= "{{category-list|" . $named . "|" . $asked .
"}}\n\n";
}
return $body;
}
/**
* drawnFrontPageMark draws one of the marks a front page holds /** Draws
* one of the marks a front page holds, given as it was written. A place
* naming a story becomes the opening of that story; a place naming a
* category becomes its list. Anything else comes back empty, so a mark this
* does not know cannot put stray text on a page.
* @param object $parent controller that called this
* @param object $group_model model used to look pages up
* @param int $group_id which group the page belongs to
* @param array $data fields from the controller
* @param int $user_id who is reading, so links carry their token
* @param string $said the mark as a writer wrote it
* @return string the drawn place
*/
private function drawnFrontPageMark($parent, $group_model, $group_id,
$data, $user_id, $said)
{
$said = trim($said);
$inside = trim(substr($said, 2, max(0, strlen($said) - 4)));
$parts = explode("|", $inside);
$which = trim(array_shift($parts));
$named = trim(array_shift($parts) ?? "");
if ($which == "lead-story") {
$classes = trim($parts[0] ?? "");
$paragraphs = (int)trim($parts[1] ?? "0");
return $this->leadStoryMarkup($parent, $group_model, $group_id,
$data, $user_id, $named, $classes, $paragraphs);
}
if ($which == "category-list") {
return $this->categoryListMarkup($parent, $group_model,
$group_id, $data, $user_id, $named, implode("|", $parts));
}
if ($which == "category-names") {
return $this->categoryNamesMarkup($parent, $group_model,
$group_id, $data, $user_id, $named);
}
return "";
}
/**
* groupAndCategory reads a category that may name a group before it,
* written the way a page in another group is written: group@category. A
* name with no group before it belongs to the group the page is in.
* @param string $said the category as a writer wrote it
* @param int $group_id which group the page is in
* @param object $group_model model used to look a group up by name
* @return array the group to ask and the category to ask for, the group
* being zero where the name is of no group
*/
public static function groupAndCategory($said, $group_id, $group_model)
{
$at = strpos($said, "@");
if ($at === false) {
return [$group_id, $said];
}
$named = trim(substr($said, 0, $at));
$category = trim(substr($said, $at + 1));
$other = $group_model->getGroupId($named);
return [empty($other) ? 0 : $other, $category];
}
/**
* frontPageSlotNames gives the places each layout holds, by the name each
* is saved under and the words a writer sees beside it. The form and the
* placeholder a reader is shown for a place left empty are named from this
* one list, so the two cannot come to say different things.
* @param string $layout which layout to give the places of, or "" for every
* layout
* @return array place name to the words describing it, under the layout
* name when no layout is asked for
*/
private function frontPageSlotNames($layout = "")
{
$names = [
"lead-and-list" => [
"lead" => tl('wiki_element_front_page_lead'),
"below" => tl('wiki_element_front_page_below')],
"sections" => [
"section1" => tl('wiki_element_front_page_section_one'),
"section2" => tl('wiki_element_front_page_section_two'),
"section3" => tl('wiki_element_front_page_section_three')],
"cards" => [
"card1" => tl('wiki_element_front_page_card_one'),
"card2" => tl('wiki_element_front_page_card_two'),
"card3" => tl('wiki_element_front_page_card_three')],
"grid" => [
"cell1" => tl('wiki_element_front_page_cell_one'),
"cell2" => tl('wiki_element_front_page_cell_two'),
"cell3" => tl('wiki_element_front_page_cell_three'),
"cell4" => tl('wiki_element_front_page_cell_four')]
];
if ($layout === "") {
return $names;
}
return $names[$layout] ?? $names["grid"];
}
/**
* articleCategories gives the category names a writer picked for an
* article. They arrive as one line with commas between, the way the picker
* keeps them, and come back with the blanks and the repeats dropped so a
* name is written once however many times it was chosen.
* @param object $parent controller that called this, used to clean what was
* sent
* @return array the names, in the order they were picked
*/
private function articleCategories($parent)
{
$line = $parent->clean($_REQUEST['article_categories'] ?? "",
"string");
$names = [];
foreach (explode(",", $line) as $one) {
$one = trim($one);
if ($one !== "" && !in_array($one, $names)) {
$names[] = $one;
}
}
return $names;
}
/**
* articleBodyWithoutCategories gives an article's words without the marks
* at the top saying what /** Gives an article's words without the marks
* at the top saying what it is filed under. Those marks are how the filing
* is kept, and a writer chooses the filing from a control of its own, so
* seeing them in the writing area is clutter they never typed and might
* undo by hand.
* @param string $page the article as it is kept
* @return string the same without its filing marks
*/
public static function articleBodyWithoutCategories($page)
{
$body = $page;
$tag = self::CATEGORY_TAG;
while (strncasecmp(ltrim($body), $tag, strlen($tag)) === 0) {
$body = ltrim($body);
$close = strpos($body, "}}");
if ($close === false) {
break;
}
$body = substr($body, $close + 2);
}
return ltrim($body);
}
/**
* articleBodyWithCategories writes the run of category tags that stands at
* the top of an article's body, from the names a writer picked. The tags
* are what files the page: the parser reads each one as a link to the
* category and records it, which is how asking for a category finds the
* page again. A tag draws as nothing, so a reader sees only the article.
* The run an earlier save wrote is taken off first, so choosing again
* replaces what was there instead of piling a second run on top of it. Only
* a run at the very top is touched, since a tag a writer put in the middle
* of the body is theirs and not this method's to move.
* @param string $page the body as the writer left it
* @param array $categories names the page is filed under
* @return string the body with its tags at the top
*/
private function articleBodyWithCategories($page, $categories)
{
$body = self::articleBodyWithoutCategories($page);
$tags = "";
foreach ($categories as $name) {
$tags .= self::CATEGORY_TAG . $name . "}}\n";
}
return ($tags === "") ? $body : $tags . "\n" . $body;
}
/**
* articleProperties builds the properties an article carries in its head,
* so that a link to it posted elsewhere shows its title, its abstract and
* its thumbnail rather than a bare address. These are the open-graph
* properties, the set most sites read for that preview, and each is written
* as one line naming the property and its content, which is the form the
* head already keeps properties in. What a writer put in themselves is left
* alone: only the lines this method writes are replaced, so a property
* added by hand survives a save. Each category the page is filed under
* becomes a tag property, which is the open-graph way of saying what an
* article is about. The thumbnail is named only where the page has one, and
* its address carries a dash where a token would go, since a reader
* following the link from elsewhere holds no token of ours.
* @param array $head_vars the page's settings, holding its title and its
* abstract
* @param array $categories names the page is filed under
* @param object $group_model model used to find the page and its icon
* @param int $group_id which group the page belongs to
* @param string $page_name the page's own name
* @param string $locale_tag which language's copy of the page
* @return string the properties, one to a line
*/
private function articleProperties($head_vars, $categories,
$group_model, $group_id, $page_name, $locale_tag)
{
$parent = $this->parent;
$wiki_model = $parent->model("wiki");
$written = ["og:type", "og:title", "og:description", "og:image",
"og:url", "article:tag"];
$kept = [];
foreach (explode("\n", $head_vars['properties'] ?? "") as $line) {
$named = trim(explode("|", $line, 2)[0]);
if ($named !== "" && !in_array(strtolower($named), $written)) {
$kept[] = rtrim($line, "\r");
}
}
$page_id = $wiki_model->getPageId($group_id, $page_name,
$locale_tag);
$lines = ["og:type|article"];
if (trim($head_vars['title'] ?? "") !== "") {
$lines[] = "og:title|" . trim($head_vars['title']);
}
if (trim($head_vars['description'] ?? "") !== "") {
$lines[] = "og:description|" .
trim(str_replace("\n", " ", $head_vars['description']));
}
if (!empty($page_id) && file_exists(
$wiki_model->getGroupPageIconPath($group_id, $page_id))) {
$lines[] = "og:image|" . C\SHORT_BASE_URL . "wd/thumbs/-/" .
$group_id . "/" . $page_id . "/page_icon.webp";
}
$lines[] = "og:url|" . C\SHORT_BASE_URL . "group/" . $group_id .
"/" . urlencode($page_name);
foreach ($categories as $name) {
$lines[] = "article:tag|" . $name;
}
return implode("\n", array_merge($lines, $kept));
}
/**
* initializeWikiPageToggle used to create Javascript used to toggle a wiki
* page's settings control
* @param array &$data will contain in SCRIPT field necessary Javascript to
* pass to view.
*/
private function initializeWikiPageToggle(&$data)
{
$init_toggle_settings = (empty($data['RESOURCE_NAME'])) ?
'setDisplay("toggle-settings", true, "inline");': '';
$toggle_settings_on = (empty($data['RESOURCE_NAME'])) ?
'setDisplay("toggle-settings", true);': '';
$toggle_settings_false = (empty($data['RESOURCE_NAME'])) ?
'setDisplay("toggle-settings", false);': '';
$toggle_settings_inline = (empty($data['RESOURCE_NAME'])) ?
'setDisplay("toggle-settings", true, "inline");': '';
$data['SCRIPT'] .= <<< EOD
mode = '{$data['MODE']}';
function toggleSettings()
{
var settings = elt('p-settings');
settings.value = (settings.value == 'true')
? 'false' : 'true';
var value = (settings.value == 'true') ? true : false;
var r_settings =elt('r-settings');
if (r_settings && mode == 'edit') {
elt('r-settings').value = settings.value;
}
setDisplay('page-settings', value);
var page_type = elt("page-type");
var cur_type = page_type.options[
page_type.selectedIndex].value;
if (cur_type == "media_list" && mode == 'edit') {
setDisplay('save-container', value);
}
toggleClass("settings-toggle-button","back-gray");
}
ptype = document.getElementById("page-type");
is_media_list = ('media_list'=='{$data['current_page_type']}');
is_settings = {$data['settings']};
is_page_alias = ('page_alias'=='{$data['current_page_type']}');
is_url_shortener =
('url_shortener'=='{$data['current_page_type']}');
is_share = ('share'=='{$data['current_page_type']}');
setDisplay('page-settings', is_settings || is_page_alias ||
is_share || is_url_shortener);
setDisplay("page-container", !is_media_list && !is_page_alias &&
!is_url_shortener && !is_share);
setDisplay("non-alias-type", !is_page_alias && !is_url_shortener &&
!is_share);
setDisplay("alias-type", is_page_alias);
setDisplay("shortener-container", is_url_shortener);
setDisplay("short-url-label", is_url_shortener);
setDisplay("page-resources", !is_page_alias && !is_url_shortener &&
!is_share);
setDisplay("share-container", is_share);
setDisplay("page-toc-setting", !is_media_list);
setDisplay("media-list-index-setting", is_media_list);
function updateMediaListIndexFile()
{
var index_box = elt("media-list-index");
setDisplay("media-list-index-file-setting",
index_box != null && index_box.checked);
}
var media_index_box = elt("media-list-index");
if (media_index_box != null) {
media_index_box.onchange = updateMediaListIndexFile;
}
updateMediaListIndexFile();
if (mode == 'edit') {
setDisplay('save-container', !is_media_list || is_settings);
setDisplay('resource-upload-form', is_media_list &&
!is_share);
}
$init_toggle_settings
ptype.onchange = function() {
var cur_type = ptype.options[ptype.selectedIndex].value;
setDisplay("page-toc-setting",
cur_type != "media_list");
setDisplay("media-list-index-setting",
cur_type == "media_list");
updateMediaListIndexFile();
if (cur_type == "media_list") {
setDisplay("page-container", false);
$toggle_settings_on
setDisplay("non-alias-type", true);
setDisplay("alias-type", false);
setDisplay("shortener-container", false);
setDisplay("short-url-label", false);
setDisplay("share-container", false);
setDisplay("page-resources", true);
if (mode == 'edit') {
setDisplay("resource-upload-form", true);
}
} else if (cur_type == "page_alias") {
$toggle_settings_false
setDisplay("page-container", false);
setDisplay("non-alias-type", false);
setDisplay("alias-type", true);
setDisplay("shortener-container", false);
setDisplay("short-url-label", false);
setDisplay("share-container", false);
setDisplay("page-resources", false);
} else if (cur_type == "url_shortener") {
$toggle_settings_false
setDisplay("page-container", false);
setDisplay("non-alias-type", false);
setDisplay("alias-type", false);
setDisplay("shortener-container", true);
setDisplay("short-url-label", true);
setDisplay("share-container", false);
setDisplay("page-resources", false);
setDisplay("resource_msg", false);
} else if (cur_type == "share") {
$toggle_settings_false
setDisplay("page-container", false);
setDisplay("non-alias-type", false);
setDisplay("alias-type", false);
setDisplay("shortener-container", false);
setDisplay("short-url-label", false);
setDisplay("share-container", true);
setDisplay("page-resources", false);
setDisplay("resource_msg", false);
} else {
setDisplay("page-container", true);
$toggle_settings_inline
setDisplay("non-alias-type", true);
setDisplay("alias-type", false);
setDisplay("shortener-container", false);
setDisplay("short-url-label", false);
setDisplay("share-container", false);
setDisplay("page-resources", true);
if (mode == 'edit') {
setDisplay("resource-upload-form", false);
}
}
}
EOD;
}
}