<?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 Mallika Perepa, Chris Pollett
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\models;
use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\av_processing\AudioConverter;
use seekquarry\yioop\library\av_processing\VideoExtractor;
use seekquarry\yioop\library\MediaConstants;
use seekquarry\yioop\library\version_control\VersionManager;
use seekquarry\yioop\library\wiki\WikiParser;
use seekquarry\yioop\library\processors\TextProcessor;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\library\processors\EpubProcessor;
use seekquarry\yioop\library\processors\PdfProcessor;
use seekquarry\yioop\library\processors\VideoProcessor;
use seekquarry\yioop\models\ImpressionModel;
use seekquarry\yioop\library\wiki as LW;
/**
* FeedModel stores the feed of a group in the database. A feed is a
* list of threads the members write. Each thread holds posts.
*
* It starts a thread, adds a post to one, edits a post, and deletes
* one. It counts the threads in a group and the posts in a thread. It
* stores who follows a thread and who subscribes to a group, so they
* can be told when a post arrives. It stores that a member flagged a
* post as unwanted, and it stores a moderator's decision about it.
*
* This model extends GroupModel, which stores the group a thread
* belongs to and the key that encrypts a private group's posts.
*
* SocialComponent uses this model to draw the group feed screen and to
* save what someone writes there. GroupfeedElement draws what it
* reads.
*
* @author Chris Pollett
*/
class FeedModel extends GroupModel
{
/**
* countGroupThreads returns how many threads (top-level feed items) a group
* has.
* @param int $group_id group to count threads for
* @return int number of threads belonging to $group_id
*/
public function countGroupThreads($group_id)
{
$db = $this->db;
$sql = "SELECT COUNT(*) AS NUM FROM GROUP_ITEM " .
"WHERE GROUP_ID = ? AND PARENT_ID = ID";
$result = $db->execute($sql, [$group_id]);
$row = ($result) ? $db->fetchArray($result) : ['NUM' => 0];
return $row['NUM'];
}
/**
* countThreadPosts returns how many posts a thread has, counting the
* thread's own starting item together with its replies.
* @param int $thread_id parent thread to count posts within
* @return int number of posts whose parent is $thread_id
*/
public function countThreadPosts($thread_id)
{
$db = $this->db;
$sql = "SELECT COUNT(*) AS NUM FROM GROUP_ITEM " .
"WHERE PARENT_ID = ?";
$result = $db->execute($sql, [$thread_id]);
$row = ($result) ? $db->fetchArray($result) : ['NUM' => 0];
return $row['NUM'];
}
/**
* isThreadSubscribed says whether a member is following one discussion
* thread, meaning they have asked to be mailed about it. Not following is
* the default, so this returns true only when the member has posted to the
* thread or pressed the follow control for it.
* @param int $user_id the member to check
* @param int $thread_id the thread (its first post's id) to check
* @return bool true if the member is following this thread's mail
*/
public function isThreadSubscribed($user_id, $thread_id)
{
$db = $this->db;
$sql = "SELECT USER_ID FROM THREAD_FOLLOW " .
"WHERE USER_ID = ? AND THREAD_ID = ?";
$result = $db->execute($sql, [$user_id, $thread_id]);
return ($result && $db->fetchArray($result)) ? true : false;
}
/**
* setThreadSubscribe starts or stops one member following a single
* discussion thread. Following records a row saying "mail me about this
* thread"; unfollowing removes any such row. Any existing row is cleared
* first so a thread never has two follow rows for the same member, which
* also makes following a thread you already follow harmless.
* @param int $user_id the member whose choice is changing
* @param int $thread_id the thread (its first post's id) the choice is for
* @param bool $subscribe true to follow this thread (be mailed about it),
* false to stop following it
*/
public function setThreadSubscribe($user_id, $thread_id,
$subscribe)
{
$db = $this->db;
$db->execute("DELETE FROM THREAD_FOLLOW " .
"WHERE USER_ID = ? AND THREAD_ID = ?",
[$user_id, $thread_id]);
if ($subscribe) {
$db->execute("INSERT INTO THREAD_FOLLOW " .
"(USER_ID, THREAD_ID) VALUES (?, ?)",
[$user_id, $thread_id]);
}
}
/**
* getGroupThreadId get the PARENT_ID of a thread based on the group-id opf
* group thread is in, the user_id of the user posting the thread and the
* exact thread title
* @param int $group_id id of group thread is in
* @param int $user_id id of user posting to thread
* @param string $title exact title of thread
* @return int the id of the first thread matching the above criteria
*/
public function getGroupThreadId($group_id, $user_id, $title)
{
$db = $this->db;
if ($user_id !== null) {
$sql = "SELECT PARENT_ID FROM GROUP_ITEM WHERE GROUP_ID=? AND " .
"USER_ID=? AND TITLE=? " . $db->limitOffset(1);
$result = $db->execute($sql, [$group_id, $user_id, $title]);
} else {
$sql = "SELECT PARENT_ID FROM GROUP_ITEM WHERE GROUP_ID=? AND " .
"TITLE=? " . $db->limitOffset(1);
$result = $db->execute($sql, [$group_id, $title]);
}
$row = $db->fetchArray($result);
return $row["PARENT_ID"] ?? false;
}
/**
* getGroupItem returns the GROUP_FEED item with the given id
* @param int $item_id the item to get info about
* @param bool $is_group_id whether $item_id is a group_id (true) or a row
* id from GROUP_ITEM (false)
* @return array row from GROUP_FEED table
*/
public function getGroupItem($item_id, $is_group_id = false)
{
$db = $this->db;
if ($is_group_id) {
$sql = "SELECT * FROM GROUP_ITEM WHERE GROUP_ID=? " .
$db->limitOffset(1);
} else {
$sql = "SELECT * FROM GROUP_ITEM WHERE ID=? " . $db->limitOffset(1);
}
$result = $db->execute($sql, [$item_id]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
if (!empty($row['GROUP_ID']) &&
$this->isGroupEncrypted($row['GROUP_ID'])) {
/* Decrypt group's title and description */
$key = $this->getGroupKey($row['GROUP_ID']);
$row['TITLE'] = $this->decrypt($row['TITLE'], $key);
$row['DESCRIPTION'] = $this->decrypt($row['DESCRIPTION'], $key);
}
return $row;
}
/**
* getThreadFollowers returns an array of user information about users who
* have contributed to a thread or own the group a thread belongs to
* @param int $thread_id the id of the thread that want users for
* @param int $owner_id usually owner of group thread belongs to however
* could be id of any additional user want info for. If left a default
* value (-1) than only user's who have contributed to thread will be
* returned
* @param int $exclude_id an id of a user to exclude from the array returned
* @return array user information of users following the thread
*/
public function getThreadFollowers($thread_id, $owner_id = -1,
$exclude_id = -1)
{
$db = $this->db;
$params = [$thread_id, $owner_id];
$sql = "SELECT DISTINCT U.USER_ID AS USER_ID, " .
"U.USER_NAME AS USER_NAME, U.EMAIL AS EMAIL ".
"FROM GROUP_ITEM GI, USERS U ".
"WHERE GI.PARENT_ID=? AND (GI.USER_ID=U.USER_ID OR U.USER_ID=?)";
if ($exclude_id != -1) {
$sql .= " AND U.USER_ID != ?";
$params[] = $exclude_id;
}
$result = $db->execute($sql, $params);
if (!$result) {
return false;
}
$i = 0;
$rows = [];
while ($row = $db->fetchArray($result)) {
$rows[] = $row;
}
return $rows;
}
/**
* getThreadSubscribers returns the members who are following one discussion
* thread, that is the people who asked to be mailed about it (by posting to
* it or pressing its follow control). Used to build the recipient list when
* a new reply is posted, so each row carries the address fields a mail
* needs.
* @param int $thread_id the thread (its first post's id) to list followers
* for
* @param int $exclude_id a user to leave out of the list, normally the
* person who just posted; pass -1 to exclude no one
* @return array one row per follower with USER_ID, USER_NAME and EMAIL
*/
public function getThreadSubscribers($thread_id, $exclude_id = -1)
{
$db = $this->db;
$params = [$thread_id];
$sql = "SELECT DISTINCT U.USER_ID AS USER_ID, " .
"U.USER_NAME AS USER_NAME, U.EMAIL AS EMAIL " .
"FROM THREAD_FOLLOW TF, USERS U " .
"WHERE TF.THREAD_ID = ? AND TF.USER_ID = U.USER_ID";
if ($exclude_id != -1) {
$sql .= " AND U.USER_ID != ?";
$params[] = $exclude_id;
}
$result = $db->execute($sql, $params);
if (!$result) {
return false;
}
$rows = [];
while ($row = $db->fetchArray($result)) {
$rows[] = $row;
}
return $rows;
}
/**
* getFollowedThreads given a list of thread ids, says which of them a
* member is following. The feed uses this so each thread row can show
* whether its follow control is on or off, looked up in one query rather
* than one lookup per row.
* @param int $user_id the member to check
* @param array $thread_ids the thread ids shown on the feed page
* @return array the subset of $thread_ids the member follows, given as a
* map from thread id to true for an easy isset() test
*/
public function getFollowedThreads($user_id, $thread_ids)
{
$followed = [];
if (empty($thread_ids)) {
return $followed;
}
$db = $this->db;
$marks = implode(", ", array_fill(0, count($thread_ids), "?"));
$sql = "SELECT THREAD_ID FROM THREAD_FOLLOW " .
"WHERE USER_ID = ? AND THREAD_ID IN ($marks)";
$params = array_merge([$user_id], $thread_ids);
$result = $db->execute($sql, $params);
if ($result) {
while ($row = $db->fetchArray($result)) {
$followed[$row['THREAD_ID']] = true;
}
}
return $followed;
}
/**
* addGroupItem creates a new group item
* @param int $parent_id thread id to use for the item
* @param int $group_id what group the item should be added to
* @param int $user_id of user making the post
* @param string $title title of the group feed item
* @param string $description actual content of the post
* @param int $type flag saying what kind of group item this is. One of
* STANDARD_GROUP_ITEM, WIKI_GROUP_ITEM (used for threads discussing a
* wiki page)
* @param int $post_time timstamp for when this group item was created
* default to the current time
* @param string $url a url associated with this group item (mainly for
* search group, otherwise use empty string)
* @param int $edit_time timestamp for when this group item was last edited
* @param int $ups number of times the item has been upvoted
* @param int $downs number of times the item has been down voted
* @param int $flag number of times the item has been flagged
* @param int $input_timestamp optional UNIX timestamp to use for impression
* tracking; if set to -1 or omitted, the current time will be used
* @return int $id of item added
*/
public function addGroupItem($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)
{
$db = $this->db;
if ($post_time == 0) {
$post_time = time();
}
if ($edit_time <= $post_time) {
$edit_time = $post_time;
}
if ($this->isGroupEncrypted($group_id)) {
$key = $this->getGroupKey($group_id);
$title = $this->encrypt($title, $key);
$description = $this->encrypt($description, $key);
}
$sql = "INSERT INTO GROUP_ITEM (PARENT_ID, GROUP_ID, USER_ID, URL,
TITLE, DESCRIPTION, PUBDATE, EDIT_DATE, TYPE, UPS, DOWNS,
FLAG) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$db->execute($sql, [$parent_id, $group_id, $user_id, $url, $title,
$description, $post_time, $edit_time, $type, $ups, $downs, $flag]);
$id = $db->insertID("GROUP_ITEM");
if ($parent_id == 0) {
$sql = "UPDATE GROUP_ITEM SET PARENT_ID=? WHERE ID=?";
$db->execute($sql, [$id, $id]);
ImpressionModel::initWithDb($user_id, $id, C\THREAD_IMPRESSION,
$db, $input_timestamp);
}
return $id;
}
/**
* addGroupItemModeration adds a flagged group item to the moderation group
* @param int $parent_id thread id to use for the item
* @param int $group_id what group the item should be added to
* @param int $user_id of user making the post
* @param string $title title of the group feed item
* @param string $description actual content of the post
* @param int $flag flag value to record on the inserted GROUP_ITEM row
* (e.g. NEEDS_MODERATION_FLAG)
* @param int $parent_item_id row id of the original item that was flagged;
* the new moderation post points back to it
* @param int $parent_group_id group id the flagged item lives in; used to
* fetch the encryption key when re-decrypting before reinsertion
* @param int $type flag saying what kind of group item this is. One of
* STANDARD_GROUP_ITEM, WIKI_GROUP_ITEM (used for threads discussing a
* wiki page)
* @param int $post_time timstamp for when this group item was created
* default to the current time
* @param string $url a url associated with this group item (mainly for
* search group)
* @return int $id of item added
*/
public function addGroupItemModeration($parent_id, $group_id, $user_id,
$title, $description, $flag, $parent_item_id, $parent_group_id,
$type= C\STANDARD_GROUP_ITEM, $post_time = 0, $url = "")
{
$db = $this->db;
if ($post_time == 0) {
$post_time = time();
}
if($this->isGroupEncrypted($parent_group_id)) {
$key = $this->getGroupKey($parent_group_id);
$title = $this->decrypt($title, $key);
$description = $this->decrypt($description, $key);
}
$sql = "INSERT INTO GROUP_ITEM (PARENT_ID, GROUP_ID, USER_ID, URL,
TITLE, DESCRIPTION, PUBDATE, EDIT_DATE, FLAG, PARENT_ITEM_ID, TYPE)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$db->execute($sql, [$parent_id, $group_id, $user_id, $url, $title,
$description, $post_time, $post_time, $flag, $parent_item_id,
$type]);
$id = $db->insertID("GROUP_ITEM");
$update_sql = "UPDATE GROUP_ITEM SET PARENT_ID = ? WHERE ID = ?";
$db->execute($update_sql, [$id, $id]);
return $id;
}
/**
* updateGroupItem updates a group feed item's title and description. This
* assumes the given item already exists.
* @param int $id which item to change
* @param string $title the new title
* @param string $description the new description
*/
public function updateGroupItem($id, $title, $description)
{
$db = $this->db;
$edit_date = time();
$group_item = $this->getGroupItem($id);
if ($this->isGroupEncrypted($group_item['GROUP_ID'])) {
$key = $this->getGroupKey($group_item['GROUP_ID']);
$title = $this->encrypt($title, $key);
$description = $this->encrypt($description, $key);
}
$sql = "UPDATE GROUP_ITEM SET TITLE=?, DESCRIPTION=?,
EDIT_DATE=? WHERE ID=?";
$db->execute($sql, [$title, $description, $edit_date, $id]);
}
/**
* deleteConversationThreadRow lets go of the row a conversation hangs from
* in one person's own message group, which is what takes the conversation
* off their list of contacts. The other person's row is left alone, so they
* still see the conversation on theirs.
* @param int $group_id the person's own message group
* @param string $thread_title the name the pair's conversation is kept
* under
* @return int how many rows were let go
*/
public function deleteConversationThreadRow($group_id, $thread_title)
{
$db = $this->db;
/* The row a conversation hangs from carries no words, which is
what tells it apart from the messages under it. */
$sql = "DELETE FROM GROUP_ITEM WHERE GROUP_ID=? AND TITLE=?
AND DESCRIPTION = ''";
$db->execute($sql, [$group_id, $thread_title]);
return $db->affectedRows();
}
/**
* deleteGroupItem removes a group feed item from the GROUP_ITEM table.
* @param int $post_id of item to remove
* @param int $user_id the id of the person trying to perform the removal.
* If not root, or the original creator of the item, the item won't be
* removed
* @return int number of GROUP_ITEM rows actually deleted (0 when the user
* lacked permission and the row was untouched)
*/
public function deleteGroupItem($post_id, $user_id)
{
$db = $this->db;
$params = [$post_id];
if ($user_id == C\ROOT_ID) {
$and_where = "";
} else {
$and_where = " AND USER_ID=?";
$params[] = $user_id;
}
$sql = "DELETE FROM GROUP_ITEM WHERE ID=? $and_where";
if ($result = $db->execute($sql, $params)) {
$affected_rows = $db->affectedRows();
ImpressionModel::deleteWithDb($user_id, $post_id,
C\THREAD_IMPRESSION, $db);
ImpressionModel::deleteWithDb(C\PUBLIC_USER_ID, $post_id,
C\THREAD_IMPRESSION, $db);
} else {
$affected_rows = $db->affectedRows();
}
return $affected_rows;
}
/**
* flagGroupItem flags a group feed item in the GROUP_ITEM table.
* @param int $post_id of item to be flagged
* @param int $user_id the id of the person trying to flag the item If user
* does not belong to the group, the item won't be flagged
* @return int|bool number of rows updated by the flag insert, or false when
* the threshold has already been reached so the additional flag is
* rejected
*/
public function flagGroupItem($post_id, $user_id)
{
$db = $this->db;
$sql = "SELECT * FROM GROUP_ITEM WHERE ID = ?";
$stmt = $db->execute($sql, [$post_id]);
$result = $stmt->fetch();
$group_id = $result['GROUP_ID'];
$flag_count = $this->getFlagCount($post_id);
$threshold = $this->getThresholdValue($group_id);
if ($this->isGroupEncrypted($group_id)) {
$key = $this->getGroupKey($group_id);
if (is_null($flag_count)) {
$flag_count = $this->encryptFlag(strval($flag_count), $key);
}
if ($flag_count === $threshold) {
$flag_count_decrypt = $this->decryptFlag(
base64_decode($flag_count), $key);
if (C\p('USE_MODERATION')) {
$this->addGroupItemModeration(0,
C\MODERATION_GROUP_ID, $result['USER_ID'],
$result['TITLE'], $result['DESCRIPTION'],
C\MODERATION_FLAGGED, $post_id, $group_id,
C\STANDARD_GROUP_ITEM, 0, "");
}
} else if ($flag_count === $this->encryptFlag($threshold, $key)) {
return false;
}
$flag_count = $this->encryptFlag(strval($flag_count), $key);
} else {
$flag_count++;
if($flag_count > C\p('MODERATION_FLAG_THRESHOLD')) {
return false;
}
if($flag_count === $threshold && C\p('USE_MODERATION')) {
$this->addGroupItemModeration(0, C\MODERATION_GROUP_ID,
$result['USER_ID'], $result['TITLE'],$result['DESCRIPTION'],
C\MODERATION_FLAGGED, $post_id, $group_id,
C\STANDARD_GROUP_ITEM, 0, "");
}
}
$sql = "INSERT INTO GROUP_ITEM_FLAG VALUES (?, ?)";
$this->db->execute($sql, [$user_id, $post_id]);
$update_sql = "UPDATE GROUP_ITEM SET FLAG = ? WHERE ID = ?
AND GROUP_ID IN (SELECT GROUP_ID FROM USER_GROUP
WHERE USER_ID = ?)";
$db->execute($update_sql, [$flag_count, $post_id, $user_id]);
$affected_rows = $db->affectedRows();
return $affected_rows;
}
/**
* getParentPostById get the id of the parent thread of a post
* @param int $post_id denotes the thread
* @return mixed ID of the grand-parent GROUP_ITEM row, or false on query
* failure / missing parent
*/
public function getParentPostById($post_id)
{
$db = $this->db;
$sql = "SELECT ID FROM GROUP_ITEM WHERE ID =
(SELECT PARENT_ITEM_ID FROM GROUP_ITEM WHERE ID = ?)";
$stmt = $db->execute($sql, [$post_id]);
if (!$stmt) {
return false;
}
if (!($row = $db->fetchArray($stmt))) {
return false;
}
return $row['ID'];
}
/**
* getParentIdOfParentPost get parent_id of the parent thread of a given
* post
* @param int $post_id denotes the post
* @return mixed PARENT_ID of the parent GROUP_ITEM row, or false on query
* failure / missing parent
*/
public function getParentIdOfParentPost($post_id)
{
$db = $this->db;
$sql = "SELECT PARENT_ID FROM GROUP_ITEM WHERE ID =
(SELECT PARENT_ITEM_ID FROM GROUP_ITEM WHERE ID = ?)";
$stmt = $db->execute($sql, [$post_id]);
if (!$stmt) {
return false;
}
if (!($row = $db->fetchArray($stmt))) {
return false;
}
return $row['PARENT_ID'];
}
/**
* approveFlaggedPost moderator approves a flagged post
* @param int $post_id to fetch the flagged post
* @return int number of GROUP_ITEM rows updated by the MODERATION_CLEARED
* reset
*/
public function approveFlaggedPost($post_id)
{
/* get parent_item_id and go to the original post */
$item_id= $this->getParentPostById($post_id);
/* reset flag column value to zero */
$db = $this->db;
$update_sql = "UPDATE GROUP_ITEM SET FLAG = 0 WHERE ID = ?";
$db->execute($update_sql, [$item_id]);
$reset_sql = "UPDATE GROUP_ITEM SET FLAG = " . C\MODERATION_CLEARED .
" WHERE ID = ?";
$db->execute($reset_sql, [$post_id]);
$affected_rows = $db->affectedRows();
return $affected_rows;
}
/**
* deleteFlaggedPost deletes a flagged post from group items
* @param int $post_id to fetch the flagged post
* @param string $message string to show for the removed post
* @return int number of GROUP_ITEM rows affected by the MODERATION_REMOVED
* state change
*/
public function deleteFlaggedPost($post_id, $message)
{
$parent_group_id = $this->getParentGroupId($post_id);
if($this->isGroupEncrypted($parent_group_id)) {
$key = $this->getGroupKey($parent_group_id);
$description = $this->encrypt($description, $key);
}
$item_id = $this->getParentPostById($post_id);
$db = $this->db;
$update_sql = "UPDATE GROUP_ITEM SET DESCRIPTION = ? WHERE ID = ?";
$db->execute($update_sql, [$message, $item_id]);
$reset_sql = "UPDATE GROUP_ITEM SET FLAG = ". C\MODERATION_REMOVED .
" WHERE ID = ?";
$db->execute($reset_sql, [$post_id]);
$affected_rows = $db->affectedRows();
return $affected_rows;
}
/**
* getGroupItems gets the group feed items visible to a user with $user_id
* and which match the supplied search criteria found in $search_array,
* starting from the $limit'th matching item to the $limit+$num item.
* @param int $limit starting offset group item to display
* @param int $num number of items from offset to display
* @param array $search_array each element of this is a quadruple name of a
* field, what comparison to perform, a value to check, and an order
* (ascending/descending) to sort by
* @param int $user_id who is making this request to determine which
* @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
* @return array elements of which represent one group feed item
*/
public function getGroupItems($limit = 0, $num = 100, $search_array = [],
$user_id = C\ROOT_ID, $for_group = -1)
{
$db = $this->db;
$limit = $db->limitOffset($limit, $num);
$any_fields = ["access", "register"];
$is_thread = false;
foreach ($search_array as $search_item) {
if ($search_item[0] == 'parent_id') {
if ($search_item[2] > 0) {
$is_thread = true;
}
break;
}
}
list($where, $order_by) =
$this->searchArrayToWhereOrderClauses($search_array, $any_fields);
$where = str_replace("O.USER_ID", "P.USER_ID", $where); /* hacky */
$add_where = " WHERE ";
if (!empty($where)) {
$add_where = " AND ";
if (str_ends_with(rtrim($where), "AND")) {
$add_where = " ";
}
}
$user_id = $db->escapeString($user_id);
if ($for_group > 0 || $for_group == -2) { /* -2 is just_thread case) */
$non_public_where = " (UG.USER_ID='$user_id' OR ".
" G.REGISTER_TYPE IN ('" . C\PUBLIC_JOIN . "','".
C\PUBLIC_BROWSE_REQUEST_JOIN . "') ) AND ";
if (!$is_thread) {
$non_public_where .=
"TYPE = " . C\STANDARD_GROUP_ITEM. " AND ";
}
} else {
$non_public_where = " UG.USER_ID='$user_id' AND ";
}
$non_public_status = ($user_id != C\PUBLIC_GROUP_ID) ?
" UG.STATUS='" . C\ACTIVE_STATUS . "' AND " : "";
$where .= $add_where . $non_public_where .
"GI.GROUP_ID=G.GROUP_ID AND GI.GROUP_ID=UG.GROUP_ID AND ((
$non_public_status
G.MEMBER_ACCESS IN ('" . C\GROUP_READ ."','".C\GROUP_READ_COMMENT.
"','".C\GROUP_READ_WRITE."', '". C\GROUP_READ_WIKI ."')) OR
(G.OWNER_ID = UG.USER_ID OR UG.USER_ID = '" . C\ROOT_ID . "'))";
$where .= $this->excludeGitIssueThreadsClause("GI");
if ($for_group >= 0) {
$outer_where = "";
if (preg_match("/P\.USER_ID=[\'\"]\d+[\'\"]/", $where, $matches)) {
$outer_where = " AND " . $matches[0];
$where = preg_replace("/P\.USER_ID=[\'\"]\d+[\'\"]/", "",
$where);
/* above could result in two ANDs */
$where = preg_replace('/AND\s+AND/ui', "AND", $where);
}
$group_by = " GROUP BY GI.PARENT_ID";
$order_by = " ORDER BY E.PUBDATE DESC ";
$select = "SELECT E.*, I.TITLE AS TITLE,
I.DESCRIPTION AS DESCRIPTION, I.FLAG AS FLAG,
I.USER_ID AS USER_ID, II.USER_ID AS LAST_POSTER_ID,
U.USER_NAME AS USER_NAME, P.USER_NAME AS LAST_POSTER,
COALESCE(IIS.NUM_VIEWS, 0) AS NUM_VIEWS,
COALESCE(IIS.FUZZY_NUM_VIEWS, 0) AS FUZZY_NUM_VIEWS,
COALESCE(IIS.TMP_NUM_VIEWS, 0) AS TMP_NUM_VIEWS";
$sub_select = "SELECT DISTINCT MIN(GI.ID) AS ID,
MAX(GI.ID) AS LAST_ID,
COUNT(DISTINCT GI.ID) AS NUM_POSTS, GI.PARENT_ID AS PARENT_ID,
MIN(GI.GROUP_ID) AS GROUP_ID, MAX(GI.PUBDATE) AS PUBDATE,
MIN(G.OWNER_ID) AS OWNER_ID,
MIN(G.MEMBER_ACCESS) AS MEMBER_ACCESS,
MIN(G.GROUP_NAME) AS GROUP_NAME,
MIN(GI.PUBDATE) AS RECENT_DATE,
MIN(GI.TYPE) AS TYPE";
$sub_sql = "$sub_select
FROM GROUP_ITEM GI, SOCIAL_GROUPS G, USER_GROUP UG
$where $group_by";
$sql = "$select FROM ($sub_sql) E JOIN GROUP_ITEM I
ON E.ID = I.ID
JOIN GROUP_ITEM II ON E.LAST_ID = II.ID
JOIN USERS U ON I.USER_ID = U.USER_ID
JOIN USERS P ON II.USER_ID = P.USER_ID
LEFT OUTER JOIN ITEM_IMPRESSION_SUMMARY IIS ON
IIS.ITEM_ID = E.PARENT_ID AND IIS.ITEM_TYPE=" .
C\THREAD_IMPRESSION . " AND IIS.USER_ID=" .
C\PUBLIC_USER_ID . " AND
IIS.ITEM_ID = E.PARENT_ID AND
IIS.UPDATE_PERIOD=" . C\FOREVER .
" $outer_where $order_by $limit";
} else {
$where .= " AND P.USER_ID = GI.USER_ID ";
$select = "SELECT DISTINCT GI.ID AS ID,
GI.PARENT_ID AS PARENT_ID, GI.GROUP_ID AS GROUP_ID,
GI.TITLE AS TITLE, GI.DESCRIPTION AS DESCRIPTION,
GI.FLAG AS FLAG, GI.PUBDATE AS PUBDATE,
GI.EDIT_DATE AS EDIT_DATE, G.OWNER_ID AS OWNER_ID,
G.MEMBER_ACCESS AS MEMBER_ACCESS,
G.GROUP_NAME AS GROUP_NAME, P.USER_NAME AS USER_NAME,
P.USER_ID AS USER_ID, GI.TYPE AS TYPE, GI.UPS AS UPS,
GI.DOWNS AS DOWNS, G.VOTE_ACCESS AS VOTE_ACCESS ";
$sql = "$select
FROM GROUP_ITEM GI, SOCIAL_GROUPS G, USER_GROUP UG, USERS P
$where $order_by $limit";
}
$result = $db->execute($sql);
$i = 0;
$read_only = ($user_id == C\PUBLIC_GROUP_ID);
if ($read_only) {
while ($groups[$i] = $db->fetchArray($result)) {
$groups[$i]["MEMBER_ACCESS"] = C\GROUP_READ;
$i++;
}
} else {
while ($groups[$i] = $db->fetchArray($result)) {
$i++;
}
}
unset($groups[$i]); /* last one will be null */
$i = 0;
foreach ($groups as $group_key => $group_value) {
if ($this->isGroupEncrypted($group_value['GROUP_ID'])) {
$keys = $this->getGroupKey($group_value['GROUP_ID']);
$groups[$i]['TITLE'] = $this->decrypt(
$group_value['TITLE'], $keys);
$groups[$i]['DESCRIPTION'] = $this->decrypt(
$group_value['DESCRIPTION'], $keys);
}
$i++;
}
return $groups;
}
/**
* getGroupItemCount gets the number of group feed items visible to a user
* with $user_id and which match the supplied search criteria found in
* $search_array
* @param array $search_array each element of this is a quadruple name of a
* field, what comparison to perform, a value to check, and an order
* (ascending/descending) to sort by
* @param int $user_id who is making this request to determine which
* @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 the count
* desired is over the number of threads in that group
* @return int number of items matching the search criteria for the given
* user_id
*/
public function getGroupItemCount($search_array = [], $user_id = C\ROOT_ID,
$for_group = -1)
{
$db = $this->db;
$any_fields = ["access", "register"];
$is_thread = false;
foreach ($search_array as $search_item) {
if ($search_item[0] == 'parent_id') {
if ($search_item[2] > 0) {
$is_thread = true;
}
break;
}
}
list($where, $order_by) =
$this->searchArrayToWhereOrderClauses($search_array, $any_fields);
$add_where = " WHERE ";
if ($where != "") {
$add_where = " AND ";
}
$user_id = $db->escapeString($user_id);
if ($for_group > 0 || $for_group == -2) { /* -2 is just_thread case */
$non_public_where = " (UG.USER_ID='$user_id' OR ".
" G.REGISTER_TYPE IN ('".C\PUBLIC_JOIN."','".
C\PUBLIC_BROWSE_REQUEST_JOIN."') ) AND ";
if (!$is_thread) {
$non_public_where .=
"TYPE = " . C\STANDARD_GROUP_ITEM. " AND ";
}
} else {
$non_public_where = " UG.USER_ID='$user_id' AND ";
}
$non_public_status = ($user_id != C\PUBLIC_GROUP_ID) ?
" UG.STATUS='" . C\ACTIVE_STATUS."' AND " : "";
$where .= $add_where. $non_public_where .
"GI.USER_ID=O.USER_ID AND
GI.GROUP_ID=G.GROUP_ID AND GI.GROUP_ID=UG.GROUP_ID AND ((
$non_public_status
G.MEMBER_ACCESS IN ('".C\GROUP_READ."','".C\GROUP_READ_COMMENT.
"','".C\GROUP_READ_WRITE."', '" . C\GROUP_READ_WIKI . "')) OR
(G.OWNER_ID = UG.USER_ID OR UG.USER_ID = '".C\ROOT_ID."'))";
if ($for_group >= 0) {
$count_col = " COUNT(DISTINCT GI.PARENT_ID) ";
} else {
$count_col = " COUNT(DISTINCT GI.ID) ";
}
$sql = "SELECT $count_col AS NUM FROM GROUP_ITEM GI, SOCIAL_GROUPS G,
USER_GROUP UG, USERS O $where";
$result = $db->execute($sql);
$row = $db->fetchArray($result);
return $row['NUM'] ?? false;
}
/**
* getMostRecentGroupPost returns the most recent post posted to a group
* @param int $group_id id of the group to get the most recent post for
* @return array associate array of post details
*/
public function getMostRecentGroupPost($group_id)
{
$db = $this->db;
$sql = "SELECT MAX(GI.PUBDATE) AS PUBDATE
FROM GROUP_ITEM GI
WHERE GI.GROUP_ID = ?" .
$this->excludeGitIssueThreadsClause("GI");
$result = $db->execute($sql, [$group_id]);
if (empty($result)) {
return "";
}
$row = $db->fetchArray($result);
if (empty($row) || empty($row["PUBDATE"])) {
return "";
}
$sql = "SELECT DISTINCT GI.ID AS ID, GI.PARENT_ID AS PARENT_ID,
GI.GROUP_ID AS GROUP_ID, GI.TITLE AS TITLE,
GI.DESCRIPTION AS DESCRIPTION, GI.PUBDATE AS PUBDATE,
GI.EDIT_DATE AS EDIT_DATE, G.OWNER_ID AS OWNER_ID,
G.MEMBER_ACCESS AS MEMBER_ACCESS, G.GROUP_NAME AS GROUP_NAME,
P.USER_NAME AS USER_NAME, P.USER_ID AS USER_ID, GI.TYPE AS TYPE,
GI.UPS AS UPS, GI.DOWNS AS DOWNS, G.VOTE_ACCESS AS VOTE_ACCESS
FROM GROUP_ITEM GI, SOCIAL_GROUPS G, USERS P
WHERE GI.GROUP_ID = ? AND GI.GROUP_ID=G.GROUP_ID AND
GI.USER_ID = P.USER_ID AND GI.PUBDATE = ?" .
$this->excludeGitIssueThreadsClause("GI") . " " .
$db->limitOffset(0, 1);
$result = $db->execute($sql, [$group_id, $row['PUBDATE']]);
if (empty($result)) {
return "";
}
$row = $db->fetchArray($result);
if (!empty($group_id) &&
$this->isGroupEncrypted($group_id)) {
/* Decrypt group's title and description */
$key = $this->getGroupKey($group_id);
$row['TITLE'] = $this->decrypt($row['TITLE'], $key);
$row['DESCRIPTION'] = $this->decrypt($row['DESCRIPTION'], $key);
}
return $row;
}
/**
* getGroupThreadCount returns the number of distinct threads in a group's
* feed
* @param int $group_id id of the group to get thread count for
* @return int number of threads
*/
public function getGroupThreadCount($group_id)
{
$db = $this->db;
$sql = "SELECT COUNT(GI.PARENT_ID) AS NUM
FROM GROUP_ITEM GI
WHERE GI.GROUP_ID = ? AND GI.ID = GI.PARENT_ID" .
$this->excludeGitIssueThreadsClause("GI");
$result = $db->execute($sql, [$group_id]);
if (!$result) {
return 0;
}
$row = $db->fetchArray($result);
return $row['NUM'] ?? 0;
}
/**
* getUserPostCount returns the number of posts to groups that a user
* belongs to since a timestamp
* @param int $user_id id of the user to get posts for
* @param int $timestamp only post with value pubdate greater than this will
* be counted
* @return int number of posts
*/
public function getUserPostCount($user_id, $timestamp = 0)
{
$db = $this->db;
$subselect = "(SELECT GROUP_ID " .
"FROM USER_GROUP WHERE USER_ID = ? AND STATUS = ".
C\ACTIVE_STATUS . ")";
$sql = "SELECT COUNT(GI.ID) AS NUM FROM GROUP_ITEM GI ".
"WHERE GI.GROUP_ID IN $subselect AND GI.TITLE NOT LIKE ".
"? AND GI.PUBDATE > ?";
if (empty($user_id)) {
return 0;
}
$result = $db->execute($sql, [$user_id, "%$user_id%", $timestamp]);
if (!$result) {
return 0;
}
$row = $db->fetchArray($result);
return $row['NUM'] ?? 0;
}
/**
* getGroupPostCount returns the number of posts to a group
* @param int $group_id id of the group to get post count for
* @param int $timestamp only post with value pubdate greater or EDIT_DATE
* than this will be counted
* @return int number of posts
*/
public function getGroupPostCount($group_id, $timestamp = 0)
{
$db = $this->db;
$sql = "SELECT COUNT(GI.ID) AS NUM FROM GROUP_ITEM GI
WHERE GI.GROUP_ID = ? AND GI.PUBDATE > ?" .
$this->excludeGitIssueThreadsClause("GI");
$result = $db->execute($sql, [$group_id, $timestamp]);
if (!$result) {
return 0;
}
$row = $db->fetchArray($result);
return $row['NUM'] ?? 0;
}
/**
* excludeGitIssueThreadsClause gives a SQL fragment that leaves out the
* discussion threads belonging to git issue companion pages, whose titles
* carry the reserved issue separator, so those issues are not counted or
* shown as ordinary group posts in feeds, in a group's post and thread
* counts, or as a group's most recent post. The check that the thread is
* set keeps the NOT IN test from wrongly matching when a page has no
* thread.
* @param string $alias the table alias used for GROUP_ITEM in the query
* @return string the fragment to add to a WHERE clause
*/
private function excludeGitIssueThreadsClause($alias)
{
return " AND " . $alias . ".PARENT_ID NOT IN (SELECT DISCUSS_THREAD" .
" FROM GROUP_PAGE WHERE TITLE LIKE '%" . C\GIT_ISSUE_SEPARATOR .
"%' AND DISCUSS_THREAD IS NOT NULL)";
}
/**
* getFeedClipboardId get the id of the clipboard thread used for moving
* around group items for a given user.
* @param int $user_id id of user to get the clipboard thread for
* @return int id of thread
*/
public function getFeedClipboardId($user_id)
{
if (($clip_group_id = $this->getPersonalGroupId($user_id)) < 0) {
return false;
}
$clip_title = C\PERSONAL_GROUP_PREFIX . "_clipboard_" . $user_id;
$db = $this->db;
$sql = "SELECT PARENT_ID FROM GROUP_ITEM WHERE GROUP_ID=? AND " .
"TITLE=? " . $db->limitOffset(1);
$result = $db->execute($sql, [$clip_group_id, $clip_title]);
if (!empty($result)) {
$row = $db->fetchArray($result);
if (!empty($row)) {
return $row["PARENT_ID"];
}
}
return $this->addGroupItem(0, $clip_group_id, $user_id, $clip_title,
"start_feed_clip");
}
/**
* getMessagesThreadTitle messages use the same storage mechanism as group
* posts, so need a title, The title used is always computed as the `-`
* concatenation of the sorted id's of the participants in the message
* session. This method computes such a title string from an array of
* message participants.
* @param array $thread_follower_ids message chat participants
* @return string appropriate title of chat thread.
*/
public function getMessagesThreadTitle($thread_follower_ids)
{
sort($thread_follower_ids);
return implode("-", $thread_follower_ids);
}
/**
* getThreadPostCount returns the number of posts to a thread
* @param int $thread_id id of the thread to get post count for
* @param int $timestamp only post with value pubdate greater or EDIT_DATE
* than this will be counted
* @return int number of posts
*/
public function getThreadPostCount($thread_id, $timestamp = 0)
{
$db = $this->db;
$sql = "SELECT COUNT(GI.ID) AS NUM FROM GROUP_ITEM GI
WHERE GI.PARENT_ID = ? AND GI.PUBDATE > ?";
$result = $db->execute($sql, [$thread_id, $timestamp]);
if (!$result) {
return 0;
}
$row = $db->fetchArray($result);
return $row['NUM'] ?? 0;
}
/**
* cullExpiredGroupItems deletes Group Items which are older than the expiry
* date for posts for that group
*/
public function cullExpiredGroupItems()
{
$time = time();
/* nesting SELECTs to keep Mysql from giving a Error 1093 */
$sql = "DELETE FROM GROUP_ITEM WHERE ID IN (
SELECT ID FROM (
SELECT GI.ID AS ID FROM GROUP_ITEM GI, SOCIAL_GROUPS G
WHERE GI.GROUP_ID=G.GROUP_ID AND G.POST_LIFETIME > 0
AND ($time - GI.PUBDATE) > G.POST_LIFETIME) AS C)";
$this->db->execute($sql);
}
/**
* getPageInfoByThread returns the group_id, language, page name, last
* modified date of a wiki pagecorresponding to a page discussion thread
* with id $page_thread_id
* @param int $page_thread_id the id of a wiki page discussion thread to
* look up page info for
* @return array (group_id, language, and page name) of that wiki page
*/
public function getPageInfoByThread($page_thread_id)
{
$db = $this->db;
$sql = "SELECT GROUP_ID, LOCALE_TAG, TITLE AS PAGE_NAME,
LAST_MODIFIED FROM GROUP_PAGE WHERE DISCUSS_THREAD = ?";
$result = $db->execute($sql, [$page_thread_id]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
if (!$row) {
return false;
}
return $row;
}
/**
* copyThreadResources copies resources associated with a thread post in one
* group to the resource folders associated with a different group and
* thread id
* @param int $from_group_id group resources are coming from
* @param int $from_thread_id thread post to get resources from
* @param int $to_group_id group that we are copying to
* @param int $to_thread_id resource folder of this thread post will be
* copied to
*/
public function copyThreadResources($from_group_id, $from_thread_id,
$to_group_id, $to_thread_id)
{
$from_folders = $this->getGroupPageResourcesFolders($from_group_id,
"post" . $from_thread_id);
list($from_folder, $from_thumb_folder, $from_base_folder, ) =
$from_folders;
$to_folders = $this->getGroupPageResourcesFolders($to_group_id,
"post" . $to_thread_id, "", true);
list($to_folder, $to_thumb_folder, $to_base_folder, ) = $to_folders;
$this->db->copyRecursive($from_folder, $to_folder);
$this->db->copyRecursive($from_thumb_folder, $to_thumb_folder);
}
/**
* getGitIssueThread reads the discussion thread that holds an issue's
* comments: the id of the thread and every reply posted to it, in the order
* they were made. The thread's opening post is left out because the issue's
* description stands in for it on the detail page.
* @param int $group_id id of the group the issue belongs to
* @param string $page_name name of the git repository wiki page
* @param int $issue_number which issue on that page
* @param string $locale_tag language the page is written for
* @return array a pair of the thread id and a list of its reply posts, each
* with an id, a poster id and name, a time, and a body
*/
public function getGitIssueThread($group_id, $page_name, $issue_number,
$locale_tag)
{
$issue_page_name = $page_name . C\GIT_ISSUE_SEPARATOR .
$issue_number;
$info = $this->getPageInfoByName($group_id, $issue_page_name,
$locale_tag, "edit");
$thread_id = (int)($info["DISCUSS_THREAD"] ?? 0);
if ($thread_id <= 0) {
return [0, []];
}
$db = $this->db;
$sql = "SELECT GI.ID AS ID, GI.USER_ID AS USER_ID,
GI.PUBDATE AS PUBDATE, GI.DESCRIPTION AS DESCRIPTION,
U.USER_NAME AS USER_NAME
FROM GROUP_ITEM GI, USERS U
WHERE GI.PARENT_ID = ? AND GI.ID <> ? AND GI.USER_ID = U.USER_ID
ORDER BY GI.PUBDATE ASC";
$result = $db->execute($sql, [$thread_id, $thread_id]);
$posts = [];
while ($result && $row = $db->fetchArray($result)) {
$posts[] = $row;
}
return [$thread_id, $posts];
}
}