WebOrbiton
v1.0.0.0

StocketBase

161 lines · 5.3 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. final class IndexNow
  6. {
  7. private const ENDPOINT = 'https://api.indexnow.org/indexnow';
  8. private const MIN_INTERVAL_MINUTES = 10;
  9. ​
  10. public static function key(): string
  11. {
  12. try {
  13. $statement = Database::site()->prepare('SELECT setting_key, setting_value FROM site_settings WHERE setting_key IN (:enabled, :key)');
  14. $statement->execute(['enabled' => 'indexnow_enabled', 'key' => 'indexnow_key']);
  15. $values = $statement->fetchAll(PDO::FETCH_KEY_PAIR);
  16. } catch (PDOException $e) {
  17. return '';
  18. }
  19. ​
  20. if (($values['indexnow_enabled'] ?? '0') !== '1') {
  21. return '';
  22. }
  23. ​
  24. return (string) ($values['indexnow_key'] ?? '');
  25. }
  26. ​
  27. public static function keyLocation(string $key): string
  28. {
  29. $basePath = rtrim((string) Config::get('APP_BASE_PATH', ''), '/');
  30. ​
  31. return rtrim((string) Config::get('APP_URL', ''), '/') . $basePath . '/' . rawurlencode($key) . '.txt';
  32. }
  33. ​
  34. public static function notifyProduct(int $productId): void
  35. {
  36. if (self::key() === '' || $productId <= 0) {
  37. return;
  38. }
  39. ​
  40. $statement = Database::site()->prepare('SELECT slug, status FROM products WHERE id = :id LIMIT 1');
  41. $statement->execute(['id' => $productId]);
  42. $product = $statement->fetch();
  43. ​
  44. if (!$product || $product['status'] !== 'published' || (string) $product['slug'] === '') {
  45. return;
  46. }
  47. ​
  48. self::submit([self::absoluteProductUrl((string) $product['slug'])], $productId);
  49. }
  50. ​
  51. public static function notifyRemoved(string $slug, int $productId): void
  52. {
  53. if (self::key() === '' || $slug === '') {
  54. return;
  55. }
  56. ​
  57. self::submit([self::absoluteProductUrl($slug)], $productId);
  58. }
  59. ​
  60. private static function absoluteProductUrl(string $slug): string
  61. {
  62. return rtrim((string) Config::get('APP_URL', ''), '/') . SiteFront::productUrl($slug);
  63. }
  64. ​
  65. private static function submit(array $urls, int $productId): void
  66. {
  67. $key = self::key();
  68. $host = (string) parse_url((string) Config::get('APP_URL', ''), PHP_URL_HOST);
  69. if ($key === '' || $host === '') {
  70. return;
  71. }
  72. ​
  73. if (self::recentlySubmitted($productId)) {
  74. if (class_exists('ActivityLog')) {
  75. ActivityLog::record('indexnow.skipped', 'product', $productId, 'already submitted in the last ' . self::MIN_INTERVAL_MINUTES . ' minutes');
  76. }
  77. ​
  78. return;
  79. }
  80. ​
  81. $body = [
  82. 'host' => $host,
  83. 'key' => $key,
  84. 'urlList' => array_values($urls),
  85. ];
  86. if (rtrim((string) Config::get('APP_BASE_PATH', ''), '/') !== '') {
  87. $body['keyLocation'] = self::keyLocation($key);
  88. }
  89. ​
  90. $status = self::post((string) json_encode($body, JSON_UNESCAPED_SLASHES));
  91. ​
  92. if ($status === 200 || $status === 202) {
  93. self::markSubmitted($productId);
  94. }
  95. ​
  96. if (class_exists('ActivityLog')) {
  97. ActivityLog::record('indexnow.submit', 'product', $productId, $status > 0 ? 'HTTP ' . $status : 'request failed');
  98. }
  99. }
  100. ​
  101. private static function recentlySubmitted(int $productId): bool
  102. {
  103. try {
  104. $statement = Database::site()->prepare(
  105. 'SELECT COUNT(*) FROM indexnow_log WHERE product_id = :id AND submitted_at > DATE_SUB(NOW(), INTERVAL ' . self::MIN_INTERVAL_MINUTES . ' MINUTE)'
  106. );
  107. $statement->execute(['id' => $productId]);
  108. ​
  109. return (int) $statement->fetchColumn() > 0;
  110. } catch (PDOException $e) {
  111. return false;
  112. }
  113. }
  114. ​
  115. private static function markSubmitted(int $productId): void
  116. {
  117. try {
  118. Database::site()->prepare(
  119. 'INSERT INTO indexnow_log (product_id, submitted_at) VALUES (:id, NOW()) ON DUPLICATE KEY UPDATE submitted_at = NOW()'
  120. )->execute(['id' => $productId]);
  121. } catch (PDOException $e) {
  122. error_log('StocketBase: could not record IndexNow submission: ' . $e->getMessage());
  123. }
  124. }
  125. ​
  126. private static function post(string $payload): int
  127. {
  128. if (function_exists('curl_init')) {
  129. $handle = curl_init(self::ENDPOINT);
  130. curl_setopt_array($handle, [
  131. CURLOPT_POST => true,
  132. CURLOPT_POSTFIELDS => $payload,
  133. CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
  134. CURLOPT_RETURNTRANSFER => true,
  135. CURLOPT_CONNECTTIMEOUT => 2,
  136. CURLOPT_TIMEOUT => 4,
  137. ]);
  138. curl_exec($handle);
  139. $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
  140. ​
  141. return $status;
  142. }
  143. ​
  144. $context = stream_context_create([
  145. 'http' => [
  146. 'method' => 'POST',
  147. 'header' => "Content-Type: application/json; charset=utf-8\r\n",
  148. 'content' => $payload,
  149. 'timeout' => 4,
  150. 'ignore_errors' => true,
  151. ],
  152. ]);
  153. $result = @file_get_contents(self::ENDPOINT, false, $context);
  154. if ($result === false || empty($http_response_header[0])) {
  155. return 0;
  156. }
  157. ​
  158. return preg_match('#\s(\d{3})\s#', $http_response_header[0], $matches) === 1 ? (int) $matches[1] : 0;
  159. }
  160. }
  161. ​