Built redirector service into Misuzu.
This commit is contained in:
parent
a83cfdc595
commit
f2233c5390
18 changed files with 612 additions and 2 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
||||||
20250104
|
20250129
|
||||||
|
|
25
assets/redir-bsky.js/main.js
Normal file
25
assets/redir-bsky.js/main.js
Normal 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);
|
||||||
|
})();
|
39
assets/redir-fedi.js/main.js
Normal file
39
assets/redir-fedi.js/main.js
Normal 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);
|
||||||
|
})();
|
2
build.js
2
build.js
|
@ -18,6 +18,8 @@ const fs = require('fs');
|
||||||
const tasks = {
|
const tasks = {
|
||||||
js: [
|
js: [
|
||||||
{ source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.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: [
|
css: [
|
||||||
{ source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
|
{ source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
|
||||||
|
|
|
@ -6,3 +6,6 @@ database:dsn mariadb://<user>:<pass>@<host>/<name>?charset=utf8mb4
|
||||||
;sentry:dsn https://sentry dsn here
|
;sentry:dsn https://sentry dsn here
|
||||||
;sentry:tracesRate 1.0
|
;sentry:tracesRate 1.0
|
||||||
;sentry:profilesRate 1.0
|
;sentry:profilesRate 1.0
|
||||||
|
|
||||||
|
domain:localhost main redirect
|
||||||
|
domain:localhost:redirect:path /go
|
||||||
|
|
28
database/2025_01_29_002819_create_redirect_tables.php
Normal file
28
database/2025_01_29_002819_create_redirect_tables.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ use Misuzu\Messages\MessagesContext;
|
||||||
use Misuzu\News\News;
|
use Misuzu\News\News;
|
||||||
use Misuzu\Perms\Permissions;
|
use Misuzu\Perms\Permissions;
|
||||||
use Misuzu\Profile\ProfileFields;
|
use Misuzu\Profile\ProfileFields;
|
||||||
|
use Misuzu\Redirects\RedirectsContext;
|
||||||
use Misuzu\Routing\{BackedRoutingContext,RoutingContext};
|
use Misuzu\Routing\{BackedRoutingContext,RoutingContext};
|
||||||
use Misuzu\Users\{UsersContext,UserInfo};
|
use Misuzu\Users\{UsersContext,UserInfo};
|
||||||
use RPCii\HmacVerificationProvider;
|
use RPCii\HmacVerificationProvider;
|
||||||
|
@ -43,8 +44,9 @@ class MisuzuContext {
|
||||||
|
|
||||||
public private(set) AuthContext $authCtx;
|
public private(set) AuthContext $authCtx;
|
||||||
public private(set) ForumContext $forumCtx;
|
public private(set) ForumContext $forumCtx;
|
||||||
private MessagesContext $messagesCtx;
|
public private(set) MessagesContext $messagesCtx;
|
||||||
public private(set) UsersContext $usersCtx;
|
public private(set) UsersContext $usersCtx;
|
||||||
|
public private(set) RedirectsContext $redirectsCtx;
|
||||||
|
|
||||||
public private(set) ProfileFields $profileFields;
|
public private(set) ProfileFields $profileFields;
|
||||||
|
|
||||||
|
@ -68,6 +70,7 @@ class MisuzuContext {
|
||||||
$this->forumCtx = new ForumContext($dbConn);
|
$this->forumCtx = new ForumContext($dbConn);
|
||||||
$this->messagesCtx = new MessagesContext($dbConn);
|
$this->messagesCtx = new MessagesContext($dbConn);
|
||||||
$this->usersCtx = new UsersContext($dbConn);
|
$this->usersCtx = new UsersContext($dbConn);
|
||||||
|
$this->redirectsCtx = new RedirectsContext($dbConn, $config->scopeTo('redirects'));
|
||||||
|
|
||||||
$this->auditLog = new AuditLog($dbConn);
|
$this->auditLog = new AuditLog($dbConn);
|
||||||
$this->changelog = new Changelog($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;
|
return $routingCtx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
49
src/Redirects/AliasRedirectsRoutes.php
Normal file
49
src/Redirects/AliasRedirectsRoutes.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
30
src/Redirects/IncrementalRedirectInfo.php
Normal file
30
src/Redirects/IncrementalRedirectInfo.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
67
src/Redirects/IncrementalRedirectsData.php
Normal file
67
src/Redirects/IncrementalRedirectsData.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
67
src/Redirects/IncrementalRedirectsRoutes.php
Normal file
67
src/Redirects/IncrementalRedirectsRoutes.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
21
src/Redirects/LandingRedirectsRoutes.php
Normal file
21
src/Redirects/LandingRedirectsRoutes.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
30
src/Redirects/NamedRedirectInfo.php
Normal file
30
src/Redirects/NamedRedirectInfo.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
80
src/Redirects/NamedRedirectsData.php
Normal file
80
src/Redirects/NamedRedirectsData.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
38
src/Redirects/NamedRedirectsRoutes.php
Normal file
38
src/Redirects/NamedRedirectsRoutes.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
11
src/Redirects/RedirectInfo.php
Normal file
11
src/Redirects/RedirectInfo.php
Normal 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; }
|
||||||
|
}
|
18
src/Redirects/RedirectsContext.php
Normal file
18
src/Redirects/RedirectsContext.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
82
src/Redirects/SocialRedirectsRoutes.php
Normal file
82
src/Redirects/SocialRedirectsRoutes.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue