<?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\models\MailAccountModel;
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\WikiParser;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\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;
/**
* Provides activities to AdminController related to creating, updating
* blogs (and blog entries), static web pages, and crawl mixes.
*
* @author Chris Pollett
*/
class SocialComponent extends Component implements CrawlConstants
{
/**
* Name of the folder in which Yioop keeps earlier versions of a
* page's resources, alongside the resources themselves. It matches
* the name VersionManager gives that folder, and is here so a
* download of a page's resources can leave the record of what they
* used to be out of it.
*/
const RESOURCE_ARCHIVE_FOLDER = ".archive";
/**
* What a download of a page's resources is called when the page
* turns out to have no usable name to call it after. Everything in
* the archive sits under one folder of this name, so unpacking it
* leaves one folder behind rather than scattering a page's resources
* into whatever folder it was unpacked in.
*/
const PAGE_RESOURCES_ZIP_FOLDER = "page_resources";
/**
* File that stands for a folder in a static HTML folder when the
* page names none of its own, matching what a web server calls a
* directory index.
*/
const STATIC_FOLDER_INDEX_FILE = "index.html";
/**
* What is sent as the body when a static HTML folder holds nothing
* by the name asked for. It is plain text because whatever asked may
* have been a stylesheet or a script rather than a reader.
*/
const STATIC_FOLDER_MISSING_BODY = "Not Found";
/**
* Constant for when attempt to handle file uploads and no files were
* uploaded
*/
const UPLOAD_NO_FILES = -1;
/**
* Constant for when attempt to handle file uploads and not all of the
* file upload information was present
*/
const UPLOAD_FAILED = 0;
/**
* Constant for when attempt to handle file uploads and file were
* successfully uploaded
*/
const UPLOAD_SUCCESS = 1;
/**
* File to tell RecommendationJob the paths of eligible wiki resources
* description files
*/
const RECOMMENDATION_FILE = C\APP_DIR . "/resources/recommendation.txt";
/**
* Used to handle the manage group activity.
*
* This activity allows new groups to be created out of a set of users.
* It allows admin rights for the group to be transferred and it allows
* roles to be added to a group. One can also delete groups and roles from
* groups.
*
* @param array $modifiers that affect access to this activity
* @return array $data information about groups in the system
*/
public function manageGroups($modifiers)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$possible_arguments = ["activateuser",
"addgroup", "banuser", "changemailpref", "creategroup",
"deletegroup", "deleteselected", "deleteuser", "editgroup",
"graphstats", "import", "infogroup", "inviteusers", "joingroup",
"makeeditor", "memberaccess", "postlifetime", "registertype",
"reinstateuser", "search", "statistics", "unsubscribe",
"voteaccess"];
$data["ELEMENT"] = "managegroups";
$data['SCRIPT'] = "";
$data['MEMBERSHIP_CODES'] = [
C\INACTIVE_STATUS => tl('social_component_request_join'),
C\INVITED_STATUS => tl('social_component_invited'),
C\ACTIVE_STATUS => tl('social_component_active_status'),
C\EDITOR_STATUS => tl('social_component_editor_status'),
C\SUSPENDED_STATUS => tl('social_component_suspended_status')
];
$data['REGISTER_CODES'] = [
C\INVITE_ONLY_JOIN => tl('social_component_invite_only_join'),
C\REQUEST_JOIN => tl('social_component_unlisted_by_request'),
C\PUBLIC_BROWSE_REQUEST_JOIN =>
tl('social_component_by_request'),
C\PUBLIC_JOIN => tl('social_component_public_join'),
];
if (in_array(C\p('MONETIZATION_TYPE'),
['group_fees', 'fees_and_keywords'])) {
$data['can_monetise_group'] = true;
$monetise_codes = [100 => tl('social_component_hundred_credits'),
200 => tl('social_component_two_hundred_credits'),
500 => tl('social_component_five_hundred_credits'),
1000 => tl('social_component_thousand_credits'),
2000 => tl('social_component_two_thousand_credits')];
$data['REGISTER_CODES'] += $monetise_codes;
} else {
$data['can_monetise_group'] = false;
}
$data['ACCESS_CODES'] = [
C\GROUP_READ => tl('social_component_members_only'),
C\GROUP_READ_COMMENT => tl('social_component_members_can_comment'),
C\GROUP_READ_WRITE => tl('social_component_members_start_threads'),
C\GROUP_READ_WIKI => tl('social_component_members_full_access'),
];
$data['VOTING_CODES'] = [
C\NON_VOTING_GROUP => tl('social_component_no_voting'),
C\UP_VOTING_GROUP => tl('social_component_up_voting'),
C\UP_DOWN_VOTING_GROUP => tl('social_component_up_down_voting')
];
$data['PAGE_SOURCE_CODES'] = [
1 => tl('social_component_page_source_allowed'),
0 => tl('social_component_page_source_not_allowed')
];
$data['PAGE_LIST_CODES'] = [
1 => tl('social_component_page_list_allowed'),
0 => tl('social_component_page_list_not_allowed')
];
$data['POST_LIFETIMES'] = [
C\FOREVER => tl('social_component_forever'),
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['ENCRYPTION_CODES'] = [
1 => tl('social_component_encryption_enable'),
0 => tl('social_component_encryption_disable'),
];
$data['RENDER_CODES'] = [
1 => tl('social_component_markdown'),
0 => tl('social_component_mediawiki'),
];
if (in_array(C\p('MONETIZATION_TYPE'), ['group_fees',
'fees_and_keywords'])) {
$data['can_monetise_group'] = true;
} else {
$data['can_monetise_group'] = false;
}
$search_array = [];
$default_group = ["name" => "", "id" => "", "owner" =>"",
"register" => -1, "member_access" => -1, 'vote_access' => -1,
"post_lifetime" => -1, "encryption" => 0,
"render_engine" => C\MEDIAWIKI_ENGINE,
"page_source_allowed" => 1, "page_list_allowed" => 1];
$data['CURRENT_GROUP'] = $default_group;
$data['PAGING'] = "";
$name = "";
$data['visible_users'] = "";
$is_owner = false;
if (!isset($_REQUEST['arg'])) {
$_REQUEST['arg'] = "";
}
/* start owner verify code / get current group
$group_id is only set in this block (except creategroup) and it
is only not null if $group['OWNER_ID'] == $_SESSION['USER_ID'] where
this is also the only place group loaded using $group_id
*/
if (!empty($_REQUEST['group_id'])) {
$group_id = $parent->clean($_REQUEST['group_id'], "int" );
$group = $group_model->getGroupById($group_id,
$_SESSION['USER_ID']);
$info_no_access = false;
if (empty($group) && ($_REQUEST['arg'] ?? "") == 'infogroup') {
$group = $group_model->getGroupById($group_id,
C\ROOT_ID);
$info_no_access = true;
}
if (isset($group['OWNER_ID'] ) &&
($group['OWNER_ID'] == $_SESSION['USER_ID'] ||
(isset($_REQUEST['arg']) &&
($_SESSION['USER_ID'] == C\ROOT_ID && in_array($_REQUEST['arg'],
['statistics', 'graphstats', 'editgroup'])))) ||
(isset($_REQUEST['arg']) && in_array($_REQUEST['arg'],
['infogroup', 'changemailpref']))) {
$name = $group['GROUP_NAME'];
$data['CURRENT_GROUP']['name'] = $name;
$data['CURRENT_GROUP']['id'] = $group['GROUP_ID'];
$data['CURRENT_GROUP']['owner'] = $group['OWNER'];
$data['CURRENT_GROUP']['register'] =
$group['REGISTER_TYPE'];
if ($info_no_access && $group['REGISTER_TYPE'] ==
C\INVITE_ONLY_JOIN) {
$group_id = false;
}
$data['CURRENT_GROUP']['member_access'] =
$group['MEMBER_ACCESS'];
$data['CURRENT_GROUP']['vote_access'] =
$group['VOTE_ACCESS'];
$data['CURRENT_GROUP']['post_lifetime'] =
$group['POST_LIFETIME'];
$data['CURRENT_GROUP']['encryption'] =
$group['ENCRYPTION'];
$data['CURRENT_GROUP']['render_engine'] =
$group['RENDER_ENGINE'];
$data['CURRENT_GROUP']['page_source_allowed'] =
$group['PAGE_SOURCE_ALLOWED'];
$data['CURRENT_GROUP']['page_list_allowed'] =
$group['PAGE_LIST_ALLOWED'];
$is_owner = true;
} else if (!in_array($_REQUEST['arg'],
["deletegroup", "joingroup", "unsubscribe"]) &&
$_SESSION['USER_ID'] != C\ROOT_ID) {
$group_id = null;
$group = null;
}
} else if (isset($_REQUEST['name'])) {
$name = substr(trim($parent->clean($_REQUEST['name'], "string")), 0,
C\SHORT_TITLE_LEN);
$data['CURRENT_GROUP']['name'] = $name;
$group_id = null;
$group = null;
} else {
$group_id = null;
$group = null;
}
/* end ownership verify */
$browse = false;
$search_name = "manageGroups";
if (isset($_REQUEST['browse']) && $_REQUEST['browse'] == 'true') {
$browse = true;
$data['browse'] = 'true';
$search_name = "browseGroups";
$this->initSocialBadges($_SESSION['USER_ID'], $data);
}
$data['FORM_TYPE'] = ($browse) ? "" : "addgroup";
$data['CONTEXT'] = 'groups';
if (!empty($_REQUEST['context']) && in_array($_REQUEST['context'],
['account', 'groups', "join_groups"])) {
$data['CONTEXT'] = $_REQUEST['context'];
}
$data['USER_FILTER'] = "";
if (isset($_REQUEST['arg']) &&
in_array($_REQUEST['arg'], $possible_arguments)) {
switch ($_REQUEST['arg']) {
case "activateuser":
case "makeeditor":
$updated_status = ($_REQUEST['arg'] == "activateuser") ?
C\ACTIVE_STATUS : C\EDITOR_STATUS;
$_REQUEST['arg'] = "editgroup";
$num_activated = 0;
if ($is_owner && !empty($_REQUEST['user_ids'])) {
$user_ids = $_REQUEST['user_ids'];
$ids = explode("*", $user_ids);
foreach ($ids as $user_id) {
$user_id = (!empty($user_id)) ?
$parent->clean($user_id, 'int'): 0;
if ($group_model->checkUserGroup($user_id,
$group_id)) {
$group_model->updateStatusUserGroup($user_id,
$group_id, $updated_status);
$num_activated++;
}
}
}
$this->getGroupUsersData($data, $group_id);
if ($num_activated == 1) {
$message = ($updated_status == C\ACTIVE_STATUS) ?
tl('social_component_user_activated') :
tl('social_component_user_editor');
return $parent->redirectWithMessage($message,
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts', "visible_users"]);
} else if ($num_activated > 1) {
$message = ($updated_status == C\ACTIVE_STATUS) ?
tl('social_component_users_activated') :
tl('social_component_users_editors');
return $parent->redirectWithMessage($message,
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts', "visible_users"]);
}
$message = ($updated_status == C\ACTIVE_STATUS) ?
tl('social_component_no_user_activated') :
tl('social_component_no_user_editor');
return $parent->redirectWithMessage($message,
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts', "visible_users"]);
case "addgroup":
if (($add_id = $group_model->getGroupId($name)) <= 0) {
return $parent->redirectWithMessage(
tl('social_component_group_doesnt_exist'),
['arg', 'context', 'end_row',
'group_limit', 'name', 'num_show', 'start_row',
'user_filter', 'user_sorts',"visible_users"]);
}
$register =
$group_model->getRegisterType($add_id);
if ($register >= C\LOW_JOIN_FEE &&
!in_array(C\p('MONETIZATION_TYPE'),
['group_fees','fees_and_keywords'])) {
$register = C\INVITE_ONLY_JOIN;
}
if ((!empty($register) && $register !=C\INVITE_ONLY_JOIN) ||
$_SESSION['USER_ID'] == C\ROOT_ID) {
return $this->addGroup($data, $add_id, $register);
} else {
return $parent->redirectWithMessage(
tl('social_component_groupname_cant_add'));
}
break;
case "banuser":
$_REQUEST['arg'] = "editgroup";
if (!empty($_REQUEST['context']) &&
$_REQUEST['context']=='search') {
$data['CONTEXT'] = 'search';
};
$banned = 0;
if ($is_owner && !empty($_REQUEST['user_ids'])) {
$user_ids = $_REQUEST['user_ids'];
$ids = explode("*", $user_ids);
foreach ($ids as $user_id) {
$user_id = (!empty($user_id)) ?
$parent->clean($user_id, 'int'): 0;
if ($group_model->checkUserGroup($user_id,
$group_id)) {
$group_model->updateStatusUserGroup($user_id,
$group_id, C\SUSPENDED_STATUS);
$banned++;
}
}
}
$this->getGroupUsersData($data, $group_id);
if ($banned == 1) {
return $parent->redirectWithMessage(
tl('social_component_user_banned'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row','user_filter',
'user_sorts',"visible_users"]);
} else if ($banned > 1) {
return $parent->redirectWithMessage(
tl('social_component_users_banned'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts',"visible_users"]);
}
return $parent->redirectWithMessage(
tl('social_component_no_user_banned'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts',"visible_users"]);
case "creategroup":
if (in_array("cannot_create_groups", $modifiers)) {
return $parent->redirectWithMessage(
tl('social_component_user_cant_create'));
} else if ($_SESSION['USER_ID'] == C\PUBLIC_USER_ID) {
return $parent->redirectWithMessage(
tl('social_component_public_cant_create'));
} else if ($group_model->getGroupId($name) > 0) {
return $parent->redirectWithMessage(
tl('social_component_groupname_exists'));
} else if (!empty($name)) {
$group_fields = [
"member_access" => ["ACCESS_CODES", C\GROUP_READ],
"register" => ["REGISTER_CODES", C\REQUEST_JOIN],
"vote_access" => ["VOTING_CODES",
C\NON_VOTING_GROUP],
"post_lifetime" => ["POST_LIFETIMES", C\FOREVER],
"encryption" => ["ENCRYPTION_CODES", 0],
"render_engine" => ["RENDER_CODES",
C\MEDIAWIKI_ENGINE]
];
foreach ($group_fields as $field => $info) {
if (!isset($_REQUEST[$field]) ||
!in_array($_REQUEST[$field],
array_keys($data[$info[0]]))) {
$_REQUEST[$field] = $info[1];
}
}
if ($this->overUserGroupLimit(
$_SESSION['USER_ID'], 'MAX_GROUPS_OWNED',
$group_model->countGroupsOwnedByUser(
$_SESSION['USER_ID']))) {
return $this->parent->redirectWithMessage(
tl('social_component_groups_owned_full'));
}
$group_model->addGroup($name,
$_SESSION['USER_ID'],
$_REQUEST['register'],
$_REQUEST['member_access'],
$_REQUEST['vote_access'],
$_REQUEST['post_lifetime'],
$_REQUEST['encryption'],
$_REQUEST['render_engine']);
//one exception to setting $group_id
$group_id = $group_model->getGroupId($name);
return $parent->redirectWithMessage(
tl('social_component_groupname_created'),
["arg", 'start_row', 'end_row', 'num_show']);
}
break;
case "deletegroup":
$_REQUEST['arg'] = empty($_REQUEST['context']) ?
'none': 'search';
$data['CURRENT_GROUP'] = $default_group;
if (in_array("cannot_delete_groups", $modifiers)) {
return $parent->redirectWithMessage(
tl('social_component_user_cant_delete'));
} else
if ( $group_id <= 0) {
return $parent->redirectWithMessage(
tl('social_component_groupname_doesnt_exists'),
["arg"]);
} else if (($group &&
$group['OWNER_ID'] == $_SESSION['USER_ID']) ||
$_SESSION['USER_ID'] == C\ROOT_ID) {
$group_model->deleteGroup($group_id);
unset($_REQUEST['route']);
$_REQUEST['c'] = "group";
return $parent->redirectWithMessage(
tl('social_component_group_deleted'), ["arg"]);
}
return $parent->redirectWithMessage(
tl('social_component_no_delete_group'),
["arg", 'start_row', 'end_row', 'num_show']);
case "deleteuser":
$_REQUEST['arg'] = "editgroup";
if (!empty($_REQUEST['context']) &&
$_REQUEST['context']=='search') {
$data['CONTEXT'] = 'search';
}
$deleted = 0;
if ($is_owner && !empty($_REQUEST['user_ids'])) {
$user_ids = $_REQUEST['user_ids'];
$ids = explode("*", $user_ids);
foreach ($ids as $user_id) {
$user_id = (!empty($user_id)) ?
$parent->clean($user_id, 'int'): 0;
if ($group_model->deletableUser($user_id,
$group_id)) {
$group_model->deleteUserGroup(
$user_id, $group_id);
$deleted++;
}
}
}
if ($deleted == 1) {
return $parent->redirectWithMessage(
tl('social_component_user_deleted'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts',"visible_users"]);
} else if ($deleted > 1) {
return $parent->redirectWithMessage(
tl('social_component_users_deleted'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts',"visible_users"]);
}
return $parent->redirectWithMessage(
tl('social_component_no_delete_user_group'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts',"visible_users"]);
case "editgroup":
if (!$group_id ||
(!$is_owner && $_SESSION['USER_ID'] != C\ROOT_ID)) {
break;
}
if (!empty($_REQUEST['context']) &&
$_REQUEST['context']=='search') {
$data['CONTEXT'] = 'search';
}
$data['FORM_TYPE'] = "editgroup";
$mail_pref_submitted =
!empty($_REQUEST['mail_pref_shown']) &&
$group_model->getMailSubscription(
$_SESSION['USER_ID'], $group_id) !== null;
if ($mail_pref_submitted) {
$group_model->setMailSubscription(
$_SESSION['USER_ID'], $group_id,
empty($_REQUEST['receive_group_mail']) ? 0 : 1);
}
$update_fields = [
['owner', "OWNER", "valid_user"],
['member_access', 'MEMBER_ACCESS', 'ACCESS_CODES'],
['register', 'REGISTER_TYPE','REGISTER_CODES'],
['vote_access', 'VOTE_ACCESS', 'VOTING_CODES'],
['post_lifetime', 'POST_LIFETIME', 'POST_LIFETIMES'],
['encryption', 'ENCRYPTION', 'ENCRYPTION_CODES'],
['render_engine', 'RENDER_ENGINE', 'RENDER_CODES'],
['page_source_allowed', 'PAGE_SOURCE_ALLOWED',
'PAGE_SOURCE_CODES'],
['page_list_allowed', 'PAGE_LIST_ALLOWED',
'PAGE_LIST_CODES']
];
$message = $this->updateGroup($data, $group,
$update_fields);
if (!empty($message) || $mail_pref_submitted) {
$preserve_fields = ['arg', 'browse', 'context',
'start_row','end_row', 'group_limit', 'num_show',
'user_filter', 'user_sorts', 'visible_users'];
if ($message == tl('social_component_owner_updated')) {
$preserve_fields = ['arg', 'browse', 'start_row',
'end_row', 'group_limit', 'num_show',
'user_filter', 'user_sorts', 'visible_users'];
}
return $parent->redirectWithMessage($message ?:
tl('social_component_group_updated'),
$preserve_fields);
}
$data['CURRENT_GROUP']['register'] =
$group['REGISTER_TYPE'];
$data['CURRENT_GROUP']['member_access'] =
$group['MEMBER_ACCESS'];
$data['CURRENT_GROUP']['vote_access'] =
$group['VOTE_ACCESS'];
$data['CURRENT_GROUP']['post_lifetime'] =
$group['POST_LIFETIME'];
$data['CURRENT_GROUP']['encryption'] =
$group['ENCRYPTION'];
$data['CURRENT_GROUP']['render_engine'] =
$group['RENDER_ENGINE'];
$data['SCRIPT'] .= "listenAll('input.user-id', 'click',".
" updateCheckedUserIds);";
$this->getGroupUsersData($data, $group_id);
if (!empty($_FILES['DISCUSSION_DATA']['tmp_name'])) {
if (empty($_FILES['DISCUSSION_DATA']['data'])) {
$feed_data = $parent->web_site->fileGetContents(
$_FILES['DISCUSSION_DATA']['tmp_name']);
} else {
$feed_data = $_FILES['DISCUSSION_DATA']['data'];
}
$this->importDiscussions($group_id,
$group['OWNER_ID'], $feed_data);
}
$this->addMailPrefData($data, $group_id);
$data['SCRIPT'] .= "elt('focus-button').focus();";
break;
case "infogroup":
if (!$group_id) {
return $parent->redirectWithMessage(
tl('social_component_groupname_lookup_error'));
}
$user_model = $parent->model("user");
$data['FORM_TYPE'] = "infogroup";
$this->getGroupUsersData($data, $group_id);
$owner_id = $user_model->getUserId(
$data['CURRENT_GROUP']['owner']);
$data['CURRENT_GROUP']['owner_id'] = $owner_id;
$data['CURRENT_GROUP']['user_icon'] =
$user_model->getUserIconUrl($owner_id);
$this->addMailPrefData($data, $group_id);
break;
case "changemailpref":
if (!$group_id) {
return $parent->redirectWithMessage(
tl('social_component_groupname_lookup_error'));
}
$member_status = $group_model->checkUserGroup(
$_SESSION['USER_ID'], $group_id);
if ($member_status === false ||
$member_status === C\NOT_MEMBER_STATUS) {
return $parent->redirectWithMessage(
tl('social_component_no_permission'));
}
$receive = empty($_REQUEST['receive_group_mail'])
? 0 : 1;
$group_model->setMailSubscription(
$_SESSION['USER_ID'], $group_id, $receive);
$_REQUEST['arg'] = (!empty($_REQUEST['return']) &&
$_REQUEST['return'] === 'editgroup') ?
'editgroup' : 'infogroup';
return $parent->redirectWithMessage(
tl('social_component_group_updated'),
['arg', 'context']);
break;
case "inviteusers":
$data['FORM_TYPE'] = "inviteusers";
if (!empty($_REQUEST['context']) &&
$_REQUEST['context']=='search') {
$data['CONTEXT'] = 'search';
}
if (isset($_REQUEST['users_names']) && $is_owner) {
$users_string = $parent->clean($_REQUEST['users_names'],
"string");
$pre_user_names = preg_split("/\s+|\,/", $users_string);
$users_invited = false;
foreach ($pre_user_names as $user_name) {
$user_name = trim($user_name);
$user = $parent->model("user")->getUser($user_name);
if ($user) {
if (!$group_model->checkUserGroup(
$user['USER_ID'], $group_id)) {
$this->addUserGroupWithinLimits(
$user['USER_ID'], $group_id,
C\INVITED_STATUS);
$users_invited = true;
}
}
}
$_REQUEST['arg'] = "editgroup";
if ($users_invited) {
return $parent->redirectWithMessage(
tl('social_component_users_invited'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts',"visible_users"]);
} else {
return $parent->redirectWithMessage(
tl('social_component_no_users_invited'),
["arg", 'context', 'end_row', 'group_limit',
'num_show', 'start_row', 'user_filter',
'user_sorts',"visible_users"]);
}
}
break;
case "joingroup":
if (!empty($_REQUEST['context']) &&
$_REQUEST['context'] == 'search') {
$_REQUEST['arg'] = 'search';
} else {
$_REQUEST['arg'] = 'none';
}
$user_id = (isset($_REQUEST['user_id'])) ?
$parent->clean($_REQUEST['user_id'], 'int'): 0;
if (empty($group_id) && !empty($_REQUEST['name'])) {
$group_id = $group_model->getGroupId(
$parent->clean($_REQUEST['name'], 'string'));
}
if ($user_id && $group_id &&
$group_model->checkUserGroup($user_id,
$group_id, C\INVITED_STATUS)) {
$group_model->updateStatusUserGroup($user_id,
$group_id, C\ACTIVE_STATUS);
unset($_REQUEST['route']);
$_REQUEST['c'] = "group";
return $parent->redirectWithMessage(
tl('social_component_joined'),
['arg']);
}
return $parent->redirectWithMessage(
tl('social_component_no_join'),
['arg', 'browse', 'start_row', 'end_row',
'num_show']);
case "graphstats":
if (!$group_id || (!$is_owner &&
$_SESSION['USER_ID'] != C\ROOT_ID)) {
break;
}
if (!empty($_REQUEST['context']) &&
$_REQUEST['context']=='search') {
$data['CONTEXT'] = 'search';
};
$period = C\ONE_DAY;
if (in_array($_REQUEST['time'], [C\ONE_DAY, C\ONE_MONTH,
C\ONE_YEAR])) {
$period = $_REQUEST['time'];
}
$impression_type = C\THREAD_IMPRESSION;
if (in_array($_REQUEST['impression'], [C\THREAD_IMPRESSION,
C\WIKI_IMPRESSION, C\GROUP_IMPRESSION,
C\QUERY_IMPRESSION])) {
$impression_type = $_REQUEST['impression'];
}
$item_id = $parent->clean($_REQUEST['item'], "int");
$this->makeImpressionChart($data, $impression_type,
$period, $item_id);
$this->getGroupUsersData($data, $group_id);
$data['SCRIPT'] .= "elt('focus-button').focus();";
break;
case "memberaccess":
$update_fields = [
['memberaccess', 'MEMBER_ACCESS', 'ACCESS_CODES']];
$message =
$this->updateGroup($data, $group, $update_fields);
$_REQUEST['arg'] = empty($_REQUEST['context']) ?
'none': 'search';
unset($_REQUEST['group_id']);
return $parent->redirectWithMessage($message,
['arg', 'browse', 'start_row', 'end_row',
'num_show', 'visible_users', 'user_filter']);
case "postlifetime":
$update_fields = [
['postlifetime', 'POST_LIFETIME', 'POST_LIFETIMES']];
$message =
$this->updateGroup($data, $group, $update_fields);
$_REQUEST['arg'] = empty($_REQUEST['context']) ?
'none': 'search';
unset($_REQUEST['group_id']);
return $parent->redirectWithMessage($message,
['arg', 'browse', 'start_row', 'end_row',
'num_show', 'visible_users', 'user_filter']);
case "voteaccess":
$update_fields = [
['voteaccess', 'VOTE_ACCESS', 'VOTING_CODES']];
$message =
$this->updateGroup($data, $group, $update_fields);
$_REQUEST['arg'] = empty($_REQUEST['context']) ?
'none': 'search';
unset($_REQUEST['group_id']);
return $parent->redirectWithMessage($message,
['arg', 'browse', 'start_row', 'end_row',
'num_show', 'visible_users', 'user_filter']);
case "registertype":
$update_fields = [
['registertype', 'REGISTER_TYPE',
'REGISTER_CODES']];
$message =
$this->updateGroup($data, $group, $update_fields);
$_REQUEST['arg'] = empty($_REQUEST['context']) ?
'none': 'search';
unset($_REQUEST['group_id']);
return $parent->redirectWithMessage($message,
['arg', 'browse', 'start_row', 'end_row',
'num_show', 'visible_users', 'user_filter']);
case "statistics":
if (!$group_id || (!$is_owner &&
$_SESSION['USER_ID'] != C\ROOT_ID)) {
break;
}
if (!empty($_REQUEST['context']) &&
$_REQUEST['context']=='search') {
$data['CONTEXT'] = 'search';
};
$data['FORM_TYPE'] = "statistics";
$impression_model = $parent->model("impression");
$periods = [C\ONE_HOUR, C\ONE_DAY, C\ONE_MONTH, C\ONE_YEAR,
C\FOREVER];
$stat_types = [C\GROUP_IMPRESSION, C\THREAD_IMPRESSION,
C\WIKI_IMPRESSION];
$filter = (empty($_REQUEST['filter'])) ? "" :
$parent->clean($_REQUEST['filter'], 'string');
$data['FILTER'] = $filter;
foreach ($periods as $period) {
$data["STATISTICS"][C\GROUP_IMPRESSION][$period] =
$impression_model->getStatistics(C\GROUP_IMPRESSION,
$period, $filter, $group_id);
$data["STATISTICS"][C\THREAD_IMPRESSION][$period] =
$impression_model->getStatistics(
C\THREAD_IMPRESSION, $period, $filter, $group_id);
$data["STATISTICS"][C\WIKI_IMPRESSION][$period] =
$impression_model->getStatistics(C\WIKI_IMPRESSION,
$period, $filter, $group_id);
}
if (C\p('DIFFERENTIAL_PRIVACY')) {
$this->socialPrivacy($data);
}
$this->getGroupUsersData($data, $group_id);
$data['SCRIPT'] .= "elt('focus-button').focus();";
break;
case "unsubscribe":
if (!empty($_REQUEST['context'])) {
$_REQUEST['arg'] = 'search';
} else {
$_REQUEST['arg'] = 'none';
}
$user_id = (isset($_REQUEST['user_id'])) ?
$parent->clean($_REQUEST['user_id'], 'int'): 0;
unset($_REQUEST['route']);
$_REQUEST['c'] = "group";
if ($user_id && $group_id &&
$group_model->checkUserGroup($user_id,
$group_id)) {
$group_model->deleteUserGroup($user_id,
$group_id);
return $parent->redirectWithMessage(
tl('social_component_unsubscribe'),
['arg','start_row', 'end_row', 'num_show']);
}
return $parent->redirectWithMessage(
tl('social_component_no_unsubscribe'),
['arg', 'start_row', 'end_row', 'num_show']);
}
}
$current_id = $_SESSION["USER_ID"];
$this->initSocialBadges($current_id, $data);
$data['group_sorts'] = [ "name_asc" =>
html_entity_decode(tl('social_component_name_asc')),
"name_desc" => html_entity_decode(tl('social_component_name_desc')),
"newest_asc" =>
html_entity_decode(tl('social_component_newest_asc')),
"newest_desc" =>
html_entity_decode(tl('social_component_newest_desc')),
];
$data['GROUP_SORT'] = "name_asc";
if (!empty($_REQUEST['group_sort']) && in_array($_REQUEST['group_sort'],
array_keys($data['group_sorts']))) {
$data['GROUP_SORT'] = $_REQUEST['group_sort'];
}
$data["GROUP_FILTER"] = (empty($_REQUEST['group_filter'])) ?
"" : $parent->clean($_REQUEST['group_filter'], "string");
$search_array = [];
if ($data["GROUP_FILTER"]) {
if ($data["GROUP_FILTER"][0] == '=') {
$name_clause = ["name", "=", substr($data["GROUP_FILTER"],1)];
} else {
$name_clause = ["name", "CONTAINS", $data["GROUP_FILTER"]];
}
} else {
$name_clause = ["name", "", ""];
}
$name_clause[3] = ($data['GROUP_SORT'] == "name_asc") ?
"ASC" : ($data['GROUP_SORT'] == "name_desc" ? "DESC" : "");
if ($name_clause != ["name", "", "", ""]) {
$search_array[] = $name_clause;
}
$newest_clause = ["created_time", "", ""];
$newest_clause[3] = ($data['GROUP_SORT'] == "newest_asc") ?
"ASC" : ($data['GROUP_SORT'] == "newest_desc" ? "DESC" : "");
if ($newest_clause != ["created_time", "", "", ""]) {
$search_array[] = $newest_clause;
}
if (isset($_SESSION['MAX_PAGES_TO_SHOW']) &&
$_SESSION['MAX_PAGES_TO_SHOW'] > 0) {
$results_per_page = $_SESSION['MAX_PAGES_TO_SHOW'];
} else {
$results_per_page = C\NUM_RESULTS_PER_PAGE;
}
$data['RESULTS_PER_PAGE'] = $results_per_page;
$_REQUEST['start_row'] = $_REQUEST['limit'] ?? 0;
$parent->pagingLogic($data, $group_model,
"GROUPS", $results_per_page, $search_array, "",
[$current_id, $browse]);
$data['LIMIT'] = $data['START_ROW'];
$this->addActivityInfoToGroups($data);
return $data;
}
/**
* What modifiers of the manageLocales activity can be added in
* manageRoles
* @return array key value pairs modifier_name => localized description
*/
public function manageGroupsModifiers()
{
return [
"cannot_create_groups" =>
tl("system_component_cannot_create_groups"),
"cannot_delete_groups" =>
tl("system_component_cannot_delete_groups"),
];
}
/**
* Returns the extra choices, called modifiers, that a role can attach to
* the Feed and Wiki activity. Attaching no_git_repository to a role takes
* away that role's ability to turn a wiki page into a git repository. By
* default no role carries it, so the ability is present unless removed.
*
* @return array modifier key => human readable label
*/
public function groupFeedsModifiers()
{
return [
"no_git_repository" =>
tl("social_component_no_git_repository"),
];
}
/**
* Decides whether a user is allowed to turn a wiki page into a git
* repository. This is allowed unless every one of the user's roles that
* carries the Feed and Wiki activity also carries the no_git_repository
* modifier, which removes the ability. The root account may always do it.
*
* @param int $user_id id of the user whose roles are checked
* @return bool whether the user may create a git repository page
*/
private function canCreateGitRepository($user_id)
{
if ($user_id == C\ROOT_ID) {
return true;
}
$user_model = $this->parent->model("user");
$activities = $user_model->getUserActivities($user_id);
foreach ($activities as $activity) {
if ($activity["METHOD_NAME"] == "groupFeeds") {
$modifiers = preg_split("/\s*,\s*/",
trim((string)($activity["ALLOWED_ARGUMENTS"] ?? "")));
if (!in_array("no_git_repository", $modifiers)) {
return true;
}
}
}
return false;
}
/**
* Used to handle request related to usage statistics for groups
*
* @param array &$data fields to be sent to the view with chart data
* @param int $impression_type what type $item_id is. Could have values:
* C\WIKI_IMPRESSION, C\THREAD_IMPRESSION, C\GROUP_IMPRESSION,
* C\QUERY_IMPRESSION
* @param int $period C\ONE_HOUR, C\ONE_DAY, etc that the chart is drawn
* should be for
* @param int $item_id id of group, wiki page, thread, chart should be for
* @param string $chart_name string identifier echoed into the rendered
* view to label this chart instance (so multiple charts on one page
* don't collide)
* @param string $chart_id DOM id used by the client-side chart code
* to bind the rendered <canvas> to its data
*/
public function makeImpressionChart(&$data, $impression_type,
$period, $item_id, $chart_name = "chart", $chart_id = "chart")
{
$parent = $this->parent;
$data['FORM_TYPE'] = "graphstats";
$data['INCLUDE_SCRIPTS'][] = "chart";
$impression_model = $parent->model("impression");
$data["STATISTICS"][$period] =
$impression_model->getPeriodHistogramData(
$impression_type, $period, $item_id);
$graph_data = [];
if ($period == C\ONE_DAY) {
$column_name = tl('social_component_hour');
$dt_format = "H";
$now = date("H");
for ($i = 0 ; $i < 24; $i++) {
$graph_data[" ".(($now + $i) % 24 + 1)] = 0;
}
} else if ($period == C\ONE_MONTH) {
$column_name = tl('social_component_day');
$dt_format = "d";
$now = date("d");
for ($i = 0 ; $i < 31; $i++) {
$graph_data[" ".(($now + $i) % 31 +1)] = 0;
}
} else if ($period == C\ONE_YEAR) {
$column_name = tl('social_component_month');
$dt_format = "M";
$now = date("n");
$months = [tl('social_component_jan'),
tl('social_component_feb'),
tl('social_component_mar'),
tl('social_component_apr'),
tl('social_component_may'),
tl('social_component_jun'),
tl('social_component_jul'),
tl('social_component_aug'),
tl('social_component_sep'),
tl('social_component_oct'),
tl('social_component_nov'),
tl('social_component_dec')];
for ($i = 0 ; $i < 12; $i++) {
$graph_data[$months[(($now + $i) % 12 )]] = 0;
}
}
$graph_title = tl('social_component_visits', $column_name);
foreach ($data['STATISTICS'][$period] as $key =>
$statistics_value) {
$timestamp = $statistics_value['UPDATE_TIMESTAMP'];
$dt = date($dt_format, $timestamp);
if ($period != C\ONE_YEAR) {
$graph_data[" ".intval($dt)] =
$statistics_value['VIEWS'];
} else {
$graph_data[$dt] =
$statistics_value['VIEWS'];
}
}
$graph_data = json_encode($graph_data);
if ($_SERVER["MOBILE"]) {
$properties = ["title" => $graph_title,
"width" => 340, "height" => 300,
"tick_font_size" => 8];
} else {
$properties = ["title" => $graph_title,
"width" => 700, "height" => 500];
}
$properties = json_encode($properties);
$data['SCRIPT'] .= "$chart_name = new Chart(" .
'"'. $chart_id . '", '. $graph_data .
', '. $properties . "); $chart_name.draw();";
}
/**
* Used to add Differential Privacy for each group
*
* @param object &$data contains fields which will be sent to the view
*/
public function socialPrivacy(&$data)
{
$parent = $this->parent;
$impression_model = $parent->model("impression");
foreach ($stat_types as $field) {
$i = 0;
foreach ($periods as $period) {
if ($field == C\GROUP_IMPRESSION) {
if (!empty($data["STATISTICS"][$field][$period])) {
$view_stat = $impression_model->getImpressionStat(
$data["STATISTICS"][$field][$period][0]['ID'],
$field, $period);
$fuzzy_views = $view_stat[1];
if (empty($view_stat[0]) ||
$view_stat[0] != $data["STATISTICS"][$field][
$period][0]['NUM_VIEWS'] ||
$tmp_data[$i - 1] > $fuzzy_views) {
$fuzzy_views = $parent->addDifferentialPrivacy(
$data["STATISTICS"][$field][
$period][0]['NUM_VIEWS']);
/* Make sure each time period's
fuzzified view is at least as large as
previous time period's value */
if ($i > 0) {
if ($tmp_data[$i - 1] > $fuzzy_views) {
$fuzzy_views = $tmp_data[$i - 1];
}
}
$impression_model->updateImpressionStat(
$data["STATISTICS"][$field][$period][0]['ID'],
$field, $period, $data["STATISTICS"][$field][
$period][0]['NUM_VIEWS'],
$fuzzy_views);
}
$data["STATISTICS"][$field][$period][0]
['NUM_VIEWS'] = ($fuzzy_views == 0)?
tl('managegroups_element_no_activity'):
$fuzzy_views;
$tmp_data[$i] = $fuzzy_views;
$i++;
}
} else {
if (!empty($data['STATISTICS'][$field][$period])) {
foreach ($data['STATISTICS'][$field]
[$period] as $item_name =>
$item_data) {
$view_stat = $impression_model->getImpressionStat(
$item_data[0]['ID'], $field, $period);
$fuzzy_views = $view_stat[1];
if ($view_stat[0] != $item_data[0]['NUM_VIEWS'] ||
$tmp_data[$item_name][$i - 1] > $fuzzy_views) {
$fuzzy_views = $parent->addDifferentialPrivacy(
$item_data[0]['NUM_VIEWS']);
if ($i > 0) {
if ($tmp_data[$item_name][$i-1] >
$fuzzy_views) {
$fuzzy_views =
$tmp_data[$item_name][$i - 1];
}
}
$impression_model->updateImpressionStat(
$item_data[0]['ID'],
$field, $period, $item_data[0]['NUM_VIEWS'],
$fuzzy_views);
}
$data["STATISTICS"][$field][$period][
$item_name][0]['NUM_VIEWS']=
($fuzzy_views == 0) ?
tl('managegroups_element_no_activity'):
$fuzzy_views;
$tmp_data[$item_name][$i] =
$fuzzy_views;
/* Decrypt group items if encrypted before
displaying */
if ($field == C\THREAD_IMPRESSION &&
$group_model->isGroupEncrypted($group_id)) {
// Decrypt thread's title
$key = $group_model->getGroupKey($group_id);
$decrypted_item_name = $group_model->decrypt(
$item_name, $key);
$data['STATISTICS'][$field][$period][
$decrypted_item_name] = $item_data;
unset($data['STATISTICS'][$field][$period][
$item_name]);
}
}
$i++;
}
}
}
}
}
/**
* Used to import group discussion thread from another grouping or bulletin
* site that has the ability to show the group as rss or atom. Examples
* of such site are: phpBB, google groups, phorum
*
* @param int $group_id id of group that thread post data will be imported
* into
* @param int $user_id id of person doing the importing (should be
* owner of group)
* @param string $feed_data an rss or atom feed containing forum/group posts
*/
public function importDiscussions($group_id, $user_id, $feed_data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$page = preg_replace('@<(/?)(\w+\s*)\:@u', '<$1', $feed_data);
$page = preg_replace("@<link@", "<slink", $page);
$page = preg_replace("@</link@", "</slink", $page);
$page = preg_replace("@pubDate@i", "pubdate", $page);
$page = preg_replace("@<@", "<", $page);
$page = preg_replace("@>@", ">", $page);
$page = preg_replace("@<br(\s[^>]*)*\/?>@i", "[br]", $page);
$page = preg_replace("@<hr(\s[^>]*)*\/?>@i", "[hr]", $page);
$page = preg_replace("@<\/?i(\s[^>]*)*>@", "''", $page);
$page = preg_replace("@<\/?b(\s[^>]*)*>@", "'''", $page);
$page = preg_replace("@<\/?u(\s[^>]*)*>@", "'''", $page);
$page = preg_replace("@<\/?tt(\s[^>]*)*>@", "'''", $page);
$page = preg_replace("@<p(\s[^>]*)*>@", "\n\n", $page);
$page = preg_replace("@<\/p(\s[^>]*)*>@", "\n", $page);
$page = preg_replace('@<\/?(o|u)l(\s[^>]*)*>@', "\n\n", $page);
$page = preg_replace("@<li(\s[^>]*)*>@", "*", $page);
$page = preg_replace("@<\/li(\s[^>]*)*>@", "\n", $page);
$page = preg_replace("@<!\[CDATA\[(.+?)\]\]>@s", '$1', $page);
$dom = L\getDomFromString($page);
$rss_elements = ["title" => "title",
"description" => "description", "link" =>"slink",
"author" => ["author", "creator"],
"guid" => "guid", "pubdate" => "pubdate"];
$nodes = $dom->getElementsByTagName('item');
if ($nodes->length == 0) {
// maybe we're dealing with atom rather than rss
$nodes = $dom->getElementsByTagName('entry');
$rss_elements = [
"title" => "title", "description" => ["summary", "content"],
"link" => "slink", "guid" => "id",
"author" => ["author", "creator"],
"pubdate" => "updated"];
}
$num_added = 0;
$num_seen = 0;
$items = [];
$feed_types = ['phpbb', 'googlegroup', 'phorum'];
$feed_type = 'unknown';
$i = 0;
foreach ($nodes as $node) {
$item = [];
foreach ($rss_elements as $db_element => $feed_element) {
if (!is_array($feed_element)) {
$feed_element = [$feed_element];
}
foreach ($feed_element as $tag_name) {
$tag_node = $node->getElementsByTagName(
$tag_name)->item(0);
$element_text = (is_object($tag_node)) ?
$tag_node->nodeValue: "";
if ($element_text) {
break;
}
}
if ($db_element == "link" && $tag_node && $element_text == "") {
$element_text = $tag_node->getAttribute("href");
}
$element_text = htmlentities(strip_tags($element_text));
$element_text = preg_replace('/\[br\]/', "<br>", $element_text);
$element_text = preg_replace('/\[hr\]/', "<hr>", $element_text);
$item[$db_element] = $element_text;
}
if ($feed_type == 'unknown') {
if (stripos($item['link'], 'viewtopic.php') !== false) {
$feed_type = 'phpbb';
} else if (stripos($item['link'],'groups.google.com')
!==false) {
$feed_type = 'googlegroup';
} else if (stripos($item['link'], 'read.php') !== false) {
$feed_type = 'phorum';
}
}
switch ($feed_type) {
case 'phpbb':
if (@preg_match('/t\=(\d+)/', $item['link'], $match)
!== false) {
$item['thread'] = $match[1];
}
if (($pos = strrpos($item['description'], 'Statistics:'))
!== false) {
$item['description'] = substr($item['description'], 0,
$pos);
}
if (($pos = strrpos($item['title'], '•'))
!== false) {
$item['title'] = trim(substr($item['title'], $pos + 6));
}
break;
case 'googlegroup':
if (@preg_match('@/d/msg/.*/(.*)/@', $item['link'], $match)
!== false) {
$item['thread'] = $match[1];
}
break;
case 'phorum':
if (@preg_match('@read\.php\?.*\,(.*)\,@', $item['link'],
$match) !== false) {
$item['thread'] = $match[1];
}
break;
default:
$item['thread'] = $i;
}
$i++;
$pos = (strtotime($item['pubdate'], 0)) ?
strtotime($item['pubdate'], 0) : $i;
$items[$pos] = $item;
}
ksort($items);
$threads = [];
foreach ($items as $item) {
$parent_id = 0;
if (!empty($item['thread']) &&
!empty($threads[$item['thread']])) {
$parent_id = $threads[$item['thread']];
$item['title'] = preg_replace("/^Re\:/", "--",
trim($item['title']), 1);
}
$post_prefix = "";
$post_user_id = $group_model->getUserId($item['author']);
if (!$post_user_id) {
$post_user_id = $user_id;
$post_prefix .= "'''" .
tl('social_component_originally_posted', $item['author']) .
"'''\n\n";
}
if (!$timestamp = strtotime($item['pubdate'], 0)) {
$timestamp = time();
$post_prefix .= "'''" .
tl('social_component_originally_dated', $item['pubdate']) .
"'''\n\n";
}
$thread_id = $this->addGroupItemWithinLimits(
$parent_id, $group_id, $post_user_id, $item['title'],
$parent->clean($post_prefix . $item['description'], "string"),
C\STANDARD_GROUP_ITEM, $timestamp);
if ($parent_id == 0) {
$threads[$item['thread']] = $thread_id;
}
}
}
/**
* Used to add a group to a user's list of group or to request
* membership in a group if the group is By Request or Public
* Request
*
* @param array &$data field variables to be drawn to view,
* we modify the SCRIPT component of this with a message
* regarding success of not of add attempt.
* @param int $add_id group id to be added
* @param int $register the registration type of the group
* @return mixed redirectWithMessage call result; under HTTP this
* exits without returning
*/
public function addGroup(&$data, $add_id, $register)
{
$parent = $this->parent;
$group_model = $parent->model('group');
$credit_model = $parent->model('credit');
$user_id = $_SESSION['USER_ID'];
$join_type = (($register == C\REQUEST_JOIN ||
$register == C\PUBLIC_BROWSE_REQUEST_JOIN) &&
$_SESSION['USER_ID'] != C\ROOT_ID) ?
C\INACTIVE_STATUS : C\ACTIVE_STATUS;
// if register fee or anyone can join will be active_status
if ($register >= C\LOW_JOIN_FEE && in_array(C\p('MONETIZATION_TYPE'),
['group_fees','fees_and_keywords'])) {
$balance = $credit_model->getCreditBalance($user_id);
if ($balance - $register < 0) {
return $parent->redirectWithMessage(
tl('social_component_buy_more_credits_join'));
}
$group_name = $group_model->getGroupName($add_id);
$strings_to_translate_for_model =
[tl('social_component_join_group_fee')];
$credit_model->updateCredits($user_id, -$register,
'social_component_join_group_fee');
}
$this->addUserGroupWithinLimits($user_id, $add_id, $join_type);
if ($join_type == C\ACTIVE_STATUS) {
return $parent->redirectWithMessage(tl('social_component_joined'),
['browse', 'start_row', 'end_row', 'num_show']);
}
// if account needs to be activated email owner
$group_info = $group_model->getGroupById($add_id,
C\ROOT_ID);
$user_model = $parent->model("user");
$owner_info = $user_model->getUser(
$group_info['OWNER']);
$server = new SmtpClient(C\p('MAIL_SENDER'), C\p('MAIL_SERVER'),
C\p('MAIL_SERVERPORT'), C\p('MAIL_USERNAME'), C\p('MAIL_PASSWORD'),
C\p('MAIL_SECURITY'));
$subject = tl('social_component_activate_group',
$group_info['GROUP_NAME']);
$current_username = $user_model->getUserName(
$_SESSION['USER_ID']);
$edit_user_url = C\p('NAME_SERVER') . "?c=admin&a=manageGroups".
"&arg=editgroup&group_id=$add_id&visible_users=true".
"&user_filter=$current_username&preserve=true";
$body = tl('social_component_activate_body',
$current_username,
$group_info['GROUP_NAME'])."\n".
$edit_user_url . "\n\n".
tl('social_component_notify_closing')."\n".
tl('social_component_notify_signature');
$message = tl(
'social_component_notify_salutation',
$owner_info['USER_NAME'])."\n\n";
$message .= $body;
$server->send($subject, C\p('MAIL_SENDER'),
$owner_info['EMAIL'], $message);
return $parent->redirectWithMessage(
tl('social_component_group_request_join'),
['browse', 'start_row', 'end_row', 'num_show']);
}
/**
* Adds the signed-in member's "Receive Group Mail" checkbox state
* (SHOW_MAIL_PREF, RECEIVE_GROUP_MAIL) to a group page's view data.
*
* @param array &$data manageGroups view data
* @param int $group_id group being shown
*/
public function addMailPrefData(&$data, $group_id)
{
$mail_pref = $this->parent->model("group")->getMailSubscription(
$_SESSION['USER_ID'], $group_id);
$data['SHOW_MAIL_PREF'] = ($mail_pref !== null);
$data['RECEIVE_GROUP_MAIL'] = $mail_pref;
}
/**
* Uses $_REQUEST and $user_id to look up all the users that a group
* has to subject to $_REQUEST['user_limit'] and
* $_REQUEST['user_filter']. Information about these roles is added as
* fields to $data[NUM_USERS_GROUP'] and $data['GROUP_USERS']
*
* @param array &$data data for the manageGroups view.
* @param int $group_id group to look up users for
*/
public function getGroupUsersData(&$data, $group_id)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$data['visible_users'] = $_REQUEST['visible_users'] ?? 'false';
$data['USER_SORTS'] = (empty($_REQUEST['user_sorts'])) ? [] :
json_decode(urldecode($_REQUEST['user_sorts']), true);
if ($data['USER_SORTS'] === null) {
$data['USER_SORTS'] = json_decode(html_entity_decode(
urldecode($_REQUEST['user_sorts'])), true);
$data['USER_SORTS'] = ($data['USER_SORTS']) ? $data['USER_SORTS'] :
[];
}
if ($data['visible_users'] == 'false') {
unset($_REQUEST['user_filter']);
unset($_REQUEST['user_limit']);
}
if (isset($_REQUEST['user_filter'])) {
$user_filter = substr($parent->clean(
$_REQUEST['user_filter'], 'string'), 0, C\NAME_LEN);
} else {
$user_filter = "";
}
$data['USER_FILTER'] = $user_filter;
$num_users = $group_model->countGroupUsers($group_id, $user_filter);
if (C\p('DIFFERENTIAL_PRIVACY')) {
$data['NUM_USERS_GROUP'] = $parent->addDifferentialPrivacy(
$num_users);
} else {
$data['NUM_USERS_GROUP'] = $num_users;
}
if (isset($_REQUEST['group_limit'])) {
$group_limit = min($parent->clean(
$_REQUEST['group_limit'], 'int'), $num_users);
$group_limit = max($group_limit, 0);
} else {
$group_limit = 0;
}
$data['GROUP_LIMIT'] = $group_limit;
$data['GROUP_USERS'] =
$group_model->getGroupUsers($group_id, $user_filter,
$data['USER_SORTS'], $group_limit);
}
/**
* Used by $this->manageGroups to check and clean $_REQUEST variables
* related to groups, to check that a user has the correct permissions
* if the current group is to be modified, and if so, to call model to
* handle the update
*
* @param array &$data used to add any information messages for the view
* about changes or non-changes to the model
* @param array &$group current group which might be altered
* @param array $update_fields which fields in the current group might be
* changed. Elements of this array are triples, the name of the
* group field, name of the request field to use for data, and an
* array of allowed values for the field
* @return string informational message describing the result of the
* update (e.g. permission failure or fields successfully changed),
* or an empty string when nothing was changed
*/
public function updateGroup(&$data, &$group, $update_fields)
{
$parent = $this->parent;
$changed = false;
if (!isset($group["OWNER_ID"]) ||
($group["OWNER_ID"] != $_SESSION['USER_ID'] &&
$_SESSION['USER_ID'] != C\ROOT_ID)) {
return tl('social_component_no_permission');
}
$group_id = $group["GROUP_ID"];
$return_value = "";
foreach ($update_fields as $row) {
list($request_field, $group_field, $check_field) = $row;
if (isset($_REQUEST[$request_field]) &&
$check_field == "valid_user") {
$new_owner_name = substr(
$parent->clean($_REQUEST['owner'],
'string'), 0, C\NAME_LEN);
$new_owner = $parent->model("user")->getUser(
$new_owner_name);
if (!isset($new_owner['USER_ID']) ) {
return tl('social_component_not_a_user');
}
if (!$parent->model("group")->checkUserGroup(
$new_owner['USER_ID'], $group_id)) {
return tl('social_component_not_in_group');
}
if ($group["OWNER_ID"] != $new_owner['USER_ID']) {
$group["OWNER_ID"] = $new_owner['USER_ID'];
$changed = true;
$return_value =
tl('social_component_owner_updated');
}
} else if (isset($_REQUEST[$request_field]) &&
in_array($_REQUEST[$request_field],
array_keys($data[$check_field]))) {
if ($group[$group_field] != $_REQUEST[$request_field]) {
$group[$group_field] =
$_REQUEST[$request_field];
$changed = true;
$return_value =
tl('social_component_group_updated');
}
} else if (!empty($_REQUEST[$request_field]) &&
is_int($_REQUEST[$request_field])) {
$return_value =
tl('social_component_unknown_access');
}
}
if ($changed) {
$parent->model("group")->updateGroup($group);
}
return $return_value;
}
/**
* Used to support requests related to posting, editing, modifying,
* and deleting group feed items.
*
* @return array $data fields to be used by GroupfeedElement
*/
public function groupFeeds()
{
$parent = $this->parent;
$controller_name = (get_class($parent) == C\NS_CONTROLLERS .
"AdminController") ? "admin" : "group";
$data["CONTROLLER"] = $controller_name;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$cron_model = $parent->model("cron");
$cron_time = $cron_model->getCronTime("cull_old_items");
$delta = time() - $cron_time;
if ($delta > C\ONE_HOUR) {
$cron_model->updateCronTime("cull_old_items");
$group_model->cullExpiredGroupItems();
} else if ($delta == 0) {
$cron_model->updateCronTime("cull_old_items");
}
$data["ELEMENT"] = "groupfeed";
$data['SCRIPT'] = "";
$data["INCLUDE_STYLES"] = ["editor"];
if (isset($_SESSION['USER_ID'])) {
$user_id = $_SESSION['USER_ID'];
} else {
$user_id = C\PUBLIC_USER_ID;
}
$username = $user_model->getUsername($user_id);
if (isset($_REQUEST['num'])) {
$results_per_page = $parent->clean($_REQUEST['num'], "int");
} else if (isset($_SESSION['MAX_PAGES_TO_SHOW']) &&
$_SESSION['MAX_PAGES_TO_SHOW'] > 0) {
$results_per_page = $_SESSION['MAX_PAGES_TO_SHOW'];
} else {
$results_per_page = C\NUM_RESULTS_PER_PAGE;
}
if (isset($_REQUEST['limit'])) {
$limit = $parent->clean($_REQUEST['limit'], "int");
} else {
$limit = 0;
}
if (isset($_SESSION['OPEN_IN_TABS'])) {
$data['OPEN_IN_TABS'] = $_SESSION['OPEN_IN_TABS'];
} else {
$data['OPEN_IN_TABS'] = false;
}
$clean_array = [ "title" => "string", "description" => "string",
"contact_id" => 'int', "just_group_id" => "int",
"just_thread" => "int", "just_user_id" => "int"];
$strings_array = [ "title" => C\TITLE_LEN, "description" =>
C\MAX_GROUP_POST_LEN];
if ($user_id == C\PUBLIC_USER_ID) {
$_SESSION['LAST_ACTIVITY']['a'] = 'groupFeeds';
$_SESSION['LAST_ACTIVITY']['c'] = $controller_name;
} else {
unset($_SESSION['LAST_ACTIVITY']);
}
foreach ($clean_array as $field => $type) {
$$field = ($type == "string") ? "" : 0;
if (isset($_REQUEST[$field])) {
$tmp = $parent->clean($_REQUEST[$field], $type);
if (isset($strings_array[$field])) {
$tmp = substr($tmp, 0, $strings_array[$field]);
}
if ($user_id == C\PUBLIC_USER_ID) {
$_SESSION['LAST_ACTIVITY'][$field] = $tmp;
}
$$field = $tmp;
}
}
$possible_arguments = ["status"];
if ($user_id != C\PUBLIC_USER_ID) {
$user_messages_id = $group_model->getPersonalGroupId($user_id);
$data['USER_MESSAGES_ID'] = $user_messages_id;
$data['CONTACTS'] = array_diff($group_model->getGroupUserIds(
$user_messages_id), [$user_id]);
$possible_arguments = ["addcomment", "addcontact","approveflagged",
"addgroup", "copypost", "cutpost", "deleteflagged",
"deleteclip", "deletepost", "downvote",
"emptyclip", "flagpost", "newthread", "pasteall", "pastepost",
"status", "togglethreadmail", "updatepost", "upvote", ];
}
if (isset($_REQUEST['arg']) &&
in_array($_REQUEST['arg'], $possible_arguments)) {
$arg = $_REQUEST['arg'];
switch ($arg) {
case "togglethreadmail":
$follow_thread = empty($_REQUEST['follow_thread']) ?
$just_thread :
$parent->clean($_REQUEST['follow_thread'], "int");
if (!empty($follow_thread)) {
$subscribe = !empty($_REQUEST['follow']);
$group_model->setThreadSubscribe($user_id,
$follow_thread, $subscribe);
if ($subscribe) {
$message =
tl('social_component_thread_followed');
} else {
$message =
tl('social_component_thread_unfollowed');
}
return $parent->redirectWithMessage($message);
}
break;
case "addcomment":
if (!empty($_REQUEST['page_type']) &&
$_REQUEST['page_type'] == "page_and_feedback") {
$_REQUEST['a'] = "wiki";
unset($_REQUEST['just_thread']);
unset($_REQUEST['limit']);
unset($_REQUEST['num']);
}
if (!isset($_REQUEST['parent_id'])
|| !$_REQUEST['parent_id']
|| !isset($_REQUEST['group_id'])
|| !$_REQUEST['group_id']) {
return $parent->redirectWithMessage(
tl('social_component_comment_error'),
['page_name']);
}
if (!$description) {
return $parent->redirectWithMessage(
tl('social_component_no_comment'),
['page_name']);
}
$parent_id = $parent->clean($_REQUEST['parent_id'], "int");
$group_id = $parent->clean($_REQUEST['group_id'], "int");
$group = $group_model->getGroupById($group_id,
$user_id, true);
$read_comment = [C\GROUP_READ_COMMENT, C\GROUP_READ_WRITE,
C\GROUP_READ_WIKI];
if (!$group || $user_id == C\PUBLIC_USER_ID ||
($group["OWNER_ID"] != $user_id &&
!in_array($group["MEMBER_ACCESS"], $read_comment) &&
$user_id != C\ROOT_ID)) {
return $parent->redirectWithMessage(
tl('social_component_no_post_access'),
['page_name']);
}
if ($parent_id >= 0) {
$parent_item = $group_model->getGroupItem($parent_id);
if (!$parent_item) {
return $parent->redirectWithMessage(
tl('social_component_no_post_access'),
['page_name']);
}
} else {
$parent_item = [
'TITLE' => tl('social_component_join_group',
$username, $group['GROUP_NAME']),
'DESCRIPTION' =>
tl('social_component_join_group_detail',
date("r", $group['JOIN_DATE']),
$group['GROUP_NAME']),
'ID' => -$group_id,
'PARENT_ID' => -$group_id,
'GROUP_ID' => $group_id
];
}
$title = "-- " . $parent_item['TITLE'];
$id = $this->addGroupItemWithinLimits($parent_item["ID"],
$group_id, $user_id, $title, $description);
$group_model->setThreadSubscribe($user_id,
$parent_item["ID"], true);
list($bots_called, $post_parts) =
$this->getRequestedBots($group_id, $description);
$result = $this->handleResourceUploads(
$group_id, "post" . $id);
if ($result == self::UPLOAD_FAILED) {
return $parent->redirectWithMessage(
tl('social_component_upload_error'));
}
$followers = $group_model->getThreadSubscribers(
$parent_item["ID"], $user_id);
$server = new SmtpClient(C\p('MAIL_SENDER'),
C\p('MAIL_SERVER'),
C\p('MAIL_SERVERPORT'), C\p('MAIL_USERNAME'),
C\p('MAIL_PASSWORD'),
C\p('MAIL_SECURITY'));
$post_url = "";
if (in_array($group['REGISTER_TYPE'],
[C\PUBLIC_BROWSE_REQUEST_JOIN, C\PUBLIC_JOIN])) {
$post_url = B\feedsUrl("thread", $parent_item["ID"],
true, "group", false) . "preserve=true\n";
}
$subject = tl('social_component_thread_notification',
$parent_item['TITLE']);
$body = tl('social_component_notify_body') . "\n" .
$parent_item['TITLE']."\n".
$post_url .
tl('social_component_notify_closing')."\n".
tl('social_component_notify_signature');
foreach ($followers as $follower) {
if (empty($follower['USER_ID'])) {
continue;
}
if (!$user_model->isBotUser($follower['USER_ID'])) {
$message = tl('social_component_notify_salutation',
$follower['USER_NAME']) . "\n\n";
$message .= $body;
$server->send($subject, C\p('MAIL_SENDER'),
$follower['EMAIL'], $message);
}
}
$this->addAnyBotResponses($parent_item["ID"], $group_id,
$bots_called, $title, $post_parts);
return $parent->redirectWithMessage(
tl('social_component_comment_added'), ['page_name']);
break;
case "addcontact":
$data['CONTACT_ID'] = $contact_id;
return $this->addContact($user_id, $data);
break;
case "addgroup":
if ($_SESSION['USER_ID'] == C\PUBLIC_USER_ID) {
return $parent->redirectWithMessage(
tl('social_component_public_cant_add'));
}
$register =
$group_model->getRegisterType($just_group_id);
if ($just_group_id > 0 && !empty($register)
&& $register != C\INVITE_ONLY_JOIN) {
if ($register >= C\LOW_JOIN_FEE &&
!in_array(C\p('MONETIZATION_TYPE'),
['group_fees','fees_and_keywords'])) {
$register = C\INVITE_ONLY_JOIN;
return $parent->redirectWithMessage(
tl('social_component_groupname_cant_add'));
}
return
$this->addGroup($data, $just_group_id, $register);
} else {
return $parent->redirectWithMessage(
tl('social_component_groupname_cant_add'));
}
break;
case "copypost":
case "cutpost":
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$post = $group_model->getGroupItem($post_id);
if (empty($post)) {
return $parent->redirectWithMessage(
tl('social_component_invalid_message_id'));
}
$group = $group_model->getGroupById($post['GROUP_ID'],
$user_id);
if (empty($group) || $group['OWNER_ID'] != $user_id) {
return $parent->redirectWithMessage(
tl('social_component_invalid_access'));
}
$clipboard_id = $group_model->getFeedClipboardId($user_id);
if (!$clipboard_id) {
return $parent->redirectWithMessage(
tl('social_component_clipboard_unavailable'));
}
if ($group_model->getThreadPostCount($clipboard_id)
>= C\MAX_THREAD_CLIPBOARD_ITEMS) {
return $parent->redirectWithMessage(
tl('social_component_clipboard_full'));
}
$clip_group_id = $group_model->getPersonalGroupId($user_id);
$clipped_item_id = $this->addGroupItemWithinLimits(
$clipboard_id,
$clip_group_id, $post['USER_ID'], $post['TITLE'],
$post['DESCRIPTION'], $post['TYPE'], $post['PUBDATE'],
"", $post['UPS'], $post['DOWNS'], $post['FLAG']);
$group_model->copyThreadResources($post["GROUP_ID"],
$post_id, $clip_group_id, $clipped_item_id);
if ($arg == 'copypost') {
return $parent->redirectWithMessage(
tl('social_component_message_clipcopied'));
}
//note a cut will do a delete after the copy
case "deletepost":
if (!empty($_REQUEST['page_type']) &&
$_REQUEST['page_type'] == "page_and_feedback") {
$_REQUEST['a'] = "wiki";
}
if (!isset($_REQUEST['post_id'])) {
return $parent->redirectWithMessage(
tl('social_component_delete_error'),
['page_name']);
break;
}
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$group_item = $group_model->getGroupItem($post_id);
$success = false;
if ($group_item) {
// this method checks if user can delete post
$success =
$group_model->deleteGroupItem($post_id, $user_id);
}
$search_array = [["parent_id", "=", $just_thread, ""]];
$item_count = $group_model->getGroupItemCount($search_array,
$user_id, -1);
if (!empty($_REQUEST['page_type']) &&
$_REQUEST['page_type'] == "page_and_feedback") {
unset($_REQUEST['just_thread']);
$_REQUEST['group_id'] = $group_item['GROUP_ID'];
}
if ($success) {
$group_model->deleteResources($group_item["GROUP_ID"],
"post" . $post_id);
if ($item_count == 0) {
unset($_REQUEST['just_thread']);
}
if ($arg == 'cutpost') {
return $parent->redirectWithMessage(
tl('social_component_item_clipcut'),
['page_name']);
} else {
return $parent->redirectWithMessage(
tl('social_component_item_deleted'),
['page_name']);
}
} else {
if ($arg == 'cutpost') {
return $parent->redirectWithMessage(
tl('social_component_item_cut_nodelete'),
['page_name']);
} else {
return $parent->redirectWithMessage(
tl('social_component_no_item_deleted'),
['page_name']);
}
}
break;
case "deleteclip":
$clipboard_id = $group_model->getFeedClipboardId($user_id);
$clip_group_id = $group_model->getPersonalGroupId($user_id);
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$group_item = $group_model->getGroupItem($post_id);
if (empty($group_item)) {
return $parent->redirectWithMessage(
tl('social_component_invalid_message_id'));
}
if ($group_item) {
// this method checks if user can delete post
$success =
$group_model->deleteGroupItem($post_id, $user_id);
}
if ($success) {
$group_model->deleteResources($clip_group_id,
"post" . $post_id);
return $parent->redirectWithMessage(
tl('social_component_clip_item_deleted'));
}
return $parent->redirectWithMessage(
tl('social_component_not_clip_item_deleted'));
break;
case "emptyclip":
$clipboard_id = $group_model->getFeedClipboardId($user_id);
$clip_group_id = $group_model->getPersonalGroupId($user_id);
$clip_search_array = [
["parent_id", "=", $clipboard_id, ""]];
$clip_items = $group_model->getGroupItems(0,
C\MAX_THREAD_CLIPBOARD_ITEMS, $clip_search_array,
$user_id, -2);
foreach ($clip_items as $clip_item) {
if ($clip_item['DESCRIPTION'] == 'start_feed_clip') {
continue;
}
$success = $group_model->deleteGroupItem(
$clip_item['ID'], $user_id);
if ($success) {
$group_model->deleteResources($clip_group_id,
"post" . $clip_item['ID']);
}
}
return $parent->redirectWithMessage(
tl('social_component_clipboard_emptied'));
break;
case "pasteall":
$clipboard_id = $group_model->getFeedClipboardId($user_id);
$clip_group_id = $group_model->getPersonalGroupId($user_id);
if (empty($just_thread)) {
return $parent->redirectWithMessage(
tl('social_component_no_target_thread'));
}
$thread_item = $group_model->getGroupItem($just_thread);
if (!$thread_item) {
return $parent->redirectWithMessage(
tl('social_component_no_thread_access'));
}
$paste_group_id = $thread_item['GROUP_ID'];
$clip_search_array = [
["parent_id", "=", $clipboard_id, ""]];
$clip_items = $group_model->getGroupItems(0,
C\MAX_THREAD_CLIPBOARD_ITEMS, $clip_search_array,
$user_id, -2);
foreach ($clip_items as $clip_item) {
if ($clip_item['DESCRIPTION'] == 'start_feed_clip') {
continue;
}
$pasted_id = $this->addGroupItemWithinLimits(
$just_thread,
$paste_group_id , $clip_item['USER_ID'],
$clip_item['TITLE'], $clip_item['DESCRIPTION'],
$clip_item['TYPE'], $clip_item['PUBDATE'], "",
$clip_item['UPS'], $clip_item['DOWNS'],
$clip_item['FLAG']);
$group_model->copyThreadResources( $clip_group_id,
$clipped_item['ID'], $paste_group_id, $pasted_id);
}
return $parent->redirectWithMessage(
tl('social_component_clipboard_pasted'));
break;
case "pastepost":
$clipboard_id = $group_model->getFeedClipboardId($user_id);
$clip_group_id = $group_model->getPersonalGroupId($user_id);
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$post = $group_model->getGroupItem($post_id);
if (empty($post)) {
return $parent->redirectWithMessage(
tl('social_component_invalid_message_id'));
}
if (empty($just_thread)) {
return $parent->redirectWithMessage(
tl('social_component_no_target_thread'));
}
$thread_item = $group_model->getGroupItem($just_thread);
if (!$thread_item) {
return $parent->redirectWithMessage(
tl('social_component_no_thread_access'));
}
$paste_group_id = $thread_item['GROUP_ID'];
$pasted_id = $this->addGroupItemWithinLimits($just_thread,
$paste_group_id , $post['USER_ID'],
$post['TITLE'], $post['DESCRIPTION'],
$post['TYPE'], $post['PUBDATE'], "",
$post['UPS'], $post['DOWNS'],
$post['FLAG']);
$group_model->copyThreadResources($clip_group_id,
$post_id, $paste_group_id, $pasted_id);
return $parent->redirectWithMessage(
tl('social_component_clipboard_pasted'));
break;
case "flagpost":
if (!isset($_REQUEST['post_id'])) {
return $parent->redirectWithMessage(
tl('social_component_flag_error'),
['page_name']);
break;
}
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$group_item = $group_model->getGroupItem($post_id);
if ($group_model->alreadyFlagged($user_id, $post_id)) {
return $parent->redirectWithMessage(
tl('social_component_already_flagged'));
}
$success = false;
if ($group_item) {
$success =
$group_model->flagGroupItem($post_id, $user_id);
}
if ($success) {
return $parent->redirectWithMessage(
tl('social_component_item_flagged'),
['page_name']);
} else {
return $parent->redirectWithMessage(
tl('social_component_no_item_flagged'),
['page_name']);
}
break;
case "approveflagged":
if (!isset($_REQUEST['post_id'])){
return $parent->redirectWithMessage(
tl('social_component_no_approve_access'));
}
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$group_item = $group_model->getGroupItem($post_id);
$success = false;
if ($group_item) {
$success = $group_model->approveFlaggedPost($post_id);
}
if ($success) {
return $parent->redirectWithMessage(
tl('social_component_flagged_item_approved'),
['page_name']);
} else {
return $parent->redirectWithMessage(
tl('social_component_no_flagged_item_approved'),
['page_name']);
}
break;
case "deleteflagged":
if (!isset($_REQUEST['post_id'])){
return $parent->redirectWithMessage(
tl('social_component_no_delete_access'));
}
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$group_item = $group_model->getGroupItem($post_id);
$success = false;
if ($group_item) {
$success = $group_model->deleteFlaggedPost($post_id,
tl('social_component_flagged_and_removed'));
}
if ($success) {
return $parent->redirectWithMessage(
tl('social_component_flagged_item_deleted'),
['page_name']);
} else {
return $parent->redirectWithMessage(
tl('social_component_no_flagged_item_deleted'),
['page_name']);
}
break;
case "downvote":
if (!isset($_REQUEST['group_id']) || !$_REQUEST['group_id']
||!isset($_REQUEST['post_id']) ||
!$_REQUEST['post_id']) {
return $parent->redirectWithMessage(
tl('social_component_vote_error'));
}
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$group_id = $parent->clean($_REQUEST['group_id'], "int");
$group = $group_model->getGroupById($group_id,
$user_id, true);
if (!$group || $user_id == C\PUBLIC_USER_ID
|| (!in_array($group["VOTE_ACCESS"],
[C\UP_DOWN_VOTING_GROUP] ) ) ) {
return $parent->redirectWithMessage(
tl('social_component_no_vote_access'));
}
$post_item = $group_model->getGroupItem($post_id);
if (!$post_item || $post_item['GROUP_ID'] != $group_id) {
return $parent->redirectWithMessage(
tl('social_component_no_post_access'));
}
if ($group_model->alreadyVoted($user_id, $post_id)) {
return $parent->redirectWithMessage(
tl('social_component_already_voted'));
}
$group_model->voteDown($user_id, $post_id);
return $parent->redirectWithMessage(
tl('social_component_vote_recorded'));
break;
case "newthread":
if (!isset($_REQUEST['group_id']) ||
!$_REQUEST['group_id']) {
return $parent->redirectWithMessage(
tl('social_component_comment_error'));
}
$group_id = $parent->clean($_REQUEST['group_id'], "int");
if (!$description || !$title) {
return $parent->redirectWithMessage(
tl('social_component_need_title_description'));
}
$group_id = $parent->clean($_REQUEST['group_id'], "int");
$group = $group_model->getGroupById($group_id,
$user_id, true);
$new_thread = [C\GROUP_READ_WRITE, C\GROUP_READ_WIKI];
if (!$group || $user_id == C\PUBLIC_USER_ID ||
($group["OWNER_ID"] != $user_id &&
!in_array($group["MEMBER_ACCESS"], $new_thread) &&
$user_id != C\ROOT_ID)) {
return $parent->redirectWithMessage(
tl('social_component_no_post_access'));
}
$thread_id = $this->addGroupItemWithinLimits(0,
$group_id, $user_id, $title, $description);
$group_model->setThreadSubscribe($user_id,
$thread_id, true);
list($bots_called, $post_parts) =
$this->getRequestedBots($group_id, $description);
$result = $this->handleResourceUploads(
$group_id, "post" . $thread_id);
if ($result == self::UPLOAD_FAILED) {
return $parent->redirectWithMessage(
tl('social_component_upload_error'));
}
if ($user_id == $group['OWNER_ID']) {
$followers = $group_model->getGroupUsers(
$group_id, "", [], "",
C\NUM_RESULTS_PER_PAGE, true);
} else {
$owner_name = $user_model->getUsername(
$group['OWNER_ID']);
$follower = $user_model->getUser($owner_name);
$followers = [$follower];
}
$server = new SmtpClient(C\p('MAIL_SENDER'),
C\p('MAIL_SERVER'),
C\p('MAIL_SERVERPORT'), C\p('MAIL_USERNAME'),
C\p('MAIL_PASSWORD'),
C\p('MAIL_SECURITY'));
$subject = tl('social_component_new_thread_mail',
$group['GROUP_NAME']);
$post_url = B\feedsUrl("thread", $thread_id, true,
"group", false)."preserve=true\n";
$body = tl('social_component_new_thread_body',
$group['GROUP_NAME'])."\n".
"\"".$title."\"\n".
$post_url .
tl('social_component_notify_closing')."\n".
tl('social_component_notify_signature');
$unsubscribe_mailto =
ML\MailSiteFactory::unsubscribeMailtoAddress(
C\p('MAIL_SENDER'));
foreach ($followers as $follower) {
if ($follower['USER_ID'] != $user_id &&
($user_id == $group['OWNER_ID'] ||
$follower['USER_ID'] == $group['OWNER_ID'])
&& !$user_model->isBotUser($follower['USER_ID'])) {
$message = tl('social_component_notify_salutation',
$follower['USER_NAME'])."\n\n";
$message .= $body;
$extra_headers =
ML\UnsubscribeToken::listUnsubscribeHeaders(
$follower['USER_ID'], $group_id,
C\baseUrl(), $unsubscribe_mailto);
$server->send($subject, C\p('MAIL_SENDER'),
$follower['EMAIL'], $message,
$extra_headers);
}
}
$this->addAnyBotResponses($thread_id, $group_id,
$bots_called, "--" . $title, $post_parts);
$thread_url = B\feedsUrl('thread', $thread_id) .
C\p('CSRF_TOKEN') . "=" .
$parent->generateCSRFToken($_SESSION["USER_ID"]) ;
$_SESSION['DISPLAY_MESSAGE'] =
tl('social_component_thread_created');
return $parent->redirectWithMessage(
tl('social_component_thread_created'));
break;
case "status":
$data['REFRESH'] = "feedstatus";
if (!empty($_REQUEST['feed_time']))
$data['REFRESH_TIMESTAMP'] = $parent->clean(
$_REQUEST['feed_time'], "int");
break;
case "updatepost":
if (!empty($_REQUEST['page_type']) &&
$_REQUEST['page_type'] == "page_and_feedback") {
$_REQUEST['a'] = "wiki";
unset($_REQUEST['limit']);
unset($_REQUEST['num']);
}
if (!isset($_REQUEST['post_id'])) {
return $parent->redirectWithMessage(
tl('social_component_comment_error'),
['page_name']);
}
if (!$description || !$title) {
return $parent->redirectWithMessage(
tl('social_component_need_title_description'),
['page_name']);
}
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$action = "updatepost" . $post_id;
if (!$parent->checkCSRFTime(C\p('CSRF_TOKEN'), $action)) {
return $parent->redirectWithMessage(
tl('social_component_post_edited_elsewhere'),
['page_name']);
}
$items = $group_model->getGroupItems(0, 1,
[["post_id", "=", $post_id, ""]], $user_id);
if (isset($items[0])) {
$item = $items[0];
} else {
return $parent->redirectWithMessage(
tl('social_component_no_update_access'),
['page_name']);
}
$group_id = $item['GROUP_ID'];
$_REQUEST['group_id'] = $group_id;
$group = $group_model->getGroupById($group_id, $user_id,
true);
$update_thread = [C\GROUP_READ_WRITE, C\GROUP_READ_WIKI];
if ($post_id != $item['PARENT_ID'] && $post_id > 0) {
$update_thread[] = C\GROUP_READ_COMMENT;
$parent_items = $group_model->getGroupItems(0, 1,
[["post_id", "=", $item['PARENT_ID'], ""]],
$user_id);
if (!empty($parent_items[0])) {
$parent_item = $parent_items[0];
$title = "-- " . $parent_item['TITLE'];
}
}
if (!$group || $user_id == C\PUBLIC_USER_ID ||
($group["OWNER_ID"] != $user_id &&
!in_array($group["MEMBER_ACCESS"], $update_thread) &&
$user_id != ROOT_ID)) {
return $parent->redirectWithMessage(
tl('social_component_no_update_access'),
['page_name']);
break;
}
$group_model->updateGroupItem($post_id, $title,
$description);
$result = $this->handleResourceUploads(
$group_id, "post" . $post_id);
if ($result == self::UPLOAD_FAILED) {
return $parent->redirectWithMessage(
tl('social_component_upload_error'),
['page_name']);
}
return $parent->redirectWithMessage(
tl('social_component_post_updated'),
['page_name']);
break;
case "upvote":
if (!isset($_REQUEST['group_id']) || !$_REQUEST['group_id']
||!isset($_REQUEST['post_id']) ||
!$_REQUEST['post_id']) {
return $parent->redirectWithMessage(
tl('social_component_vote_error'));
}
$post_id = $parent->clean($_REQUEST['post_id'], "int");
$group_id = $parent->clean($_REQUEST['group_id'], "int");
$group = $group_model->getGroupById($group_id, $user_id,
true);
if (!$group || $user_id == C\PUBLIC_USER_ID ||
(!in_array($group["VOTE_ACCESS"],
[C\UP_VOTING_GROUP, C\UP_DOWN_VOTING_GROUP] ) ) ) {
return $parent->redirectWithMessage(
tl('social_component_no_vote_access'));
}
$post_item = $group_model->getGroupItem($post_id);
if (!$post_item || $post_item['GROUP_ID'] != $group_id) {
return $parent->redirectWithMessage(
tl('social_component_no_post_access'));
}
if ($group_model->alreadyVoted($user_id, $post_id)) {
return $parent->redirectWithMessage(
tl('social_component_already_voted'));
}
$group_model->voteUp($user_id, $post_id);
return $parent->redirectWithMessage(
tl('social_component_vote_recorded'));
break;
}
}
$view_mode = (isset($_REQUEST['v'])) ?
$parent->clean($_REQUEST['v'], "string") : "grouped";
$data['VIEW_MODE'] = $view_mode;
$view_mode = (!$just_group_id && !$just_user_id
&& !$just_thread) ? $view_mode : "ungrouped";
if ($view_mode == "grouped") {
if ($user_id == C\PUBLIC_USER_ID) {
$_REQUEST = ['c' => "admin", 'a' => '',
C\p('CSRF_TOKEN') => ''];
return $parent->redirectWithMessage(
tl("social_component_login_first"));
}
$this->initSocialBadges($user_id, $data);
$this->calculateRecentFeedsAndThread($data, $user_id);
return $this->calculateGroupedFeeds($user_id, $limit,
$results_per_page, $controller_name, $data);
} else {
$this->initSocialBadges($user_id, $data);
}
$groups_count = 0;
$pages = [];
$page = [];
if (!$just_user_id && (!$just_thread || $just_thread < 0)) {
$search_array = [
["group_id", "=", max(-$just_thread, $just_group_id), ""],
["access", "!=", C\GROUP_PRIVATE, ""],
["status", "IN", [C\ACTIVE_STATUS, C\EDITOR_STATUS], ""],
["join_date", "=", "", "DESC"]
];
$groups = $group_model->getRows(
0, $limit + $results_per_page, $groups_count,
$search_array, [$user_id, false]);
// create feed items for first join dates to given groups
foreach ($groups as $group) {
$page = [];
$page['USER_ICON'] = C\SHORT_BASE_URL .
"resources/anonymous.png";
$page[self::TITLE] = tl('social_component_join_group',
$username, $group['GROUP_NAME']);
$page[self::DESCRIPTION] =
tl('social_component_join_group_detail',
date("r", $group['JOIN_DATE']), $group['GROUP_NAME']);
$page['ID'] = -$group['GROUP_ID'];
$page['PARENT_ID'] = -$group['GROUP_ID'];
$page['USER_NAME'] = "";
$page['USER_ID'] = "";
$page['GROUP_ID'] = $group['GROUP_ID'];
$page[self::SOURCE_NAME] = $group['GROUP_NAME'];
$page['MEMBER_ACCESS'] = $group['MEMBER_ACCESS'];
$page['STATUS'] = $group['STATUS'];
if ($group['OWNER_ID'] == $user_id || $user_id == C\ROOT_ID) {
$page['MEMBER_ACCESS'] = C\GROUP_READ_WIKI;
}
$page['PUBDATE'] = $group['JOIN_DATE'];
$pages[$group['JOIN_DATE']] = $page;
}
}
$pub_clause = ['pub_date', "=", "", "DESC"];
$sort = "krsort";
if ($just_thread) {
$thread_parent = $group_model->getGroupItem($just_thread);
$group_id = $thread_parent['GROUP_ID'] ?? false;
if ($group_id && $user_id != C\PUBLIC_USER_ID &&
$group_model->checkUserGroup($user_id, $group_id) !==
false) {
$data['SHOW_THREAD_FOLLOW'] = true;
$data['THREAD_FOLLOWED'] = $group_model->
isThreadSubscribed($user_id, $just_thread);
}
if (isset($thread_parent["TYPE"]) &&
$thread_parent["TYPE"] == C\WIKI_GROUP_ITEM) {
$page_info = $group_model->getPageInfoByThread($just_thread);
if (isset($page_info["PAGE_NAME"])) {
$group_id = $page_info['GROUP_ID'];
$data["WIKI_PAGE_NAME"] = $page_info["PAGE_NAME"];
$group = $group_model->getGroupById($group_id, $user_id,
true);
if (!empty($group) && ($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;
$edit_or_source = "edit";
} else {
$data["CAN_EDIT"] = false;
$edit_or_source = "source";
}
if (empty($data["CAN_EDIT"]) &&
$user_id == C\PUBLIC_USER_ID) {
$thread_source_allowed =
!empty($group['PAGE_SOURCE_ALLOWED']);
if ($thread_source_allowed) {
$thread_page_info =
$group_model->getPageInfoByName($group_id,
$page_info["PAGE_NAME"],
$page_info["LOCALE_TAG"], "read");
$thread_head = WikiParser::parsePageHeadVars(
$thread_page_info["PAGE"] ?? "");
$thread_source_allowed =
empty($thread_head['public_source']) ||
$thread_head['public_source'] == 'true';
}
if (!$thread_source_allowed) {
$parent->web_site->header(
"HTTP/1.0 404 Not Found");
$parent->displayView("nocache", $data);
\seekquarry\atto\webExit();
}
}
}
} else {
$group = $group_model->getGroupById($group_id, $user_id,
true);
}
if ((!isset($_REQUEST['f']) ||
!in_array($_REQUEST['f'], ["rss", "json", "serial"]))) {
$pub_clause = ['pub_date', "=", "", "ASC"];
$sort = "ksort";
$parent->model("impression")->add($user_id, $just_thread,
C\THREAD_IMPRESSION);
$parent->model("impression")->add($user_id, $group_id,
C\GROUP_IMPRESSION);
$parser = new WikiParser("", true);
$math = false;
if (!empty($group) && $user_id == $group["OWNER_ID"]) {
$clipboard_id = $group_model->getFeedClipboardId($user_id);
$clip_group_id = $group_model->getPersonalGroupId($user_id);
$clip_search_array = [
["parent_id", "=", $clipboard_id, ""]];
$clip_items = $group_model->getGroupItems(0,
1000, $clip_search_array, $user_id, -2);
$data['CLIP_ITEMS'] = [];
$csrf_token = C\p('CSRF_TOKEN') . "=" .
$this->parent->generateCSRFToken($user_id);
foreach ($clip_items as $clip_item) {
if ($clip_item['DESCRIPTION'] == 'start_feed_clip') {
continue;
}
$clip_item["DESCRIPTION"] =
$group_model->insertResourcesParsePage(
$clip_group_id,
"post" . $clip_item['ID'], L\getLocaleTag(),
$clip_item["DESCRIPTION"]);
$clip_item["DESCRIPTION"] = preg_replace(
'/\[{rtoken}\]/', $csrf_token,
$clip_item["DESCRIPTION"]);
$clip_item['USER_ICON'] = $user_model->getUserIconUrl(
$clip_item['USER_ID']);
$data['CLIP_ITEMS'][] = $clip_item;
if (!$math && strpos(
$clip_item["DESCRIPTION"], "`") !== false) {
$math = true;
if (!isset($data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"] = [];
}
$data["INCLUDE_SCRIPTS"][] = "math";
}
}
}
}
}
$search_array = [
["parent_id", "=", $just_thread, ""],
["group_id", "=", $just_group_id, ""],
["group_name", "NOT BEGINS WITH", C\PERSONAL_GROUP_PREFIX, ""],
["user_id", "=", $just_user_id, ""],
$pub_clause];
$for_group = ($just_group_id) ? $just_group_id : (($just_thread) ?
-2 : -1);
if (!empty($just_thread) ) {
$data['JUST_THREAD'] = $just_thread;
}
list($item_count, $pages) = $this->initializeFeedItems($data, $pages,
$user_id, $search_array, $for_group, $sort, $limit,
$results_per_page);
$data['SUBTITLE'] = "";
$type = "";
$type_id = "";
if (!empty($just_thread) ) {
$thread_start_item = $group_model->getGroupItem($just_thread);
if (empty($thread_start_item) ||
empty($thread_start_item["TITLE"])) {
$data['NO_POSTS_IN_THREAD'] = true;
if ($just_thread < 0) {
$data['SUBTITLE'] = empty($pages[0][self::TITLE]) ?
"" : $pages[0][self::TITLE];
$data["GROUP_ID"] = -$just_thread;
$group = $group_model->getGroupById(
$data["GROUP_ID"], $user_id);
if (!empty($group)) {
$data["GROUP_NAME"] = $group["GROUP_NAME"];
$data['GROUP_STATUS'] = $group['STATUS'];
} else {
$title = "";
$data['SUBTITLE'] = "";
}
}
} else {
$title = $thread_start_item["TITLE"];
$data['SUBTITLE'] = trim($title, "\- \t\n\r\0\x0B");
$type = "thread";
$type_id = $just_thread;
$group = $group_model->getGroupById(
$thread_start_item['GROUP_ID'], $user_id);
$data["GROUP_ID"] = $thread_start_item['GROUP_ID'];
if (!empty($group)) {
$data["GROUP_NAME"] = $group["GROUP_NAME"];
$data['GROUP_STATUS'] = $group['STATUS'];
} else {
$title = "";
$data['SUBTITLE'] = "";
$data['NO_POSTS_IN_THREAD'] = true;
}
}
}
if (!$just_group_id && !$just_thread) {
$data['GROUP_STATUS'] = C\ACTIVE_STATUS;
}
if ($just_group_id) {
$group = $group_model->getGroupById($just_group_id, $user_id);
if (!$group) {
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['just_group_id'] = C\PUBLIC_GROUP_ID;
return $parent->redirectWithMessage(
tl("social_component_no_group_access"), false, false,
true);
}
$data['GROUP_STATUS'] = $group['STATUS'];
if (!isset($page[self::SOURCE_NAME]) ) {
$page[self::SOURCE_NAME] = $group['GROUP_NAME'];
}
if (empty($pages) ) {
$data['NO_POSTS_YET'] = true;
if ($user_id == $group['OWNER_ID'] || $user_id == C\ROOT_ID) {
// this case happens when a group is no read
$data['NO_POSTS_START_THREAD'] = true;
}
}
if ($user_id != C\PUBLIC_USER_ID &&
!$group_model->checkUserGroup($user_id, $just_group_id)) {
$data['SUBSCRIBE_LINK'] = $group_model->getRegisterType(
$just_group_id);
}
$data['SUBTITLE'] = $page[self::SOURCE_NAME];
$type= "group";
$type_id = $just_group_id;
$data['JUST_GROUP_ID'] = $just_group_id;
$parent->model("impression")->add($user_id, $just_group_id,
C\GROUP_IMPRESSION);
}
if ($just_user_id) {
$page["USER_NAME"] = $user_model->getUsername($just_user_id);
$data['SUBTITLE'] = $page["USER_NAME"];
$type = "user";
$type_id = $just_user_id;
$data['JUST_USER_ID'] = $just_user_id;
}
if ($user_id != C\PUBLIC_USER_ID) {
$thread_ids = $parent->model("impression")->recent($user_id,
C\THREAD_IMPRESSION, 3);
}
$this->calculateRecentFeedsAndThread($data, $user_id);
$data['TOTAL_ROWS'] = $item_count + $groups_count;
$data['RESULTS_PER_PAGE'] = $results_per_page;
$token_string = ($user_id != C\PUBLIC_USER_ID )? C\p('CSRF_TOKEN') .
"=".
$this->parent->generateCSRFToken($user_id) : "";
$data['PAGING_QUERY'] = htmlentities(B\feedsUrl($type, $type_id,
true, $controller_name));
$paging_query = $data['PAGING_QUERY'];
if (!empty($type)) {
$data['RSS_FEED_URL'] = $paging_query . "f=rss";
}
if ($view_mode == 'ungrouped') {
$connector = (str_ends_with($paging_query, "?")) ? "" :
"&";
$paging_query .= "{$connector}v=ungrouped";
$data['RSS_FEED_URL'] = $paging_query . "&f=rss";
}
$paging_query = html_entity_decode($paging_query);
$data['SCRIPT'] .= " let nextPage = initNextResultsPage($limit," .
" {$data['TOTAL_ROWS']}, $results_per_page, ".
"'$paging_query&$token_string', '', 'results-container', ".
"'result-batch');\n";
if ($limit > 0) {
$data['SCRIPT'] .= " let previousPage = initPreviousResultsPage(".
"$limit, {$data['TOTAL_ROWS']}, $results_per_page, ".
"'$paging_query&$token_string', 'results-container', ".
"'result-batch');\n";
}
if (!empty($data['REFRESH_TIMESTAMP']) && !empty($pages)) {
$max_pubdate = 0;
$num_pages = count($pages);
for ($i = 0; $i < $num_pages; $i++) {
$page = $pages[$i];
if (empty($page['PUBDATE']) ||
($page['PUBDATE'] <= $data['REFRESH_TIMESTAMP'] &&
!empty($page['EDIT_DATE']) &&
$page['EDIT_DATE'] > $max_pubdate)) {
unset($pages[$i]);
$limit++;
continue;
}
if ($page['PUBDATE'] > $max_pubdate) {
$max_pubdate = $page['PUBDATE'];
}
if (!empty($page['EDIT_DATE']) &&
$page['EDIT_DATE'] > $max_pubdate) {
$max_pubdate = $page['EDIT_DATE'];
}
}
if ($max_pubdate <= $data['REFRESH_TIMESTAMP']) {
\seekquarry\atto\webExit();
}
}
$data['PAGES'] = array_values($pages);
if ($user_id != C\PUBLIC_USER_ID && !$just_thread) {
$feed_thread_ids = [];
foreach ($data['PAGES'] as $feed_page) {
if (!empty($feed_page['PARENT_ID']) &&
$feed_page['PARENT_ID'] > 0) {
$feed_thread_ids[] = $feed_page['PARENT_ID'];
}
}
$data['FOLLOWED_THREADS'] = $group_model->getFollowedThreads(
$user_id, $feed_thread_ids);
}
$data['LIMIT'] = $limit;
$this->initializeWikiEditor($data, -1);
return $data;
}
/**
* Handles requests for the user messages subsystem of yioop that allows
* users to directly send messages to each other. This involves for a user
* request gettting a list of user contacts, getttingmessages of the
* currently selected user and handles any new messages posted.
* This data is the sent to UsermessagesElement for display.
*
* @return mixed associative $data array prepared for
* UsermessagesElement, or the result of redirectWithMessage if
* the user has no permission and the response was a redirect
*/
public function userMessages()
{
$parent = $this->parent;
$controller_name = (get_class($parent) == C\NS_CONTROLLERS .
"AdminController") ? "admin" : "group";
$data["CONTROLLER"] = $controller_name;
$data['VIEW_MODE'] = "ungrouped";
$data['SUBTITLE'] = C\PERSONAL_GROUP_PREFIX;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$data["ELEMENT"] = "usermessages";
$data['SCRIPT'] = "";
$data["INCLUDE_STYLES"] = ["messages", "editor"];
$data["INCLUDE_SCRIPTS"] = ["messages"];
if (!isset($_SESSION['USER_ID'])) {
$_REQUEST = ['c' => "admin", 'a' => '', C\p('CSRF_TOKEN') => ''];
return $parent->redirectWithMessage(
tl("social_component_login_first"));
}
$user_id = $_SESSION['USER_ID'];
$username = $user_model->getUsername($user_id);
if (isset($_REQUEST['num'])) {
$results_per_page = $parent->clean($_REQUEST['num'], "int");
} else if (isset($_SESSION['MAX_PAGES_TO_SHOW']) &&
$_SESSION['MAX_PAGES_TO_SHOW'] > 0) {
$results_per_page = $_SESSION['MAX_PAGES_TO_SHOW'];
} else {
$results_per_page = C\NUM_RESULTS_PER_PAGE;
}
if (isset($_REQUEST['limit'])) {
$limit = $parent->clean($_REQUEST['limit'], "int");
} else {
$limit = 0;
}
$contact_id = $parent->clean($_REQUEST["contact_id"]
?? "", 'int');
$contact_filter = $parent->clean($_REQUEST['contact_name'] ?? "",
"string");
$data['USER_ID'] = $user_id;
$data['CONTACT_ID'] = $contact_id;
$data['CONTACT_NAME'] = (!empty($contact_id)) ?
$user_model->getUsername($contact_id) : "";
$data['CONTACT_FILTER'] = $contact_filter;
$data['CONTACT_ICON_URL'] = $user_model->getUserIconUrl($contact_id);
$user_messages_id = $group_model->getPersonalGroupId($user_id);
$data['USER_MESSAGES_ID'] = $user_messages_id;
if (!empty($contact_id)) {
$data['TRANSLATION_LANGUAGE'] =
$group_model->getTranslationLanguage($user_id, $contact_id);
}
if (!empty(C\LLM_API_URL) && !empty(C\LLM_MODEL)) {
$locale_model = $parent->model("locale");
$data['AVAILABLE_LOCALES'] =
$this->getTranslationLocales($locale_model);
$data['CURRENT_LOCALE_TAG'] = L\getLocaleTag();
}
$message_actions = ['addcontact' => 'addContact',
'blockcontact' => 'blockContact',
'ignorecontact' => 'ignoreContact',
'newmessage' => 'newMessage',
'loadmessages' => 'loadMoreMessages',
'settranslation' => 'setTranslationLanguage', "status" =>
"messagesStatus"];
$arg = $_REQUEST['arg'] ?? "";
if (in_array($arg, array_keys($message_actions))) {
$action = $message_actions[$arg];
if (!empty($contact_filter)) {
$data['CONTACT_ID'] = $user_model->getUserId($contact_filter);
$data['CONTACT_NAME'] = $contact_filter;
$data['CONTACT_FILTER'] = "";
}
return $this->$action($user_id, $data);
}
$data[C\p('CSRF_TOKEN')] =
$parent->generateCSRFToken($user_id);
if (C\GROUP_CALL_ICE_SERVERS) {
$data['SCRIPT'] .= "configuration = {'iceServers': [";
$comma = "";
foreach (C\GROUP_CALL_ICE_SERVERS as $ice_server) {
$data['SCRIPT'] .= "$comma$ice_server";
$comma = ",";
}
$data['SCRIPT'] .= "]};";
}
$messages_url = B\feedsUrl('user_messages',
$data['CONTACT_ID'], true, $data['CONTROLLER']) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')];
$data['SCRIPT'] .= 'window.start_url = "'. $messages_url .
'&arg=status";';
$data['SCRIPT'] .= "window.tl = {".
'social_component_no_longer_update:"'.
tl('social_component_no_longer_update').'"'.
'}; window.USERNAME = "' . $user_model->getUsername($user_id) .
'";'.
'window.CSRF_TOKEN = "' . C\p('CSRF_TOKEN') . '";'.
'window.onload = doUpdate;';
$contact_ids = array_diff($group_model->getGroupUserIds(
$user_messages_id), [$user_id]);
$contacts = $this->marshallContactInfo($user_id, $contact_ids,
$contact_filter);
$requests_thread_id = $group_model->getGroupThreadId(
$user_messages_id, null, $group_model->getMessagesThreadTitle(
[$user_id]));
$contact_requests = [];
if (!empty($requests_thread_id)) {
$pre_contact_requests = $group_model->getThreadFollowers(
$requests_thread_id);
$contact_requests = [];
foreach ($pre_contact_requests as $pre_contact_request) {
if (!empty($contact_name) && strpos(
$pre_contact_request['USER_NAME'],
$contact_filter) === false) {
continue;
}
$pre_contact_request_id = $pre_contact_request['USER_ID'];
$pre_contact_request['ICON_URL'] =
$user_model->getUserIconUrl($pre_contact_request_id);
$contact_requests[$pre_contact_request_id] =
$pre_contact_request;
}
}
$data['CONTACTS'] = $contacts;
$data['CONTACT_REQUESTS'] = $contact_requests;
$data['ELEMENT'] = 'usermessages';
$data['MESSAGES'] = [];
if (!empty($contact_id)) {
$message_thread_id = $group_model->getGroupThreadId(
$user_messages_id, $user_id,
$group_model->getMessagesThreadTitle(
[$user_id, $contact_id]));
if ($message_thread_id) {
$parent->model("impression")->add($user_id, $message_thread_id,
C\THREAD_IMPRESSION);
$search_array = [
["parent_id", "=", $message_thread_id, ""],
["group_id", "=", $user_messages_id, ""],
["pubdate", "", "", "DESC"]];
list(, $data['MESSAGES']) = $this->initializeFeedItems($data,
[], $user_id, $search_array, -2, "krsort", $limit,
15);
if (!empty(C\LLM_API_URL) && !empty(C\LLM_MODEL)) {
$translation_target = null;
$translation_lang = $data['TRANSLATION_LANGUAGE'] ?? '';
if (!empty($translation_lang) &&
$translation_lang !== 'off') {
$translation_target = $translation_lang;
} elseif (empty($translation_lang) ||
$translation_lang === 'off') {
$user_locale = $data['CURRENT_LOCALE_TAG'] ?? 'en_US';
if ($user_locale !== 'en_US' &&
$user_locale !== C\p('DEFAULT_LOCALE')) {
$translation_target = $user_locale;
}
}
if (!empty($translation_target)) {
$data['MESSAGES'] = $this->translateMessageBatch(
$data['MESSAGES'], $translation_target);
}
}
}
}
$this->initSocialBadges($user_id, $data);
$this->initializeWikiEditor($data, -1);
return $data;
}
/**
* Gets an array of contact details including USER_NAME, ICON_URL,
* NUM_UNREAD_MESSAGES for
* an array of contact ids for $user_id, subject to a filter.
* @param int $user_id the viewer whose unread-message counts are
* computed against the contact list
* @param array $contact_ids id's that contact details are desired of
* @param string $contact_filter a substring of user_names to restrict
* the returned details to (i.e., only contacts whose user name's match
* this substring)
* @return array $contacts array of contact details (which in turn
* is an array with fields USER_NAME, ICON_URL, ...) for ids with
* user names matching the filter
*/
private function marshallContactInfo($user_id, $contact_ids,
$contact_filter = "")
{
$parent = $this->parent;
$user_model = $parent->model("user");
$group_model = $parent->model("group");
$impression_model = $parent->model("impression");
$contacts= [];
$personal_group_id = $group_model->getPersonalGroupId($user_id);
foreach ($contact_ids as $contact_id) {
$contact_username = $user_model->getUsername(
$contact_id);
if (!empty($contact_filter) && strpos($contact_username,
$contact_filter) === false) {
continue;
}
$icon_url = $user_model->getUserIconUrl($contact_id);
$chat_id = $group_model->getGroupThreadId(
$personal_group_id, $user_id,
$group_model->getMessagesThreadTitle(
[$user_id, $contact_id]));
if (empty($chat_id)) {
continue;
}
$chat_stamp = $impression_model->mostRecentThreadView($user_id,
$chat_id);
if (empty($chat_stamp)) {
$chat_stamp = 0;
}
$contacts[$contact_id] = [
"USER_NAME" => $contact_username,
"ICON_URL" => $icon_url,
"NUM_UNREAD_MESSAGES" => floor($group_model->getThreadPostCount(
$chat_id, $chat_stamp)/2)
];
}
return $contacts;
}
/**
* Get messages since last status update as determined by session
* variable $user_id and $data['CONTACT_ID'].
* Add them to a list in $data['MESSAGES']
* @param int $user_id user we are trying to update conversation of
* @param array $data this will be data sent to the view, but should
* be called after the 'CONTACT_ID' field has been set to the value
* of the person who $user_id is have a messaging exchange
* @return array $data updated view data with new messages (if any)
*/
private function messagesStatus($user_id, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$data["REFRESH"] = "api";
$data["STATUS"] = true;
$data['MESSAGES'] = [];
$contact_id = $data['CONTACT_ID'] ?? "";
$type = $_REQUEST['type'] ?? "status";
$type = in_array($type, ['status', 'call-event']) ?
$type : 'status';
$call_hash_id = $group_model->getCallGroupID([$user_id,
$contact_id]);
$call_active = $group_model->isActiveCall($call_hash_id);
if($call_active) {
$call_time = $group_model->getGroupCallTime($call_hash_id);
$group_model->cullCallEvents($call_hash_id);
}
$event = null;
if ($type == 'call-event') {
$event = json_decode(file_get_contents('php://input'), true);
}
if ($call_active) {
$call_time = $group_model->getGroupCallTime($call_hash_id);
if ($type == 'status') {
$parent->web_site->header("X-Accel-Buffering: no");
$parent->web_site->header("Content-Type: text/event-stream");
$parent->web_site->header("Cache-Control: no-cache");
$_SESSION["PROCESS_TIME"] ??= 0;
$event_info = $group_model->nextCallEvent($call_hash_id,
$user_id, $_SESSION["PROCESS_TIME"]);
if ($event_info) {
echo 'data: ' . json_encode($event_info['EVENT']) .
PHP_EOL;
$_SESSION["PROCESS_TIME"] = $event_info['EVENT_TIME'];
}
echo 'retry: 3000' . PHP_EOL . PHP_EOL;
\seekquarry\atto\webExit();
} else if ($type == 'call-event') {
$parent->web_site->header("X-Accel-Buffering: no");
$parent->web_site->header("Content-Type: text/event-stream");
$parent->web_site->header("Cache-Control: no-cache");
$group_model->addCallEvent($call_hash_id, $user_id,
$event['type'], $event['data']);
\seekquarry\atto\webExit();
}
} else if (!empty($event['type'])) {
$group_model->deleteGroupCall($call_hash_id);
$call_time =
$group_model->createGroupCall($call_hash_id, $user_id);
$group_model->addCallEvent($call_hash_id, $user_id,
$event['type'], $event['data']);
$_SESSION["PROCESS_TIME"] = $call_time;
return $data;
}
$message_thread_id = $group_model->getGroupThreadId(
$data['USER_MESSAGES_ID'], $user_id,
$group_model->getMessagesThreadTitle(
[$user_id, $data["CONTACT_ID"]]));
$conversation_time = $_SESSION['CONVERSATION_TIME'] ?? 0;
$_SESSION['CONVERSATION_TIME'] = time();
if (!$message_thread_id || !$conversation_time) {
return $data;
}
$search_array = [
["parent_id", "=", $message_thread_id, ""],
["group_id", "=", $data['USER_MESSAGES_ID'], ""],
["pubdate", ">", $conversation_time, "DESC"]];
$limit = 0;
list(, $data['MESSAGES']) = $this->initializeFeedItems($data,
[], $user_id, $search_array, -2, "krsort", $limit,
100);
return $data;
}
/**
* Contains the logic need to add a contact $data['CONTACT_ID'] to
* the list of $user_id's contacts for messaging.
*
* @param int $user_id id of user adding contact to
* @param array $data current data to be sent to view after processing
* needs to contain a field $data['CONTACT_ID'] with the contact_id
* of user to add to contacts
* @return mixed redirectWithMessage call result; under HTTP this
* exits without returning
*/
private function addContact($user_id, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$contact_id = $data['CONTACT_ID'] ?? "";
if (empty($contact_id)) {
return $parent->redirectWithMessage(
tl('social_component_invalid_contact'));
}
if ($user_id == C\PUBLIC_USER_ID || $user_id == $contact_id) {
return $parent->redirectWithMessage(
tl('social_component_invalid_user'));
}
$user_messages_id = $group_model->getPersonalGroupId($user_id);
$contact_messages_id = $group_model->getPersonalGroupId($contact_id);
if (!empty($contact_id) &&
!in_array($user_model->getUserStatus($contact_id), [
C\ACTIVE_STATUS, C\EDITOR_STATUS]) ) {
return $parent->redirectWithMessage(
tl('social_component_invalid_contact'));
}
$this->addUserGroupWithinLimits($contact_id, $user_messages_id);
$messages_thread_title = $group_model->getMessagesThreadTitle(
[$user_id, $contact_id]);
$_REQUEST["contact_id"] = $contact_id;
if ($group_model->isUserIdInContacts($user_id,
$contact_id)) {
$existing_parent_id =
$group_model->getGroupThreadId(
$contact_messages_id, $contact_id,
$messages_thread_title);
$this->addGroupItemWithinLimits(
$existing_parent_id, $user_messages_id, $user_id,
$messages_thread_title, "");
$request_thread_title = $group_model->getMessagesThreadTitle(
[$user_id]);
$request_thread_id = $group_model->getGroupThreadId(
$user_messages_id, $contact_id, $request_thread_title);
if (!empty($request_thread_id)) {
$group_model->deleteGroupItem($request_thread_id, $contact_id);
}
return $parent->redirectWithMessage(
tl('social_component_connection_established'), ["contact_id"]);
} else {
$this->addGroupItemWithinLimits(0,
$user_messages_id, $user_id, $messages_thread_title,
"");
$blocked_thread_title = $group_model->getMessagesThreadTitle(
[$user_id, "blocked"]);
$blocked_thread_id = $group_model->getGroupThreadId(
$user_messages_id, $contact_id, $blocked_thread_title);
if (!$blocked_thread_id) {
$request_thread_title = $group_model->getMessagesThreadTitle(
[$contact_id]);
}
// thread used to keep tract of contact requests for $contact_id
$this->addGroupItemWithinLimits(0,
$contact_messages_id, $user_id, $request_thread_title,
"");
return $parent->redirectWithMessage(
tl('social_component_connection_requested'), ["contact_id"]);
}
}
/**
* When a contact request is made the receiving user can either accept,
* ignore, or block. Accept means connection is made, ignore means
* the connection request is removed from the list of request, but the
* requestor could send a new request, and block means that a new request
* will automatically be discarded. This method implements the ignore
* connection request for a user with id $user_id from a user
* $data['CONTACT_ID'].
*
* @param int $user_id id of user who is doing the ignoring
* @param array $data current data to be sent to view after processing
* needs to contain a field $data['CONTACT_ID'] with the contact_id
* of user to ignore
* @return mixed redirectWithMessage call result; under HTTP this
* exits without returning
*/
private function ignoreContact($user_id, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$contact_id = $data['CONTACT_ID'] ?? "";
if (empty($contact_id)) {
return $parent->redirectWithMessage(
tl('social_component_invalid_contact'));
}
if ($user_id == C\PUBLIC_USER_ID || $user_id == $contact_id) {
return $parent->redirectWithMessage(
tl('social_component_invalid_user'));
}
$user_messages_id = $group_model->getPersonalGroupId($user_id);
if (!empty($contact_id) &&
!in_array($user_model->getUserStatus($contact_id), [
C\ACTIVE_STATUS, C\EDITOR_STATUS])) {
return $parent->redirectWithMessage(
tl('social_component_invalid_contact'));
}
$request_thread_title = $group_model->getMessagesThreadTitle(
[$user_id]);
$request_thread_id = $group_model->getGroupThreadId(
$user_messages_id, $contact_id, $request_thread_title);
if (!empty($request_thread_id)) {
$group_model->deleteGroupItem($request_thread_id, $contact_id);
}
return $parent->redirectWithMessage(
tl('social_component_connection_request_ignored'));
}
/**
* When a contact request is made the receiving user can either accept,
* ignore, or block. Accept means connection is made, ignore means
* the connection request is removed from the list of request, but the
* requestor could send a new request, and block means that a new request
* will automatically be discarded. This method implements the block
* connection request for a user with id $user_id from a user
* $data['CONTACT_ID'].
*
* @param int $user_id id of user who is doing the blocking
* @param array $data current data to be sent to view after processing
* needs to contain a field $data['CONTACT_ID'] with the contact_id
* of user to block
* @return mixed redirectWithMessage call result; under HTTP this
* exits without returning
*/
private function blockContact($user_id, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$contact_id = $data['CONTACT_ID'] ?? "";
if (empty($contact_id)) {
return $parent->redirectWithMessage(
tl('social_component_invalid_contact'));
}
if ($user_id == C\PUBLIC_USER_ID || $user_id == $contact_id) {
return $parent->redirectWithMessage(
tl('social_component_invalid_user'));
}
$user_messages_id = $group_model->getPersonalGroupId($user_id);
if (!empty($contact_id) &&
!in_array($user_model->getUserStatus($contact_id), [
C\ACTIVE_STATUS, C\EDITOR_STATUS]) ) {
return $parent->redirectWithMessage(
tl('social_component_invalid_contact'));
}
$contact_messages_id = $group_model->getPersonalGroupId($contact_id);
$request_thread_title = $group_model->getMessagesThreadTitle(
[$user_id]);
$request_thread_id = $group_model->getGroupThreadId(
$user_messages_id, $contact_id, $request_thread_title);
if (!empty($request_thread_id)) {
$group_model->deleteGroupItem($request_thread_id, $contact_id);
$block_thread_title = $group_model->getMessagesThreadTitle(
[$contact_id, "blocked"]);
$this->addGroupItemWithinLimits(0,
$contact_messages_id, $user_id, $block_thread_title,
"");
}
return $parent->redirectWithMessage(
tl('social_component_connection_request_blocked'));
}
/**
* Sets the translation language for messages from a specific contact
*
* @param int $user_id id of user who will receive translated messages
* @param array $data current data to be sent to view after processing
* @return mixed redirectWithMessage call result; under HTTP this
* exits without returning
*/
private function setTranslationLanguage($user_id, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$contact_id = $data['CONTACT_ID'] ?? null;
$language = $parent->clean($_REQUEST['language'] ?? 'off', 'string');
if (empty($contact_id)) {
return $parent->redirectWithMessage(
tl('social_component_invalid_contact'),
['contact_id']
);
}
$group_model->setTranslationLanguage($user_id, $contact_id, $language);
$message = ($language == 'off')
? tl('social_component_translation_disabled')
: tl('social_component_translation_enabled');
return $parent->redirectWithMessage($message, ['contact_id']);
}
/**
* Translates a batch of messages using the LLM API
*
* @param array $messages Array of message items to translate
* @param string $target_language Target language code
* @return array Array of messages with translated content
*/
private function translateMessageBatch($messages, $target_language)
{
if (empty($messages) || empty($target_language) ||
$target_language == 'off') {
return $messages;
}
$translated_count = 0;
foreach ($messages as $index => &$message) {
$content_field = null;
$original_text = null;
if (!empty($message['t'])) {
$content_field = 't';
$original_text = $message['t'];
} else if (!empty($message['DESCRIPTION'])) {
$content_field = 'DESCRIPTION';
$original_text = $message['DESCRIPTION'];
}
if ($content_field && $original_text) {
$clean_text = strip_tags($original_text);
$clean_text = trim($clean_text);
$is_media_message = (
str_contains($original_text, '<audio') ||
str_contains($original_text, '<video') ||
str_contains($original_text, '<img') ||
str_contains($original_text, 'audio-message-container') ||
str_contains($original_text, 'video-message-container') ||
str_contains($original_text, 'image-message-container') ||
str_contains($original_text, 'audio-message') ||
str_contains($original_text, 'video-message') ||
str_contains($original_text, 'image-message') ||
str_contains($clean_text, 'Description audio_') ||
str_contains($clean_text, 'Description video_') ||
str_contains($original_text, 'transcribe-btn') ||
str_contains($original_text, 'transcript-container')
);
if (strlen($clean_text) > 5 && !ctype_digit($clean_text) &&
!$is_media_message) {
$translation_result = $this->translateText(
$original_text, $target_language);
if (!empty($translation_result['translation'])) {
$message[$content_field] =
$translation_result['translation'];
$message['ORIGINAL_' . $content_field] = $original_text;
$translated_count++;
}
}
}
}
unset($message);
return $messages;
}
/**
* Gets available translation locales with their native names
*
* @param object $locale_model Locale model instance
* @return array Array of locale codes and their native names
*/
private function getTranslationLocales($locale_model)
{
$locales = $locale_model->getLocaleList(true, true);
$translation_locales = ["off" => tl('toggle_helper_off')];
foreach ($locales as $locale) {
if (!empty($locale['LOCALE_TAG']) &&
!empty($locale['LOCALE_NAME'])) {
$translation_locales[$locale['LOCALE_TAG']] =
$locale['LOCALE_NAME'];
}
}
return $translation_locales;
}
/**
* Translates a given text to the specified target language using LLM API
*
* @param string $text The text to translate
* @param string $target_lang The target language code
* @return array Translation results with status and content
*/
private function translateText($text, $target_lang)
{
if (empty($text) || empty($target_lang)) {
return ['errors' => ["Missing required parameters"]];
}
$api_url = C\LLM_API_URL ?? "http://localhost:1234/v1/chat/completions";
$model = C\LLM_MODEL ?? "aya-23-8b";
$language_map = ["ar" => "Arabic", "bn" => "Bengali", "de" => "German",
"el_GR" => "Greek", "en_US" => "English (US)", "es" => "Spanish",
"fa" => "Persian", "fr_FR" => "French (France)", "he" => "Hebrew",
"hi" => "Hindi", "id" => "Indonesian", "it" => "Italian",
"ja" => "Japanese", "kn" => "Kannada", "ko" => "Korean",
"nl" => "Dutch", "pl" => "Polish", "pt" => "Portuguese",
"ru" => "Russian", "te" => "Telugu", "th" => "Thai",
"tl" => "Tagalog", "tr" => "Turkish", "vi_VN" => "Vietnamese",
"zh_CN" => "Chinese (Simplified)"];
$target_language_name = $language_map[$target_lang] ?? $target_lang;
$request_body = ["model" => $model, "messages" => [["role" => "system",
"content" => "You are a helpful translation assistant. " .
"Translate text accurately while preserving meaning."],
["role" => "user", "content" => "Translate the following text to " .
$target_language_name .
". Only return the translation without explanations:\n\n" . $text]],
"temperature" => 0.1, "max_tokens" => 512, "stream" => false];
$post_data = json_encode($request_body);
$headers = ['Content-Type: application/json'];
$response = L\FetchUrl::getPage($api_url, $post_data, true, null,
C\SINGLE_PAGE_TIMEOUT, $headers);
if ($response === false) {
return ['errors' => ["Failed to connect to translation service"]];
}
$result_obj = json_decode($response, true);
if (isset($result_obj['response'])) {
$content = trim($result_obj['response']);
return ['translation' => $content, 'status' => 'success'];
} else if (isset($result_obj['choices'][0]['message']['content'])) {
$content = trim($result_obj['choices'][0]['message']['content']);
return ['translation' => $content, 'status' => 'success'];
} else {
return ['errors' => ["Invalid response from translation service"]];
}
}
/**
* Sends a message cleaned $_REQUEST["description"] from user with id
* $user_id to user with $data['CONTACT_ID']
*
* Expects a message $_REQUEST["description"] coming from message form to
* to send.
*
* @param int $user_id id of user who is sending the message
* @param array $data current data to be sent to view after processing
* needs to contain a field $data['CONTACT_ID'] with the contact_id
* of user to send to
* @return mixed redirectWithMessage call result; under HTTP this
* exits without returning
*/
private function newMessage($user_id, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_messages_id = $group_model->getPersonalGroupId($user_id);
$messages_thread_title = $group_model->getMessagesThreadTitle(
[$user_id, $data['CONTACT_ID']]);
$description = $contact_id = $parent->clean($_REQUEST["description"]
?? "", 'string');
$message_thread_id = $group_model->getGroupThreadId($user_messages_id,
$user_id, $messages_thread_title);
$parent->model("impression")->add($user_id, $message_thread_id,
C\THREAD_IMPRESSION);
if (empty($message_thread_id)) {
return $parent->redirectWithMessage(
tl("social_component_invalid_message_thread"),
['contact_id']);
}
$thread_contributors = explode("-", $messages_thread_title);
$first_group_id = -1;
$result = self::UPLOAD_NO_FILES;
foreach ($thread_contributors as $contributor_id) {
$contributor_personal_id = $group_model->
getPersonalGroupId($contributor_id);
if (empty($contributor_personal_id)) {
continue;
}
$message_id = $this->addGroupItemWithinLimits($message_thread_id,
$contributor_personal_id, $user_id,
"--" . $messages_thread_title, $description);
if ($first_group_id == -1) {
$first_group_id = $contributor_personal_id;
$first_message_id = $message_id;
$result = $this->handleResourceUploads($contributor_personal_id,
"post" . $message_id);
if ($result == self::UPLOAD_FAILED) {
return $parent->redirectWithMessage(
tl('social_component_upload_error'));
}
} else if ($result == self::UPLOAD_SUCCESS) {
$group_model->linkResourceFolders($contributor_personal_id,
"post" . $message_id, $first_group_id,
"post" . $first_message_id);
}
}
return $parent->redirectWithMessage("", ['contact_id']);
}
/**
* Loads more messages for a conversation to support infinite scroll
* pagination. Returns JSON response with older messages.
*
* @param int $user_id id of user requesting more messages
* @param array $data current data array (not used but required by
* interface)
*/
private function loadMoreMessages($user_id, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$contact_id = $parent->clean($_REQUEST['contact_id'] ?? 0, 'int');
$before_timestamp = $parent->clean(
$_REQUEST['before_timestamp'] ?? 0, 'int');
$limit = $parent->clean($_REQUEST['limit'] ?? 20, 'int');
if (empty($contact_id) || empty($before_timestamp)) {
$parent->web_site->header('Content-Type: application/json');
e(json_encode(['error' => 'Missing required parameters',
'contact_id' => $contact_id,
'before_timestamp' => $before_timestamp]));
\seekquarry\atto\webExit();
}
if ($limit > 100) {
$limit = 100;
}
$user_messages_id = $group_model->getPersonalGroupId($user_id);
$messages_thread_title = $group_model->getMessagesThreadTitle(
[$user_id, $contact_id]);
$message_thread_id = $group_model->getGroupThreadId($user_messages_id,
$user_id, $messages_thread_title);
if (empty($message_thread_id)) {
$parent->web_site->header('Content-Type: application/json');
e(json_encode(['error' => 'Invalid message thread']));
\seekquarry\atto\webExit();
}
$search_array = [
["parent_id", "=", $message_thread_id, ""],
["group_id", "=", $user_messages_id, ""],
["pubdate", "<", $before_timestamp, "DESC"]
];
$temp_data = $data;
$temp_limit = 0;
try {
list(, $messages) = $this->initializeFeedItems($temp_data, [],
$user_id, $search_array, -2, "krsort", $temp_limit, $limit);
} catch (Exception $e) {
$messages = $group_model->getGroupItems(0, $limit,
$search_array, $user_id, -2);
}
if (!empty(C\LLM_API_URL) && !empty(C\LLM_MODEL)) {
$translation_language =
$group_model->getTranslationLanguage($user_id, $contact_id);
$user_locale = L\getLocaleTag();
$translation_target = null;
if (!empty($translation_language) &&
$translation_language !== 'off') {
$translation_target = $translation_language;
} elseif (empty($translation_language) ||
$translation_language === 'off') {
if ($user_locale !== 'en_US' &&
$user_locale !== C\p('DEFAULT_LOCALE')) {
$translation_target = $user_locale;
}
}
if (!empty($translation_target)) {
$messages = $this->translateMessageBatch($messages,
$translation_target);
}
}
$has_more = count($messages) == $limit;
$element_data = [
'MESSAGES' => $messages,
'USERNAME' => $user_model->getUsername($user_id),
'CONTACT_ID' => $contact_id,
'CONTACT_NAME' => $user_model->getUsername($contact_id),
'CONTACT_ICON_URL' => $user_model->getUserIconUrl($contact_id),
'MOBILE' => false,
'LOCALE_DIR' => 'ltr'
];
$rendered_html = "";
$now = time();
$old_pubdate = $now;
$old_user = $messages[0]['USER_NAME'] ?? "";
$num_messages = count($messages);
$rendered_count = 0;
for ($i = 0; $i < $num_messages; $i++) {
$message_info = $messages[$i];
$next_message_info = $messages[$i + 1] ?? $message_info;
$next_user_name = $next_message_info['USER_NAME'];
$description_content = '';
if (!empty($message_info['DESCRIPTION'])) {
$description_content = $message_info['DESCRIPTION'];
} else {
foreach (['t', 'DESCRIPTION', 'OLD_DESCRIPTION',
'SOURCE_NAME', 'TITLE'] as $field) {
if (!empty($message_info[$field])) {
$description_content = $message_info[$field];
break;
}
}
}
if (empty($description_content)) {
continue;
}
$rendered_count++;
$pub_date = $message_info['PUBDATE'];
$next_pub_date = $next_message_info['PUBDATE'];
$pub_date_diff = abs($next_pub_date - $pub_date);
$pub_date_age = $now - $pub_date;
$user_change = $message_info['USER_NAME'] != $next_user_name;
$pub_data_change = $user_change ||($pub_date_age < C\ONE_HOUR &&
$pub_date_diff > 5 * C\ONE_MINUTE) ||
($pub_date_age < C\ONE_DAY && $pub_date_diff > C\ONE_HOUR)||
($pub_date_diff > C\ONE_DAY);
$pub_date_string = date('M j, Y g:i A', $pub_date);
$color = "back-light-gray";
$align = "align-same";
$is_user = false;
$user_string = "other";
$top_corner = " top-corner ";
if ($element_data['USERNAME'] == $message_info['USER_NAME']) {
$color = "back-light-blue";
$align = "align-opposite";
$is_user = true;
$user_string = "user";
$top_corner = "";
}
$comma = "";
$time_content = "";
if (!$is_user && ($pub_data_change || $user_change)) {
$time_content .= '<img class="message-icon float-same" src="'.
htmlentities($message_info['USER_ICON']).'" > ';
$comma = ", ";
$time_content .= htmlentities($message_info['USER_NAME']);
}
if ($pub_data_change) {
$time_content .= htmlentities($comma . $pub_date_string);
}
$rendered_html .= '<div class="' . htmlentities($align) . '">' .
'<div class="message-detail-outer">' .
'<div class="message-' . htmlentities($user_string) .
'-time">' .
$time_content . '</div>' .
'<div class="message-detail ' .
htmlentities($color . $top_corner) . '">' .
$description_content . '</div>' .
'</div></div>';
}
$oldest_timestamp = null;
if (!empty($messages)) {
$oldest_timestamp = $messages[count($messages) - 1]['PUBDATE'];
}
$parent->web_site->header('Content-Type: application/json');
$response = json_encode([
'html' => $rendered_html,
'has_more' => $has_more,
'message_count' => count($messages),
'oldest_timestamp' => $oldest_timestamp
]);
e($response);
\seekquarry\atto\webExit();
}
/**
* Determines a list of posts that might need to reply to a post in
* a group
*
* @param int $group_id get chat bots following this group
* @param string $description post message to see if called any bots
* by using a phrase like: @bot_name some request
* @return array [array of bots referred to in post, array of post
* portions for each robot]
*/
private function getRequestedBots($group_id, $description)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$bot_followers = $group_model->getGroupBots($group_id);
$bots = [];
$bots_called = [];
$post_parts = [];
foreach ($bot_followers as $bot_follower) {
$bots[] = $bot_follower['USER_NAME'];
}
if (preg_match_all('/(?<!\w)@(\w+)\s([^@]*)/si', $description,
$matches)) {
foreach ($matches[1] as $match) {
$match = mb_strtolower($match);
$index = array_search($match, $bots);
if ($index !== false) {
$bots_called[] = $bot_followers[$index];
} else {
$bots_called[] = null;
}
}
$post_parts = $matches[2];
}
return [$bots_called, $post_parts];
}
/**
* This follows up to the thread post $thread_id to $group_id any
* response that $bots following this group might have
*
* @param int $thread_id id of the thread post to follow up
* @param int $group_id of group thread post was posted to
* @param array $bots list of chat bot users following group
* @param string $title title of thread post to follow up
* @param array $posts for each bot the contents of message applicable
* to that bot
*/
private function addAnyBotResponses($thread_id, $group_id, $bots, $title,
$posts)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$num_bots = count($bots);
$sites = [];
$post_data = [];
$time = time();
$user_id = (empty($_SESSION['USER_ID'])) ? C\PUBLIC_USER_ID:
$_SESSION['USER_ID'];
$user_name = (empty($_SESSION['USER_NAME'])) ? "PUBLIC" :
$_SESSION['USER_NAME'];
if (empty($_SESSION["CHAT_BOT_STATES"])) {
$_SESSION["CHAT_BOT_STATES"] = [];
}
for ($i = 0; $i < $num_bots; $i++) {
if (empty($bots[$i]['USER_ID'])) {
continue;
}
$bot_id = $bots[$i]['USER_ID'];
if (empty($_SESSION["CHAT_BOT_STATES"][$bot_id])) {
$_SESSION["CHAT_BOT_STATES"][$bot_id] = "0";
}
$bots[$i]['PATTERN'] =
$this->computeBotPattern($bot_id, $posts[$i]);
if (!empty($bots[$i]['PATTERN'])) {
$bots[$i]['PATTERN']['VARS']['REMOTE_MESSAGE'] =
$this->interpolateBotVariables(
$bots[$i]['PATTERN']['REMOTE_MESSAGE'],
$bots[$i]['PATTERN']['VARS']);
}
if (empty($bots[$i]['PATTERN']['VARS']['REMOTE_MESSAGE'])) {
$sites[$i] = [];
} else {
$sites[$i][CrawlConstants::URL] = $bots[$i]['CALLBACK_URL'];
$post_data[$i] = "remote_message=".
urlencode($bots[$i]['PATTERN']['VARS']['REMOTE_MESSAGE']) .
"&post=" . urlencode($posts[$i]) . "&bot_token=" .
hash("sha256", $bots[$i]['BOT_TOKEN'] .
$time . $posts[$i]) . "*" . $time .
"&bot_name=" . $bots[$i]['USER_NAME'];
}
}
$outputs = [];
if (count($sites) > 0) {
$outputs = FetchUrl::getPages($sites, false, 0, null,
self::URL, self::PAGE, true, $post_data);
}
for ($i = 0; $i < $num_bots; $i++) {
if (!empty($bots[$i]['PATTERN']) &&
isset($outputs[$i][self::PAGE]) ) {
$bots[$i]['PATTERN']['VARS']['REMOTE_RESPONSE'] =
$outputs[$i][self::PAGE];
}
}
foreach ($bots as $bot) {
if (empty($bot['PATTERN'])) {
continue;
}
$bot_id = $bot['USER_ID'];
$result_state = $this->interpolateBotVariables(
$bot['PATTERN']['RESULT_STATE'],
$bot['PATTERN']['VARS']);
$_SESSION["CHAT_BOT_STATES"][$bot_id] = (empty($result_state)) ?
"0" : $result_state;
$bot['PATTERN']['VARS']['RESULT_STATE'] = $result_state;
$response = $this->interpolateBotVariables(
$bot['PATTERN']['RESPONSE'],
$bot['PATTERN']['VARS']);
if (!empty($response)) {
$this->addGroupItemWithinLimits($thread_id,
$group_id, $bot_id, $title, $response);
}
}
}
/**
* Determines which, if any, chat bot patterns of chat bot $bot_id are
* applicable to the post $post given the current state of the chat bot
* for the user who made $post.
*
* @param int $bot_id of chat bot to look for applicable pattern
* @param string $post messages to compare against pattern request
* expressions
* @return array $pattern first pattern that matches. Its ['VARS'] field
* will contain any binding values that were made to make the match
*/
private function computeBotPattern($bot_id, $post)
{
$parent = $this->parent;
$bot_model = $parent->model("bot");
$total = 0;
$patterns = $bot_model->getRows(0, C\MAX_BOT_PATTERNS,
$total, [], [$bot_id]);
if (empty($patterns)) {
return [];
}
$post = preg_replace("/" . C\PUNCT . "/", " ", $post);
$post = trim(preg_replace("/\s+/mu", " ", $post));
foreach ($patterns as $pattern) {
$request = $pattern['REQUEST'];
$num_vars = preg_match_all('/\$(\w+)/', $request, $var_matches);
$request = preg_replace('/\$\w+/', "dzqqzd", $request);
$request = preg_replace("/" . C\PUNCT . "/", " ", $request);
$request = trim(preg_replace('/\s+/mu', " ", $request));
$request = preg_quote($request, "/");
$request = preg_replace('/dzqqzd/', "(.+)", $request);
$num_matches = preg_match("/$request/iu", $post, $matches);
if ($num_matches > 0) {
array_shift($matches);
$bot_variables = array_combine($var_matches[1], $matches);
if (!empty($_SESSION['USER_NAME'])) {
$bot_variables['USER_NAME'] = $_SESSION['USER_NAME'];
}
$state = $this->interpolateBotVariables(
$pattern['TRIGGER_STATE'], $bot_variables);
if ($_SESSION["CHAT_BOT_STATES"][$bot_id] == $state) {
$pattern['VARS'] = $bot_variables;
return $pattern;
}
}
}
return [];
}
/**
* Given a string $to_interpolate with variables in it (strings of word
* characters beginning with a $) and given an array of variable =>
* value, replaces the variables in $to_inpolate with their corresponding
* value, returning the resulting string
*
* @param string $to_interpolate string to replace variables in
* @param array $bot_variables sequence of variable => value pairs to
* replace in string.
* @return string $to_interpolate after substitutions have been made
*/
private function interpolateBotVariables($to_interpolate, $bot_variables)
{
foreach ($bot_variables as $var => $value) {
$pattern = '/\$' . preg_quote($var, "/") . '/u';
$to_interpolate = preg_replace($pattern, $value, $to_interpolate);
}
return $to_interpolate;
}
/**
* Used to compute set up a list of feed items to be displayed by the
* groupFeeds activity
*
* @param array &$data associative array of values to be echoed by the view
* this method might add to INCLUDE_SCRIPT formatting scripts such as
* for math which might be used to help draw feed items
* @param array $pages contains feed items corresponding to first join
* dates to various groups. Other feed items will be added to this
* array
* @param int $user_id id of user requesting thread info
* @param array $search_array associative array used to determine where
* clause of what threads, groups, or user posts to get feed items for
* @param int $for_group if this value is set it is a assumed
* that group_items are being returned for only one group
* and that they should be grouped by thread
* @param string $sort either ksort or krsort to specify final sort
* direction of feed items
* @param int $limit index of first feed item out of all applicable items
* to display
* @param int $results_per_page number of feed items to display feed data
* for
*/
private function initializeFeedItems(&$data, $pages, $user_id,
$search_array, $for_group, $sort, &$limit, $results_per_page)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$impression_model = $parent->model("impression");
$user_model = $parent->model("user");
$item_count = $group_model->getGroupItemCount($search_array, $user_id,
$for_group);
$updatable = false;
if (!empty($data["JUST_THREAD"]) && $data["JUST_THREAD"] >= 0
&& empty($data["GROUP_ID"])) {
$display_message = $_SESSION['DISPLAY_MESSAGE'] ?? "";
$is_wiki = !empty($data["HEAD"]['page_type']) &&
$data["HEAD"]['page_type'] == 'page_and_feedback';
if (!$is_wiki &&
$display_message == tl('social_component_comment_added')) {
$limit = floor($item_count / $results_per_page) *
$results_per_page;
}
if ($limit > $item_count - $results_per_page) {
$updatable = true;
}
}
$group_items = $group_model->getGroupItems(0,
$limit + $results_per_page, $search_array, $user_id, $for_group);
$recent_found = false;
$time = time();
$j = 0;
$parser = new WikiParser("", true);
$locale_tag = L\getLocaleTag();
$page = false;
$math = false;
$csrf_token = C\p('CSRF_TOKEN') . "=" .
$this->parent->generateCSRFToken(
$user_id);
if ($for_group) {
$render_engine = $group_model->getRenderEngine($for_group);
$data['RENDER_ENGINE'] = ($render_engine == C\MARKDOWN_ENGINE) ?
"markdown" : "mediawiki";
}
foreach ($group_items as $item) {
$page = $item;
if (C\p('DIFFERENTIAL_PRIVACY') && !empty($page['NUM_VIEWS'])) {
/* Recalculate fuzzy view only if NUM_VIEWS
has been updated since last calculation
*/
if (empty($page['TMP_NUM_VIEWS']) ||
($page['NUM_VIEWS'] != $page['TMP_NUM_VIEWS'])) {
// fuzzify the number of views to add privacy
$fuzzy_views =
$parent->addDifferentialPrivacy($page['NUM_VIEWS']);
$impression_model->updatePrivacyViews($page['ID'],
$page['NUM_VIEWS'], $fuzzy_views);
$page['NUM_VIEWS'] = $fuzzy_views;
} else {
$page['NUM_VIEWS'] = $page['FUZZY_NUM_VIEWS'];
}
}
if (empty($page['NUM_VIEWS'])) {
$page['NUM_VIEWS'] = 0;
}
$page['USER_ICON'] = $user_model->getUserIconUrl($page['USER_ID']);
$page[self::TITLE] = $page['TITLE'];
//functionality for moderation group
if($page['GROUP_ID'] == C\MODERATION_GROUP_ID
&& $page['FLAG'] != C\MODERATION_GENERAL) {
$parent_group_id = $group_model->getParentGroupId($page['ID']);
$page['PARENT_ITEM_ID'] =
$group_model->getParentIdOfParentPost($page['ID']);
$flag_group_name = $group_model->getGroupName($parent_group_id);
$page['IS_MEMBER'] = false;
if ($group_model->checkUserGroup($user_id, $parent_group_id)) {
$page['IS_MEMBER'] = true;
}
$page[self::TITLE] = tl('social_component_flagged_post',
$flag_group_name, $page['TITLE']);
}
unset($page['TITLE']);
$description = $page['DESCRIPTION'];
//start code for sharing crawl mixes
preg_match_all("/\[\[([^\:\n]+)\:mix(\d+)\]\]/", $description,
$matches);
$num_matches = count($matches[0]);
for ($i = 0; $i < $num_matches; $i++) {
$match = preg_quote($matches[0][$i], "@");
$match = str_replace("@","\@", $match);
$replace = "<a href='?c=admin&a=mixCrawls" .
"&arg=importmix&".C\p('CSRF_TOKEN')."=".
$parent->generateCSRFToken($user_id).
"×tamp={$matches[2][$i]}'>".
$matches[1][$i]."</a>";
$description = preg_replace("@".$match."@u", $replace,
$description);
$page["NO_EDIT"] = true;
}
//end code for sharing crawl mixes
$render_engine = $group_model->getRenderEngine($page['GROUP_ID']);
$data['RENDER_ENGINE'] = ($render_engine == C\MARKDOWN_ENGINE) ?
"markdown" : "mediawiki";
$page[self::DESCRIPTION] = $parser->parse($description,
render_engine: $render_engine);
$page[self::DESCRIPTION] =
$group_model->insertResourcesParsePage($item['GROUP_ID'],
"post" . $item['ID'], $locale_tag, $page[self::DESCRIPTION]);
$page[self::DESCRIPTION] = preg_replace('/\[{rtoken}\]/',
$csrf_token, $page[self::DESCRIPTION]);
if (!$math && str_contains($page[self::DESCRIPTION], "`")) {
$math = true;
if (!isset($data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"] = [];
}
$data["INCLUDE_SCRIPTS"][] = "math";
}
unset($page['DESCRIPTION']);
$page['OLD_DESCRIPTION'] = $description;
$page[self::SOURCE_NAME] = $page['GROUP_NAME'];
unset($page['GROUP_NAME']);
if ($item['OWNER_ID'] == $user_id || $user_id == C\ROOT_ID) {
$page['MEMBER_ACCESS'] = C\GROUP_READ_WIKI;
}
if ($updatable &&
!$recent_found && !$math && $time - $item["PUBDATE"] <
5 * C\ONE_MINUTE) {
$recent_found = true;
$data['SCRIPT'] .= 'window.onload = doUpdate;';
}
$pages[$item["PUBDATE"] . sprintf("%04d", $j)] = $page;
$j++;
}
if ($pages) {
$sort($pages);
$pages = array_slice($pages, $limit, $results_per_page);
}
return [$item_count, $pages];
}
/**
* Renders the Mail activity. When MAIL_MODE is 'disabled' this
* redirects back to manage account with a "mail disabled" notice.
* Otherwise it dispatches on $_REQUEST['arg'] to one of the
* external-IMAP account handlers (add / edit / delete) or the
* inbox handlers (list / view a message), and falls back to the
* default content pane (an empty "select an account" prompt)
* when no arg is supplied. Posts to add/edit/delete handlers
* loop back through the default content pane via
* redirectWithMessage so the URL stays clean and the success
* notice can render.
*
* @return mixed associative $data array prepared for MailElement,
* or the result of redirectWithMessage if mail is disabled,
* the user is not signed in, or a write operation just
* completed
*/
public function userMail()
{
$parent = $this->parent;
$mail_mode = C\nsdefined("MAIL_MODE") ? C\p('MAIL_MODE') : 'disabled';
if ($mail_mode === 'disabled') {
return $parent->redirectWithMessage(
tl("social_component_mail_disabled"));
}
if (!isset($_SESSION['USER_ID'])) {
$_REQUEST = ['c' => "admin", 'a' => '', C\p('CSRF_TOKEN') => ''];
return $parent->redirectWithMessage(
tl("social_component_login_first"));
}
$external_enabled = in_array($mail_mode,
['external_mail', 'both']);
$mailsite_enabled = in_array($mail_mode,
['mailsite', 'both']);
/* Message, folder and compose actions act on whichever
mailbox the account_id names, including the synthetic
MailSite account (id 0), so they are available whenever
either mail kind is on. Only account-set management
(add/edit/delete/clone/rename/reorder) stays external
only, since the MailSite account has no DB row. */
$mailbox_enabled = $external_enabled || $mailsite_enabled;
$arg = $_REQUEST['arg'] ?? '';
if ($arg === 'addAccount' && $external_enabled) {
return $this->userMailAddAccount();
}
if ($arg === 'editAccount' && $external_enabled) {
return $this->userMailEditAccount();
}
if ($arg === 'deleteAccount' && $external_enabled) {
return $this->userMailDeleteAccount();
}
if ($arg === 'startClone' && $external_enabled) {
return $this->userMailStartClone();
}
if ($arg === 'cloneStatus' && $external_enabled) {
return $this->userMailCloneStatus();
}
if ($arg === 'cancelClone' && $external_enabled) {
$data = $this->userMailBaseData();
$job_id = $parent->clean(
$_REQUEST['job_id'] ?? null, 'int', 0);
if ($job_id > 0) {
$clone_model = $parent->model("MailClone");
$clone_model->cancelJob($job_id, $data["USER_ID"]);
}
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_cancelled'));
}
if ($arg === 'retryClone' && $external_enabled) {
$data = $this->userMailBaseData();
$job_id = $parent->clean(
$_REQUEST['job_id'] ?? null, 'int', 0);
if ($job_id > 0) {
$clone_model = $parent->model("MailClone");
$clone_model->retryJob($job_id, $data["USER_ID"]);
}
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_retried'));
}
if (in_array($arg, ['downloadAttachment',
'downloadAllAttachments'])
&& $mailbox_enabled) {
return ($arg === 'downloadAllAttachments') ?
$this->userMailDownloadAllAttachments() :
$this->userMailDownloadAttachment();
}
if ($arg === 'viewMessage' && $mailbox_enabled) {
return $this->userMailViewMessage();
}
if ($arg === 'trustSender' && $mailsite_enabled) {
return $this->userMailTrustSender();
}
if ($arg === 'untrustSender' && $mailsite_enabled) {
return $this->userMailUntrustSender();
}
if ($arg === 'editMailsite' && $mailsite_enabled) {
return $this->userMailEditMailsite();
}
if ($arg === 'aliasAction' && $mailsite_enabled) {
return $this->userMailAliasAction();
}
if ($arg === 'listMessages' && $mailbox_enabled) {
return $this->userMailListMessages();
}
if ($arg === 'bulkAction' && $mailbox_enabled) {
return $this->userMailBulkAction();
}
if ($arg === 'toggleFlag' && $mailbox_enabled) {
return $this->userMailToggleFlag();
}
if ($arg === 'folderAction' && $mailbox_enabled) {
return $this->userMailFolderAction();
}
if ($arg === 'accountRename' && $external_enabled) {
return $this->userMailAccountRename();
}
if ($arg === 'loadMessages' && $mailbox_enabled) {
return $this->userMailLoadMessages();
}
if ($arg === 'toggleAccount' && $mailbox_enabled) {
return $this->userMailToggleAccount();
}
if ($arg === 'reorderAccounts' && $external_enabled) {
return $this->userMailReorderAccounts();
}
if ($arg === 'toggleFolderExpanded' && $mailbox_enabled) {
return $this->userMailToggleFolderExpanded();
}
if ($arg === 'listFolders' && $mailbox_enabled) {
return $this->userMailListFolders();
}
if ($arg === 'compose' && $mailbox_enabled) {
return $this->userMailCompose();
}
if ($arg === 'sendMessage' && $mailbox_enabled) {
return $this->userMailSendMessage();
}
if ($arg === 'scheduledList' && $mailbox_enabled) {
return $this->userMailScheduledList();
}
if ($arg === 'scheduledCancel' && $mailbox_enabled) {
$data = $this->userMailBaseData();
$scheduled_id = $parent->clean(
$_REQUEST['scheduled_id'] ?? null, 'int', 0);
if ($scheduled_id > 0) {
$scheduled_model = $parent->model("MailScheduled");
$scheduled_model->deleteMessage($scheduled_id,
$data["USER_ID"]);
}
$_REQUEST['arg'] = 'scheduledList';
return $parent->redirectWithMessage(
tl("mail_element_scheduled_cancelled"),
['arg', 'account_id']);
}
/* explicit deselect (clicking outside any account on
desktop, or the back button on mobile): forget the
remembered active account and show the placeholder. */
if (!empty($_REQUEST['deselect'])) {
if (isset($_SESSION['MAIL_ACTIVE_ACCOUNT'])) {
unset($_SESSION['MAIL_ACTIVE_ACCOUNT']);
$this->parent->model("user")->setUserSession(
$_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID,
$_SESSION);
}
}
/* no explicit arg: if the session remembers an active
account from a prior visit, jump straight to its inbox
rather than the empty selectAccount placeholder. The
MailSite synthetic account has id 0, so we use
array_key_exists rather than ?? to tell "remembered as
MailSite" apart from "never remembered". Real accounts
are verified via getAccount so a stale or forged session
pointer cannot exfiltrate another user's account.
MailSite has no DB row; trust MAILSITE_ENABLED. Skipped
on mobile, where the side list and the inbox are not both
on screen: auto-jumping there would strand the user in an
inbox with no visible way back to the account list. */
if (array_key_exists('MAIL_ACTIVE_ACCOUNT', $_SESSION) &&
$mailbox_enabled && empty($_SERVER['MOBILE'])) {
$remembered = (int) $_SESSION['MAIL_ACTIVE_ACCOUNT'];
$data = $this->userMailBaseData();
$resolved = false;
if ($remembered === self::MAILSITE_ACCOUNT_ID) {
$resolved = !empty($data['MAILSITE_ENABLED']);
} else if ($remembered > 0) {
$account_model =
$this->parent->model("MailAccount");
$account = $account_model->getAccount($remembered,
$data["USER_ID"]);
$resolved = (bool) $account;
}
if ($resolved) {
$_REQUEST['arg'] = 'listMessages';
$_REQUEST['account_id'] = $remembered;
return $this->userMailListMessages();
}
}
$data = $this->userMailBaseData();
$data["CONTENT_PANE"] = "selectAccount";
return $data;
}
/**
* Handles GET (render Add Account form) and POST (insert a new
* account row) for the external-IMAP add-account flow.
*
* @return mixed $data for MailElement on GET, or
* redirectWithMessage on POST success
*/
protected function userMailAddAccount()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$data["CONTENT_PANE"] = "addAccount";
$data["FORM_VALUES"] = [
"DISPLAY_NAME" => "",
"PROVIDER" => "",
"HOST" => "",
"PORT" => 993,
"USERNAME" => "",
"TLS_MODE" => "imaps",
"ALLOW_SELF_SIGNED" => 0,
"SMTP_HOST" => "",
"SMTP_PORT" => 587,
"SMTP_USERNAME" => "",
"SMTP_TLS_MODE" => "starttls",
];
if (($_REQUEST['save'] ?? '') === 'save') {
$fields = $this->userMailParseAccountFields();
$errors = $this->userMailValidateAccountFields($fields, true);
if (empty($errors)) {
$account_model = $parent->model("MailAccount");
$account_model->addAccount($data["USER_ID"], $fields);
return $parent->redirectWithMessage(
tl("social_component_mail_account_saved"));
}
$data["FORM_VALUES"] = $fields;
$data["FORM_ERRORS"] = $errors;
}
$this->userMailCleanAccountFormValues($data);
return $data;
}
/**
* Handles GET (render Edit Account form) and POST (update the
* row) for the external-IMAP edit-account flow. The password
* field is rendered empty; submitting it empty preserves the
* stored ciphertext, submitting it non-empty replaces it.
*
* @return mixed $data for MailElement on GET, or
* redirectWithMessage on POST success
*/
protected function userMailEditAccount()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
$account_model = $parent->model("MailAccount");
$existing = $account_model->getAccount($account_id,
$data["USER_ID"]);
if (!$existing) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$data["CONTENT_PANE"] = "editAccount";
$data["ACCOUNT_ID"] = $account_id;
/*
When a credential is already stored for a section, seed
the password field with the masked sentinel so the edit
form shows a fixed run of dots rather than an empty box.
The real password is never decrypted into the form; the
sentinel round-trips back as "leave unchanged".
*/
$imap_password = empty($existing["HAS_PASSWORD"]) ? "" :
MailAccountModel::PASSWORD_SENTINEL;
$smtp_password = empty($existing["HAS_SMTP_PASSWORD"]) ? "" :
MailAccountModel::PASSWORD_SENTINEL;
$data["FORM_VALUES"] = [
"DISPLAY_NAME" => $existing["DISPLAY_NAME"],
"PROVIDER" => $existing["PROVIDER"] ?? '',
"HOST" => $existing["HOST"],
"PORT" => $existing["PORT"],
"USERNAME" => $existing["USERNAME"],
"PASSWORD" => $imap_password,
"TLS_MODE" => $existing["TLS_MODE"],
"ALLOW_SELF_SIGNED" => $existing["ALLOW_SELF_SIGNED"],
"SMTP_HOST" => $existing["SMTP_HOST"],
"SMTP_PORT" => $existing["SMTP_PORT"],
"SMTP_USERNAME" => $existing["SMTP_USERNAME"],
"SMTP_PASSWORD" => $smtp_password,
"SMTP_TLS_MODE" => $existing["SMTP_TLS_MODE"],
];
if (($_REQUEST['save'] ?? '') === 'save') {
$fields = $this->userMailParseAccountFields();
$errors = $this->userMailValidateAccountFields($fields, false);
if (empty($errors)) {
$leave_password = empty($fields['PASSWORD']);
$leave_smtp_password = empty($fields['SMTP_PASSWORD']);
$account_model->updateAccount($account_id,
$data["USER_ID"], $fields, $leave_password,
$leave_smtp_password);
return $parent->redirectWithMessage(
tl("social_component_mail_account_saved"));
}
$data["FORM_VALUES"] = $fields;
/*
userMailParseAccountFields() normalized an untouched
password field to "". On an error re-render, restore
the masked sentinel for any section that still has a
stored credential so the dots survive the bounce
rather than the field going blank.
*/
if ($fields['PASSWORD'] === '' &&
!empty($existing["HAS_PASSWORD"])) {
$data["FORM_VALUES"]['PASSWORD'] =
MailAccountModel::PASSWORD_SENTINEL;
}
if ($fields['SMTP_PASSWORD'] === '' &&
!empty($existing["HAS_SMTP_PASSWORD"])) {
$data["FORM_VALUES"]['SMTP_PASSWORD'] =
MailAccountModel::PASSWORD_SENTINEL;
}
$data["FORM_ERRORS"] = $errors;
}
/* Surface the active clone-job row (if any) and the
MailClone enabled-toggle so the view can render the
Clone button in one of three states: active button,
greyed-out (toggle disabled), and hidden (no MailSite
mode). The MachineModel job-status read is a small
file_get_contents so doing it here is cheap. */
$machine_model = $parent->model("Machine");
$data["MAILCLONE_ENABLED"] =
(bool) $machine_model->getJobStatus('MailClone');
/* Cheap glob over the schedules lock-files; surfaces
whether MediaUpdater and MailServer are live so the
clone status banner can show a red warning if either
is down. Re-checked on every status-poll by the JS
script. */
$daemons = L\CrawlDaemon::statuses();
$data["MEDIA_UPDATER_RUNNING"] =
!empty($daemons['MediaUpdater']);
$data["MAIL_SERVER_RUNNING"] =
!empty($daemons['MailServer']);
/* pane=clone on the editAccount URL switches the view
from the settings form to the clone form. The link sits
below the Name field on the settings form; the
closeHelper [x] rendered on the clone form points back
at the same URL without pane=clone. */
$data["CLONE_PANE_ACTIVE"] =
(($_REQUEST['pane'] ?? '') === 'clone');
$this->userMailCleanAccountFormValues($data);
return $data;
}
/**
* HTML-cleans the account-form string fields that
* renderAccountForm echoes into input value attributes, so the
* view can emit them raw. Only the free-text fields are
* touched; numeric (PORT) and enum (TLS_MODE) fields are echoed
* with an int cast or fixed set in the view and need no
* cleaning, and password fields carry only the empty string or
* the masked sentinel.
*
* @param array $data the mail $data array; $data['FORM_VALUES']
* is cleaned in place when present
*/
protected function userMailCleanAccountFormValues(&$data)
{
if (empty($data["FORM_VALUES"])) {
return;
}
$parent = $this->parent;
$clean_fields = ['DISPLAY_NAME', 'HOST', 'USERNAME',
'SMTP_HOST', 'SMTP_USERNAME'];
foreach ($clean_fields as $form_field) {
if (isset($data["FORM_VALUES"][$form_field])) {
$data["FORM_VALUES"][$form_field] = $parent->clean(
$data["FORM_VALUES"][$form_field], "string", "");
}
}
}
/**
* Submits a new MAIL_CLONE_JOB row. The clone-job copies
* messages from an external IMAP account the user owns into
* the user's local MailSite mailbox. Validates ownership of
* the source account, presence of MailSite (mode=mailsite or
* both), single-job-per-user, and MODE token (add or wipe).
* On success, inserts a pending row -- the MediaUpdater's
* MailCloneJob tick picks it up on the next pass and starts
* actually copying messages.
*
* @return mixed redirectWithMessage result
*/
protected function userMailStartClone()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
if (empty($data["MAILSITE_ENABLED"])) {
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_mailsite_required'));
}
$machine_model = $parent->model("Machine");
if (!$machine_model->getJobStatus('MailClone')) {
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_job_disabled'));
}
/* MediaUpdater and MailServer must both be live for the
clone to make progress: MediaUpdater dispatches the
tick, MailServer hosts the destination MailSite that
appendMessage talks to. CrawlDaemon::statuses globs
the schedules lock-file directory; the cost is one
glob + a stat per file (cheap; an inflight worker
refreshes its lock-file mtime each pass). When either
is down we refuse the submission rather than insert a
row that will sit pending until someone notices. */
$daemons = L\CrawlDaemon::statuses();
if (empty($daemons['MediaUpdater'])) {
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_updater_down'));
}
if (empty($daemons['MailServer'])) {
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_server_down'));
}
$account_model = $parent->model("MailAccount");
$existing = $account_model->getAccount($account_id,
$data["USER_ID"]);
if (!$existing) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$clone_mode = $_REQUEST['clone_mode'] ?? 'add';
if (!in_array($clone_mode,
['add', 'wipe', 'dry_run'], true)) {
$clone_mode = 'add';
}
/* The mode form has three mutually-exclusive radios:
add, wipe, and dry_run. Internally we store MODE as
either 'add' or 'wipe' (wipe + dry_run combined would
be incoherent and is unreachable via this UI), and
split out dry_run as the separate persistence flag the
MailCloneJob already understands. */
$dry_run = ($clone_mode === 'dry_run');
$mode = ($clone_mode === 'wipe') ? 'wipe' : 'add';
/* Newest-N-per-folder cap from the dropdown. Only the
offered values are honored -- 0 meaning ALL (no cap) --
and anything else (or a missing field) falls back to 0
so a tampered or absent value cannot smuggle in an odd
limit. */
$folder_cap = (int) ($_REQUEST['folder_cap'] ?? 0);
if (!in_array($folder_cap,
[0, 1000, 5000, 10000, 50000], true)) {
$folder_cap = 0;
}
$clone_model = $parent->model("MailClone");
$existing_job = $clone_model->activeJobForUser(
$data["USER_ID"]);
if ($existing_job) {
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_already_running'));
}
$destination_user = $data["USER_NAME"];
$clone_model->insertJob($data["USER_ID"], $account_id,
$destination_user, $mode, $dry_run, $folder_cap);
/* Redirect back to the editAccount + pane=clone URL the
user came from. The view sees the now-active job
(populated in userMailBaseData) and renders the
status banner instead of the form. The redirect
helper preserves only fields named in copy_fields
plus a default set (c, a, CSRF token, ...); pre-set
arg/account_id/pane on $_REQUEST so they survive. */
$_REQUEST['arg'] = 'editAccount';
$_REQUEST['account_id'] = $account_id;
$_REQUEST['pane'] = 'clone';
return $parent->redirectWithMessage(tl(
'social_component_mail_clone_started'),
['arg', 'account_id', 'pane']);
}
/**
* Returns the current user's active clone-job row as JSON,
* or null when no job is pending or running. Polled by the
* status-banner JS every five seconds; the response shape
* matches the banner's rendering needs (STATUS, IMPORTED,
* SKIPPED, FAILED, CURRENT_FOLDER, LAST_ERROR) without
* leaking back-end internals (full row keys, ownership,
* raw timestamps).
*/
protected function userMailCloneStatus()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$clone_model = $parent->model("MailClone");
$row = $clone_model->activeJobForUser($data["USER_ID"]);
$parent->web_site->header(
'Content-Type: application/json');
$daemons = L\CrawlDaemon::statuses();
$payload = [
'active' => (bool) $row,
'media_updater_running' =>
!empty($daemons['MediaUpdater']),
'mail_server_running' =>
!empty($daemons['MailServer']),
];
if ($row) {
$payload['id'] = (int) $row['ID'];
$payload['status'] = $row['STATUS'];
$payload['mode'] = $row['MODE'];
$payload['current_folder'] = $row['CURRENT_FOLDER'];
$payload['imported'] = (int) $row['IMPORTED'];
$payload['skipped'] = (int) $row['SKIPPED'];
/* FAILED is sourced from MAIL_CLONE_ERROR row count
(unresolved only) rather than the persisted
counter so it stays consistent with the errors
list shown beneath it; the persisted FAILED is
also kept in sync by flushBatch but the model
query is the authoritative read. */
$payload['failed'] = $clone_model
->unresolvedErrorCount((int) $row['ID']);
/* Most-recent errors, newest first. The UI groups
unresolved vs resolved client-side and shows a
toggle to expand the full list. Cap at 50 to
keep the JSON payload small; older errors are
still in the DB if a future debug view wants
them. */
$error_rows = $clone_model->recentErrors(
(int) $row['ID'], 50);
$payload['errors'] = [];
foreach ($error_rows as $error_row) {
$payload['errors'][] = [
'folder' => $error_row['FOLDER'],
'uidvalidity' =>
(int) $error_row['SRC_UIDVALIDITY'],
'uid' => (int) $error_row['SRC_UID'],
'kind' => $error_row['ERROR_KIND'],
'message' => $error_row['MESSAGE'],
'occurred_at' =>
(int) $error_row['OCCURRED_AT'],
'resolved_at' =>
(int) $error_row['RESOLVED_AT'],
'resolved_via' =>
$error_row['RESOLVED_VIA'] ?? '',
];
}
}
e(json_encode($payload));
\seekquarry\atto\webExit();
}
/**
* Deletes an account row owned by the current user. Returns
* the default content pane via redirectWithMessage so the
* success notice shows.
*
* @return mixed redirectWithMessage result
*/
protected function userMailDeleteAccount()
{
$parent = $this->parent;
$user_id = $_SESSION['USER_ID'];
$account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
if ($account_id > 0) {
$account_model = $parent->model("MailAccount");
$account_model->deleteAccount($account_id, $user_id);
/* drop per-account session caches for this account
so neither the folder list nor the active-account
pointer survive the delete. */
$parent->model("Mail")->invalidateFolders($account_id);
unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
unset($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]);
unset($_SESSION["MAIL_ACCOUNT_COLLAPSED"][$account_id]);
if ((int) ($_SESSION['MAIL_ACTIVE_ACCOUNT'] ?? 0) ===
$account_id) {
unset($_SESSION['MAIL_ACTIVE_ACCOUNT']);
}
$parent->model("user")->setUserSession(
$_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID,
$_SESSION);
}
return $parent->redirectWithMessage(
tl("social_component_mail_account_deleted"));
}
/**
* Connects to the account's IMAP server, runs LOGIN + SELECT +
* FETCH 1:N (envelope only, no body) and prepares $data for the
* inbox listing view. Errors during connect / login surface as
* a flash message and the user is bounced back to the default
* content pane.
*
* @return mixed $data for MailElement, or redirectWithMessage
* on connect failure
*/
protected function userMailListMessages()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
} catch (ML\MailBackendException $exception) {
return $parent->redirectWithMessage(
$exception->getMessage());
}
/* remember the active account in the session so the next
visit to user_mail (no explicit account_id, e.g. from
the main nav after a detour through another activity)
re-opens this inbox instead of the empty placeholder.
the DB-backed user session is flushed only when the
pointer actually changes, so this handler doesn't pay
a write cost on every inbox refresh. */
$previous_active =
(int) ($_SESSION['MAIL_ACTIVE_ACCOUNT'] ?? 0);
$_SESSION['MAIL_ACTIVE_ACCOUNT'] = $account_id;
if ($previous_active !== $account_id) {
$parent->model("user")->setUserSession(
$_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID,
$_SESSION);
}
$data["CONTENT_PANE"] = "inbox";
$data["ACCOUNT_ID"] = $account_id;
$data["ACCOUNT_DISPLAY_NAME"] = $parent->clean(
$backend->displayName(), "string", "");
$requested_folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
$default_folder = $backend->defaultFolder();
$folder = $requested_folder !== "" ? $requested_folder :
$default_folder;
$data["ACTIVE_FOLDER"] = $folder;
$data["FOLDERS"] = [];
try {
$mail_model = $parent->model("Mail");
$cached_folders = $mail_model->cachedFolders($account_id);
if ($cached_folders !== null) {
$data["FOLDERS"] = $cached_folders;
$backend->annotateUnreadCounts($data["FOLDERS"]);
} else {
$data["FOLDERS"] = $backend->listFolders();
$backend->annotateUnreadCounts($data["FOLDERS"]);
$mail_model->cacheFolders($account_id,
$data["FOLDERS"]);
}
$this->userMailDecorateFolderDisplay($data);
$filter = $this->userMailGetFilter();
$unread_only = $this->userMailGetUnreadOnly();
$flagged_only = $this->userMailGetFlaggedOnly();
$sort = $this->userMailGetSort($account_id, $folder);
$result = $backend->listMessages($folder, [
'window' => self::MAILSITE_WINDOW,
'sort' => $sort,
'filter' => $filter,
'unread_only' => $unread_only,
'flagged_only' => $flagged_only,
'unreadable_subject' =>
tl('mail_element_unreadable_message'),
'sort_supported' =>
$mail_model->sortSupportHint($account_id),
'cursor' => null]);
$mail_model->cacheSortSupport($account_id,
$result['sort_supported']);
$data["MESSAGES"] =
$this->userMailCleanEnvelopes($result['messages']);
$data["TOTAL_MESSAGES"] = $result['total'];
$data["SORT"] = $result['sort'];
$data["SORT_SUPPORTED"] = $result['sort_supported'];
$data["SORT_OVERSIZED"] =
!empty($result['sort_oversized']);
$data["SORT_DATE_ONLY"] =
!empty($result['sort_date_only']);
$data["SORT_MODE"] = $result['mode'];
$next_cursor = $result['next_cursor'];
/* OLDEST_SEQ drives the range-mode "load more" (the next
page is the messages below this sequence); SORT_OFFSET
drives the sorted-mode one (continue this many rows
into the ordered list). The other is zero. */
$data["OLDEST_SEQ"] = ($result['mode'] === 'range' &&
$next_cursor) ? (int) $next_cursor['before'] : 0;
$data["SORT_OFFSET"] = ($result['mode'] === 'sorted' &&
$next_cursor) ? (int) $next_cursor['offset'] :
count($result['messages']);
$mail_model->cacheSequence($account_id, $folder,
$filter, $sort, $unread_only, $flagged_only,
$next_cursor);
$data["FILTER"] = $parent->clean($filter, "string", "");
$data["UNREAD_ONLY"] = $unread_only;
$data["FLAGGED_ONLY"] = $flagged_only;
} catch (ML\MailBackendException $exception) {
$data["IMAP_ERROR"] = $parent->clean(
$exception->getMessage(), "string", "");
$data["MESSAGES"] = [];
} finally {
$backend->close();
}
return $data;
}
/**
* Infinite-scroll endpoint for the inbox listing. Fetches the
* window of message envelopes immediately older than the
* sequence number the client already has, renders them to an
* HTML fragment using the same row markup as the initial page,
* and returns a small JSON payload the mailmessages.js handler
* appends to the list. Always emits JSON and exits; it never
* returns $data for a view.
*
* @return void emits a JSON response and exits
*/
protected function userMailLoadMessages()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
/* this handler emits JSON and exits, so it never returns to
GroupController, which is what injects the CSRF token on
the normal page-render path; set it here so the message
links built by MailElement::renderInboxRow are valid. */
$data[C\p('CSRF_TOKEN')] = $parent->generateCSRFToken(
$data["USER_ID"]);
$account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
$mode = $parent->clean($_REQUEST['mode'] ?? null,
['range', 'sorted'], 'range');
$before_seq = $parent->clean($_REQUEST['before_seq'] ?? null, 'int', 0);
$offset = $parent->clean($_REQUEST['offset'] ?? null, 'int', 0);
$limit = $parent->clean($_REQUEST['limit'] ?? null, 'int', 25);
if ($limit < 1 || $limit > 100) {
$limit = 25;
}
$parent->web_site->header('Content-Type: application/json');
$valid = ($mode === 'range' ? $before_seq >= 2 :
$offset >= 1);
if (!$valid) {
e(json_encode(['error' => 'invalid_request',
'html' => '', 'message_count' => 0,
'has_more' => false]));
\seekquarry\atto\webExit();
}
$folder = $parent->clean($_REQUEST['folder'] ?? null,
'imap_arg', '');
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
} catch (ML\MailBackendException $exception) {
e(json_encode(['error' => 'invalid_request',
'html' => '', 'message_count' => 0,
'has_more' => false]));
\seekquarry\atto\webExit();
}
if ($folder === "") {
$folder = $backend->defaultFolder();
}
/* renderInboxRow builds per-message viewMessage links and
wants to thread the active folder through them so the
Back link returns to the same folder. */
$data["ACCOUNT_ID"] = $account_id;
$data["ACTIVE_FOLDER"] = $folder;
try {
$mail_model = $this->parent->model("Mail");
$sort = $this->userMailGetSort($account_id, $folder);
$filter = $this->userMailGetFilter();
$unread_only = $this->userMailGetUnreadOnly();
$flagged_only = $this->userMailGetFlaggedOnly();
if ($mode === 'sorted') {
$signature = ImapListing::sortSignature($sort,
$unread_only, $flagged_only);
$sequence = $mail_model->cachedSequence($account_id,
$folder, $filter, $signature);
$cursor = ['mode' => 'sorted', 'offset' => $offset,
'sequence' => $sequence];
} else {
$cursor = ['mode' => 'range', 'before' => $before_seq];
}
$result = $backend->listMessages($folder, [
'window' => $limit,
'sort' => $sort,
'filter' => $filter,
'unread_only' => $unread_only,
'flagged_only' => $flagged_only,
'unreadable_subject' =>
tl('mail_element_unreadable_message'),
'sort_supported' =>
$mail_model->sortSupportHint($account_id),
'cursor' => $cursor]);
$messages =
$this->userMailCleanEnvelopes($result['messages']);
$has_more = $result['has_more'];
$next_cursor = $result['next_cursor'];
$next_oldest_seq = ($result['mode'] === 'range' &&
$next_cursor) ? (int) $next_cursor['before'] : 0;
$next_offset = ($result['mode'] === 'sorted' &&
$next_cursor) ? (int) $next_cursor['offset'] : 0;
} catch (\Exception $exception) {
$backend->close();
e(json_encode(['error' => 'imap_error',
'html' => '', 'message_count' => 0,
'has_more' => false]));
\seekquarry\atto\webExit();
}
$backend->close();
$html = "";
foreach ($messages as $msg) {
$html .= MailElement::renderInboxRow($data, $msg);
}
e(json_encode([
'html' => $html,
'message_count' => count($messages),
'has_more' => $has_more,
'oldest_seq' => $next_oldest_seq,
'offset' => $next_offset
]));
\seekquarry\atto\webExit();
}
/**
* Records whether an account's folder list is collapsed or
* expanded in the account pane. Emits a small JSON
* acknowledgement and exits; the disclosure triangle in
* MailElement is toggled on the client, and this only persists
* the new state in the session so it survives later page
* loads. The account is verified to belong to the signed-in
* user before anything is stored.
*
* @return void this handler emits JSON and exits
*/
protected function userMailToggleAccount()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$parent->web_site->header('Content-Type: application/json');
$account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
$collapsed = $parent->clean($_REQUEST['collapsed'] ?? null, 'bool');
if (!($data["MAILSITE_ENABLED"] &&
$account_id === self::MAILSITE_ACCOUNT_ID)) {
$account_model = $parent->model("MailAccount");
$account = $account_model->getAccount($account_id,
$data["USER_ID"]);
if (!$account) {
e(json_encode(['error' => 'invalid_request']));
\seekquarry\atto\webExit();
}
}
if (empty($_SESSION["MAIL_ACCOUNT_COLLAPSED"])) {
$_SESSION["MAIL_ACCOUNT_COLLAPSED"] = [];
}
$_SESSION["MAIL_ACCOUNT_COLLAPSED"][$account_id] = $collapsed;
$parent->model("user")->setUserSession(
$_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID, $_SESSION);
e(json_encode(['status' => 'OK',
'account_id' => $account_id, 'collapsed' => $collapsed]));
\seekquarry\atto\webExit();
}
/**
* Persists the user's drag-chosen ordering of their mail
* accounts. Expects an 'order' request parameter: a
* comma-separated list of account ids in the desired display
* order. The synthetic local MailSite account is pinned and not
* reorderable, so its id is dropped from the list before the
* order is stored. The model scopes every update to the
* signed-in user, so ids belonging to another user (or the
* MailSite id) change nothing. Returns a small JSON status.
*/
protected function userMailReorderAccounts()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$parent->web_site->header('Content-Type: application/json');
$order_raw = $parent->clean($_REQUEST['order'] ?? null,
'string', '');
$ordered_ids = [];
foreach (explode(',', $order_raw) as $piece) {
$piece = trim($piece);
if ($piece === '' || !ctype_digit($piece)) {
continue;
}
$account_id = (int) $piece;
if ($account_id === self::MAILSITE_ACCOUNT_ID) {
continue;
}
$ordered_ids[] = $account_id;
}
if (empty($ordered_ids)) {
e(json_encode(['error' => 'invalid_request']));
\seekquarry\atto\webExit();
}
$account_model = $parent->model("MailAccount");
$account_model->updateAccountOrder($data["USER_ID"],
$ordered_ids);
e(json_encode(['status' => 'OK',
'order' => $ordered_ids]));
\seekquarry\atto\webExit();
}
/**
* Persists a per-folder expand/collapse state in the session
* so the side-panel disclosure arrows render the same way on
* the next page load. Expected request parameters: account_id
* (int), folder (string, the folder full path), expanded
* (bool). Verifies the account belongs to the signed-in user
* before storing.
*
* The session map is sparse: only folders that the user has
* explicitly expanded are stored. When a folder is collapsed
* its entry is removed so the map does not grow indefinitely
* with stale "explicitly collapsed" entries -- absent means
* collapsed (the page-load default).
*
* @return void this handler emits JSON and exits
*/
protected function userMailToggleFolderExpanded()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$parent->web_site->header('Content-Type: application/json');
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
$expanded = (bool) $parent->clean(
$_REQUEST['expanded'] ?? null, 'bool', false);
if (!($data["MAILSITE_ENABLED"] &&
$account_id === self::MAILSITE_ACCOUNT_ID)) {
$account_model = $parent->model("MailAccount");
$account = $account_model->getAccount($account_id,
$data["USER_ID"]);
if (!$account || $folder === '') {
e(json_encode(['error' => 'invalid_request']));
\seekquarry\atto\webExit();
}
} else if ($folder === '') {
e(json_encode(['error' => 'invalid_request']));
\seekquarry\atto\webExit();
}
if (empty($_SESSION["MAIL_FOLDER_EXPANDED"])) {
$_SESSION["MAIL_FOLDER_EXPANDED"] = [];
}
if (empty($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id])) {
$_SESSION["MAIL_FOLDER_EXPANDED"][$account_id] = [];
}
if ($expanded) {
$_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]
[$folder] = true;
} else {
unset($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]
[$folder]);
}
$parent->model("user")->setUserSession(
$_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID, $_SESSION);
e(json_encode(['status' => 'OK',
'account_id' => $account_id,
'folder' => $folder,
'expanded' => $expanded]));
\seekquarry\atto\webExit();
}
/**
* Fetches the IMAP folder list for an account on demand and
* returns its rendered HTML rows as JSON, for the JS lazy-
* load that fires when a user first expands an account in
* the side column. Verifies account ownership, opens IMAP,
* runs LIST only (no SELECT, no FETCH) so the call is cheap,
* parses and sorts the folders, caches them in the session
* so subsequent page renders see them, then renders the
* folder items the same way renderUserAccounts does and
* returns the HTML fragment. On any IMAP error the response
* carries status 'error' and an empty html string so the
* caller can leave the disclosure empty without special-
* casing the failure.
*
* @return void this handler emits JSON and exits
*/
protected function userMailListFolders()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
/* this handler emits JSON and exits, so it never returns
to GroupController, which is what injects the CSRF
token on the normal page-render path; set it here so
the folder links built by renderFolderItem are valid. */
$data[C\p('CSRF_TOKEN')] = $parent->generateCSRFToken(
$data["USER_ID"]);
$parent->web_site->header('Content-Type: application/json');
$account_id = $parent->clean($_REQUEST['account_id'] ?? null, 'int', 0);
$folders = [];
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
$mail_model = $this->parent->model("Mail");
$cached_folders = $mail_model->cachedFolders($account_id);
if ($cached_folders !== null) {
$folders = $cached_folders;
} else {
$folders = $backend->listFolders();
$backend->annotateUnreadCounts($folders);
$mail_model->cacheFolders($account_id, $folders);
}
} catch (ML\MailBackendException $exception) {
echo json_encode(['status' => 'error',
'account_id' => $account_id, 'html' => '']);
\seekquarry\atto\webExit();
} finally {
if ($backend !== null) {
$backend->close();
}
}
$data["ACCOUNT_ID"] = $account_id;
$data["FOLDERS"] = $folders;
$this->userMailDecorateFolderDisplay($data);
ob_start();
MailElement::renderFolderTree($data, $account_id,
$data["FOLDERS"], false, '');
$html = ob_get_clean();
echo json_encode(['status' => 'OK',
'account_id' => $account_id, 'html' => $html]);
\seekquarry\atto\webExit();
}
/**
* Renders the Compose Message form. On a fresh GET the form is
* blank with To focused. On a re-render after a failed
* sendMessage POST (validation error or SMTP failure) the form
* is repopulated with the user's typed values and a per-field
* or top-level error message is shown so they can fix and
* retry without losing their work.
*
* @return array $data for MailElement with CONTENT_PANE set to
* "compose"
*/
protected function userMailCompose()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$account = null;
if ((int) $account_id === self::MAILSITE_ACCOUNT_ID) {
if (empty($data['MAILSITE_ENABLED'])) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
} else {
$account_model = $parent->model("MailAccount");
$account = $account_model->getAccount($account_id,
$data["USER_ID"]);
if (!$account) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
}
/* the userMailBaseData ACCOUNTS list carries every
sender identity available to this user (MailSite if
enabled, plus every IMAP row); look up the display
name there so the compose pane and the from-account
dropdown read from one source of truth. */
$display_name = '';
foreach ($data['ACCOUNTS'] as $entry) {
if ((int) $entry['ID'] === (int) $account_id) {
$display_name = $entry['DISPLAY_NAME'] ?? '';
break;
}
}
$data["CONTENT_PANE"] = "compose";
$data["ACCOUNT_ID"] = $account_id;
$data["ACCOUNT_DISPLAY_NAME"] = $display_name;
$data["FROM_DISPLAY"] = ($account !== null) ?
$this->userMailFormatFrom($account) : $display_name;
$data["FROM_OPTIONS"] = $this->userMailFromOptions($data);
$data["FROM_VALUE"] = $account_id . '|' .
$parent->clean($data["FROM_DISPLAY"], "string", "");
$form_values = [
"TO" => "",
"CC" => "",
"BCC" => "",
"SUBJECT" => "",
"BODY" => "",
];
$reply_context = ['KIND' => '', 'UID' => 0,
'FOLDER' => '', 'MESSAGE_ID' => '', 'REFERENCES' => '',
'SOURCE_ACCOUNT_ID' => $account_id];
$reply_kind = $parent->clean(
$_REQUEST['reply_kind'] ?? null, 'string', '');
$reply_uid = $parent->clean(
$_REQUEST['reply_uid'] ?? null, 'int', 0);
$reply_folder = $parent->clean(
$_REQUEST['reply_folder'] ?? null, 'imap_arg', '');
if (in_array($reply_kind,
['reply', 'reply_all', 'forward'], true) &&
$reply_uid > 0 && $reply_folder !== '') {
$prefill = $this->userMailBuildReplyPrefill(
$account_id, $data, $reply_kind, $reply_folder,
$reply_uid);
if ($prefill !== null) {
$form_values = array_merge($form_values,
$prefill['FORM_VALUES']);
$prefill_context = $prefill['CONTEXT'];
$prefill_context['SOURCE_ACCOUNT_ID'] = $account_id;
$reply_context = $prefill_context;
}
}
$data["FORM_VALUES"] = $form_values;
$data["REPLY_CONTEXT"] = $reply_context;
$data["FORM_ERRORS"] = [];
$this->userMailCleanComposeValues($data);
return $data;
}
/**
* HTML-cleans the compose-pane values the view echoes into
* input value attributes / hidden fields / the body textarea,
* so renderCompose can emit them raw. Covers the FORM_VALUES
* the user (or a reply prefill) supplies and the free-text
* REPLY_CONTEXT header fields; REPLY_CONTEXT KIND is a fixed
* token and the UID / account-id members are ints, so they are
* left untouched, as is FROM_DISPLAY which is cleaned where it
* is assigned.
*
* @param array $data the mail $data array; FORM_VALUES and
* REPLY_CONTEXT are cleaned in place when present
*/
protected function userMailCleanComposeValues(&$data)
{
$parent = $this->parent;
if (!empty($data["FORM_VALUES"])) {
foreach (['TO', 'CC', 'BCC', 'SUBJECT', 'BODY']
as $compose_field) {
if (isset($data["FORM_VALUES"][$compose_field])) {
$data["FORM_VALUES"][$compose_field] =
$parent->clean(
$data["FORM_VALUES"][$compose_field],
"string", "");
}
}
}
if (!empty($data["REPLY_CONTEXT"])) {
foreach (['FOLDER', 'MESSAGE_ID', 'REFERENCES']
as $reply_field) {
if (isset($data["REPLY_CONTEXT"][$reply_field])) {
$data["REPLY_CONTEXT"][$reply_field] =
$parent->clean(
$data["REPLY_CONTEXT"][$reply_field],
"string", "");
}
}
}
}
/**
* Handles the Compose form POST: reads the To / Subject / Body
* fields, sanitizes them (strip CRLF from the header fields to
* defeat header injection, enforce the body-size cap), and on
* success hands the message to SmtpClient for transmission via
* the account's SMTP_HOST/PORT/USERNAME/PASSWORD. On validation
* error or SMTP failure the form is re-rendered with values
* preserved and the error displayed; on success the user is
* redirected back to the inbox with a "Message sent." flash.
*
* @return mixed $data for MailElement on failure (re-render),
* or a redirect on success
*/
protected function userMailSendMessage()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
/* The single From dropdown submits from_option as
"<account_id>|<address>". Split it into the account_id
and from_identity the rest of the flow already reads, so
the user sends from the chosen account and, for the local
account, as the chosen address. The address is validated
later against the identities the user owns. */
$from_option = $parent->clean(
$_REQUEST['from_option'] ?? "", "string", "");
if ($from_option !== "" && strpos($from_option, '|') !==
false) {
list($option_account, $option_address) =
explode('|', $from_option, 2);
$_REQUEST['account_id'] = (int) $option_account;
$_REQUEST['from_identity'] = $option_address;
}
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$account = null;
if ((int) $account_id === self::MAILSITE_ACCOUNT_ID) {
if (empty($data['MAILSITE_ENABLED'])) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
} else {
$account_model = $parent->model("MailAccount");
$account = $account_model->getAccountForSending(
$account_id, $data["USER_ID"]);
if (!$account) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
}
/* RFC 5322 §2.1.1 limits one header line to 998 octets
(MAIL_HEADER_MAX_LEN). The per-address ceiling
(MAIL_ADDRESS_MAX_LEN, 320 octets per RFC 5321) is
applied after splitting comma-separated lists. The
body cap is MAX_MAIL_BODY_LEN from Config. CRLF is
stripped from headers to defeat header injection; the
body keeps its CRLF since line breaks are legitimate
there. */
$raw_to = MailHeaderParser::cleanHeader(
$_REQUEST['to'] ?? '', self::MAIL_HEADER_MAX_LEN);
$raw_cc = MailHeaderParser::cleanHeader(
$_REQUEST['cc'] ?? '', self::MAIL_HEADER_MAX_LEN);
$raw_bcc = MailHeaderParser::cleanHeader(
$_REQUEST['bcc'] ?? '', self::MAIL_HEADER_MAX_LEN);
$subject = MailHeaderParser::cleanHeader(
$_REQUEST['subject'] ?? '', self::MAIL_HEADER_MAX_LEN);
$raw_body = (string) ($_REQUEST['body'] ?? '');
$errors = [];
$to_addresses = MailHeaderParser::parseAddressList($raw_to);
$cc_addresses = MailHeaderParser::parseAddressList($raw_cc);
$bcc_addresses = MailHeaderParser::parseAddressList($raw_bcc);
if (empty($to_addresses)) {
$errors['TO'] = tl('mail_element_field_to_required');
} else {
foreach ($to_addresses as $addr) {
$bare = MailHeaderParser::extractBareAddress($addr);
if (strlen($bare) > self::MAIL_ADDRESS_MAX_LEN ||
!MailHeaderParser::isValidAddress($bare)) {
$errors['TO'] = tl(
'mail_element_field_address_invalid', $addr);
break;
}
}
}
if (empty($errors['TO']) && !empty($cc_addresses)) {
foreach ($cc_addresses as $addr) {
$bare = MailHeaderParser::extractBareAddress($addr);
if (strlen($bare) > self::MAIL_ADDRESS_MAX_LEN ||
!MailHeaderParser::isValidAddress($bare)) {
$errors['CC'] = tl(
'mail_element_field_address_invalid', $addr);
break;
}
}
}
if (empty($errors['CC']) && !empty($bcc_addresses)) {
foreach ($bcc_addresses as $addr) {
$bare = MailHeaderParser::extractBareAddress($addr);
if (strlen($bare) > self::MAIL_ADDRESS_MAX_LEN ||
!MailHeaderParser::isValidAddress($bare)) {
$errors['BCC'] = tl(
'mail_element_field_address_invalid', $addr);
break;
}
}
}
if (strlen($raw_body) > C\MAX_MAIL_BODY_LEN) {
$errors['BODY'] = tl(
'mail_element_field_body_too_long');
}
/* harvest uploaded attachments. $_FILES['attachments']
is the "array of files" shape PHP exposes when the
file input is named "attachments[]". Each file is
validated individually and any error short-circuits
with a form-level message; a partial send (some
attachments lost in the upload) would be worse than
failing the whole compose attempt. */
$attachments = [];
$attached_files = $_FILES['attachments'] ?? null;
if ($attached_files && is_array($attached_files['name'])) {
$count = count($attached_files['name']);
if ($count > self::MAIL_ATTACH_MAX_COUNT) {
$errors['ATTACH'] = tl(
'mail_element_attachment_too_many',
self::MAIL_ATTACH_MAX_COUNT);
}
for ($i = 0; $i < $count && empty($errors['ATTACH']);
$i++) {
$err = $attached_files['error'][$i];
if ($err === UPLOAD_ERR_NO_FILE) {
continue;
}
if ($err !== UPLOAD_ERR_OK) {
$errors['ATTACH'] = tl(
'mail_element_attachment_upload_failed',
$attached_files['name'][$i]);
break;
}
$size = (int) $attached_files['size'][$i];
if ($size > self::MAIL_ATTACH_MAX_BYTES) {
$errors['ATTACH'] = tl(
'mail_element_attachment_too_large',
$attached_files['name'][$i],
intval(self::MAIL_ATTACH_MAX_BYTES /
(1024 * 1024)));
break;
}
$captured = '';
set_error_handler(
function ($errno, $errstr) use (&$captured) {
if (strlen($captured) < 512) {
$captured .= $errstr;
}
});
try {
$content = file_get_contents(
$attached_files['tmp_name'][$i]);
} finally {
restore_error_handler();
}
if ($captured !== '' &&
C\nsdefined('LOG_DIR')) {
$line = "[" . date(DATE_RFC822) .
"] attach-read: " .
substr($attached_files['name'][$i],
0, 80) . " -> " .
substr(str_replace(["\r", "\n"], " ",
$captured), 0, 200) . "\n";
file_put_contents(C\LOG_DIR . "/mail.log",
$line, FILE_APPEND | LOCK_EX);
}
if ($content === false) {
$errors['ATTACH'] = tl(
'mail_element_attachment_upload_failed',
$attached_files['name'][$i]);
break;
}
$attachments[] = [
'filename' => $attached_files['name'][$i],
'mime_type' => $attached_files['type'][$i] ?:
'application/octet-stream',
'content' => $content,
];
}
}
$reply_kind = $parent->clean(
$_REQUEST['reply_kind'] ?? null, 'string', '');
if (!in_array($reply_kind,
['reply', 'reply_all', 'forward'], true)) {
$reply_kind = '';
}
$reply_uid = $parent->clean(
$_REQUEST['reply_uid'] ?? null, 'int', 0);
$reply_folder = $parent->clean(
$_REQUEST['reply_folder'] ?? null, 'imap_arg', '');
$reply_message_id = MailHeaderParser::cleanHeader(
$_REQUEST['reply_message_id'] ?? '',
self::MAIL_HEADER_MAX_LEN);
$reply_references = MailHeaderParser::cleanHeader(
$_REQUEST['reply_references'] ?? '',
self::MAIL_HEADER_MAX_LEN);
$extra_headers = [];
if (($reply_kind === 'reply' || $reply_kind === 'reply_all')
&& $reply_message_id !== '') {
$extra_headers['In-Reply-To'] = $reply_message_id;
$extra_headers['References'] =
$reply_references !== '' ? $reply_references :
$reply_message_id;
}
if (empty($errors)) {
$scheduled_at = (int) $parent->clean(
$_REQUEST['scheduled_at'] ?? null, 'int', 0);
if ($scheduled_at > 0) {
if ($scheduled_at <= time()) {
$errors['SEND'] = tl(
'mail_element_compose_schedule_past');
} else if (!empty($attachments)) {
$errors['SEND'] = tl(
'mail_element_compose_schedule_with_attachments');
} else {
$scheduled_model = $parent->model(
"MailScheduled");
$composer = [
'subject' => $subject,
'to_list' => $raw_to,
'cc_list' => $raw_cc,
'bcc_list' => $raw_bcc,
'body_text' => $raw_body,
'body_html' => '',
'in_reply_to' =>
$extra_headers['In-Reply-To'] ?? '',
'references' =>
$extra_headers['References'] ?? ''];
$new_id = $scheduled_model->add(
$data["USER_ID"], $account_id,
$scheduled_at, $composer);
if ($new_id) {
$_REQUEST['arg'] = 'listMessages';
return $parent->redirectWithMessage(
tl(
'mail_element_compose_scheduled_flash',
date('Y-m-d H:i',
$scheduled_at)),
['arg', 'account_id']);
}
$errors['SEND'] = tl(
'mail_element_message_send_failed',
'storage failed');
}
}
}
if (empty($errors)) {
$recipients = ['to' => $to_addresses, 'cc' => $cc_addresses,
'bcc' => $bcc_addresses];
if ($account === null) {
$backend = $this->userMailBackend(
self::MAILSITE_ACCOUNT_ID, $data);
$primary_identity = $backend->senderEmail();
$requested_identity = $this->parent->clean(
$_REQUEST["from_identity"] ?? "", "string", "");
$from = $primary_identity;
if ($requested_identity !== "") {
$available_identities =
$this->parent->model("mailAlias")->identitiesFor(
$data["USER_ID"], $primary_identity);
foreach ($available_identities as
$available_identity) {
if (strcasecmp($available_identity,
$requested_identity) === 0) {
$from = $available_identity;
break;
}
}
}
$backend->close();
$result = $this->userMailDispatchMailsiteSend(
$data, $from, $recipients, $subject, $raw_body,
$attachments, $extra_headers);
} else {
$from = $this->userMailFormatFrom($account);
$result = $this->userMailDispatchSend($account,
$from, $recipients, $subject, $raw_body,
$attachments, $extra_headers);
}
if ($result['ok']) {
$reply_source_id = (int) $parent->clean(
$_REQUEST['reply_account_id'] ?? null, 'int',
$account_id);
if (($reply_kind === 'reply' ||
$reply_kind === 'reply_all') &&
$reply_uid > 0 && $reply_folder !== '') {
/* mark the source message \Answered so other
mail clients show it as replied-to. Failures
here are non-fatal: send already succeeded
and the user has nothing more to do. Target
the SOURCE account: with the from-account
dropdown the user may have replied while
sending as a different identity. */
$answered_backend = null;
try {
$answered_backend =
$this->userMailBackend($reply_source_id, $data);
$answered_backend->setFlag($reply_folder,
$reply_uid, '\\Answered', true);
} catch (\Exception $answered_exception) {
$this->userMailArchiveLog(
"answered exception: " .
$answered_exception->getMessage());
} finally {
if ($answered_backend !== null) {
$answered_backend->close();
}
}
}
/* land back on this account's inbox with the
success flash. redirectWithMessage's second
argument is the list of fields to KEEP in the
redirect URL (added to the framework defaults);
we pull arg over to listMessages so we don't
redirect into another sendMessage (which would
re-fire the SMTP transmission and loop). */
$_REQUEST['arg'] = 'listMessages';
return $parent->redirectWithMessage(
tl('mail_element_message_sent'),
['arg', 'account_id']);
}
$errors['SEND'] = tl(
'mail_element_message_send_failed',
$result['error']);
}
$display_name = '';
foreach ($data['ACCOUNTS'] as $entry) {
if ((int) $entry['ID'] === (int) $account_id) {
$display_name = $entry['DISPLAY_NAME'] ?? '';
break;
}
}
$data["CONTENT_PANE"] = "compose";
$data["ACCOUNT_ID"] = $account_id;
$data["ACCOUNT_DISPLAY_NAME"] = $display_name;
if ($account === null) {
$backend = $this->userMailBackend(self::MAILSITE_ACCOUNT_ID, $data);
$data["FROM_DISPLAY"] = $parent->clean(
$backend->senderEmail(), "string", "");
$backend->close();
} else {
$data["FROM_DISPLAY"] = $parent->clean(
$this->userMailFormatFrom($account), "string", "");
}
$data["FROM_OPTIONS"] = $this->userMailFromOptions($data);
$requested_from = $parent->clean(
$_REQUEST["from_option"] ?? "", "string", "");
$data["FROM_VALUE"] = ($requested_from !== "") ?
$requested_from : $account_id . '|' . $data["FROM_DISPLAY"];
/* The compose form re-renders with whatever the user typed
(TO/CC/BCC/SUBJECT/BODY) on a validation or send failure,
so HTML-clean each here and let the view echo it raw. */
$data["FORM_VALUES"] = [
"TO" => $parent->clean($raw_to, "string", ""),
"CC" => $parent->clean($raw_cc, "string", ""),
"BCC" => $parent->clean($raw_bcc, "string", ""),
"SUBJECT" => $parent->clean($subject, "string", ""),
"BODY" => $parent->clean($raw_body, "string", ""),
];
$reply_source_id = (int) $parent->clean(
$_REQUEST['reply_account_id'] ?? null, 'int',
$account_id);
/* FOLDER / MESSAGE_ID / REFERENCES ride along in hidden
inputs and originate from the replied-to message's
headers, so clean them too; KIND is one of a fixed set
and UID / SOURCE_ACCOUNT_ID are ints. */
$data["REPLY_CONTEXT"] = [
'KIND' => $reply_kind,
'UID' => $reply_uid,
'FOLDER' => $parent->clean($reply_folder, "string", ""),
'MESSAGE_ID' => $parent->clean($reply_message_id,
"string", ""),
'REFERENCES' => $parent->clean($reply_references,
"string", ""),
'SOURCE_ACCOUNT_ID' => $reply_source_id,
];
$data["FORM_ERRORS"] = $errors;
return $data;
}
/**
* Maximum number of attachments allowed on a single compose
* submission. Arbitrary cap to prevent pathological cases
* (a UI bug or malicious form replay sending thousands of
* one-byte attachments); 20 comfortably covers any
* legitimate mail use.
*/
const MAIL_ATTACH_MAX_COUNT = 20;
/**
* Maximum byte size of any single attachment. 25 MB matches
* Gmail's per-attachment limit and is what most receiver-
* side mail servers accept without bouncing; sending
* attachments much larger than this generally fails at the
* recipient's MTA, so capping here gives the user immediate
* feedback rather than a 4xx SMTP error later.
*/
const MAIL_ATTACH_MAX_BYTES = 25 * 1024 * 1024;
/**
* Per-address byte cap. RFC 5321 §4.5.3.1.3 limits a path
* (mailbox in <angle-brackets>) to 256 octets, and the
* bare mailbox itself is bounded by 64 (local-part) + 1
* + 255 (domain) = 320 octets. Used as the validation
* ceiling for each entry in a parsed TO/CC/BCC list.
*/
const MAIL_ADDRESS_MAX_LEN = 320;
/**
* Per-header byte cap. RFC 5322 §2.1.1 limits a single
* unfolded header line to 998 octets excluding the
* trailing CRLF. Used as the clamp on raw header inputs
* (TO/CC/BCC/Subject) coming off the compose form before
* further parsing.
*/
const MAIL_HEADER_MAX_LEN = 998;
/**
* Sentinel account-id used for the synthetic per-user MailSite
* mailbox that surfaces at the top of the sidebar when
* MAILSITE_ENABLED. Real external accounts use auto-increment
* IDs starting at 1, so 0 cannot collide. Routing handlers
* check (int) $account_id === self::MAILSITE_ACCOUNT_ID to
* branch into the MailSite-served paths instead of opening an
* IMAP connection.
*/
const MAILSITE_ACCOUNT_ID = 0;
/**
* Default page size for the MailSite inbox listing. Matches
* the IMAP-side window so the two paths render comparable
* first pages; the inbox-load-more pagination patch will
* surface a follow-on window beyond this.
*/
const MAILSITE_WINDOW = 25;
/**
* Renders the per-account Scheduled folder view: a list of
* pending and failed scheduled-send rows for the currently
* active account. Each row shows the target time, recipient
* list, subject, and status badge; failed rows additionally
* surface the LAST_ERROR. A small Cancel form per row POSTs
* to scheduledCancel for removal before the dispatcher fires.
* Access-control fence: getAccount enforces user-ownership of
* account_id; MailScheduledModel methods scope to user_id
* regardless of account, providing belt + suspenders.
*
* @return array|mixed $data for MailElement on success, or a
* redirectWithMessage when the account_id is invalid
*/
protected function userMailScheduledList()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$account_model = $parent->model("MailAccount");
$account = $account_model->getAccount($account_id,
$data["USER_ID"]);
if (!$account) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$scheduled_model = $parent->model("MailScheduled");
$data["CONTENT_PANE"] = "scheduledList";
$data["ACCOUNT_ID"] = $account_id;
$data["ACCOUNT_DISPLAY_NAME"] = $parent->clean(
$account["DISPLAY_NAME"], "string", "");
$data["ACTIVE_FOLDER"] = "Scheduled";
$data["SCHEDULED_MESSAGES"] =
$scheduled_model->getMessagesForUser(
$data["USER_ID"], $account_id);
/* The scheduled-list view echoes TO_LIST, SUBJECT and
LAST_ERROR. Truncate the long display fields on the raw
value first (so a multibyte/entity boundary is never cut
mid-sequence), then HTML-clean; the view echoes the
prepared values raw and does no truncation of its own.
STATUS is one of a fixed set used in a class name. */
foreach ($data["SCHEDULED_MESSAGES"]
as $scheduled_index => $scheduled_row) {
$recipients = $scheduled_row['TO_LIST'] ?? '';
if (strlen($recipients) > 80) {
$recipients = substr($recipients, 0, 80) . "...";
}
$subject = $scheduled_row['SUBJECT'] ?? '';
if (strlen($subject) > 60) {
$subject = substr($subject, 0, 60) . "...";
}
$data["SCHEDULED_MESSAGES"][$scheduled_index]
["TO_LIST_DISPLAY"] = $parent->clean($recipients,
"string", "");
$data["SCHEDULED_MESSAGES"][$scheduled_index]
["SUBJECT_DISPLAY"] = $parent->clean($subject,
"string", "");
$data["SCHEDULED_MESSAGES"][$scheduled_index]
["LAST_ERROR_DISPLAY"] = $parent->clean(
$scheduled_row['LAST_ERROR'] ?? '', "string", "");
}
return $data;
}
/**
* Returns the sender email address derived from an account
* row. The rule, in order, is:
* 1. USERNAME if it contains '@' (provider mailboxes like
* gmail.com store the email address as USERNAME).
* 2. SMTP_USERNAME if it contains '@' (some users have a
* bare-name IMAP login but a full-address SMTP login).
* 3. USERNAME + '@' + PROVIDER's email_domain when PROVIDER
* is a known preset that defines email_domain (Gmail is
* currently the only one; bare-username + provider domain
* yields the right From address without forcing the user
* to type the full email at login time).
* 4. USERNAME + '@' + HOST otherwise (self-hosted setups
* where USERNAME is a bare login name; stitching with
* the IMAP HOST yields a plausible address).
*
* @param array $account row from MailAccountModel
* @return string the sender email address
*/
protected function userMailSenderEmail($account)
{
return MailAccountModel::senderEmail($account);
}
/**
* Builds an RFC 5322 From-header value from an account row.
* Returns just the bare email address, with no display-name
* wrapper: the account's DISPLAY_NAME is for the UI's account
* list, not for the sender identity in outgoing mail. Anyone
* who wants a real "Friendly Name <email>" form can request
* it as a follow-up; until then the wire form is simply
* "chris@pollett.org" rather than something like "Pollett
* <chris@pollett.org>".
*
* @param array $account row from MailAccountModel
* @return string the sender email address
*/
protected function userMailFormatFrom($account)
{
return $this->userMailSenderEmail($account);
}
/**
* Fetches the source message from IMAP and assembles the
* prefill values + context needed to compose a reply,
* reply-all, or forward. Returns null on any fetch/parse
* failure so the caller can fall back to a blank compose.
*
* Prefill rules by kind:
* - reply: TO = source Reply-To (else From). CC = empty.
* Subject prefixed "Re: " (not double-prefixed). Body
* gets an attribution line followed by each line of the
* source plain-text body prefixed with "> ".
* - reply_all: TO same as reply. CC = source CC plus the
* remaining To recipients minus this account's own
* address (the user is already the sender so they
* shouldn't CC themselves). Subject and body same as
* reply.
* - forward: TO empty (user fills it in). Subject prefixed
* "Fwd: ". Body has a "Begin forwarded message" header
* block followed by the source body. No attribution.
*
* Context returned to the renderer:
* - KIND: the validated kind string
* - UID + FOLDER: for the post-send \Answered store call
* - MESSAGE_ID: source Message-ID, used as In-Reply-To
* - REFERENCES: source References (or Message-ID if none)
* plus the source Message-ID appended, used as the new
* message's References header
*
* @param int $account_id account whose backend to use to
* fetch the source message
* @param array $data handler-shared data with USER_ID for
* the backend factory lookup
* @param string $kind one of 'reply', 'reply_all', 'forward'
* @param string $folder IMAP folder holding the source
* @param int $uid IMAP UID of the source message
* @return array|null associative array
* ['FORM_VALUES' => [TO, CC, SUBJECT, BODY],
* 'CONTEXT' => [KIND, UID, FOLDER, MESSAGE_ID,
* REFERENCES]], or null on fetch failure
*/
protected function userMailBuildReplyPrefill($account_id, $data,
$kind, $folder, $uid)
{
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
$raw = $backend->fetchMessage($folder, $uid);
$my_email = strtolower($backend->senderEmail());
} catch (\Exception $exception) {
if ($backend !== null) {
$backend->close();
}
return null;
}
$backend->close();
$source = MimeMessage::parse($raw);
return MailComposeBuilder::replyPrefill($source, $my_email,
$kind, $folder, $uid);
}
/**
* Constructs an SmtpClient for the given account and asks it
* to transmit one message via sendImmediate. Returns a small
* status array — ['ok' => bool, 'error' => string] — so the
* caller can branch on success without reaching into SmtpClient
* internals. SMTP_USERNAME and SMTP_PASSWORD on the account
* row are assumed to have already been resolved via
* MailAccountModel::getAccountForSending (which falls back to
* IMAP credentials when SMTP-specific ones are blank).
*
* @param array $account row including SMTP_HOST, SMTP_PORT,
* SMTP_USERNAME, SMTP_PASSWORD, SMTP_TLS_MODE
* @param string $from From-header value
* @param string $to RCPT TO email address (bare)
* @param string $subject Subject header
* @param string $body message body
* @param array $attachments optional list of attachment
* associative arrays (filename, mime_type, content)
* forwarded to SmtpClient::sendImmediate; empty list
* produces a single-part text/plain message
* @param array $extra_headers optional name=>value map of
* additional RFC 5322 headers (In-Reply-To, References
* for the Reply flow) emitted alongside the standard
* headers in the sent message
* @return array ['ok' => bool, 'error' => string]
*/
protected function userMailDispatchSend($account, $from, $to,
$subject, $body, $attachments = [], $extra_headers = [])
{
$sender_email = $this->userMailSenderEmail($account);
$client = new SmtpClient(
$sender_email,
$account['SMTP_HOST'] ?? '',
(int) ($account['SMTP_PORT'] ?? 587),
$account['SMTP_USERNAME'] ?? '',
$account['SMTP_PASSWORD'] ?? '',
$account['SMTP_TLS_MODE'] ?? 'starttls',
!empty($account['ALLOW_SELF_SIGNED']));
$ok = $client->sendImmediate($subject, $from, $to, $body,
$attachments, $extra_headers);
if ($ok) {
/* archive a copy of just-sent mail into the IMAP
Sent folder; failures here are non-fatal since
the SMTP send already succeeded -- the recipient
got the mail. */
$this->userMailArchiveToSent($account,
$client->last_wire_message);
}
return ['ok' => (bool) $ok,
'error' => $ok ? '' : trim($client->getLastError())];
}
/**
* Sends an outbound message on behalf of a MailSite-account
* user. Builds the RFC 5322 wire bytes once and delivers them
* along two channels: local recipients (whose domain is in
* MAIL_DOMAINS and who have a MailSite account on this
* install) get the message appended directly to their INBOX
* via MailSite::deliverMail, bypassing the SMTP listener;
* external recipients go through Yioop's notification SMTP
* relay (MAIL_SERVER etc.). After delivery the same bytes are
* archived to the sender's Sent folder via the MailSite
* backend's appendMessage.
*
* Refuses the send -- rather than partially delivering -- if
* any recipient is external and the install has no outbound
* SMTP server configured. Partial success would leave the
* sender thinking everyone got the message when some
* recipients silently did not.
*
* @param array $data userMailBaseData (carries USER_NAME etc.
* needed for the synthetic-account backend factory)
* @param string $from envelope MAIL FROM and From-header
* bare address (already computed via the backend's
* senderEmail in the caller)
* @param array $recipients ['to' => [], 'cc' => [], 'bcc' => []]
* lists of parsed address strings
* @param string $subject Subject header value
* @param string $body plain-text body
* @param array $attachments parsed attachment list (each
* entry: filename, mime_type, content)
* @param array $extra_headers In-Reply-To / References for
* reply mode
* @return array ['ok' => bool, 'error' => string]
*/
protected function userMailDispatchMailsiteSend($data, $from,
$recipients, $subject, $body, $attachments = [],
$extra_headers = [])
{
$to_addresses = $recipients['to'] ?? [];
$cc_addresses = $recipients['cc'] ?? [];
$bcc_addresses = $recipients['bcc'] ?? [];
$to_header = implode(", ", $to_addresses);
$cc_header = implode(", ", $cc_addresses);
$message_arg = ($cc_header === '') ? $to_header :
['to' => $to_header, 'cc' => $cc_header];
$bytes = ML\MimeMessage::build($from, $message_arg, $subject,
$body, $attachments, $extra_headers);
/* classify recipients: domains in MAIL_DOMAINS go to
direct FileMailStorage delivery; everything else to
the outbound SMTP relay. The Bcc list is split by
the same rule but its addresses never appear in the
wire headers (the bytes are built with To and Cc
only). */
$local_domains = MailSiteFactory::localDomains();
$local_domain_lookup = [];
foreach ($local_domains as $domain) {
$local_domain_lookup[strtolower($domain)] = true;
}
$local_rcpts = [];
$external_rcpts = [];
foreach (array_merge($to_addresses, $cc_addresses, $bcc_addresses) as
$address) {
$bare = MailHeaderParser::extractBareAddress($address);
if ($bare === '') {
continue;
}
$at_pos = strrpos($bare, '@');
$domain = ($at_pos === false) ? '' :
strtolower(substr($bare, $at_pos + 1));
if ($domain !== '' && isset($local_domain_lookup[$domain])) {
$local_rcpts[] = $bare;
} else {
$external_rcpts[] = $bare;
}
}
/* local delivery via MailSite::deliverMail. one round-
trip per recipient, no SMTP listener loopback. failures
accumulate so we can report a partial outcome rather
than dropping silently. */
$local_failures = [];
if (!empty($local_rcpts)) {
$site = MailSiteFactory::build();
foreach ($local_rcpts as $rcpt) {
$delivered = $site->deliverMail($from, $rcpt,
$bytes, ['source' => 'webmail-compose']);
if ($delivered === false) {
$local_failures[] = $rcpt;
}
}
}
/* external delivery as a peer MTA: group recipients by
lowercase domain, resolve MX records per domain, and
try each MX in priority order. One DATA carries all
the domain's recipients at once (RFC 5321 §3.6: a
single transaction may serve multiple RCPT TOs as long
as they share an SMTP server). No AUTH -- we're the
sending MTA talking to the recipient's MX, not a
client submitting through a smarthost. Empty login
and password on the SmtpClient instance skip the
AUTH LOGIN exchange in startSession. Per MX host, port
25 is tried first; when C\p('MAIL_TEST_MODE') is on and
C\p('MAIL_TEST_MODE_FALLBACK_PORT') is nonzero, that port
is tried as a TCP-failure fallback so a deployment
whose network egress blocks 25 can reach the upstream
through a tunnel forwarder on a higher port (the
smtp_tunnel.php helper script is the intended
use-case). Replaces an earlier unconditional 25 -> 587
fallback that was speculative -- the test server did
not in fact accept unauthenticated submission on 587.
'opportunistic' STARTTLS upgrades the channel when the
peer advertises it and passes plaintext to peers that
don't. */
$external_failures = [];
if (!empty($external_rcpts)) {
$by_domain = [];
foreach ($external_rcpts as $rcpt) {
$at_pos = strrpos($rcpt, '@');
if ($at_pos === false) {
$external_failures[] =
$rcpt . ': no domain';
continue;
}
$domain = strtolower(substr($rcpt, $at_pos + 1));
$by_domain[$domain][] = $rcpt;
}
$ports_to_try = [25];
if (C\p('MAIL_TEST_MODE') &&
(int) C\p('MAIL_TEST_MODE_FALLBACK_PORT') > 0) {
$ports_to_try[] = (int) C\p('MAIL_TEST_MODE_FALLBACK_PORT');
}
foreach ($by_domain as $domain => $domain_rcpts) {
$mx_hosts = SmtpClient::resolveMxHosts($domain);
if (empty($mx_hosts)) {
$external_failures[] =
implode(', ', $domain_rcpts) .
': no MX record for ' . $domain;
continue;
}
$accepted = false;
$last_error = '';
foreach ($mx_hosts as $mx_host) {
foreach ($ports_to_try as $port) {
$smtp = new SmtpClient($from, $mx_host,
$port, '', '', 'opportunistic',
false);
if ($smtp->deliverBytes($from,
$domain_rcpts, $bytes)) {
$accepted = true;
break 2;
}
$last_error = trim(
$smtp->getLastError());
/* fall through to the submission port
only when the failure was at the TCP
layer (refused / timed out). Server-
level SMTP rejections on port 25 are
definitive for that MX -- the server
is reachable and said no. */
if (stripos($last_error,
'could not connect') === false) {
break;
}
}
}
if (!$accepted) {
$external_failures[] =
implode(', ', $domain_rcpts) .
': ' . ($last_error !== '' ?
$last_error : 'no MX accepted');
}
}
}
/* archive a copy in the sender's Sent folder. Same bytes
that went to local recipients (and that the relay
rebuilt from for external). Failures here are non-
fatal: delivery already happened and the user has
nothing more to do; the archive miss is logged. */
$archive_backend = null;
try {
$archive_backend = $this->userMailBackend(
self::MAILSITE_ACCOUNT_ID, $data);
$sent_folder = null;
foreach ($archive_backend->listFolders() as $folder) {
if (strcasecmp($folder['SPECIAL_USE'] ?? '',
'\\Sent') === 0 ||
strcasecmp($folder['NAME'] ?? '',
'Sent') === 0) {
$sent_folder = $folder['NAME'];
break;
}
}
if ($sent_folder !== null) {
$archive_backend->appendMessage($sent_folder,
$bytes, ['\\Seen']);
} else {
$this->userMailArchiveLog(
"mailsite-archive: no Sent folder");
}
} catch (\Exception $exception) {
$this->userMailArchiveLog(
"mailsite-archive exception: " .
$exception->getMessage());
} finally {
if ($archive_backend !== null) {
$archive_backend->close();
}
}
$failure_parts = [];
if (!empty($external_failures)) {
$failure_parts[] = tl(
'mail_element_external_delivery_failed',
implode('; ', $external_failures));
}
if (!empty($local_failures)) {
$failure_parts[] = tl(
'mail_element_local_delivery_failed',
implode(', ', $local_failures));
}
if (!empty($failure_parts)) {
return ['ok' => false,
'error' => implode(' ', $failure_parts)];
}
return ['ok' => true, 'error' => ''];
}
/**
* Connects to the account's IMAP server, finds the Sent
* folder, and APPENDs the given RFC822 message there with
* the \Seen flag. Failures (folder not discoverable, APPEND
* rejected, IMAP connect refused, etc.) are logged to the
* SMTP mail.log but do not raise an exception or change
* the overall send-status seen by the user. The cost of
* doing this work synchronously after every send is small
* (a few hundred milliseconds for typical messages), and
* doing it asynchronously would require a queue mechanism
* the user-mail feature does not yet have.
*
* @param array $account row from getAccountForSending
* @param string $rfc822 the message bytes that went out via
* SMTP, with CRLF line endings and no SMTP terminator
*/
protected function userMailArchiveToSent($account, $rfc822)
{
if ($rfc822 === '') {
$this->userMailArchiveLog(
"skipped: empty wire message");
return;
}
$account_id = (int) ($account['ID'] ?? 0);
if ($account_id <= 0) {
$this->userMailArchiveLog(
"skipped: missing account ID");
return;
}
$data = $this->userMailBaseData();
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
$folders = $backend->listFolders();
$sent_folder = null;
foreach ($folders as $folder) {
if (strcasecmp($folder['SPECIAL_USE'] ?? '',
'\\Sent') === 0) {
$sent_folder = $folder['NAME'];
break;
}
}
if ($sent_folder === null) {
foreach (['Sent', 'Sent Items',
'[Gmail]/Sent Mail'] as $candidate) {
foreach ($folders as $folder) {
if (strcasecmp($folder['NAME'],
$candidate) === 0) {
$sent_folder = $folder['NAME'];
break 2;
}
}
}
}
if ($sent_folder === null) {
$this->userMailArchiveLog(
"no Sent folder discovered");
return;
}
$backend->appendMessage($sent_folder, $rfc822,
['\\Seen']);
$this->userMailArchiveLog(
"APPEND to $sent_folder OK");
} catch (ML\MailBackendException $exception) {
$this->userMailArchiveLog(
"APPEND failed: " . $exception->getMessage());
} catch (\Exception $exception) {
$this->userMailArchiveLog(
"exception: " . $exception->getMessage());
} finally {
if ($backend !== null) {
$backend->close();
}
}
}
/**
* Handles a bulk action (delete or move) submitted from the
* inbox view. Expected request parameters: account_id (int),
* folder (imap_arg, the source folder the user is acting on),
* bulk_action ('delete' or 'move'), uids (array of int),
* destination (imap_arg, only required when bulk_action is
* 'move'). Validates everything, dispatches to the backend's
* bulk methods (setFlagBulk / moveMessagesBulk /
* deleteMessagesBulk), and redirects back to the source
* folder with a flash message describing the result.
*
* Caps the UID list at MAIL_BULK_MAX_COUNT to prevent
* pathological request sizes (a UI bug or replay attempt
* sending tens of thousands of UIDs); the value is high
* enough that any sane user-selected batch fits.
*
* @return mixed result of redirectWithMessage
*/
protected function userMailBulkAction()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$source = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
$action = $parent->clean(
$_REQUEST['bulk_action'] ?? null, 'string', '');
$destination = $parent->clean(
$_REQUEST['destination'] ?? null, 'imap_arg', '');
$destination_account_id = $parent->clean(
$_REQUEST['destination_account_id'] ?? null, 'int',
$account_id);
$raw_uids = $_REQUEST['uids'] ?? [];
if (!is_array($raw_uids)) {
$raw_uids = [];
}
$uids = [];
foreach ($raw_uids as $raw_uid) {
$uid = (int) $raw_uid;
if ($uid > 0) {
$uids[] = $uid;
}
if (count($uids) >= self::MAIL_BULK_MAX_COUNT) {
break;
}
}
if (empty($uids) || !in_array($action,
['delete', 'move', 'mark_read', 'mark_unread'],
true)) {
return $parent->redirectWithMessage(
tl('mail_element_bulk_invalid'),
['arg', 'account_id', 'folder']);
}
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
} catch (ML\MailBackendException $exception) {
return $parent->redirectWithMessage(
$exception->getMessage());
}
if ($source === '') {
$source = $backend->defaultFolder();
}
/* validate move destination against the cached folder
list of the destination account so a forged form post
cannot copy mail to an attacker-named folder; this also
stops the user from moving into a no-select / namespace
placeholder. The destination account defaults to the
source account, in which case this is an ordinary
same-account move; a different account id makes it a
cross-account move (copy into the other account, then
purge from the source). */
$cross_account =
($destination_account_id !== $account_id);
if ($action === 'move') {
$mail_model = $this->parent->model("Mail");
$destination_folders =
$mail_model->foldersFor($destination_account_id);
/* the session folder cache is only warm for accounts
opened this session; when moving into an account the
user has not visited (commonly the local MailSite
account), fetch its folder list so a legitimate
destination is not rejected. */
if (empty($destination_folders)) {
try {
$destination_backend =
$this->userMailBackend($destination_account_id, $data);
$destination_folders =
$destination_backend->listFolders();
$destination_backend->close();
$mail_model->cacheFolders(
$destination_account_id,
$destination_folders);
} catch (ML\MailBackendException $exception) {
$destination_folders = [];
}
}
$valid_destination = false;
foreach ($destination_folders as $folder_row) {
if ($folder_row['NAME'] === $destination) {
if (!empty($folder_row['SELECTABLE'])) {
$valid_destination = true;
}
break;
}
}
$same_place = (!$cross_account &&
$destination === $source);
if (!$valid_destination || $same_place) {
$backend->close();
return $parent->redirectWithMessage(
tl('mail_element_bulk_invalid_destination'),
['arg', 'account_id', 'folder']);
}
}
$this->userMailLog('bulk-apply', ['action' => $action,
'source' => $source, 'dest' => $destination,
'dest_account' => $destination_account_id,
'count' => count($uids)]);
$moved_count = count($uids);
try {
if ($action === 'mark_read') {
$backend->setFlagBulk($source, $uids, '\\Seen',
true);
} else if ($action === 'mark_unread') {
$backend->setFlagBulk($source, $uids, '\\Seen',
false);
} else if ($action === 'move' && $cross_account) {
$moved_count = $this->mailMoveAcrossAccounts(
$backend, $source, $uids,
$destination_account_id, $destination,
$data);
} else if ($action === 'move') {
$backend->moveMessagesBulk($source, $uids,
$destination);
} else {
$backend->deleteMessagesBulk($source, $uids);
}
} catch (ML\MailBackendException $exception) {
$backend->close();
return $parent->redirectWithMessage(
$exception->getMessage(),
['arg', 'account_id', 'folder']);
}
$backend->close();
$_REQUEST['arg'] = 'listMessages';
$_REQUEST['folder'] = $source;
$num_uids = $moved_count;
if ($action === 'delete') {
$flash = ($num_uids === 1) ?
tl('mail_element_bulk_deleted_one') :
tl('mail_element_bulk_deleted_n', $num_uids);
} else if ($action === 'mark_read') {
$flash = ($num_uids === 1) ?
tl('mail_element_bulk_marked_read_one') :
tl('mail_element_bulk_marked_read_n', $num_uids);
} else if ($action === 'mark_unread') {
$flash = ($num_uids === 1) ?
tl('mail_element_bulk_marked_unread_one') :
tl('mail_element_bulk_marked_unread_n',
$num_uids);
} else if ($cross_account) {
$destination_label = $destination_account_id;
foreach ($data['ACCOUNTS'] ?? [] as $account_row) {
if ((int) ($account_row['ID'] ?? -1) ===
$destination_account_id) {
$destination_label =
$account_row['DISPLAY_NAME'] ??
$destination_account_id;
break;
}
}
$flash = ($num_uids === 1) ?
tl('mail_element_bulk_moved_account_one',
$destination, $destination_label) :
tl('mail_element_bulk_moved_account_n', $num_uids,
$destination, $destination_label);
} else {
$flash = ($num_uids === 1) ?
tl('mail_element_bulk_moved_one', $destination) :
tl('mail_element_bulk_moved_n', $num_uids,
$destination);
}
ML\MailUnreadProbe::invalidate($data["USER_ID"]);
unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
if ($cross_account) {
unset($_SESSION["MAIL_FOLDER_UNREAD"]
[$destination_account_id]);
}
return $parent->redirectWithMessage($flash,
['arg', 'account_id', 'folder']);
}
/**
* Moves a batch of messages from the currently open account to
* a folder in a different account. Each message is fetched as
* raw RFC 5322 bytes from the source and appended to the
* destination account's folder; only the messages that append
* successfully are then permanently purged from the source, so
* a transport failure never destroys mail (the worst case is a
* message left in both places rather than lost). The
* destination backend is opened here and always closed before
* returning.
*
* @param object $source_backend already-open MailBackend for
* the account the messages currently live in
* @param string $source source folder name
* @param array $uids list of source IMAP UIDs to move
* @param int $destination_account_id account id to move into
* @param string $destination destination folder name in that
* account
* @param array $data base mail data used to build the
* destination backend
* @return int number of messages actually moved (appended to
* the destination and purged from the source)
* @throws ML\MailBackendException if the destination
* account cannot be opened
*/
protected function mailMoveAcrossAccounts($source_backend,
$source, $uids, $destination_account_id, $destination,
$data)
{
$destination_backend = $this->userMailBackend(
$destination_account_id, $data);
$copied_uids = [];
try {
foreach ($uids as $uid) {
$bytes = $source_backend->fetchMessage($source,
$uid);
if ($bytes === '' || $bytes === null) {
continue;
}
$destination_backend->appendMessage($destination,
$bytes);
$copied_uids[] = $uid;
}
} finally {
$destination_backend->close();
}
if (!empty($copied_uids)) {
$source_backend->purgeMessagesBulk($source,
$copied_uids);
}
return count($copied_uids);
}
/**
* Maximum number of UIDs accepted in a single bulk-action
* submission. High enough that any sane user-selected batch
* fits, low enough to prevent pathological forged requests
* with tens of thousands of UIDs from monopolising an IMAP
* connection. The UI shows the page's worth of messages at
* a time (typically 50-100) so 500 leaves headroom for
* "select all + load more" workflows.
*/
const MAIL_BULK_MAX_COUNT = 500;
/**
* Toggles the \Flagged IMAP flag on a single message via UID
* STORE. Backs the starred-column click affordance in the
* inbox view. Expected request parameters: account_id (int),
* folder (string, the source folder), uid (int, the message
* UID), flag (int 0 or 1, the desired final state).
*
* Idempotent: the caller passes the desired final state, not
* "toggle from current," so two browser tabs both clicking
* the same star do not ping-pong. The "flip" UI semantic is
* resolved in the template that renders the link target with
* the inverse of the current state.
*
* Redirects back to the inbox listing on success or failure
* with a flash message; on success the inbox re-renders with
* the new IS_FLAGGED state already reflected by the envelope
* parser.
*
* @return mixed redirectWithMessage result
*/
protected function userMailToggleFlag()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
$uid = $parent->clean(
$_REQUEST['uid'] ?? null, 'int', 0);
$flag = (bool) $parent->clean(
$_REQUEST['flag'] ?? null, 'bool', false);
if ($folder === '' || $uid <= 0) {
return $parent->redirectWithMessage(
tl('mail_element_toggle_flag_invalid'),
['arg', 'account_id', 'folder']);
}
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
$this->userMailLog('toggle-flag',
['folder' => $folder, 'uid' => $uid,
'flag' => '\\Flagged',
'value' => $flag ? '1' : '0']);
$backend->setFlag($folder, $uid, '\\Flagged', $flag);
} catch (ML\MailBackendException $exception) {
return $parent->redirectWithMessage(
$exception->getMessage(),
['arg', 'account_id', 'folder', 'sort_key',
'sort_reverse', 'filter', 'unread_only',
'flagged_only']);
} finally {
if ($backend !== null) {
$backend->close();
}
}
$_REQUEST['arg'] = 'listMessages';
$_REQUEST['folder'] = $folder;
return $parent->redirectWithMessage('',
['arg', 'account_id', 'folder', 'sort_key',
'sort_reverse', 'filter', 'unread_only',
'flagged_only']);
}
/**
* Handles a folder-management action (create, rename, delete)
* submitted from the side panel. Expected request parameters:
* account_id (int), folder_action ('create'|'rename'|'delete'),
* folder (existing name, required for rename/delete),
* new_name (the new name, required for create/rename).
* Validates that the source folder (for rename/delete) is in
* the cached folder list, selectable, and not special-use or
* INBOX. After a successful operation re-fetches the folder
* list so the UI redraws with the change. Redirects back to
* the previous page with a flash message.
*
* @return mixed result of redirectWithMessage
*/
protected function userMailFolderAction()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$op = $parent->clean(
$_REQUEST['folder_action'] ?? null, 'string', '');
$folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
$new_name = $parent->clean(
$_REQUEST['new_name'] ?? null, 'imap_arg', '');
if (!in_array($op, ['create', 'rename', 'delete'], true)) {
return $parent->redirectWithMessage(
tl('mail_element_folder_invalid'),
['arg', 'account_id']);
}
/* common validation: rename and delete need a source
folder we can find in the cached list, selectable, and
not protected (INBOX or special-use). */
if ($op === 'rename' || $op === 'delete') {
$check = $this->userMailFolderProtected($account_id,
$folder);
if ($check !== '') {
return $parent->redirectWithMessage($check,
['arg', 'account_id']);
}
}
if ($op === 'create' || $op === 'rename') {
$valid = '';
if ($new_name === '') {
$valid = tl('mail_element_folder_name_required');
} else if (strlen($new_name) >
self::MAIL_FOLDER_NAME_MAX_LEN) {
$valid = tl('mail_element_folder_name_too_long',
self::MAIL_FOLDER_NAME_MAX_LEN);
} else if (preg_match('/[\x00-\x1F\x7F"%*]/',
$new_name)) {
$valid = tl('mail_element_folder_name_invalid');
} else if (strcasecmp($new_name, 'INBOX') === 0) {
$valid = tl('mail_element_folder_protected');
}
if ($valid !== '') {
return $parent->redirectWithMessage($valid,
['arg', 'account_id']);
}
}
$this->userMailLog('folder-op', ['op' => $op,
'folder' => $folder, 'new_name' => $new_name]);
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
if ($op === 'create') {
$backend->createFolder($new_name);
$this->userMailArchiveLog(
"CREATE $new_name OK");
} else if ($op === 'rename') {
$backend->renameFolder($folder, $new_name);
$this->userMailArchiveLog(
"RENAME $folder -> $new_name OK");
} else {
$backend->deleteFolder($folder);
$this->userMailArchiveLog(
"DELETE $folder OK");
}
} catch (ML\MailBackendException $exception) {
$detail = ($op === 'create') ? "CREATE $new_name" :
(($op === 'rename') ?
"RENAME $folder -> $new_name" :
"DELETE $folder");
$this->userMailArchiveLog(
"$detail FAILED: " . $exception->getMessage());
$this->userMailLog('folder-op-fail', ['op' => $op,
'folder' => $folder, 'new_name' => $new_name,
'error' => $exception->getMessage()]);
return $parent->redirectWithMessage(
$exception->getMessage(),
['arg', 'account_id']);
} finally {
if ($backend !== null) {
$backend->close();
}
}
$_REQUEST['arg'] = 'listMessages';
$this->userMailLog('folder-op-ok', ['op' => $op,
'folder' => $folder, 'new_name' => $new_name]);
/* refresh the cached folder list so the side panel
re-renders with the change on the next page load. */
$this->parent->model("Mail")->invalidateFolders($account_id);
unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
/* rename/delete change folder identities, so drop the
per-account expanded-folder map; otherwise stale
entries linger for paths that no longer exist or have
been renamed. create doesn't invalidate existing
entries. */
if ($op === 'rename' || $op === 'delete') {
unset($_SESSION["MAIL_FOLDER_EXPANDED"][$account_id]);
}
if ($op === 'create') {
$flash = tl('mail_element_folder_create_ok', $new_name);
} else if ($op === 'rename') {
$flash = tl('mail_element_folder_rename_ok', $new_name);
} else {
$flash = tl('mail_element_folder_delete_ok', $folder);
}
return $parent->redirectWithMessage($flash,
['arg', 'account_id']);
}
/**
* Returns an empty string when the folder is safe to rename
* or delete; otherwise returns a localized error message
* explaining the refusal. A folder is protected when it (a)
* is not in the cached folder list (we only act on known
* folders to prevent name-injection from forged form posts),
* (b) is not selectable (a namespace placeholder), (c)
* carries a RFC 6154 special-use attribute (\Sent, \Trash,
* \Drafts, \Junk, \Archive, \All, \Flagged), or (d) is named
* INBOX (special-cased by RFC 3501 §5.1 even without a
* special-use attribute).
*
* @param int $account_id the account whose cached folder
* list we look up
* @param string $folder the folder name being acted on
* @return string empty on safe; otherwise a localized error
* message explaining the refusal
*/
protected function userMailFolderProtected($account_id,
$folder)
{
if ($folder === '' || strcasecmp($folder, 'INBOX') === 0) {
return tl('mail_element_folder_protected');
}
$folders = $this->parent->model("Mail")
->foldersFor($account_id);
foreach ($folders as $folder_row) {
if ($folder_row['NAME'] === $folder) {
if (empty($folder_row['SELECTABLE'])) {
return tl('mail_element_folder_protected');
}
if (!empty($folder_row['SPECIAL_USE'])) {
return tl('mail_element_folder_protected');
}
return '';
}
}
return tl('mail_element_folder_invalid');
}
/**
* Maximum length of a user-entered folder name. IMAP itself
* does not formally cap mailbox name length, but in practice
* most servers refuse names longer than around 255 octets;
* we cap a little below that to leave room for the encoding
* overhead modified UTF-7 imposes on non-ASCII names. Long
* enough for any legitimate user-chosen name.
*/
const MAIL_FOLDER_NAME_MAX_LEN = 200;
/**
* Handles an inline-rename of a mail account's display name,
* submitted from the side-panel long-press UI. Expected
* request parameters: account_id (int), new_name (string).
* Validates that the user owns the account, that the new
* name is non-empty, under MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN
* characters, and contains no control characters. Updates
* only the DISPLAY_NAME column via the model method of the
* same purpose, then redirects back to the mail entry point
* so the side panel re-renders with the new label.
*
* @return mixed result of redirectWithMessage
*/
protected function userMailAccountRename()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$new_name = $parent->clean(
$_REQUEST['new_name'] ?? null, 'string', '');
$new_name = trim($new_name);
if ($new_name === '') {
return $parent->redirectWithMessage(
tl('mail_element_account_rename_empty'));
}
if (strlen($new_name) >
self::MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN) {
return $parent->redirectWithMessage(tl(
'mail_element_account_rename_too_long',
self::MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN));
}
if (preg_match('/[\x00-\x1F\x7F]/', $new_name)) {
return $parent->redirectWithMessage(
tl('mail_element_account_rename_invalid'));
}
$account_model = $parent->model("MailAccount");
$existing = $account_model->getAccount($account_id,
$data["USER_ID"]);
if (!$existing) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$account_model->updateDisplayName($account_id,
$data["USER_ID"], $new_name);
return $parent->redirectWithMessage(
tl('mail_element_account_rename_ok', $new_name));
}
/**
* Maximum length of a mail account's user-facing display
* name. Same cap as folder names -- 200 is arbitrary but
* generous; the DB column itself is much larger.
*/
const MAIL_ACCOUNT_DISPLAY_NAME_MAX_LEN = 200;
/**
* Maximum length per logged key-value pair after sanitization,
* so even an adversarial IMAP server response can't make one
* mail.log line gigantic. Long values get truncated with a "…"
* suffix.
*/
const MAIL_LOG_FIELD_MAX_LEN = 200;
/**
* Generalized one-line log writer used by the IMAP/SMTP
* instrumentation across this component. Each call emits one
* record of the form
* [<rfc822-date>] <tag>: k1=v1 k2=v2 ...
* so an operator tailing mail.log gets a greppable trail.
* Values are sanitized: newlines and control characters get
* squashed to single spaces and the result is truncated to
* MAIL_LOG_FIELD_MAX_LEN. Keys are emitted as-is and assumed
* to be alphanumeric.
*
* Gates and rotation happen inside SmtpClient::appendLog so
* disabled logging is a silent no-op and a long-running
* install doesn't grow mail.log without bound.
*
* @param string $tag short identifier for the kind of event
* (e.g. "imap-open", "imap-select", "folder-create");
* shows up after the timestamp in the log line
* @param array $fields associative array of key => value
* fields to include after the tag; values are
* stringified and sanitized
* @internal Used by ImapMailBackend during the in-progress
* backend migration; can revert to protected (or move
* into ImapMailBackend) once the patch 12 cleanup folds
* the IMAP-protocol helpers into the backend
*/
public function userMailLog($tag, $fields = [])
{
$parts = [];
foreach ($fields as $key => $value) {
$clean = str_replace(["\r", "\n", "\t"], " ",
(string) $value);
if (strlen($clean) > self::MAIL_LOG_FIELD_MAX_LEN) {
$clean = substr($clean, 0,
self::MAIL_LOG_FIELD_MAX_LEN) . "…";
}
$parts[] = $key . "=" . $clean;
}
$line = "[" . date(DATE_RFC822) . "] " . $tag;
if (!empty($parts)) {
$line .= ": " . implode(" ", $parts);
}
$line .= "\n";
SmtpClient::appendLog($line);
}
/**
* Appends a one-line note about the IMAP archive step to
* LOG_DIR/mail.log. Lives alongside the SmtpClient transcript
* lines so an admin tailing the log file sees the whole
* send-then-archive sequence in one place.
*
* @param string $message free-form description of what
* happened during the archive step
*/
protected function userMailArchiveLog($message)
{
$this->userMailLog('sent-archive', ['msg' => $message]);
}
/**
* Fetches a single message body and prepares $data for the
* single-message view. Falls back to the inbox view on any
* IMAP error.
*
* @return mixed $data for MailElement, or redirectWithMessage
* on connect failure
*/
protected function userMailViewMessage()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
if ($uid <= 0) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
} catch (ML\MailBackendException $exception) {
return $parent->redirectWithMessage(
$exception->getMessage());
}
$data["CONTENT_PANE"] = "message";
$data["ACCOUNT_ID"] = $account_id;
$data["ACCOUNT_DISPLAY_NAME"] = $parent->clean(
$backend->displayName(), "string", "");
$data["UID"] = $uid;
$data["MESSAGE_BODY"] = "";
$requested_folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
$default_folder = $backend->defaultFolder();
$folder = $requested_folder !== "" ? $requested_folder :
$default_folder;
$data["ACTIVE_FOLDER"] = $folder;
/* The message-view back link echoes the active folder
name; ACTIVE_FOLDER itself stays raw for URLs/IMAP, so
provide a cleaned copy for display. */
$data["ACTIVE_FOLDER_HTML"] = $parent->clean($folder,
"string", "");
/* the message-view template's move-dropdown needs the
folder list; populate from the session cache when
present, else fetch fresh. handles the deep-link
case where the user arrives at a viewMessage URL
without first having rendered the inbox. */
$mail_model = $this->parent->model("Mail");
$cached_folders = $mail_model->cachedFolders($account_id);
if ($cached_folders !== null) {
$data["FOLDERS"] = $cached_folders;
}
try {
if (empty($data["FOLDERS"])) {
$data["FOLDERS"] = $backend->listFolders();
$backend->annotateUnreadCounts($data["FOLDERS"]);
$mail_model->cacheFolders($account_id,
$data["FOLDERS"]);
}
$this->userMailDecorateFolderDisplay($data);
$bytes = $backend->fetchMessage($folder, $uid);
$data["MESSAGE_BODY"] = $bytes;
$data["MIME_MESSAGE"] = MimeMessage::parse($bytes);
$data["MESSAGE_VIEW"] = $this->userMailBuildMessageView(
$data["MIME_MESSAGE"], $bytes);
/* Verify the message's DKIM signature for the security
badge. verify() returns a status (pass/fail/no_key/
none) plus the signing domain and selector; a 'none'
result means no signature and no badge is shown. The
domain and selector are cleaned for display since they
come from the received message. The lookup does a DNS
query for the signer's key, so it runs once here on
view rather than on every render of the pane. */
$verdict = ML\DkimKey::verify($bytes);
if (($verdict['status'] ?? ML\DkimKey::VERIFY_NONE) !==
ML\DkimKey::VERIFY_NONE) {
$data["DKIM_STATUS"] = $verdict['status'];
$data["DKIM_DOMAIN"] = $parent->clean(
$verdict['domain'] ?? '', "string", "");
$data["DKIM_SELECTOR"] = $parent->clean(
$verdict['selector'] ?? '', "string", "");
$data["DKIM_DNS_NAME"] = $parent->clean(
$verdict['dns_name'] ?? '', "string", "");
$data["DKIM_KEY_FOUND"] =
!empty($verdict['key_found']);
$data["DKIM_ALGORITHM"] = $parent->clean(
$verdict['algorithm'] ?? '', "string", "");
$data["DKIM_SIGNED_HEADERS"] = $parent->clean(
$verdict['signed_headers'] ?? '', "string", "");
$data["DKIM_BODY_HASH_HEADER"] = $parent->clean(
$verdict['body_hash_header'] ?? '', "string", "");
$data["DKIM_BODY_HASH_COMPUTED"] = $parent->clean(
$verdict['body_hash_computed'] ?? '', "string",
"");
$data["DKIM_BODY_HASH_MATCH"] =
!empty($verdict['body_hash_match']);
$data["DKIM_SIGNATURE_OK"] =
!empty($verdict['signature_ok']);
}
/* mark as read on view, mirroring the implicit
\Seen-on-FETCH behaviour the old IMAP path got
from plain RFC822 FETCH. With BODY.PEEK and the
in-process MailSite path both leaving flags
alone, the mark-read becomes an explicit step
that fires for both backend types. A failure
here is logged but not propagated: the user
still sees the message, the read state will
catch up on the next interaction. */
try {
$backend->setFlag($folder, $uid, '\\Seen', true);
} catch (ML\MailBackendException $exception) {
$this->userMailLog('mark-read-fail',
['folder' => $folder, 'uid' => $uid,
'err' => $exception->getMessage()]);
}
} catch (ML\MailBackendException $exception) {
$data["IMAP_ERROR"] = $parent->clean(
$exception->getMessage(), "string", "");
} finally {
$backend->close();
}
ML\MailUnreadProbe::invalidate($data["USER_ID"]);
unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
$this->userMailDecorateTrustSender($data, $backend, $folder);
return $data;
}
/**
* Decides whether to offer a "trust this sender" control on the
* message view and supplies the data the template needs for it.
* Two mutually exclusive notices are possible, and both apply
* only under the Spam Insecure posture, only for the local
* MailSite account, and only for a message that arrived without
* TLS (read from the delivery Received header):
* - in the Junk folder, when the sender is not already
* trusted, a notice offering to trust the sender (which
* moves the message to the inbox);
* - in any other folder, a notice offering to untrust the
* sender and send this and future mail to Junk.
* The envelope sender is read from the Return-Path the delivery
* server recorded, since that is the address the spam routing
* keyed on and need not match the From header. Sets
* SHOW_TRUST_SENDER or SHOW_UNTRUST_SENDER accordingly, with
* TRUST_SENDER carrying the cleaned address for display.
*
* @param array &$data message-view data to add fields to
* @param object $backend the mail backend for the account
* @param string $folder the folder the message is in
*/
protected function userMailDecorateTrustSender(&$data, $backend,
$folder)
{
$parent = $this->parent;
$data["SHOW_TRUST_SENDER"] = false;
$data["SHOW_UNTRUST_SENDER"] = false;
$posture = C\nsdefined("MAIL_DELIVERY_SECURITY") ?
C\p('MAIL_DELIVERY_SECURITY') : 'insecure';
if ($posture !== 'spam' || !$backend->isSyntheticAccount()) {
return;
}
$bytes = $data["MESSAGE_BODY"] ?? '';
$insecure_body = (string) $bytes;
$insecure_break = strpos($insecure_body, "\r\n\r\n");
if ($insecure_break === false) {
$insecure_break = strpos($insecure_body, "\n\n");
}
$insecure_header = ($insecure_break === false) ?
$insecure_body :
substr($insecure_body, 0, $insecure_break);
$arrived_insecurely = true;
if (preg_match('/^Received:.*?\swith\s+(\S+)/mis',
$insecure_header, $insecure_match)) {
$arrived_insecurely = strpos(
strtoupper($insecure_match[1]), 'ESMTPS') !== 0;
}
if (!$arrived_insecurely) {
return;
}
$sender = MailHeaderParser::envelopeSender($bytes);
if ($sender === '') {
return;
}
$data["TRUST_SENDER"] = $parent->clean($sender, "string", "");
$allow_model = $parent->model("mailSenderAllow");
if ($folder === ML\MailSiteFactory::JUNK_FOLDER) {
if (!$allow_model->isAllowed($data["USER_ID"], $sender)) {
$data["SHOW_TRUST_SENDER"] = true;
}
return;
}
$data["SHOW_UNTRUST_SENDER"] = true;
}
/**
* Adds the envelope sender of a Junk message to the signed-in
* user's trusted-sender list and moves that message to the
* inbox. Used by the "trust this sender" control the message
* view offers for mail that the Spam Insecure posture filed in
* Junk for arriving without TLS. The sender address is taken
* from the stored message's Return-Path rather than from the
* request, so a request cannot trust an arbitrary address; the
* move and the list addition are both scoped to the signed-in
* user. Expected request parameters: account_id (int), uid
* (int), folder (the Junk folder the message is in).
*
* @return mixed redirectWithMessage result
*/
protected function userMailTrustSender()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
$folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
if ($uid <= 0 || $folder === '') {
return $parent->redirectWithMessage(
tl('social_component_invalid_account'));
}
$backend = null;
$sender = '';
try {
$backend = $this->userMailBackend($account_id, $data);
$bytes = $backend->fetchMessage($folder, $uid);
$sender = MailHeaderParser::envelopeSender($bytes);
if ($sender !== '') {
$allow_model = $parent->model("mailSenderAllow");
$allow_model->addSender($data["USER_ID"], $sender);
$backend->moveMessage($folder, $uid, "INBOX");
$this->userMailLog('trust-sender',
['folder' => $folder, 'uid' => $uid]);
}
} catch (ML\MailBackendException $exception) {
return $parent->redirectWithMessage(
$exception->getMessage(),
['arg', 'account_id', 'folder']);
} finally {
if ($backend !== null) {
$backend->close();
}
}
ML\MailUnreadProbe::invalidate($data["USER_ID"]);
unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
$message = ($sender === '') ?
tl('social_component_trust_sender_none') :
tl('social_component_trust_sender_done', $sender);
$_REQUEST['arg'] = 'listMessages';
$_REQUEST['folder'] = $folder;
return $parent->redirectWithMessage($message,
['arg', 'account_id', 'folder']);
}
/**
* Removes the envelope sender of a message from the signed-in
* user's trusted-sender list and moves that message to Junk.
* Used by the "do not trust" control the message view offers in
* a non-Junk folder for mail that arrived without TLS under the
* Spam Insecure posture: it both files this message in Junk and,
* by dropping the sender from the trusted-sender list, lets
* future mail from that sender be junked again. The sender
* address is taken from the stored message's Return-Path rather
* than from the request, so a request cannot untrust an
* arbitrary address; the move and the list change are both
* scoped to the signed-in user. Expected request parameters:
* account_id (int), uid (int), folder (the source folder).
*
* @return mixed redirectWithMessage result
*/
protected function userMailUntrustSender()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
$folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
if ($uid <= 0 || $folder === '') {
return $parent->redirectWithMessage(
tl('social_component_invalid_account'));
}
$backend = null;
$sender = '';
try {
$backend = $this->userMailBackend($account_id, $data);
$bytes = $backend->fetchMessage($folder, $uid);
$sender = MailHeaderParser::envelopeSender($bytes);
if ($sender !== '') {
$allow_model = $parent->model("mailSenderAllow");
$allow_model->removeSender($data["USER_ID"], $sender);
$backend->moveMessage($folder, $uid,
ML\MailSiteFactory::JUNK_FOLDER);
$this->userMailLog('untrust-sender',
['folder' => $folder, 'uid' => $uid]);
}
} catch (ML\MailBackendException $exception) {
return $parent->redirectWithMessage(
$exception->getMessage(),
['arg', 'account_id', 'folder']);
} finally {
if ($backend !== null) {
$backend->close();
}
}
ML\MailUnreadProbe::invalidate($data["USER_ID"]);
unset($_SESSION["MAIL_FOLDER_UNREAD"][$account_id]);
$message = ($sender === '') ?
tl('social_component_trust_sender_none') :
tl('social_component_untrust_sender_done', $sender);
$_REQUEST['arg'] = 'listMessages';
$_REQUEST['folder'] = $folder;
return $parent->redirectWithMessage($message,
['arg', 'account_id', 'folder']);
}
/**
* Builds the MailSite account properties pane data, reached from
* the edit pencil on the local MailSite account row. Supplies
* the signed-in user's current aliases and the configured mail
* domains for the add form.
*
* @return array the mail-view data with CONTENT_PANE editMailsite
*/
protected function userMailEditMailsite()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$data["CONTENT_PANE"] = "editMailsite";
$username = $_SESSION["USER_NAME"] ?? '';
$domains = ML\MailSiteFactory::localDomains();
$first_domain = empty($domains) ? 'localhost' : $domains[0];
$data["MAILSITE_DISPLAY_NAME"] = ($username === '') ? '' :
$parent->clean($username . '@' . $first_domain,
"string", "");
$alias_model = $parent->model("mailAlias");
$aliases = $alias_model->aliasesForUser($data["USER_ID"]);
$data["ALIASES"] = [];
foreach ($aliases as $entry) {
$local = $parent->clean($entry["ALIAS"], "string", "");
$alias_domain = $parent->clean($entry["DOMAIN"],
"string", "");
$data["ALIASES"][] = [
'ALIAS' => $local,
'DOMAIN' => $alias_domain,
'ADDRESS' => $local . '@' . $alias_domain];
}
$data["MAIL_DOMAINS_LIST"] = [];
foreach ($domains as $domain) {
$data["MAIL_DOMAINS_LIST"][] =
$parent->clean($domain, "string", "");
}
if (!empty($_SESSION["MAIL_ALIAS_ERROR"])) {
$data["ALIAS_ERROR"] = $parent->clean(
$_SESSION["MAIL_ALIAS_ERROR"], "string", "");
unset($_SESSION["MAIL_ALIAS_ERROR"]);
}
return $data;
}
/**
* Adds or removes one alias for the signed-in user, then returns
* to the MailSite properties pane. The alias local-part comes
* from the request; the chosen domain only affects the
* confirmation message, since an alias is stored once and is
* valid at every configured mail domain. An add that collides
* with an account name or another user's alias is rejected with
* a message. The change is scoped to the signed-in user.
*
* @return mixed redirectWithMessage result
*/
protected function userMailAliasAction()
{
$parent = $this->parent;
$user_id = $_SESSION["USER_ID"];
$action = $parent->clean(
$_REQUEST["alias_action"] ?? "", "string", "");
$alias = $parent->clean(
$_REQUEST["alias"] ?? "", "string", "");
$domain = strtolower($parent->clean(
$_REQUEST["alias_domain"] ?? "", "string", ""));
$alias_model = $parent->model("mailAlias");
if (!in_array($domain, ML\MailSiteFactory::localDomains())) {
$_SESSION["MAIL_ALIAS_ERROR"] =
tl("social_component_alias_bad_domain");
$_REQUEST["arg"] = "editMailsite";
return $parent->redirectWithMessage("", ["arg"]);
}
$address = $alias . '@' . $domain;
if ($action === "remove") {
$alias_model->removeAlias($user_id, $alias, $domain);
} else if ($action === "add") {
if (!$alias_model->addAlias($user_id, $alias, $domain)) {
$_SESSION["MAIL_ALIAS_ERROR"] = tl(
"social_component_alias_unavailable", $address);
}
}
$_REQUEST["arg"] = "editMailsite";
return $parent->redirectWithMessage("", ["arg"]);
}
/**
* Builds the unified list of From identities for the compose
* dropdown: one entry per address the signed-in user may send
* as. For the local MailSite account this is the primary
* address and each owned alias address; for each external mail
* account it is that account's email. Every entry carries the
* owning account id and the address; the option value joins them
* as "<account_id>|<address>" so the send handler can route to
* the right account and, for the local account, the right
* identity. The label is the bare email address, not the
* account's shorthand display name.
*
* @param array $data mail data carrying USER_ID, ACCOUNTS, and
* MAILSITE_ENABLED
* @return array list of ['VALUE' => ..., 'ADDRESS' => ...,
* 'ACCOUNT_ID' => int]
*/
protected function userMailFromOptions($data)
{
$parent = $this->parent;
$options = [];
if (!empty($data["MAILSITE_ENABLED"])) {
$backend = $this->userMailBackend(self::MAILSITE_ACCOUNT_ID, $data);
$primary = $backend->senderEmail();
$backend->close();
foreach ($this->parent->model("mailAlias")->identitiesFor(
$data["USER_ID"], $primary) as $address) {
$clean = $parent->clean($address, "string", "");
$options[] = [
'VALUE' => self::MAILSITE_ACCOUNT_ID . '|' .
$clean,
'ADDRESS' => $clean,
'ACCOUNT_ID' => self::MAILSITE_ACCOUNT_ID];
}
}
foreach (($data["ACCOUNTS"] ?? []) as $entry) {
if (!empty($entry["IS_MAILSITE"])) {
continue;
}
$aid = (int) ($entry["ID"] ?? 0);
$address = $parent->clean(
MailAccountModel::senderEmail($entry),
"string", "");
if ($address === '') {
continue;
}
$options[] = [
'VALUE' => $aid . '|' . $address,
'ADDRESS' => $address,
'ACCOUNT_ID' => $aid];
}
return $options;
}
/**
* Builds the HTML-cleaned view-model the message-view template
* echoes, so MailElement can emit message content raw. The
* parsed MimeMessage stays untouched (it is a message model,
* not a view model, and its body_html is sanitized separately
* by HtmlSanitizer at render time); this returns a plain array
* of cleaned display strings:
* HEADERS map of subject/from/to/cc/date, each cleaned
* BODY_TEXT the plaintext part, cleaned
* BODY_RAW the full RFC822 source, cleaned (raw-view <pre>)
* ATTACHMENTS list of {FILENAME_HTML, EXT_LABEL} in the
* original attachment order so the view can pair
* each with the matching $index for its
* download URL
*
* @param object $mime the parsed MimeMessage
* @param string $raw_bytes the full RFC822 message source
* @return array the cleaned view-model
*/
protected function userMailBuildMessageView($mime, $raw_bytes)
{
$parent = $this->parent;
$headers = $mime->headers;
$clean_headers = [];
foreach (['subject', 'from', 'to', 'cc', 'date']
as $header_key) {
$clean_headers[$header_key] = $parent->clean(
$headers[$header_key] ?? '', "string", "");
}
$attachments = [];
foreach ($mime->attachments as $attachment) {
$filename = $attachment['filename'] ?? '';
$dot = strrpos($filename, '.');
if ($dot === false || $dot === strlen($filename) - 1) {
$ext_label = 'FILE';
} else {
$ext_label = substr(strtoupper(substr($filename,
$dot + 1)), 0, 4);
}
$attachments[] = [
"FILENAME_HTML" => $parent->clean($filename,
"string", ""),
"EXT_LABEL" => $parent->clean($ext_label,
"string", ""),
];
}
return [
"HEADERS" => $clean_headers,
"BODY_TEXT" => $parent->clean($mime->body_text,
"string", ""),
"BODY_RAW" => $parent->clean((string) $raw_bytes,
"string", ""),
"ATTACHMENTS" => $attachments,
];
}
/**
* Streams a single attachment from a specified message as an
* HTTP download. Refetches the full RFC822 message, parses it,
* locates the attachment at the given index, sends Content-Type
* and Content-Disposition: attachment headers, writes the
* decoded bytes to the response body, and exits the framework
* dispatch so nothing else writes to the response.
*
* Required request parameters: account_id (int), uid (int),
* index (int >= 0), folder (imap_arg, optional -- defaults to
* the account's DEFAULT_FOLDER or INBOX).
*
* Errors (bad params, missing account, IMAP failure, index out
* of range) redirect back to the account list with a message.
*
* @return mixed null when the download succeeded (response has
* already been written and exit called), or the result of
* redirectWithMessage on error
*/
protected function userMailDownloadAttachment()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean($_REQUEST['account_id'] ?? null,
'int', 0);
$uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
$index = $parent->clean($_REQUEST['index'] ?? null,
'int', -1);
$folder = $parent->clean($_REQUEST['folder'] ?? null,
'imap_arg', '');
if ($uid <= 0 || $index < 0) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
if ($folder === '') {
$folder = $backend->defaultFolder();
}
$raw = $backend->fetchMessage($folder, $uid);
} catch (ML\MailBackendException $exception) {
if ($backend !== null) {
$backend->close();
}
return $parent->redirectWithMessage(
$exception->getMessage());
}
$backend->close();
$mime = MimeMessage::parse($raw);
if (!isset($mime->attachments[$index])) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$this->userMailStreamAttachment(
$mime->attachments[$index]);
return null;
}
/**
* Streams an attachment as an HTTP download response. Writes
* Content-Type from the attachment's declared mime type
* (falling back to application/octet-stream), Content-Length
* from the byte count, Content-Disposition: attachment with a
* filename parameter so browsers always save rather than try
* to display the bytes inline. Calls exit after the body is
* written, terminating the framework dispatch.
*
* The filename is sanitised to prevent header-injection
* (stripping CRLF and double-quote) and falls back to a
* generic name if the attachment had none. A future
* enhancement could use RFC 5987 filename* encoding for full
* Unicode support; today non-ASCII filenames fall back to the
* generic name to stay within ASCII-safe header bytes.
*
* @param array $attachment one entry from MimeMessage's
* attachments list: filename, mime_type, size, content
*/
protected function userMailStreamAttachment($attachment)
{
$parent = $this->parent;
$mime_type = $attachment['mime_type'] ?:
'application/octet-stream';
$raw_name = $attachment['filename'] ?: 'attachment';
/* strip CRLF / quotes that would let an attacker inject
extra headers; also reject the value if it is empty or
contains non-ASCII (a future patch can switch to
filename* / RFC 5987 to keep Unicode names). */
$safe_name = str_replace(["\r", "\n", '"'], '', $raw_name);
if ($safe_name === '' ||
preg_match('/[\x80-\xFF]/', $safe_name)) {
$safe_name = 'attachment';
}
$parent->web_site->header('Content-Type: ' . $mime_type);
$parent->web_site->header('Content-Length: ' .
$attachment['size']);
$parent->web_site->header(
'Content-Disposition: attachment; filename="' .
$safe_name . '"');
$parent->web_site->header(
'X-Content-Type-Options: nosniff');
e($attachment['content']);
\seekquarry\atto\webExit();
}
/**
* Streams a zip containing every attachment from a message
* as an HTTP download. Re-fetches the full RFC822 via IMAP
* (same path as userMailDownloadAttachment), parses it,
* packs every attachment into a zip via ZipArchive
* (filenames deduplicated by numeric suffix so two
* attachments with the same display name still both end up
* in the archive), and streams the zip bytes. The zip name
* is derived from the message subject, sanitised to ASCII
* letters/digits/-_; an empty subject yields the generic
* "attachments.zip".
*
* Required request parameters: account_id (int), uid (int),
* folder (imap_arg, optional).
*
* Errors (bad params, missing account, IMAP failure,
* message with no attachments) redirect back with a message
* rather than streaming an empty or malformed zip.
*
* @return mixed null when streamed (response written and
* exit called), or the result of redirectWithMessage
* on error
*/
protected function userMailDownloadAllAttachments()
{
$parent = $this->parent;
$data = $this->userMailBaseData();
$account_id = $parent->clean(
$_REQUEST['account_id'] ?? null, 'int', 0);
$uid = $parent->clean($_REQUEST['uid'] ?? null, 'int', 0);
$folder = $parent->clean(
$_REQUEST['folder'] ?? null, 'imap_arg', '');
if ($uid <= 0) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$backend = null;
try {
$backend = $this->userMailBackend($account_id, $data);
if ($folder === '') {
$folder = $backend->defaultFolder();
}
$raw = $backend->fetchMessage($folder, $uid);
} catch (ML\MailBackendException $exception) {
if ($backend !== null) {
$backend->close();
}
return $parent->redirectWithMessage(
$exception->getMessage());
}
$backend->close();
$mime = MimeMessage::parse($raw);
if (empty($mime->attachments)) {
return $parent->redirectWithMessage(
tl("social_component_invalid_account"));
}
$this->userMailStreamAttachmentsZip(
$mime->headers['subject'] ?? '',
$mime->attachments);
return null;
}
/**
* Builds an in-disk zip containing the given attachments,
* streams it as a binary download, then deletes the temp
* file and exits the framework dispatch. ZipArchive is a
* core PHP extension so this is not a new dependency.
* Duplicate filenames inside one message are handled by
* appending " (N)" before the extension; the first
* occurrence keeps its name. The zip is named from the
* message subject (sanitised to ASCII letters/digits/-_),
* or "attachments" when no usable subject is available.
*
* @param string $subject the message Subject header (used
* to derive the zip filename)
* @param array $attachments list from MimeMessage::parse
*/
protected function userMailStreamAttachmentsZip($subject,
$attachments)
{
$parent = $this->parent;
$zip_name = preg_replace('/[^A-Za-z0-9._-]+/', '_',
trim($subject));
$zip_name = trim($zip_name, '_');
if ($zip_name === '') {
$zip_name = 'attachments';
}
$zip_name = substr($zip_name, 0, 80) . '.zip';
$tmp_path = tempnam(sys_get_temp_dir(), 'yioop_attach_');
if ($tmp_path === false) {
return;
}
$zip = new \ZipArchive();
if ($zip->open($tmp_path, \ZipArchive::CREATE |
\ZipArchive::OVERWRITE) !== true) {
unlink($tmp_path);
return;
}
$seen_names = [];
foreach ($attachments as $attach) {
$name = $attach['filename'] ?: 'attachment';
/* dedupe within the archive: same filename appearing
twice in one message gets " (2)", " (3)" etc.
appended before the extension. */
$unique = $name;
$copy = 2;
while (isset($seen_names[$unique])) {
$dot = strrpos($name, '.');
if ($dot === false) {
$unique = $name . ' (' . $copy . ')';
} else {
$unique = substr($name, 0, $dot) . ' (' .
$copy . ')' . substr($name, $dot);
}
$copy++;
}
$seen_names[$unique] = true;
$zip->addFromString($unique, $attach['content']);
}
$zip->close();
$size = filesize($tmp_path);
$bytes = file_get_contents($tmp_path);
unlink($tmp_path);
$parent->web_site->header('Content-Type: application/zip');
$parent->web_site->header('Content-Length: ' . $size);
$parent->web_site->header(
'Content-Disposition: attachment; filename="' .
$zip_name . '"');
$parent->web_site->header(
'X-Content-Type-Options: nosniff');
e($bytes);
\seekquarry\atto\webExit();
}
/**
* Adds up how much room every file in a folder takes, including
* those in folders inside it, leaving out the folder of earlier
* versions. A page's resources sit in a folder with folders of their
* own beside them, so the size of the folder itself says nothing;
* this walks the lot. It is used to decide whether a page's
* resources are few enough to be offered as one download, which has
* to be known before any of the archive is built rather than
* discovered part way through it.
*
* @param string $folder path of the folder to measure
* @return int total size in bytes of every file at or below it that
* would go into a download
*/
private function folderContentsLen($folder)
{
$total = 0;
if (!is_dir($folder)) {
return $total;
}
$walker = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($folder,
\FilesystemIterator::SKIP_DOTS));
$prefix_len = strlen(rtrim($folder, "/")) + 1;
foreach ($walker as $item) {
if ($item->isFile() && !$this->inResourceArchive(
substr($item->getPathname(), $prefix_len))) {
$total += $item->getSize();
}
}
return $total;
}
/**
* Says whether something in a page's resource folder is part of the
* record of earlier versions rather than a resource of the page as
* it stands now. Yioop keeps previous versions of a page's resources
* in a folder of its own beside them, which someone asking for a
* copy of the page's resources is not asking for.
*
* @param string $in_folder path of the item within the resource
* folder, with the folder's own path taken off the front
* @return bool whether the item belongs to the version record
*/
private function inResourceArchive($in_folder)
{
return in_array(self::RESOURCE_ARCHIVE_FOLDER,
explode("/", $in_folder));
}
/**
* Puts every file in a folder, and in the folders inside it, into an
* open archive under a folder name of its own, leaving out the
* record of earlier versions. Kept apart from the packing itself so
* that a page's resources and their thumbnails can each be put in
* under their own name by the same code.
*
* @param object $zip open archive to add to
* @param string $folder path of the folder to add
* @param string $in_zip name the folder is to have inside the
* archive
*/
private function addFolderToZip($zip, $folder, $in_zip)
{
if (!is_dir($folder)) {
return;
}
$zip->addEmptyDir($in_zip);
$prefix_len = strlen(rtrim($folder, "/")) + 1;
$walker = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($folder,
\FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST);
foreach ($walker as $item) {
$in_folder = substr($item->getPathname(), $prefix_len);
if ($this->inResourceArchive($in_folder)) {
continue;
}
if ($item->isDir()) {
$zip->addEmptyDir("$in_zip/$in_folder");
} else if ($item->isFile()) {
$zip->addFile($item->getPathname(), "$in_zip/$in_folder");
}
}
}
/**
* Turns a wiki page's name into a name safe to give a file and a
* folder. Spaces become underscores, since a name with spaces in it
* is awkward to handle once downloaded, and anything else outside
* the plain characters a file name may have is url encoded, which
* keeps a page named in any language recognisable rather than
* reduced to nothing. A page with no usable name at all falls back
* to a fixed one.
*
* @param string $page_name name of the wiki page
* @return string name to give the archive and the folder it opens as
*/
private function pageResourcesZipName($page_name)
{
$name = urlencode(str_replace(" ", "_", trim($page_name)));
return ($name === "") ? self::PAGE_RESOURCES_ZIP_FOLDER : $name;
}
/**
* Sends a page's resources and their thumbnails as one zip download.
* Someone who wants a copy of what a page holds would otherwise have
* to fetch the files one at a time, and the folders beside them a
* level at a time. The archive opens as a single folder holding a
* resources and a thumbs folder, so it unpacks as the page keeps
* them rather than as a heap; it is built on disk and read back a
* block at a time rather than held whole, so a large page does not
* cost the always-on server its own size in memory, and the file is
* removed once it has gone out.
*
* @param string $folder path of the page's resource folder
* @param string $thumb_folder path of the folder holding the
* thumbnails of those resources
* @param string $page_name name of the wiki page whose resources
* these are, which names both the file and the folder it opens
* as
* @return bool whether the archive could be made and sent; false
* when there was nowhere to build it or it would not open, so
* the caller can say so rather than send an empty download
*/
private function streamResourcesZip($folder, $thumb_folder,
$page_name)
{
$parent = $this->parent;
$tmp_path = tempnam(sys_get_temp_dir(), 'yioop_resources_');
if ($tmp_path === false) {
return false;
}
$zip = new \ZipArchive();
if ($zip->open($tmp_path, \ZipArchive::CREATE |
\ZipArchive::OVERWRITE) !== true) {
unlink($tmp_path);
return false;
}
$top = $this->pageResourcesZipName($page_name);
$this->addFolderToZip($zip, $folder, "$top/resources");
$this->addFolderToZip($zip, $thumb_folder, "$top/thumbs");
$zip->close();
$size = filesize($tmp_path);
$parent->web_site->header('Content-Type: application/zip');
$parent->web_site->header('Content-Length: ' . $size);
$parent->web_site->header(
'Content-Disposition: attachment; filename="' . $top .
'.zip"');
$parent->web_site->header('X-Content-Type-Options: nosniff');
$parent->web_site->stream(function () use ($tmp_path) {
$handle = fopen($tmp_path, "rb");
try {
while (!feof($handle)) {
$bytes = fread($handle,
C\RESOURCE_STREAM_BLOCK_LEN);
if ($bytes === false || $bytes === "") {
break;
}
yield $bytes;
}
} finally {
fclose($handle);
unlink($tmp_path);
}
});
return true;
}
/**
* Builds the right mail backend for an account id, loading the
* account row first so the backend factory never has to reach
* into a model itself. The synthetic MailSite account needs no
* row; for a real account this looks up the owned row with its
* decrypted password and hands it to the factory.
*
* @param int $account_id the account to build a backend for;
* MAILSITE_ACCOUNT_ID selects the synthetic local account
* @param array $data the userMailBaseData() context, read for
* USER_ID, USER_NAME, and MAILSITE_ENABLED
* @return object a MailBackend bound to the account, ready to
* connect on first use
* @throws object a MailBackendException when the id resolves to
* neither the synthetic account nor an owned IMAP row
*/
protected function userMailBackend($account_id, $data)
{
$account = $this->parent->model("MailAccount")
->getAccountWithPassword($account_id, $data["USER_ID"]);
return ML\MailBackend::forAccountId($account_id, $data,
$account, $this);
}
/**
* Returns the $data scaffold every Mail view starts from.
*
* @return array $data with CONTROLLER, ELEMENT, SCRIPT,
* INCLUDE_STYLES, MAIL_MODE, EXTERNAL_ENABLED,
* MAILSITE_ENABLED, USER_ID, MESSAGES, ACCOUNTS populated
*/
protected function userMailBaseData()
{
$parent = $this->parent;
$mail_mode = C\nsdefined("MAIL_MODE") ? C\p('MAIL_MODE') : 'disabled';
$controller_name = (get_class($parent) == C\NS_CONTROLLERS .
"AdminController") ? "admin" : "group";
$data = [];
$data["CONTROLLER"] = $controller_name;
$data["ELEMENT"] = "mail";
$data["SCRIPT"] = "";
$data["INCLUDE_STYLES"] = ["messages", "mail"];
$data["INCLUDE_SCRIPTS"] ??= [];
$data["INCLUDE_SCRIPTS"][] = "mailmessages";
$data["INCLUDE_SCRIPTS"][] = "mailclone";
$data["SUBTITLE"] = C\PERSONAL_GROUP_PREFIX;
$data["MAIL_MODE"] = $mail_mode;
$data["EXTERNAL_ENABLED"] = in_array($mail_mode,
['external_mail', 'both']);
$data["MAILSITE_ENABLED"] = in_array($mail_mode,
['mailsite', 'both']);
$data["USER_ID"] = $_SESSION['USER_ID'];
$data["USER_NAME"] = $parent->clean(
$_SESSION['USER_NAME'] ?? '', "string", "");
$data["MOBILE"] = !empty($_SERVER["MOBILE"]);
/* Refresh the unread-mail badge here, inside the Mail
activity, where talking to the mail servers is expected
and the listing already connects. The shared page chrome
only reads the cached value (cachedCount), so no general
page ever waits on a slow mailbox to paint the badge; it
catches up the next time the user opens their mail. This
call is a no-op unless external mail is on and the cached
badge has expired. */
$mail_badge_user = (int) $data["USER_ID"];
ML\MailUnreadProbe::count($mail_badge_user,
function () use ($parent, $mail_badge_user) {
return $parent->model("MailAccount")
->getAccountsWithPassword($mail_badge_user);
});
$data["MESSAGES"] = [];
$data["ACCOUNTS"] = [];
/* Active clone job (if any) is surfaced here so every
mail page can paint the cloning account's sidebar row
with the cloning-link badge, not just the
editAccount page where the clone gets started. */
$clone_model = $parent->model("MailClone");
$data["ACTIVE_CLONE_JOB"] =
$clone_model->activeJobForUser($_SESSION['USER_ID']);
/* CURRENT_FOLDER on the job row is a mailbox path the
clone walked off the remote server; the status banner
echoes it, so HTML-clean it here and let the view emit
it raw. (The 5-second JS poll sets the same value via
textContent, which needs no escaping.) */
if (!empty($data["ACTIVE_CLONE_JOB"]["CURRENT_FOLDER"])) {
$data["ACTIVE_CLONE_JOB"]["CURRENT_FOLDER"] =
$parent->clean(
$data["ACTIVE_CLONE_JOB"]["CURRENT_FOLDER"],
"string", "");
}
/* When the latest clone job failed (and no live job has
since replaced it), surface it separately so the status
banner can offer a Retry. Kept out of ACTIVE_CLONE_JOB on
purpose: that key drives the single-job limit and the
"cloning" sidebar badge, which should track only live
work. CURRENT_FOLDER is echoed by the banner, so clean
it the same way. */
$data["FAILED_CLONE_JOB"] = empty($data["ACTIVE_CLONE_JOB"])
? $clone_model->recentFailedJobForUser(
$_SESSION['USER_ID']) : null;
if (!empty($data["FAILED_CLONE_JOB"]["CURRENT_FOLDER"])) {
$data["FAILED_CLONE_JOB"]["CURRENT_FOLDER"] =
$parent->clean(
$data["FAILED_CLONE_JOB"]["CURRENT_FOLDER"],
"string", "");
}
/* Template the clone-error "later succeeded via %s"
string here so the element can emit it raw into a data
attribute without escaping in the view. The %s is left
in place for mailclone.js to substitute the rescue
token client-side. */
$data["CLONE_ERROR_RESOLVED_VIA_TEMPLATE"] =
$parent->clean(tl(
'mail_element_clone_error_resolved_via', '%s'),
"string");
/* per-account folder lists cached by userMailListMessages;
lets every mail pane render the folder list, not just the
inbox pane that fetched it */
$data["FOLDERS_BY_ACCOUNT"] =
$parent->model("Mail")->allCachedFolders();
/* per-account collapsed state for the folder-list
disclosure triangle; an account with no entry defaults to
expanded */
$data["ACCOUNT_COLLAPSED"] =
$_SESSION["MAIL_ACCOUNT_COLLAPSED"] ?? [];
/* per-folder expanded state for the tree-view disclosure
arrows; a folder with no entry defaults to collapsed */
$data["FOLDER_EXPANDED"] =
$_SESSION["MAIL_FOLDER_EXPANDED"] ?? [];
if ($data["MAILSITE_ENABLED"]) {
$username = $_SESSION['USER_NAME'] ?? '';
$domains = trim((string) C\p('MAIL_DOMAINS'));
$first_domain = ($domains === '') ? 'localhost' :
trim(strtok($domains, ','));
$mailsite_email = ($username === '') ? '' :
($username . '@' . $first_domain);
$data["ACCOUNTS"][] = [
'ID' => self::MAILSITE_ACCOUNT_ID,
'USER_ID' => $data["USER_ID"],
'DISPLAY_NAME' => $mailsite_email,
'PROVIDER' => 'mailsite',
'HOST' => '',
'USERNAME' => $username,
'DEFAULT_FOLDER' => 'INBOX',
'IS_MAILSITE' => true,
];
/* Make sure the local MailSite account's folder list is
available as a move destination even when the user
has not opened that account this session. The
backend is local (a directory listing, no network),
so listing folders here is cheap; only fetch when the
session cache has nothing for this account. */
if (empty($data["FOLDERS_BY_ACCOUNT"]
[self::MAILSITE_ACCOUNT_ID])) {
try {
$mailsite_backend = $this->userMailBackend(
self::MAILSITE_ACCOUNT_ID, $data);
$mailsite_folders =
$mailsite_backend->listFolders();
$mailsite_backend->close();
$data["FOLDERS_BY_ACCOUNT"]
[self::MAILSITE_ACCOUNT_ID] =
$mailsite_folders;
} catch (ML\MailBackendException $exception) {
$data["FOLDERS_BY_ACCOUNT"]
[self::MAILSITE_ACCOUNT_ID] = [];
}
}
}
if ($data["EXTERNAL_ENABLED"]) {
$account_model = $parent->model("MailAccount");
$data["ACCOUNTS"] = array_merge(
$data["ACCOUNTS"],
$account_model->getAccountsForUser(
$data["USER_ID"]));
$scheduled_model = $parent->model("MailScheduled");
$rows = $scheduled_model->getMessagesForUser(
$data["USER_ID"]);
$by_account = [];
$now = time();
$any_due = false;
foreach ($rows as $row) {
$aid = (int) $row['ACCOUNT_ID'];
$by_account[$aid] = ($by_account[$aid] ?? 0) + 1;
if (!$any_due && (int) $row['SCHEDULED_AT'] <=
$now &&
($row['STATUS'] ?? '') === 'pending') {
$any_due = true;
}
}
$data["SCHEDULED_PENDING_BY_ACCOUNT"] = $by_account;
if ($any_due && C\MAIL_SCHEDULED_LAZY_DISPATCH) {
MailScheduledDispatcher::dispatch(
$scheduled_model, $account_model, $now);
}
}
/* The account string fields below are echoed into the
accounts sidebar and the account/compose forms, so
HTML-clean them once here at the source and let
MailElement emit them raw. DISPLAY_NAME, USERNAME, and
HOST are user- or server-supplied; DEFAULT_FOLDER comes
off the wire. */
$clean_account_fields = ['DISPLAY_NAME', 'USERNAME',
'HOST', 'DEFAULT_FOLDER'];
foreach ($data["ACCOUNTS"] as $account_index => $account) {
foreach ($clean_account_fields as $account_field) {
if (isset($account[$account_field])) {
$data["ACCOUNTS"][$account_index][$account_field]
= $parent->clean(
$account[$account_field], "string", "");
}
}
}
/* wiki.js carries the split-adjuster drag handler that the
two-pane layout shares with the Messages activity */
$this->initializeWikiEditor($data, -1);
/* every other activity calls initSocialBadges via its own
render path; the Mail dispatch did not, leaving every
badge (mail, messages, posts, groups) empty on the Mail
page even though they populated everywhere else. Doing
it here covers all the userMailBaseData callers. */
$this->initSocialBadges($data["USER_ID"], $data);
return $data;
}
/**
* Pulls submitted form fields into an associative array shaped
* for MailAccountModel::addAccount / updateAccount. Trims
* strings and coerces the ports to int. Covers both the
* incoming (IMAP) and outgoing (SMTP) sections of the form plus
* the per-account allow-self-signed-certificate checkbox.
*
* @return array fields ready for the model
*/
protected function userMailParseAccountFields()
{
/*
A password field submitted as exactly the masked sentinel
means "the stored credential was not touched": normalize
it to the empty string here so the rest of the pipeline
(validation, updateAccount's leave-password flags) treats
it identically to a field the user left blank.
*/
$password = $_REQUEST['password'] ?? '';
if ($password === MailAccountModel::PASSWORD_SENTINEL) {
$password = '';
}
$smtp_password = $_REQUEST['smtp_password'] ?? '';
if ($smtp_password === MailAccountModel::PASSWORD_SENTINEL) {
$smtp_password = '';
}
$provider = trim($_REQUEST['provider'] ?? '');
if ($provider !== '' && !array_key_exists($provider,
MailAccountModel::PROVIDER_PRESETS)) {
$provider = '';
}
return [
"DISPLAY_NAME" => trim($_REQUEST['display_name'] ?? ''),
"PROVIDER" => $provider,
"HOST" => trim($_REQUEST['host'] ?? ''),
"PORT" => intval($_REQUEST['port'] ?? 993),
"USERNAME" => trim($_REQUEST['username'] ?? ''),
"PASSWORD" => $password,
"TLS_MODE" => in_array($_REQUEST['tls_mode'] ?? '',
['imaps', 'starttls', 'plain']) ?
$_REQUEST['tls_mode'] : 'imaps',
"DEFAULT_FOLDER" => "INBOX",
"ALLOW_SELF_SIGNED" =>
empty($_REQUEST['allow_self_signed']) ? 0 : 1,
"SMTP_HOST" => trim($_REQUEST['smtp_host'] ?? ''),
"SMTP_PORT" => intval($_REQUEST['smtp_port'] ?? 587),
"SMTP_USERNAME" => trim($_REQUEST['smtp_username'] ?? ''),
"SMTP_PASSWORD" => $smtp_password,
"SMTP_TLS_MODE" => in_array($_REQUEST['smtp_tls_mode'] ?? '',
['smtps', 'starttls', 'plain']) ?
$_REQUEST['smtp_tls_mode'] : 'starttls',
];
}
/**
* Returns an array of human-readable field-level errors for the
* supplied account fields. An empty array means the fields pass
* validation. The outgoing (SMTP) host is optional; when it is
* left blank the SMTP section is simply not validated.
*
* @param array $fields output of userMailParseAccountFields()
* @param bool $password_required true on Add (must be present),
* false on Edit (empty means "keep stored password")
* @return array map of field name => message
*/
protected function userMailValidateAccountFields($fields,
$password_required)
{
$errors = [];
if (empty($fields['DISPLAY_NAME'])) {
$errors['DISPLAY_NAME'] =
tl("social_component_mail_field_required");
}
if (empty($fields['HOST'])) {
$errors['HOST'] = tl("social_component_mail_field_required");
}
if (empty($fields['USERNAME'])) {
$errors['USERNAME'] =
tl("social_component_mail_field_required");
}
if ($password_required && empty($fields['PASSWORD'])) {
$errors['PASSWORD'] =
tl("social_component_mail_field_required");
}
if ($fields['PORT'] < 1 || $fields['PORT'] > 65535) {
$errors['PORT'] = tl("social_component_mail_port_invalid");
}
if (!empty($fields['SMTP_HOST']) &&
($fields['SMTP_PORT'] < 1 || $fields['SMTP_PORT'] > 65535)) {
$errors['SMTP_PORT'] =
tl("social_component_mail_port_invalid");
}
return $errors;
}
/**
* Precomputes the HTML-cleaned display values the folder list
* needs so MailElement can echo them raw. Folder NAME is left
* untouched because it is reused verbatim to build folder URLs
* and IMAP SELECT arguments, where HTML-escaping it would
* corrupt the mailbox path; instead each entry gains:
* NAME_HTML cleaned full name (for data-folder-name)
* DELIMITER_HTML cleaned hierarchy delimiter
* DISPLAY_NAME cleaned last path segment, brackets stripped
* A FOLDER_DISPLAY map (raw full name => cleaned display) is
* also returned via $data so synthetic parent rows inserted
* during tree building (e.g. Gmail's [Gmail] root) can show a
* cleaned label without the view doing any cleaning. Every
* ancestor prefix of every folder is seeded into the map for
* that reason.
*
* @param array $data the mail $data array; $data['FOLDERS'] is
* decorated in place and $data['FOLDER_DISPLAY'] is set
*/
protected function userMailDecorateFolderDisplay(&$data)
{
$parent = $this->parent;
$folders = $data["FOLDERS"] ?? [];
if (empty($folders)) {
$data["FOLDER_DISPLAY"] = [];
return;
}
/* dominant delimiter across the account, mirroring the
choice MailElement makes when it splits names into
segments; per-folder DELIMITER is preferred when set. */
$delimiter_counts = [];
foreach ($folders as $folder_info) {
$hierarchy = $folder_info['DELIMITER'] ?? '';
if ($hierarchy !== '') {
$delimiter_counts[$hierarchy] =
($delimiter_counts[$hierarchy] ?? 0) + 1;
}
}
$dominant_delimiter = '/';
if (!empty($delimiter_counts)) {
arsort($delimiter_counts);
$dominant_delimiter = array_key_first($delimiter_counts);
}
$display_map = [];
foreach ($folders as $folder_index => $folder_info) {
$raw_name = $folder_info['NAME'] ?? '';
$row_delimiter = ($folder_info['DELIMITER'] ?? '') !== ''
? $folder_info['DELIMITER'] : $dominant_delimiter;
$data["FOLDERS"][$folder_index]["NAME_HTML"] =
$parent->clean($raw_name, "string", "");
$data["FOLDERS"][$folder_index]["DELIMITER_HTML"] =
$parent->clean($row_delimiter, "string", "");
$display = $this->userMailFolderSegmentDisplay(
$raw_name, $row_delimiter);
$data["FOLDERS"][$folder_index]["DISPLAY_NAME"] =
$parent->clean($display, "string", "");
/* seed this folder and every ancestor prefix so a
synthesized parent row can find a cleaned label. */
$segments = explode($row_delimiter, $raw_name);
$prefix = '';
for ($segment_index = 0;
$segment_index < count($segments); $segment_index++) {
$prefix = ($segment_index === 0) ?
$segments[0] :
$prefix . $row_delimiter .
$segments[$segment_index];
if (!isset($display_map[$prefix])) {
$display_map[$prefix] = [
"DISPLAY" => $parent->clean(
$this->userMailFolderSegmentDisplay(
$prefix, $row_delimiter), "string", ""),
"NAME_HTML" => $parent->clean($prefix,
"string", ""),
"DELIMITER_HTML" => $parent->clean(
$row_delimiter, "string", ""),
];
}
}
}
$data["FOLDER_DISPLAY"] = $display_map;
}
/**
* Returns the user-facing label for a folder path: the last
* segment after splitting on the hierarchy delimiter, with a
* single pair of surrounding square brackets stripped (the
* Gmail / IMAP namespace convention that wraps roots such as
* [Gmail]). The raw, unstripped path is what callers keep for
* IMAP operations; this is presentation only.
*
* @param string $raw_name full folder path as reported by the
* server
* @param string $delimiter hierarchy delimiter to split on
* @return string the display label (not yet HTML-cleaned)
*/
protected function userMailFolderSegmentDisplay($raw_name,
$delimiter)
{
$segments = explode($delimiter, $raw_name);
$display = end($segments);
if (strlen($display) >= 2 && $display[0] === '[' &&
substr($display, -1) === ']') {
$display = substr($display, 1, -1);
}
return $display;
}
/**
* Resolves and persists the current sort choice for an inbox
* view. If the request supplies sort_key, that overrides the
* stored choice and is written into the session; otherwise the
* session value for this account+folder is returned. The
* default when nothing is stored is ["key" => "date",
* "reverse" => false] which matches the historical newest-first
* behavior and needs no SORT command. The key is restricted to
* "date", "subject" or "from"; any other value falls through
* to the default.
*
* @param int $account_id the active mail account id
* @param string $folder the active folder name
* @return array ["key" => string, "reverse" => bool]
*/
protected function userMailGetSort($account_id, $folder)
{
$parent = $this->parent;
$allowed_keys = ["date", "subject", "from"];
$mail_model = $parent->model("Mail");
$requested_key = $_REQUEST['sort_key'] ?? null;
if ($requested_key !== null) {
$key = $parent->clean($requested_key, $allowed_keys,
"date");
$reverse = $parent->clean(
$_REQUEST['sort_reverse'] ?? null, 'bool');
$sort = ["key" => $key, "reverse" => $reverse];
$mail_model->storeSort($account_id, $folder, $sort);
return $sort;
}
$stored = $mail_model->storedSort($account_id, $folder);
if (is_array($stored) &&
in_array($stored["key"] ?? "", $allowed_keys, true)) {
return ["key" => $stored["key"],
"reverse" => !empty($stored["reverse"])];
}
return ["key" => "date", "reverse" => false];
}
/**
* Returns the trimmed and sanitized filter term from the
* request, or the empty string when no filter is set. The
* value is cleaned through the imap_arg type so it cannot
* carry CRLF (which would smuggle an extra IMAP command) and
* is length-bounded. Unlike sort, filter is deliberately not
* persisted in the session or scoped to the account/folder:
* a non-empty value lives only as a URL parameter, and folder
* links built elsewhere do not carry it, so switching folders
* clears the filter naturally.
*
* @return string the trimmed filter term, empty when absent
*/
protected function userMailGetFilter()
{
$parent = $this->parent;
$filter = $parent->clean($_REQUEST['filter'] ?? null,
'imap_arg', '');
return trim($filter);
}
/**
* Returns the current state of the unread-only filter toggle
* as a bool. Reads the unread_only query parameter; absent or
* falsy values mean "show all messages", any truthy value
* means "show only messages without \Seen". Toggle state lives
* in URL parameters rather than session so the existing
* browser-history affordances cover undo/redo for the user.
*
* @return bool true when only unread messages should be listed
*/
protected function userMailGetUnreadOnly()
{
$parent = $this->parent;
return (bool) $parent->clean(
$_REQUEST['unread_only'] ?? null, 'bool', false);
}
/**
* Returns the current state of the flagged-only filter toggle
* as a bool. Reads the flagged_only query parameter; absent or
* falsy values mean "show all messages," any truthy value
* means "show only messages with \Flagged set." Same URL-only
* persistence as userMailGetUnreadOnly.
*
* @return bool true when only flagged messages should be listed
*/
protected function userMailGetFlaggedOnly()
{
$parent = $this->parent;
return (bool) $parent->clean(
$_REQUEST['flagged_only'] ?? null, 'bool', false);
}
/**
* HTML-cleans the echoed display fields (SUBJECT, FROM,
* FROM_NAME) on an already-built list of message envelopes, so
* the inbox view can emit them raw. Used for envelope lists
* that do not pass through userMailParseEnvelopes -- chiefly
* the in-process MailSite backend, whose listMessages returns
* structured rows directly. Idempotency is not assumed:
* callers pass raw envelopes exactly once before render.
*
* @param array $messages list of envelope arrays, cleaned in
* place
* @return array the same list with cleaned display fields
*/
protected function userMailCleanEnvelopes($messages)
{
$parent = $this->parent;
foreach ($messages as $message_index => $message) {
foreach (['SUBJECT', 'FROM', 'FROM_NAME']
as $envelope_field) {
if (isset($message[$envelope_field])) {
$messages[$message_index][$envelope_field] =
$parent->clean($message[$envelope_field],
"string", "");
}
}
}
return $messages;
}
/**
* Used to add to $data information about the most recently view threads
* and groups of the current user. This will be used to populate the
* navigation dropdown in WikiView or WikiElement
*
* @param array &$data associative array of values to be echoed by the view
* @param int $user_id id of user requesting thread info
*/
public function calculateRecentFeedsAndThread(&$data, $user_id)
{
if ($user_id == C\PUBLIC_USER_ID) {
return;
}
$parent = $this->parent;
$group_model = $parent->model("group");
$personal_group_id = $group_model->getPersonalGroupId($user_id);
$thread_ids = $parent->model("impression")->recent($user_id,
C\THREAD_IMPRESSION, 5);
$group_ids = $parent->model("impression")->recent($user_id,
C\GROUP_IMPRESSION, 10);
if (!empty($thread_ids)) {
$data['RECENT_THREADS'] = [];
foreach ($thread_ids as $recent_thread_id) {
$thread_start_item = $group_model->getGroupItem(
$recent_thread_id);
$thread_name = $thread_start_item['TITLE'] ?? "";
if (!empty($thread_name) &&
(empty($data['JUST_THREAD']) ||
$recent_thread_id != $data['JUST_THREAD'])) {
$data['RECENT_THREADS'][$thread_name] =
htmlentities(B\feedsUrl("thread", $recent_thread_id,
true, $data['CONTROLLER']));
}
}
}
if (!empty($group_ids)) {
$data['RECENT_GROUPS'] = [];
$thread_group_id = (empty($data["JUST_GROUP_ID"])) ?
(empty($data["GROUP_ID"]) ? -1 : $data["GROUP_ID"] ) :
$data["JUST_GROUP_ID"];
$len = strlen(C\PERSONAL_GROUP_PREFIX);
foreach ($group_ids as $recent_group_id) {
$group_name = $group_model->getGroupName(
$recent_group_id);
if (!empty($group_name) && !empty($thread_group_id) &&
$recent_group_id != $thread_group_id &&
$recent_group_id != $personal_group_id &&
substr($group_name, 0, $len) !=
C\PERSONAL_GROUP_PREFIX) {
$data['RECENT_GROUPS'][$group_name] =
htmlentities(B\feedsUrl("group", $recent_group_id,
false, $data['CONTROLLER']));
}
}
if (count($data['RECENT_GROUPS']) > 5) {
array_pop($data['RECENT_GROUPS']);
}
}
}
/**
* Saves an uploaded wiki page icon as a thumbnail under the
* page's resource folder, after validating the upload's MIME
* type and size.
*
* @param int $group_id group the page belongs to
* @param int $page_id wiki page id whose icon is being updated
* @param array $preserve_fields query-string fields to carry
* through the redirect (csrf, current sub-path, etc.) on
* validation failures
* @return mixed redirectWithMessage result on validation
* failure; void on the success path
*/
public function handlePageIconUpload($group_id, $page_id, $preserve_fields)
{
$parent = $this->parent;
$group_model = $parent->model("group");
if (!in_array($_FILES['page_icon']['type'],
['image/png', 'image/gif', 'image/jpeg',
'image/webp', 'image/x-icon'])) {
return $parent->redirectWithMessage(
tl('social_component_unknown_imagetype'),
$preserve_fields);
}
if ($_FILES['page_icon']['size'] > C\THUMB_SIZE) {
return $parent->redirectWithMessage(
tl('social_component_icon_too_big'),
$preserve_fields);
}
if (empty($_FILES['page_icon']['data'])) {
$image_string = file_get_contents(
$_FILES['page_icon']['tmp_name']);
} else {
$image_string = $_FILES['page_icon']['data'];
}
$icon_path =
$group_model->getGroupPageIconPath($group_id, $page_id);
$image = @imagecreatefromstring($image_string);
$thumb_string = ImageProcessor::createThumb($image);
if (!empty($icon_path) && !empty($thumb_string)) {
file_put_contents($icon_path . ".webp", $thumb_string);
clearstatcache($icon_path . ".webp");
}
return $parent->redirectWithMessage(
tl("social_component_page_saved"), $preserve_fields);
}
/**
* Says whether adding items would pass a user's role-group limit for
* one ROLE_LIMITS column. A -1 cap means unlimited.
*
* @param int $user_id whose role-group limits apply
* @param string $column ROLE_LIMITS column such as MAX_GROUPS_OWNED
* @param int $current how many already exist
* @param int $add how many are being added, default 1
* @return bool true when $current + $add would pass the cap
*/
private function overUserGroupLimit($user_id, $column, $current,
$add = 1)
{
$limits = $this->parent->model("role")->getUserGroupLimits(
$user_id);
$cap = $limits[$column] ?? -1;
return $cap != -1 && $current + $add > $cap;
}
/**
* Says whether adding items would pass a group owner's role-group
* limit for one ROLE_LIMITS column. The owner's roles size the group,
* so a member growing someone else's group is still held to that
* owner's cap.
*
* @param int $group_id group whose owner's limits apply
* @param string $column ROLE_LIMITS column such as MAX_GROUP_MEMBERS
* @param int $current how many already exist
* @param int $add how many are being added, default 1
* @return bool true when $current + $add would pass the cap
*/
private function overGroupLimit($group_id, $column, $current, $add = 1)
{
$group = $this->parent->model("group")->getGroupById($group_id,
C\ROOT_ID);
if (empty($group)) {
return false;
}
$owner_id = $group['OWNER'] ?? ($group['OWNER_ID'] ?? 0);
return $this->overUserGroupLimit($owner_id, $column, $current,
$add);
}
/**
* Adds a group feed item after checking the owner's thread and post
* caps. A new thread (parent 0) is held to MAX_GROUP_THREADS and a
* reply to the thread's MAX_THREAD_POSTS; past the cap the request is
* redirected with a message and no item is added. Arguments and the
* return value otherwise match GroupModel::addGroupItem.
*
* @param int $parent_id thread id, or 0 to start a new thread
* @param int $group_id group the item belongs to
* @param int $user_id who is posting
* @param string $title item title
* @param string $description item body
* @param int $type kind of group item
* @param int $post_time post timestamp
* @param string $url url associated with the item
* @param int $edit_time last edit timestamp
* @param int $ups up votes
* @param int $downs down votes
* @param int $flag moderation flag
* @param int $input_timestamp impression timestamp seed
* @return mixed id of the added item, or a redirect when over a cap
*/
private function addGroupItemWithinLimits($parent_id, $group_id,
$user_id, $title, $description, $type = C\STANDARD_GROUP_ITEM,
$post_time = 0, $url = "", $edit_time = 0, $ups = 0, $downs = 0,
$flag = 0, $input_timestamp = -1)
{
$group_model = $this->parent->model("group");
if ($parent_id == 0) {
$over = $this->overGroupLimit($group_id, 'MAX_GROUP_THREADS',
$group_model->countGroupThreads($group_id));
$message = tl('social_component_group_threads_full');
} else {
$over = $this->overGroupLimit($group_id, 'MAX_THREAD_POSTS',
$group_model->countThreadPosts($parent_id));
$message = tl('social_component_thread_posts_full');
}
if ($over) {
return $this->parent->redirectWithMessage($message);
}
return $group_model->addGroupItem($parent_id, $group_id, $user_id,
$title, $description, $type, $post_time, $url, $edit_time,
$ups, $downs, $flag, $input_timestamp);
}
/**
* Adds a user to a group after checking the owner's member cap. Past
* MAX_GROUP_MEMBERS the request is redirected with a message and no
* membership is added. Arguments match GroupModel::addUserGroup.
*
* @param int $user_id user to add to the group
* @param int $group_id group to add the user to
* @param int $status membership status to grant
* @return mixed result of the add, or a redirect when over the cap
*/
private function addUserGroupWithinLimits($user_id, $group_id,
$status = C\ACTIVE_STATUS)
{
$group_model = $this->parent->model("group");
if ($this->overGroupLimit($group_id, 'MAX_GROUP_MEMBERS',
$group_model->countGroupUsers($group_id))) {
return $this->parent->redirectWithMessage(
tl('social_component_group_members_full'));
}
return $group_model->addUserGroup($user_id, $group_id, $status);
}
/**
* Used to handle file uploads either to message posts or wiki pages
*
* @param string $group_id the group the message or wiki page is associated
* with
* @param string $store_id the id of the message post or wiki page
* @param string $sub_path used to specify sub-folder of default resource
* folder to copy to
* @return string one of the self::UPLOAD_* status constants:
* UPLOAD_NO_FILES if nothing was submitted, UPLOAD_FAILED if a
* file copy failed, or UPLOAD_SUCCESS on completion
*/
public function handleResourceUploads($group_id, $store_id, $sub_path = "")
{
if (!isset($_FILES) || !is_array($_FILES)) {
return self::UPLOAD_NO_FILES;
}
$keys = array_keys($_FILES);
if (!isset($keys[0])) {
return self::UPLOAD_NO_FILES;
}
$upload_field = $keys[0];
$parent = $this->parent;
$group_model = $parent->model("group");
if (!isset($_FILES[$upload_field]['name'])) {
return self::UPLOAD_NO_FILES;
}
$upload_parts = ['name', 'full_path', 'type', 'tmp_name', 'data'];
$is_file_array = false;
$num_files = 1;
if (is_array($_FILES[$upload_field]['name'])) {
$num_files =
count($_FILES[$upload_field]['name']);
$is_file_array = true;
}
$files = [];
$upload_okay = true;
for ($i = 0; $i < $num_files; $i ++) {
foreach ($upload_parts as $part) {
$file_part = ($is_file_array && isset(
$_FILES[$upload_field][$part][$i])) ?
$_FILES[$upload_field][$part][$i] :
((!$is_file_array && isset(
$_FILES[$upload_field][$part])) ?
$_FILES[$upload_field][$part] :
false );
if ($part == 'data') {
$files[$i][$part] = (empty($file_part) ) ? "" :
$file_part;
continue;
}
if ($file_part) {
$files[$i][$part] = $parent->clean(
$file_part, 'string');
} else {
$upload_okay = false;
break 2;
}
}
}
if ($upload_okay) {
$is_thread = strncmp($store_id, "post", 4) === 0;
$resource_folders = $group_model->getGroupPageResourcesFolders(
$group_id, $store_id);
$resource_folder = (is_array($resource_folders)) ?
($resource_folders[0] ?? "") : "";
$existing_files = (is_string($resource_folder) &&
$resource_folder !== "" && is_dir($resource_folder)) ?
glob($resource_folder . "/*") : [];
if ($is_thread) {
$over_resource = $this->overGroupLimit($group_id,
'MAX_THREAD_RESOURCES', count($existing_files),
$num_files);
$resource_message =
tl('social_component_thread_resources_full');
} else {
$existing_bytes = 0;
foreach ($existing_files as $existing_file) {
$existing_bytes += (is_file($existing_file)) ?
filesize($existing_file) : 0;
}
$incoming_bytes = 0;
foreach ($files as $file) {
$incoming_bytes += (isset($file['tmp_name']) &&
is_file($file['tmp_name'])) ?
filesize($file['tmp_name']) : 0;
}
$over_resource = $this->overGroupLimit($group_id,
'MAX_PAGE_RESOURCE_MEMORY', $existing_bytes,
$incoming_bytes);
$resource_message =
tl('social_component_page_resources_full');
}
if ($over_resource) {
return $parent->redirectWithMessage($resource_message);
}
foreach ($files as $file) {
$file_sub_path = $sub_path;
/* A file uploaded from inside a dropped folder arrives
with its folder path in front of its name, for
example "photos/trip/beach.jpg". That path is read
from full_path rather than from name: PHP takes the
folders off name before a handler ever sees it, so
under a web server the folders were lost and every
file landed in one heap, while under the command line
server, which does no such thinning, they survived.
full_path is what both leave whole. Pull the folder
part off and fold it into the sub-path so those
folders get created, and keep just the bare name for
the file itself, so the file is written into the
folder rather than having the path repeated. */
$client_path = $file['full_path'];
$base_name = pathinfo($client_path, PATHINFO_BASENAME);
$folder_part = trim(str_replace("\\", "/",
pathinfo($client_path, PATHINFO_DIRNAME)), "/");
if ($folder_part !== "" && $folder_part !== ".") {
$file_sub_path = ($sub_path === "") ? $folder_part :
trim($sub_path, "/") . "/" . $folder_part;
}
$group_model->copyFileToGroupPageResource(
$file['tmp_name'], $base_name, $file['type'],
$group_id, $store_id, $file_sub_path, $file['data']);
}
}
if (!$upload_okay) {
return self::UPLOAD_FAILED;
}
return self::UPLOAD_SUCCESS;
}
/**
* Used to set up GroupfeedView to draw a users group feeds grouped
* by group names as opposed to as a linear list of thread and post
* titles
*
* @param int $user_id id of current user
* @param int $limit lower bound on the groups to display feed data for
* @param int $results_per_page number of groups to display feed data
* for
* @param string $controller_name name of controller on which this
* this component lives (either admin or group). Used by
* view to draw expand or collapse link
* @param array $data field data for view to draw itself
* @return array $data after being augmented with the grouped-feed
* keys (MODE, group_sorts, plus per-group thread/post slices)
* that GroupfeedView consumes
*/
public function calculateGroupedFeeds($user_id, $limit, $results_per_page,
$controller_name, $data)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$data['MODE'] = 'grouped';
$data['group_sorts'] = [ "name_asc" =>
html_entity_decode(tl('social_component_name_asc')),
"name_desc" => html_entity_decode(tl('social_component_name_desc')),
"join_asc" => html_entity_decode(tl('social_component_join_asc')),
"join_desc" => html_entity_decode(tl('social_component_join_desc')),
];
$data['GROUP_SORT'] = (!empty($_REQUEST['group_sort']) &&
isset($data['group_sorts'][$_REQUEST['group_sort']])) ?
$_REQUEST['group_sort'] : "join_desc";
$data["GROUP_FILTER"] = (empty($_REQUEST['group_filter'])) ?
"" : $parent->clean($_REQUEST['group_filter'], "string");
$search_array = [];
if ($data["GROUP_FILTER"]) {
if ($data["GROUP_FILTER"][0] == '=') {
$name_clause = ["name", "=", substr($data["GROUP_FILTER"],1)];
} else {
$name_clause = ["name", "CONTAINS", $data["GROUP_FILTER"]];
}
} else {
$name_clause = ["name", "", ""];
}
$name_clause[3] = ($data['GROUP_SORT'] == "name_asc") ?
"ASC" : ($data['GROUP_SORT'] == "name_desc" ? "DESC" : "");
if ($name_clause != ["name", "", "", ""]) {
$search_array[] = $name_clause;
}
$join_clause = ["join_date", "", ""];
$join_clause[3] = ($data['GROUP_SORT'] == "join_asc") ?
"ASC" : ($data['GROUP_SORT'] == "join_desc" ? "DESC" : "");
if ($join_clause != ["join_date", "", "", ""]) {
$search_array[] = $join_clause;
}
$data['GROUPS'] = $group_model->getRows($limit, $results_per_page,
$data['NUM_GROUPS'], $search_array, [$user_id, false]);
$this->addActivityInfoToGroups($data);
$data['LIMIT'] = $limit;
$data['RESULTS_PER_PAGE'] = $results_per_page;
$data['PAGING_QUERY'] = B\feedsUrl("", "",
true, $controller_name);
return $data;
}
/**
*
* @param array &$data
*/
public function addActivityInfoToGroups(&$data)
{
$group_model = $this->parent->model("group");
$impression_model = $this->parent->model("impression");
$num_shown = count($data['GROUPS']);
$user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
for ($i = 0; $i < $num_shown; $i++) {
$group = $data['GROUPS'][$i];
$group_id = $group['GROUP_ID'];
$item = $group_model->getMostRecentGroupPost($group_id);
$most_recent_views = $impression_model->mostRecentGroupViews(
$user_id, [$group_id]);
$group["MOST_RECENT_VIEW"] =
$most_recent_views[$group_id] ?? 0;
$group['NEW_POSTS'] =
$group_model->getGroupPostCount($group_id,
$group["MOST_RECENT_VIEW"]);
$group['NUM_POSTS'] = $group_model->getGroupPostCount($group_id);
$group['NUM_THREADS'] =
$group_model->getGroupThreadCount($group_id);
$group['NUM_PAGES'] = $group_model->getGroupPageCount(
$group['GROUP_ID']);
$group["MEMBER_STATUS"] = $group_model->checkUserGroup(
$user_id, $group_id);
if (isset($item['TITLE'])) {
$group["ITEM_TITLE"] = $item['TITLE'];
$group["THREAD_ID"] = $item['PARENT_ID'];
} else {
$group["ITEM_TITLE"] = tl('social_component_no_posts_yet');
$group["THREAD_ID"] = -1;
}
$data['GROUPS'][$i] = $group;
}
$data['NUM_SHOWN'] = $num_shown;
}
/**
* Handles requests to reading, editing, viewing history, reverting, etc
* wiki pages
* @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");
if (isset($_SESSION['USER_ID'])) {
$user_id = $_SESSION['USER_ID'];
$data['ADMIN'] = 1;
} else {
$user_id = C\PUBLIC_USER_ID;
}
$last_care_missing = 2;
$missing_fields = false;
$i = 0;
if ($user_id == C\PUBLIC_USER_ID) {
$_SESSION['LAST_ACTIVITY']['a'] = 'wiki';
$_SESSION['LAST_ACTIVITY']['c'] = $controller_name;
} else {
unset($_SESSION['LAST_ACTIVITY']);
}
$missings = [];
foreach ($clean_array as $field => $type) {
if (isset($_REQUEST[$field])) {
if ($field == 'page' && is_array($_REQUEST[$field])) {
$tmp = [];
foreach ($_REQUEST[$field] as $key => $value) {
$key = $parent->clean($key, "string");
$value = $parent->clean($value, "string");
$tmp[substr($key, 0, C\TITLE_LEN)] =
substr($value, 0, C\MAX_GROUP_PAGE_LEN);
}
} else {
$tmp = $parent->clean($_REQUEST[$field], $type);
}
if (isset($strings_array[$field]) &&
!is_array($tmp)) {
$tmp = substr($tmp, 0, $strings_array[$field]);
}
if ($field == "page_name") {
$tmp = str_replace(" ", "_", $tmp);
$tmp = str_replace("$", "", $tmp);
}
if ($field == "group_name") {
$pre_id = $group_model->getGroupId($tmp);
if ($pre_id > 0) {
$group_id = $pre_id;
unset($missings[$field]);
if (empty($missings)) {
$missing_fields = false;
}
}
}
$$field = $tmp;
if ($user_id == C\PUBLIC_USER_ID) {
$_SESSION['LAST_ACTIVITY'][$field] = $tmp;
}
} else if ($i < $last_care_missing) {
$$field = false;
$missing_fields = true;
$missings[$field] = true;
}
$i++;
}
$data['RESOURCE_FILTER'] = (isset($resource_filter)) ?
$resource_filter : "";
$data['OPEN_IN_TABS'] = empty($_SESSION['OPEN_IN_TABS']) ? false :
true;
$data["SHARE_WALL_EDIT"] = false;
$data['TARGET'] = $target ?? "";
$data["CAN_DELETE"] = false;
if (!empty($group_id)) {
if (isset($share_wall_data) && !empty($page_name)) {
$page_info = $group_model->getPageInfoByName($group_id,
$page_name, $data['CURRENT_LOCALE_TAG'], "edit");
if (!empty($page_info['PAGE']) &&
$group_model->getPageType($page_info['PAGE']) == 'share') {
$page = $share_wall_data;
$page_id = $page_info['ID'];
$data["CAN_EDIT"] = true;
$data["SHARE_WALL_EDIT"] = true;
}
}
} else if (!empty($page_id)) {
$page_info = $group_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;
}
$page_id = $page_id ?? 0;
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=";
}
if (isset($_REQUEST["arg"])) {
switch ($_REQUEST["arg"]) {
case "podcast_status":
$podcast_page_id = $page_id ?? $group_model->getPageId(
$group_id, $page_name,
$data['CURRENT_LOCALE_TAG']);
$this->outputPodcastStatus($group_id,
$podcast_page_id, $sub_path);
break;
case "podcast_update":
$podcast_page_id = $page_id ?? $group_model->getPageId(
$group_id, $page_name,
$data['CURRENT_LOCALE_TAG']);
$folder_key =
L\media_jobs\PodcastDownloadJob::podcastFolderKey(
$group_id, $podcast_page_id, $sub_path);
L\media_jobs\PodcastDownloadJob::requestPodcastUpdate(
$folder_key);
$this->outputPodcastStatus($group_id,
$podcast_page_id, $sub_path);
break;
case "deletepage":
$_REQUEST["arg"] = "pages";
if ($data["CAN_DELETE"] &&
!empty($page_name) && isset($group_id) &&
!empty($data['CURRENT_LOCALE_TAG'])) {
if ($group_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']);
break;
case "edit":
$page_id ??= null;
$page_name ??= null;
$page ??= null;
$edit_reason ??= null;
$this->editWiki($data, $user_id, $group_id, $group,
$page_id, $page_name, $page, $sub_path,
$edit_reason, $missing_fields, $read_address);
break;
case "history":
if (!isset($page_id) || !$page_id) {
break;
}
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 =
$group_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";
$limit = isset($limit) ? $limit : 0;
$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 = $group_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 =
$group_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 = $group_model->getHistoryPage(
$page_id, $diff1);
$page_info2 = $group_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 = $group_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>');";
break;
}
$group_model->revertResources($page_id, $group_id,
$revert);
$group_model->setPageName($user_id,
$group_id, $page_info["PAGE_NAME"],
$page_info["PAGE"],
$data['CURRENT_LOCALE_TAG'],
tl('social_component_page_revert_to',
date('c', $revert)), "", "", $read_address);
return $parent->redirectWithMessage(
tl("social_component_page_reverted"),
['arg', 'page_name', 'page_id']);
} else {
return $parent->redirectWithMessage(
tl("social_component_revert_error"),
['arg', 'page_name', 'page_id']);
}
}
if (empty($data["DISCUSS_THREAD"])) {
$page_info = $group_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"]) =
$group_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;
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 "pages":
$data["MODE"] = "pages";
if ($user_id == C\PUBLIC_USER_ID && !empty($group) &&
empty($group['PAGE_LIST_ALLOWED'])) {
/* A group keeping its list of pages to itself
answers a stranger asking for one the way the
site answers a request for any page that is not
there. What stood here was the page saying a
crawled copy of something could not be found,
which has nothing to do with the ask. The
reader is sent to where the site says that, so
a routed domain says it in its own words. */
$parent->redirectLocation(
B\directUrl("error"));
\seekquarry\atto\webExit();
}
$limit = isset($limit) ? $limit : 0;
$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"]
?? "";
$filter = (empty($filter)) ? "" : $filter;
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 = $group_model->getPageInfoByName(
$group_id, $filter, $data['CURRENT_LOCALE_TAG'],
"read");
}
if (!$search_page_info) {
list($data["TOTAL_ROWS"], $data["PAGES"]) =
$group_model->getPageList(
$group_id, $data['CURRENT_LOCALE_TAG'], $filter,
$data['CURRENT_SORT'], $limit, $num);
} else {
$data["MODE"] = "read";
$page_name = $data["FILTER"];
}
break;
case 'relationships':
$data["MODE"] = "relationships";
$data["PAGE_NAME"] = "related";
if (empty($page_id)) {
break;
}
$page_info = $group_model->getPageInfoByPageId(
$page_id);
if (!isset($page_name)) {
$page_name = empty($page_info['PAGE_NAME']) ? "links" :
$page_info['PAGE_NAME'];
}
$limit = isset($limit) ? $limit : 0;
$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"]) =
$group_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 = $group_model->getPageInfoByPageId(
$page_id);
if (!isset($page_name)) {
$page_name = empty($page_info['PAGE_NAME'])
? "rel-types" : $page_info['PAGE_NAME'];
}
$limit = isset($limit) ? $limit : 0;
$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"]) =
$group_model->pagesLinkedWithRelationship(
$page_id, $data["GROUP_ID"],
$data["PAGE_NAME"], $rel_type, $limit,$num);
}
}
break;
case 'source':
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 = $group_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'] =
$group_model->getGroupPageResourceUrls($group_id,
$page_info['ID'], $sub_path);
$this->addPodcastSourceStatus($data, $group_id,
$page_info['ID'], $sub_path);
} else {
$data['RESOURCES_INFO'] = [];
}
break;
}
}
if (!$page_name) {
$page_name = tl('social_component_main');
}
$data["GROUP"] = $group;
$read_page_id = $group_model->getPageId($group_id, $page_name,
$data['CURRENT_LOCALE_TAG']);
if (!empty($read_page_id) &&
$group_model->resourcePathBroken($group_id, $read_page_id)) {
$data["RESOURCE_PATH_ERROR"] = true;
}
if ($data["MODE"] == "history") {
if (!empty($page_id)) {
$page_info = $group_model->getPageInfoByPageId($page_id);
$page_name = $page_info['PAGE_NAME'] ?? "";
}
$page_info = $group_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;
}
} else if (in_array($data["MODE"], ["api", "read", "edit", "media",
"source"])) {
// 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 = $group_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 = $group_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']);
$parent->parsePageHeadVarsView($view, $data["PAGE_ID"],
$data["PAGE"]);
$data['page_icon'] = $group_model->getGroupPageIconUrl(
$parent->generateCSRFToken($user_id), $group_id,
$data["PAGE_ID"]);
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;
}
$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']));
}
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();";
}
} else if (in_array($data['MODE'], ['edit', 'source'])) {
foreach (WikiParser::PAGE_DEFAULTS as $key => $default) {
$data[$key] = $default;
if (isset($data["HEAD"][$key])) {
$data[$key] = $data["HEAD"][$key];
}
}
$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'])) {
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";
}
}
$data['current_page_type'] = $data["page_type"];
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);
if (empty($data['RESOURCE_NAME'])) {
$this->initializeWikiEditor($data);
}
}
}
if (!empty($data['PAGE_ID'])) {
$data['PAGE_HAS_RELATIONSHIPS'] =
$group_model->countPageRelationships($data['PAGE_ID']);
}
$this->updateGetWikiImpressionInfo($data, $user_id, $group_id);
return $data;
}
/**
* 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");
$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 = $group_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;
$is_required = (isset($_POST['CSVFORM'][$csv_header]) &&
str_starts_with($_POST['CSVFORM'][$csv_header], "r-") ) ? 1 : 0;
if ($is_required && (empty($_POST[$csv_header]) ||
$_POST[$csv_header] == 'empty')) {
$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 && !empty($clean_field) &&
$clean_field != 'choice') {
$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)) {
list($message, $receipt) = $signin_model->addVote($secrets_filepath,
$secret_form_hash, $_POST['CSVFORM'], $out_row);
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') {
$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";
return $parent->redirectWithMessage(
tl('social_component_choices_recorded'), array_merge(
['SUBMIT_SUCCESSFUL'], $preserve_fields, $csv_headers));
}
/**
* Sets up view variables for wiki pages when in read mode. If
* a user send a command to indicate a media resource on a media list
* is not viewed, then also update session accordingly
*
* @param array &$data associative array of values to be echoed by the view
* @param int $user_id id of user requesting a wiki page
* @param int $group_id group in which wiki page belongs
* @param string $sub_path any path within wiki page folder for resources
*/
private function initializeReadMode(&$data, $user_id, $group_id, $sub_path)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$this->checkAuthRequirement($data, $user_id);
$member_not_editor = $group_model->checkUserGroup($user_id, $group_id,
C\ACTIVE_STATUS);
$editor = $group_model->checkUserGroup($user_id,
$group_id, C\EDITOR_STATUS);
$data["NOT_MEMBER"] = !$member_not_editor && !$editor;
if (isset($data["HEAD"]['page_header']) &&
$data["HEAD"]['page_type'] != 'presentation') {
$page_header = $group_model->getPageInfoByName($group_id,
$data["HEAD"]['page_header'],
$data['CURRENT_LOCALE_TAG'], $data["MODE"]);
if (isset($page_header['PAGE'])) {
$header_parts =
explode(WikiParser::END_HEAD_VARS, $page_header['PAGE']);
}
$data["PAGE_HEADER"] = (isset($header_parts[1])) ?
$header_parts[1] : ($page_header['PAGE'] ?? "");
}
if (($data["HEAD"]['page_type'] ?? "") == 'share' &&
!empty($data['PAGE_ID'])) {
$last_edit = $group_model->getPageHistoryList(
$data['PAGE_ID'], 0, 1);
if (!empty($last_edit)) {
$data['LAST_EDITOR'] = $last_edit[2][0]["USER_NAME"];
$data['LAST_EDIT_TIME'] = $last_edit[2][0]["PUBDATE"];
$share_expires = $data["HEAD"]['share_expires'] ?? C\FOREVER;
if ($share_expires != C\FOREVER) {
$expires_time = $data['LAST_EDIT_TIME'] + $share_expires;
if (time() > $expires_time) {
$data['PAGE'] = "";
}
}
}
}
if (isset($data["HEAD"]['page_footer']) &&
$data["HEAD"]['page_type'] != 'presentation') {
$page_footer = $group_model->getPageInfoByName($group_id,
$data["HEAD"]['page_footer'], $data['CURRENT_LOCALE_TAG'],
$data["MODE"]);
if (isset($page_footer['PAGE'])) {
$footer_parts =
explode(WikiParser::END_HEAD_VARS, $page_footer['PAGE']);
}
$data['PAGE_FOOTER'] = (isset($footer_parts[1])) ?
$footer_parts[1] : ($page_footer['PAGE'] ?? "");
}
$data["INCLUDE_SCRIPTS"] ??= [];
if (str_contains($data["PAGE"], "`")) {
$data["INCLUDE_SCRIPTS"][] = "math";
}
if (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')."';";
}
if (preg_match("/\(\(resource(\-?[a-z]+)?\:(.+?)csv(.+?)\|(.+?)\)\)/ui",
$data["PAGE"])) {
if ($data['AUTHORIZED']) {
$data["PAGE"] = $group_model->insertResourcesParsePage(
$group_id, $data["PAGE_ID"], $data['CURRENT_LOCALE_TAG'],
$data["PAGE"], "", "admin", true);
} else {
$data['PAGE'] =
"<h1 class='center-page warning'>{$last_match}</h1>";
}
}
if (stripos($data["PAGE"], "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($data["PAGE"], "[{proof-of-work}]")) {
$parent->setupProofOfWorkViewData($data);
}
if (str_contains($data["PAGE"], "spreadsheet_data")) {
if (!in_array("spreadsheet", $data["INCLUDE_SCRIPTS"])) {
$data["INCLUDE_SCRIPTS"][] = "spreadsheet";
}
if (!str_contains($data["SCRIPT"], "new Spreadsheet")) {
$data['SCRIPT'] .= <<< 'EOD'
var spreadsheet = [];
var i = 0;
for (var spreadsheet_elt in spreadsheet_data) {
spreadsheet[i] = new Spreadsheet(
'spreadsheet_' + spreadsheet_elt,
spreadsheet_data[spreadsheet_elt],
spreadsheet_config[spreadsheet_elt]);
spreadsheet[i].draw();
i++;
}
EOD;
}
$data['SPREADSHEET'] = true;
}
if (preg_match('/\[\{secret-ballot((?:\|[^{|\n]+)+)\}\]/si',
$data['PAGE'], $matches)) {
$witnesses = explode("|", $matches[1] ?? "");
array_shift($witnesses);
$this->initializeBallot($data, $user_id, $group_id, $sub_path,
$witnesses);
}
if (preg_match("/\[{(share-edited-form|user-record-form|".
"hash-record-form)}\]/", $data["PAGE"], $form_matches) ) {
$is_shared = $form_matches[1] == 'share-edited-form';
$active_field = ($is_shared) ? "" :
(($form_matches[1] == 'user-record-form') ? "username" :
"user_captcha_text");
$default_folders = $group_model->getGroupPageResourcesFolders(
$group_id, $data['PAGE_ID']);
$csv_filepath = $default_folders[0] . '/' . C\WIKI_FORM_CSV_FILE;
if (file_exists($csv_filepath) &&
($fh = fopen($csv_filepath, "r")) !== false) {
$csv_headers = fgetcsv($fh, escape: "\\");
$i = 0;
$key_column = -1;
foreach ($csv_headers as $csv_field) {
if (!empty($csv_field) && $csv_field == $active_field) {
$key_column = $i;
}
$col_numbers[$csv_field] = $i;
$i++;
}
$key = "";
$request_key = empty($_REQUEST[$active_field]) ? "" :
trim($parent->clean($_REQUEST[$active_field], "string"));
if ($active_field == "username") {
$request_key = $_SESSION["USER_NAME"];
}
while ($csv_data = fgetcsv($fh, escape: "\\")) {
if ($key_column >= 0) {
$key = $csv_data[$key_column];
}
if ($is_shared || (!empty($request_key) &&
$request_key == $key)) {
foreach($csv_headers as $csv_field) {
$_REQUEST[$csv_field] = $csv_data[
$col_numbers[$csv_field]] ?? "";
}
break;
}
}
fclose($fh);
}
if ($is_shared) {
$data["PAGE"] = preg_replace("/\[{share-edited-form}\]/", "",
$data["PAGE"]);
} else if ($active_field == 'username') {
$data["PAGE"] = preg_replace(
"/\[{user-record-form}\]/",
"<input type='hidden' name='username' " .
"value='[{username}]' ><input type='hidden' " .
"name='CSVFORM[username]' value='textfield' >",
$data["PAGE"]);
} else if ($active_field == 'user_captcha_text') {
$data["PAGE"] = preg_replace(
"/\[{hash-record-form}\]/","", $data["PAGE"]);
}
}
$category_match = "/\[\{category-list\|([^\|]+)\|([^\}]+)\}\]/";
while (preg_match($category_match,
$data["PAGE"], $matches) && !empty($matches[1])) {
$category = $matches[1];
list($num, $category_list) = $group_model->getPageList($group_id,
$data['CURRENT_LOCALE_TAG'], "", 'modified_desc', 0, 10,
$category);
if ($num <= 0) {
continue;
}
$out_list = "<x-c-list type='$category' class='{$matches[2]}'>";
$csrf_token = $parent->generateCSRFToken($user_id);
$token_string = C\p('CSRF_TOKEN') . '='. $csrf_token;
foreach ($category_list as $item) {
$out_list .=
"<x-c-item>";
$header = $item['HEADER'];
$page_title = (empty($header['title'])) ?
$item['SHOW_PAGE_NAME'] :
$header['title'];
$page_url = htmlentities(B\wikiUrl($item['PAGE_NAME'],
true, $data['CONTROLLER'], $group_id)) . $token_string;
$out_list .= "<x-c-icon><a href='$page_url'>".
"<img src='". $group_model->getGroupPageIconUrl(
$csrf_token, $group_id, $item['ID']) .
"' alt='item image'></a></x-c-icon>";
$out_list .= "<x-c-title><a href='$page_url'>".
"$page_title</a></x-c-title>";
$out_list .= "<x-c-description>" .
$item['SHOW_DESCRIPTION'] . "</x-c-description>";
$out_list .= "</x-c-item>";
}
$out_list .= "</x-c-list>";
$data['PAGE'] = preg_replace($category_match, $out_list,
$data["PAGE"], 1);
}
if (empty($data["HEAD"]['page_type'])) {
return;
}
//handles template page types for read case
if ($data["HEAD"]['page_type'][0] == 't' &&
is_numeric(substr($data["HEAD"]['page_type'], 1))) {
$templates = $group_model->getTemplateMap($group_id,
$data['CURRENT_LOCALE_TAG']);
if (empty($_REQUEST['n']) &&
!empty($templates[$data["HEAD"]['page_type']])) {
$template_name = $templates[$data["HEAD"]['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("/{{(area|text)\|(.+?)\|(.+?)}}/",
"{{field|$2}}", $tmp_page);
if (empty($data['PAGE'])) {
$data['PAGE'] = preg_replace(
"/{{field\|(.+?)}}/", "", $tmp_page);
} else {
set_error_handler(null);
$page_data = @unserialize(base64_decode(substr(
$data['PAGE'], strlen('<div>'),
-strlen('</div>'))));
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);
}
}
} else if ($data["HEAD"]['page_type'] == 'page_and_feedback') {
$just_thread = $data['DISCUSS_THREAD'];
$thread_parent =
$group_model->getGroupItem($just_thread);
$edit_or_source = ($data["CAN_EDIT"]) ? "edit" : "source";
$search_array = [
["parent_id", "=", $just_thread, ""],
["pub_date", "", "", "DESC"]];
$limit = (!empty($_REQUEST['limit'])) ?
$parent->clean($_REQUEST['limit'], 'int') : 0;
$results_per_page = (!empty($_REQUEST['num'])) ?
$parent->clean($_REQUEST['num'], 'int') :
C\NUM_RESULTS_PER_PAGE;
list($item_count, $pages) = $this->initializeFeedItems($data, [],
$user_id, $search_array, -2, "krsort",
$limit, $results_per_page);
if ($limit + count($pages) == $item_count) {
$begin_page = array_pop($pages);
$data["WIKI_MEMBER_ACCESS"] = $begin_page["MEMBER_ACCESS"];
$data['WIKI_PARENT_ID'] = $data['DISCUSS_THREAD'];
$data['WIKI_GROUP_ID'] = $group_id;
}
$item_count--;
$data['TOTAL_ROWS'] = $item_count;
if ($data['TOTAL_ROWS'] == 0) {
$data['NO_POSTS_YET'] = true;
}
$data['INCLUDE_SCRIPTS'][] = "wiki";
$data['LIMIT'] = $limit;
$data['RESULTS_PER_PAGE'] = $results_per_page;
$data['PAGES'] = $pages;
$data[C\p('CSRF_TOKEN')] = $parent->generateCSRFToken($user_id);
$data['PAGING_QUERY'] = htmlentities(B\wikiUrl($data['PAGE_NAME'],
true, $data['CONTROLLER'], $group_id)) .
C\p('CSRF_TOKEN') . '='. $data[C\p('CSRF_TOKEN')] .
"&page_type=page_and_feedback";
$data['WIKI_FEED_BASE'] = C\baseUrl() . "?c=". $data['CONTROLLER'] .
"&a=groupFeeds&just_thread=".$data['DISCUSS_THREAD'] .
"&". C\p('CSRF_TOKEN') . '='. $data[C\p('CSRF_TOKEN')] .
"&page_type=page_and_feedback&page_name=" .
$data['PAGE_NAME'];
if ($data['VIEW'] != 'api') {
$data['SCRIPT'] .= " let nextPage = initNextResultsPage(" .
"$limit, {$data['TOTAL_ROWS']}, $results_per_page, ".
"'{$data['PAGING_QUERY']}', '', " .
"'results-container', 'result-batch');\n";
}
} else if ($data["HEAD"]['page_type'] == 'media_list') {
if ($this->serveStaticFolderFile($data, $group_id,
$data['PAGE_ID'], $sub_path, $user_id)) {
return;
}
$data['INCLUDE_SCRIPTS'][] = "wiki";
$data['RESOURCES_INFO'] =
$group_model->getGroupPageResourceUrls($group_id,
$data['PAGE_ID'], $sub_path,
needs_descriptions_format:
$data["HEAD"]['update_description'] ?? "");
$thumb_folder = $data['RESOURCES_INFO']['thumb_folder'] ?? "";
if (!empty($thumb_folder) && $fp = fopen(self::RECOMMENDATION_FILE,
"a")) {
fwrite($fp, $group_id . "###" . $data['PAGE_ID'] . "###" .
$thumb_folder . "\n");
fclose($fp);
}
$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'] . "');";
}
$this->addPodcastSourceStatus($data, $group_id,
$data['PAGE_ID'], $sub_path);
} else if ($data["HEAD"]['page_type'] == 'presentation' &&
$data['CONTROLLER'] == 'group') {
$data['page_type'] = 'presentation';
$data['INCLUDE_SCRIPTS'][] = "frise";
$data['INCLUDE_STYLES'][] = "frise";
}
}
/**
* Prepares the Git application-code controls a person sees when they
* open a Git repository wiki page for editing. This code stands in for
* the person's account password when they push to the repository, so
* the clone address shown here has their name and code woven in. The
* same saved code is shown on each visit; the person can ask for a fresh
* one by giving their password and choosing how long it should last. A
* fresh code is made from that password, the site's secret key, and the
* current time, and is saved for next time. An expired code is reported
* rather than shown.
*
* @param array &$data view data to fill with the sign-in clone address,
* the expiry choices, and any message about a refused or expired
* code
* @param string $clone_url the plain clone address for this repository,
* the address the person's name and code are woven into
*/
private function initializeGitAppCode(&$data, $clone_url)
{
$parent = $this->parent;
$user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
$username = $_SESSION['USER_NAME'] ?? "";
$git_model = $parent->model("git");
$durations = ["month" => C\ONE_MONTH,
"three_months" => 3 * C\ONE_MONTH,
"six_months" => 6 * C\ONE_MONTH, "year" => C\ONE_YEAR,
"never" => C\FOREVER];
$data['GIT_APP_CODE_DURATIONS'] = array_keys($durations);
$wants_refresh = !empty($_REQUEST['git_app_refresh']);
if ($wants_refresh) {
$signin_model = $parent->model("signin");
$check_name = $username;
$password = $_REQUEST['git_app_password'] ?? "";
if ($signin_model->checkValidSignin($check_name, $password)) {
$chosen = $_REQUEST['git_app_expiry'] ?? "";
$duration = $durations[$chosen] ?? C\ONE_MONTH;
$expires = ($duration == C\FOREVER) ? C\FOREVER :
time() + $duration;
$app_code = L\crawlAuthHash($password . microtime());
$git_model->setAppCode($user_id, $app_code, $expires);
} else {
$data['GIT_APP_CODE_BAD_PASSWORD'] = true;
}
}
$record = $git_model->getAppCode($user_id);
$valid = !empty($record['APP_CODE']) &&
($record['EXPIRES'] == C\FOREVER ||
$record['EXPIRES'] > time());
if ($valid) {
$scheme_end = strpos($clone_url, "://");
if ($scheme_end === false) {
$data['GIT_AUTH_CLONE_URL'] = $clone_url;
} else {
$data['GIT_AUTH_CLONE_URL'] =
substr($clone_url, 0, $scheme_end + 3) .
rawurlencode($username) . ":" . $record['APP_CODE'] .
"@" . substr($clone_url, $scheme_end + 3);
}
$data['GIT_APP_CODE_EXPIRES'] = $record['EXPIRES'];
} else if (!empty($record['APP_CODE'])) {
$data['GIT_APP_CODE_EXPIRED'] = true;
}
}
/**
* Works out, and remembers, the statistics shown for a Git repository
* wiki page: how many commits and files it has, its busiest authors,
* its commits by month, and its commonest file endings. The numbers are
* worked out from the repository once for a given newest commit and
* saved, so later views reuse them until a new commit changes the tip of
* the branch. Everything is shaped into bar rows the view can draw with
* no further arithmetic. The page's bare repository is opened here and
* its newest commit found, so this can be called wherever the page id is
* known without the read view having to hand a repository across.
*
* @param array &$data view data to fill with the statistics
* @param int $group_id id of the group the page belongs to, used to find
* the repository and name the saved-statistics file
* @param int $page_id id of the page, used to find the repository and
* name the saved-statistics file
*/
private function initializeGitStatistics(&$data, $group_id, $page_id)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$git_model = $parent->model("git");
$folders = $group_model->getGroupPageResourcesFolders($group_id,
$page_id, "", true, true);
$repository = new L\GitRepository($folders[0]);
if (!$repository->isRepository()) {
return;
}
$branches = $repository->branches();
if (empty($branches)) {
return;
}
$branch = $repository->headBranch();
if (!isset($branches[$branch])) {
$branch = array_key_first($branches);
}
$head_commit = $branches[$branch];
$cache_path = C\WORK_DIRECTORY . "/cache/git-stats-" .
$group_id . "-" . $page_id . ".json";
$stats = $git_model->readStatisticsCache($cache_path, $head_commit);
if ($stats === false) {
$stats = $repository->statistics($head_commit);
$git_model->writeStatisticsCache($cache_path, $head_commit,
$stats);
}
$data["GIT_STATS_COMMITS"] = $stats["commits"];
$data["GIT_STATS_FILES"] = $stats["files"];
/* the full, sorted rows are passed for each group; the view shows a
top handful and offers the rest behind a "more" control, so a
repository with many authors, file types, or months of history
can be explored without a long wall of bars by default */
$authors = $stats["authors"];
arsort($authors);
$data["GIT_STATS_AUTHORS"] = $this->gitStatsBars($parent, $authors);
$extensions = $stats["extensions"];
arsort($extensions);
$data["GIT_STATS_TYPES"] = $this->gitStatsBars($parent, $extensions);
$months = $stats["months"];
ksort($months);
$data["GIT_STATS_MONTHS"] = $this->gitStatsBars($parent,
array_reverse($months, true));
}
/**
* Turns a set of statistic labels and their counts into rows the view
* can draw as a simple bar chart. Each row carries the escaped label,
* the count, and the width its bar should take as a percentage of the
* largest count, so the view has no arithmetic to do. Labels come from
* repository contents, so they are escaped here before reaching the
* view.
*
* @param object $parent controller used to escape the labels
* @param array $counts label to count, in the order they should appear
* @return array one row per label, each ["label" => escaped label,
* "count" => the count, "width" => bar width as a whole-number
* percentage of the largest count]
*/
private function gitStatsBars($parent, $counts)
{
$largest = 0;
foreach ($counts as $count) {
if ($count > $largest) {
$largest = $count;
}
}
$full = 100;
$bars = [];
foreach ($counts as $label => $count) {
$width = ($largest > 0) ?
(int)round($full * $count / $largest) : 0;
$bars[] = ["label" => $parent->clean((string)$label, "string"),
"count" => $count, "width" => $width];
}
return $bars;
}
/**
* Prepares the issue view of a Git repository wiki page and, when a
* signed-in visitor has just filled in the new-issue form, reports the
* issue first so it appears in the list. The list can be narrowed to
* open, closed, unassigned, or the visitor's own issues. Reporting an
* issue stores its record, including the branch and version it was seen
* on, on a hidden companion page and starts that issue's own discussion
* thread. The reporter does not pick an urgency; a new issue starts at
* middle urgency and an editor changes it later.
*
* @param array &$data view data array; on return carries the GIT_ISSUE
* fields the issue view renders
* @param int $group_id id of the group the page belongs to
* @param string $page_name name of the Git repository wiki page
* @param string $prefix start of the address for links back into this
* page, already worked out by the caller
* @param object $repository the open repository, used to offer recent
* release tags as version choices
* @param string $branch name of the branch being shown, the default
* choice in the new-issue form
* @param array $branches map from each branch name to the commit it
* points at, used to check the chosen branch and to stand in for
* the current-code version
*/
private function initializeGitIssues(&$data, $group_id, $page_name,
$prefix, $repository, $branch, $branches)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_id = $_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID;
$locale_tag = L\getLocaleTag();
$issue_number = isset($_REQUEST["repo_issue"]) ?
(int)$_REQUEST["repo_issue"] : 0;
if ($issue_number > 0) {
$this->initializeGitIssueDetail($data, $group_id, $page_name,
$prefix, $issue_number);
return;
}
$issues_url = $prefix . "arg=read&repo_view=issues";
$data["GIT_VIEW"] = "issues";
$data["GIT_ISSUES_URL"] = htmlentities($issues_url);
$data["GIT_ISSUE_FILTER_URL"] = htmlentities($prefix .
"arg=read&repo_view=issues&repo_issue_filter=");
$can_report = ($user_id != C\PUBLIC_USER_ID);
$data["GIT_ISSUE_CAN_REPORT"] = $can_report;
if ($can_report) {
$data["GIT_ISSUE_TOKEN"] = $parent->generateCSRFToken($user_id);
}
$branch_names = [];
foreach (array_keys($branches) as $branch_name) {
$branch_names[] = $parent->clean($branch_name, "string");
}
$data["GIT_ISSUE_BRANCHES"] = $branch_names;
$data["GIT_ISSUE_DEFAULT_BRANCH"] = $parent->clean($branch,
"string");
$version_tags = [];
foreach (array_slice($repository->tags(), 0,
C\GIT_ISSUE_VERSION_CHOICES) as $tag) {
$version_tags[] = $parent->clean($tag["name"], "string");
}
$data["GIT_ISSUE_VERSIONS"] = $version_tags;
if ($can_report && !empty($_REQUEST["issue_title"]) &&
!empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
$parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
$title = trim($_REQUEST["issue_title"]);
$description = trim($_REQUEST["issue_description"] ?? "");
$chosen_branch = $_REQUEST["issue_branch"] ?? $branch;
if (!isset($branches[$chosen_branch])) {
$chosen_branch = $branch;
}
$chosen_version = $_REQUEST["issue_version"] ?? "current";
$tag_shas = [];
foreach ($repository->tags() as $tag) {
$tag_shas[$tag["name"]] = $tag["sha"];
}
if (isset($tag_shas[$chosen_version])) {
$version = $chosen_version;
} else {
$version = $branches[$chosen_branch] ?? "";
}
$record = L\WikiIssue::open($user_id, time(), $title,
$chosen_branch, $version);
$record["description"] = $description;
$group_model->createGitIssue($user_id, $group_id, $page_name,
$record, $locale_tag, $title, $description);
$parent->redirectLocation($issues_url);
return;
}
$filter = $_REQUEST["repo_issue_filter"] ?? "open";
if (!in_array($filter, ["reported", "assigned", "marked_fixed",
"marked_wont_fix", "open", "closed", "mine", "all"])) {
$filter = "open";
}
$data["GIT_ISSUE_FILTER"] = $filter;
$user_model = $parent->model("user");
$issues = $group_model->gitIssueList($group_id, $page_name,
$locale_tag);
$rows = [];
foreach ($issues as $number => $record) {
if (!$this->gitIssueMatchesFilter($record, $filter, $user_id)) {
continue;
}
$reporter = (int)($record["reporter"] ?? 0);
$assignee = (int)($record["assignee"] ?? 0);
$reporter_name = $reporter > 0 ?
(string)$user_model->getUsername($reporter) : "";
$assignee_name = $assignee > 0 ?
(string)$user_model->getUsername($assignee) : "";
$status_user = L\WikiIssue::statusUser($record);
$status_user_name = $status_user > 0 ?
(string)$user_model->getUsername($status_user) : "";
$last_modified = (int)($record["last_modified"] ?? 0);
$rows[] = ["NUMBER" => $number,
"TITLE" => $parent->clean($record["title"] ?? "", "string"),
"STATUS" => L\WikiIssue::displayStatus($record),
"PRIORITY" => $record["priority"] ?? 0,
"REPORTER" => $parent->clean($reporter_name, "string"),
"ASSIGNEE" => $parent->clean($assignee_name, "string"),
"STATUS_USER" =>
$parent->clean($status_user_name, "string"),
"UPDATED_TIME" => $last_modified,
"URL" => htmlentities($issues_url . "&repo_issue=" .
$number)];
}
usort($rows, function ($first, $second) {
return $second["NUMBER"] <=> $first["NUMBER"];
});
$data["GIT_ISSUE_ROWS"] = $rows;
}
/**
* Prepares the detail page for one issue and, when a group editor has
* just used one of its controls, makes the change first so the page
* shows it. An editor can give the issue to someone by name, mark it
* fixed with the commit that fixed it, mark it as one that won't be
* acted on, reopen it, or change how urgent it is. Each change is saved
* back onto the issue's companion page.
*
* @param array &$data view data array; on return carries the detail
* fields the detail page renders
* @param int $group_id id of the group the page belongs to
* @param string $page_name name of the git repository wiki page
* @param string $prefix start of the address for links back into this
* page, already worked out by the caller
* @param int $issue_number which issue to show
*/
private function initializeGitIssueDetail(&$data, $group_id, $page_name,
$prefix, $issue_number)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$user_id = $_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID;
$locale_tag = L\getLocaleTag();
$can_edit = !empty($data["CAN_EDIT"]);
$can_comment = ($user_id != C\PUBLIC_USER_ID);
$list_url = $prefix . "arg=read&repo_view=issues";
$detail_url = $list_url . "&repo_issue=" . $issue_number;
$data["GIT_VIEW"] = "issues";
$data["GIT_ISSUES_URL"] = htmlentities($list_url);
$record = $group_model->getGitIssue($group_id, $page_name,
$issue_number, $locale_tag);
if ($record === false) {
$parent->redirectLocation($list_url);
return;
}
/* Removing an issue cannot be undone, so it is asked for by a
link carrying a token of its own, which says the ask came from
the page rather than from somewhere else, and only someone
allowed to edit the repository's page is offered or obeyed. */
if ($can_edit && !empty($_REQUEST["repo_issue_delete"]) &&
!empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
$parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
$group_model->deleteGitIssue($group_id, $page_name,
$issue_number, $locale_tag);
$parent->redirectLocation($list_url);
return;
}
if ($can_edit) {
$data["GIT_ISSUE_DELETE_URL"] = htmlentities($detail_url .
"&repo_issue_delete=true&" . C\p('CSRF_TOKEN') . "=" .
$parent->generateCSRFToken($user_id));
}
[$thread_id, $comment_posts] = $group_model->getGitIssueThread(
$group_id, $page_name, $issue_number, $locale_tag);
if ($can_comment && $thread_id > 0 &&
!empty($_REQUEST["description"]) &&
!empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
$parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
$body = $parent->clean($_REQUEST["description"], "string");
if (trim($body) !== "") {
$title = "-- " . ($record["title"] ?? "");
$post_id = $this->addGroupItemWithinLimits($thread_id,
$group_id,
$user_id, $title, $body);
$this->handleResourceUploads($group_id, "post" . $post_id);
$parent->redirectLocation($detail_url);
return;
}
}
if ($can_edit && !empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
$parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
$record = $this->applyGitIssueChange($record, $user_id,
$user_model);
if ($record !== false) {
$group_model->updateGitIssue($user_id, $group_id, $page_name,
$issue_number, $record, $locale_tag);
$parent->redirectLocation($detail_url);
return;
}
$record = $group_model->getGitIssue($group_id, $page_name,
$issue_number, $locale_tag);
}
$data["GIT_ISSUE_DETAIL"] = true;
$data["GIT_ISSUE_NUMBER"] = $issue_number;
$data["GIT_ISSUE_DISPLAY_STATUS"] =
L\WikiIssue::displayStatus($record);
$data["GIT_ISSUE_IS_OPEN"] =
(($record["status"] ?? "") === L\WikiIssue::STATUS_OPEN);
$data["GIT_ISSUE_PRIORITY"] = (int)($record["priority"] ?? 0);
$data["GIT_ISSUE_TITLE"] = $parent->clean($record["title"] ?? "",
"string");
$data["GIT_ISSUE_HISTORY"] = $this->gitIssueHistoryLines($record,
$user_model);
$data["GIT_ISSUE_COMMENTS"] = $this->gitIssueCommentLines($record,
$comment_posts, $group_id, $locale_tag, $user_id);
$data["GIT_ISSUE_CAN_EDIT"] = $can_edit;
$data["GIT_ISSUE_CAN_COMMENT"] = $can_comment;
$data["GIT_ISSUE_UPLOAD_MAX"] = min(
L\metricToInt(ini_get('upload_max_filesize')),
L\metricToInt(ini_get('post_max_size')));
$data["GIT_ISSUE_DETAIL_URL"] = htmlentities($detail_url);
if ($can_edit || $can_comment) {
$data["GIT_ISSUE_TOKEN"] = $parent->generateCSRFToken($user_id);
}
if ($can_comment) {
$this->initializeWikiEditor($data, -1);
$data['SCRIPT'] .= "if (elt('comment-add-comment')) {" .
"initializeFileHandler('comment-add-comment', " .
"'file-add-comment', " .
(int)($data["GIT_ISSUE_UPLOAD_MAX"] ?? 0) .
", 'textarea', null, true);editorize('comment-add-comment');" .
"setDisplay('add-comment', false);}\n";
}
}
/**
* Carries out whichever detail-page control an editor used on an issue
* record and hands back the changed record, or false when nothing valid
* was asked for. A change of who holds the issue is looked up by name, a
* fix keeps the commit that made it, and a change of urgency is limited
* to the three known levels.
*
* @param array $record the issue record to change
* @param int $user_id id of the editor making the change
* @param object $user_model model used to look up an assignee by name
* @return mixed the changed record, or false when nothing changed
*/
private function applyGitIssueChange($record, $user_id, $user_model)
{
$changed = false;
$target = $_REQUEST["issue_action"] ?? "";
$is_closed = (($record["status"] ?? "") ===
L\WikiIssue::STATUS_CLOSED);
if ($target === "assigned") {
$who = trim($_REQUEST["issue_assignee"] ?? "");
$found = $user_model->getUser($who);
if (!empty($found["USER_ID"])) {
if ($is_closed) {
$record = L\WikiIssue::reopen($record, $user_id, time());
}
$record = L\WikiIssue::assign($record, $found["USER_ID"],
$user_id, time());
$changed = true;
}
} else if ($target === "marked_fixed") {
$commit = trim($_REQUEST["issue_commit"] ?? "");
$record = L\WikiIssue::close($record,
L\WikiIssue::RESOLUTION_FIXED, $user_id, time(), $commit);
$changed = true;
} else if ($target === "marked_wont_fix") {
$record = L\WikiIssue::close($record,
L\WikiIssue::RESOLUTION_WONT_FIX, $user_id, time());
$changed = true;
} else if ($target === "reported") {
$record = L\WikiIssue::report($record, $user_id, time());
$changed = true;
}
if (isset($_REQUEST["issue_priority"])) {
$priority = (int)$_REQUEST["issue_priority"];
if (in_array($priority, [L\WikiIssue::PRIORITY_LOW,
L\WikiIssue::PRIORITY_MEDIUM, L\WikiIssue::PRIORITY_HIGH])) {
$record = L\WikiIssue::setPriority($record, $priority,
$user_id, time());
$changed = true;
}
}
return $changed ? $record : false;
}
/**
* Turns an issue's history into the dated lines the detail page shows,
* keeping only the status changes and putting each into words: reported,
* assigned to a named person, marked fixed with or without a commit,
* marked won't fix, or reopened.
*
* @param array $record the issue record whose history is wanted
* @param object $user_model model used to name the person an issue went
* to
* @return array a list of lines, each with its wording and its date
*/
private function gitIssueHistoryLines($record, $user_model)
{
$parent = $this->parent;
$lines = [];
foreach ($record["history"] ?? [] as $entry) {
$action = $entry["action"] ?? "";
$when = date("Y-m-d H:i", (int)($entry["time"] ?? 0));
if ($action === L\WikiIssue::ACTION_OPENED) {
$who = $parent->clean((string)
$user_model->getUsername($entry["by"] ?? 0), "string");
$label = tl('social_component_git_issue_h_reported', $who);
} else if ($action === L\WikiIssue::ACTION_ASSIGNED) {
$who = $parent->clean((string)
$user_model->getUsername($entry["to"] ?? 0), "string");
$label = tl('social_component_git_issue_h_assigned', $who);
} else if ($action === L\WikiIssue::ACTION_CLOSED) {
if (($entry["resolution"] ?? "") ===
L\WikiIssue::RESOLUTION_WONT_FIX) {
$label = tl('social_component_git_issue_h_wont_fix');
} else if (!empty($entry["commit"])) {
$commit = $parent->clean($entry["commit"], "string");
$label = tl('social_component_git_issue_h_fixed_at',
$commit);
} else {
$label = tl('social_component_git_issue_h_fixed');
}
} else if ($action === L\WikiIssue::ACTION_REOPENED) {
$label = tl('social_component_git_issue_h_reopened');
} else {
continue;
}
$lines[] = ["LABEL" => $label, "WHEN" => $when];
}
return $lines;
}
/**
* Builds the comments shown on an issue's detail page. The first comment
* is the issue's own description, credited to whoever reported it at the
* time they did; the rest are the replies posted to the issue's
* discussion thread. Every body is run through the wiki parser so wiki
* or markdown text and uploaded resources show the way they do on a
* normal discussion post.
*
* @param array $record the issue record whose comments are wanted
* @param array $comment_posts reply posts read from the issue's thread
* @param int $group_id id of the group the issue belongs to
* @param string $locale_tag language the page is written for
* @param int $user_id id of the person viewing, for the resource token
* @return array a list of comments, each with a name, a time, and a body
*/
private function gitIssueCommentLines($record, $comment_posts, $group_id,
$locale_tag, $user_id)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$user_model = $parent->model("user");
$parser = new WikiParser("", true);
$render_engine = $group_model->getRenderEngine($group_id);
$csrf_token = C\p('CSRF_TOKEN') . "=" .
$parent->generateCSRFToken($user_id);
$reported_time = (int)($record["history"][0]["time"] ?? 0);
$lines = [];
$lines[] = [
"NAME" => $parent->clean((string)
$user_model->getUsername($record["reporter"] ?? 0),
"string"),
"WHEN" => date("Y-m-d H:i", $reported_time),
"BODY" => $parser->parse($record["description"] ?? "",
render_engine: $render_engine)];
foreach ($comment_posts as $post) {
$body = $parser->parse($post["DESCRIPTION"] ?? "",
render_engine: $render_engine);
$body = $group_model->insertResourcesParsePage($group_id,
"post" . $post["ID"], $locale_tag, $body);
$body = preg_replace('/\[{rtoken}\]/', $csrf_token, $body);
$lines[] = [
"NAME" => $parent->clean((string)($post["USER_NAME"] ?? ""),
"string"),
"WHEN" => date("Y-m-d H:i", (int)($post["PUBDATE"] ?? 0)),
"BODY" => $body];
}
return $lines;
}
/**
* Decides whether one issue belongs in the list under the chosen filter:
* by its single status of reported, assigned, marked fixed, or marked
* won't fix; by the open group that gathers reported and assigned or the
* closed group that gathers the two marked states; or by whether the
* visitor reported it or was given it.
*
* @param array $record the issue record being weighed
* @param string $filter which set of issues the visitor asked for
* @param int $user_id id of the visitor, for their own issues
* @return bool whether this issue belongs in the list
*/
private function gitIssueMatchesFilter($record, $filter, $user_id)
{
$status = $record["status"] ?? "";
$display = L\WikiIssue::displayStatus($record);
$assignee = $record["assignee"] ?? 0;
$reporter = $record["reporter"] ?? 0;
if ($filter === "all") {
return true;
}
if ($filter === "reported") {
return $display === L\WikiIssue::DISPLAY_REPORTED;
}
if ($filter === "assigned") {
return $display === L\WikiIssue::DISPLAY_ASSIGNED;
}
if ($filter === "marked_fixed") {
return $display === L\WikiIssue::DISPLAY_FIXED;
}
if ($filter === "marked_wont_fix") {
return $display === L\WikiIssue::DISPLAY_WONT_FIX;
}
if ($filter === "closed") {
return $status === L\WikiIssue::STATUS_CLOSED;
}
if ($filter === "mine") {
return $reporter == $user_id || $assignee == $user_id;
}
return $status === L\WikiIssue::STATUS_OPEN;
}
/**
* Gives the media type a repository file should be drawn inline as,
* based on its name ending, for the file types a browser can show
* directly: common images, PDF, and common audio and video files.
* Returns the empty string for any other file, which is shown as text
* or noted as binary instead.
*
* @param string $name the file's name
* @return string the media type, such as "image/png", "video/mp4", or
* "application/pdf", or "" when the file is not one a browser draws
*/
private function gitRenderableMediaType($name)
{
$media_types = ["png" => "image/png", "gif" => "image/gif",
"jpg" => "image/jpeg", "jpeg" => "image/jpeg",
"webp" => "image/webp", "bmp" => "image/bmp",
"ico" => "image/x-icon", "svg" => "image/svg+xml",
"pdf" => "application/pdf",
"mp3" => "audio/mpeg", "wav" => "audio/wav",
"ogg" => "audio/ogg", "oga" => "audio/ogg",
"m4a" => "audio/mp4", "aac" => "audio/aac",
"flac" => "audio/flac",
"mp4" => "video/mp4", "m4v" => "video/mp4",
"webm" => "video/webm", "ogv" => "video/ogg",
"mov" => "video/quicktime"];
$ending = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return $media_types[$ending] ?? "";
}
/**
* Sorts a media type into the kind of tag the view should use to draw
* it: an image, a PDF viewer, an audio player, or a video player.
*
* @param string $media_type the file's media type, such as "image/png"
* @return string one of "image", "pdf", "audio", or "video"
*/
private function gitMediaKind($media_type)
{
if ($media_type === "application/pdf") {
return "pdf";
}
if (str_starts_with($media_type, "audio/")) {
return "audio";
}
if (str_starts_with($media_type, "video/")) {
return "video";
}
return "image";
}
/**
* Prepares everything the read view needs to show a Git repository wiki
* page as a browsable file tree. A Git repository page keeps its files
* inside a bare repository in the page's resource folder rather than as
* wiki text, so this opens that repository, picks the branch to show,
* walks to the folder or file named in the address, and hands the view
* an escaped listing, an optional file's contents, a rendered README,
* the branch list, breadcrumb links, and the address a visitor would
* clone from. When the repository holds no commits yet the page is
* marked empty so the view can say so instead of listing nothing.
*
* @param array &$data view data array; on return carries the GIT_ fields
* the read view renders
* @param int $group_id id of the group the page belongs to
* @param string $sub_path folder path from the wiki resource system,
* unused here because the path inside the repository travels in its
* own request field
*/
public function initializeGitRepositoryReadMode(&$data, $group_id,
$sub_path)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$page_name = $data["PAGE_NAME"];
$data["GIT_REPO"] = true;
/*
Everything a visitor can steer in this request is read here, in
one place, so a reader can see the whole request surface at a
glance. Each of these is checked further down against the
repository's own data (its branch and tag names, its object
names) or against a fixed set of choices before it is acted on,
which is why they travel from here as the raw text the visitor
sent. Where any of them is shown back to the visitor it is
escaped at the point it is placed into the view data, not here.
*/
$requested_branch = $_REQUEST["repo_branch"] ?? "";
$requested_ref = $_REQUEST["repo_ref"] ?? "";
$requested_commit = $_REQUEST["repo_commit"] ?? "";
$requested_download = $_REQUEST["repo_download"] ?? "";
$requested_path = $_REQUEST["repo_path"] ?? "";
$requested_view = $_REQUEST["repo_view"] ?? "";
$data["GIT_CLONE_URL"] = C\p('NAME_SERVER') . "group/" . $group_id .
"/" . $page_name . ".git";
$folders = $group_model->getGroupPageResourcesFolders($group_id,
$data["PAGE_ID"], "", true, true);
$repository = new L\GitRepository($folders[0]);
if ($repository->folderState() === "occupied") {
/* the resource path points at a folder that already holds other
content and is not a repository, so a git request cannot be
served from it; flag this so the read view can warn */
$data["RESOURCE_PATH_NOT_GIT"] = true;
}
$branches = $repository->isRepository() ?
$repository->branches() : [];
$data["GIT_BRANCHES"] = array_keys($branches);
if (empty($branches)) {
/* the repository has no commits yet, but its issue tracker does
not depend on repository content and should still open, so
people can report and discuss issues before any code has been
pushed; every other view has nothing to show and falls back to
the empty-repository notice */
if ($requested_view === "issues") {
$prefix = B\wikiUrl($page_name, true, $data["CONTROLLER"],
$group_id);
$this->initializeGitIssues($data, $group_id, $page_name,
$prefix, $repository, "", $branches);
return;
}
$data["GIT_EMPTY"] = true;
return;
}
$branch = $repository->headBranch();
if ($requested_branch !== "" &&
isset($branches[$requested_branch])) {
$branch = $requested_branch;
}
if (!isset($branches[$branch])) {
$branch = array_key_first($branches);
}
$data["GIT_BRANCH"] = $parent->clean($branch, "string");
$head_commit = $branches[$branch];
$view_commit = $head_commit;
$ref_label = tl("wiki_element_git_head");
$ref_query = "";
if ($requested_ref !== "") {
foreach ($repository->tags() as $tag) {
if ($tag["name"] === $requested_ref) {
$view_commit = $tag["sha"];
$ref_label = $parent->clean($tag["name"], "string");
$ref_query =
"&repo_ref=" . rawurlencode($tag["name"]);
break;
}
}
} else if ($requested_commit !== "" &&
ctype_xdigit($requested_commit) &&
strlen($requested_commit) ===
L\GitRepository::SHA_HEX_LENGTH) {
try {
$repository->commit($requested_commit);
$view_commit = $requested_commit;
$ref_label = substr($view_commit, 0,
C\GIT_SHORT_HASH_LENGTH);
$ref_query = "&repo_commit=" . $view_commit;
} catch (\Exception $error) {
$view_commit = $head_commit;
}
}
$data["GIT_REF_LABEL"] = $ref_label;
if ($requested_download !== "" && $requested_path === "") {
$this->gitStreamArchive($repository, $view_commit, $page_name,
$requested_download);
return;
}
$commit = $repository->commit($view_commit);
$path_parts = array_values(array_filter(explode("/", $requested_path),
function ($part) {
return $part !== "" && $part !== "." && $part !== "..";
}));
$tree_sha = $commit["tree"];
$walked = [];
$file_entry = null;
foreach ($path_parts as $part) {
$found = null;
foreach ($repository->tree($tree_sha) as $entry) {
if ($entry["name"] === $part) {
$found = $entry;
break;
}
}
if ($found === null) {
break;
}
$walked[] = $part;
if ($found["is_dir"]) {
$tree_sha = $found["sha"];
} else {
$file_entry = $found;
break;
}
}
$current_path = implode("/", $walked);
$data["GIT_PATH"] = $parent->clean($current_path, "string");
if ($requested_download !== "") {
$this->gitStreamResource($repository, $file_entry, $tree_sha,
$view_commit, $page_name, $current_path,
$requested_download);
return;
}
$prefix = B\wikiUrl($page_name, true, $data["CONTROLLER"], $group_id);
if (!empty($data[C\p('CSRF_TOKEN')])) {
$prefix .= C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] . "&";
}
$link_for = function ($path) use ($prefix, $branch, $ref_query) {
return htmlentities($prefix . "arg=read&repo_branch=" .
rawurlencode($branch) . $ref_query . "&repo_path=" .
rawurlencode($path));
};
$data["GIT_BRANCH_OPTIONS"] = [];
foreach ($branches as $branch_name => $branch_sha) {
$data["GIT_BRANCH_OPTIONS"][] = ["NAME" =>
$parent->clean($branch_name, "string"), "SELECTED" =>
($branch_name === $branch), "URL" => htmlentities($prefix .
"arg=read&repo_branch=" . rawurlencode($branch_name) .
"&repo_path=")];
}
$data["GIT_REF_MENU"] = $this->gitRefMenu($repository, $prefix,
$branch, $view_commit);
$download_base = $prefix . "arg=read&repo_branch=" .
rawurlencode($branch) . $ref_query;
$data["GIT_DOWNLOAD_TARGZ"] =
htmlentities($download_base . "&repo_download=targz");
$data["GIT_DOWNLOAD_ZIP"] =
htmlentities($download_base . "&repo_download=zip");
$resource_download = ($file_entry !== null) ? "raw" : "targz";
$data["GIT_RESOURCE_DOWNLOAD_URL"] = htmlentities($download_base .
"&repo_path=" . rawurlencode($current_path) .
"&repo_download=" . $resource_download);
$crumbs = [["NAME" => $parent->clean($page_name, "string"),
"URL" => $link_for("")]];
$trail = "";
foreach ($walked as $part) {
$trail = ($trail === "") ? $part : $trail . "/" . $part;
$crumbs[] = ["NAME" => $parent->clean($part, "string"),
"URL" => $link_for($trail)];
}
$data["GIT_CRUMBS"] = $crumbs;
$data["GIT_PARENT_URL"] = (count($crumbs) > 1) ?
$crumbs[count($crumbs) - 2]["URL"] : "";
if ($requested_view === "commits") {
$this->gitListView($data, $repository, $view_commit, $prefix,
$branch, $ref_query, "commits");
return;
}
if ($requested_view === "tags") {
$this->gitListView($data, $repository, $view_commit, $prefix,
$branch, $ref_query, "tags");
return;
}
if ($requested_view === "diff") {
$this->gitCommitDiff($data, $repository, $view_commit, $prefix,
$branch, $ref_query);
return;
}
if ($requested_view === "issues") {
$this->initializeGitIssues($data, $group_id, $page_name,
$prefix, $repository, $branch, $branches);
return;
}
if ($file_entry !== null) {
$blob = $repository->blob($file_entry["sha"]);
if (strlen($blob) > C\MAX_GIT_BLOB_VIEW_LEN) {
$data["GIT_FILE_TOO_LARGE"] = true;
} else {
$media_type = $this->gitRenderableMediaType(
$file_entry["name"]);
if ($media_type !== "") {
$data["GIT_FILE_MEDIA"] = "data:" . $media_type .
";base64," . base64_encode($blob);
$data["GIT_FILE_MEDIA_KIND"] =
$this->gitMediaKind($media_type);
} else if (str_contains($blob, "\0")) {
$data["GIT_FILE_BINARY"] = true;
} else {
$data["GIT_FILE"] = $parent->clean($blob, "string");
}
}
return;
}
$entries = $repository->tree($tree_sha);
$listed_folders = [];
$listed_files = [];
$folder_names = [];
$file_names = [];
$readme_sha = "";
foreach ($entries as $entry) {
$entry_path = ($current_path === "") ? $entry["name"] :
$current_path . "/" . $entry["name"];
$item = ["NAME" => $parent->clean($entry["name"], "string"),
"IS_DIR" => $entry["is_dir"], "URL" => $link_for($entry_path)];
if ($entry["is_dir"]) {
$listed_folders[] = $item;
$folder_names[] = $entry["name"];
} else {
$listed_files[] = $item;
$file_names[] = $entry["name"];
if ($readme_sha === "" && in_array(
strtolower($entry["name"]), ["readme.md", "readme"])) {
$readme_sha = $entry["sha"];
}
}
}
$data["GIT_ENTRIES"] = array_merge($listed_folders, $listed_files);
$ordered_names = array_merge($folder_names, $file_names);
$commit_info = [];
try {
$commit_info = $repository->lastCommitForEntries(
$view_commit, $walked, $ordered_names);
} catch (\Exception $error) {
$commit_info = [];
}
foreach ($ordered_names as $index => $raw_name) {
$summary = "";
$commit_time = 0;
$commit_author = "";
if (isset($commit_info[$raw_name])) {
$summary = $parent->clean(
$commit_info[$raw_name]["summary"], "string");
$commit_time = (int)$commit_info[$raw_name]["time"];
$commit_author = $parent->clean(
$commit_info[$raw_name]["author"] ?? "", "string");
}
$data["GIT_ENTRIES"][$index]["COMMIT_SUMMARY"] = $summary;
$data["GIT_ENTRIES"][$index]["COMMIT_TIME"] = $commit_time;
$data["GIT_ENTRIES"][$index]["COMMIT_AUTHOR"] = $commit_author;
}
if ($readme_sha !== "") {
$readme = $repository->blob($readme_sha);
if (strlen($readme) <= C\MAX_GIT_BLOB_VIEW_LEN &&
!str_contains($readme, "\0")) {
$wiki_parser = new L\WikiParser();
$data["GIT_README_HTML"] =
$wiki_parser->parseMarkdown($readme);
$data["GIT_README_TOC"] =
$this->gitReadmeToc($data["GIT_README_HTML"]);
}
}
}
/**
* Builds the ref chooser's menu: the list a reader opens beside the
* branch chooser to move between points in the repository's history. It
* offers the branch's latest commit (its HEAD), a way into the full
* commit list, the few most recent commits directly, and, when the
* repository has tags, a way into the full tag list and the few most
* recent tags directly. Picking a commit or tag shows that snapshot's
* files.
*
* @param object $repository the repository being read
* @param string $prefix start of every read-view link, carrying the
* page address and any request token
* @param string $branch name of the branch currently chosen
* @param string $view_commit object name of the commit whose recent
* history the menu lists
* @return array menu rows, each ["LABEL" => text shown, "URL" => where
* it leads, "KIND" => "head" for a section opener or "item" for an
* ordinary entry]
*/
private function gitRefMenu($repository, $prefix, $branch, $view_commit)
{
$parent = $this->parent;
$branch_query = "arg=read&repo_branch=" . rawurlencode($branch);
$menu = [["LABEL" => tl("wiki_element_git_head"),
"URL" => htmlentities($prefix . $branch_query . "&repo_path="),
"KIND" => "item"]];
$menu[] = ["LABEL" => tl("wiki_element_git_commit_list"),
"URL" => htmlentities($prefix . $branch_query .
"&repo_view=commits"), "KIND" => "head"];
foreach ($repository->commitList($view_commit, 0,
C\GIT_RECENT_REF_COUNT) as $entry) {
$menu[] = ["LABEL" => substr($entry["sha"], 0,
C\GIT_SHORT_HASH_LENGTH) . " " .
$parent->clean($entry["subject"], "string"),
"URL" => htmlentities($prefix . $branch_query .
"&repo_commit=" . $entry["sha"] . "&repo_path="),
"KIND" => "item"];
}
$tags = $repository->tags();
if (!empty($tags)) {
$menu[] = ["LABEL" => tl("wiki_element_git_tag_list"),
"URL" => htmlentities($prefix . $branch_query .
"&repo_view=tags"), "KIND" => "head"];
foreach (array_slice($tags, 0, C\GIT_RECENT_REF_COUNT)
as $tag) {
$menu[] = ["LABEL" =>
$parent->clean($tag["name"], "string"),
"URL" => htmlentities($prefix . $branch_query .
"&repo_ref=" . rawurlencode($tag["name"]) .
"&repo_path="), "KIND" => "item"];
}
}
return $menu;
}
/**
* Prepares one page of the commit list or the tag list for the read
* view. The rows are built already escaped and ready to place in the
* table. When the request is the background one the page makes as the
* reader scrolls, just the rows are sent back so they can be added to
* the end of the table; otherwise the rows and the address to fetch the
* next page from are handed to the view to draw the first page.
*
* @param array &$data view data to add the list to
* @param object $repository the repository being read
* @param string $view_commit object name of the commit the commit list
* starts from
* @param string $prefix start of every read-view link
* @param string $branch name of the branch currently chosen
* @param string $ref_query the part of a link naming the chosen commit
* or tag, if any
* @param string $kind either "commits" or "tags"
*/
private function gitListView(&$data, $repository, $view_commit, $prefix,
$branch, $ref_query, $kind)
{
$parent = $this->parent;
/*
The list request's own fields are read here in one place. The
offset is a count, the sort column and direction are checked
below against a fixed set of choices, and the search text is
only ever compared against and shown back escaped, so, as in
the read view above, these travel from here as the raw text the
visitor sent.
*/
$offset = (isset($_REQUEST["repo_offset"]) &&
ctype_digit((string)$_REQUEST["repo_offset"])) ?
(int)$_REQUEST["repo_offset"] : 0;
$sort = $_REQUEST["repo_sort"] ?? "";
$direction = (($_REQUEST["repo_dir"] ?? "") === "asc") ?
"asc" : "desc";
$search = trim($_REQUEST["repo_search"] ?? "");
$send_rows_only = !empty($_REQUEST["repo_rows"]);
$page_size = C\GIT_LIST_PAGE_SIZE;
if ($kind === "tags") {
$rows = array_slice($repository->tags(), $offset, $page_size);
} else {
$rows = $repository->commitList($view_commit, $offset,
$page_size);
}
if ($sort !== "" || $search !== "") {
if ($kind === "tags") {
$full = $repository->tags();
} else {
$full = $repository->commitList($view_commit, 0, 0);
}
$full = $this->gitFilterSortRows($full, $kind, $search, $sort,
$direction);
$rows = array_slice($full, $offset, $page_size);
}
$rows_html = $this->gitListRowsHtml($rows, $kind, $prefix, $branch);
if ($send_rows_only) {
$parent->web_site->header(
"Content-Type: text/html; charset=utf-8");
echo $rows_html;
\seekquarry\atto\webExit();
return;
}
$sort_query = "";
if ($sort !== "") {
$sort_query = "&repo_sort=" . rawurlencode($sort) .
"&repo_dir=" . $direction;
}
if ($search !== "") {
$sort_query .= "&repo_search=" . rawurlencode($search);
}
$data["GIT_VIEW"] = $kind;
$data["GIT_LIST_ROWS"] = $rows_html;
$data["GIT_LIST_SORT"] = $sort;
$data["GIT_LIST_DIR"] = $direction;
$data["GIT_LIST_SEARCH"] = $parent->clean($search, "string");
$data["GIT_LIST_SORT_BASE"] = htmlentities($prefix .
"arg=read&repo_branch=" . rawurlencode($branch) . $ref_query .
"&repo_view=" . $kind);
$data["GIT_LIST_SCROLL_URL"] = $prefix .
"arg=read&repo_branch=" . rawurlencode($branch) . $ref_query .
"&repo_view=" . $kind . $sort_query . "&repo_rows=1";
$data["GIT_LIST_PAGE_SIZE"] = $page_size;
/* The list is filled in as the reader scrolls it, which basic.js
does. That file is loaded at the end of the layout, after the
page's own content, so a script tag written into the list
itself would run before the function it calls exists and the
reader would see only the first page of rows. What is put here
is written out after basic.js, so the function is there to be
called. */
$data['SCRIPT'] ??= "";
$data['SCRIPT'] .= 'gitInfiniteScroll("git-list-box", ' .
'"git-list-rows", "' . $data["GIT_LIST_SCROLL_URL"] . '", ' .
$page_size . ');';
}
/**
* Builds the table rows for a page of the commit list or the tag list,
* already escaped. A commit row shows the date, author, and message; a
* tag row shows the tag name, date, and message. Both end with links to
* browse that snapshot's files and to download it as a tar.gz or a zip.
*
* @param array $rows the commits or tags to render
* @param string $kind either "commits" or "tags"
* @param string $prefix start of every read-view link
* @param string $branch name of the branch currently chosen
* @return string the rows as HTML
*/
private function gitListRowsHtml($rows, $kind, $prefix, $branch)
{
$parent = $this->parent;
$branch_query = "arg=read&repo_branch=" . rawurlencode($branch);
$html = "";
foreach ($rows as $row) {
if ($kind === "tags") {
$ref = "&repo_ref=" . rawurlencode($row["name"]);
} else {
$ref = "&repo_commit=" . $row["sha"];
}
$browse = htmlentities($prefix . $branch_query . $ref .
"&repo_path=");
$diff = htmlentities($prefix . $branch_query . "&repo_commit=" .
$row["sha"] . "&repo_view=diff");
$targz = htmlentities($prefix . $branch_query . $ref .
"&repo_download=targz");
$zip = htmlentities($prefix . $branch_query . $ref .
"&repo_download=zip");
$date = date("Y-m-d H:i", $row["time"]);
$subject = $parent->clean($row["subject"], "string");
$actions = "<a class='git-action' title='" .
tl("wiki_element_git_browse") . "' href='" . $browse .
"'>📂</a><a class='git-action' title='" .
tl("wiki_element_git_diff") . "' href='" . $diff .
"'></></a><a class='git-action' title='tar.gz'" .
" href='" . $targz .
"'>🗜️</a><a class='git-action' " .
"title='zip' href='" . $zip . "'>📦</a>";
if ($kind === "tags") {
$html .= "<tr><td class='git-name-col'>" .
$parent->clean($row["name"], "string") .
"</td><td class='git-date-col'>" . $date .
"</td><td class='git-msg-col'>" . $subject .
"</td><td class='git-actions-col'>" . $actions .
"</td></tr>";
} else {
$html .= "<tr><td class='git-date-col'>" . $date .
"</td><td class='git-author-col'>" .
$parent->clean($row["author"], "string") .
"</td><td class='git-msg-col'>" . $subject .
"</td><td class='git-actions-col'>" . $actions .
"</td></tr>";
}
}
return $html;
}
/**
* Builds a downloadable archive of one snapshot of the repository and
* sends it to the reader's browser. The archive is written to a
* temporary file rather than built up in memory, streamed back with a
* name made from the page and the short commit name, and then removed.
* A build that is refused, for instance because the snapshot is too
* large to archive, is answered with a server-error status instead.
*
* @param object $repository the repository being read
* @param string $view_commit object name of the commit to archive
* @param string $page_name the wiki page name, used to name the file
* @param string $format either "zip" or "targz"
*/
private function gitStreamArchive($repository, $view_commit, $page_name,
$format)
{
$parent = $this->parent;
$format = ($format === "zip") ? "zip" : "targz";
$extension = ($format === "zip") ? ".zip" : ".tar.gz";
$content_type = ($format === "zip") ? "application/zip" :
"application/gzip";
$safe_name = preg_replace("/[^A-Za-z0-9._-]/", "_", $page_name);
$short = substr($view_commit, 0, C\GIT_SHORT_HASH_LENGTH);
$temp_dir = C\TEMP_DIR . "/";
if (!file_exists($temp_dir)) {
mkdir($temp_dir);
}
$temp_path = $temp_dir . "git_archive_" .
L\crawlHash($view_commit . microtime() .
random_int(0, PHP_INT_MAX)) . $extension;
try {
$repository->writeArchive($view_commit, $format, $temp_path,
$safe_name . "/");
} catch (\Exception $error) {
@unlink($temp_path);
$parent->web_site->header("HTTP/1.1 500 Internal Server Error");
\seekquarry\atto\webExit();
return;
}
$this->gitSendDownloadFile($temp_path, $content_type,
$safe_name . "-" . $short . $extension);
}
/**
* Streams a finished download file back to the reader as an attachment
* and removes it. Shared by the whole-repository archive, the single
* folder archive, so the download headers and cleanup are written in
* one place.
*
* @param string $temp_path file holding the built download
* @param string $content_type MIME type to label the download with
* @param string $download_name file name the browser saves it under
*/
private function gitSendDownloadFile($temp_path, $content_type,
$download_name)
{
$parent = $this->parent;
$parent->web_site->header("HTTP/1.1 200 OK");
$parent->web_site->header("Content-Type: " . $content_type);
$parent->web_site->header("Content-Disposition: attachment; " .
"filename=\"" . $download_name . "\"");
$parent->web_site->header("Content-Length: " .
filesize($temp_path));
readfile($temp_path);
@unlink($temp_path);
\seekquarry\atto\webExit();
}
/**
* Sends the one file or folder the reader is looking at to their
* browser. A file is served as its raw bytes to save; a folder is
* packaged into a tar.gz or zip archive of just that folder's subtree,
* built to a temporary file and streamed back the same way a whole
* repository archive is. A build that is refused is answered with a
* server-error status instead.
*
* @param object $repository the repository being read
* @param array $file_entry the file being viewed, or null on a folder
* view
* @param string $tree_sha object name of the folder's tree, used when
* archiving a folder
* @param string $view_commit object name of the snapshot being read
* @param string $page_name the wiki page name, used to name downloads
* @param string $current_path folder path being viewed, used to name
* a folder download
* @param string $format "raw" for a file, else "zip" or "targz"
*/
private function gitStreamResource($repository, $file_entry, $tree_sha,
$view_commit, $page_name, $current_path, $format)
{
$parent = $this->parent;
if ($file_entry !== null) {
$blob = $repository->blob($file_entry["sha"]);
$safe_name = preg_replace("/[^A-Za-z0-9._-]/", "_",
$file_entry["name"]);
$parent->web_site->header("HTTP/1.1 200 OK");
$parent->web_site->header(
"Content-Type: application/octet-stream");
$parent->web_site->header("Content-Disposition: attachment; " .
"filename=\"" . $safe_name . "\"");
$parent->web_site->header("Content-Length: " . strlen($blob));
echo $blob;
\seekquarry\atto\webExit();
return;
}
$format = ($format === "zip") ? "zip" : "targz";
$extension = ($format === "zip") ? ".zip" : ".tar.gz";
$content_type = ($format === "zip") ? "application/zip" :
"application/gzip";
$safe_name = preg_replace("/[^A-Za-z0-9._-]/", "_", $page_name);
if ($current_path !== "") {
$safe_name .= "_" . preg_replace("/[^A-Za-z0-9._-]/", "_",
$current_path);
}
$temp_dir = C\TEMP_DIR . "/";
if (!file_exists($temp_dir)) {
mkdir($temp_dir);
}
$temp_path = $temp_dir . "git_archive_" . L\crawlHash(
$view_commit . $current_path . microtime() .
random_int(0, PHP_INT_MAX)) . $extension;
try {
$repository->writeArchiveFromTree($tree_sha, $format,
$temp_path, $safe_name . "/");
} catch (\Exception $error) {
@unlink($temp_path);
$parent->web_site->header(
"HTTP/1.1 500 Internal Server Error");
\seekquarry\atto\webExit();
return;
}
$this->gitSendDownloadFile($temp_path, $content_type,
$safe_name . $extension);
}
/**
* Filters and sorts the commits or tags for the read view's search box
* and sortable column headers. When a search term is given, only rows
* whose text (a commit's message, author, and date, or a tag's name
* and date) contains it are kept. When a sort column is given, the rows
* are ordered by that column's value in the chosen direction. Called
* with the whole bounded history so the result stays consistent as the
* reader scrolls.
*
* @param array $rows the commits or tags to narrow and order
* @param string $kind either "commits" or "tags"
* @param string $search text to keep only matching rows, or "" for all
* @param string $sort column to order by: "date", "author", "message",
* or "tag", or "" to leave the order unchanged
* @param string $direction "asc" or "desc"
* @return array the kept rows in the chosen order
*/
private function gitFilterSortRows($rows, $kind, $search, $sort,
$direction)
{
if ($search !== "") {
$needle = mb_strtolower($search);
$rows = array_values(array_filter($rows,
function ($row) use ($needle, $kind) {
if ($kind === "tags") {
$text = $row["name"] . " " .
date("Y-m-d", $row["time"]);
} else {
$text = $row["subject"] . " " . $row["author"] .
" " . date("Y-m-d", $row["time"]);
}
return str_contains(mb_strtolower($text), $needle);
}));
}
$keys = ["date" => "time", "author" => "author",
"message" => "subject", "tag" => "name"];
if (isset($keys[$sort])) {
$key = $keys[$sort];
usort($rows, function ($first, $second) use ($key) {
if ($key === "time") {
return $first["time"] <=> $second["time"];
}
return strcasecmp((string)($first[$key] ?? ""),
(string)($second[$key] ?? ""));
});
if ($direction === "desc") {
$rows = array_reverse($rows);
}
}
return $rows;
}
/**
* Builds the changed-file diff of one commit for the read view. Each
* file the commit added, removed, or changed is listed with a line by
* line comparison of its earlier and later contents; a binary file or
* one too large to show is named without a body. The rendered diff and
* the commit's own details are handed to the view already escaped.
*
* @param array &$data view data to add the diff fields to
* @param object $repository the repository being read
* @param string $view_commit object name of the commit to show
* @param string $prefix start of every read-view link
* @param string $branch name of the branch currently chosen
* @param string $ref_query the ref part of read-view links
*/
private function gitCommitDiff(&$data, $repository, $view_commit,
$prefix, $branch, $ref_query)
{
$parent = $this->parent;
try {
$changes = $repository->treeDiff($view_commit);
$commit = $repository->commit($view_commit);
} catch (\Exception $error) {
$changes = [];
$commit = ["author" => "", "message" => ""];
}
$files_html = "";
foreach ($changes as $change) {
$old_blob = ($change["old_sha"] === "") ? "" :
$repository->blob($change["old_sha"]);
$new_blob = ($change["new_sha"] === "") ? "" :
$repository->blob($change["new_sha"]);
if (strlen($old_blob) > C\MAX_GIT_BLOB_VIEW_LEN ||
strlen($new_blob) > C\MAX_GIT_BLOB_VIEW_LEN) {
$body = "<p class='git-note'>" .
tl("wiki_element_git_too_large") . "</p>";
} else if (str_contains($old_blob, "\0") ||
str_contains($new_blob, "\0")) {
$body = "<p class='git-note'>" .
tl("wiki_element_git_binary") . "</p>";
} else {
$body = "<div class='git-diff-body'>" .
L\diff($parent->clean($old_blob, "string"),
$parent->clean($new_blob, "string"), true) . "</div>";
}
$files_html .= "<div class='git-diff-file'>" .
"<h4 class='git-diff-" . $change["status"] . "'>" .
$parent->clean($change["path"], "string") . "</h4>" .
$body . "</div>";
}
$data["GIT_VIEW"] = "diff";
$data["GIT_DIFF_FILES"] = $files_html;
$data["GIT_DIFF_SUBJECT"] = $parent->clean(
strtok($commit["message"], "\n"), "string");
$data["GIT_DIFF_AUTHOR"] = $parent->clean($commit["author"],
"string");
$data["GIT_DIFF_EMPTY"] = empty($changes);
}
/**
* Pulls the list of section headings out of a rendered README so the
* read view can offer a contents menu that jumps to a section. Every
* heading is given a plain, predictable id as it is scanned, and the
* rendered README passed in is updated in place to carry those ids, so
* each menu entry can be an ordinary link to its heading that works
* without any script. Each returned entry keeps the heading level (so
* the menu can indent subsections), the plain text for the menu label,
* and the link target. An empty list is returned when the README has at
* most one heading, since a contents menu would not help then.
*
* @param string $readme_html the rendered README, updated in place to
* give each heading the id its menu entry links to
* @return array list of headings, each with LEVEL, TEXT, and URL
*/
private function gitReadmeToc(&$readme_html)
{
$headings = [];
$index = 0;
$readme_html = preg_replace_callback(
"/<h([1-6])([^>]*)>(.*?)<\/h\\1>/s",
function ($matches) use (&$headings, &$index) {
$anchor = "git-heading-" . $index;
$text = trim(strip_tags($matches[3]));
$headings[] = ["LEVEL" => (int)$matches[1],
"TEXT" => ($text === "") ? "…" : $text,
"URL" => "#" . $anchor];
$index++;
$attributes = preg_replace("/\s+id='[^']*'/", "",
$matches[2]);
return "<h{$matches[1]}{$attributes} id='{$anchor}'>" .
"{$matches[3]}</h{$matches[1]}>";
}, $readme_html);
return (count($headings) > 1) ? $headings : [];
}
/**
* Detects whether any feed or scrape podcast search source
* downloads to the resource folder of the media-list wiki page
* being viewed, and if so records in $data whether a download is
* in progress for that folder right now. The Media List view uses
* this to show a live downloading indicator or, when idle, an
* update button. Sources are matched by resolving each podcast
* source's destination wiki page to the same group, page, and
* sub-path that identify the current folder.
*
* @param array &$data view data array; on return may carry
* PODCAST_FOLDER (the folder key), PODCAST_SOURCE_COUNT, and
* PODCAST_DOWNLOADING (bool) when the page is a destination
* @param int $group_id group of the page being viewed
* @param int $page_id wiki page being viewed
* @param string $sub_path resource sub-folder being viewed, empty
* for the page's top resource folder
*/
private function addPodcastSourceStatus(&$data, $group_id,
$page_id, $sub_path)
{
$parent = $this->parent;
$source_model = $parent->model("source");
$group_model = $parent->model("group");
$locale_tag = L\getLocaleTag();
$folder_key =
L\media_jobs\PodcastDownloadJob::podcastFolderKey(
$group_id, $page_id, $sub_path);
$match_count = 0;
foreach (['feed_podcast', 'scrape_podcast'] as $type) {
$sources = $source_model->getMediaSources($type);
foreach ($sources as $source) {
$aux_parts = explode("###", html_entity_decode(
$source['AUX_INFO'], ENT_QUOTES));
if (count($aux_parts) < 6) {
continue;
}
$wiki_page = $aux_parts[5];
list($source_group_id, $source_page_id,
$source_sub_path, ) =
$group_model->getGroupIdPageIdSubPathFromName(
$wiki_page, $locale_tag);
$source_key = L\media_jobs\PodcastDownloadJob::podcastFolderKey(
$source_group_id, $source_page_id,
$source_sub_path);
if ($source_key === $folder_key) {
$match_count++;
}
}
}
if ($match_count == 0) {
return;
}
$data['PODCAST_FOLDER'] = $folder_key;
$data['PODCAST_SOURCE_COUNT'] = $match_count;
$data['PODCAST_STATE'] = $this->podcastFolderState($folder_key);
}
/**
* Emits, as JSON, whether a podcast download is currently in
* progress for a media-list page's folder, then ends the
* response. The Media List view polls this to switch between a
* live downloading indicator and the update button without
* reloading the page.
*
* @param int $group_id group of the page being polled
* @param int $page_id wiki page being polled
* @param string $sub_path resource sub-folder being polled, empty
* for the page's top resource folder
*/
private function outputPodcastStatus($group_id, $page_id,
$sub_path)
{
$parent = $this->parent;
$folder_key =
L\media_jobs\PodcastDownloadJob::podcastFolderKey(
$group_id, $page_id, $sub_path);
$state = $this->podcastFolderState($folder_key);
$parent->web_site->header("Content-Type: application/json");
e(json_encode(["state" => $state]));
\seekquarry\atto\webExit();
}
/**
* Resolves the current state of one podcast destination folder for
* the Media List status indicator. The states are "downloading"
* while items are being fetched, "queued" once an immediate update
* has been requested but the updater has not yet started it,
* "not_running" when an update is requested but the media updater
* daemon is not active to carry it out, and "idle" otherwise.
*
* @param string $folder_key key from podcastFolderKey
* @return string one of downloading, queued, not_running, idle
*/
private function podcastFolderState($folder_key)
{
if (L\media_jobs\PodcastDownloadJob::isPodcastDownloading(
$folder_key)) {
return "downloading";
}
if (L\media_jobs\PodcastDownloadJob::isPodcastUpdateRequested(
$folder_key)) {
$daemons = L\CrawlDaemon::statuses();
if (empty($daemons['MediaUpdater'])) {
return "not_running";
}
return "queued";
}
return "idle";
}
/**
* 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
*/
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";
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";
}
/**
* 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
*/
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";
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";
}
/**
* Checks whether the current user satisfies the page's sign-in and access
* requirements.
*
* Scans the wiki contents for a `[{require-signin|...|message}]` directive
* and enforces access control based on the current user's identity:
*
* - **Unauthenticated user** (PUBLIC_USER_ID): If the directive is present,
* replaces the page content with the directive's final pipe-delimited
* segment as a warning heading and sets `$data['AUTHORIZED']` to false.
*
* - **Authenticated user, restricted to named users**: If more than one
* pipe-delimited value is present, the final segment is treated as the
* warning message and the preceding segments are treated as an allowlist
* of usernames. If the current session username is not in the allowlist,
* the page is replaced with the warning heading and
* `$data['AUTHORIZED']` is set to false.
*
* - **Authorized user**: The `[{require-signin}]` directive is replaced
* with a hidden input field (`CSVFORM[require_signin]=true`) so
* downstream form handling is aware that sign-in was required.
*
* If no `require-signin` directive is found, the method returns without
* modifying `$data`.
*
* @param array &$data view data array, modified in place. Relevant keys:
* - 'PAGE' (string) Raw wiki page content; may be rewritten.
* - 'AUTHORIZED' (bool) Set to true initially; set to false if
* access is denied.
* @param int $user_id ID of the currently authenticated user.
* C\PUBLIC_USER_ID indicates an unauthenticated (guest) session.
* @return void
*/
private function checkAuthRequirement(&$data, $user_id)
{
$data['AUTHORIZED'] = true;
$auth_requirement = preg_match(
"/\[{require-signin((?:\|[^}|\n]+)+)}\]/", $data['PAGE'],
$matches);
$match_parts = array_filter(explode("|", ($matches[1] ?? "")));
if ($user_id == C\PUBLIC_USER_ID && $auth_requirement) {
$last_match = end($match_parts);
$data['PAGE'] =
"<h1 class='center-page warning'>{$last_match}</h1>";
$data['AUTHORIZED'] = false;
return;
} else if ($auth_requirement) {
if (count($match_parts) > 1) {
$last_match = array_pop($match_parts);
if (!in_array($_SESSION['USER_NAME'], $match_parts)) {
$data['PAGE'] =
"<h1 class='center-page warning'>{$last_match}</h1>";
$data['AUTHORIZED'] = false;
}
}
$data['PAGE'] = preg_replace(
"/\[{require-signin((?:\|[^}|\n]+)+)}\]/",
"<input type='hidden' name='CSVFORM[require_signin]' ".
"value='true'/>",
$data['PAGE']);
}
}
/**
* Manages the full lifecycle of a secret ballot for a wiki group page.
*
* Determines the current ballot state based on the presence of a secrets
* file and a CSV results file, then transitions between the following
* modes:
*
* - **ballot-init**: No secrets file exists yet. Renders the witness
* credential form. On valid witness submission, creates the ballot secrets
* file and redirects with a success message.
*
* - **count-ballots**: Secrets file exists and the "count-ballots" mode is
* requested. Renders the witness authorization form. On valid witness
* submission, decrypts and tallies all cast votes into a CSV
* results file and redirects with a success or error message.
*
* - **ballot-concluded**: A CSV results file already exists, meaning
* voting is closed. Loads the results and initializes the client-side
* Spreadsheet JS to render histograms and raw vote data.
*
* - **active ballot**: Secrets file exists but counting has not been
* requested. Replaces the `[{secret-ballot}]` page placeholder with a
* "Count Ballots" link for editor users, or removes it for voters
* who are not editors otherwise.
*
* Authorization is enforced at two levels:
* - `$data['CAN_UPDATE_POLL']` is set to false if the current user is
* neither the group owner nor has editor status.
* - witness logins are validated using `checkValidSignin()`
* before any ballot operation is performed. IF LDAP is enable might
* involved LDAP
*
* @param array&$data view data array, modified in place. Relevant keys
* read and written:
* - 'PAGE_ID' (int) ID of the current wiki page.
* - 'GROUP' (array) Group metadata including OWNER_ID and STATUS.
* - 'PAGE' (string) Raw wiki page content; may be rewritten.
* - 'FORM_HASH' (string) Hash identifying this ballot form.
* - 'MODE' (string) Set to 'ballot-init', 'count-ballots',
* or 'ballot-concluded' as appropriate.
* - 'CAN_UPDATE_POLL' (bool) Whether the user may administer the poll.
* - 'WITNESSES' (array) Passed to the view when a witness form is needed.
* - 'VOTE_INFO' (array) Parsed CSV results, set in concluded mode.
* - 'INCLUDE_SCRIPTS' (array) Client-side scripts to enqueue.
* - 'SCRIPT' (string) Inline JS appended for the spreadsheet widget.
* - 'SPREADSHEET' (bool) Flag indicating spreadsheet data is present.
* - C\p('CSRF_TOKEN') (string) CSRF token passed through to the view.
* @param int $user_id ID of the currently authenticated user.
* @param int $group_id ID of the group that owns the wiki page.
* @param string $sub_path Sub-path of the wiki page resources (not used)
* @param array $witnesses Ordered list of witness identifier strings
* for key pair derivation.
* @return void|mixed Returns void in most cases. Returns a
* redirect response on successful
* or failed witness submission, or when a key mismatch is detected.
*/
public function initializeBallot(&$data, $user_id, $group_id, $sub_path,
$witnesses)
{
$parent = $this->parent;
$group_model = $parent->model("group");
$signin_model = $parent->model("signin");
$default_folders = $group_model->getGroupPageResourcesFolders(
$group_id, $data['PAGE_ID']);
$csv_filepath = $default_folders[0] . '/' . C\WIKI_FORM_CSV_FILE;
$csv_exists = file_exists($csv_filepath);
$secrets_filepath = $default_folders[0] . '/' .
C\WIKI_FORM_SECRETS_FILE;
$data['CAN_UPDATE_POLL'] = true;
$group = $data["GROUP"] ?? [];
$preserve_fields =
['arg', 'page_name', 'group_name', 'settings',
'caret', 'scroll_top', 'sf', 'ballot_message'];
if (empty($group) || (
$group["OWNER_ID"] != $user_id &&
$group["STATUS"] != C\EDITOR_STATUS &&
($group["MEMBER_ACCESS"] != C\GROUP_READ_WIKI ||
$group["STATUS"] != C\ACTIVE_STATUS
))) {
$data['CAN_UPDATE_POLL'] = false;
}
if (!$csv_exists && !empty($_REQUEST['WITNESS_SUBMIT'])) {
$num_witnesses = count(($_REQUEST['WITNESS'] ?? []) );
$passwords = $_REQUEST['PASSWORD'];
$auth_passed = ($num_witnesses > 0);
for ($i = 0; $i < $num_witnesses; $i++) {
$witnesses[$i] = $parent->clean(
$_REQUEST['WITNESS'][$i], "string");
if (!$signin_model->checkValidSignin(
$witnesses[$i], $passwords[$i])) {
$auth_passed = false;
break;
}
}
}
if ($csv_exists) {
$data['MODE'] = "ballot-concluded";
$data['VOTE_INFO'] = L\parseCsv(file_get_contents($csv_filepath));
$data['INCLUDE_SCRIPTS'][] = 'spreadsheet';
$data['SCRIPT'] .= 'spreadsheet = new Spreadsheet(' .
'"histograms",' . json_encode($data['VOTE_INFO']) . ");".
'spreadsheet.drawHistograms({has_column_headers:true,' .
'ignore_values:[""], ignore_columns:["FORM_HASH","RECEIPT"],' .
'stop_condition:["FORM_HASH", ""]});';
$data['SCRIPT'] .= 'spreadsheet.setContainer("spreadsheet");'.
"spreadsheet.draw();";
$data['SPREADSHEET'] = true;
} else if (file_exists($secrets_filepath)) {
if (!empty($_REQUEST['mode']) &&
$_REQUEST['mode'] == 'count-ballots') {
if (!empty($_REQUEST['WITNESS_SUBMIT'])) {
if ($auth_passed) {
$tmp_page = preg_replace("/\[{form\-hash(.+?)}\]/",
"[{form-hash}]", $data['PAGE']);
$message = $signin_model->countBallotFile(
$secrets_filepath, $csv_filepath,
$data['FORM_HASH'], $witnesses, $passwords);
if ($message == "PUBLIC_KEY_MISMATCH") {
unset($_REQUEST['route']);
return $parent->redirectWithMessage(
tl('social_component_key_error'),
$preserve_fields);
} else
unset($_REQUEST['route']);
$_REQUEST['ballot_message'] = $message;
return $parent->redirectWithMessage(
tl('social_component_ballots_counted'),
$preserve_fields);
} else {
unset($_REQUEST['route']);
return $parent->redirectWithMessage(
tl('social_component_witness_auth_fail'),
$preserve_fields);
}
}
$data['MODE'] = "count-ballots";
$data['WITNESSES'] = $witnesses;
return;
} else {
$end_poll = "";
if ($data['CAN_UPDATE_POLL']) {
$ballot_request = array_merge($_REQUEST,
["mode" => "count-ballots"]);
unset($ballot_request['route'],
$ballot_request['noredirect']);
$end_poll = "<div class='csv-form-field'
><a href='.?" . http_build_query($ballot_request) .
"'>" .tl('social_component_count_ballots').
"</a></div>";
}
$data['PAGE'] = preg_replace(
'/\[\{secret-ballot((?:\|[^{|\n]+)+)\}\]/si',
$end_poll, $data['PAGE']);
}
} else {
$data['MODE'] = "ballot-init";
if (!empty($_REQUEST['WITNESS_SUBMIT'])) {
if ($auth_passed) {
$tmp_page = preg_replace("/\[{form\-hash(.+?)}\]/",
"[{form-hash}]", $data['PAGE']);
$signin_model->createBallotFile($secrets_filepath,
$data['FORM_HASH'], $witnesses, $passwords);
return $parent->redirectWithMessage(
tl('social_component_ballot_initialized'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_witness_auth_fail'),
$preserve_fields);
}
}
$data['WITNESSES'] = $witnesses;
return;
}
}
/**
* Extracts from session information about resource sort, layout, etc,
* so that WikiElement can draw resources correctly
*
* @param array &$data associative array of values to be echoed by the view
*/
public function initUserResourcePreferences(&$data)
{
$parent = $this->parent;
// Delete a marked diamond icon from video list
$changed = false;
if (!empty($_REQUEST['clear']) && !empty($_SESSION['seen_media'])
&& is_array($_SESSION['seen_media']) && !empty($data['PAGE_ID'])) {
$media_name = $parent->clean($_REQUEST['clear'], 'file_name');
$type = UrlParser::getDocumentType($media_name);
if ($type != "") {
$media_name = UrlParser::getDocumentFilename($media_name);
$media_name = urlencode($media_name);
$media_name = "$media_name.$type";
}
$sub_path = $data['SUB_PATH'] ?? "";
$hash_id = L\crawlHash($data['PAGE_ID']. $media_name . $sub_path);
if (in_array($hash_id, $_SESSION['seen_media'])) {
$_SESSION['seen_media'] = array_diff($_SESSION['seen_media'],
[$hash_id]);
$changed = true;
}
}
$sub_path = $data['SUB_PATH'] ?? "";
$folder_hash_id = L\crawlHash(($data['PAGE_ID'] ?? -1) . $sub_path);
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'][$folder_hash_id] = $_REQUEST['sort'];
$changed = true;
}
if (!empty($_REQUEST['layout']) && in_array($_REQUEST['layout'],
['list', 'grid', 'detail'])) {
unset($_SESSION['layouts'][$folder_hash_id]);
$_SESSION['layouts'][$folder_hash_id] = $_REQUEST['layout'];
if (count($_SESSION['layouts']) > 10) {
$first_key = array_key_first($_SESSION['layouts']);
unset($_SESSION['layouts'][$first_key]);
}
$changed = true;
}
$data['CURRENT_LAYOUT'] = $_SESSION['layouts'][$folder_hash_id] ??
"list";
$data['CURRENT_SORT'] = $_SESSION['media_sorts'][$folder_hash_id] ?? "";
if ($changed) {
// only saves session in not PUBLIC_USER_ID
$parent->model("user")->setUserSession($_SESSION['USER_ID'] ??
C\PUBLIC_USER_ID, $_SESSION);
}
$this->sortWikiResources($data);
}
/**
* 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");
$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 = $group_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']);
}
}
}
/**
* Used to sort the resources on a wiki pages either for display in case
* of reading a media list or to help find resources in the case of a
* user using edit mode
*
* @param array &$data data to be sent to the view. The
* $data["RESOURCES_INFO"]['resources'] array of resources will be
* sorted according to the wiki page's settings as given in
* $data["HEAD"]['sort']
*/
public function sortWikiResources(&$data)
{
if (empty($data["RESOURCES_INFO"]['resources'])) {
return;
}
$folder_hash_id = L\crawlHash(($data['PAGE_ID'] ?? "").
($data['SUB_PATH'] ?? ""));
if (!empty($_SESSION['media_sorts'][$folder_hash_id])) {
list($sort_field, $direction) = explode("_",
$_SESSION['media_sorts'][$folder_hash_id]);
$callback = ($direction == 'asc') ?
"orderCallback" : "rorderCallback";
if ($sort_field == 'name') {
$callback = ($direction == 'asc') ?
"stringROrderCallback" : "stringOrderCallback";
}
} else {
if (empty($data["HEAD"]['default_sort'])) {
return;
}
set_error_handler(null);
$sort_map = @unserialize(L\webdecode(
$data["HEAD"]['default_sort']));
restore_error_handler();
$sort_key = (empty($data['SUB_PATH'])) ? "." : $data['SUB_PATH'];
$sort_key = rtrim($sort_key, '/');
if (empty($sort_map[$sort_key])) {
return;
}
$sort_field = substr($sort_map[$sort_key], 1);
$callback = ($sort_map[$sort_key][0] == 'r') ?
"rorderCallback" : "orderCallback";
if ($sort_field == 'name') {
$callback = ($sort_map[$sort_key][0] == 'r') ?
"stringROrderCallback" : "stringOrderCallback";
}
}
$callback_name = C\NS_LIB . $callback;
$callback_name(null, null, $sort_field);
usort($data["RESOURCES_INFO"]["resources"], C\NS_LIB . $callback);
}
/**
* 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");
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 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 = $group_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.
*/
$upload_allowed = true;
$preserve_fields = ['arg', 'page_name', 'resources', 'settings',
'caret', 'scroll_top', 'sf'];
if ($missing_fields) {
return $parent->redirectWithMessage(
tl("social_component_missing_fields"));
} else if (isset($page_info['ID']) && !empty($_REQUEST['n'])
&& !isset($_REQUEST['resource_description'])) {
$file_name = $parent->clean(urldecode($_REQUEST['n']),
"file_name");
$name_parts = pathinfo($file_name);
if (!empty($name_parts['extension']) &&
in_array($name_parts['extension'], ['csv', 'css', 'txt',
'tex', 'php', 'sql', 'html', 'java', 'js', 'py',
'pl', 'P', 'srt'])) {
$extension = $name_parts['extension'];
$data['RAW'] = !empty($_REQUEST['download']);
$data['PAGE'] = $group_model->getPageResource(
$file_name, $group_id, $page_info['ID'], $sub_path,
$data['RAW']);
if (empty($data['RAW']) && $name_parts['extension'] != 'csv') {
$data['PAGE'] = htmlentities($data['PAGE']);
}
} else {
$data['PAGE'] = false;
}
if ($page !== null && $data['PAGE'] !== false) {
$action = "wikiupdate_group=$group_id" .
"&page=" . $page_name . "&resource_name=" . $file_name;
if (!$parent->checkCSRFTime(C\p('CSRF_TOKEN'), $action)) {
$data['SCRIPT'] .= "doMessage('<h1 class=\"red\" >".
tl('social_component_wiki_edited_elsewhere').
"</h1>');";
return;
}
$success = $group_model->setPageResource($file_name,
$_REQUEST['page'], $group_id, $page_info['ID'],
$sub_path);
$preserve_fields[] = 'n';
if ($success) {
$preserve_fields[] = 'back_params';
return $parent->redirectWithMessage(
tl("social_component_resource_saved"),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_not_saved'),
$preserve_fields);
}
}
$data['PAGE_ID'] = $page_info['ID'];
$data['PAGE_NAME'] = $page_name;
$data['RESOURCE_NAME'] = $file_name;
} else {
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;
}
$write_head = false;
$head_vars = [];
$page_types = array_keys($data['page_types']);
$page_borders = array_keys($data['page_borders']);
$set_path = false;
$resource_path_error = false;
foreach (WikiParser::PAGE_DEFAULTS as $key => $default) {
$head_vars[$key] = (isset($head_object[$key])) ?
$head_object[$key] : $default;
if (isset($_REQUEST[$key])) {
$head_vars[$key] = trim(
$parent->clean($_REQUEST[$key], "string"));
switch ($key) {
case 'page_type':
if (!in_array($head_vars[$key], $page_types) &&
($head_vars[$key][0] != 't' ||
!is_numeric(substr($head_vars[$key],1))) ) {
$head_vars[$key] =
$head_object[$key] ?? $default;
}
break;
case 'page_borders':
if (!in_array($head_vars[$key],
$page_borders)) {
$head_vars[$key] = $default;
}
break;
case 'alternative_path':
if (!empty($head_vars[$key]) &&
!is_dir($head_vars[$key])) {
$parent_folder =
dirname($head_vars[$key]);
if (!is_dir($parent_folder) ||
!@mkdir($head_vars[$key])) {
$resource_path_error = true;
$head_vars[$key] = $default;
}
}
if ((empty($head_vars[$key]) ||
is_dir($head_vars[$key])) &&
!empty($_SESSION['USER_ID']) &&
$_SESSION['USER_ID'] == C\ROOT_ID) {
$set_path = true;
}
break;
case 'default_sort':
if (empty($page) &&
!isset($page_info['PAGE'])) {
break;
}
if (in_array($head_vars[$key],
['name', 'size', 'modified'])) {
if (isset($page_info['PAGE'])) {
if (!isset($page)) {
$page_parts = explode(
WikiParser::END_HEAD_VARS,
$page_info['PAGE']);
$page = isset($page_parts[1]) ?
$page_parts[1] : $page_parts[0];
}
}
$new_key = 'a' . $head_vars[$key];
if (isset($head_object['default_sort'])) {
set_error_handler(null);
$head_object['default_sort'] =
@unserialize(L\webdecode(
$head_object['default_sort']));
restore_error_handler();
} else {
$head_object['default_sort'] = [];
}
$sort_path = empty($sub_path) ? "." :
$sub_path;
$sort_path = rtrim($sort_path, '/');
$direction = '_asc';
if (empty($head_object['default_sort'][
$sort_path]) ||
$head_object['default_sort'][$sort_path]
== $new_key) {
$direction = '_desc';
$new_key = 'r' . $head_vars[$key];
}
$head_object['default_sort'][$sort_path] =
$new_key;
$folder_hash_id = L\crawlHash(
$page_info['ID'] . $sub_path);
$_SESSION['media_sorts'][$folder_hash_id] =
$head_vars[$key] . $direction;
$head_vars[$key] = L\webencode(serialize(
$head_object['default_sort']));
$edit_reason = "Change resource sort";
$write_head = true;
} else {
$head_vars[$key] = $default;
}
break;
case 'static_html_folder':
case 'directory_indexes':
case 'index_file':
if (empty($_SESSION['USER_ID']) ||
$_SESSION['USER_ID'] != C\ROOT_ID) {
$head_vars[$key] =
(isset($head_object[$key])) ?
$head_object[$key] : $default;
} else if ($key == 'index_file') {
$head_vars[$key] =
$this->cleanIndexFileSetting(
$head_vars[$key]);
if ($head_vars[$key] === '') {
$head_vars[$key] = $default;
}
}
break;
default:
$head_vars[$key] =
trim(preg_replace("/\n+/", "\n",
$head_vars[$key]));
}
if ($head_vars[$key] != $default) {
$write_head = true;
}
} else if ($key == 'toc' || $key == 'public_source') {
if (isset($_REQUEST['title'])) {
$head_vars[$key] = false;
} else {
$head_vars[$key] == true;
}
} else if ($key == 'static_html_folder' ||
$key == 'directory_indexes') {
if (!empty($_SESSION['USER_ID']) &&
$_SESSION['USER_ID'] == C\ROOT_ID &&
isset($_REQUEST['title'])) {
$head_vars[$key] = false;
}
} else if ($key == 'update_description') {
$head_vars[$key] =
isset($_REQUEST['update_description']);
}
}
$head_string = WikiParser::makeWikiPageHead($head_vars);
if (is_array($page)) { //template case
$page = base64_encode(serialize($page));
}
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($group_model->getPageId($group_id, $page_name,
$data['CURRENT_LOCALE_TAG'])) &&
$this->overGroupLimit($group_id,
'MAX_GROUP_WIKI_PAGES',
$group_model->countGroupWikiPages($group_id))) {
return $this->parent->redirectWithMessage(
tl('social_component_wiki_pages_full'));
}
$page_info['ID'] = $group_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 =
$group_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 = $group_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 L\GitRepository($resource_path);
$default_branch = preg_replace(
"/[^A-Za-z0-9._\/-]/", "",
(string)C\p('GIT_DEFAULT_BRANCH'));
if ($default_branch === "") {
$default_branch =
L\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);
}
}
}
if (isset($_REQUEST['empty_clip'])) {
$upload_allowed = false;
if ($group_model->emptyClipFolder($user_id)) {
return $parent->redirectWithMessage(
tl('social_component_clipboard_emptied'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_clipboard_not_emptied'),
$preserve_fields);
}
} else if (isset($_REQUEST['paste_all'])) {
$upload_allowed = false;
if ($group_model->pasteAllClipFolder($user_id, $group_id,
$page_info['ID'], $sub_path)) {
return $parent->redirectWithMessage(
tl('social_component_paste_all_success'), $preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_paste_all_failed'), $preserve_fields);
}
} else if (!empty($_REQUEST['move_resource']) &&
isset($_REQUEST['move_to_path'])) {
$upload_allowed = false;
$move_resource = $parent->clean($_REQUEST['move_resource'],
"file_name");
$move_to_path = $parent->clean($_REQUEST['move_to_path'],
'string');
$move_to_path = str_replace("..", "", $move_to_path);
$move_to_path = str_replace("/./", "/", $move_to_path);
if (isset($page_info['ID']) &&
$group_model->moveResourceToSubPath($move_resource,
$move_to_path, $group_id, $page_info['ID'], $sub_path)) {
$group_model->versionGroupPage($user_id, $page_info['ID'],
tl('social_component_resource_moved'));
return $parent->redirectWithMessage(
tl('social_component_resource_moved'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_not_moved'),
$preserve_fields);
}
} else if (!empty($_REQUEST['delete_resource'])) {
$upload_allowed = false;
$delete_resource = $parent->clean(
$_REQUEST['delete_resource'], "file_name");
if (isset($page_info['ID']) &&
$group_model->deleteResource($delete_resource,
$group_id, $page_info['ID'], $sub_path)) {
$group_model->versionGroupPage($user_id, $page_info['ID'],
tl('social_component_resource_deleted'));
return $parent->redirectWithMessage(
tl('social_component_resource_deleted'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_not_deleted'),
$preserve_fields);
}
} else if (!empty($_REQUEST['move_resource']) &&
!empty($_REQUEST['move_target'])) {
$upload_allowed = false;
$move_resource = $parent->clean($_REQUEST['move_resource'],
"file_name");
$move_target = $parent->clean($_REQUEST['move_target'],
"file_name");
if (isset($page_info['ID']) &&
$group_model->moveResourceToFolder($move_resource,
$move_target, $group_id, $page_info['ID'], $sub_path)) {
$group_model->versionGroupPage($user_id, $page_info['ID'],
tl('social_component_resource_moved'));
return $parent->redirectWithMessage(
tl('social_component_resource_moved'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_not_moved'),
$preserve_fields);
}
} else if (isset($_REQUEST['paste'])) {
$resource_name = $parent->clean($_REQUEST['paste'],
"string");
$upload_allowed = false;
if (!$group_model->pasteFromClipFolder(
$user_id, $resource_name, $group_id,
$page_info['ID'], $sub_path)) {
return $parent->redirectWithMessage(
tl('social_component_paste_fail'), $preserve_fields);
}
return $parent->redirectWithMessage(
tl('social_component_paste_success'), $preserve_fields);
} else if (isset($_REQUEST['clip_copy'])) {
$resource_name = $parent->clean($_REQUEST['clip_copy'],
"string");
$upload_allowed = false;
if (!$group_model->copyResourceToClipFolder(
$user_id, $resource_name, $group_id,
$page_info['ID'], $sub_path)) {
return $parent->redirectWithMessage(
tl('social_component_copy_fail'), $preserve_fields);
}
return $parent->redirectWithMessage(
tl('social_component_copy_success'), $preserve_fields);
} else if (isset($_REQUEST['clip_cut'])) {
$upload_allowed = false;
$resource_name = $parent->clean($_REQUEST['clip_cut'],
"string");
if (!$group_model->moveResourceToClipFolder(
$user_id, $resource_name, $group_id,
$page_info['ID'], $sub_path)) {
return $parent->redirectWithMessage(
tl('social_component_clip_cut_fail'), $preserve_fields);
}
$group_model->versionGroupPage($user_id, $page_info['ID'],
tl('social_component_clip_cut_success'));
$_REQUEST['reset_detail'] = "true";
return $parent->redirectWithMessage(
tl('social_component_cut_success'), array_merge(
["reset_detail"], $preserve_fields));
} else if (isset($_REQUEST['extract'])) {
$resource_name = $parent->clean($_REQUEST['extract'],
"string");
$upload_allowed = false;
if (isset($page_info['ID']) &&
$group_model->extractResource($resource_name,
$group_id, $page_info['ID'], $sub_path)) {
$group_model->versionGroupPage($user_id, $page_info['ID'],
tl('social_component_resource_extracted'));
return $parent->redirectWithMessage(
tl('social_component_resource_extracted'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_not_extracted'),
$preserve_fields);
}
} else if (!empty($_REQUEST['new_resource_name']) &&
!empty($_REQUEST['old_resource_name'])) {
$upload_allowed = false;
$old_resource_name = $parent->clean(
$_REQUEST['old_resource_name'], "file_name");
$new_resource_name = $parent->clean(
$_REQUEST['new_resource_name'], "file_name");
if (isset($page_info['ID']) &&
$group_model->renameResource($old_resource_name,
$new_resource_name, $group_id,
$page_info['ID'], $sub_path)) {
$group_model->versionGroupPage($user_id, $page_info['ID'],
tl('social_component_resource_renamed'));
return $parent->redirectWithMessage(
tl('social_component_resource_renamed'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_not_renamed'),
$preserve_fields);
}
} else if (isset($_REQUEST['resource_description'])) {
$resource_description = $parent->clean(
$_REQUEST['resource_description'], "string");
if (!($resource_name = $parent->clean(urldecode($_REQUEST['n']??""),
"file_name"))) {
return $parent->redirectWithMessage(
tl('social_component_resource_description_file_error'),
$preserve_fields);
}
$success = $group_model->setResourceDescription($resource_name,
$resource_description, $group_id, $page_info['ID'],
$sub_path ?? "");
if ($data['TARGET'] == 'child') {
$_REQUEST['arg'] = 'media-detail-edit';
$_REQUEST['resources'] = 'false';
$_REQUEST['page_id'] = $page_info['ID'];
$preserve_fields[] = 'n';
$preserve_fields[] = 'page_id';
$preserve_fields[] = 'resources';
$preserve_fields[] = 'target';
}
if ($success) {
return $parent->redirectWithMessage(
tl('social_component_resource_description_saved'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_description_error'),
$preserve_fields);
}
} else if (isset($_REQUEST['resource_actions']) &&
in_array($_REQUEST['resource_actions'],
['clear-lock', 'version']) && !empty($page_info['ID']) &&
((isset($group['OWNER_ID']) &&
$group['OWNER_ID'] == $user_id) ||
$user_id == C\ROOT_ID)) {
if ($_REQUEST['resource_actions'] == 'clear-lock') {
$done = $group_model->clearGroupPageResourceLock(
$group_id, $page_info['ID'], $sub_path);
$message = ($done) ?
tl('social_component_resource_lock_cleared') :
tl('social_component_resource_lock_not_cleared');
} else {
$done = $group_model->versionGroupPageResource(
$group_id, $page_info['ID'], $sub_path);
$message = ($done) ?
tl('social_component_resource_versioned') :
tl('social_component_resource_not_versioned');
}
return $parent->redirectWithMessage($message,
$preserve_fields);
} else if (isset($_REQUEST['resource_actions']) &&
$_REQUEST['resource_actions'] == 'zip' &&
!empty($page_info['ID'])) {
$folders = $group_model->getGroupPageResourcesFolders(
$group_id, $page_info['ID'], $sub_path);
$folder = (empty($folders[0])) ? "" : $folders[0];
$thumb_folder = (empty($folders[1])) ? "" : $folders[1];
if ($folder == "" || !is_dir($folder)) {
return $parent->redirectWithMessage(
tl('social_component_no_resources_to_zip'),
$preserve_fields);
}
if ($this->folderContentsLen($folder) +
$this->folderContentsLen($thumb_folder) >
C\MAX_RESOURCES_ZIP_LEN) {
return $parent->redirectWithMessage(
tl('social_component_resources_zip_too_big',
L\intToMetric(C\MAX_RESOURCES_ZIP_LEN)),
$preserve_fields);
}
if (!$this->streamResourcesZip($folder, $thumb_folder,
$page_name)) {
return $parent->redirectWithMessage(
tl('social_component_resources_zip_failed'),
$preserve_fields);
}
\seekquarry\atto\webExit();
} else if (isset($_REQUEST['resource_actions']) &&
in_array($_REQUEST['resource_actions'],
['new-folder', 'new-text-file', 'new-csv-file']) &&
!empty($page_info['ID'])) {
if ($group_model->newResource($_REQUEST['resource_actions'],
$group_id, $page_info['ID'], $sub_path)) {
$group_model->versionGroupPage($user_id, $page_info['ID'],
tl('social_component_resource_created'));
return $parent->redirectWithMessage(
tl('social_component_resource_created'),
$preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_resource_not_created'),
$preserve_fields);
}
}
if ($upload_allowed && !empty($_FILES['page_resource']['name'])) {
if (!isset($page_info['ID'])) {
$_FILES = [];
return $parent->redirectWithMessage(
tl('social_component_resource_save_first'),
$preserve_fields);
}
$result = $this->handleResourceUploads(
$group_id, $page_info['ID'], $sub_path);
if ($result == self::UPLOAD_SUCCESS) {
//we re-parse page so resources parsed
if (isset($page) && isset($edit_reason)) {
$group_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);
}
return $parent->redirectWithMessage(
tl('social_component_resource_uploaded'), $preserve_fields);
} else {
return $parent->redirectWithMessage(
tl('social_component_upload_error'), $preserve_fields);
}
}
if (isset($page_info['ID'])) {
$create = ($user_id == C\PUBLIC_USER_ID) ? false : true;
$data['RESOURCES_INFO'] =
$group_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'] =
$group_model->getClipboardResourceNames($user_id);
}
} else {
$data['RESOURCES_INFO'] = [];
}
}
/**
* Used to set-up information for drawing the mediaWikiDetail of a media
* resource page
*
* @param array &$data array of field variables for view will be modified
* by this function
* @param int $group_id id of group wiki page belongs to
* @param int $page_id id of wiki page
* @param string $sub_path sub-resource folder that is being used, if any,
* that resource is from
*/
public function mediaWikiDetail(&$data, $group_id, $page_id, $sub_path = "")
{
if (!isset($page_id)) {
return;
}
$parent = $this->parent;
$group_model = $parent->model("group");
if (empty($_REQUEST['n'])) {
$sub_parts = explode("/", $sub_path);
$media_name = array_pop($sub_parts);
$sub_path = implode("/", $sub_parts);
} else {
$media_name = $parent->clean($_REQUEST['n'], "file_name");
}
$page_info = $group_model->getPageInfoByPageId($page_id);
$data['PAGE_NAME'] = htmlentities($page_info['PAGE_NAME'] ?? "");
$page_info = $group_model->getPageInfoByName($group_id,
$page_info['PAGE_NAME'] ?? "", $data['CURRENT_LOCALE_TAG'], 'edit');
$data["PAGE"] = $page_info["PAGE"];
$this->checkAuthRequirement($data,
$_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID);
if (!$data["AUTHORIZED"]) {
$parent->web_site->header("HTTP/1.0 403 FORBIDDEN");
$data["MEDIA_NAME"] = $media_name;
$parent->displayView("nocache", $data);
\seekquarry\atto\webExit(); //bail
}
$data['HEAD'] = WikiParser::parsePageHeadVars($page_info['PAGE'] ?? "");
$resources_info = $group_model->getGroupPageResourceUrls(
$group_id, $page_id, $sub_path);
$data['ORIGINAL_URL_PREFIX'] = $resources_info['url_prefix'];
$resources = $resources_info['resources'] ?? "";
$num_resources = (is_array($resources)) ? count($resources) : 0;
for ($i = 0; $i < $num_resources; $i++) {
if ($resources[$i]['name'] == $media_name) {
break;
}
}
if ($i == $num_resources) {
$parent->web_site->header("HTTP/1.0 404 Not Found");
$data["MEDIA_NAME"] = $media_name;
$parent->displayView("nocache", $data);
\seekquarry\atto\webExit(); //bail
}
$data["RESOURCE_INFO"] = $resources[$i];
$thumb_folder = $resources_info['thumb_folder'] ?? "";
$description_file = $thumb_folder ."/$media_name.txt";
$data['RESOURCE_DESCRIPTION'] = $group_model->getResourceDescription(
$media_name, $group_id, $page_id, $sub_path);
$data['RESOURCE_DESCRIPTION'] = (empty($data['RESOURCE_DESCRIPTION'])) ?
tl('social_component_media_no_description') :
$data['RESOURCE_DESCRIPTION'];
$base_url = htmlentities(B\wikiUrl($data['PAGE_NAME'] , true,
$data['CONTROLLER'], $group_id));
if (isset($_SESSION['USER_ID']) && intval($_SESSION['USER_ID']) > 0) {
$user_id = $_SESSION['USER_ID'];
$data['ADMIN'] = 1;
} else {
$user_id = C\PUBLIC_USER_ID;
}
$csrf_token = $this->parent->generateCSRFToken(
$user_id);
if (!empty($data['ADMIN'])) {
$base_url .= C\p('CSRF_TOKEN') . "=". $csrf_token;
}
$folder_prefix = $base_url . "&";
$folder_prefix .= "page_id=". $page_id;
$data['ROOT_LINK'] = $folder_prefix;
if (!empty($data['SUB_PATH'])) {
$folder_prefix .= "&sf=" . urlencode($data['SUB_PATH']);
}
$data['FOLDER_PREFIX'] = $folder_prefix;
$url_prefix = $folder_prefix . "&arg=media";
$data['MEDIA_NAME'] = $media_name;
$page_string = "";
$data['URL_PREFIX'] = $url_prefix;
$data['THUMB_PREFIX'] = $resources_info['thumb_prefix'];
$data['ATHUMB_PREFIX'] = $resources_info['athumb_prefix'];
$data['DEFAULT_THUMB_URL'] = C\SHORT_BASE_URL .
$resources_info['default_thumb'];
$data['DEFAULT_EDITABLE_THUMB_URL'] = C\SHORT_BASE_URL .
$resources_info['default_editable_thumb'];
$data['DEFAULT_FOLDER_THUMB_URL'] = C\SHORT_BASE_URL .
$resources_info['default_folder_thumb'];
$data['EDIT'] = ($_REQUEST['arg'] == "media-detail-edit") ?
true : false;
$data["MODE"] = "media-detail";
$data['VIEW'] = "mediadetail";
}
/**
* Used to set up the partially processed wiki page, before media inserted,
* needed to display a single media item on a media list. The name of
* the media item to be display is expected to come from $_REQUEST['n'].
*
* @param array &$data array of field variables for view will be modified
* by this function
* @param int $group_id id of group wiki page belongs to
* @param int $page_id id of wiki page
* @param string $sub_path sub-resource folder that is being used, if any,
* to get resources from
*/
public function mediaWiki(&$data, $group_id, $page_id, $sub_path="")
{
if (!isset($page_id) || !isset($_REQUEST['n'])) {
return;
}
$parent = $this->parent;
$group_model = $parent->model("group");
$media_name = $parent->clean($_REQUEST['n'], "file_name");
$page_info = $group_model->getPageInfoByPageId($page_id);
$data["DISCUSS_THREAD"] = $page_info["DISCUSS_THREAD"] ?? "";
$data['SCRIPT'] = $data['SCRIPT'] ?? "";
$data['PAGE_NAME'] = htmlentities($page_info['PAGE_NAME'] ?? "");
$page_info = $group_model->getPageInfoByName($group_id,
$page_info['PAGE_NAME'] ?? "", $data['CURRENT_LOCALE_TAG'], 'edit');
$data["PAGE"] = $page_info["PAGE"];
$this->checkAuthRequirement($data,
$_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID);
if (!$data["AUTHORIZED"]) {
$parent->web_site->header("HTTP/1.0 403 FORBIDDEN");
$data["MEDIA_NAME"] = $media_name;
$parent->displayView("nocache", $data);
\seekquarry\atto\webExit(); //bail
}
$data['RESOURCES_INFO'] = $group_model->getGroupPageResourceUrls(
$group_id, $page_id, $sub_path);
$data['HEAD'] = WikiParser::parsePageHeadVars($page_info['PAGE'] ?? "");
$public_view_source = (!empty($data['HEAD']['public_source']) &&
$data['HEAD']['public_source'] == 'true') ||
empty($data['HEAD']['public_source']);
$this->initUserResourcePreferences($data);
$resources = $data['RESOURCES_INFO']['resources'] ?? "";
$num_resources = (is_array($resources)) ? count($resources) : 0;
for ($i = 0; $i < $num_resources; $i++) {
if ($resources[$i]['name'] == $media_name) {
break;
}
}
if ($i == $num_resources) {
$parent->web_site->header("HTTP/1.0 404 Not Found");
$data["MEDIA_NAME"] = $media_name;
$parent->displayView("nocache", $data);
\seekquarry\atto\webExit(); //bail
}
$current_resource = $resources[$i];
$is_static = ($data['CONTROLLER'] == 'static') ? true : false;
$base_url = htmlentities(B\wikiUrl($data['PAGE_NAME'] , true,
$data['CONTROLLER'], $group_id));
if (isset($_SESSION['USER_ID']) && intval($_SESSION['USER_ID']) > 0) {
$user_id = $_SESSION['USER_ID'];
$data['ADMIN'] = 1;
} else {
$user_id = C\PUBLIC_USER_ID;
if (!$public_view_source) {
$parent->web_site->header("HTTP/1.0 404 Not Found");
$data["MEDIA_NAME"] = $media_name;
$parent->displayView("nocache", $data);
\seekquarry\atto\webExit(); //bail
}
}
$csrf_token = $this->parent->generateCSRFToken(
$user_id);
if (!empty($data['ADMIN'])) {
$base_url .= C\p('CSRF_TOKEN') . "=". $csrf_token;
}
$folder_prefix = ($is_static) ? $base_url : $base_url . "&";
$folder_prefix .= "page_id=". $page_id;
$data['ROOT_LINK'] = $folder_prefix;
if (!empty($data['SUB_PATH'])) {
$folder_prefix .= "&sf=" . urlencode($data['SUB_PATH']);
}
$url_prefix = $folder_prefix . "&arg=media";
$mime_type = L\mimeType($media_name, true);
$prev_name = ($i < $num_resources &&
isset($resources[$i - 1]['name'])) ?
$resources[$i - 1]['name'] : false;
$next_name = (isset($resources[$i + 1]['name'])) ?
$resources[$i + 1]['name'] : false;
$name_parts = pathinfo($media_name);
$file_name = $name_parts['filename'];
$data['MEDIA_NAME'] = $media_name;
$page_string = ($is_static) ? "" : WikiParser::makeWikiPageHead(
["page_type" => "media_item"]) . WikiParser::END_HEAD_VARS;
$data['URL_PREFIX'] = $url_prefix;
if (!empty($prev_name)) {
$data['PREV_LINK'] = "$url_prefix&n=" . urlencode($prev_name);
$prev_link = $data['PREV_LINK'];
if (!in_array($mime_type, ["application/epub+zip",
"application/pdf", 'video/mp4', 'video/m4v'])) {
$data['SCRIPT'] .= 'leftSwipe(document, function(evt) {'.
'window.location="'.$prev_link.'";})'."\n";
}
}
if (!empty($next_name)) {
$data['NEXT_LINK'] = "$url_prefix&n=" . urlencode($next_name);
$data['NEXT_INDEX'] = $i+1;
$next_link = $data['NEXT_LINK'];
if (!in_array($mime_type, ["application/epub+zip",
"application/pdf", 'video/mp4', 'video/m4v'])) {
$data['SCRIPT'] .= 'rightSwipe(document, function(evt) {'.
'window.location="'.$next_link.'";})'."\n";
}
}
if (in_array($mime_type, ['video/mp4', 'video/m4v'])) {
$current_url = $_SERVER['REQUEST_URI'];
$current_url = substr($current_url, strlen(C\SHORT_BASE_URL));
$current_url = C\baseUrl() . $current_url;
$current_url = preg_replace("/". C\p('CSRF_TOKEN') .
"\=[^\/\&]+(\/|\&)/", "", $current_url);
$resource_url = $group_model->getGroupPageResourceUrl($csrf_token,
$group_id, $page_id, $media_name, $data['SUB_PATH'] ?? "");
$resource_url = substr($resource_url, strlen(C\SHORT_BASE_URL));
$resource_url = C\baseUrl() . $resource_url;
$resource_url = preg_replace("/". C\p('CSRF_TOKEN') .
"\=[^\/\&]+/", "-", $resource_url);
$thumb_url = "";
if (C\REDIRECTS_ON) {
if (!empty($current_resource['has_animated_thumb'])) {
$thumb_url = str_replace("wd/resources", "wd/athumbs",
$resource_url);
} else if (!empty($current_resource['has_thumb'])) {
$thumb_url = str_replace("wd/resources", "wd/thumbs",
$resource_url);
}
} else {
if (!empty($current_resource['has_animated_thumb'])) {
$thumb_url = $resource_url . "&t=athumbs";
} else if (!empty($current_resource['has_thumb'])) {
$thumb_url = $resource_url . "&t=thumbs";
}
}
list($folder, $thumb_folder) =
$group_model->getGroupPageResourcesFolders(
$group_id, $page_id, $data['SUB_PATH'] ?? "");
$media_path = "$folder/$media_name";
$pub_date = filemtime($media_path);
$additional_metas =
"\n<meta property='og:type' content='video' >\n" .
"<meta property='og:url' content='$current_url' >\n" .
"<meta property='og:title' content='$media_name' >\n" .
"<meta property='video:release_date' content='" .
date("c", $pub_date) . "' >\n";
if (!empty($thumb_url)) {
$additional_metas .=
"<meta property='og:image' content='$thumb_url' >\n";
}
if (C\nsdefined("SITE_NAME")) {
$additional_metas .=
"<meta property='og:site_name' content='". C\SITE_NAME .
"' >\n";
}
if (!empty(C\FFMPEG)) {
$probe = $this->videoProbeInfo($media_path, $thumb_folder,
$media_name);
if (!empty($probe)) {
$width = $probe['width'];
$height = $probe['height'];
$duration = $probe['duration'];
$additional_metas .=
"<meta property='og:video:width' content='$width' >\n".
"<meta property='og:video:height' ".
"content='$height' >\n" .
"<meta property='video:duration' " .
"content='$duration' >";
}
}
$description = $group_model->getResourceDescription(
$media_name, $group_id, $page_id, $data['SUB_PATH'] ?? "");
if (!empty($description)) {
$description = htmlentities(strip_tags($description));
$additional_metas .= "\n<meta property='og:description' " .
"content='$description' >\n";
}
if (!empty($data['VIEW'])) {
$view = $parent->view($data['VIEW']);
$view->head_objects['additional_metas'] = $additional_metas;
}
}
/* a single csv shown as its own page is the explicit-ask case for
the download link and histogram toggle, so it requests them with
the !verbose flag */
$verbose_marker = ($mime_type == 'text/csv') ? "!verbose" : "";
$page_string .= "<div class='media-container'>";
if (!empty($sub_path)) {
$page_string .= "((resource:$media_name$verbose_marker".
"|$sub_path|$file_name ))";
} else {
$page_string .= "((resource:$media_name$verbose_marker".
"|$file_name ))";
}
$page_string .= "</div>";
$include_charts_and_spreadsheets = ($mime_type == 'text/csv') ?
true : false;
$data["PAGE"] = $group_model->insertResourcesParsePage(
$group_id, $page_id, $data['CURRENT_LOCALE_TAG'],
$page_string, $csrf_token, $data['CONTROLLER'],
$include_charts_and_spreadsheets);
if (str_starts_with($mime_type, 'text')) {
$this->parent->recordViewSession($page_id, $sub_path, $media_name);
}
if ($mime_type == "text/csv" && empty($data['RAW'])) {
$data['INCLUDE_SCRIPTS'][] = 'spreadsheet';
$data['SPREADSHEET'] = true;
}
$data["PAGE_ID"] = $page_id;
if (!empty($data['RESOURCES_INFO']['thumb_folder'])) {
$resource_id = unpack('n', md5($group_id . $page_id .
$data['RESOURCES_INFO']['thumb_folder'] . "/" .
$_REQUEST['n'], true))[1];
$parent->model("impression")->add($user_id, $resource_id,
C\RESOURCE_IMPRESSION);
}
}
/**
* Serves one file out of a page's resources when the page is set to
* be a static HTML folder, and says whether it did. A folder of
* generated documentation, or any other small site, refers to its
* own stylesheets, scripts and pages by paths relative to itself, so
* it only works if those paths fetch the files they name. A folder
* is answered with the first of the page's index files that is in
* it, the way a web server answers one; if none is, the page's
* ordinary resource listing is shown when directory indexes are on,
* and nothing is found when they are off. What is served comes only
* from the page's own resource folder, so no path can reach anything
* else the site holds.
*
* @param array &$data fields for the view, whose HEAD holds the
* page's settings and whose PAGE_NAME names it
* @param int $group_id which group the page belongs to
* @param int $page_id which page within that group
* @param string $sub_path what was asked for beneath the page,
* empty for the folder itself
* @param int $user_id who is asking, used to offer an edit button to
* someone allowed to edit the page
* @return bool whether the request was answered here, in which case
* the caller has nothing left to do
*/
public function serveStaticFolderFile(&$data, $group_id, $page_id,
$sub_path, $user_id)
{
$head = $data["HEAD"] ?? [];
$settings = $this->staticFolderSettings($head);
if (empty($settings['static_html_folder'])) {
return false;
}
$folders = $this->parent->model("group")
->getGroupPageResourcesFolders($group_id, $page_id);
if (!is_array($folders) || empty($folders[0])) {
return false;
}
$path = $this->staticFolderPath($folders[0], $sub_path);
if (is_dir($path)) {
$index_path = $this->staticFolderIndexPath($path,
$settings['index_files']);
if ($index_path === "") {
if (!empty($settings['directory_indexes'])) {
/* nothing here stands for the folder, so let the
page's own resource listing be what shows it */
return false;
}
$this->sendStaticFolderMissing();
return true;
}
if ($this->redirectStaticFolderSlash()) {
return true;
}
$path = $index_path;
}
if (!file_exists($path) || is_dir($path)) {
$this->sendStaticFolderMissing();
return true;
}
$this->sendStaticFolderFile($data, $path, $group_id, $user_id);
return true;
}
/**
* Finds which of a page's index files stands for a folder. A page
* may name several, and the first of them that is in the folder is
* the one used, so a folder holding either an index.html or an
* index.htm is served whichever it holds.
*
* @param string $folder folder being asked for
* @param array $index_files names that may stand for a folder, in
* the order they are to be tried
* @return string path of the file that stands for the folder, empty
* when none of them is in it
*/
private function staticFolderIndexPath($folder, $index_files)
{
foreach ($index_files as $index_file) {
$candidate = $folder . "/" . $index_file;
if (file_exists($candidate) && !is_dir($candidate)) {
return $candidate;
}
}
return "";
}
/**
* Sends a reader who asked for a folder without the closing slash to
* the same address with one, and says whether it did. A page inside
* a folder names its stylesheets and its neighbours by where they
* sit relative to it, and a browser works out where that is from the
* address it was given: without the closing slash it reads the
* folder's own name as a file name and looks for everything one
* level too high. This is what a web server does for the same
* reason.
*
* @return bool whether the reader was sent somewhere, in which case
* the caller has nothing left to do
*/
private function redirectStaticFolderSlash()
{
$request_uri = $_SERVER['REQUEST_URI'] ?? "";
$query_at = strpos($request_uri, "?");
$path = ($query_at === false) ? $request_uri :
substr($request_uri, 0, $query_at);
$query = ($query_at === false) ? "" :
substr($request_uri, $query_at);
if ($path === "" || substr($path, -1) === "/") {
return false;
}
$parent = $this->parent;
$parent->web_site->header("HTTP/1.1 301 Moved Permanently");
$parent->web_site->header("Location: " . $path . "/" . $query);
unset($_SESSION['DISPLAY_MESSAGE']);
\seekquarry\atto\webExit();
return true;
}
/**
* Tidies what was typed into a page's Index File setting. Several
* names may be given, separated by commas, and each is cut back to a
* plain file name so that none of them can name a place of its own
* rather than something in the folder being asked for. Each is dealt
* with on its own: cutting the whole line back at once would keep
* only what came after the last slash in it and quietly lose every
* name before that.
*
* @param string $setting what was typed into the setting
* @return string the names that survived, comma separated, in the
* order they were given
*/
private function cleanIndexFileSetting($setting)
{
$index_files = [];
foreach (explode(",", (string)$setting) as $index_file) {
$index_file = basename(trim($index_file));
if ($index_file !== "") {
$index_files[] = $index_file;
}
}
return implode(",", $index_files);
}
/**
* Whether a page's settings say it is served as a static HTML
* folder. This lets a controller know, before it does anything that
* would send the reader elsewhere, that what was asked for is a file
* of a folder and has to be answered where it was asked for.
*
* @param array $head the page's settings as saved with it
* @return bool whether the page is a static HTML folder
*/
public function isStaticFolderPage($head)
{
$settings = $this->staticFolderSettings($head);
return !empty($settings['static_html_folder']);
}
/**
* Says whether one of a page's yes-or-no settings is on. A page's
* settings are saved as text, so a setting turned off can arrive as
* the word false rather than as nothing at all, and asking merely
* whether it is empty would read that word as a yes.
*
* @param array $head the page's settings as saved with it
* @param string $key which setting is being asked about
* @param bool $default what the setting means when the page does not
* mention it
* @return bool whether the setting is on
*/
private function headVarOn($head, $key, $default)
{
if (!isset($head[$key])) {
return $default;
}
$value = $head[$key];
if (is_bool($value)) {
return $value;
}
$value = strtolower(trim((string)$value));
return !($value === "" || $value === "false" || $value === "0");
}
/**
* Reads a page's static HTML folder settings, understanding the
* names they used to be saved under. Serving an index page was once
* all this did and was saved as such, so a page set up that way and
* not since re-saved is read as a static folder; once the page names
* the newer setting, that is what is believed, so the setting can be
* turned back off.
*
* @param array $head the page's settings as saved with it
* @return array whether the page is a static HTML folder, whether a
* folder holding no index file of its own lists what is in it,
* and the names that may stand for a folder in the order they
* are to be tried
*/
private function staticFolderSettings($head)
{
if (isset($head['static_html_folder'])) {
$is_static = $this->headVarOn($head, 'static_html_folder',
false);
} else {
$is_static = $this->headVarOn($head, 'media_list_index',
false);
}
$index_setting = (string)($head['index_file'] ?? "");
if (trim($index_setting) === "") {
$index_setting = (string)($head['media_list_index_file'] ?? "");
}
$index_files = [];
foreach (explode(",", $index_setting) as $index_file) {
$index_file = basename(trim($index_file));
if ($index_file !== "") {
$index_files[] = $index_file;
}
}
if ($index_files === []) {
$index_files = [self::STATIC_FOLDER_INDEX_FILE];
}
return ['static_html_folder' => $is_static,
'directory_indexes' => $this->headVarOn($head,
'directory_indexes', false), 'index_files' => $index_files];
}
/**
* Works out which file beneath a page's resource folder was asked
* for, reading a step back up the way a web server reads one. A step
* back up from the folder itself has nowhere to go, so it stays
* there rather than reaching anything outside; what comes back is
* always within the page's own resources however the path was
* written.
*
* @param string $folder the page's own resource folder
* @param string $sub_path what was asked for beneath the page
* @return string path of the file or folder asked for
*/
private function staticFolderPath($folder, $sub_path)
{
$parts = [];
foreach (explode("/", (string)$sub_path) as $part) {
if ($part === "" || $part === ".") {
continue;
}
if ($part === "..") {
array_pop($parts);
continue;
}
$parts[] = $part;
}
return ($parts === []) ? $folder : $folder . "/" .
implode("/", $parts);
}
/**
* Tells the reader that a static HTML folder holds nothing by the
* name they asked for. A folder holding no index file of its own is
* answered this way when directory indexes are off, so that turning
* listings off keeps them off rather than falling back to showing
* one.
*/
private function sendStaticFolderMissing()
{
$parent = $this->parent;
$parent->web_site->header("HTTP/1.1 404 Not Found");
$parent->web_site->header("Content-Type: text/plain");
$body = self::STATIC_FOLDER_MISSING_BODY;
$parent->web_site->header("Content-Length: " . strlen($body));
unset($_SESSION['DISPLAY_MESSAGE']);
e($body);
\seekquarry\atto\webExit();
}
/**
* Sends one file of a static HTML folder as the whole response. A
* page served as its own bytes never renders the normal wiki page,
* so any one-time message waiting to be shown is consumed here;
* otherwise it would surface later on the next ordinary page.
* Someone allowed to edit the page is given a fixed edit button in
* the top opposite corner of an HTML file; every other reader
* receives the file exactly as stored. The file is sent a block at a
* time rather than held whole, so a large one does not cost the
* always-on server its own size in memory.
*
* @param array &$data fields for the view, whose PAGE_NAME and
* CONTROLLER build the edit link
* @param string $path file to send
* @param int $group_id which group the page belongs to
* @param int $user_id who is asking
*/
private function sendStaticFolderFile(&$data, $path, $group_id,
$user_id)
{
$parent = $this->parent;
$mime_type = L\mimeType($path, true);
$parent->web_site->header("Content-Type: " . $mime_type);
unset($_SESSION['DISPLAY_MESSAGE']);
$edit_overlay = "";
if (!empty($data["CAN_EDIT"]) &&
stripos($mime_type, "html") !== false) {
$edit_overlay = $this->staticFolderEditOverlay($data,
$group_id, $user_id);
}
if ($edit_overlay !== "") {
$body = file_get_contents($path) . $edit_overlay;
$parent->web_site->header("Content-Length: " . strlen($body));
e($body);
\seekquarry\atto\webExit();
}
$parent->web_site->header("Content-Length: " . filesize($path));
$parent->web_site->stream(function () use ($path) {
$handle = fopen($path, "rb");
try {
while (!feof($handle)) {
$bytes = fread($handle, C\RESOURCE_STREAM_BLOCK_LEN);
if ($bytes === false || $bytes === "") {
break;
}
yield $bytes;
}
} finally {
fclose($handle);
}
});
\seekquarry\atto\webExit();
}
/**
* Builds the edit button laid over an HTML file of a static HTML
* folder for someone allowed to edit the page. The file is served as
* itself with no wiki page around it, so this is the only way back
* to editing the page from what a reader sees.
*
* @param array &$data fields for the view, whose PAGE_NAME and
* CONTROLLER build the link
* @param int $group_id which group the page belongs to
* @param int $user_id who is asking, whose token the link carries
* @return string HTML of the button
*/
private function staticFolderEditOverlay(&$data, $group_id, $user_id)
{
$parent = $this->parent;
$edit_url = htmlentities(B\wikiUrl($data['PAGE_NAME'], true,
$data['CONTROLLER'], $group_id) . C\p('CSRF_TOKEN') . "=" .
$parent->generateCSRFToken($user_id) . "&arg=edit");
list($edit_label, $edit_glyph, ) = $parent->view("group")
->helper("iconlink")->icon_possibilities['edit'];
return "<a href=\"" . $edit_url . "\" role=\"button\" " .
"aria-label=\"" . $edit_label . "\" style=\"position:fixed;" .
"top:0;right:0;z-index:2147483647;margin:0.5em;" .
"padding:0.35em 0.5em;background:#f2f2f2;" .
"border:1px solid #888;border-radius:0.4em;" .
"box-shadow:0 1px 3px rgba(0,0,0,0.35);" .
"font:bold 1.25rem sans-serif;color:#222;" .
"text-decoration:none;white-space:nowrap;line-height:1;\">" .
$edit_glyph . "</a>";
}
/**
* Reads the width, height and running time of a video, from a note
* kept beside the video's thumbnails when there is one and by asking
* ffprobe when there is not. These figures go into a video's page as
* the tags a link preview reads, and they only change when the video
* itself does, so asking ffprobe on every view of the page started a
* process each time and read the file each time; a crawler walking a
* site's videos made that a process per hit, and on a server already
* holding a lot of memory starting one can fail outright, which is
* how this showed up. The note is named after the video and holds the
* time the video was last changed, so a video that is replaced is
* measured again rather than described by the old figures.
*
* @param string $media_path file system path of the video
* @param mixed $thumb_folder folder holding the thumbnails of the
* page the video is on, where the note is kept, or false when
* the page has no such folder, in which case ffprobe is asked
* each time
* @param string $media_name name of the video within its folder
* @return array width, height and duration of the video, or empty
* when the figures could not be read
*/
private function videoProbeInfo($media_path, $thumb_folder, $media_name)
{
$modified = filemtime($media_path);
$note_path = "";
if (!empty($thumb_folder) && is_dir($thumb_folder)) {
$note_path = $thumb_folder . "/" . L\crawlHash($media_name) .
".probe.txt";
}
if ($note_path != "" && file_exists($note_path)) {
$note = json_decode(file_get_contents($note_path), true);
if (!empty($note['modified']) && $note['modified'] == $modified &&
isset($note['width'], $note['height'], $note['duration'])) {
return ['width' => $note['width'],
'height' => $note['height'],
'duration' => $note['duration']];
}
}
$ffprobe = str_replace("ffmpeg", "ffprobe", C\FFMPEG);
$probe_exec = "$ffprobe -v error -show_entries " .
"stream=width,height,duration -of " .
"default=noprint_wrappers=1 \"$media_path\"";
$probe_output = [];
exec($probe_exec, $probe_output);
if (empty($probe_output[2])) {
return [];
}
preg_match('/\=(\d+)/', $probe_output[0], $width_matches);
preg_match('/\=(\d+)/', $probe_output[1], $height_matches);
preg_match('/\=(\d+(\.\d*)?)/', $probe_output[2],
$duration_matches);
if (empty($width_matches[1]) || empty($height_matches[1]) ||
empty($duration_matches[1])) {
return [];
}
$probe = ['width' => $width_matches[1],
'height' => $height_matches[1],
'duration' => $duration_matches[1]];
if ($note_path != "") {
file_put_contents($note_path, json_encode(
array_merge(['modified' => $modified], $probe)));
}
return $probe;
}
/**
* 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();
$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),
'string');
$sub_path = str_replace("..", "", $sub_path);
$sub_path = str_replace("/./", "/", $sub_path);
$data['SUB_PATH'] = htmlentities($sub_path);
}
}
if (!empty($_REQUEST['sf'])) {
$sub_path = $parent->clean($_REQUEST['sf'], 'string');
$sub_path = str_replace("..", "", $sub_path);
$sub_path = str_replace("/./", "/", $sub_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')
];
$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'),
];
$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];
}
/**
* 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;
}
}