WebOrbiton
v2.0.0.0

Publisium

229 lines · 8.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/site-front.php';
  7. require_once __DIR__ . '/slugger.php';
  8. ​
  9. // Short links (/my-article, /about, /jan-kowalski) shared by articles, pages and author pages.
  10. // Every name is used once: the oldest item keeps the plain name, later ones get -2, -3 and so on.
  11. // A name stays with its item once it is given out, so links never change.
  12. final class CleanUrls
  13. {
  14. private const SETTINGS = [
  15. 'article' => 'clean_article_urls',
  16. 'page' => 'clean_page_urls',
  17. 'author' => 'clean_author_urls',
  18. ];
  19. ​
  20. private const ENTITIES = [
  21. 'article' => ['table' => 'articles', 'id' => 'id', 'name' => 'slug'],
  22. 'page' => ['table' => 'pages', 'id' => 'id', 'name' => 'title'],
  23. 'author' => ['table' => 'author_profiles', 'id' => 'account_id', 'name' => 'slug'],
  24. ];
  25. ​
  26. private const MAX_BASE_LENGTH = 180;
  27. ​
  28. private static array $slugCache = [];
  29. private static bool $synced = false;
  30. private static ?array $reserved = null;
  31. ​
  32. public static function enabled(string $type): bool
  33. {
  34. $key = self::SETTINGS[$type] ?? null;
  35. ​
  36. return $key !== null && (SiteFront::settings()[$key] ?? '0') === '1';
  37. }
  38. ​
  39. // Short name for an item, looked up by the item's own slug. Null when short links are off for
  40. // this type or the item has no short name yet (then callers fall back to the old link).
  41. public static function slugFor(string $type, string $realSlug): ?string
  42. {
  43. if ($realSlug === '' || !self::enabled($type)) {
  44. return null;
  45. }
  46. ​
  47. $cacheKey = $type . ':' . $realSlug;
  48. if (array_key_exists($cacheKey, self::$slugCache)) {
  49. return self::$slugCache[$cacheKey];
  50. }
  51. ​
  52. $slug = self::lookup($type, $realSlug);
  53. if ($slug === null && !self::$synced) {
  54. self::sync();
  55. $slug = self::lookup($type, $realSlug);
  56. }
  57. ​
  58. return self::$slugCache[$cacheKey] = $slug;
  59. }
  60. ​
  61. // Finds the item behind a short name. Returns ['type' => ..., 'slug' => the item's own slug].
  62. public static function resolve(string $slug): ?array
  63. {
  64. if (preg_match('/^[a-z0-9-]{1,191}$/', $slug) !== 1) {
  65. return null;
  66. }
  67. ​
  68. try {
  69. $statement = Database::site()->prepare('SELECT entity_type, entity_id FROM clean_urls WHERE slug = :slug LIMIT 1');
  70. $statement->execute(['slug' => $slug]);
  71. $row = $statement->fetch();
  72. ​
  73. if (!$row || !isset(self::ENTITIES[$row['entity_type']]) || !self::enabled($row['entity_type'])) {
  74. return null;
  75. }
  76. ​
  77. $entity = self::ENTITIES[$row['entity_type']];
  78. $realStatement = Database::site()->prepare('SELECT slug FROM ' . $entity['table'] . ' WHERE ' . $entity['id'] . ' = :id LIMIT 1');
  79. $realStatement->execute(['id' => $row['entity_id']]);
  80. $realSlug = $realStatement->fetchColumn();
  81. } catch (PDOException $e) {
  82. return null;
  83. }
  84. ​
  85. return $realSlug === false ? null : ['type' => $row['entity_type'], 'slug' => (string) $realSlug];
  86. }
  87. ​
  88. // Gives short names to every item that doesn't have one yet, oldest first, and frees the names
  89. // of items that no longer exist.
  90. public static function sync(): void
  91. {
  92. self::$synced = true;
  93. self::$slugCache = [];
  94. $db = Database::site();
  95. ​
  96. $missing = [];
  97. foreach (self::ENTITIES as $type => $entity) {
  98. try {
  99. $db->exec(
  100. 'DELETE u FROM clean_urls u LEFT JOIN ' . $entity['table'] . ' e ON e.' . $entity['id'] . ' = u.entity_id'
  101. . " WHERE u.entity_type = '" . $type . "' AND e." . $entity['id'] . ' IS NULL'
  102. );
  103. ​
  104. $rows = $db->query(
  105. "SELECT '" . $type . "' AS entity_type, e." . $entity['id'] . ' AS entity_id, e.' . $entity['name'] . ' AS name, e.created_at AS created_at'
  106. . ' FROM ' . $entity['table'] . " e LEFT JOIN clean_urls u ON u.entity_type = '" . $type . "' AND u.entity_id = e." . $entity['id']
  107. . ' WHERE u.slug IS NULL'
  108. )->fetchAll();
  109. array_push($missing, ...$rows);
  110. } catch (PDOException $e) {
  111. error_log('Publisium: could not prepare short links for ' . $type . ': ' . $e->getMessage());
  112. }
  113. }
  114. ​
  115. if (empty($missing)) {
  116. return;
  117. }
  118. ​
  119. $typeOrder = array_flip(array_keys(self::ENTITIES));
  120. usort($missing, static fn(array $a, array $b): int => [(string) $a['created_at'], $typeOrder[$a['entity_type']], (int) $a['entity_id']]
  121. <=> [(string) $b['created_at'], $typeOrder[$b['entity_type']], (int) $b['entity_id']]);
  122. ​
  123. $insert = $db->prepare('INSERT INTO clean_urls (slug, entity_type, entity_id) VALUES (:slug, :type, :id)');
  124. $hasRow = $db->prepare('SELECT 1 FROM clean_urls WHERE entity_type = :type AND entity_id = :id LIMIT 1');
  125. $reserved = self::reserved();
  126. ​
  127. foreach ($missing as $item) {
  128. $base = self::baseFor((string) $item['entity_type'], (string) $item['name']);
  129. ​
  130. for ($number = 1; $number <= 1000; $number++) {
  131. $candidate = $number === 1 ? $base : $base . '-' . $number;
  132. if (isset($reserved[$candidate])) {
  133. continue;
  134. }
  135. ​
  136. try {
  137. $insert->execute(['slug' => $candidate, 'type' => $item['entity_type'], 'id' => $item['entity_id']]);
  138. break;
  139. } catch (PDOException $e) {
  140. if ((string) $e->getCode() !== '23000') {
  141. error_log('Publisium: could not save a short link: ' . $e->getMessage());
  142. break;
  143. }
  144. ​
  145. $hasRow->execute(['type' => $item['entity_type'], 'id' => $item['entity_id']]);
  146. if ($hasRow->fetchColumn()) {
  147. break;
  148. }
  149. }
  150. }
  151. }
  152. }
  153. ​
  154. // Sends visitors from an old address to the short one, keeping any extra query parameters.
  155. public static function redirect(string $url): void
  156. {
  157. if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET' || headers_sent()) {
  158. return;
  159. }
  160. ​
  161. $extra = $_GET;
  162. unset($extra['slug'], $extra['publisium_lang']);
  163. if (!empty($extra)) {
  164. $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($extra);
  165. }
  166. ​
  167. header('Location: ' . $url, true, 301);
  168. exit;
  169. }
  170. ​
  171. private static function lookup(string $type, string $realSlug): ?string
  172. {
  173. $entity = self::ENTITIES[$type] ?? null;
  174. if ($entity === null) {
  175. return null;
  176. }
  177. ​
  178. try {
  179. $statement = Database::site()->prepare(
  180. 'SELECT u.slug FROM clean_urls u JOIN ' . $entity['table'] . ' e ON e.' . $entity['id'] . ' = u.entity_id'
  181. . ' WHERE u.entity_type = :type AND e.slug = :slug LIMIT 1'
  182. );
  183. $statement->execute(['type' => $type, 'slug' => $realSlug]);
  184. $slug = $statement->fetchColumn();
  185. } catch (PDOException $e) {
  186. return null;
  187. }
  188. ​
  189. return $slug === false ? null : (string) $slug;
  190. }
  191. ​
  192. private static function baseFor(string $type, string $slug): string
  193. {
  194. if ($type === 'article') {
  195. // New articles get a random 6-character ending; the short link leaves it out.
  196. $slug = (string) preg_replace('/-[0-9a-f]{6}$/', '', $slug);
  197. }
  198. ​
  199. $slug = rtrim(substr(Slugger::make($slug), 0, self::MAX_BASE_LENGTH), '-');
  200. ​
  201. return $slug !== '' ? $slug : $type;
  202. }
  203. ​
  204. // Names that would clash with folders on the server or with language addresses.
  205. private static function reserved(): array
  206. {
  207. if (self::$reserved !== null) {
  208. return self::$reserved;
  209. }
  210. ​
  211. $reserved = ['index' => true];
  212. foreach (scandir(dirname(__DIR__)) ?: [] as $entry) {
  213. if ($entry !== '.' && $entry !== '..' && is_dir(dirname(__DIR__) . '/' . $entry)) {
  214. $reserved[strtolower($entry)] = true;
  215. }
  216. }
  217. ​
  218. try {
  219. foreach (Languages::all() as $language) {
  220. $reserved[strtolower((string) $language['code'])] = true;
  221. }
  222. $reserved[Languages::defaultCode()] = true;
  223. } catch (Throwable $e) {
  224. }
  225. ​
  226. return self::$reserved = $reserved;
  227. }
  228. }
  229. ​