From f2233c539038a83e0e9bb33d36f002879b25f75a Mon Sep 17 00:00:00 2001 From: flashwave <me@flash.moe> Date: Wed, 29 Jan 2025 21:07:12 +0000 Subject: [PATCH] Built redirector service into Misuzu. --- VERSION | 2 +- assets/redir-bsky.js/main.js | 25 ++++++ assets/redir-fedi.js/main.js | 39 +++++++++ build.js | 2 + config/config.example.cfg | 3 + ...25_01_29_002819_create_redirect_tables.php | 28 +++++++ src/MisuzuContext.php | 22 ++++- src/Redirects/AliasRedirectsRoutes.php | 49 +++++++++++ src/Redirects/IncrementalRedirectInfo.php | 30 +++++++ src/Redirects/IncrementalRedirectsData.php | 67 +++++++++++++++ src/Redirects/IncrementalRedirectsRoutes.php | 67 +++++++++++++++ src/Redirects/LandingRedirectsRoutes.php | 21 +++++ src/Redirects/NamedRedirectInfo.php | 30 +++++++ src/Redirects/NamedRedirectsData.php | 80 ++++++++++++++++++ src/Redirects/NamedRedirectsRoutes.php | 38 +++++++++ src/Redirects/RedirectInfo.php | 11 +++ src/Redirects/RedirectsContext.php | 18 ++++ src/Redirects/SocialRedirectsRoutes.php | 82 +++++++++++++++++++ 18 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 assets/redir-bsky.js/main.js create mode 100644 assets/redir-fedi.js/main.js create mode 100644 database/2025_01_29_002819_create_redirect_tables.php create mode 100644 src/Redirects/AliasRedirectsRoutes.php create mode 100644 src/Redirects/IncrementalRedirectInfo.php create mode 100644 src/Redirects/IncrementalRedirectsData.php create mode 100644 src/Redirects/IncrementalRedirectsRoutes.php create mode 100644 src/Redirects/LandingRedirectsRoutes.php create mode 100644 src/Redirects/NamedRedirectInfo.php create mode 100644 src/Redirects/NamedRedirectsData.php create mode 100644 src/Redirects/NamedRedirectsRoutes.php create mode 100644 src/Redirects/RedirectInfo.php create mode 100644 src/Redirects/RedirectsContext.php create mode 100644 src/Redirects/SocialRedirectsRoutes.php diff --git a/VERSION b/VERSION index d01f0cb4..ee73a5fc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20250104 +20250129 diff --git a/assets/redir-bsky.js/main.js b/assets/redir-bsky.js/main.js new file mode 100644 index 00000000..a8f66016 --- /dev/null +++ b/assets/redir-bsky.js/main.js @@ -0,0 +1,25 @@ +(async () => { + const status = document.querySelector('.js-status'); + status.textContent = 'Looking up DID...'; + + let did = null; + + try { + const response = await fetch(`${location.protocol}//${BSKY_HANDLE}/.well-known/atproto-did`); + did = await response.text(); + } catch(ex) { + status.style.color = 'red'; + status.textContent = `Could not find DID! ${ex}`; + return; + } + + if(typeof did !== 'string' || !did.startsWith('did:')) { + status.style.color = 'red'; + status.textContent = 'Look up result was not a valid DID.'; + return; + } + + const url = BSKY_FORMAT.replace('%s', did); + status.textContent = `Redirecting to ${url}...`; + location.replace(url); +})(); diff --git a/assets/redir-fedi.js/main.js b/assets/redir-fedi.js/main.js new file mode 100644 index 00000000..00a05946 --- /dev/null +++ b/assets/redir-fedi.js/main.js @@ -0,0 +1,39 @@ +(async () => { + const status = document.querySelector('.js-status'); + status.textContent = 'Looking up Fediverse profile...'; + + let url = null; + try { + const response = await fetch(`${location.protocol}//${FEDI_INSTANCE}/.well-known/webfinger?format=json&resource=acct:${FEDI_USERNAME}@${FEDI_INSTANCE}`); + const webfinger = await response.json(); + + if(typeof webfinger === 'object') { + if(Array.isArray(webfinger.links)) + for(const link of webfinger.links) + if(typeof link === 'object' && link.rel === 'http://webfinger.net/rel/profile-page' && typeof link.href === 'string') { + url = link.href; + break; + } + + if(typeof url !== 'string' && Array.isArray(webfinger.aliases)) + for(const alias of webfinger.aliases) + if(typeof alias === 'string') { + url = alias; + break; + } + } + } catch(ex) { + status.style.color = 'red'; + status.textContent = `Could not complete Webfinger lookup! ${ex}`; + return; + } + + if(typeof url !== 'string' || (!url.startsWith('https://') && !url.startsWith('http://'))) { + status.style.color = 'red'; + status.textContent = 'Could not find an acceptable profile URL.'; + return; + } + + status.textContent = `Redirecting to ${url}...`; + location.replace(url); +})(); diff --git a/build.js b/build.js index a9150d4c..c54b79be 100644 --- a/build.js +++ b/build.js @@ -18,6 +18,8 @@ const fs = require('fs'); const tasks = { js: [ { source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.js', }, + { source: 'redir-bsky.js', target: '/assets', name: 'redir-bsky.{hash}.js', }, + { source: 'redir-fedi.js', target: '/assets', name: 'redir-fedi.{hash}.js', }, ], css: [ { source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', }, diff --git a/config/config.example.cfg b/config/config.example.cfg index 1b0bbe1f..21422f97 100644 --- a/config/config.example.cfg +++ b/config/config.example.cfg @@ -6,3 +6,6 @@ database:dsn mariadb://<user>:<pass>@<host>/<name>?charset=utf8mb4 ;sentry:dsn https://sentry dsn here ;sentry:tracesRate 1.0 ;sentry:profilesRate 1.0 + +domain:localhost main redirect +domain:localhost:redirect:path /go diff --git a/database/2025_01_29_002819_create_redirect_tables.php b/database/2025_01_29_002819_create_redirect_tables.php new file mode 100644 index 00000000..ede88992 --- /dev/null +++ b/database/2025_01_29_002819_create_redirect_tables.php @@ -0,0 +1,28 @@ +<?php +use Index\Db\DbConnection; +use Index\Db\Migration\DbMigration; + +final class CreateRedirectTables_20250129_002819 implements DbMigration { + public function migrate(DbConnection $conn): void { + $conn->execute(<<<SQL + CREATE TABLE msz_redirects_named ( + redir_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + redir_vanity VARCHAR(255) NOT NULL COLLATE 'ascii_general_ci', + redir_url VARCHAR(255) NOT NULL COLLATE 'ascii_bin', + redir_clicks BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + redir_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (redir_id), + UNIQUE INDEX redirects_vanity_unique (redir_vanity) + ) ENGINE=InnoDB COLLATE='utf8mb4_bin' + SQL); + + $conn->execute(<<<SQL + CREATE TABLE msz_redirects_incremental ( + redir_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + redir_url VARCHAR(255) NOT NULL COLLATE 'utf8mb4_bin', + redir_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (redir_id) + ) ENGINE=InnoDB COLLATE='utf8mb4_bin' + SQL); + } +} diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 1846a6de..410facf8 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -13,6 +13,7 @@ use Misuzu\Messages\MessagesContext; use Misuzu\News\News; use Misuzu\Perms\Permissions; use Misuzu\Profile\ProfileFields; +use Misuzu\Redirects\RedirectsContext; use Misuzu\Routing\{BackedRoutingContext,RoutingContext}; use Misuzu\Users\{UsersContext,UserInfo}; use RPCii\HmacVerificationProvider; @@ -43,8 +44,9 @@ class MisuzuContext { public private(set) AuthContext $authCtx; public private(set) ForumContext $forumCtx; - private MessagesContext $messagesCtx; + public private(set) MessagesContext $messagesCtx; public private(set) UsersContext $usersCtx; + public private(set) RedirectsContext $redirectsCtx; public private(set) ProfileFields $profileFields; @@ -68,6 +70,7 @@ class MisuzuContext { $this->forumCtx = new ForumContext($dbConn); $this->messagesCtx = new MessagesContext($dbConn); $this->usersCtx = new UsersContext($dbConn); + $this->redirectsCtx = new RedirectsContext($dbConn, $config->scopeTo('redirects')); $this->auditLog = new AuditLog($dbConn); $this->changelog = new Changelog($dbConn); @@ -286,6 +289,23 @@ class MisuzuContext { )); } + if(in_array('redirect', $purposes)) { + $scopedInfo = $hostInfo->scopeTo('redirect'); + $scopedCtx = $routingCtx->scopeTo( + $scopedInfo->getString('name'), + $scopedInfo->getString('path') + ); + + $scopedCtx->register(new Redirects\LandingRedirectsRoutes); + $scopedCtx->register(new Redirects\AliasRedirectsRoutes($this->redirectsCtx)); + $scopedCtx->register(new Redirects\IncrementalRedirectsRoutes($this->redirectsCtx)); + $scopedCtx->register(new Redirects\SocialRedirectsRoutes( + $this->redirectsCtx, + $this->getWebAssetInfo(...) + )); + $scopedCtx->register(new Redirects\NamedRedirectsRoutes($this->redirectsCtx)); + } + return $routingCtx; } } diff --git a/src/Redirects/AliasRedirectsRoutes.php b/src/Redirects/AliasRedirectsRoutes.php new file mode 100644 index 00000000..676c1237 --- /dev/null +++ b/src/Redirects/AliasRedirectsRoutes.php @@ -0,0 +1,49 @@ +<?php +namespace Misuzu\Redirects; + +use Index\Config\Config; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerTrait}; + +class AliasRedirectsRoutes implements RouteHandler { + use RouteHandlerTrait; + + private Config $config; + + public function __construct( + private RedirectsContext $redirectsCtx, + ) { + $this->config = $redirectsCtx->config->scopeTo('alias'); + } + + private function redirect(HttpResponseBuilder $response, HttpRequest $request, string $configKey, string $value) { + $url = sprintf($this->config->getString($configKey), rawurlencode($value)); + + $params = $request->getParamString(); + if(!empty($params)) + $url .= (strpos($url, '?') === false ? '?' : '&') . $params; + + $response->redirect($url, true); + } + + #[HttpGet('/[up]([0-9]+)')] + #[HttpGet('/[up]/([A-Za-z0-9\-_]+)')] + public function getProfileRedirect(HttpResponseBuilder $response, HttpRequest $request, string $userId) { + $this->redirect($response, $request, 'user_profile', $userId); + } + + #[HttpGet('/fc?/?([0-9]+)')] + public function getForumCategoryRedirect(HttpResponseBuilder $response, HttpRequest $request, string $categoryId) { + $this->redirect($response, $request, 'forum_category', $categoryId); + } + + #[HttpGet('/ft/?([0-9]+)')] + public function getForumTopicRedirect(HttpResponseBuilder $response, HttpRequest $request, string $topicId) { + $this->redirect($response, $request, 'forum_topic', $topicId); + } + + #[HttpGet('/fp/?([0-9]+)')] + public function getForumPostRedirect(HttpResponseBuilder $response, HttpRequest $request, string $postId) { + $this->redirect($response, $request, 'forum_post', $postId); + } +} diff --git a/src/Redirects/IncrementalRedirectInfo.php b/src/Redirects/IncrementalRedirectInfo.php new file mode 100644 index 00000000..8956b470 --- /dev/null +++ b/src/Redirects/IncrementalRedirectInfo.php @@ -0,0 +1,30 @@ +<?php +namespace Misuzu\Redirects; + +use Carbon\CarbonImmutable; +use Index\XNumber; +use Index\Db\DbResult; + +class IncrementalRedirectInfo implements RedirectInfo { + public function __construct( + public private(set) string $id, + public private(set) string $url, + public private(set) int $createdTime, + ) {} + + public static function fromResult(DbResult $result): IncrementalRedirectInfo { + return new IncrementalRedirectInfo( + id: $result->getString(0), + url: $result->getString(1), + createdTime: $result->getInteger(2), + ); + } + + public string $id62 { + get => XNumber::toBase62($this->id); + } + + public CarbonImmutable $createdAt { + get => CarbonImmutable::createFromTimestampUTC($this->createdTime); + } +} diff --git a/src/Redirects/IncrementalRedirectsData.php b/src/Redirects/IncrementalRedirectsData.php new file mode 100644 index 00000000..2d6739c8 --- /dev/null +++ b/src/Redirects/IncrementalRedirectsData.php @@ -0,0 +1,67 @@ +<?php +namespace Misuzu\Redirects; + +use RuntimeException; +use Index\Db\{DbConnection,DbStatementCache}; + +class IncrementalRedirectsData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + public function getIncrementalRedirects(): iterable { + $query = <<<SQL + SELECT redir_id, redir_url, UNIX_TIMESTAMP(redir_created) + FROM msz_redirects_incremental + SQL; + + $stmt = $this->cache->get($query); + $stmt->execute(); + + return $stmt->getResult()->getIterator(IncrementalRedirectInfo::fromResult(...)); + } + + public const int INC_BY_ID = 1; + public const int INC_BY_URL = 2; + + public function getIncrementalRedirect(string $value, int $field = self::INC_BY_ID): IncrementalRedirectInfo { + $args = 0; + $query = <<<SQL + SELECT redir_id, redir_url, UNIX_TIMESTAMP(redir_created) + FROM msz_redirects_incremental + SQL; + if(($field & self::INC_BY_ID) > 0) { + ++$args; + $query .= ' WHERE redir_id = ?'; + } + if(($field & self::INC_BY_URL) > 0) + $query .= sprintf(' %s redir_url = ?', ++$args > 1 ? 'OR' : 'WHERE'); + + $stmt = $this->cache->get($query); + while(--$args >= 0) + $stmt->nextParameter($value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('incremental redirect not found'); + + return IncrementalRedirectInfo::fromResult($result); + } + + public function createIncrementalRedirect(string $url): IncrementalRedirectInfo { + $stmt = $this->cache->get('INSERT INTO msz_redirects_incremental (redir_url) VALUES (?)'); + $stmt->nextParameter($url); + $stmt->execute(); + + return $this->getIncrementalRedirect((string)$stmt->getLastInsertId()); + } + + public function deleteIncrementalRedirect(IncrementalRedirectInfo|string $redirectInfo): void { + $stmt = $this->cache->get('DELETE msz_redirects_incremental WHERE redir_id = ?'); + $stmt->nextParameter($redirectInfo instanceof IncrementalRedirectInfo ? $redirectInfo->id : $redirectInfo); + $stmt->execute(); + } +} diff --git a/src/Redirects/IncrementalRedirectsRoutes.php b/src/Redirects/IncrementalRedirectsRoutes.php new file mode 100644 index 00000000..f4fbe1a3 --- /dev/null +++ b/src/Redirects/IncrementalRedirectsRoutes.php @@ -0,0 +1,67 @@ +<?php +namespace Misuzu\Redirects; + +use RuntimeException; +use Index\XNumber; +use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerTrait}; + +class IncrementalRedirectsRoutes implements RouteHandler { + use RouteHandlerTrait; + + public function __construct( + private RedirectsContext $redirectsCtx, + ) {} + + #[HttpGet('/[bg]/([A-Za-z0-9]+)')] + public function getIncrementalRedirect(HttpResponseBuilder $response, HttpRequest $request, string $linkId) { + $linkId = XNumber::fromBase62($linkId); + + try { + $redirectInfo = $this->redirectsCtx->incremental->getIncrementalRedirect($linkId); + } catch(RuntimeException $ex) { + return 404; + } + + $response->redirect($redirectInfo->url, true); + } + + #[HttpPost('/satori/create')] + public function postIncrementalRedirect(HttpResponseBuilder $response, HttpRequest $request) { + $content = $request->getContent(); + if(!($content instanceof FormHttpContent)) + return 400; + + $config = $this->redirectsCtx->config->scopeTo('incremental'); + $url = (string)$content->getParam('u'); + $time = (int)$content->getParam('t', FILTER_SANITIZE_NUMBER_INT); + $sign = base64_decode((string)$content->getParam('s')); + $hash = hash_hmac('sha256', "satori#create#{$time}#{$url}", $config->getString('secret'), true); + + if(!hash_equals($hash, $sign)) + return 403; + + $currentTime = time(); + if($time < $currentTime - 30 || $time > $currentTime + 30) + return 403; + + try { + $redirectInfo = $this->redirectsCtx->incremental->getIncrementalRedirect( + $url, + IncrementalRedirectsData::INC_BY_URL + ); + } catch(RuntimeException $ex) { + try { + $redirectInfo = $this->redirectsCtx->incremental->createIncrementalRedirect($url); + } catch(\Throwable $ex) { + return [ + 'url' => (string)$ex, + ]; + } + } + + return [ + 'url' => sprintf($config->getString('format'), $redirectInfo->id62), + ]; + } +} diff --git a/src/Redirects/LandingRedirectsRoutes.php b/src/Redirects/LandingRedirectsRoutes.php new file mode 100644 index 00000000..62009d6c --- /dev/null +++ b/src/Redirects/LandingRedirectsRoutes.php @@ -0,0 +1,21 @@ +<?php +namespace Misuzu\Redirects; + +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerTrait}; + +class LandingRedirectsRoutes implements RouteHandler { + use RouteHandlerTrait; + + #[HttpGet('/')] + public function getIndex() { + return <<<HTML + <!doctype html> + <meta charset=utf-8> + <title>Redirects</title> + <p>Short URL Service for <a href=/home rel=noopener>Flashii</a></p> + <p>This is a temporary landing page until backend is less tied up.</p> + <p><a href=https://flash.moe target=_blank rel=noopener>flashwave</a> 2017-2025</p> + HTML; + } +} diff --git a/src/Redirects/NamedRedirectInfo.php b/src/Redirects/NamedRedirectInfo.php new file mode 100644 index 00000000..361a0985 --- /dev/null +++ b/src/Redirects/NamedRedirectInfo.php @@ -0,0 +1,30 @@ +<?php +namespace Misuzu\Redirects; + +use Carbon\CarbonImmutable; +use Index\XNumber; +use Index\Db\DbResult; + +class NamedRedirectInfo implements RedirectInfo { + public function __construct( + public private(set) string $id, + public private(set) ?string $vanity, + public private(set) string $url, + public private(set) int $clicks, + public private(set) int $createdTime, + ) {} + + public static function fromResult(DbResult $result): NamedRedirectInfo { + return new NamedRedirectInfo( + id: $result->getString(0), + vanity: $result->getStringOrNull(1), + url: $result->getString(2), + clicks: $result->getInteger(3), + createdTime: $result->getInteger(4), + ); + } + + public CarbonImmutable $createdAt { + get => CarbonImmutable::createFromTimestampUTC($this->createdTime); + } +} diff --git a/src/Redirects/NamedRedirectsData.php b/src/Redirects/NamedRedirectsData.php new file mode 100644 index 00000000..326beb9f --- /dev/null +++ b/src/Redirects/NamedRedirectsData.php @@ -0,0 +1,80 @@ +<?php +namespace Misuzu\Redirects; + +use RuntimeException; +use Index\Db\{DbConnection,DbStatementCache}; + +class NamedRedirectsData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + public function getNamedRedirects(): iterable { + $query = <<<SQL + SELECT redir_id, redir_vanity, redir_url, redir_clicks, UNIX_TIMESTAMP(redir_created) + FROM msz_redirects_named + SQL; + + $stmt = $this->cache->get($query); + $stmt->execute(); + + return $stmt->getResult()->getIterator(NamedRedirectInfo::fromResult(...)); + } + + public const int NAMED_BY_ID = 1; + public const int NAMED_BY_VANITY = 2; + public const int NAMED_BY_URL = 4; + + public function getNamedRedirect(string $value, int $field = self::NAMED_BY_ID): NamedRedirectInfo { + $args = 0; + $query = <<<SQL + SELECT redir_id, redir_vanity, redir_url, redir_clicks, UNIX_TIMESTAMP(redir_created) + FROM msz_redirects_named + SQL; + if(($field & self::NAMED_BY_ID) > 0) { + ++$args; + $query .= ' WHERE redir_id = ?'; + } + if(($field & self::NAMED_BY_VANITY) > 0) + $query .= sprintf(' %s redir_vanity = ?', ++$args > 1 ? 'OR' : 'WHERE'); + if(($field & self::NAMED_BY_URL) > 0) + $query .= sprintf(' %s redir_url = ?', ++$args > 1 ? 'OR' : 'WHERE'); + + $stmt = $this->cache->get($query); + while(--$args >= 0) + $stmt->nextParameter($value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('named redirect not found'); + + return NamedRedirectInfo::fromResult($result); + } + + public function createNamedRedirect( + string $vanity, + string $url + ): NamedRedirectInfo { + $stmt = $this->cache->get('INSERT INTO msz_redirects_named (redir_vanity, redir_url) VALUES (?, ?)'); + $stmt->nextParameter($vanity); + $stmt->nextParameter($url); + $stmt->execute(); + + return $this->getNamedRedirect((string)$stmt->getLastInsertId(), self::NAMED_BY_ID); + } + + public function deleteNamedRedirect(NamedRedirectInfo|string $redirectInfo): void { + $stmt = $this->cache->get('DELETE msz_redirects_named WHERE redir_id = ?'); + $stmt->nextParameter($redirectInfo instanceof NamedRedirectInfo ? $redirectInfo->id : $redirectInfo); + $stmt->execute(); + } + + public function incrementNamedRedirectClicks(NamedRedirectInfo|string $redirectInfo): void { + $stmt = $this->cache->get('UPDATE msz_redirects_named SET redir_clicks = redir_clicks + 1 WHERE redir_id = ?'); + $stmt->nextParameter($redirectInfo instanceof NamedRedirectInfo ? $redirectInfo->id : $redirectInfo); + $stmt->execute(); + } +} diff --git a/src/Redirects/NamedRedirectsRoutes.php b/src/Redirects/NamedRedirectsRoutes.php new file mode 100644 index 00000000..230487de --- /dev/null +++ b/src/Redirects/NamedRedirectsRoutes.php @@ -0,0 +1,38 @@ +<?php +namespace Misuzu\Redirects; + +use RuntimeException; +use Index\Config\Config; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerTrait}; + +class NamedRedirectsRoutes implements RouteHandler { + use RouteHandlerTrait; + + private Config $config; + + public function __construct( + private RedirectsContext $redirectsCtx, + ) { + $this->config = $redirectsCtx->config->scopeTo('named'); + } + + #[HttpGet('/([A-Za-z0-9\-_]+)')] + public function getNamedRedirect(HttpResponseBuilder $response, HttpRequest $request, string $name) { + try { + $redirectInfo = $this->redirectsCtx->named->getNamedRedirect( + $name, + NamedRedirectsData::NAMED_BY_ID | NamedRedirectsData::NAMED_BY_VANITY + ); + } catch(RuntimeException $ex) { + return 404; + } + + $url = $redirectInfo->url; + $params = $request->getParamString(); + if(!empty($params)) + $url .= (strpos($url, '?') === false ? '?' : '&') . $params; + + $response->redirect($url, true); + } +} diff --git a/src/Redirects/RedirectInfo.php b/src/Redirects/RedirectInfo.php new file mode 100644 index 00000000..c17f9ef0 --- /dev/null +++ b/src/Redirects/RedirectInfo.php @@ -0,0 +1,11 @@ +<?php +namespace Misuzu\Redirects; + +use Carbon\CarbonImmutable; + +interface RedirectInfo { + public string $id { get; } + public string $url { get; } + public int $createdTime { get; } + public CarbonImmutable $createdAt { get; } +} diff --git a/src/Redirects/RedirectsContext.php b/src/Redirects/RedirectsContext.php new file mode 100644 index 00000000..768f9cab --- /dev/null +++ b/src/Redirects/RedirectsContext.php @@ -0,0 +1,18 @@ +<?php +namespace Misuzu\Redirects; + +use Index\Config\Config; +use Index\Db\DbConnection; + +class RedirectsContext { + public private(set) NamedRedirectsData $named; + public private(set) IncrementalRedirectsData $incremental; + + public function __construct( + DbConnection $dbConn, + public private(set) Config $config + ) { + $this->named = new NamedRedirectsData($dbConn); + $this->incremental = new IncrementalRedirectsData($dbConn); + } +} diff --git a/src/Redirects/SocialRedirectsRoutes.php b/src/Redirects/SocialRedirectsRoutes.php new file mode 100644 index 00000000..863e6c67 --- /dev/null +++ b/src/Redirects/SocialRedirectsRoutes.php @@ -0,0 +1,82 @@ +<?php +namespace Misuzu\Redirects; + +use InvalidArgumentException; +use Index\Config\Config; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerTrait}; + +class SocialRedirectsRoutes implements RouteHandler { + use RouteHandlerTrait; + + private Config $config; + private $getWebAssetInfo; + + public function __construct( + RedirectsContext $redirectsCtx, + $getWebAssetInfo + ) { + $this->config = $redirectsCtx->config->scopeTo('social'); + if(!is_callable($getWebAssetInfo)) + throw new InvalidArgumentException('$getWebAssetInfo must be callable'); + $this->getWebAssetInfo = $getWebAssetInfo; + } + + #[HttpGet('/bsky/((did:[a-z0-9]+:[A-Za-z0-9.\-_:%]+)|(([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])))')] + public function getBlueskyRedirect(HttpResponseBuilder $response, HttpRequest $request, string $handle) { + $did = null; + + if(str_starts_with($handle, 'did:')) + $did = $handle; + else { + $timeout = ini_get('default_socket_timeout'); + try { + ini_set('default_socket_timeout', 3); + $records = dns_get_record(sprintf('_atproto.%s', $handle), DNS_TXT); + + if(is_array($records)) + foreach($records as $record) + if(array_key_exists('txt', $record) && str_starts_with($record['txt'], 'did=')) { + $did = trim(substr(trim($record['txt']), 4)); + break; + } + } finally { + ini_set('default_socket_timeout', $timeout); + } + } + + $format = $this->config->getString('bsky_profile', 'https://bsky.app/profile/%s'); + if(is_string($did)) { + $response->redirect(sprintf($format, $did), true); + return; + } + + $handle = rawurlencode($handle); + $script = ($this->getWebAssetInfo)()->{'redir-bsky.js'} ?? ''; + + return <<<HTML + <!doctype html> + <meta charset=utf-8> + <title>Redirecting to Bluesky profile...</title> + <div class=js-status><noscript>Javascript must be enabled for Bluesky redirects to work.</noscript></div> + <script>const BSKY_FORMAT = '{$format}'; const BSKY_HANDLE = decodeURIComponent('{$handle}');</script> + <script src="{$script}"></script> + HTML; + } + + #[HttpGet('/fedi/([A-Za-z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})')] + public function getFediverseRedirect(HttpResponseBuilder $response, HttpRequest $request, string $userName, string $instance) { + $userName = rawurlencode($userName); + $instance = rawurlencode($instance); + $script = ($this->getWebAssetInfo)()->{'redir-fedi.js'} ?? ''; + + return <<<HTML + <!doctype html> + <meta charset=utf-8> + <title>Redirecting to Fediverse profile...</title> + <div class=js-status><noscript>Javascript must be enabled for Fediverse redirects to work.</noscript></div> + <script>const FEDI_USERNAME = '{$userName}'; const FEDI_INSTANCE = decodeURIComponent('{$instance}');</script> + <script src="{$script}"></script> + HTML; + } +}