"may chieu dep". Used for keyword matching. */
function onduty_fold($s)
{
static $map = null;
if ($map === null) {
$groups = array(
'a' => 'àáạảãâầấậẩẫăằắặẳẵ',
'e' => 'èéẹẻẽêềếệểễ',
'i' => 'ìíịỉĩ',
'o' => 'òóọỏõôồốộổỗơờớợởỡ',
'u' => 'ùúụủũưừứựửữ',
'y' => 'ỳýỵỷỹ',
'd' => 'đ',
);
$map = array();
foreach ($groups as $base => $chars) {
$len = mb_strlen($chars, 'UTF-8');
for ($i = 0; $i < $len; $i++) {
$map[mb_substr($chars, $i, 1, 'UTF-8')] = $base;
}
}
}
return strtr(mb_strtolower($s, 'UTF-8'), $map);
}
/** HTML or messy text to one clean line, cut at $max characters on a word boundary. */
function onduty_text($s, $max = ONDUTY_MAX_BODY)
{
$s = onduty_utf8((string) $s);
$s = preg_replace('#<(script|style)[^>]*>.*?\1>#is', ' ', $s);
$s = preg_replace('#
|
||#i', '. ', $s);
$s = html_entity_decode(strip_tags($s), ENT_QUOTES, 'UTF-8');
$s = str_replace("\xC2\xA0", ' ', $s);
$s = trim(preg_replace('/\s+/u', ' ', $s));
$s = preg_replace('/(\.\s*){2,}/', '. ', $s);
if (mb_strlen($s, 'UTF-8') <= $max) {
return $s;
}
$cut = mb_substr($s, 0, $max - 1, 'UTF-8');
$space = mb_strrpos($cut, ' ', 0, 'UTF-8');
if ($space !== false && $space > $max * 0.6) {
$cut = mb_substr($cut, 0, $space, 'UTF-8');
}
return rtrim($cut, " ,.;:-") . '…';
}
/**
* The passage of a long text that best answers the question: the window of $max characters holding the most
* distinct search terms, started at a sentence boundary. A policy page is 8 KB; its first 600 characters rarely
* say how long the warranty is.
*/
function onduty_snippet($s, $terms, $max = ONDUTY_MAX_BODY)
{
// Work is bounded before anything else: 300 KB of input, 50 matches per word. Unbounded, one row holding
// "11 " six thousand times cost 36 million comparisons for a signed question "11" (Codex SEC-1).
$text = onduty_text(substr((string) $s, 0, 300000), 1000000);
$len = mb_strlen($text, 'UTF-8');
if ($len <= $max || !$terms) {
return onduty_text($text, $max);
}
$folded = onduty_fold($text); // same length in characters: every mapped letter is one letter
$words = array();
foreach ($terms as $t) {
$words[] = onduty_fold($t);
}
// Byte offsets in the folded text (nearly all ASCII once folded), converted to characters once at the end.
// mb_strpos with an offset rescans from the start every call.
$hits = array();
foreach ($words as $w) {
if ($w === '') {
continue;
}
$offset = 0;
$n = 0;
while ($n < 50 && ($p = strpos($folded, $w, $offset)) !== false) {
$hits[] = array($p, $w);
$offset = $p + 1;
$n++;
}
}
if (!$hits) {
return onduty_text($text, $max);
}
$bestByte = 0;
$bestScore = -1;
foreach ($hits as $h) {
$seen = array();
foreach ($hits as $o) {
if ($o[0] >= $h[0] && $o[0] < $h[0] + $max - 80) {
$seen[$o[1]] = true;
}
}
if (count($seen) > $bestScore) {
$bestScore = count($seen);
$bestByte = $h[0];
}
}
$best = mb_strlen(substr($folded, 0, $bestByte), 'UTF-8');
// back up to the start of the sentence, at most 150 characters
$from = max(0, $best - 150);
$before = mb_substr($text, $from, $best - $from, 'UTF-8');
$dot = mb_strrpos($before, '. ', 0, 'UTF-8');
$start = $dot === false ? $from : $from + $dot + 2;
$out = onduty_text(mb_substr($text, $start, $max + 200, 'UTF-8'), $max - 1);
return ($start > 0 ? '…' : '') . $out;
}
/** Vietnamese money: 1250000 -> "1.250.000đ". Zero or empty returns ''. */
function onduty_money($n)
{
$n = (float) $n;
if ($n <= 0) {
return '';
}
return number_format($n, 0, ',', '.') . 'đ';
}
/** Force valid UTF-8, so one bad byte in an old database row cannot make json_encode return false. */
function onduty_utf8($s)
{
if (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($s, 'UTF-8', 'UTF-8');
}
return $s;
}
/* ------------------------------------------------------------------------------------------------------------
* Config validation: the part that keeps private tables out, whatever a config author types
* ---------------------------------------------------------------------------------------------------------- */
function onduty_sensitive_pattern()
{
return '/(member|user|order|customer|client|admin|account|session|remember|contact|password|passwd|pass|pwd|token|secret|salt|otp|email_template|cart|payment|invoice)/i';
}
/** Returns an error string, or '' when the config is safe to run. */
function onduty_check_config($cfg)
{
if (empty($cfg['secret']) || strpos($cfg['secret'], '...') !== false || strlen($cfg['secret']) < 16) {
return 'secret is not set';
}
if (empty($cfg['site']) || !preg_match('#^https://#', $cfg['site'])) {
return 'site must be the https:// address of the website';
}
$allowed = isset($cfg['allow_names']) ? array_map('strtolower', $cfg['allow_names']) : array();
$sources = isset($cfg['sources']) ? $cfg['sources'] : array();
foreach ($sources as $i => $src) {
foreach (array('id', 'table', 'search', 'title') as $key) {
if (empty($src[$key])) {
return "source #$i has no '$key'";
}
}
$names = array($src['table'], onduty_id_col($src));
if (!empty($src['price_column'])) {
$names[] = $src['price_column'];
}
foreach (array('search', 'select', 'deep') as $key) {
if (!empty($src[$key])) {
$names = array_merge($names, array_keys(onduty_weighted($src[$key])));
}
}
if (!empty($src['where'])) {
$names = array_merge($names, array_keys($src['where']));
}
foreach ($names as $name) {
if (!preg_match('/^[A-Za-z0-9_]{1,64}$/', $name)) {
return "source '{$src['id']}': '$name' is not a plain table or column name";
}
if (preg_match(onduty_sensitive_pattern(), $name) && !in_array(strtolower($name), $allowed, true)) {
return "source '{$src['id']}': '$name' looks private and is refused (list it in allow_names only if every row is public)";
}
}
}
return '';
}
/** array('name' => 3, 'description') -> array('name' => 3, 'description' => 1) */
function onduty_weighted($cols)
{
$out = array();
foreach ($cols as $k => $v) {
if (is_int($k)) {
$out[$v] = 1;
} else {
$out[$k] = (int) $v;
}
}
return $out;
}
/* ------------------------------------------------------------------------------------------------------------
* Question -> search terms
* ---------------------------------------------------------------------------------------------------------- */
function onduty_stopwords()
{
return array(
// Vietnamese, accented: question words, pronouns, particles. "giá", "bán", "mua" say what kind of
// answer is wanted but match nearly every product row, so they are intent, not search terms.
'có', 'không', 'ko', 'hông', 'bao', 'nhiêu', 'mấy', 'giá', 'là', 'cho', 'mình', 'em', 'anh', 'chị',
'ạ', 'à', 'ah', 'ha', 'nhé', 'nha', 'với', 'và', 'thì', 'ở', 'của', 'này', 'kia', 'đó', 'đâu', 'thế',
'nào', 'gì', 'ai', 'cần', 'muốn', 'hỏi', 'xin', 'vui', 'lòng', 'bạn', 'shop', 'bên', 'cái', 'loại', 'được',
'khi', 'sẽ', 'đã', 'đang', 'một', 'các', 'những', 'còn', 'hàng', 'bán', 'mua', 'tiền', 'nhiêu', 'vậy',
'sao', 'ơi', 'giúp', 'tư', 'vấn', 'hiện', 'tại', 'bây', 'giờ', 'nay', 'nữa', 'ra', 'lên', 'về', 'tôi',
'mọi', 'người', 'hả', 'hem', 'dc', 'đc', 'k', 'j', 'z', 'vs', 'thôi', 'rồi', 'lắm', 'quá', 'rất',
// price intent: handled by onduty_wants_cheap() and onduty_price_range(), never matched as text
'rẻ', 'nhất', 'hơn', 'dưới', 'trên', 'khoảng', 'tầm', 'từ', 'đến', 'tới', 'triệu', 'tr', 'nghìn',
'ngàn', 'đồng', 'vnđ', 'vnd', 'nhiêu', 'mắc', 'đắt',
// unaccented forms that are never a product word
'khong', 'bao', 'nhieu', 'cho', 'minh', 'chi', 'nhe', 'voi', 'thi', 'cua', 'nay', 'dau', 'nao', 'gi',
'can', 'muon', 'hoi', 'duoc', 'nhung', 'con', 'hang', 'tien', 'vay', 'giup', 'hien', 'gio',
// English
'do', 'you', 'have', 'the', 'a', 'an', 'is', 'are', 'what', 'how', 'much', 'many', 'price', 'for', 'of',
'any', 'can', 'i', 'me', 'my', 'to', 'in', 'on', 'it', 'this', 'that', 'please', 'sell', 'buy', 'cost',
);
}
/**
* Words worth searching for, in the order asked, at most $max. Each term carries its LIKE variants: a word typed
* with "d" also tries "đ", because MySQL's utf8_general_ci folds "á" to "a" but never "đ" to "d".
*/
function onduty_terms($question, $max = 8, $extraStop = array())
{
$q = mb_strtolower(onduty_utf8($question), 'UTF-8');
$q = str_replace(array('[số điện thoại]', '[email]'), ' ', $q);
$q = preg_replace('/[^\p{L}\p{N}\s\.\-]+/u', ' ', $q);
$stop = array_flip(array_merge(onduty_stopwords(), array_map('mb_strtolower', $extraStop)));
$terms = array();
foreach (preg_split('/\s+/u', trim($q)) as $w) {
$w = trim($w, '.-');
if ($w === '' || isset($stop[$w]) || mb_strlen($w, 'UTF-8') < 2 && !ctype_digit($w)) {
continue;
}
if (isset($terms[$w])) {
continue;
}
$variants = array($w);
if (strpos($w, 'd') !== false && strpos($w, 'đ') === false) {
$variants[] = str_replace('d', 'đ', $w);
}
// Older editors (TinyMCE, CKEditor with named entities) stored "hành" as "hành": the Latin-1
// letters become entities, the Vietnamese-only ones (ư, ạ, ệ) stay raw. htmlentities() makes the same
// split, so the encoded form is one more variant.
foreach ($variants as $v) {
$enc = htmlentities($v, ENT_QUOTES, 'UTF-8');
if ($enc !== $v && !in_array($enc, $variants, true)) {
$variants[] = $enc;
}
}
$terms[$w] = $variants;
if (count($terms) >= $max) {
break;
}
}
return $terms;
}
/** Wants the cheapest first? */
function onduty_wants_cheap($question)
{
return (bool) preg_match('/\b(re|re nhat|gia re|re hon|cheap|cheaper|cheapest|thap nhat)\b/', onduty_fold($question));
}
/** "10", "1,5", "10.000.000" with a unit ("triệu", "tr", "k", "nghìn", "đ") -> amount in đồng. */
function onduty_amount($num, $unit)
{
$unit = trim($unit);
if (preg_match('/^\d{1,3}(\.\d{3})+$/', $num)) {
$n = (float) str_replace('.', '', $num);
} else {
$n = (float) str_replace(',', '.', $num);
}
if (preg_match('/^(trieu|tr|cu|million)$/', $unit)) {
return $n * 1000000;
}
if (preg_match('/^(nghin|ngan|k)$/', $unit)) {
return $n * 1000;
}
return $n; // "đ", "dong", "vnd", or a full number
}
/**
* A price range asked for in the question: "dưới 10 triệu", "trên 20tr", "từ 5 đến 8 triệu", "5-8tr",
* "khoảng 10 triệu", "under 5 million". Returns array(min, max) in đồng (either may be null), or null.
*/
function onduty_price_range($question)
{
$q = onduty_fold($question);
$n = '(\d+(?:[\.,]\d+)*)';
$u = '\s*(trieu|tr|cu|million|nghin|ngan|k|d|dong|vnd)\b';
if (preg_match("/(?:tu\s*)?$n(?:$u)?\s*(?:den|toi|-|~)\s*$n$u/", $q, $m)) {
$unit = isset($m[2]) && $m[2] !== '' ? $m[2] : $m[4];
return array(onduty_amount($m[1], $unit), onduty_amount($m[3], $m[4]));
}
if (preg_match("/(?:re hon|thap hon|duoi|nho hon|it hon|khong qua|toi da|under|below|less than|cheaper than)\s*$n$u/", $q, $m)) {
return array(null, onduty_amount($m[1], $m[2]));
}
if (preg_match("/(?:tren|lon hon|hon|tu|toi thieu|over|above|more than)\s*$n$u/", $q, $m)) {
return array(onduty_amount($m[1], $m[2]), null);
}
if (preg_match("/(?:khoang|tam|co|around|about)\s*$n$u/", $q, $m)) {
$v = onduty_amount($m[1], $m[2]);
return array($v * 0.8, $v * 1.2);
}
return null;
}
/** The question with its price phrase removed, so "10" and "triệu" are not searched for as text. */
function onduty_strip_price($question)
{
$n = '\d+(?:[\.,]\d+)*';
$u = '\s*(?:triệu|trieu|tr|củ|million|nghìn|ngàn|nghin|ngan|k|đ|đồng|dong|vnđ|vnd)(?![\p{L}\p{N}])';
return preg_replace("/$n(?:$u)?\s*(?:đến|den|tới|toi|-|~)\s*$n$u|$n$u/iu", ' ', $question);
}
function onduty_like($s)
{
return '%' . str_replace(array('\\', '%', '_'), array('\\\\', '\\%', '\\_'), $s) . '%';
}
/* ------------------------------------------------------------------------------------------------------------
* Database
* ---------------------------------------------------------------------------------------------------------- */
/** Runs a prepared SELECT and returns rows as associative arrays. Works with and without mysqlnd. */
function onduty_select($db, $sql, $params)
{
$stmt = $db->prepare($sql);
if (!$stmt) {
throw new Exception('prepare failed: ' . $db->error);
}
if ($params) {
$types = str_repeat('s', count($params));
$refs = array($types);
foreach ($params as $k => $v) {
$params[$k] = (string) $v;
$refs[] = &$params[$k];
}
call_user_func_array(array($stmt, 'bind_param'), $refs);
}
if (!$stmt->execute()) {
throw new Exception('execute failed: ' . $stmt->error);
}
$rows = array();
if (method_exists($stmt, 'get_result')) {
$res = $stmt->get_result();
if ($res) {
while ($r = $res->fetch_assoc()) {
$rows[] = $r;
}
$stmt->close();
return $rows;
}
}
$meta = $stmt->result_metadata();
$row = array();
$bind = array();
foreach ($meta->fetch_fields() as $f) {
$row[$f->name] = null;
$bind[] = &$row[$f->name];
}
call_user_func_array(array($stmt, 'bind_result'), $bind);
while ($stmt->fetch()) {
$copy = array();
foreach ($row as $k => $v) {
$copy[$k] = $v;
}
$rows[] = $copy;
}
$stmt->close();
return $rows;
}
function onduty_q($name)
{
return '`' . $name . '`';
}
/** Timing for a signed request carrying `debug: true`: where the milliseconds went, per source and pass. */
function onduty_timing($key = null, $ms = null)
{
static $t = array();
if ($key === false) {
$t = array(); // reset, for tests and long-running callers
return $t;
}
if ($key !== null) {
$t[$key] = isset($t[$key]) ? $t[$key] + $ms : $ms;
}
return $t;
}
function onduty_ms($since)
{
return round((microtime(true) - $since) * 1000, 1);
}
/** Columns searched in a pass: `search` always, plus `deep` (long text such as specs or article bodies) only in
the second pass. LIKE over kilobytes of text per row is what made a real shop answer in 900 ms instead of 90. */
function onduty_search_cols($src, $deep)
{
$cols = onduty_weighted($src['search']);
if ($deep && !empty($src['deep'])) {
foreach (onduty_weighted($src['deep']) as $c => $w) {
if (!isset($cols[$c])) {
$cols[$c] = $w;
}
}
}
return $cols;
}
/** The primary key column of a source: 'id' unless the table calls it something else ('news_id'). */
function onduty_id_col($src)
{
return !empty($src['id_column']) ? $src['id_column'] : 'id';
}
/** The WHERE fragment for a source's fixed conditions, e.g. array('bl_active' => 1). */
function onduty_fixed_where($src, &$params)
{
$parts = array();
if (!empty($src['where'])) {
foreach ($src['where'] as $col => $val) {
$parts[] = onduty_q($col) . ' = ?';
$params[] = $val;
}
}
return $parts;
}
/** The SELECT list: the primary key always comes back as `id`, so templates and callers never care what the
table calls it. */
function onduty_select_list($src)
{
$idCol = onduty_id_col($src);
$cols = array();
if (!empty($src['select'])) {
$cols = array_keys(onduty_weighted($src['select']));
}
$cols = array_values(array_unique(array_merge($cols, array_keys(onduty_search_cols($src, true)))));
$list = array(onduty_q($idCol) . ' AS `id`');
foreach ($cols as $c) {
if ($c !== 'id' && $c !== $idCol) {
$list[] = onduty_q($c);
}
}
return implode(', ', $list);
}
/** A byte that belongs to a word: an ASCII letter or digit, or any byte of a multibyte character (every
multibyte character left in plain text is a letter; onduty_plain() turns Unicode spaces and punctuation into
ASCII spaces first). */
function onduty_wordbyte($c)
{
$o = ord($c);
return $o >= 0x80 || ($o >= 48 && $o <= 57) || ($o >= 97 && $o <= 122) || ($o >= 65 && $o <= 90);
}
/**
* Does $word occur in the text as a whole word? Accented words match accented text exactly ("số" is not "sở"),
* unaccented ones match either way ("so" finds "số"). Words with a digit may match inside a longer token, so
* "vw355" finds "PT-VW355NZ". Byte checks around strpos instead of a /u regex, and each word prepared once:
* on PHP 5.6 a /u regex re-validates the whole subject on every call and strtr() rebuilds its table, which
* together cost 600 ms per question against 688 rows of specs.
*/
function onduty_has_word($lower, $folded, $word)
{
static $prep = array();
if (!isset($prep[$word])) {
$f = onduty_fold($word);
$l = mb_strtolower($word, 'UTF-8');
$mark = $f !== $l;
$prep[$word] = array($mark, $mark ? $l : $f, (bool) preg_match('/\d/', $f));
}
list($mark, $needle, $digit) = $prep[$word];
$hay = $mark ? $lower : $folded;
$len = strlen($needle);
if ($len === 0) {
return false;
}
$n = strlen($hay);
$off = 0;
$tries = 0;
while ($tries++ < 200 && ($p = strpos($hay, $needle, $off)) !== false) {
if ($digit) {
return true;
}
$before = $p > 0 ? $hay[$p - 1] : ' ';
$after = $p + $len < $n ? $hay[$p + $len] : ' ';
if (!onduty_wordbyte($before) && !onduty_wordbyte($after)) {
return true;
}
$off = $p + 1;
}
return false;
}
/** Text as the coverage rule reads it: tags stripped, entities decoded, whitespace collapsed, lowercase. */
function onduty_plain($s)
{
$s = html_entity_decode(strip_tags((string) $s), ENT_QUOTES, 'UTF-8');
// Unicode spaces, dashes, quotes, bullets and ellipses become ASCII spaces, so onduty_wordbyte() can treat
// every remaining multibyte character as a letter.
$s = preg_replace('/[\x{00A0}\x{00AB}\x{00B7}\x{00BB}\x{2000}-\x{200B}\x{2010}-\x{2027}\x{2030}-\x{205E}\x{3000}]/u', ' ', $s);
return mb_strtolower(trim(preg_replace('/\s+/u', ' ', $s)), 'UTF-8');
}
/**
* The coverage rule, shared by the SQL path and the index path. A row must cover all of a one- or two-word
* question and 60% of a longer one. Two words typed side by side are usually ONE Vietnamese word ("xe máy",
* "loa kéo", "bảo hành"): the row must hold them together, not "gửi xe" in one paragraph and "máy chiếu" in
* another. Skipped for model numbers and for words borrowed from the previous question.
*/
function onduty_cover_rule($terms, $weights)
{
$n = count($terms);
$need = $n <= 2 ? $n : (int) ceil($n * 0.6);
$pair = '';
$keys = array_keys($terms);
if ($n === 2 && !preg_match('/\d/', implode('', $keys))
&& (!$weights || (isset($weights[$keys[0]], $weights[$keys[1]]) && $weights[$keys[0]] == 2 && $weights[$keys[1]] == 2))) {
$pair = $keys[0] . ' ' . $keys[1];
}
return array($need, $pair);
}
/** Distinct terms one row covers, or -1 when it fails the rule. $requireName: at least one term in the name. */
function onduty_cover_hits($lower, $folded, $nameLower, $nameFolded, $terms, $need, $pair, $requireName)
{
$hits = 0;
$nameHits = 0;
foreach (array_keys($terms) as $t) {
$t = (string) $t;
if (onduty_has_word($lower, $folded, $t)) {
$hits++;
if ($requireName && onduty_has_word($nameLower, $nameFolded, $t)) {
$nameHits++;
}
}
}
if ($hits < $need || ($requireName && $nameHits < 1)) {
return -1;
}
if ($pair !== '' && !onduty_has_word($lower, $folded, $pair)) {
return -1;
}
return $hits;
}
/**
* Keeps the rows that actually answer the question. SQL LIKE under utf8_general_ci matches substrings with the
* accents folded, so "số" matches "Epson" and "xe" matches "NEXEDGE": fine for ranking, useless as a gate.
* Here every term is checked as a word, by the rule above.
*/
function onduty_covering($rows, $src, $terms, $requireName, $weights = array(), $deep = false)
{
if (!$terms) {
return $rows;
}
list($need, $pair) = onduty_cover_rule($terms, $weights);
$cols = array_keys(onduty_search_cols($src, $deep));
$out = array();
foreach ($rows as $r) {
$all = '';
foreach ($cols as $c) {
$all .= ' ' . (isset($r[$c]) ? $r[$c] : '');
}
$lower = onduty_plain($all);
$name = onduty_plain(isset($r[$cols[0]]) ? $r[$cols[0]] : '');
if (onduty_cover_hits($lower, onduty_fold($lower), $name, onduty_fold($name), $terms, $need, $pair, $requireName) >= 0) {
$out[] = $r;
}
}
return $out;
}
/* ------------------------------------------------------------------------------------------------------------
* The deep index: long text searched in PHP instead of by LIKE
*
* On a shared MariaDB, LIKE over kilobytes of specs per row cost 360 to 730 ms per question, and decoding that
* text again for the coverage rule another 300 to 450. The index does both once: plain and folded text of every
* row's search and deep columns, written next to the kit (cache_dir), rebuilt after cache_ttl seconds AFTER the
* answer has been sent. It holds only columns the config already publishes.
*
* serialize(), not JSON: on PHP 5.6 json_decode() of the 2.6 MB product index took 97 ms, unserialize() 4 ms.
* Every string stored is lowercase, so an uppercase object token ("O:" or "C:") can only mean a tampered file,
* which is refused before unserialize() sees it; PHP 7+ also gets allowed_classes => false.
* ---------------------------------------------------------------------------------------------------------- */
function onduty_index_path($cfg, $src, $suffix = '')
{
$dir = isset($cfg['cache_dir']) ? $cfg['cache_dir'] : __DIR__;
// ".txt" so the kit's .htaccess denies it like every other text file
return rtrim($dir, '/') . '/onduty-cache-' . preg_replace('/[^A-Za-z0-9_-]/', '', $src['id']) . $suffix . '.txt';
}
function onduty_index_on($cfg)
{
return $cfg !== null && !(isset($cfg['cache']) && $cfg['cache'] === false);
}
function onduty_index_load($cfg, $src)
{
static $loaded = array();
$path = onduty_index_path($cfg, $src);
if (array_key_exists($path, $loaded)) {
return $loaded[$path];
}
$d = null;
if (is_file($path)) {
$raw = (string) file_get_contents($path);
if ($raw !== '' && strpos($raw, 'O:') === false && strpos($raw, 'C:') === false) {
// The options argument only runs on PHP 7+, behind the version check; PHP 5.6 relies on the token guard above.
// phpcs:ignore PHPCompatibility.FunctionUse.NewFunctionParameters.unserialize_optionsFound
$d = PHP_VERSION_ID >= 70000 ? @unserialize($raw, array('allowed_classes' => false)) : @unserialize($raw);
}
}
$ok = is_array($d) && isset($d['v'], $d['built'], $d['rows']) && $d['v'] === 2 && is_array($d['rows']);
return $loaded[$path] = $ok ? $d : null;
}
function onduty_index_build($cfg, $db, $src)
{
$lock = @fopen(onduty_index_path($cfg, $src, '-lock'), 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
return false; // another request is building it
}
$params = array();
$where = onduty_fixed_where($src, $params);
$cols = array_keys(onduty_search_cols($src, true));
$sel = array(onduty_q(onduty_id_col($src)) . ' AS `id`');
foreach ($cols as $c) {
if ($c !== 'id') {
$sel[] = onduty_q($c);
}
}
$sql = 'SELECT ' . implode(', ', $sel) . ' FROM ' . onduty_q($src['table']) . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
$rows = array();
foreach (onduty_select($db, $sql, $params) as $r) {
$all = '';
foreach ($cols as $c) {
$all .= ' ' . substr((string) $r[$c], 0, 60000);
}
$lower = onduty_plain(onduty_utf8($all));
$name = onduty_plain(onduty_utf8((string) $r[$cols[0]]));
$rows[(string) $r['id']] = array($lower, onduty_fold($lower), $name, onduty_fold($name));
}
$path = onduty_index_path($cfg, $src);
$tmp = onduty_index_path($cfg, $src, '-tmp' . getmypid());
$data = serialize(array('v' => 2, 'built' => time(), 'rows' => $rows));
$ok = file_put_contents($tmp, $data) !== false && @chmod($tmp, 0640) && @rename($tmp, $path);
if (!$ok) {
@unlink($tmp);
error_log('onduty-connector: could not write ' . $path);
}
flock($lock, LOCK_UN);
fclose($lock);
return $ok;
}
/** Work to run after the answer has left: index rebuilds. Keyed, so a source is rebuilt once per request. */
function onduty_defer($key = null, $job = null)
{
static $jobs = array();
if ($key !== null) {
$jobs[$key] = $job;
}
return $jobs;
}
/**
* Deep candidates from the index, scored the way the SQL path scores: a term in the name counts the name's
* weight, a term anywhere counts 1, both times the term's weight; the phrase in the name, and the name starting
* with it, add the same bonuses. Counting hits alone ranked "Cho thuê màn hình LED" above "Thuê âm thanh" for
* a question about sound systems.
*/
function onduty_search_index($db, $src, $idx, $terms, $weights, $range, $requireName, $limit, $phrase = '')
{
list($need, $pair) = onduty_cover_rule($terms, $weights);
$search = onduty_weighted($src['search']);
$keys = array_keys($search);
$nameW = $search[$keys[0]];
$fphrase = $phrase !== '' ? onduty_fold($phrase) : '';
$hits = array();
foreach ($idx['rows'] as $id => $t) {
if (!is_array($t) || !isset($t[3]) || !is_string($t[0]) || !is_string($t[1]) || !is_string($t[2]) || !is_string($t[3])) {
continue; // a damaged entry is skipped, never fatal
}
if (onduty_cover_hits($t[0], $t[1], $t[2], $t[3], $terms, $need, $pair, $requireName) < 0) {
continue;
}
$score = 0;
foreach (array_keys($terms) as $term) {
$w = isset($weights[$term]) ? $weights[$term] : 2;
if (onduty_has_word($t[2], $t[3], (string) $term)) {
$score += $nameW * $w;
}
$score += $w; // every passing row holds each counted term somewhere; the rule already checked
}
if ($fphrase !== '') {
$p = strpos($t[3], $fphrase);
if ($p !== false) {
$score += 4 * $nameW + ($p === 0 ? 4 * $nameW : 0);
}
}
$hits[] = array((int) $id, $score);
}
if (!$hits) {
return array();
}
usort($hits, function ($a, $b) {
if ($a[1] !== $b[1]) {
return $b[1] - $a[1];
}
return $b[0] - $a[0];
});
$hits = array_slice($hits, 0, $limit);
$rank = array();
foreach ($hits as $i => $h) {
$rank[$h[0]] = array($i, $h[1]);
}
$params = array();
$where = onduty_fixed_where($src, $params);
$where[] = onduty_q(onduty_id_col($src)) . ' IN (' . implode(', ', array_fill(0, count($rank), '?')) . ')';
foreach (array_keys($rank) as $id) {
$params[] = $id;
}
$priceCol = !empty($src['price_column']) ? $src['price_column'] : '';
if ($range) {
onduty_range_where($priceCol, $range, $where, $params);
}
$sql = 'SELECT ' . onduty_select_list($src) . ($priceCol !== '' ? ', ' . onduty_q($priceCol) . ' AS `_price`' : '')
. ' FROM ' . onduty_q($src['table']) . ' WHERE ' . implode(' AND ', $where);
$rows = onduty_select($db, $sql, $params);
foreach ($rows as $k => $r) {
$rows[$k]['score'] = $rank[(int) $r['id']][1];
}
usort($rows, function ($a, $b) use ($rank) {
return $rank[(int) $a['id']][0] - $rank[(int) $b['id']][0];
});
return $rows;
}
/** The price filter, one place for the SQL path and the index path. */
function onduty_range_where($priceCol, $range, &$where, &$params)
{
$where[] = onduty_q($priceCol) . ' > 0';
if ($range[0] !== null) {
$where[] = onduty_q($priceCol) . ' >= ?';
$params[] = (string) round($range[0]);
}
if ($range[1] !== null) {
$where[] = onduty_q($priceCol) . ' <= ?';
$params[] = (string) round($range[1]);
}
}
/**
* Rows of one source ranked against the terms. Score = sum over terms of (term weight x column weight) for
* every column containing the term, plus a bonus when the whole phrase appears in the best column.
*/
function onduty_search_source($db, $src, $terms, $weights, $phrase, $cheapFirst, $range = null, $requireName = false, $deep = false, $cfg = null)
{
$priceCol = !empty($src['price_column']) ? $src['price_column'] : '';
if ($range && $priceCol === '') {
return array(); // a price question says nothing about a source without prices
}
$cheap = $cheapFirst && $priceCol !== '';
if ($deep && onduty_index_on($cfg)) {
$t0 = microtime(true);
$idx = onduty_index_load($cfg, $src);
$ttl = isset($cfg['cache_ttl']) ? (int) $cfg['cache_ttl'] : 3600;
if ($idx === null || time() - $idx['built'] > $ttl) {
onduty_defer(onduty_index_path($cfg, $src), function () use ($cfg, $db, $src) {
onduty_index_build($cfg, $db, $src);
});
}
if ($idx !== null) {
$rows = onduty_search_index($db, $src, $idx, $terms, $weights, $range, $requireName, $cheap ? 40 : 3 * ONDUTY_MAX_FACTS, $phrase);
onduty_timing($src['id'] . ':index_ms', onduty_ms($t0));
return $cheap ? onduty_cheapest($rows) : array_slice($rows, 0, ONDUTY_MAX_FACTS);
}
// no index yet: this one question pays for LIKE, the index is built after it is answered
}
$search = onduty_search_cols($src, $deep);
$params = array();
$score = array();
$any = array();
foreach ($terms as $key => $variants) {
foreach ($search as $col => $w) {
$likes = array();
foreach ($variants as $v) {
$likes[] = onduty_q($col) . ' LIKE ?';
$params[] = onduty_like($v);
}
$score[] = '(' . implode(' OR ', $likes) . ') * ' . ($w * $weights[$key]);
}
}
// The first search column is the one a phrase match counts in (the name, usually). array_keys, not key():
// PHP 5's foreach moves the internal pointer.
$keys = array_keys($search);
$best = $keys[0];
if ($phrase !== '') {
$score[] = '(' . onduty_q($best) . ' LIKE ?) * ' . (4 * max($search));
$params[] = onduty_like($phrase);
// A name that starts with the phrase IS that kind of thing ("Máy chiếu Sony ..."); one that merely
// contains it is often an accessory ("Giá treo máy chiếu").
$score[] = '(' . onduty_q($best) . ' LIKE ?) * ' . (4 * max($search));
$params[] = substr(onduty_like($phrase), 1);
}
// Placeholders are positional: the fixed conditions come first in the WHERE, so their values go first.
$where = onduty_fixed_where($src, $params);
foreach ($terms as $variants) {
foreach ($search as $col => $w) {
foreach ($variants as $v) {
$any[] = onduty_q($col) . ' LIKE ?';
$params[] = onduty_like($v);
}
}
}
$where[] = '(' . implode(' OR ', $any) . ')';
if ($range) {
onduty_range_where($priceCol, $range, $where, $params);
}
$sql = 'SELECT ' . onduty_select_list($src) . ($priceCol !== '' ? ', ' . onduty_q($priceCol) . ' AS `_price`' : '')
. ', (' . implode(' + ', $score) . ') AS score FROM ' . onduty_q($src['table'])
. ' WHERE ' . implode(' AND ', $where) . ' ORDER BY score DESC, ' . onduty_q(onduty_id_col($src)) . ' DESC LIMIT '
. ($cheap ? 40 : 3 * ONDUTY_MAX_FACTS);
$t0 = microtime(true);
$fetched = onduty_select($db, $sql, $params);
$label = $src['id'] . ($deep ? ':deep' : '');
onduty_timing($label . ':sql_ms', onduty_ms($t0));
onduty_timing($label . ':rows', count($fetched));
$t0 = microtime(true);
$rows = onduty_covering($fetched, $src, $terms, $requireName, $weights, $deep);
onduty_timing($label . ':php_ms', onduty_ms($t0));
return $cheap ? onduty_cheapest($rows) : array_slice($rows, 0, ONDUTY_MAX_FACTS);
}
/** "Cheapest" among the rows that are actually about the question: ordering every row that shares one word by
price answers "máy chiếu rẻ nhất" with an HDMI cable. Rows arrive best first. */
function onduty_cheapest($rows)
{
if (!$rows) {
return $rows;
}
$top = (float) $rows[0]['score'];
$keep = array();
foreach ($rows as $r) {
if ((float) $r['score'] >= $top * 0.75) {
$keep[] = $r;
}
}
usort($keep, function ($a, $b) {
$pa = (float) $a['_price'];
$pb = (float) $b['_price'];
if (($pa > 0) !== ($pb > 0)) {
return $pa > 0 ? -1 : 1; // "giá liên hệ" last
}
if ($pa == $pb) {
return 0;
}
return $pa < $pb ? -1 : 1;
});
return array_slice($keep, 0, ONDUTY_MAX_FACTS);
}
/** One row by id, for the page the visitor is looking at. */
function onduty_row_by_id($db, $src, $id)
{
$params = array();
$where = onduty_fixed_where($src, $params);
$where[] = onduty_q(onduty_id_col($src)) . ' = ?';
$params[] = $id;
$rows = onduty_select($db, 'SELECT ' . onduty_select_list($src) . ' FROM ' . onduty_q($src['table']) . ' WHERE ' . implode(' AND ', $where) . ' LIMIT 1', $params);
return $rows ? $rows[0] : null;
}
/* ------------------------------------------------------------------------------------------------------------
* Rows -> facts
* ---------------------------------------------------------------------------------------------------------- */
/** "{name}" style template, or a function($row, $terms) returning a string. $terms are the words searched
for, which a function can hand to onduty_snippet() to quote the relevant part of a long text. */
function onduty_render($tpl, $row, $max, $terms = array())
{
if (is_callable($tpl) && !is_string($tpl)) {
$s = call_user_func($tpl, $row, $terms);
} else {
$s = preg_replace_callback('/\{([A-Za-z0-9_]+)\}/', function ($m) use ($row) {
return isset($row[$m[1]]) ? (string) $row[$m[1]] : '';
}, (string) $tpl);
}
return onduty_text($s, $max);
}
function onduty_url($cfg, $src, $row)
{
if (empty($src['url'])) {
return '';
}
if (is_callable($src['url']) && !is_string($src['url'])) {
// A function for sites whose addresses are not one pattern (static pages mapped by code).
$path = (string) call_user_func($src['url'], $row);
} else {
$path = preg_replace_callback('/\{([A-Za-z0-9_]+)\}/', function ($m) use ($row) {
return isset($row[$m[1]]) ? rawurlencode((string) $row[$m[1]]) : '';
}, $src['url']);
}
if ($path === '') {
return '';
}
return rtrim($cfg['site'], '/') . '/' . ltrim($path, '/');
}
function onduty_fact($cfg, $src, $row, $terms = array())
{
$body = isset($src['body']) ? onduty_render($src['body'], $row, ONDUTY_MAX_BODY, $terms) : '';
$fact = array(
'title' => onduty_render($src['title'], $row, ONDUTY_MAX_TITLE),
'body' => $body,
);
$url = onduty_url($cfg, $src, $row);
if ($url !== '') {
$fact['url'] = $url;
}
return $fact;
}
/** Does this page URL belong to a source row? Returns array(source, id) or null. */
function onduty_page_row($cfg, $pageUrl)
{
$path = ltrim((string) parse_url($pageUrl, PHP_URL_PATH), '/');
if ($path === '') {
return null;
}
foreach ($cfg['sources'] as $src) {
if (empty($src['url']) || !is_string($src['url']) || strpos($src['url'], '{id}') === false) {
continue;
}
$re = preg_quote(ltrim($src['url'], '/'), '#');
$re = str_replace(preg_quote('{id}', '#'), '(?P\d+)', $re);
$re = preg_replace('/\\\\\{[A-Za-z0-9_]+\\\\\}/', '[^/]+', $re);
if (preg_match('#^' . $re . '$#u', $path, $m)) {
return array($src, (int) $m['id']);
}
}
return null;
}
/** Fixed facts from the config whose keywords appear in the question. */
function onduty_fixed_facts($cfg, $question)
{
$out = array();
$q = ' ' . onduty_fold($question) . ' ';
$list = isset($cfg['facts']) ? $cfg['facts'] : array();
foreach ($list as $f) {
$hit = false;
foreach (isset($f['keywords']) ? $f['keywords'] : array() as $kw) {
if (strpos($q, onduty_fold($kw)) !== false) {
$hit = true;
break;
}
}
if ($hit) {
$out[] = array_intersect_key($f, array('title' => 1, 'body' => 1, 'url' => 1));
}
}
return $out;
}
/** Common shop words an English-speaking visitor types, in the Vietnamese the catalogue is written in. Used only
when OnDuty says the visitor's locale is "en", because unaccented Vietnamese collides ("day" is "dây", cable).
A shop adds its own with 'synonyms_en' in the config. */
function onduty_en_words()
{
return array(
'rent' => 'thuê', 'rental' => 'thuê', 'renting' => 'thuê', 'hire' => 'thuê', 'lease' => 'thuê',
'projector' => 'máy chiếu', 'projectors' => 'máy chiếu', 'screen' => 'màn chiếu', 'screens' => 'màn chiếu',
'tv' => 'tivi', 'tvs' => 'tivi', 'television' => 'tivi', 'speaker' => 'loa', 'speakers' => 'loa',
'sound' => 'âm thanh', 'audio' => 'âm thanh', 'microphone' => 'micro', 'mic' => 'micro',
'walkie' => 'bộ đàm', 'walkie-talkie' => 'bộ đàm', 'radio' => 'bộ đàm', 'radios' => 'bộ đàm',
'day' => 'ngày', 'days' => 'ngày', 'week' => 'tuần', 'month' => 'tháng',
'warranty' => 'bảo hành', 'guarantee' => 'bảo hành', 'return' => 'đổi trả', 'returns' => 'đổi trả',
'refund' => 'đổi trả', 'exchange' => 'đổi trả', 'deliver' => 'giao hàng', 'delivery' => 'giao hàng',
'shipping' => 'giao hàng', 'ship' => 'giao hàng', 'payment' => 'thanh toán', 'pay' => 'thanh toán',
'address' => 'địa chỉ', 'location' => 'địa chỉ', 'where' => 'địa chỉ', 'hotline' => 'hotline',
'phone' => 'hotline', 'repair' => 'sửa', 'fix' => 'sửa', 'install' => 'lắp đặt', 'installation' => 'lắp đặt',
'event' => 'sự kiện', 'events' => 'sự kiện', 'wedding' => 'cưới', 'conference' => 'hội nghị',
'meeting' => 'hội nghị', 'stage' => 'sân khấu', 'light' => 'ánh sáng', 'lights' => 'ánh sáng',
'lighting' => 'ánh sáng', 'cable' => 'cáp', 'used' => 'cũ', 'second-hand' => 'cũ', 'new' => 'mới',
'printer' => 'máy in', 'office' => 'văn phòng', 'outside' => 'ngoài', 'city' => 'thành phố',
);
}
function onduty_translate_en($question, $cfg)
{
$map = onduty_en_words();
if (!empty($cfg['synonyms_en']) && is_array($cfg['synonyms_en'])) {
foreach ($cfg['synonyms_en'] as $k => $v) {
$map[strtolower($k)] = $v;
}
}
return preg_replace_callback('/[A-Za-z][A-Za-z\-]*/', function ($m) use ($map) {
$w = strtolower($m[0]);
return isset($map[$w]) ? $map[$w] : $m[0];
}, $question);
}
/** Does the question carry one of a source's intent keywords ("cách", "hướng dẫn" for articles)? Folded, whole words. */
function onduty_intent_hit($src, $foldedQuestion)
{
if (empty($src['intent']['keywords'])) {
return false;
}
foreach ($src['intent']['keywords'] as $kw) {
$k = trim(preg_replace('/[^a-z0-9]+/', ' ', onduty_fold($kw)));
if ($k !== '' && strpos($foldedQuestion, ' ' . $k . ' ') !== false) {
return true;
}
}
return false;
}
/** The newest rows of a source, for a question that asks what kind of thing exists ("đã làm công trình nào")
rather than naming one. */
function onduty_browse($db, $src, $n)
{
$params = array();
$where = onduty_fixed_where($src, $params);
$sql = 'SELECT ' . onduty_select_list($src) . ' FROM ' . onduty_q($src['table'])
. ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY ' . onduty_q(onduty_id_col($src)) . ' DESC LIMIT ' . (int) $n;
return onduty_select($db, $sql, $params);
}
/**
* The whole answer for one query. Pure except for the database, so a test can call it directly.
* $req is the decoded request body: question, and optionally context.page.url and context.previous.
*/
function onduty_answer($cfg, $db, $req)
{
$question = isset($req['question']) && is_string($req['question']) ? $req['question'] : '';
$context = isset($req['context']) && is_array($req['context']) ? $req['context'] : array();
// Anything but a string is ignored: an array question became the word "Array", an array page URL a
// TypeError on PHP 8 (Codex review).
if (isset($context['page']) && (!is_array($context['page']) || !isset($context['page']['url']) || !is_string($context['page']['url']))) {
unset($context['page']);
}
if (isset($context['previous']) && !is_string($context['previous'])) {
unset($context['previous']);
}
$facts = array();
$seen = array();
// 1. The page the visitor is on, when it is one of our rows: "cái này giá bao nhiêu" needs no noun.
if (!empty($context['page']['url'])) {
$hit = onduty_page_row($cfg, $context['page']['url']);
if ($hit) {
$row = onduty_row_by_id($db, $hit[0], $hit[1]);
if ($row) {
$f = onduty_fact($cfg, $hit[0], $row, array());
$f['title'] = onduty_text('Trang khách đang xem: ' . $f['title'], ONDUTY_MAX_TITLE);
$facts[] = $f;
$seen[$hit[0]['id'] . ':' . $row['id']] = true;
}
}
}
// 2. Search terms. A price phrase ("dưới 10 triệu") becomes a filter and leaves the text, but only when one
// was recognised, so "tivi 4k" keeps its "4k". A short follow-up borrows the previous question's words.
// An English visitor's words are put into the catalogue's Vietnamese first.
$raw = $question;
if (isset($req['locale']) && $req['locale'] === 'en') {
$question = onduty_translate_en($question, $cfg);
}
$range = onduty_price_range($question);
$extraStop = isset($cfg['stopwords']) ? $cfg['stopwords'] : array();
$terms = onduty_terms($range ? onduty_strip_price($question) : $question, 8, $extraStop);
$ownTerms = count($terms);
// The visitor is on a product page and asked about "this one" with at most one word of their own ("máy này
// còn hàng không"): the page row is the answer. Searching "máy" as well buried it under ten other projectors.
if (!empty($facts) && $ownTerms <= 1) {
return onduty_cap(array_merge($facts, onduty_fixed_facts($cfg, $raw . ' ' . $question)));
}
$foldedQ = ' ' . trim(preg_replace('/[^a-z0-9]+/', ' ', onduty_fold($raw . ' ' . $question))) . ' ';
$weights = array();
foreach ($terms as $k => $v) {
$weights[$k] = 2;
}
if (count($terms) < 2 && !empty($context['previous'])) {
foreach (onduty_terms((string) $context['previous'], 4, $extraStop) as $k => $v) {
if (!isset($terms[$k])) {
$terms[$k] = $v;
$weights[$k] = 1;
}
}
}
$phrase = count($terms) >= 2 ? implode(' ', array_slice(array_keys($terms), 0, 3)) : '';
$cheap = onduty_wants_cheap($raw) || onduty_wants_cheap($question);
// 3. Every source, ranked together by score x source weight. A row must cover most of what was asked:
// all of a one- or two-word question, 60% of a longer one. When a fixed fact (hotline, address) already
// answered, a row must also name a term in its title, or "số hotline" lists every product whose
// description mentions the hotline.
$fixed = onduty_fixed_facts($cfg, $raw . ' ' . $question);
// Two passes: short columns (names, codes, summaries) for every source; long `deep` columns only when the
// short ones left the answer short of eight.
$ranked = array();
if ($terms) {
foreach (array(false, true) as $deep) {
if ($deep && count($ranked) + count($fixed) + count($facts) >= ONDUTY_MAX_FACTS) {
break;
}
foreach ($cfg['sources'] as $order => $src) {
$intent = onduty_intent_hit($src, $foldedQ);
// An intent-matched source is searched in full in the first pass and skipped in the second.
if ($deep && (empty($src['deep']) || $intent)) {
continue;
}
$useDeep = $deep || ($intent && !empty($src['deep']));
$w = isset($src['weight']) ? (float) $src['weight'] : 1.0;
if ($intent) {
$w *= isset($src['intent']['boost']) ? (float) $src['intent']['boost'] : 2.0;
}
foreach (onduty_search_source($db, $src, $terms, $weights, $phrase, $cheap, $range, (bool) $fixed, $useDeep, $cfg) as $pos => $row) {
$key = $src['id'] . ':' . $row['id'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
// a deep-only match ranks below any short-column match of the same score
$ranked[] = array('s' => $row['score'] * $w * ($deep ? 0.5 : 1), 'o' => ($deep ? 10000 : 0) + $order * 100 + $pos, 'src' => $src, 'row' => $row);
}
}
}
usort($ranked, function ($a, $b) use ($cheap) {
if ($cheap) {
return $a['o'] - $b['o'];
}
if ($a['s'] == $b['s']) {
return $a['o'] - $b['o'];
}
return $a['s'] < $b['s'] ? 1 : -1;
});
}
// 4. Fixed facts (hotline, address, opening hours) matched by keyword go first after the page row, then the
// newest rows of a source whose intent was asked for but whose words matched nothing.
foreach ($fixed as $f) {
$facts[] = $f;
}
foreach ($cfg['sources'] as $src) {
$n = isset($src['intent']['browse']) ? (int) $src['intent']['browse'] : 0;
if ($n < 1 || !onduty_intent_hit($src, $foldedQ)) {
continue;
}
$has = false;
foreach ($ranked as $r) {
if ($r['src']['id'] === $src['id']) {
$has = true;
break;
}
}
if (!$has) {
foreach (onduty_browse($db, $src, min($n, ONDUTY_MAX_FACTS)) as $row) {
if (!isset($seen[$src['id'] . ':' . $row['id']])) {
$facts[] = onduty_fact($cfg, $src, $row, array_keys($terms));
}
}
}
}
foreach ($ranked as $r) {
if (count($facts) >= ONDUTY_MAX_FACTS) {
break;
}
$facts[] = onduty_fact($cfg, $r['src'], $r['row'], array_keys($terms));
}
return onduty_cap($facts);
}
/** Enforce OnDuty's caps here too, so nothing we send is silently cut on the other side. */
function onduty_cap($facts)
{
$out = array();
foreach (array_slice($facts, 0, ONDUTY_MAX_FACTS) as $f) {
if (empty($f['body']) && empty($f['title'])) {
continue;
}
$f['title'] = onduty_text(isset($f['title']) ? $f['title'] : '', ONDUTY_MAX_TITLE);
$f['body'] = onduty_text(isset($f['body']) ? $f['body'] : '', ONDUTY_MAX_BODY);
if ($f['body'] === '') {
$f['body'] = $f['title'];
}
$out[] = $f;
}
while ($out && strlen(onduty_json(array('facts' => $out, 'ttl' => 60))) > ONDUTY_MAX_BYTES) {
array_pop($out);
}
return $out;
}
function onduty_json($data)
{
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
$flags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
}
return json_encode($data, $flags);
}
/* ------------------------------------------------------------------------------------------------------------
* HTTP
* ---------------------------------------------------------------------------------------------------------- */
function onduty_send($status, $data)
{
while (ob_get_level() > 0) {
ob_end_clean();
}
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex');
}
echo onduty_json($data);
$jobs = onduty_defer();
if ($jobs) {
// The visitor's answer is complete; index rebuilds happen on OnDuty's time, not theirs.
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
} else {
flush();
}
ignore_user_abort(true);
@set_time_limit(60);
foreach ($jobs as $job) {
try {
call_user_func($job);
} catch (Exception $e) {
error_log('onduty-connector index: ' . $e->getMessage());
}
}
}
exit;
}
/** Verifies signature and freshness. Returns '' when the request may be answered, else the reason. */
function onduty_verify($secret, $raw, $sigHeader, $req, $windowSeconds, $nowMs)
{
$mine = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!is_string($sigHeader) || !hash_equals($mine, $sigHeader)) {
return 'bad signature';
}
// Strict: OnDuty sends numbers. "1junk" or "1.9" must not coerce into version 1 (Codex review).
if (!is_array($req) || !isset($req['v']) || $req['v'] !== 1) {
return 'unsupported version';
}
if (!isset($req['ts']) || !(is_int($req['ts']) || is_float($req['ts'])) || abs($nowMs - $req['ts']) > $windowSeconds * 1000) {
return 'stale request';
}
return '';
}
/**
* Where the config is, first match wins:
* 1. the ONDUTY_CONFIG environment variable;
* 2. onduty-config.php next to this file: the "one folder" layout, everything in e.g. public_html/onduty/ with
* the .htaccess from the kit denying the config and secret files;
* 3. onduty-config.php one folder above this file: the "outside the web root" layout.
* The config files check ONDUTY_KIT_LOADED and answer 404 when a browser calls them directly.
*/
function onduty_config_path()
{
$env = getenv('ONDUTY_CONFIG');
if ($env) {
return $env;
}
foreach (array(__DIR__ . '/onduty-config.php', dirname(__DIR__) . '/onduty-config.php') as $p) {
if (is_file($p)) {
return $p;
}
}
return '';
}
function onduty_load_config()
{
$path = onduty_config_path();
if ($path === '' || !is_file($path)) {
return null;
}
$cfg = require $path;
return is_array($cfg) ? $cfg : null;
}
function onduty_main()
{
ob_start();
ini_set('display_errors', '0');
set_error_handler(function () {
return true; // a notice must never reach the body
});
register_shutdown_function(function () {
$e = error_get_last();
if ($e && in_array($e['type'], array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR), true)) {
onduty_send(500, array('facts' => array(), 'error' => 'connector crashed, see the PHP error log'));
}
});
$cfg = onduty_load_config();
if (!$cfg) {
onduty_send(500, array('facts' => array(), 'error' => 'onduty-config.php not found'));
}
$problem = onduty_check_config($cfg);
$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
if ($method === 'GET') {
$manifest = array(
'name' => isset($cfg['name']) ? $cfg['name'] : '',
'v' => 1,
'capabilities' => array('query'),
'kit' => ONDUTY_KIT_VERSION,
);
// The manifest needs no signature, so it never says WHAT is wrong: a broken config would otherwise
// show anyone a private table name, or a path pasted by mistake (Codex SEC-2). The details go to the
// PHP error log, and to OnDuty's console through a signed POST below.
if ($problem !== '') {
error_log('onduty-connector config: ' . $problem);
$manifest['error'] = 'config error, see the PHP error log';
}
onduty_send($problem === '' ? 200 : 500, $manifest);
}
if ($method !== 'POST') {
onduty_send(405, array('error' => 'GET or POST only'));
}
if ($problem === 'secret is not set') {
// Without a secret nothing can be verified, so nothing more is said.
error_log('onduty-connector config: ' . $problem);
onduty_send(500, array('facts' => array(), 'error' => 'config error, see the PHP error log'));
}
$raw = file_get_contents('php://input', false, null, 0, ONDUTY_MAX_REQUEST + 1);
if (strlen($raw) > ONDUTY_MAX_REQUEST) {
onduty_send(413, array('error' => 'request too large'));
}
$req = json_decode($raw, true);
$sig = isset($_SERVER['HTTP_X_ONDUTY_SIGNATURE']) ? $_SERVER['HTTP_X_ONDUTY_SIGNATURE'] : '';
$window = isset($cfg['replay_window']) ? (int) $cfg['replay_window'] : 300;
$why = onduty_verify($cfg['secret'], $raw, $sig, $req, $window, round(microtime(true) * 1000));
if ($why !== '') {
onduty_send(401, array('error' => $why));
}
if ($problem !== '') {
// Signed by OnDuty, so the console may see the reason ("máy chủ tiệm báo: config: ...").
onduty_send(500, array('facts' => array(), 'error' => 'config: ' . $problem));
}
if (!empty($cfg['tenant']) && (!isset($req['tenant']) || $req['tenant'] !== $cfg['tenant'])) {
onduty_send(401, array('error' => 'wrong tenant'));
}
$type = isset($req['type']) ? $req['type'] : '';
if ($type !== 'query') {
onduty_send(400, array('error' => 'unsupported type'));
}
try {
$t0 = microtime(true);
$db = call_user_func($cfg['db']);
onduty_timing('connect_ms', onduty_ms($t0));
$t0 = microtime(true);
$facts = onduty_answer($cfg, $db, $req);
onduty_timing('answer_ms', onduty_ms($t0));
} catch (Exception $e) {
error_log('onduty-connector: ' . $e->getMessage());
onduty_send(500, array('facts' => array(), 'error' => 'database error, see the PHP error log'));
}
$ttl = isset($cfg['ttl']) ? (int) $cfg['ttl'] : 60;
$out = array('facts' => $facts, 'ttl' => $ttl);
if (isset($req['debug']) && $req['debug'] === true) {
$out['timing'] = onduty_timing(); // signed requests only: this line runs after the signature check
}
onduty_send(200, $out);
}
if (!defined('ONDUTY_KIT_NO_MAIN')) {
onduty_main();
}