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