<?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 Eswara Rajesh Pinapala epinapala@live.com
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\controllers;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\mail\UnsubscribeToken;
/**
* Controller used mainly for handling JS requests for fragments of a web page.
* For example, Help Wiki Pages or search results to be used as part of
* continuous scroll
*
* @author Eswara Rajesh Pinapala
*/
class ApiController extends Controller implements CrawlConstants
{
/**
* Associative array of $components activities for this controller
* Components are collections of activities (a little like traits) which
* can be reused.
*
* @var array
*/
public static $component_activities = [ "social" => ["wiki"] ];
/**
* These are the activities supported by this controller
* @var array
*/
public $activities = ["summarize",
"unsubscribe"];
/**
* Used to process requests related to user group activities outside of
* the admin panel setting. This either could be because the admin panel
* is "collapsed" or because the request concerns a wiki page.
*
* @return mixed configureRequest result when no profile is
* configured; under HTTP this typically exits via
* displayView or redirectLocation without returning
*/
public function processRequest()
{
$data = [];
if (!C\PROFILE) {
return $this->configureRequest();
}
if (isset($_SESSION['USER_ID'])) {
if ($this->getCSRFTime(C\p('CSRF_TOKEN')) == 0 &&
$_SERVER['REQUEST_METHOD'] == "GET") {
$_REQUEST[C\p('CSRF_TOKEN')] = $this->generateCSRFToken(
$_SESSION['USER_ID']);
$this->redirectLocation(C\SHORT_BASE_URL . "?" .
http_build_query($_REQUEST));
exit();
}
$user_id = $_SESSION['USER_ID'];
$data['ADMIN'] = 1;
} else {
$user_id = L\remoteAddress();
}
$data['SCRIPT'] = "";
$token_okay = $this->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id);
$data = array_merge($data, $this->processSession());
if (isset($data["VIEW"])) {
$view = $data["VIEW"];
} else {
$view = 'api';
}
$_SESSION['REMOTE_ADDR'] = L\remoteAddress();
$this->displayView($view, $data);
}
/**
* Used to perform the actual activity call to be done by the
* api_controller.
* processSession is called from @see processRequest, which does some
* cleaning of fields if the CSRFToken is not valid. It is more likely
* that that api_controller may be involved in such requests as it can
* be invoked either when a user is logged in or not and for users with and
* without accounts. processSession makes sure the $_REQUEST'd activity is
* valid (or falls back to groupFeeds) then calls it. If someone uses
* the Settings link to change the language or default number of feed
* elements to view, this method sets up the $data variable so that
* the back/cancel button on that page works correctly.
*
* @return array $data field variables produced by the dispatched
* activity (one of "summarize" or the wiki fallback),
* augmented with PAGE_TITLE and ACTIVITY_METHOD
*/
public function processSession()
{
if (isset($_REQUEST['a']) &&
in_array($_REQUEST['a'], $this->activities)) {
$activity = $this->clean($_REQUEST['a'], "string");
} else {
$activity = "wiki";
}
$data = $this->call($activity);
$data['PAGE_TITLE'] = $data['PAGE_TITLE'] ??
$this->clean($_REQUEST['page_name'], "string");
$data['ACTIVITY_METHOD'] = $activity;
if (!is_array($data)) {
$data = [];
}
return $data;
}
/**
* Used to get the messages for a given thread id.
*
* @param string $thread_id The id of the thread to get messages for.
* @return array $messages containing the messages for the thread
*/
private function getMessages($thread_id)
{
$group_model = $this->model("group");
$user_id = isset($_SESSION['USER_ID']) ?
$_SESSION['USER_ID'] : C\PUBLIC_USER_ID;
$search_array = [ ["parent_id", "=", $thread_id, ""] ];
$limit = 0;
$results_per_page = C\MAX_SUMMARIZE_MESSAGES;
/* -2 is just_thread case */
$for_group = -2;
$sort = "ksort";
$messages = $group_model->getGroupItems($limit, $results_per_page,
$search_array, $user_id, $for_group);
return $messages;
}
/**
* Handles a request to summarize a thread using the LLM.
*
* Expected parameters:
* - thread_id: The id of the thread to summarize.
*
* @return array $data containing summary results and status
*/
public function summarize()
{
$data = [];
$data['ELEMENT'] = 'summarize';
if (empty($_REQUEST['thread_id'])) {
$data['ERRORS'] = ["Missing parameter - 'thread_id' required"];
return $data;
}
$thread_id = $this->clean($_REQUEST['thread_id'], "string");
$user_locale = L\getLocaleTag();
$messages = $this->getMessages($thread_id);
if (empty($messages)) {
$data['ERRORS'] = ["No messages found for thread id $thread_id"];
return $data;
}
$text = "";
foreach ($messages as $msg) {
$pretty_date = date("r", $msg['PUBDATE']);
$username = $msg['USER_NAME'];
$content = $msg['DESCRIPTION'];
$text .= "On {$pretty_date}, {$username} wrote: \"{$content}\"\n\n";
}
$target_language = $this->getLanguageFromLocale($user_locale);
$input_text = "\n<in>" . $text . "</in>\n";
$summarize_prompt = sprintf(
"Summarize the following thread concisely in %s, " .
"capturing the key points:%s IMPORTANT: You MUST output " .
"ONLY the summary between <out></out> tags. Do not include " .
"any other text, commentary, or explanations outside these " .
"tags.",
$target_language, $input_text);
$api_url = C\LLM_API_URL;
$llm_model = C\LLM_MODEL;
$request_body = [
"model" => $llm_model,
"messages" => [
["role" => "system", "content" => "You are a helpful assistant "
. "that summarizes threads concisely. You MUST always format "
. "your response with the summary inside <out></out> "
. "tags only. "
. "Never include any text outside these tags."],
["role" => "user", "content" => $summarize_prompt]
],
"temperature" => 0.1,
"max_tokens" => -1,
"stream" => false
];
$result = $this->sendLLMRequest($api_url, $request_body);
if (!$result) {
$data['ERRORS'] = ["Failed to connect to summarization service"];
} else {
$decoded = json_decode($result, true);
if (isset($decoded['choices'][0]['message']['content'])) {
$content = $decoded['choices'][0]['message']['content'];
preg_match('/<out>([\s\S]*?)<\/out>/', $content, $matches);
if (isset($matches[1])) {
$data['SUMMARY'] = trim($matches[1]);
$data['STATUS'] = 'success';
} else {
$data['ERRORS'] =
["Summary format not recognized - missing " .
"<out> tags"];
}
} else {
$data['ERRORS'] = ["Invalid response from LLM"];
}
}
return $data;
}
/**
* Maps Yioop locale tags to human-readable language names for LLM prompts
*
* @param string $locale_tag The Yioop locale tag (e.g., 'en_US', 'es')
* @return string The language name for LLM instruction
*/
private function getLanguageFromLocale($locale_tag)
{
$language_map = [
'ar' => 'Arabic',
'bn' => 'Bengali',
'de' => 'German',
'el_GR' => 'Greek',
'en_US' => 'English',
'es' => 'Spanish',
'fa' => 'Persian',
'fr_FR' => 'French',
'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' => 'Filipino',
'tr' => 'Turkish',
'vi_VN' => 'Vietnamese',
'zh_CN' => 'Chinese'
];
return isset($language_map[$locale_tag]) ?
$language_map[$locale_tag] : 'English';
}
/**
* Helper method to send API requests to LLM service
*
* @param string $url The API endpoint URL
* @param array $data The request data to send
* @return string|bool The response body or false on failure
*/
private function sendLLMRequest($url, $data)
{
$post_data = json_encode($data);
$headers = ['Content-Type: application/json'];
$response = L\FetchUrl::getPage($url, $post_data, true, null,
C\SINGLE_PAGE_TIMEOUT, $headers);
if ($response === false) {
error_log("LLM API request failed");
return false;
}
return $response;
}
/**
* Handles a request from an email recipient to stop receiving a
* group's mail. The link carries a signed token that names the user
* and the group. Without the one-click marker the endpoint shows a
* short confirmation page with a button, so that automated link
* scanners cannot unsubscribe someone merely by following the link;
* when the request carries the one-click marker (sent by the button
* the page shows, or by a mail client's one-click request) the
* group's mail is actually turned off for that user. The page itself
* is drawn by the unsubscribe view, not here.
*
* @return array fields the unsubscribe view draws: the state of the
* request (invalid link, confirm, or done), the group name, and
* the token to repeat on the confirm button
*/
public function unsubscribe()
{
$data = [];
$data['VIEW'] = "unsubscribe";
$token = $this->clean($_REQUEST['token'] ?? "", "string");
$data['UNSUBSCRIBE_TOKEN'] = $token;
$one_click = isset($_REQUEST['List-Unsubscribe']) &&
$_REQUEST['List-Unsubscribe'] === "One-Click";
$parsed = UnsubscribeToken::parse($token);
$email = ($parsed === false) ?
UnsubscribeToken::parseEmail($token) : false;
if ($parsed === false && $email === false) {
$data['UNSUBSCRIBE_STATE'] = "invalid";
return $data;
}
if ($parsed !== false) {
$data['UNSUBSCRIBE_SCOPE'] = "group";
$data['GROUP_NAME'] = $this->clean((string)$this->model("group")
->getGroupName($parsed['group_id']), "string");
} else {
$data['UNSUBSCRIBE_SCOPE'] = "all";
$data['UNSUBSCRIBE_EMAIL'] = $this->clean($email, "string");
}
if (!$one_click) {
$data['UNSUBSCRIBE_STATE'] = "confirm";
return $data;
}
if ($parsed !== false) {
$this->model("group")->setMailSubscription(
$parsed['user_id'], $parsed['group_id'], 0);
} else {
$this->model("mailSuppression")->suppress($email);
}
$data['UNSUBSCRIBE_STATE'] = "done";
return $data;
}
/**
* Helper method to return JSON response with proper headers
*
* @param array $data Response data to encode as JSON
*/
private function returnJsonResponse($data)
{
$parent = $this->parent;
$parent->web_site->header('Content-Type: application/json');
$parent->web_site->header('Cache-Control: no-cache, must-revalidate');
$parent->web_site->header('Expires: Sat, 26 Jul 1997 05:00:00 GMT');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
}
}