v2.0.0.0
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
- require_once __DIR__ . '/site-front.php';
- require_once __DIR__ . '/slugger.php';
-
- // Short links (/my-article, /about, /jan-kowalski) shared by articles, pages and author pages.
- // Every name is used once: the oldest item keeps the plain name, later ones get -2, -3 and so on.
- // A name stays with its item once it is given out, so links never change.
- final class CleanUrls
- {
- private const SETTINGS = [
- 'article' => 'clean_article_urls',
- 'page' => 'clean_page_urls',
- 'author' => 'clean_author_urls',
- ];
-
- private const ENTITIES = [
- 'article' => ['table' => 'articles', 'id' => 'id', 'name' => 'slug'],
- 'page' => ['table' => 'pages', 'id' => 'id', 'name' => 'title'],
- 'author' => ['table' => 'author_profiles', 'id' => 'account_id', 'name' => 'slug'],
- ];
-
- private const MAX_BASE_LENGTH = 180;
-
- private static array $slugCache = [];
- private static bool $synced = false;
- private static ?array $reserved = null;
-
- public static function enabled(string $type): bool
- {
- $key = self::SETTINGS[$type] ?? null;
-
- return $key !== null && (SiteFront::settings()[$key] ?? '0') === '1';
- }
-
- // Short name for an item, looked up by the item's own slug. Null when short links are off for
- // this type or the item has no short name yet (then callers fall back to the old link).
- public static function slugFor(string $type, string $realSlug): ?string
- {
- if ($realSlug === '' || !self::enabled($type)) {
- return null;
- }
-
- $cacheKey = $type . ':' . $realSlug;
- if (array_key_exists($cacheKey, self::$slugCache)) {
- return self::$slugCache[$cacheKey];
- }
-
- $slug = self::lookup($type, $realSlug);
- if ($slug === null && !self::$synced) {
- self::sync();
- $slug = self::lookup($type, $realSlug);
- }
-
- return self::$slugCache[$cacheKey] = $slug;
- }
-
- // Finds the item behind a short name. Returns ['type' => ..., 'slug' => the item's own slug].
- public static function resolve(string $slug): ?array
- {
- if (preg_match('/^[a-z0-9-]{1,191}$/', $slug) !== 1) {
- return null;
- }
-
- try {
- $statement = Database::site()->prepare('SELECT entity_type, entity_id FROM clean_urls WHERE slug = :slug LIMIT 1');
- $statement->execute(['slug' => $slug]);
- $row = $statement->fetch();
-
- if (!$row || !isset(self::ENTITIES[$row['entity_type']]) || !self::enabled($row['entity_type'])) {
- return null;
- }
-
- $entity = self::ENTITIES[$row['entity_type']];
- $realStatement = Database::site()->prepare('SELECT slug FROM ' . $entity['table'] . ' WHERE ' . $entity['id'] . ' = :id LIMIT 1');
- $realStatement->execute(['id' => $row['entity_id']]);
- $realSlug = $realStatement->fetchColumn();
- } catch (PDOException $e) {
- return null;
- }
-
- return $realSlug === false ? null : ['type' => $row['entity_type'], 'slug' => (string) $realSlug];
- }
-
- // Gives short names to every item that doesn't have one yet, oldest first, and frees the names
- // of items that no longer exist.
- public static function sync(): void
- {
- self::$synced = true;
- self::$slugCache = [];
- $db = Database::site();
-
- $missing = [];
- foreach (self::ENTITIES as $type => $entity) {
- try {
- $db->exec(
- 'DELETE u FROM clean_urls u LEFT JOIN ' . $entity['table'] . ' e ON e.' . $entity['id'] . ' = u.entity_id'
- . " WHERE u.entity_type = '" . $type . "' AND e." . $entity['id'] . ' IS NULL'
- );
-
- $rows = $db->query(
- "SELECT '" . $type . "' AS entity_type, e." . $entity['id'] . ' AS entity_id, e.' . $entity['name'] . ' AS name, e.created_at AS created_at'
- . ' FROM ' . $entity['table'] . " e LEFT JOIN clean_urls u ON u.entity_type = '" . $type . "' AND u.entity_id = e." . $entity['id']
- . ' WHERE u.slug IS NULL'
- )->fetchAll();
- array_push($missing, ...$rows);
- } catch (PDOException $e) {
- error_log('Publisium: could not prepare short links for ' . $type . ': ' . $e->getMessage());
- }
- }
-
- if (empty($missing)) {
- return;
- }
-
- $typeOrder = array_flip(array_keys(self::ENTITIES));
- usort($missing, static fn(array $a, array $b): int => [(string) $a['created_at'], $typeOrder[$a['entity_type']], (int) $a['entity_id']]
- <=> [(string) $b['created_at'], $typeOrder[$b['entity_type']], (int) $b['entity_id']]);
-
- $insert = $db->prepare('INSERT INTO clean_urls (slug, entity_type, entity_id) VALUES (:slug, :type, :id)');
- $hasRow = $db->prepare('SELECT 1 FROM clean_urls WHERE entity_type = :type AND entity_id = :id LIMIT 1');
- $reserved = self::reserved();
-
- foreach ($missing as $item) {
- $base = self::baseFor((string) $item['entity_type'], (string) $item['name']);
-
- for ($number = 1; $number <= 1000; $number++) {
- $candidate = $number === 1 ? $base : $base . '-' . $number;
- if (isset($reserved[$candidate])) {
- continue;
- }
-
- try {
- $insert->execute(['slug' => $candidate, 'type' => $item['entity_type'], 'id' => $item['entity_id']]);
- break;
- } catch (PDOException $e) {
- if ((string) $e->getCode() !== '23000') {
- error_log('Publisium: could not save a short link: ' . $e->getMessage());
- break;
- }
-
- $hasRow->execute(['type' => $item['entity_type'], 'id' => $item['entity_id']]);
- if ($hasRow->fetchColumn()) {
- break;
- }
- }
- }
- }
- }
-
- // Sends visitors from an old address to the short one, keeping any extra query parameters.
- public static function redirect(string $url): void
- {
- if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET' || headers_sent()) {
- return;
- }
-
- $extra = $_GET;
- unset($extra['slug'], $extra['publisium_lang']);
- if (!empty($extra)) {
- $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($extra);
- }
-
- header('Location: ' . $url, true, 301);
- exit;
- }
-
- private static function lookup(string $type, string $realSlug): ?string
- {
- $entity = self::ENTITIES[$type] ?? null;
- if ($entity === null) {
- return null;
- }
-
- try {
- $statement = Database::site()->prepare(
- 'SELECT u.slug FROM clean_urls u JOIN ' . $entity['table'] . ' e ON e.' . $entity['id'] . ' = u.entity_id'
- . ' WHERE u.entity_type = :type AND e.slug = :slug LIMIT 1'
- );
- $statement->execute(['type' => $type, 'slug' => $realSlug]);
- $slug = $statement->fetchColumn();
- } catch (PDOException $e) {
- return null;
- }
-
- return $slug === false ? null : (string) $slug;
- }
-
- private static function baseFor(string $type, string $slug): string
- {
- if ($type === 'article') {
- // New articles get a random 6-character ending; the short link leaves it out.
- $slug = (string) preg_replace('/-[0-9a-f]{6}$/', '', $slug);
- }
-
- $slug = rtrim(substr(Slugger::make($slug), 0, self::MAX_BASE_LENGTH), '-');
-
- return $slug !== '' ? $slug : $type;
- }
-
- // Names that would clash with folders on the server or with language addresses.
- private static function reserved(): array
- {
- if (self::$reserved !== null) {
- return self::$reserved;
- }
-
- $reserved = ['index' => true];
- foreach (scandir(dirname(__DIR__)) ?: [] as $entry) {
- if ($entry !== '.' && $entry !== '..' && is_dir(dirname(__DIR__) . '/' . $entry)) {
- $reserved[strtolower($entry)] = true;
- }
- }
-
- try {
- foreach (Languages::all() as $language) {
- $reserved[strtolower((string) $language['code'])] = true;
- }
- $reserved[Languages::defaultCode()] = true;
- } catch (Throwable $e) {
- }
-
- return self::$reserved = $reserved;
- }
- }
-