<?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;
use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
/**
* Displays a group's wiki pages as forward-facing static pages. By
* default it serves the world-readable Public group, the way the Yioop
* site's own Main, help, and error pages are shown. A domain an admin
* routes to a landing group in the Web Server field set is served
* through this controller too, so that group's Main page renders as the
* domain's landing page rather than through the group controller.
*
* @author Chris Pollett
*/
class StaticController extends Controller
{
/**
* Says which activities (roughly methods invoke from the web)
* this controller will respond to
* @var array
*/
public $activities = ["showPage", "signout"];
/**
* Group whose wiki pages and resources this static request serves.
* A domain an admin routes to a landing group in the Web Server
* field set is served through this controller so its Main page
* renders as a forward-facing page; the router puts that group's id
* on the ROUTE_GROUP_ID server variable, which a browser cannot set
* because it is not an HTTP_ header, so only an admin-set route can
* choose the group. With no route the world-readable Public group is
* served, the historical behavior.
*
* @return int group identifier whose pages this request reads
*/
public function staticGroupId()
{
$route_group_id = $_SERVER['ROUTE_GROUP_ID'] ?? 0;
if (is_numeric($route_group_id) && $route_group_id > 0) {
return (int)$route_group_id;
}
return C\PUBLIC_GROUP_ID;
}
/**
* Rewrites a routed group's own wiki links so they point at the
* static p/ url, the way the Public group's links do. A page saved
* in a group other than Public bakes each self link as that group's
* read url on the group controller, a form ending in the token and a
* page_name query; served on a routed domain through this controller
* that url has no clean route and errors. Each such self link is
* rebuilt as the static p/ form (for example p/Bob) using the page
* name from the query, so the match does not depend on how the base
* url looked when the page was saved. Public pages already carry the
* controller-agnostic link and are returned unchanged.
*
* @param string $html a parsed wiki page whose self links may still
* be in the group-controller form
* @return string the page with any routed-group self link pointing
* at its static p/ url
*/
public function rewriteRoutedGroupLinks($html)
{
if ($this->staticGroupId() == C\PUBLIC_GROUP_ID) {
return $html;
}
$static_page_url = C\SHORT_BASE_URL . "p/";
return preg_replace(
'/href="[^"]*\[\{token\}\]&page_name=([^"]+)"/',
'href="' . $static_page_url . '$1"', $html);
}
/**
* This is the main entry point for handling people arriving to view
* a static page. It determines which page to draw and class the view
* to draw it.
*/
public function processRequest()
{
$data = [];
$view = "static";
if (isset($_SESSION['USER_ID'])) {
$user = $_SESSION['USER_ID'];
} else {
$user = L\remoteAddress();
}
if (isset($_REQUEST['a'])) {
if (in_array($_REQUEST['a'], $this->activities)) {
$activity = $_REQUEST['a'];
if ($activity == "signout") {
$data['SCRIPT'] = "doMessage('<h1 class=\"red\" >".
tl('static_controller_logout_successful')."</h1>')";
$activity = "showPage";
}
} else {
$activity = "showPage";
}
} else {
$activity = "showPage";
}
$data['VIEW'] = $view;
$data = array_merge($data, $this->call($activity));
if (isset($_SESSION['USER_ID'])) {
$user = $_SESSION['USER_ID'];
$data['ADMIN'] = 1;
} else {
$user = L\remoteAddress();
}
$data[C\p('CSRF_TOKEN')] = $this->generateCSRFToken($user);
if (!empty($_POST['CSVFORM']) && !empty($_POST[C\p('CSRF_TOKEN')])) {
$this->component("social")->processWikiFormData($data, $user,
$this->staticGroupId(), $data["SUB_PATH"] ?? "");
}
if (str_contains($data["PAGE"] ?? "", "[{proof-of-work}]")) {
$this->setupProofOfWorkViewData($data);
}
$this->initializeAdFields($data);
$this->displayView($view, $data);
}
/**
* Draws one static page: the wiki page named in the request read
* from this request's static group (the Public group, or a routed
* domain's landing group), together with its header, footer, and any
* media the page lists.
*
* @return array $data has title and page contents of the static page to
* display
*/
public function showPage()
{
$group_model = $this->model("group");
$group_id = $this->staticGroupId();
if (isset($_SESSION['USER_ID'])) {
$user = $_SESSION['USER_ID'];
} else {
$user = L\remoteAddress();
}
$data = [];
if (isset($_REQUEST['p'])) {
$page = $this->clean($_REQUEST['p'], "string");
$page = preg_replace("@(\.\.|\/)@", "", $page);
} else {
$page = "404";
}
$page_string = $this->getPage($page);
if (empty($page_string)) {
$page = "404";
$page_string = $this->getPage($page);
}
$page_parts = explode(L\WikiParser::END_HEAD_VARS, $page_string);
$data['PAGE'] = $page_parts[1] ?? $page_parts[0];
if (!isset($data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"] = [];
}
if (!isset($data["SCRIPT"])) {
$data["SCRIPT"] = "";
}
if (stripos($page_string, "chart_data") !== false) {
if (!in_array("chart", $data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"][] = "chart";
}
if (!str_contains($data["SCRIPT"], "new Chart")) {
if ($_SERVER["MOBILE"]) {
$properties = ["width" => 340, "height" => 300,
"tick_font_size" => 8];
} else {
$properties = ["width" => 700, "height" => 500];
}
$data['SCRIPT'] .= <<< 'EOD'
for (var chart_elt in chart_data) {
var chart = new Chart(
'chart_' + chart_elt,
chart_data[chart_elt],
chart_config[chart_elt]);
chart.draw();
}
EOD;
}
}
if (str_contains($page_string, "spreadsheet_data")) {
if (!in_array("spreadsheet", $data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"][] = "spreadsheet";
}
if (!str_contains($data["SCRIPT"], "new Spreadsheet")) {
$data['SCRIPT'] .= <<< 'EOD'
for (var spreadsheet_elt in spreadsheet_data) {
var spreadsheet = new Spreadsheet(
'spreadsheet_' + spreadsheet_elt,
spreadsheet_data[spreadsheet_elt],
spreadsheet_config[spreadsheet_elt]);
spreadsheet.draw();
}
EOD;
}
$data['SPREADSHEET'] = true;
}
if (str_contains($page_string, "`")){
if (!isset($data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"] = [];
}
$data["INCLUDE_SCRIPTS"][] = "math";
}
$data['page'] = $page;
$static_view = $this->view("static");
$this->parsePageHeadVarsView($static_view, $page, $page_string);
/* this is used by the captcha time out mechanism
and the suggest a site time out mechanism
see RegisterController for where $_SESSION['value'] set
*/
if (isset($_SESSION['value'])) {
$data['value'] = $this->clean($_SESSION['value'], "string");
}
$head_info = $static_view->head_objects[$data['page']];
if (isset($head_info['page_type']) &&
$head_info['page_type'] == 'page_alias' &&
$head_info['page_alias'] != '' ) {
$alias_parts = explode("@", $head_info['page_alias']);
$alias = $alias_parts[0];
$alias_group_id = $group_id;
$controller_name = "static";
if (!empty($alias_parts[1])) {
$alias = $alias_parts[1];
$alias_group_id = $group_model->getGroupId($alias_parts[0]);
$controller_name = "group";
}
return $this->redirectLocation(B\wikiUrl($alias,
true, $controller_name, $alias_group_id));
}
if (isset($head_info['title']) && $head_info['title']) {
/* A wiki page served here shows its own title exactly, the same
as it does through the group controller, rather than the
title wrapped with the site name. The raw title parsed above
is already in head_objects, so it is left as it is and only
the on-page subtitle is set. On a routed domain this is that
domain's group's page title, since the group read from is the
routed one. */
$data["subtitle"] = " - " . $head_info['title'];
} else {
/* A page that names no title of its own falls back to the
site's name and the page's name, read as "Site Name - Page
Name". The site name is the routed domain's when a domain is
served here, so frise.org's untitled pages read for frise.org
rather than the site. */
$data["subtitle"] = "";
$static_view->head_objects[$data['page']]['title'] =
$this->untitledPageTitle($this->siteName(), $data['page']);
}
$locale_tag = L\getLocaleTag();
$data['CONTROLLER'] = "static";
$group_model = $this->model("group");
if (!empty($head_info['page_header']) &&
$head_info['page_type'] != 'presentation') {
$page_header = $group_model->getPageInfoByName($group_id,
$head_info['page_header'], $locale_tag, "read");
if (!$page_header) {
$page_header = [];
}
if (isset($page_header['PAGE'])) {
$header_parts =
explode(L\WikiParser::END_HEAD_VARS, $page_header['PAGE']);
}
$page_header['PAGE'] = $page_header['PAGE'] ?? "";
$data["PAGE_HEADER"] = $this->rewriteRoutedGroupLinks(
(isset($header_parts[1])) ?
$header_parts[1] : "" . $page_header['PAGE']);
}
if (!empty($head_info['page_footer']) &&
$head_info['page_type'] != 'presentation') {
$page_footer = $group_model->getPageInfoByName($group_id,
$head_info['page_footer'], $locale_tag, "read");
if (!$page_footer) {
$page_footer = [];
}
if (isset($page_footer['PAGE'])) {
$footer_parts =
explode(L\WikiParser::END_HEAD_VARS, $page_footer['PAGE']);
}
$page_footer['PAGE'] = $page_footer['PAGE'] ?? "";
$data['PAGE_FOOTER'] = $this->rewriteRoutedGroupLinks(
(isset($footer_parts[1])) ?
$footer_parts[1] : "" . $page_footer['PAGE']);
}
$data['PAGE_ID'] = $group_model->getPageID($group_id,
$page, $locale_tag);
if (!empty($_REQUEST['sf'])) {
$sub_path = $this->clean($_REQUEST['sf'], 'string');
$sub_path = str_replace("..", "", $sub_path);
$sub_path = str_replace("/./", "/", $sub_path);
$data['SUB_PATH'] = htmlentities($sub_path);
} else {
$sub_path = "";
}
if (!empty($_REQUEST['arg']) && $_REQUEST['arg']=='media' &&
!empty($_REQUEST['n'])) {
$data['CURRENT_LOCALE_TAG'] = $locale_tag;
$this->component("social")->mediaWiki($data, $group_id,
$data['PAGE_ID'], $sub_path);
} else if (isset($head_info['page_type'])) {
if ($head_info['page_type'] == 'media_list') {
$data['GROUP']['GROUP_ID'] = $group_id;
$data['HEAD'] = $head_info;
$data['PAGE_NAME'] = $page;
$data['CAN_EDIT'] = false;
/* A page set to be a static HTML folder answers with the
file asked for beneath it, the same as it does when
read through the group controller, so a folder of
generated documentation works whichever url form it is
read by. */
if ($this->component("social")->serveStaticFolderFile(
$data, $group_id, $data['PAGE_ID'], $sub_path, 0)) {
return;
}
$data['MODE'] = "static";
$data['RESOURCE_FILTER'] =
(isset($_REQUEST['resource_filter'])) ?
substr($this->clean($_REQUEST['resource_filter'],
'file_name'), 0, C\SHORT_TITLE_LEN) : "";
$data['page_type'] = 'media_list';
$data['RESOURCES_INFO'] =
$group_model->getGroupPageResourceUrls(
$group_id, $data['PAGE_ID'], $sub_path);
$this->component("social")->sortWikiResources($data);
} else if ($head_info['page_type'] == 'git_repository') {
/* a repository page is read through this controller the
same way it is read through the group controller, so
the public group's repositories, and a routed domain's,
are browsable at their static p/ url too */
$data['GROUP']['GROUP_ID'] = $group_id;
$data['HEAD'] = $head_info;
$data['PAGE_NAME'] = $page;
$data['CAN_EDIT'] = false;
$data['MODE'] = "static";
$data['page_type'] = 'git_repository';
$this->component("social")->initializeGitRepositoryReadMode(
$data, $group_id, $sub_path);
} else if ($head_info['page_type'] == 'presentation') {
$data['page_type'] = 'presentation';
$data['INCLUDE_SCRIPTS'][] = "frise";
$data['INCLUDE_STYLES'][] = "frise";
}
}
if ((!empty($data['PAGE']) &&
str_contains($data['PAGE'], "canvas-360")) ||
(!empty($data['page']) &&
str_contains($data['page'], "canvas-360"))) {
$data["INCLUDE_SCRIPTS"] = array_merge($data["INCLUDE_SCRIPTS"],
["wglu-program", "vr-panorama", "vr-util"]);
$data["SCRIPT"] .= ";var tl_elt = elt('tl'); tl_elt.enter_vr ='".
tl('enter_vr') . "'; tl_elt.exit_vr = '".tl('exit_vr')."';";
}
return $data;
}
/**
* Builds the browser title for a static wiki page that names no title
* of its own, read as "Site Name - Page Name". The page name has its
* url encoding undone and underscores read as spaces; the site name is
* passed in already resolved, so on a routed domain it is that domain's
* name rather than the site's.
* @param string $site_name the serving site's or routed domain's name
* @param string $page the requested page name, as it appears in urls
* @return string the browser title for the untitled page
*/
public function untitledPageTitle($site_name, $page)
{
$page_name_text = str_replace("_", " ", urldecode($page));
return tl('static_controller_complete_title', $site_name,
$page_name_text);
}
/**
* Reads a wiki page from this request's static group (the Public
* group, or a routed domain's landing group) to present to visitors
* who are not logged in.
*
* @param string $page_name name of file less extension to read in
* @return string text of page
*/
public function getPage($page_name)
{
$group_model = $this->model("group");
$group_id = $this->staticGroupId();
$locale_tag = L\getLocaleTag();
$page_info = $group_model->getPageInfoByName(
$group_id, $page_name, $locale_tag, "read");
$page_string = $page_info["PAGE"] ?? "";
if (!$page_string && $locale_tag != C\p('DEFAULT_LOCALE')) {
/* fallback to default locale for translation */
$page_info = $group_model->getPageInfoByName(
$group_id, $page_name, C\p('DEFAULT_LOCALE'), "read");
$page_string = $page_info["PAGE"] ?? "";
}
if (!$page_string && $group_id != C\PUBLIC_GROUP_ID &&
isset(C\ERROR_PAGE_STATUSES[$page_name])) {
/* A routed domain says things in its own words where it has
them, but the pages the site itself needs to be able to
show, such as the one saying a page is not there, it may
never have been given; rather than answer with nothing at
all, the site's own is shown. Only these pages fall back
this way, so a domain's ordinary pages stay its own. */
$page_info = $group_model->getPageInfoByName(
C\PUBLIC_GROUP_ID, $page_name, $locale_tag, "read");
$page_string = $page_info["PAGE"] ?? "";
if (!$page_string && $locale_tag != C\p('DEFAULT_LOCALE')) {
$page_info = $group_model->getPageInfoByName(
C\PUBLIC_GROUP_ID, $page_name,
C\p('DEFAULT_LOCALE'), "read");
$page_string = $page_info["PAGE"] ?? "";
}
if ($page_string) {
$group_id = C\PUBLIC_GROUP_ID;
}
}
if (preg_match("/\(\(resource(\-?[a-z]+)?\:(.+?)csv(.+?)\|(.+?)\)\)/ui",
$page_string)) {
$page_string = $group_model->insertResourcesParsePage(
$group_id, $page_info['ID'], $locale_tag,
$page_string, "", "admin", true);
}
return $this->rewriteRoutedGroupLinks($page_string);
}
}