Built redirector service into Misuzu.

This commit is contained in:
flash 2025-01-29 21:07:12 +00:00
parent a83cfdc595
commit f2233c5390
18 changed files with 612 additions and 2 deletions

View file

@ -1 +1 @@
20250104
20250129

View file

@ -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);
})();

View file

@ -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);
})();

View file

@ -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', },

View file

@ -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

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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),
];
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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; }
}

View file

@ -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);
}
}

View file

@ -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;
}
}