From 5ba8b30047c8d0d43db3443a6816882c8a9e3cae Mon Sep 17 00:00:00 2001
From: flashwave <me@flash.moe>
Date: Mon, 24 Mar 2025 00:20:41 +0000
Subject: [PATCH] Updated to latest Index as well as some minor bug fixes.

---
 assets/misuzu.js/comments/api.js              |  14 +-
 assets/misuzu.js/messages/messages.js         |   2 +-
 assets/oauth2.js/authorise.js                 |   2 +-
 composer.json                                 |   8 +-
 composer.lock                                 | 135 ++++++++----
 public-legacy/auth/login.php                  |  29 +--
 public-legacy/auth/password.php               |  30 +--
 public-legacy/auth/register.php               |  27 ++-
 public-legacy/auth/twofactor.php              |  13 +-
 public-legacy/forum/leaderboard.php           |   6 +-
 public-legacy/forum/posting.php               |  24 +--
 public-legacy/manage/changelog/change.php     |  14 +-
 public-legacy/manage/changelog/tag.php        |   6 +-
 public-legacy/manage/forum/index.php          |   7 +-
 public-legacy/manage/forum/redirs.php         |   8 +-
 public-legacy/manage/general/emoticon.php     |  10 +-
 public-legacy/manage/general/emoticons.php    |   6 +-
 .../manage/general/setting-delete.php         |   2 +-
 public-legacy/manage/general/setting.php      |  12 +-
 public-legacy/manage/news/category.php        |   6 +-
 public-legacy/manage/news/post.php            |   8 +-
 public-legacy/manage/users/ban.php            |  16 +-
 public-legacy/manage/users/bans.php           |   6 +-
 public-legacy/manage/users/note.php           |  14 +-
 public-legacy/manage/users/notes.php          |   6 +-
 public-legacy/manage/users/role.php           |  28 ++-
 public-legacy/manage/users/user.php           |  10 +-
 public-legacy/manage/users/warning.php        |   8 +-
 public-legacy/manage/users/warnings.php       |   6 +-
 public-legacy/members.php                     |   6 +-
 public-legacy/profile.php                     | 201 ++++++++----------
 public-legacy/settings/sessions.php           |   2 +-
 public/index.php                              |  62 +++++-
 src/Auth/AuthTokenCookie.php                  |   4 +-
 src/CSRF.php                                  |   4 +-
 src/Changelog/ChangelogRoutes.php             |  11 +-
 src/Comments/CommentsRoutes.php               |  96 ++++-----
 src/DatabaseContext.php                       |   7 +-
 src/Forum/ForumCategoriesRoutes.php           |  11 +-
 src/Forum/ForumPostsRoutes.php                |  11 +-
 src/Forum/ForumTopicsRoutes.php               |  18 +-
 src/Home/HomeRoutes.php                       |  15 +-
 src/Info/InfoRoutes.php                       |   9 +-
 src/LegacyRoutes.php                          |  90 ++++----
 src/Messages/MessagesRoutes.php               | 113 +++++-----
 src/MisuzuContext.php                         |  28 +--
 src/News/NewsRoutes.php                       |  11 +-
 src/OAuth2/OAuth2ApiRoutes.php                | 116 ++++------
 src/OAuth2/OAuth2WebRoutes.php                |  58 +++--
 src/Pagination.php                            |   4 +-
 src/Perm.php                                  |  20 +-
 src/Redirects/AliasRedirectsRoutes.php        |  13 +-
 src/Redirects/IncrementalRedirectsRoutes.php  |  25 +--
 src/Redirects/LandingRedirectsRoutes.php      |   5 +-
 src/Redirects/NamedRedirectsRoutes.php        |   6 +-
 src/Redirects/SocialRedirectsRoutes.php       |  13 +-
 src/Routing/BackedRoutingContext.php          |  39 ----
 src/Routing/RoutingContext.php                |  33 ++-
 src/Routing/RoutingErrorHandler.php           |  25 ++-
 src/Routing/ScopedRoutingContext.php          |  27 ---
 src/Satori/SatoriRoutes.php                   |  33 +--
 src/SharpChat/SharpChatRoutes.php             | 145 +++++--------
 src/Tools.php                                 |   4 +-
 src/Users/Assets/AssetsRoutes.php             |  19 +-
 src/WebFinger/WebFingerRoutes.php             |  12 +-
 templates/auth/login.twig                     |   6 +-
 templates/auth/password_forgot.twig           |   2 +-
 templates/auth/password_reset.twig            |  10 +-
 templates/auth/register.twig                  |  10 +-
 templates/auth/twofactor.twig                 |   6 +-
 templates/forum/posting.twig                  |  18 +-
 templates/manage/users/ban.twig               |   2 +-
 templates/manage/users/note.twig              |   2 +-
 templates/manage/users/warning.twig           |   2 +-
 templates/profile/_layout/header.twig         |   2 +-
 templates/profile/index.twig                  |  10 +-
 tools/render-tpl                              |   8 +-
 77 files changed, 900 insertions(+), 937 deletions(-)
 delete mode 100644 src/Routing/BackedRoutingContext.php
 delete mode 100644 src/Routing/ScopedRoutingContext.php

diff --git a/assets/misuzu.js/comments/api.js b/assets/misuzu.js/comments/api.js
index 5bb680d6..27c3908f 100644
--- a/assets/misuzu.js/comments/api.js
+++ b/assets/misuzu.js/comments/api.js
@@ -1,4 +1,12 @@
 const MszCommentsApi = (() => {
+    const argsToFormData = args => {
+        const formData = new FormData;
+        for(const name in args)
+            formData.append(name, args[name]);
+
+        return formData;
+    };
+
     return {
         getCategory: async name => {
             if(typeof name !== 'string' || name.trim() === '')
@@ -62,7 +70,7 @@ const MszCommentsApi = (() => {
             const { status, body } = await $xhr.post(
                 '/comments/posts',
                 { csrf: true, type: 'json' },
-                args
+                argsToFormData(args)
             );
             if(status !== 201)
                 throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
@@ -75,10 +83,10 @@ const MszCommentsApi = (() => {
             if(typeof args !== 'object' || args === null)
                 throw new Error('args must be a non-null object');
 
-            const { status, body } = await $xhr.post(
+            const { status, body } = await $xhr.patch(
                 `/comments/posts/${post}`,
                 { csrf: true, type: 'json' },
-                args
+                argsToFormData(args)
             );
             if(status !== 200)
                 throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
diff --git a/assets/misuzu.js/messages/messages.js b/assets/misuzu.js/messages/messages.js
index a58a7ce0..f03c72c7 100644
--- a/assets/misuzu.js/messages/messages.js
+++ b/assets/misuzu.js/messages/messages.js
@@ -52,7 +52,7 @@ const MszMessages = () => {
         formData.append('format', format);
         formData.append('draft', draft);
 
-        const { body } = await $xhr.post(`/messages/${encodeURIComponent(messageId)}`, { type: 'json', csrf: true }, formData);
+        const { body } = await $xhr.patch(`/messages/${encodeURIComponent(messageId)}`, { type: 'json', csrf: true }, formData);
         if(body.error !== undefined)
             throw body.error;
 
diff --git a/assets/oauth2.js/authorise.js b/assets/oauth2.js/authorise.js
index 219de4f0..490ff014 100644
--- a/assets/oauth2.js/authorise.js
+++ b/assets/oauth2.js/authorise.js
@@ -188,7 +188,7 @@ const MszOAuth2Authorise = async () => {
                 params.scope = scope;
 
             try {
-                const { body } = await $xhr.post('/oauth2/authorise', { authed: true, csrf: true, type: 'json' }, params);
+                const { body } = await $xhr.post('/oauth2/authorize', { authed: true, csrf: true, type: 'json' }, params);
                 if(!body)
                     throw 'authorisation failed';
                 if(typeof body.error === 'string')
diff --git a/composer.json b/composer.json
index 264cd34e..b8128202 100644
--- a/composer.json
+++ b/composer.json
@@ -2,8 +2,8 @@
     "require": {
         "php": ">=8.4",
         "ext-mbstring": "*",
-        "flashwave/index": "^0.2501",
-        "flashii/rpcii": "~4.0",
+        "flashwave/index": "^0.2503",
+        "flashii/rpcii": "~5.0",
         "erusev/parsedown": "~1.7",
         "chillerlan/php-qrcode": "~5.0",
         "symfony/mailer": "~7.2",
@@ -11,9 +11,9 @@
         "sentry/sdk": "~4.0",
         "nesbot/carbon": "~3.8",
         "vlucas/phpdotenv": "~5.6",
-        "filp/whoops": "~2.17",
+        "filp/whoops": "~2.18",
         "phpseclib/phpseclib": "~3.0",
-        "guzzlehttp/guzzle": "~7.0"
+        "guzzlehttp/guzzle": "~7.9"
     },
     "autoload": {
         "classmap": [
diff --git a/composer.lock b/composer.lock
index a9ec0a30..87e16339 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "1045c8f605203fae19361d659fb64c54",
+    "content-hash": "baa15cf491eb4500360fa9c20fd42a4d",
     "packages": [
         {
             "name": "carbonphp/carbon-doctrine-types",
@@ -313,16 +313,16 @@
         },
         {
             "name": "egulias/email-validator",
-            "version": "4.0.3",
+            "version": "4.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/egulias/EmailValidator.git",
-                "reference": "b115554301161fa21467629f1e1391c1936de517"
+                "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b115554301161fa21467629f1e1391c1936de517",
-                "reference": "b115554301161fa21467629f1e1391c1936de517",
+                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
+                "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
                 "shasum": ""
             },
             "require": {
@@ -368,7 +368,7 @@
             ],
             "support": {
                 "issues": "https://github.com/egulias/EmailValidator/issues",
-                "source": "https://github.com/egulias/EmailValidator/tree/4.0.3"
+                "source": "https://github.com/egulias/EmailValidator/tree/4.0.4"
             },
             "funding": [
                 {
@@ -376,7 +376,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-12-27T00:36:43+00:00"
+            "time": "2025-03-06T22:45:56+00:00"
         },
         {
             "name": "erusev/parsedown",
@@ -430,16 +430,16 @@
         },
         {
             "name": "filp/whoops",
-            "version": "2.17.0",
+            "version": "2.18.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/filp/whoops.git",
-                "reference": "075bc0c26631110584175de6523ab3f1652eb28e"
+                "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/075bc0c26631110584175de6523ab3f1652eb28e",
-                "reference": "075bc0c26631110584175de6523ab3f1652eb28e",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
+                "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
                 "shasum": ""
             },
             "require": {
@@ -489,7 +489,7 @@
             ],
             "support": {
                 "issues": "https://github.com/filp/whoops/issues",
-                "source": "https://github.com/filp/whoops/tree/2.17.0"
+                "source": "https://github.com/filp/whoops/tree/2.18.0"
             },
             "funding": [
                 {
@@ -497,24 +497,26 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-01-25T12:00:00+00:00"
+            "time": "2025-03-15T12:00:00+00:00"
         },
         {
             "name": "flashii/rpcii",
-            "version": "v4.0.0",
+            "version": "v5.0.1",
             "source": {
                 "type": "git",
                 "url": "https://patchii.net/flashii/rpcii-php.git",
-                "reference": "7849b2effd7ac878e7cf174eef7e27914799e60d"
+                "reference": "28c25e0a342173524f8894d03158663842e03252"
             },
             "require": {
                 "ext-msgpack": ">=2.2",
-                "flashwave/index": "^0.2501",
-                "php": ">=8.4"
+                "flashwave/index": "^0.2503",
+                "guzzlehttp/guzzle": "~7.0",
+                "php": ">=8.4",
+                "psr/http-client": "^1.0"
             },
             "require-dev": {
                 "phpstan/phpstan": "^2.1",
-                "phpunit/phpunit": "^11.5"
+                "phpunit/phpunit": "^12.0"
             },
             "type": "library",
             "autoload": {
@@ -536,25 +538,27 @@
             ],
             "description": "HTTP RPC client/server library.",
             "homepage": "https://railgun.sh/rpcii",
-            "time": "2025-01-29T22:00:17+00:00"
+            "time": "2025-03-21T19:38:29+00:00"
         },
         {
             "name": "flashwave/index",
-            "version": "v0.2501.221237",
+            "version": "v0.2503.230355",
             "source": {
                 "type": "git",
                 "url": "https://patchii.net/flash/index.git",
-                "reference": "fee9a65e3bca341be7401fe2e21b795630f15f2a"
+                "reference": "2372a113d26380176994f64ab99c42aaf2e9d98e"
             },
             "require": {
                 "ext-mbstring": "*",
                 "php": ">=8.4",
-                "twig/html-extra": "^3.18",
-                "twig/twig": "^3.18"
+                "psr/http-message": "^2.0",
+                "psr/http-server-handler": "^1.0",
+                "twig/html-extra": "^3.20",
+                "twig/twig": "^3.20"
             },
             "require-dev": {
                 "phpstan/phpstan": "^2.1",
-                "phpunit/phpunit": "^11.5"
+                "phpunit/phpunit": "^12.0"
             },
             "suggest": {
                 "ext-memcache": "Support for the Index\\Cache\\Memcached namespace (only if you can't use ext-memcached for some reason).",
@@ -591,7 +595,7 @@
             ],
             "description": "Composer package for the common library for my projects.",
             "homepage": "https://railgun.sh/index",
-            "time": "2025-01-22T12:38:11+00:00"
+            "time": "2025-03-23T03:45:47+00:00"
         },
         {
             "name": "graham-campbell/result-type",
@@ -982,16 +986,16 @@
         },
         {
             "name": "jean85/pretty-package-versions",
-            "version": "2.1.0",
+            "version": "2.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Jean85/pretty-package-versions.git",
-                "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10"
+                "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/3c4e5f62ba8d7de1734312e4fff32f67a8daaf10",
-                "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10",
+                "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
+                "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
                 "shasum": ""
             },
             "require": {
@@ -1001,8 +1005,9 @@
             "require-dev": {
                 "friendsofphp/php-cs-fixer": "^3.2",
                 "jean85/composer-provided-replaced-stub-package": "^1.0",
-                "phpstan/phpstan": "^1.4",
+                "phpstan/phpstan": "^2.0",
                 "phpunit/phpunit": "^7.5|^8.5|^9.6",
+                "rector/rector": "^2.0",
                 "vimeo/psalm": "^4.3 || ^5.0"
             },
             "type": "library",
@@ -1035,9 +1040,9 @@
             ],
             "support": {
                 "issues": "https://github.com/Jean85/pretty-package-versions/issues",
-                "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.0"
+                "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
             },
-            "time": "2024-11-18T16:19:46+00:00"
+            "time": "2025-03-19T14:43:43+00:00"
         },
         {
             "name": "matomo/device-detector",
@@ -1882,6 +1887,62 @@
             },
             "time": "2023-04-04T09:54:51+00:00"
         },
+        {
+            "name": "psr/http-server-handler",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-server-handler.git",
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0",
+                "psr/http-message": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Server\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP server-side request handler",
+            "keywords": [
+                "handler",
+                "http",
+                "http-interop",
+                "psr",
+                "psr-15",
+                "psr-7",
+                "request",
+                "response",
+                "server"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
+            },
+            "time": "2023-04-10T20:06:20+00:00"
+        },
         {
             "name": "psr/log",
             "version": "3.0.2",
@@ -3618,16 +3679,16 @@
     "packages-dev": [
         {
             "name": "phpstan/phpstan",
-            "version": "2.1.6",
+            "version": "2.1.10",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c"
+                "reference": "051a3b6b9b80df4ba3a7f801a8b53ad7d8f1c15f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
-                "reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/051a3b6b9b80df4ba3a7f801a8b53ad7d8f1c15f",
+                "reference": "051a3b6b9b80df4ba3a7f801a8b53ad7d8f1c15f",
                 "shasum": ""
             },
             "require": {
@@ -3672,7 +3733,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-02-19T15:46:42+00:00"
+            "time": "2025-03-23T14:57:55+00:00"
         }
     ],
     "aliases": [],
diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php
index 6c0f4eca..96cbe8f4 100644
--- a/public-legacy/auth/login.php
+++ b/public-legacy/auth/login.php
@@ -15,10 +15,15 @@ if($msz->authInfo->loggedIn) {
 if(!empty($_GET['resolve'])) {
     header('Content-Type: application/json; charset=utf-8');
 
-    try {
-        // Only works for usernames, this is by design
-        $userInfo = $msz->usersCtx->users->getUser((string)filter_input(INPUT_GET, 'name'), 'name');
-    } catch(Exception $ex) {
+    if(!empty($_GET['name']) && is_scalar($_GET['name']))
+        try {
+            // Only works for usernames, this is by design
+            $userInfo = $msz->usersCtx->users->getUser((string)$_GET['name'], 'name');
+        } catch(Exception $ex) {
+            unset($userInfo);
+        }
+
+    if(empty($userInfo)) {
         echo json_encode([
             'id' => 0,
             'name' => '',
@@ -62,16 +67,16 @@ if($siteIsPrivate) {
     $canResetPassword = true;
 }
 
-while(!empty($_POST['login']) && is_array($_POST['login'])) {
+while($_SERVER['REQUEST_METHOD'] === 'POST') {
     if(!CSRF::validateRequest()) {
         $notices[] = 'Was unable to verify the request, please try again!';
         break;
     }
 
-    $loginRedirect = empty($_POST['login']['redirect']) || !is_string($_POST['login']['redirect']) ? '' : $_POST['login']['redirect'];
+    $loginRedirect = empty($_POST['redirect']) || !is_string($_POST['redirect']) ? '' : $_POST['redirect'];
 
-    if(empty($_POST['login']['username']) || empty($_POST['login']['password'])
-        || !is_string($_POST['login']['username']) || !is_string($_POST['login']['password'])) {
+    if(empty($_POST['username']) || empty($_POST['password'])
+        || !is_string($_POST['username']) || !is_string($_POST['password'])) {
         $notices[] = "You didn't fill in a username and/or password.";
         break;
     }
@@ -91,7 +96,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
     $loginFailedError = "Invalid username or password, {$attemptsRemainingError}.";
 
     try {
-        $userInfo = $msz->usersCtx->users->getUser($_POST['login']['username'], 'login');
+        $userInfo = $msz->usersCtx->users->getUser($_POST['username'], 'login');
     } catch(Exception $ex) {
         $msz->authCtx->loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo);
         $notices[] = $loginFailedError;
@@ -110,14 +115,14 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
         break;
     }
 
-    if(!$pwInfo->verifyPassword($_POST['login']['password'])) {
+    if(!$pwInfo->verifyPassword($_POST['password'])) {
         $msz->authCtx->loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
         $notices[] = $loginFailedError;
         break;
     }
 
     if($pwInfo->needsRehash)
-        $msz->usersCtx->passwords->updateUserPassword($userInfo, $_POST['login']['password']);
+        $msz->usersCtx->passwords->updateUserPassword($userInfo, $_POST['password']);
 
     if(!empty($loginPermCat) && $loginPermVal > 0 && !$msz->perms->checkPermissions($loginPermCat, $loginPermVal, $userInfo)) {
         $notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
@@ -157,7 +162,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
 
 $welcomeMode = !empty($_GET['welcome']);
 $oauth2Mode = !empty($_GET['oauth2']);
-$loginUsername = !empty($_POST['login']['username']) && is_string($_POST['login']['username']) ? $_POST['login']['username'] : (
+$loginUsername = !empty($_POST['username']) && is_string($_POST['username']) ? $_POST['username'] : (
     !empty($_GET['username']) && is_string($_GET['username']) ? $_GET['username'] : ''
 );
 $loginRedirect = $welcomeMode ? $msz->urls->format('index') : (!empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : null) ?? $_SERVER['HTTP_REFERER'] ?? $msz->urls->format('index');
diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php
index a59b8f7e..7090bbe8 100644
--- a/public-legacy/auth/password.php
+++ b/public-legacy/auth/password.php
@@ -12,11 +12,10 @@ if($msz->authInfo->loggedIn) {
     return;
 }
 
-$reset = !empty($_POST['reset']) && is_array($_POST['reset']) ? $_POST['reset'] : [];
-$forgot = !empty($_POST['forgot']) && is_array($_POST['forgot']) ? $_POST['forgot'] : [];
-$userId = !empty($reset['user']) ? (int)$reset['user'] : (
-    !empty($_GET['user']) ? (int)$_GET['user'] : 0
-);
+$userId = !empty($_POST['user']) && is_scalar($_POST['user'])
+    ? (int)$_POST['user'] : (
+        !empty($_GET['user']) && is_scalar($_GET['user']) ? (int)$_GET['user'] : 0
+    );
 
 if($userId > 0)
     try {
@@ -30,17 +29,16 @@ $notices = [];
 $ipAddress = $_SERVER['REMOTE_ADDR'];
 $siteIsPrivate = $msz->config->getBoolean('private.enable');
 $canResetPassword = $siteIsPrivate ? $msz->config->getBoolean('private.allow_password_reset', true) : true;
-
 $remainingAttempts = $msz->authCtx->loginAttempts->countRemainingAttempts($ipAddress);
 
 while($canResetPassword) {
-    if(!empty($reset) && $userId > 0) {
+    if(!empty($_POST['verification']) && is_scalar($_POST['verification']) && !empty($userInfo)) {
         if(!CSRF::validateRequest()) {
             $notices[] = 'Was unable to verify the request, please try again!';
             break;
         }
 
-        $verifyCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : '';
+        $verifyCode = (string)$_POST['verification'];
 
         try {
             $tokenInfo = $msz->authCtx->recoveryTokens->getToken(verifyCode: $verifyCode);
@@ -53,9 +51,8 @@ while($canResetPassword) {
             break;
         }
 
-        $password = !empty($reset['password']) && is_array($reset['password']) ? $reset['password'] : [];
-        $passwordNew = !empty($password['new']) && is_string($password['new']) ? $password['new'] : '';
-        $passwordConfirm = !empty($password['confirm']) && is_string($password['confirm']) ? $password['confirm'] : '';
+        $passwordNew = !empty($_POST['password_new']) && is_scalar($_POST['password_new']) ? $_POST['password_new'] : '';
+        $passwordConfirm = !empty($_POST['password_confirm']) && is_scalar($_POST['password_confirm']) ? $_POST['password_confirm'] : '';
 
         if(empty($passwordNew) || empty($passwordConfirm)
             || $passwordNew !== $passwordConfirm) {
@@ -82,24 +79,19 @@ while($canResetPassword) {
         return;
     }
 
-    if(!empty($forgot)) {
+    if(!empty($_POST['email']) && is_scalar($_POST['email'])) {
         if(!CSRF::validateRequest()) {
             $notices[] = 'Was unable to verify the request, please try again!';
             break;
         }
 
-        if(empty($forgot['email']) || !is_string($forgot['email'])) {
-            $notices[] = "You didn't supply an e-mail address.";
-            break;
-        }
-
         if($remainingAttempts < 1) {
             $notices[] = "There are too many failed login attempts from your IP address, please try again later.";
             break;
         }
 
         try {
-            $forgotUser = $msz->usersCtx->users->getUser($forgot['email'], 'email');
+            $forgotUser = $msz->usersCtx->users->getUser((string)$_POST['email'], 'email');
         } catch(RuntimeException $ex) {
             unset($forgotUser);
         }
@@ -142,7 +134,7 @@ while($canResetPassword) {
 
 Template::render(isset($userInfo) ? 'auth.password_reset' : 'auth.password_forgot', [
     'password_notices' => $notices,
-    'password_email' => !empty($forget['email']) && is_string($forget['email']) ? $forget['email'] : '',
+    'password_email' => !empty($_POST['email']) && is_scalar($_POST['email']) ? (string)$_POST['email'] : '',
     'password_attempts_remaining' => $remainingAttempts,
     'password_user' => $userInfo ?? null,
     'password_verification' => $verifyCode ?? '',
diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php
index 0b58127c..44ce50ee 100644
--- a/public-legacy/auth/register.php
+++ b/public-legacy/auth/register.php
@@ -12,14 +12,13 @@ if($msz->authInfo->loggedIn) {
     return;
 }
 
-$register = !empty($_POST['register']) && is_array($_POST['register']) ? $_POST['register'] : [];
 $notices = [];
 $ipAddress = $_SERVER['REMOTE_ADDR'];
 $countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
 
 $remainingAttempts = $msz->authCtx->loginAttempts->countRemainingAttempts($ipAddress);
 
-while(!empty($register)) {
+while($_SERVER['REQUEST_METHOD'] === 'POST') {
     if(!CSRF::validateRequest()) {
         $notices[] = 'Was unable to verify the request, please try again!';
         break;
@@ -30,13 +29,13 @@ while(!empty($register)) {
         break;
     }
 
-    if(empty($register['username']) || empty($register['password']) || empty($register['email']) || empty($register['question'])
-        || !is_string($register['username']) || !is_string($register['password']) || !is_string($register['email']) || !is_string($register['question'])) {
+    if(empty($_POST['username']) || empty($_POST['password']) || empty($_POST['email']) || empty($_POST['question'])
+        || !is_scalar($_POST['username']) || !is_scalar($_POST['password']) || !is_scalar($_POST['email']) || !is_scalar($_POST['question'])) {
         $notices[] = "You haven't filled in all fields.";
         break;
     }
 
-    $checkSpamBot = mb_strtolower($register['question']);
+    $checkSpamBot = mb_strtolower($_POST['question']);
     $spamBotValid = [
         '21', 'twentyone', 'twenty-one', 'twenty one',
     ];
@@ -52,18 +51,18 @@ while(!empty($register)) {
         break;
     }
 
-    $usernameValidation = $msz->usersCtx->users->validateName($register['username']);
+    $usernameValidation = $msz->usersCtx->users->validateName($_POST['username']);
     if($usernameValidation !== '')
         $notices[] = $msz->usersCtx->users->validateNameText($usernameValidation);
 
-    $emailValidation = $msz->usersCtx->users->validateEMailAddress($register['email']);
+    $emailValidation = $msz->usersCtx->users->validateEMailAddress($_POST['email']);
     if($emailValidation !== '')
         $notices[] = $msz->usersCtx->users->validateEMailAddressText($emailValidation);
 
-    if($register['password_confirm'] !== $register['password'])
+    if($_POST['password_confirm'] !== $_POST['password'])
         $notices[] = "The given passwords don't match.";
 
-    $passwordValidation = UserPasswordsData::validateUserPassword($register['password']);
+    $passwordValidation = UserPasswordsData::validateUserPassword($_POST['password']);
     if($passwordValidation !== '')
         $notices[] = UserPasswordsData::validateUserPasswordText($passwordValidation);
 
@@ -74,13 +73,13 @@ while(!empty($register)) {
 
     try {
         $userInfo = $msz->usersCtx->users->createUser(
-            $register['username'],
-            $register['email'],
+            $_POST['username'],
+            $_POST['email'],
             $ipAddress,
             $countryCode,
             $defaultRoleInfo
         );
-        $msz->usersCtx->passwords->updateUserPassword($userInfo, $register['password']);
+        $msz->usersCtx->passwords->updateUserPassword($userInfo, $_POST['password']);
     } catch(RuntimeException $ex) {
         $notices[] = 'Something went wrong while creating your account, please alert an administrator or a developer about this!';
         break;
@@ -99,7 +98,7 @@ while(!empty($register)) {
 
 Template::render('auth.register', [
     'register_notices' => $notices,
-    'register_username' => !empty($register['username']) && is_string($register['username']) ? $register['username'] : '',
-    'register_email' => !empty($register['email']) && is_string($register['email']) ? $register['email'] : '',
+    'register_username' => !empty($_POST['username']) && is_scalar($_POST['username']) ? (string)$_POST['username'] : '',
+    'register_email' => !empty($_POST['email']) && is_scalar($_POST['email']) ? (string)$_POST['email'] : '',
     'register_restricted' => '',
 ]);
diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php
index f8a272e1..84083c89 100644
--- a/public-legacy/auth/twofactor.php
+++ b/public-legacy/auth/twofactor.php
@@ -16,13 +16,12 @@ if($msz->authInfo->loggedIn) {
 $ipAddress = $_SERVER['REMOTE_ADDR'];
 $countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
 $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
-$twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : [];
 $notices = [];
 
 $remainingAttempts = $msz->authCtx->loginAttempts->countRemainingAttempts($ipAddress);
 
-$tokenString = !empty($_GET['token']) && is_string($_GET['token']) ? $_GET['token'] : (
-    !empty($twofactor['token']) && is_string($twofactor['token']) ? $twofactor['token'] : ''
+$tokenString = !empty($_GET['token']) && is_scalar($_GET['token']) ? (string)$_GET['token'] : (
+    !empty($_POST['token']) && is_scalar($_POST['token']) ? (string)$_POST['token'] : ''
 );
 
 $tokenUserId = $msz->authCtx->tfaSessions->getTokenUserId($tokenString);
@@ -37,16 +36,16 @@ if($totpInfo === null) {
     return;
 }
 
-while(!empty($twofactor)) {
+while($_SERVER['REQUEST_METHOD'] === 'POST') {
     if(!CSRF::validateRequest()) {
         $notices[] = 'Was unable to verify the request, please try again!';
         break;
     }
 
     $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
-    $redirect = !empty($twofactor['redirect']) && is_string($twofactor['redirect']) ? $twofactor['redirect'] : '';
+    $redirect = !empty($_POST['redirect']) && is_scalar($_POST['redirect']) ? (string)$_POST['redirect'] : '';
 
-    if(empty($twofactor['code']) || !is_string($twofactor['code'])) {
+    if(empty($_POST['code']) || !is_string($_POST['code'])) {
         $notices[] = 'Code field was empty.';
         break;
     }
@@ -59,7 +58,7 @@ while(!empty($twofactor)) {
     $clientInfo = ClientInfo::fromRequest();
     $generator = $totpInfo->createGenerator();
 
-    if(!in_array($twofactor['code'], $generator->generateRange())) {
+    if(!in_array($_POST['code'], $generator->generateRange())) {
         $notices[] = sprintf(
             "Invalid two factor code, %d attempt%s remaining",
             $remainingAttempts - 1,
diff --git a/public-legacy/forum/leaderboard.php b/public-legacy/forum/leaderboard.php
index c6818e32..68236fc1 100644
--- a/public-legacy/forum/leaderboard.php
+++ b/public-legacy/forum/leaderboard.php
@@ -16,8 +16,8 @@ $config = $msz->config->getValues([
     'forum_leader.unranked.topic:a',
 ]);
 
-$mode = (string)filter_input(INPUT_GET, 'mode');
-$yearMonth = (string)filter_input(INPUT_GET, 'id');
+$mode = isset($_GET['mode']) && is_scalar($_GET['mode']) ? (string)$_GET['mode'] : '';
+$yearMonth = isset($_GET['id']) && is_scalar($_GET['id']) ? (string)$_GET['id'] : '';
 $year = $month = 0;
 
 $currentYear = (int)date('Y');
@@ -39,7 +39,7 @@ if(!empty($yearMonth)) {
     }
 }
 
-if(filter_has_var(INPUT_GET, 'allow_unranked')) {
+if(isset($_GET['allow_unranked'])) {
     $unrankedForums = $unrankedTopics = [];
 } else {
     $unrankedForums = $config['forum_leader.unranked.forum'];
diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php
index 531bb8cc..601c2f0a 100644
--- a/public-legacy/forum/posting.php
+++ b/public-legacy/forum/posting.php
@@ -19,11 +19,11 @@ $currentUserId = $currentUser->id;
 if($msz->usersCtx->hasActiveBan($currentUser))
     Template::throwError(403);
 
-if(filter_has_var(INPUT_POST, 'preview')) {
+if(!empty($_POST['preview'])) {
     header('Content-Type: text/plain; charset=utf-8');
 
-    $text = (string)filter_input(INPUT_POST, 'text');
-    $format = TextFormat::tryFrom((string)filter_input(INPUT_POST, 'format'));
+    $text = isset($_POST['text']) && is_scalar($_POST['text']) ? (string)$_POST['text'] : '';
+    $format = TextFormat::tryFrom(isset($_POST['format']) && is_scalar($_POST['format']) ? (string)$_POST['format'] : '');
     if($format === null) {
         http_response_code(400);
         return;
@@ -39,10 +39,10 @@ $forumPostingModes = [
 ];
 
 if(!empty($_POST)) {
-    $mode = !empty($_POST['post']['mode']) && is_string($_POST['post']['mode']) ? $_POST['post']['mode'] : 'create';
-    $postId = !empty($_POST['post']['id']) && is_string($_POST['post']['id']) ? (int)$_POST['post']['id'] : 0;
-    $topicId = !empty($_POST['post']['topic']) && is_string($_POST['post']['topic']) ? (int)$_POST['post']['topic'] : 0;
-    $forumId = !empty($_POST['post']['forum']) && is_string($_POST['post']['forum']) ? (int)$_POST['post']['forum'] : 0;
+    $mode = !empty($_POST['mode']) && is_string($_POST['mode']) ? $_POST['mode'] : 'create';
+    $postId = !empty($_POST['id']) && is_string($_POST['id']) ? (int)$_POST['id'] : 0;
+    $topicId = !empty($_POST['topic']) && is_string($_POST['topic']) ? (int)$_POST['topic'] : 0;
+    $forumId = !empty($_POST['forum']) && is_string($_POST['forum']) ? (int)$_POST['forum'] : 0;
 } else {
     $mode = !empty($_GET['m']) && is_string($_GET['m']) ? $_GET['m'] : 'create';
     $postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0;
@@ -141,11 +141,11 @@ if($mode === 'edit') {
 $notices = [];
 
 if(!empty($_POST)) {
-    $topicTitle = $_POST['post']['title'] ?? '';
-    $postText = $_POST['post']['text'] ?? '';
-    $postParser = TextFormat::tryFrom((string)($_POST['post']['parser'] ?? '')) ?? TextFormat::BBCode;
-    $topicType = isset($_POST['post']['type']) ? $_POST['post']['type'] : null;
-    $postSignature = isset($_POST['post']['signature']);
+    $topicTitle = $_POST['title'] ?? '';
+    $postText = $_POST['text'] ?? '';
+    $postParser = TextFormat::tryFrom((string)($_POST['parser'] ?? '')) ?? TextFormat::BBCode;
+    $topicType = isset($_POST['type']) ? $_POST['type'] : null;
+    $postSignature = isset($_POST['signature']);
 
     if(!CSRF::validateRequest()) {
         $notices[] = 'Could not verify request.';
diff --git a/public-legacy/manage/changelog/change.php b/public-legacy/manage/changelog/change.php
index fa9622a4..1b31f76a 100644
--- a/public-legacy/manage/changelog/change.php
+++ b/public-legacy/manage/changelog/change.php
@@ -17,7 +17,7 @@ $changeActions = [];
 foreach(ChangelogData::ACTIONS as $action)
     $changeActions[$action] = ChangelogData::actionText($action);
 
-$changeId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
+$changeId = !empty($_GET['c']) && is_scalar($_GET['c']) ? (string)$_GET['c'] : '';
 $changeInfo = null;
 $changeTagIds = [];
 $tagInfos = $msz->changelog->getTags();
@@ -45,12 +45,12 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 
 // make errors not echos lol
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $action = trim((string)filter_input(INPUT_POST, 'cl_action'));
-    $summary = trim((string)filter_input(INPUT_POST, 'cl_summary'));
-    $body = trim((string)filter_input(INPUT_POST, 'cl_body'));
-    $userId = (int)filter_input(INPUT_POST, 'cl_user', FILTER_SANITIZE_NUMBER_INT);
-    $createdAt = trim((string)filter_input(INPUT_POST, 'cl_created'));
-    $tags = filter_input(INPUT_POST, 'cl_tags', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
+    $action = !empty($_POST['cl_action']) && is_scalar($_POST['cl_action']) ? trim((string)$_POST['cl_action']) : '';
+    $summary = !empty($_POST['cl_summary']) && is_scalar($_POST['cl_summary']) ? trim((string)$_POST['cl_summary']) : '';
+    $body = !empty($_POST['cl_body']) && is_scalar($_POST['cl_body']) ? trim((string)$_POST['cl_body']) : '';
+    $userId = !empty($_POST['cl_user']) && is_scalar($_POST['cl_user']) ? (int)$_POST['cl_user'] : 0;
+    $createdAt = !empty($_POST['cl_created']) && is_scalar($_POST['cl_created']) ? trim((string)$_POST['cl_created']) : '';
+    $tags = !empty($_POST['cl_tags']) && is_array($_POST['cl_tags']) ? $_POST['cl_tags'] : [];
 
     if($userId < 1) $userId = null;
     else $userId = (string)$userId;
diff --git a/public-legacy/manage/changelog/tag.php b/public-legacy/manage/changelog/tag.php
index 5c1fa5bb..1361e9de 100644
--- a/public-legacy/manage/changelog/tag.php
+++ b/public-legacy/manage/changelog/tag.php
@@ -9,7 +9,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('global')->check(Perm::G_CL_TAGS_MANAGE))
     Template::throwError(403);
 
-$tagId = (string)filter_input(INPUT_GET, 't', FILTER_SANITIZE_NUMBER_INT);
+$tagId = !empty($_GET['t']) && is_scalar($_GET['t']) ? (string)$_GET['t'] : '';
 $loadTagInfo = fn() => $msz->changelog->getTag($tagId);
 
 if(empty($tagId))
@@ -33,8 +33,8 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 }
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $name = trim((string)filter_input(INPUT_POST, 'ct_name'));
-    $description = trim((string)filter_input(INPUT_POST, 'ct_desc'));
+    $name = !empty($_POST['ct_name']) && is_scalar($_POST['ct_name']) ? trim((string)$_POST['ct_name']) : '';
+    $description = !empty($_POST['ct_desc']) && is_scalar($_POST['ct_desc']) ? trim((string)$_POST['ct_desc']) : '';
     $archive = !empty($_POST['ct_archive']);
 
     if($isNew) {
diff --git a/public-legacy/manage/forum/index.php b/public-legacy/manage/forum/index.php
index 5d3e53e2..abf1af83 100644
--- a/public-legacy/manage/forum/index.php
+++ b/public-legacy/manage/forum/index.php
@@ -12,11 +12,8 @@ if(!$msz->authInfo->getPerms('global')->check(Perm::G_FORUM_CATEGORIES_MANAGE))
 $permsInfos = $msz->perms->getPermissionInfo(categoryNames: Perm::INFO_FOR_FORUM_CATEGORY);
 $permsLists = Perm::createList(Perm::LISTS_FOR_FORUM_CATEGORY);
 
-if(filter_has_var(INPUT_POST, 'perms'))
-    Template::set('calculated_perms', Perm::convertSubmission(
-        filter_input(INPUT_POST, 'perms', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY),
-        Perm::INFO_FOR_FORUM_CATEGORY
-    ));
+if(!empty($_POST))
+    Template::set('calculated_perms', Perm::convertSubmission($_POST, Perm::INFO_FOR_FORUM_CATEGORY));
 
 Template::render('manage.forum.listing', [
     'perms_lists' => $permsLists,
diff --git a/public-legacy/manage/forum/redirs.php b/public-legacy/manage/forum/redirs.php
index da97b0d4..6e6f12cd 100644
--- a/public-legacy/manage/forum/redirs.php
+++ b/public-legacy/manage/forum/redirs.php
@@ -11,8 +11,8 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
     if(!CSRF::validateRequest())
         throw new \Exception("Request verification failed.");
 
-    $rTopicId = (string)filter_input(INPUT_POST, 'topic_redir_id');
-    $rTopicURL = trim((string)filter_input(INPUT_POST, 'topic_redir_url'));
+    $rTopicId = !empty($_POST['topic_redir_id']) && is_scalar($_POST['topic_redir_id']) ? trim((string)$_POST['topic_redir_id']) : '';
+    $rTopicURL = !empty($_POST['topic_redir_url']) && is_scalar($_POST['topic_redir_url']) ? trim((string)$_POST['topic_redir_url']) : '';
 
     $msz->createAuditLog('FORUM_TOPIC_REDIR_CREATE', [$rTopicId]);
     $msz->forumCtx->topicRedirects->createTopicRedirect($rTopicId, $msz->authInfo->userInfo, $rTopicURL);
@@ -20,11 +20,11 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
     return;
 }
 
-if(filter_input(INPUT_GET, 'm') === 'explode') {
+if(!empty($_GET['m']) && $_GET['m'] === 'explode') {
     if(!CSRF::validateRequest())
         throw new \Exception("Request verification failed.");
 
-    $rTopicId = (string)filter_input(INPUT_GET, 't');
+    $rTopicId = !empty($_GET['t']) && is_scalar($_GET['t']) ? (string)$_GET['t'] : '';
     $msz->createAuditLog('FORUM_TOPIC_REDIR_REMOVE', [$rTopicId]);
     $msz->forumCtx->topicRedirects->deleteTopicRedirect($rTopicId);
     Tools::redirect($msz->urls->format('manage-forum-topic-redirs'));
diff --git a/public-legacy/manage/general/emoticon.php b/public-legacy/manage/general/emoticon.php
index 6ecb10cf..2993c0fe 100644
--- a/public-legacy/manage/general/emoticon.php
+++ b/public-legacy/manage/general/emoticon.php
@@ -10,7 +10,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('global')->check(Perm::G_EMOTES_MANAGE))
     Template::throwError(403);
 
-$emoteId = (string)filter_input(INPUT_GET, 'e', FILTER_SANITIZE_NUMBER_INT);
+$emoteId = !empty($_GET['e']) && is_scalar($_GET['e']) ? (string)$_GET['e'] : '';
 $emoteInfo = [];
 $emoteStrings = [];
 
@@ -27,10 +27,10 @@ else
 
 // make errors not echos lol
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $order = (int)filter_input(INPUT_POST, 'em_order', FILTER_SANITIZE_NUMBER_INT);
-    $minRank = (int)filter_input(INPUT_POST, 'em_minrank', FILTER_SANITIZE_NUMBER_INT);
-    $url = trim((string)filter_input(INPUT_POST, 'em_url'));
-    $strings = explode(' ', trim((string)filter_input(INPUT_POST, 'em_strings')));
+    $order = !empty($_POST['em_order']) && is_scalar($_POST['em_order']) ? (int)$_POST['em_order'] : '';
+    $minRank = !empty($_POST['em_minrank']) && is_scalar($_POST['em_minrank']) ? (int)$_POST['em_minrank'] : '';
+    $url = !empty($_POST['em_url']) && is_scalar($_POST['em_url']) ? trim((string)$_POST['em_url']) : '';
+    $strings = explode(' ', !empty($_POST['em_strings']) && is_scalar($_POST['em_strings']) ? trim((string)$_POST['em_strings']) : '');
 
     if($isNew || $url !== $emoteInfo->url) {
         $checkUrl = $msz->emotes->checkEmoteUrl($url);
diff --git a/public-legacy/manage/general/emoticons.php b/public-legacy/manage/general/emoticons.php
index 90987fdc..5b1e60f2 100644
--- a/public-legacy/manage/general/emoticons.php
+++ b/public-legacy/manage/general/emoticons.php
@@ -10,7 +10,7 @@ if(!$msz->authInfo->getPerms('global')->check(Perm::G_EMOTES_MANAGE))
     Template::throwError(403);
 
 if(CSRF::validateRequest() && !empty($_GET['emote'])) {
-    $emoteId = (string)filter_input(INPUT_GET, 'emote', FILTER_SANITIZE_NUMBER_INT);
+    $emoteId = !empty($_GET['emote']) && is_scalar($_GET['emote']) ? (string)$_GET['emote'] : '';
 
     try {
         $emoteInfo = $msz->emotes->getEmote($emoteId);
@@ -23,14 +23,14 @@ if(CSRF::validateRequest() && !empty($_GET['emote'])) {
         $msz->createAuditLog('EMOTICON_DELETE', [$emoteInfo->id]);
     } else {
         if(isset($_GET['order'])) {
-            $order = filter_input(INPUT_GET, 'order');
+            $order = !empty($_GET['order']) && is_scalar($_GET['order']) ? (string)$_GET['order'] : '';
             $offset = $order === 'i' ? 10 : ($order === 'd' ? -10 : 0);
             $msz->emotes->updateEmoteOrderOffset($emoteInfo, $offset);
             $msz->createAuditLog('EMOTICON_ORDER', [$emoteInfo->id]);
         }
 
         if(isset($_GET['alias'])) {
-            $alias = (string)filter_input(INPUT_GET, 'alias');
+            $alias = !empty($_GET['alias']) && is_scalar($_GET['alias']) ? (string)$_GET['alias'] : '';
             if($msz->emotes->checkEmoteString($alias) === '') {
                 $msz->emotes->addEmoteString($emoteInfo, $alias);
                 $msz->createAuditLog('EMOTICON_ALIAS', [$emoteInfo->id, $alias]);
diff --git a/public-legacy/manage/general/setting-delete.php b/public-legacy/manage/general/setting-delete.php
index e614271b..0a5a8205 100644
--- a/public-legacy/manage/general/setting-delete.php
+++ b/public-legacy/manage/general/setting-delete.php
@@ -7,7 +7,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('global')->check(Perm::G_CONFIG_MANAGE))
     Template::throwError(403);
 
-$valueInfo = $msz->config->getValueInfo((string)filter_input(INPUT_GET, 'name'));
+$valueInfo = $msz->config->getValueInfo(!empty($_GET['name']) && is_scalar($_GET['name']) ? (string)$_GET['name'] : '');
 if($valueInfo === null)
     Template::throwError(404);
 
diff --git a/public-legacy/manage/general/setting.php b/public-legacy/manage/general/setting.php
index c98c5878..15aca438 100644
--- a/public-legacy/manage/general/setting.php
+++ b/public-legacy/manage/general/setting.php
@@ -10,8 +10,8 @@ if(!$msz->authInfo->getPerms('global')->check(Perm::G_CONFIG_MANAGE))
     Template::throwError(403);
 
 $isNew = true;
-$sName = (string)filter_input(INPUT_GET, 'name');
-$sType = (string)filter_input(INPUT_GET, 'type');
+$sName = !empty($_GET['name']) && is_scalar($_GET['name']) ? (string)$_GET['name'] : '';
+$sType = !empty($_GET['type']) && is_scalar($_GET['type']) ? (string)$_GET['type'] : '';
 $sValue = null;
 $loadValueInfo = fn() => $msz->config->getValueInfo($sName);
 
@@ -27,13 +27,13 @@ if(!empty($sName)) {
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
     if($isNew) {
-        $sName = trim((string)filter_input(INPUT_POST, 'conf_name'));
+        $sName = !empty($_POST['conf_name']) && is_scalar($_POST['conf_name']) ? trim((string)$_POST['conf_name']) : '';
         if(!DbConfig::validateName($sName)) {
             echo 'Name contains invalid characters.';
             break;
         }
 
-        $sType = trim((string)filter_input(INPUT_POST, 'conf_type'));
+        $sType = !empty($_POST['conf_type']) && is_scalar($_POST['conf_type']) ? trim((string)$_POST['conf_type']) : '';
         if(!in_array($sType, ['string', 'int', 'float', 'bool', 'array'])) {
             echo 'Invalid type specified.';
             break;
@@ -43,7 +43,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
     if($sType === 'array') {
         $applyFunc = $msz->config->setArray(...);
         $sValue = [];
-        $sRaw = filter_input(INPUT_POST, 'conf_value', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
+        $sRaw = !empty($_POST['conf_value']) && is_array($_POST['conf_value']) ? $_POST['conf_value'] : [];
         foreach($sRaw as $rValue) {
             if(strpos($rValue, ':') === 1) {
                 $rType = $rValue[0];
@@ -63,7 +63,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
         $sValue = !empty($_POST['conf_value']);
         $applyFunc = $msz->config->setBoolean(...);
     } else {
-        $sValue = filter_input(INPUT_POST, 'conf_value');
+        $sValue = !empty($_POST['conf_value']) && is_scalar($_POST['conf_value']) ? trim((string)$_POST['conf_value']) : '';
         if($sType === 'int') {
             $applyFunc = $msz->config->setInteger(...);
             $sValue = (int)$sValue;
diff --git a/public-legacy/manage/news/category.php b/public-legacy/manage/news/category.php
index dde4ebb6..8a64f33d 100644
--- a/public-legacy/manage/news/category.php
+++ b/public-legacy/manage/news/category.php
@@ -9,7 +9,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('global')->check(Perm::G_NEWS_CATEGORIES_MANAGE))
     Template::throwError(403);
 
-$categoryId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
+$categoryId = !empty($_GET['c']) && is_scalar($_GET['c']) ? (string)$_GET['c'] : '';
 $loadCategoryInfo = fn() => $msz->news->getCategory(categoryId: $categoryId);
 
 if(empty($categoryId))
@@ -33,8 +33,8 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 }
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $name = trim((string)filter_input(INPUT_POST, 'nc_name'));
-    $description = trim((string)filter_input(INPUT_POST, 'nc_desc'));
+    $name = !empty($_POST['nc_name']) && is_scalar($_POST['nc_name']) ? trim((string)$_POST['nc_name']) : '';
+    $description = !empty($_POST['nc_desc']) && is_scalar($_POST['nc_desc']) ? trim((string)$_POST['nc_desc']) : '';
     $hidden = !empty($_POST['nc_hidden']);
 
     if($isNew) {
diff --git a/public-legacy/manage/news/post.php b/public-legacy/manage/news/post.php
index 6673f69e..bea405af 100644
--- a/public-legacy/manage/news/post.php
+++ b/public-legacy/manage/news/post.php
@@ -10,7 +10,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('global')->check(Perm::G_NEWS_POSTS_MANAGE))
     Template::throwError(403);
 
-$postId = (string)filter_input(INPUT_GET, 'p', FILTER_SANITIZE_NUMBER_INT);
+$postId = !empty($_GET['p']) && is_scalar($_GET['p']) ? (string)$_GET['p'] : '';
 $loadPostInfo = fn() => $msz->news->getPost($postId);
 
 if(empty($postId))
@@ -34,10 +34,10 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 }
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $title = trim((string)filter_input(INPUT_POST, 'np_title'));
-    $category = (string)filter_input(INPUT_POST, 'np_category', FILTER_SANITIZE_NUMBER_INT);
+    $title = !empty($_POST['np_title']) && is_scalar($_POST['np_title']) ? trim((string)$_POST['np_title']) : '';
+    $category = !empty($_POST['np_category']) && is_scalar($_POST['np_category']) ? trim((string)$_POST['np_category']) : '';
     $featured = !empty($_POST['np_featured']);
-    $body = trim((string)filter_input(INPUT_POST, 'np_body'));
+    $body = !empty($_POST['np_body']) && is_scalar($_POST['np_body']) ? trim((string)$_POST['np_body']) : '';
 
     if($isNew) {
         $postInfo = $msz->news->createPost($category, $title, $body, $featured, $msz->authInfo->userInfo);
diff --git a/public-legacy/manage/users/ban.php b/public-legacy/manage/users/ban.php
index 0304783e..1a2d05a5 100644
--- a/public-legacy/manage/users/ban.php
+++ b/public-legacy/manage/users/ban.php
@@ -11,12 +11,12 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('user')->check(Perm::U_BANS_MANAGE))
     Template::throwError(403);
 
-if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete')) {
+if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
     if(!CSRF::validateRequest())
         Template::throwError(403);
 
     try {
-        $banInfo = $msz->usersCtx->bans->getBan((string)filter_input(INPUT_GET, 'b'));
+        $banInfo = $msz->usersCtx->bans->getBan(!empty($_GET['b']) && is_scalar($_GET['b']) ? (string)$_GET['b'] : '');
     } catch(RuntimeException $ex) {
         Template::throwError(404);
     }
@@ -28,7 +28,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete'))
 }
 
 try {
-    $userInfo = $msz->usersCtx->getUserInfo(filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT), 'id');
+    $userInfo = $msz->usersCtx->getUserInfo(!empty($_GET['u']) && is_scalar($_GET['u']) ? (string)$_GET['u'] : '', 'id');
 } catch(RuntimeException $ex) {
     Template::throwError(404);
 }
@@ -36,11 +36,11 @@ try {
 $modInfo = $msz->authInfo->userInfo;
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $expires = (int)filter_input(INPUT_POST, 'ub_expires', FILTER_SANITIZE_NUMBER_INT);
-    $expiresCustom = (string)filter_input(INPUT_POST, 'ub_expires_custom');
-    $publicReason = trim((string)filter_input(INPUT_POST, 'ub_reason_pub'));
-    $privateReason = trim((string)filter_input(INPUT_POST, 'ub_reason_priv'));
-    $severity = (int)filter_input(INPUT_POST, 'ub_severity', FILTER_SANITIZE_NUMBER_INT);
+    $expires = !empty($_POST['ub_expires']) && is_scalar($_POST['ub_expires']) ? (int)$_POST['ub_expires'] : 0;
+    $expiresCustom = !empty($_POST['ub_expires_custom']) && is_scalar($_POST['ub_expires_custom']) ? trim((string)$_POST['ub_expires_custom']) : '';
+    $publicReason = !empty($_POST['ub_reason_pub']) && is_scalar($_POST['ub_reason_pub']) ? trim((string)$_POST['ub_reason_pub']) : '';
+    $privateReason = !empty($_POST['ub_reason_priv']) && is_scalar($_POST['ub_reason_priv']) ? trim((string)$_POST['ub_reason_priv']) : '';
+    $severity = !empty($_POST['ub_severity']) && is_scalar($_POST['ub_severity']) ? (int)$_POST['ub_severity'] : 0;
 
     Template::set([
         'ban_value_expires' => $expires,
diff --git a/public-legacy/manage/users/bans.php b/public-legacy/manage/users/bans.php
index b2b985af..04f44ef5 100644
--- a/public-legacy/manage/users/bans.php
+++ b/public-legacy/manage/users/bans.php
@@ -10,8 +10,8 @@ if(!$msz->authInfo->getPerms('user')->check(Perm::U_BANS_MANAGE))
     Template::throwError(403);
 
 $filterUser = null;
-if(filter_has_var(INPUT_GET, 'u')) {
-    $filterUserId = filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT);
+if(!empty($_GET['u'])) {
+    $filterUserId = !empty($_GET['u']) && is_scalar($_GET['u']) ? (string)$_GET['u'] : '';
     try {
         $filterUser = $msz->usersCtx->getUserInfo($filterUserId);
     } catch(RuntimeException $ex) {
@@ -20,7 +20,7 @@ if(filter_has_var(INPUT_GET, 'u')) {
 }
 
 $pagination = Pagination::fromInput($msz->usersCtx->bans->countBans(userInfo: $filterUser), 10);
-if(!$pagination->validOffset)
+if(!$pagination->validOffset && $pagination->count > 0)
     Template::throwError(404);
 
 $banList = [];
diff --git a/public-legacy/manage/users/note.php b/public-legacy/manage/users/note.php
index dc122da2..0f8a3009 100644
--- a/public-legacy/manage/users/note.php
+++ b/public-legacy/manage/users/note.php
@@ -9,8 +9,8 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('user')->check(Perm::U_NOTES_MANAGE))
     Template::throwError(403);
 
-$hasNoteId = filter_has_var(INPUT_GET, 'n');
-$hasUserId = filter_has_var(INPUT_GET, 'u');
+$hasNoteId = !empty($_GET['n']);
+$hasUserId = !empty($_GET['u']);
 
 if((!$hasNoteId && !$hasUserId) || ($hasNoteId && $hasUserId))
     Template::throwError(400);
@@ -19,7 +19,7 @@ if($hasUserId) {
     $isNew = true;
 
     try {
-        $userInfo = $msz->usersCtx->getUserInfo(filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT));
+        $userInfo = $msz->usersCtx->getUserInfo(!empty($_GET['u']) && is_scalar($_GET['u']) ? (string)$_GET['u'] : '');
     } catch(RuntimeException $ex) {
         Template::throwError(404);
     }
@@ -29,12 +29,12 @@ if($hasUserId) {
     $isNew = false;
 
     try {
-        $noteInfo = $msz->usersCtx->modNotes->getNote((string)filter_input(INPUT_GET, 'n', FILTER_SANITIZE_NUMBER_INT));
+        $noteInfo = $msz->usersCtx->modNotes->getNote(!empty($_GET['n']) && is_scalar($_GET['n']) ? (string)$_GET['n'] : '');
     } catch(RuntimeException $ex) {
         Template::throwError(404);
     }
 
-    if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete')) {
+    if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
         if(!CSRF::validateRequest())
             Template::throwError(403);
 
@@ -49,8 +49,8 @@ if($hasUserId) {
 }
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $title = trim((string)filter_input(INPUT_POST, 'mn_title'));
-    $body = trim((string)filter_input(INPUT_POST, 'mn_body'));
+    $title = trim((string)($_POST['mn_title'] ?? ''));
+    $body = trim((string)($_POST['mn_body'] ?? ''));
 
     if($isNew) {
         $noteInfo = $msz->usersCtx->modNotes->createNote($userInfo, $title, $body, $authorInfo);
diff --git a/public-legacy/manage/users/notes.php b/public-legacy/manage/users/notes.php
index 1489b4a7..e734bba1 100644
--- a/public-legacy/manage/users/notes.php
+++ b/public-legacy/manage/users/notes.php
@@ -10,8 +10,8 @@ if(!$msz->authInfo->getPerms('user')->check(Perm::U_NOTES_MANAGE))
     Template::throwError(403);
 
 $filterUser = null;
-if(filter_has_var(INPUT_GET, 'u')) {
-    $filterUserId = filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT);
+if(!empty($_GET['u'])) {
+    $filterUserId = !empty($_GET['u']) && is_scalar($_GET['u']) ? (string)$_GET['u'] : '';
     try {
         $filterUser = $msz->usersCtx->getUserInfo($filterUserId);
     } catch(RuntimeException $ex) {
@@ -20,7 +20,7 @@ if(filter_has_var(INPUT_GET, 'u')) {
 }
 
 $pagination = Pagination::fromInput($msz->usersCtx->modNotes->countNotes(userInfo: $filterUser), 10);
-if(!$pagination->validOffset)
+if(!$pagination->validOffset && $pagination->count > 0)
     Template::throwError(404);
 
 $notes = [];
diff --git a/public-legacy/manage/users/role.php b/public-legacy/manage/users/role.php
index 5a10a11d..28e7bf39 100644
--- a/public-legacy/manage/users/role.php
+++ b/public-legacy/manage/users/role.php
@@ -15,8 +15,8 @@ if(!$viewerPerms->check(Perm::U_ROLES_MANAGE))
 
 $roleInfo = null;
 
-if(filter_has_var(INPUT_GET, 'r')) {
-    $roleId = (string)filter_input(INPUT_GET, 'r', FILTER_SANITIZE_NUMBER_INT);
+if(!empty($_GET['r'])) {
+    $roleId = !empty($_GET['r']) && is_scalar($_GET['r']) ? (string)$_GET['r'] : '';
 
     try {
         $isNew = false;
@@ -40,17 +40,17 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
         break;
     }
 
-    $roleString = (string)filter_input(INPUT_POST, 'ur_string');
-    $roleName = (string)filter_input(INPUT_POST, 'ur_name');
+    $roleString = !empty($_POST['ur_string']) && is_scalar($_POST['ur_string']) ? trim((string)$_POST['ur_string']) : '';
+    $roleName = !empty($_POST['ur_name']) && is_scalar($_POST['ur_name']) ? trim((string)$_POST['ur_name']) : '';
     $roleHide = !empty($_POST['ur_hidden']);
     $roleLeavable = !empty($_POST['ur_leavable']);
-    $roleRank = (int)filter_input(INPUT_POST, 'ur_rank', FILTER_SANITIZE_NUMBER_INT);
-    $roleTitle = (string)filter_input(INPUT_POST, 'ur_title');
-    $roleDesc = (string)filter_input(INPUT_POST, 'ur_desc');
+    $roleRank = !empty($_POST['ur_rank']) && is_scalar($_POST['ur_rank']) ? (int)$_POST['ur_rank'] : 0;
+    $roleTitle = !empty($_POST['ur_title']) && is_scalar($_POST['ur_title']) ? trim((string)$_POST['ur_title']) : '';
+    $roleDesc = !empty($_POST['ur_desc']) && is_scalar($_POST['ur_desc']) ? trim((string)$_POST['ur_desc']) : '';
     $colourInherit = !empty($_POST['ur_col_inherit']);
-    $colourRed = (int)filter_input(INPUT_POST, 'ur_col_red', FILTER_SANITIZE_NUMBER_INT);
-    $colourGreen = (int)filter_input(INPUT_POST, 'ur_col_green', FILTER_SANITIZE_NUMBER_INT);
-    $colourBlue = (int)filter_input(INPUT_POST, 'ur_col_blue', FILTER_SANITIZE_NUMBER_INT);
+    $colourRed = !empty($_POST['ur_col_red']) && is_scalar($_POST['ur_col_red']) ? (int)$_POST['ur_col_red'] : 0;
+    $colourGreen = !empty($_POST['ur_col_green']) && is_scalar($_POST['ur_col_green']) ? (int)$_POST['ur_col_green'] : 0;
+    $colourBlue = !empty($_POST['ur_col_blue']) && is_scalar($_POST['ur_col_blue']) ? (int)$_POST['ur_col_blue'] : 0;
 
     Template::set([
         'role_ur_string' => $roleString,
@@ -153,12 +153,8 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
         [$roleInfo->id]
     );
 
-    if($canEditPerms && filter_has_var(INPUT_POST, 'perms')) {
-        $permsApply = Perm::convertSubmission(
-            filter_input(INPUT_POST, 'perms', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY),
-            Perm::INFO_FOR_ROLE
-        );
-
+    if($canEditPerms) {
+        $permsApply = Perm::convertSubmission($_POST, Perm::INFO_FOR_ROLE);
         foreach($permsApply as $categoryName => $values)
             $msz->perms->setPermissions($categoryName, $values['allow'], $values['deny'], roleInfo: $roleInfo);
 
diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php
index 992d9de2..b6a7a90e 100644
--- a/public-legacy/manage/users/user.php
+++ b/public-legacy/manage/users/user.php
@@ -29,7 +29,7 @@ if(!$hasAccess)
     Template::throwError(403);
 
 $notices = [];
-$userId = (string)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT);
+$userId = !empty($_GET['u']) && is_scalar($_GET['u']) ? (string)$_GET['u'] : '';
 
 try {
     $userInfo = $msz->usersCtx->users->getUser($userId, 'id');
@@ -201,12 +201,8 @@ if(CSRF::validateRequest() && $canEdit) {
         }
     }
 
-    if($canEditPerms && filter_has_var(INPUT_POST, 'perms')) {
-        $permsApply = Perm::convertSubmission(
-            filter_input(INPUT_POST, 'perms', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY),
-            Perm::INFO_FOR_USER
-        );
-
+    if($canEditPerms) {
+        $permsApply = Perm::convertSubmission($_POST, Perm::INFO_FOR_USER);
         foreach($permsApply as $categoryName => $values)
             $msz->perms->setPermissions($categoryName, $values['allow'], $values['deny'], userInfo: $userInfo);
 
diff --git a/public-legacy/manage/users/warning.php b/public-legacy/manage/users/warning.php
index 55cba84d..dfac1df1 100644
--- a/public-legacy/manage/users/warning.php
+++ b/public-legacy/manage/users/warning.php
@@ -9,12 +9,12 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('user')->check(Perm::U_WARNINGS_MANAGE))
     Template::throwError(403);
 
-if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete')) {
+if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
     if(!CSRF::validateRequest())
         Template::throwError(403);
 
     try {
-        $warnInfo = $msz->usersCtx->warnings->getWarning((string)filter_input(INPUT_GET, 'w'));
+        $warnInfo = $msz->usersCtx->warnings->getWarning(!empty($_GET['w']) && is_scalar($_GET['w']) ? (string)$_GET['w'] : '');
     } catch(RuntimeException $ex) {
         Template::throwError(404);
     }
@@ -26,7 +26,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete'))
 }
 
 try {
-    $userInfo = $msz->usersCtx->users->getUser(filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT), 'id');
+    $userInfo = $msz->usersCtx->users->getUser(!empty($_GET['u']) && is_scalar($_GET['u']) ? (string)$_GET['u'] : '', 'id');
 } catch(RuntimeException $ex) {
     Template::throwError(404);
 }
@@ -34,7 +34,7 @@ try {
 $modInfo = $msz->authInfo->userInfo;
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $body = trim((string)filter_input(INPUT_POST, 'uw_body'));
+    $body = trim((string)($_POST['uw_body'] ?? ''));
     Template::set('warn_value_body', $body);
 
     $warnInfo = $msz->usersCtx->warnings->createWarning(
diff --git a/public-legacy/manage/users/warnings.php b/public-legacy/manage/users/warnings.php
index a35af7e4..7f960966 100644
--- a/public-legacy/manage/users/warnings.php
+++ b/public-legacy/manage/users/warnings.php
@@ -10,8 +10,8 @@ if(!$msz->authInfo->getPerms('user')->check(Perm::U_WARNINGS_MANAGE))
     Template::throwError(403);
 
 $filterUser = null;
-if(filter_has_var(INPUT_GET, 'u')) {
-    $filterUserId = filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT);
+if(!empty($_GET['u'])) {
+    $filterUserId = !empty($_GET['u']) && is_scalar($_GET['u']) ? (string)$_GET['u'] : '';
     try {
         $filterUser = $msz->usersCtx->getUserInfo($filterUserId);
     } catch(RuntimeException $ex) {
@@ -20,7 +20,7 @@ if(filter_has_var(INPUT_GET, 'u')) {
 }
 
 $pagination = Pagination::fromInput($msz->usersCtx->warnings->countWarnings(userInfo: $filterUser), 10);
-if(!$pagination->validOffset)
+if(!$pagination->validOffset && $pagination->count > 0)
     Template::throwError(404);
 
 $warnList = [];
diff --git a/public-legacy/members.php b/public-legacy/members.php
index ab541612..03925af6 100644
--- a/public-legacy/members.php
+++ b/public-legacy/members.php
@@ -11,9 +11,9 @@ if(!$msz->authInfo->loggedIn)
 
 // TODO: restore forum-topics and forum-posts orderings
 
-$roleId = filter_has_var(INPUT_GET, 'r') ? (string)filter_input(INPUT_GET, 'r') : null;
-$orderBy = strtolower((string)filter_input(INPUT_GET, 'ss'));
-$orderDir = strtolower((string)filter_input(INPUT_GET, 'sd'));
+$roleId = !empty($_GET['r']) && is_string($_GET['r']) ? $_GET['r'] : null;
+$orderBy = strtolower(!empty($_GET['ss']) && is_string($_GET['ss']) ? $_GET['ss'] : '');
+$orderDir = strtolower(!empty($_GET['sd']) && is_string($_GET['sd']) ? $_GET['sd'] : '');
 
 $orderDirs = [
     'asc' => 'In Order',
diff --git a/public-legacy/profile.php b/public-legacy/profile.php
index 60d478f2..25a4ebab 100644
--- a/public-legacy/profile.php
+++ b/public-legacy/profile.php
@@ -5,6 +5,8 @@ use stdClass;
 use InvalidArgumentException;
 use RuntimeException;
 use Index\ByteFormat;
+use Index\Http\Content\MultipartFormContent;
+use Index\Http\Content\Multipart\FileMultipartFormData;
 use Misuzu\Forum\ForumSignaturesData;
 use Misuzu\Parsers\TextFormat;
 use Misuzu\Profile\{ProfileAboutData,ProfileBackgroundAttach};
@@ -111,52 +113,49 @@ if($isEditing) {
         if(!CSRF::validateRequest()) {
             $notices[] = "Couldn't verify you, please refresh the page and retry.";
         } else {
-            $profileFieldsSubmit = filter_input(INPUT_POST, 'profile', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
+            if(!$perms->edit_profile) {
+                $notices[] = "You're not allowed to edit your profile.";
+            } else {
+                $profileFieldInfos = iterator_to_array($msz->profileCtx->fields->getFields());
+                $profileFieldsSetInfos = [];
+                $profileFieldsSetValues = [];
+                $profileFieldsRemove = [];
 
-            if(!empty($profileFieldsSubmit)) {
-                if(!$perms->edit_profile) {
-                    $notices[] = "You're not allowed to edit your profile.";
-                } else {
-                    $profileFieldInfos = iterator_to_array($msz->profileCtx->fields->getFields());
-                    $profileFieldsSetInfos = [];
-                    $profileFieldsSetValues = [];
-                    $profileFieldsRemove = [];
+                foreach($profileFieldInfos as $fieldInfo) {
+                    $fieldName = sprintf('profile_%s', $fieldInfo->name);
+                    $fieldValue = empty($_POST[$fieldName]) || !is_scalar($_POST[$fieldName])
+                        ? '' : (string)filter_var($_POST[$fieldName]);
 
-                    foreach($profileFieldInfos as $fieldInfo) {
-                        $fieldName = $fieldInfo->name;
-                        $fieldValue = empty($profileFieldsSubmit[$fieldName]) ? '' : (string)filter_var($profileFieldsSubmit[$fieldName]);
-
-                        if(empty($profileFieldsSubmit[$fieldName])) {
-                            $profileFieldsRemove[] = $fieldInfo;
-                            continue;
-                        }
-
-                        if($fieldInfo->checkValue($fieldValue)) {
-                            $profileFieldsSetInfos[] = $fieldInfo;
-                            $profileFieldsSetValues[] = $fieldValue;
-                        } else
-                            $notices[] = sprintf("%s isn't properly formatted.", $fieldInfo->title);
-
-                        unset($fieldName, $fieldValue, $fieldInfo);
+                    if(empty($_POST[$fieldName])) {
+                        $profileFieldsRemove[] = $fieldInfo;
+                        continue;
                     }
 
-                    if(!empty($profileFieldsRemove))
-                        $msz->profileCtx->fields->removeFieldValues($userInfo, $profileFieldsRemove);
-                    if(!empty($profileFieldsSetInfos))
-                        $msz->profileCtx->fields->setFieldValues($userInfo, $profileFieldsSetInfos, $profileFieldsSetValues);
+                    if($fieldInfo->checkValue($fieldValue)) {
+                        $profileFieldsSetInfos[] = $fieldInfo;
+                        $profileFieldsSetValues[] = $fieldValue;
+                    } else
+                        $notices[] = sprintf("%s isn't properly formatted.", $fieldInfo->title);
+
+                    unset($fieldName, $fieldValue, $fieldInfo);
                 }
+
+                if(!empty($profileFieldsRemove))
+                    $msz->profileCtx->fields->removeFieldValues($userInfo, $profileFieldsRemove);
+                if(!empty($profileFieldsSetInfos))
+                    $msz->profileCtx->fields->setFieldValues($userInfo, $profileFieldsSetInfos, $profileFieldsSetValues);
             }
 
-            if(filter_has_var(INPUT_POST, 'about_body')) {
+            if(isset($_POST['about_body']) && is_scalar($_POST['about_body'])) {
                 if(!$perms->edit_about) {
                     $notices[] = "You're not allowed to edit your about page.";
                 } else {
-                    $aboutBody = (string)filter_input(INPUT_POST, 'about_body');
+                    $aboutBody = (string)$_POST['about_body'];
                     if(trim($aboutBody) === '') {
                         $msz->profileCtx->about->deleteProfileAbout($userInfo);
                         $aboutInfo = null;
                     } else {
-                        $aboutFormat = TextFormat::tryFrom(filter_input(INPUT_POST, 'about_format'));
+                        $aboutFormat = TextFormat::tryFrom(isset($_POST['about_format']) && is_scalar($_POST['about_format']) ? (string)$_POST['about_format'] : '');
                         $aboutValid = ProfileAboutData::validateProfileAbout($aboutFormat, $aboutBody);
                         if($aboutValid === '')
                             $aboutInfo = $msz->profileCtx->about->updateProfileAbout($userInfo, $aboutBody, $aboutFormat);
@@ -166,16 +165,16 @@ if($isEditing) {
                 }
             }
 
-            if(filter_has_var(INPUT_POST, 'sig_body')) {
+            if(isset($_POST['sig_body']) && is_scalar($_POST['sig_body'])) {
                 if(!$perms->edit_signature) {
                     $notices[] = "You're not allowed to edit your forum signature.";
                 } else {
-                    $sigBody = (string)filter_input(INPUT_POST, 'sig_body');
+                    $sigBody = (string)$_POST['sig_body'];
                     if(trim($sigBody) === '') {
                         $msz->forumCtx->signatures->deleteSignature($userInfo);
                         $sigInfo = null;
                     } else {
-                        $sigFormat = TextFormat::tryFrom(filter_input(INPUT_POST, 'sig_format'));
+                        $sigFormat = TextFormat::tryFrom(isset($_POST['sig_format']) && is_scalar($_POST['sig_format']) ? (string)$_POST['sig_format'] : '');
                         $sigValid = ForumSignaturesData::validateSignature($sigFormat, $sigBody);
                         if($sigValid === '')
                             $sigInfo = $msz->forumCtx->signatures->updateSignature($userInfo, $sigBody, $sigFormat);
@@ -185,13 +184,13 @@ if($isEditing) {
                 }
             }
 
-            if(!empty($_POST['birthdate']) && is_array($_POST['birthdate'])) {
+            if(!empty($_POST['birth_day']) && !empty($_POST['birth_month'])) {
                 if(!$perms->edit_birthdate) {
                     $notices[] = "You aren't allow to change your birthdate.";
                 } else {
-                    $birthYear  = (int)($_POST['birthdate']['year'] ?? 0);
-                    $birthMonth = (int)($_POST['birthdate']['month'] ?? 0);
-                    $birthDay   = (int)($_POST['birthdate']['day'] ?? 0);
+                    $birthYear  = (int)($_POST['birth_year'] ?? 0);
+                    $birthMonth = (int)$_POST['birth_month'];
+                    $birthDay   = (int)$_POST['birth_day'];
                     $birthValid = UserBirthdatesData::validateBirthdate($birthYear, $birthMonth, $birthDay);
 
                     if($birthValid === '') {
@@ -204,53 +203,39 @@ if($isEditing) {
                 }
             }
 
-            if(!empty($_FILES['avatar'])) {
-                if(!empty($_POST['avatar']['delete'])) {
-                    $avatarAsset->delete();
-                } else {
+            if(!empty($_POST['avatar_delete'])) {
+                $avatarAsset->delete();
+            } elseif(isset($mszRequestContent) && $mszRequestContent instanceof MultipartFormContent) {
+                $avatarInfo = $mszRequestContent->getParamData('avatar_file');
+                if($avatarInfo instanceof FileMultipartFormData) {
                     if(!$perms->edit_avatar) {
                         $notices[] = "You aren't allow to change your avatar.";
-                    } elseif(!empty($_FILES['avatar'])
-                        && is_array($_FILES['avatar'])
-                        && !empty($_FILES['avatar']['name']['file'])) {
-                        if($_FILES['avatar']['error']['file'] !== UPLOAD_ERR_OK) {
-                            switch($_FILES['avatar']['error']['file']) {
-                                case UPLOAD_ERR_NO_FILE:
-                                    $notices[] = 'Select a file before hitting upload!';
-                                    break;
-                                case UPLOAD_ERR_PARTIAL:
-                                    $notices[] = 'The upload was interrupted, please try again!';
-                                    break;
-                                case UPLOAD_ERR_INI_SIZE:
-                                case UPLOAD_ERR_FORM_SIZE:
-                                    $notices[] = sprintf('Your avatar is not allowed to be larger in file size than %s!', ByteFormat::format($avatarAsset->getMaxBytes()));
-                                    break;
-                                default:
-                                    $notices[] = 'Unable to save your avatar, contact an administator!';
-                                    break;
-                            }
-                        } else {
-                            try {
-                                $avatarAsset->setFromPath($_FILES['avatar']['tmp_name']['file']);
-                            } catch(InvalidArgumentException $ex) {
-                                $exMessage = $ex->getMessage();
-                                $notices[] = match($exMessage) {
-                                    '$path is not a valid image.' => 'The file you uploaded was not an image!',
-                                    '$path is not an allowed image file.' => 'This type of image is not supported, keep to PNG, JPG or GIF!',
-                                    'Dimensions of $path are too large.' => sprintf("Your avatar can't be larger than %dx%d!", $avatarAsset->getMaxWidth(), $avatarAsset->getMaxHeight()),
-                                    'File size of $path is too large.' => sprintf('Your avatar is not allowed to be larger in file size than %s!', ByteFormat::format($avatarAsset->getMaxBytes())),
-                                    default => $exMessage,
-                                };
-                            } catch(RuntimeException $ex) {
-                                $notices[] = 'Unable to save your avatar, contact an administator!';
-                            }
+                    } elseif($avatarInfo->getSize() > 0) {
+                        $avatarTemp = tempnam(sys_get_temp_dir(), 'msz-legacy-avatar-');
+                        try {
+                            $avatarInfo->moveTo($avatarTemp);
+                            $avatarAsset->setFromPath($avatarTemp);
+                        } catch(InvalidArgumentException $ex) {
+                            $exMessage = $ex->getMessage();
+                            $notices[] = match($exMessage) {
+                                '$path is not a valid image.' => 'The file you uploaded was not an image!',
+                                '$path is not an allowed image file.' => 'This type of image is not supported, keep to PNG, JPG or GIF!',
+                                'Dimensions of $path are too large.' => sprintf("Your avatar can't be larger than %dx%d!", $avatarAsset->getMaxWidth(), $avatarAsset->getMaxHeight()),
+                                'File size of $path is too large.' => sprintf('Your avatar is not allowed to be larger in file size than %s!', ByteFormat::format($avatarAsset->getMaxBytes())),
+                                default => $exMessage,
+                            };
+                        } catch(RuntimeException $ex) {
+                            $notices[] = 'Unable to save your avatar, contact an administator!';
+                        } finally {
+                            if(is_file($avatarTemp))
+                                unlink($avatarTemp);
                         }
                     }
                 }
             }
 
-            if(filter_has_var(INPUT_POST, 'bg_attach')) {
-                $bgFormat = ProfileBackgroundAttach::tryFrom((string)filter_input(INPUT_POST, 'bg_attach'));
+            if(isset($_POST['bg_attach']) && is_scalar($_POST['bg_attach'])) {
+                $bgFormat = ProfileBackgroundAttach::tryFrom((string)$_POST['bg_attach']);
 
                 if($bgFormat === null) {
                     $backgroundAsset->delete();
@@ -259,47 +244,35 @@ if($isEditing) {
                 } else {
                     if(!$perms->edit_background) {
                         $notices[] = "You aren't allow to change your background.";
-                    } elseif(!empty($_FILES['bg_file']) && is_array($_FILES['bg_file'])) {
-                        if(!empty($_FILES['bg_file']['name'])) {
-                            if($_FILES['bg_file']['error'] !== UPLOAD_ERR_OK) {
-                                switch($_FILES['bg_file']['error']) {
-                                    case UPLOAD_ERR_NO_FILE:
-                                        $notices[] = 'Select a file before hitting upload!';
-                                        break;
-                                    case UPLOAD_ERR_PARTIAL:
-                                        $notices[] = 'The upload was interrupted, please try again!';
-                                        break;
-                                    case UPLOAD_ERR_INI_SIZE:
-                                    case UPLOAD_ERR_FORM_SIZE:
-                                        $notices[] = sprintf('Your background is not allowed to be larger in file size than %s!', ByteFormat::format(isset($backgroundProps) && is_array($backgroundProps) ? $backgroundProps['max_size'] : 0));
-                                        break;
-                                    default:
-                                        $notices[] = 'Unable to save your background, contact an administator!';
-                                        break;
-                                }
-                            } else {
-                                try {
-                                    $backgroundAsset->setFromPath($_FILES['bg_file']['tmp_name']);
-                                } catch(InvalidArgumentException $ex) {
-                                    $exMessage = $ex->getMessage();
-                                    $notices[] = match($exMessage) {
-                                        '$path is not a valid image.' => 'The file you uploaded was not an image!',
-                                        '$path is not an allowed image file.' => 'This type of image is not supported, keep to PNG, JPG or GIF!',
-                                        'Dimensions of $path are too large.' => sprintf("Your background can't be larger than %dx%d!", $backgroundAsset->getMaxWidth(), $backgroundAsset->getMaxHeight()),
-                                        'File size of $path is too large.' => sprintf('Your background is not allowed to be larger in file size than %s!', ByteFormat::format($backgroundAsset->getMaxBytes())),
-                                        default => $exMessage,
-                                    };
-                                } catch(RuntimeException $ex) {
-                                    $notices[] = 'Unable to save your background, contact an administator!';
-                                }
+                    } elseif(isset($mszRequestContent) && $mszRequestContent instanceof MultipartFormContent) {
+                        $bgInfo = $mszRequestContent->getParamData('bg_file');
+                        if($bgInfo instanceof FileMultipartFormData && $bgInfo->getSize() > 0) {
+                            $bgTemp = tempnam(sys_get_temp_dir(), 'msz-legacy-profile-background-');
+                            try {
+                                $bgInfo->moveTo($bgTemp);
+                                $backgroundAsset->setFromPath($bgTemp);
+                            } catch(InvalidArgumentException $ex) {
+                                $exMessage = $ex->getMessage();
+                                $notices[] = match($exMessage) {
+                                    '$path is not a valid image.' => 'The file you uploaded was not an image!',
+                                    '$path is not an allowed image file.' => 'This type of image is not supported, keep to PNG, JPG or GIF!',
+                                    'Dimensions of $path are too large.' => sprintf("Your background can't be larger than %dx%d!", $backgroundAsset->getMaxWidth(), $backgroundAsset->getMaxHeight()),
+                                    'File size of $path is too large.' => sprintf('Your background is not allowed to be larger in file size than %s!', ByteFormat::format($backgroundAsset->getMaxBytes())),
+                                    default => $exMessage,
+                                };
+                            } catch(RuntimeException $ex) {
+                                $notices[] = 'Unable to save your background, contact an administator!';
+                            } finally {
+                                if(is_file($bgTemp))
+                                    unlink($bgTemp);
                             }
                         }
 
                         $backgroundInfo = $msz->profileCtx->backgrounds->updateProfileBackground(
                             $userInfo,
                             $bgFormat,
-                            filter_has_var(INPUT_POST, 'bg_blend'),
-                            filter_has_var(INPUT_POST, 'bg_slide')
+                            !empty($_POST['bg_blend']),
+                            !empty($_POST['bg_slide'])
                         );
                     }
                 }
diff --git a/public-legacy/settings/sessions.php b/public-legacy/settings/sessions.php
index b1e65883..90be2cd1 100644
--- a/public-legacy/settings/sessions.php
+++ b/public-legacy/settings/sessions.php
@@ -14,7 +14,7 @@ $currentUser = $msz->authInfo->userInfo;
 $activeSessionId = $msz->authInfo->sessionId;
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
-    $sessionId = (string)filter_input(INPUT_POST, 'session');
+    $sessionId = !empty($_POST['session']) && is_scalar($_POST['session']) ? trim((string)$_POST['session']) : '';
     $activeSessionKilled = false;
 
     if($sessionId === 'all') {
diff --git a/public/index.php b/public/index.php
index 0085336d..6afe51d8 100644
--- a/public/index.php
+++ b/public/index.php
@@ -2,6 +2,9 @@
 namespace Misuzu;
 
 use RuntimeException;
+use Index\MediaType;
+use Index\Http\Content\MultipartFormContent;
+use Index\Http\Content\Multipart\ValueMultipartFormData;
 use Misuzu\Auth\{AuthTokenBuilder,AuthTokenCookie,AuthTokenInfo};
 
 require_once __DIR__ . '/../misuzu.php';
@@ -25,12 +28,12 @@ $request = \Index\Http\HttpRequest::fromRequest();
 
 $tokenPacker = $msz->authCtx->createAuthTokenPacker();
 
-if(filter_has_var(INPUT_COOKIE, 'msz_auth'))
-    $tokenInfo = $tokenPacker->unpack(filter_input(INPUT_COOKIE, 'msz_auth'));
-elseif(filter_has_var(INPUT_COOKIE, 'msz_uid') && filter_has_var(INPUT_COOKIE, 'msz_sid')) {
+if(!empty($_COOKIE['msz_auth']) && is_string($_COOKIE['msz_auth']))
+    $tokenInfo = $tokenPacker->unpack($_COOKIE['msz_auth']);
+elseif(!empty($_COOKIE['msz_uid']) && !empty($_COOKIE['msz_sid']) && is_string($_COOKIE['msz_uid']) && is_string($_COOKIE['msz_sid'])) {
     $tokenBuilder = new AuthTokenBuilder;
-    $tokenBuilder->setUserId((string)filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT));
-    $tokenBuilder->setSessionToken((string)filter_input(INPUT_COOKIE, 'msz_sid'));
+    $tokenBuilder->setUserId($_COOKIE['msz_uid']);
+    $tokenBuilder->setSessionToken($_COOKIE['msz_sid']);
     $tokenInfo = $tokenBuilder->toInfo();
     $tokenBuilder = null;
 } else
@@ -39,7 +42,7 @@ elseif(filter_has_var(INPUT_COOKIE, 'msz_uid') && filter_has_var(INPUT_COOKIE, '
 $userInfo = null;
 $sessionInfo = null;
 $userInfoReal = null;
-$remoteAddr = (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR');
+$remoteAddr = $_SERVER['REMOTE_ADDR'];
 
 if($tokenInfo->hasUserId && $tokenInfo->hasSessionToken) {
     $tokenBuilder = new AuthTokenBuilder($tokenInfo);
@@ -112,7 +115,52 @@ $router = $msz->createRouting($request);
 $msz->startTemplating();
 
 if($msz->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
-    $mszRequestPath = substr($request->path, 1);
+    // Reconstruct $_POST since PHP no longer makes it for us
+    if($request->getBody()->isReadable() && empty($_POST)) {
+        $mszRequestContent = (function($contentType, $stream) {
+            if($contentType->equals('application/x-www-form-urlencoded')) {
+                parse_str((string)$stream, $postVars);
+                return $postVars;
+            }
+
+            if($contentType->equals('multipart/form-data'))
+                try {
+                    return MultipartFormContent::parseStream($stream, $contentType->boundary);
+                } catch(RuntimeException $ex) {}
+
+            return null;
+        })(MediaType::parse($request->getHeaderLine('Content-Type')), $request->getBody());
+
+        $_POST = (function($requestContent) {
+            if(is_array($requestContent))
+                return $requestContent;
+
+            if($requestContent instanceof MultipartFormContent) {
+                $postVars = [];
+                foreach($requestContent->params as $name => $values) {
+                    if(count($values) === 0)
+                        $postVars[$name] = '';
+                    elseif(count($values) === 1)
+                        $postVars[$name] = $values[0] instanceof ValueMultipartFormData ? (string)$values[0] : '';
+                    else {
+                        $postVar = [];
+                        foreach($values as $value)
+                            $postVars[] = $value instanceof ValueMultipartFormData ? (string)$value : '';
+
+                        $postVars[$name] = $postVar;
+                    }
+                }
+
+                return $postVars;
+            }
+
+            return [];
+        })($mszRequestContent);
+
+        $_REQUEST = array_merge($_GET, $_POST);
+    }
+
+    $mszRequestPath = substr($request->requestTarget, 1);
     $mszLegacyPathPrefix = Misuzu::PATH_PUBLIC_LEGACY . '/';
     $mszLegacyPath = $mszLegacyPathPrefix . $mszRequestPath;
 
diff --git a/src/Auth/AuthTokenCookie.php b/src/Auth/AuthTokenCookie.php
index 21c32663..dd2ceba0 100644
--- a/src/Auth/AuthTokenCookie.php
+++ b/src/Auth/AuthTokenCookie.php
@@ -26,7 +26,7 @@ final class AuthTokenCookie {
             $threeMonths->format('D, d M Y H:i:s e'),
             $threeMonths->getTimestamp() - $now->getTimestamp(),
             self::domain(),
-            filter_has_var(INPUT_SERVER, 'HTTPS') ? ' Secure' : ''
+            !empty($_SERVER['HTTPS']) ? ' Secure' : ''
         ));
     }
 
@@ -34,7 +34,7 @@ final class AuthTokenCookie {
         header(sprintf(
             'Set-Cookie: msz_auth=; Expires=Wed, 31 Dec 1969 21:29:59 UTC; Max-Age=-9001; Domain=%s; Path=/; SameSite=Lax; HttpOnly;%s',
             self::domain(),
-            filter_has_var(INPUT_SERVER, 'HTTPS') ? ' Secure' : ''
+            !empty($_SERVER['HTTPS']) ? ' Secure' : ''
         ));
     }
 }
diff --git a/src/CSRF.php b/src/CSRF.php
index 96f542fb..8a7d76d8 100644
--- a/src/CSRF.php
+++ b/src/CSRF.php
@@ -36,9 +36,9 @@ final class CSRF {
         if(self::$instance === null)
             return false;
 
-        $token = (string)filter_input(INPUT_POST, '_csrf');
+        $token = isset($_POST['_csrf']) && is_string($_POST['_csrf']) ? $_POST['_csrf'] : '';
         if(empty($token))
-            $token = (string)filter_input(INPUT_GET, 'csrf');
+            $token = isset($_GET['csrf']) && is_string($_GET['csrf']) ? $_GET['csrf'] : '';
 
         return self::$instance->verifyToken($token, $tolerance);
     }
diff --git a/src/Changelog/ChangelogRoutes.php b/src/Changelog/ChangelogRoutes.php
index 4ba4d941..b5356f1d 100644
--- a/src/Changelog/ChangelogRoutes.php
+++ b/src/Changelog/ChangelogRoutes.php
@@ -4,7 +4,8 @@ namespace Misuzu\Changelog;
 use ErrorException;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Syndication\FeedBuilder;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
@@ -22,11 +23,11 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
         private CommentsContext $commentsCtx,
     ) {}
 
-    #[HttpGet('/changelog')]
+    #[ExactRoute('GET', '/changelog')]
     #[UrlFormat('changelog-index', '/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>'])]
     public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string {
         $filterDate = (string)$request->getParam('date');
-        $filterUser = (string)$request->getParam('user', FILTER_SANITIZE_NUMBER_INT);
+        $filterUser = (string)$request->getFilteredParam('user', FILTER_SANITIZE_NUMBER_INT);
         $filterTags = (string)$request->getParam('tags');
 
         if(empty($filterDate))
@@ -96,7 +97,7 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/changelog/change/([0-9]+)')]
+    #[PatternRoute('GET', '/changelog/change/([0-9]+)')]
     #[UrlFormat('changelog-change', '/changelog/change/<change>')]
     #[UrlFormat('changelog-change-comments', '/changelog/change/<change>', fragment: 'comments')]
     public function getChange(HttpResponseBuilder $response, HttpRequest $request, string $changeId): int|string {
@@ -120,7 +121,7 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/changelog.(xml|rss|atom)')]
+    #[PatternRoute('GET', '/changelog.(xml|rss|atom)')]
     #[UrlFormat('changelog-feed', '/changelog.xml')]
     public function getFeed(HttpResponseBuilder $response): string {
         $response->setContentType('application/rss+xml; charset=utf-8');
diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php
index dc1b0976..791e61cc 100644
--- a/src/Comments/CommentsRoutes.php
+++ b/src/Comments/CommentsRoutes.php
@@ -4,7 +4,11 @@ namespace Misuzu\Comments;
 use RuntimeException;
 use Index\XArray;
 use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpDelete,HttpGet,HttpMiddleware,HttpPatch,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Filters\PrefixFilter;
+use Index\Http\Routing\Processors\Before;
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{CSRF,Perm};
 use Misuzu\Auth\AuthInfo;
@@ -186,7 +190,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return void|array{error: array{name: string, text: string}} */
-    #[HttpMiddleware('/comments')]
+    #[PrefixFilter('/comments')]
     public function checkCsrf(HttpResponseBuilder $response, HttpRequest $request) {
         if(in_array($request->method, ['DELETE', 'PATCH', 'POST'])) {
             if(!$this->authInfo->loggedIn)
@@ -228,8 +232,8 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      *  posts: mixed[]
      * }|array{error: array{name: string, text: string}}
      */
-    #[HttpGet('/comments/categories/([A-Za-z0-9-]+)')]
-    public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): array {
+    #[PatternRoute('GET', '/comments/categories/([A-Za-z0-9-]+)')]
+    public function getCategory(HttpResponseBuilder $response, string $categoryName): array {
         try {
             $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName);
         } catch(RuntimeException $ex) {
@@ -290,11 +294,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      *  locked?: string|false,
      * }|array{error: array{name: string, text: string}}
      */
-    #[HttpPost('/comments/categories/([A-Za-z0-9-]+)')]
-    public function patchCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): array {
-        if(!($request->content instanceof FormHttpContent))
-            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
-
+    #[PatternRoute('POST', '/comments/categories/([A-Za-z0-9-]+)')]
+    #[Before('input:urlencoded')]
+    public function patchCategory(HttpResponseBuilder $response, FormContent $content, string $categoryName): array {
         try {
             $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName);
         } catch(RuntimeException $ex) {
@@ -304,11 +306,11 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         $perms = $this->getGlobalPerms();
         $locked = null;
 
-        if($request->content->hasParam('lock')) {
+        if($content->hasParam('lock')) {
             if(!$perms->check(Perm::G_COMMENTS_LOCK))
                 return self::error($response, 403, 'comments:lock-not-allowed', 'You are not allowed to lock this comment section.');
 
-            $locked = !empty($request->content->getParam('lock'));
+            $locked = !empty($content->getParam('lock'));
         }
 
         $this->commentsCtx->categories->updateCategory(
@@ -332,34 +334,32 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     /**
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
-    #[HttpPost('/comments/posts')]
-    public function postPost(HttpResponseBuilder $response, HttpRequest $request): array {
-        if(!($request->content instanceof FormHttpContent))
-            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
-
+    #[ExactRoute('POST', '/comments/posts')]
+    #[Before('input:multipart')]
+    public function postPost(HttpResponseBuilder $response, FormContent $content): array {
         $perms = $this->getGlobalPerms();
         if(!$perms->check(Perm::G_COMMENTS_CREATE))
             return self::error($response, 403, 'comments:create-not-allowed', 'You are not allowed to post comments.');
 
-        if(!$request->content->hasParam('category') || !$request->content->hasParam('body'))
+        if(!$content->hasParam('category') || !$content->hasParam('body'))
             return self::error($response, 400, 'comments:missing-fields', 'Required fields are not specified.');
 
         $pinned = false;
-        $body = preg_replace("/[\r\n]{2,}/", "\n", (string)$request->content->getParam('body'));
+        $body = preg_replace("/[\r\n]{2,}/", "\n", (string)$content->getParam('body'));
         if(mb_strlen(mb_trim($body)) < 1)
             return self::error($response, 400, 'comments:body-too-short', 'Your comment must be longer.');
         if(mb_strlen($body) > 5000)
             return self::error($response, 400, 'comments:body-too-long', 'Your comment is too long.');
 
         try {
-            $catInfo = $this->commentsCtx->categories->getCategory(name: (string)$request->content->getParam('category'));
+            $catInfo = $this->commentsCtx->categories->getCategory(name: (string)$content->getParam('category'));
         } catch(RuntimeException $ex) {
             return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
         }
 
-        if($request->content->hasParam('reply_to')) {
+        if($content->hasParam('reply_to')) {
             try {
-                $replyToInfo = $this->commentsCtx->posts->getPost((string)$request->content->getParam('reply_to'));
+                $replyToInfo = $this->commentsCtx->posts->getPost((string)$content->getParam('reply_to'));
                 if($replyToInfo->deleted)
                     return self::error($response, 404, 'comments:parent-not-found', 'The comment you are trying to reply to does not exist.');
             } catch(RuntimeException $ex) {
@@ -368,13 +368,13 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         } else
             $replyToInfo = null;
 
-        if($request->content->hasParam('pin')) {
+        if($content->hasParam('pin')) {
             if(!$perms->check(Perm::G_COMMENTS_PIN) && $catInfo->ownerId !== $this->authInfo->userId)
                 return self::error($response, 403, 'comments:pin-not-allowed', 'You are not allowed to pin comments.');
             if($replyToInfo !== null)
                 return self::error($response, 400, 'comments:post-not-root', 'Replies cannot be pinned.');
 
-            $pinned = !empty($request->content->getParam('pin'));
+            $pinned = !empty($content->getParam('pin'));
         }
 
         try {
@@ -396,8 +396,8 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     /**
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
-    #[HttpGet('/comments/posts/([0-9]+)')]
-    public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+    #[PatternRoute('GET', '/comments/posts/([0-9]+)')]
+    public function getPost(HttpResponseBuilder $response, string $commentId): array {
         try {
             $postInfo = $this->commentsCtx->posts->getPost($commentId);
             $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
@@ -421,8 +421,8 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     /**
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
-    #[HttpGet('/comments/posts/([0-9]+)/replies')]
-    public function getPostReplies(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+    #[PatternRoute('GET', '/comments/posts/([0-9]+)/replies')]
+    public function getPostReplies(HttpResponseBuilder $response, string $commentId): array {
         try {
             $postInfo = $this->commentsCtx->posts->getPost($commentId);
             $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
@@ -445,13 +445,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      *  edited?: string,
      * }|array{error: array{name: string, text: string}}
      */
-    #[HttpPost('/comments/posts/([0-9]+)')]
-    // this should be HttpPatch but PHP doesn't parse into $_POST for PATCH...
-    // fix this in the v3 router for index by just ignoring PHP's parsing altogether
-    public function patchPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
-        if(!($request->content instanceof FormHttpContent))
-            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
-
+    #[PatternRoute('PATCH', '/comments/posts/([0-9]+)')]
+    #[Before('input:multipart')]
+    public function patchPost(HttpResponseBuilder $response, FormContent $content, string $commentId): array {
         try {
             $postInfo = $this->commentsCtx->posts->getPost($commentId);
             $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
@@ -467,20 +463,20 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         $pinned = null;
         $edited = false;
 
-        if($request->content->hasParam('pin')) {
+        if($content->hasParam('pin')) {
             if(!$perms->check(Perm::G_COMMENTS_PIN) && $catInfo->ownerId !== $this->authInfo->userId)
                 return self::error($response, 403, 'comments:pin-not-allowed', 'You are not allowed to pin comments.');
             if($postInfo->reply)
                 return self::error($response, 400, 'comments:post-not-root', 'Replies cannot be pinned.');
 
-            $pinned = !empty($request->content->getParam('pin'));
+            $pinned = !empty($content->getParam('pin'));
         }
 
-        if($request->content->hasParam('body')) {
+        if($content->hasParam('body')) {
             if(!$perms->check(Perm::G_COMMENTS_EDIT_ANY) && !($perms->check(Perm::G_COMMENTS_EDIT_OWN) && $this->authInfo->userId === $postInfo->userId))
                 return self::error($response, 403, 'comments:edit-not-allowed', 'You are not allowed to edit comments.');
 
-            $body = preg_replace("/[\r\n]{2,}/", "\n", (string)$request->content->getParam('body'));
+            $body = preg_replace("/[\r\n]{2,}/", "\n", (string)$content->getParam('body'));
             if(mb_strlen(mb_trim($body)) < 1)
                 return self::error($response, 400, 'comments:body-too-short', 'Your comment must be longer.');
             if(mb_strlen($body) > 5000)
@@ -518,8 +514,8 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     /**
      * @return string|array{error: array{name: string, text: string}}
      */
-    #[HttpDelete('/comments/posts/([0-9]+)')]
-    public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array|string {
+    #[PatternRoute('DELETE', '/comments/posts/([0-9]+)')]
+    public function deletePost(HttpResponseBuilder $response, string $commentId): array|string {
         try {
             $postInfo = $this->commentsCtx->posts->getPost($commentId);
             if($postInfo->deleted)
@@ -547,8 +543,8 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     /**
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
-    #[HttpPost('/comments/posts/([0-9]+)/restore')]
-    public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+    #[PatternRoute('POST', '/comments/posts/([0-9]+)/restore')]
+    public function postPostRestore(HttpResponseBuilder $response, string $commentId): array {
         if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
             return self::error($response, 403, 'comments:restore-not-allowed', 'You are not allowed to restore comments.');
 
@@ -572,8 +568,8 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     /**
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
-    #[HttpPost('/comments/posts/([0-9]+)/nuke')]
-    public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+    #[PatternRoute('POST' ,'/comments/posts/([0-9]+)/nuke')]
+    public function postPostNuke(HttpResponseBuilder $response, string $commentId): array {
         if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
             return self::error($response, 403, 'comments:nuke-not-allowed', 'You are not allowed to permanently delete comments.');
 
@@ -601,12 +597,10 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      *  negative: int,
      * }|array{error: array{name: string, text: string}}
      */
-    #[HttpPost('/comments/posts/([0-9]+)/vote')]
-    public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
-        if(!($request->content instanceof FormHttpContent))
-            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
-
-        $vote = (int)$request->content->getParam('vote', FILTER_SANITIZE_NUMBER_INT);
+    #[PatternRoute('POST', '/comments/posts/([0-9]+)/vote')]
+    #[Before('input:urlencoded')]
+    public function postPostVote(HttpResponseBuilder $response, FormContent $content, string $commentId): array {
+        $vote = (int)$content->getFilteredParam('vote', FILTER_SANITIZE_NUMBER_INT);
         if($vote === 0)
             return self::error($response, 400, 'comments:vote', 'Could not process vote.');
 
@@ -649,7 +643,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      *  negative: int,
      * }|array{error: array{name: string, text: string}}
      */
-    #[HttpDelete('/comments/posts/([0-9]+)/vote')]
+    #[PatternRoute('DELETE', '/comments/posts/([0-9]+)/vote')]
     public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
         if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE))
             return self::error($response, 403, 'comments:vote-not-allowed', 'You are not allowed to vote on comments.');
diff --git a/src/DatabaseContext.php b/src/DatabaseContext.php
index 83db6ff3..75420785 100644
--- a/src/DatabaseContext.php
+++ b/src/DatabaseContext.php
@@ -4,7 +4,8 @@ namespace Misuzu;
 use Index\Config\Config;
 use Index\Db\{DbBackends,DbConnection};
 use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo};
-use Index\Http\Routing\{HttpMiddleware,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Filters\PrefixFilter;
 
 class DatabaseContext implements RouteHandler {
     use RouteHandlerCommon;
@@ -40,8 +41,8 @@ class DatabaseContext implements RouteHandler {
     }
 
     /** @return void|int */
-    #[HttpMiddleware('/')]
-    public function middleware() {
+    #[PrefixFilter('/')]
+    public function filterMigrate() {
         if(is_file($this->getMigrateLockPath()))
             return 503;
     }
diff --git a/src/Forum/ForumCategoriesRoutes.php b/src/Forum/ForumCategoriesRoutes.php
index d83c80db..ab7de7c9 100644
--- a/src/Forum/ForumCategoriesRoutes.php
+++ b/src/Forum/ForumCategoriesRoutes.php
@@ -4,7 +4,8 @@ namespace Misuzu\Forum;
 use stdClass;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{CSRF,Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
@@ -19,7 +20,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
         private AuthInfo $authInfo,
     ) {}
 
-    #[HttpGet('/forum')]
+    #[ExactRoute('GET', '/forum')]
     #[UrlFormat('forum-index', '/forum')]
     #[UrlFormat('forum-category-root', '/forum', fragment: '<forum>')]
     public function getIndex(): string {
@@ -170,7 +171,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/forum/([0-9]+)')]
+    #[PatternRoute('GET', '/forum/([0-9]+)')]
     #[UrlFormat('forum-category', '/forum/<forum>', ['page' => '<page>'])]
     public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $catId): mixed {
         try {
@@ -328,7 +329,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpPost('/forum/mark-as-read')]
+    #[ExactRoute('POST', '/forum/mark-as-read')]
     #[UrlFormat('forum-mark-as-read', '/forum/mark-as-read', ['cat' => '<category>', 'rec' => '<recursive>'])]
     public function postMarkAsRead(HttpResponseBuilder $response, HttpRequest $request): mixed {
         if(!$this->authInfo->loggedIn)
@@ -338,7 +339,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
             return 403;
         $response->setHeader('X-CSRF-Token', CSRF::token());
 
-        $catId = (string)$request->getParam('cat', FILTER_SANITIZE_NUMBER_INT);
+        $catId = (string)$request->getFilteredParam('cat', FILTER_SANITIZE_NUMBER_INT);
         $recursive = !empty($request->getParam('rec'));
 
         if($catId === '') {
diff --git a/src/Forum/ForumPostsRoutes.php b/src/Forum/ForumPostsRoutes.php
index 8a64eb6b..077db87e 100644
--- a/src/Forum/ForumPostsRoutes.php
+++ b/src/Forum/ForumPostsRoutes.php
@@ -3,7 +3,8 @@ namespace Misuzu\Forum;
 
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpDelete,HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\PatternRoute;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{CSRF,Perm};
 use Misuzu\AuditLog\AuditLogData;
@@ -21,7 +22,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
         private AuthInfo $authInfo,
     ) {}
 
-    #[HttpGet('/forum/posts/([0-9]+)')]
+    #[PatternRoute('GET', '/forum/posts/([0-9]+)')]
     #[UrlFormat('forum-post', '/forum/posts/<post>')]
     public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
         try {
@@ -54,7 +55,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
         return 302;
     }
 
-    #[HttpDelete('/forum/posts/([0-9]+)')]
+    #[PatternRoute('DELETE', '/forum/posts/([0-9]+)')]
     #[UrlFormat('forum-post-delete', '/forum/posts/<post>')]
     public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
         if(!$this->authInfo->loggedIn)
@@ -189,7 +190,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
         return 204;
     }
 
-    #[HttpPost('/forum/posts/([0-9]+)/nuke')]
+    #[PatternRoute('POST', '/forum/posts/([0-9]+)/nuke')]
     #[UrlFormat('forum-post-nuke', '/forum/posts/<post>/nuke')]
     public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
         if(!$this->authInfo->loggedIn)
@@ -269,7 +270,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
         return 204;
     }
 
-    #[HttpPost('/forum/posts/([0-9]+)/restore')]
+    #[PatternRoute('POST', '/forum/posts/([0-9]+)/restore')]
     #[UrlFormat('forum-post-restore', '/forum/posts/<post>/restore')]
     public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
         if(!$this->authInfo->loggedIn)
diff --git a/src/Forum/ForumTopicsRoutes.php b/src/Forum/ForumTopicsRoutes.php
index cf616d78..1643a144 100644
--- a/src/Forum/ForumTopicsRoutes.php
+++ b/src/Forum/ForumTopicsRoutes.php
@@ -4,7 +4,9 @@ namespace Misuzu\Forum;
 use stdClass;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpDelete,HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\PatternRoute;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{CSRF,Pagination,Perm,Template};
 use Misuzu\AuditLog\AuditLogData;
@@ -21,7 +23,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
         private AuthInfo $authInfo,
     ) {}
 
-    #[HttpGet('/forum/topics/([0-9]+)')]
+    #[PatternRoute('GET', '/forum/topics/([0-9]+)')]
     #[UrlFormat('forum-topic', '/forum/topics/<topic>', ['page' => '<page>'], '<topic_fragment>')]
     public function getTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         $isNuked = $deleted = $canDeleteAny = false;
@@ -152,7 +154,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpDelete('/forum/topics/([0-9]+)')]
+    #[PatternRoute('DELETE', '/forum/topics/([0-9]+)')]
     #[UrlFormat('forum-topic-delete', '/forum/topics/<topic>')]
     public function deleteTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         if(!$this->authInfo->loggedIn)
@@ -272,7 +274,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
         return 204;
     }
 
-    #[HttpPost('/forum/topics/([0-9]+)/restore')]
+    #[PatternRoute('POST', '/forum/topics/([0-9]+)/restore')]
     #[UrlFormat('forum-topic-restore', '/forum/topics/<topic>/restore')]
     public function postTopicRestore(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         if(!$this->authInfo->loggedIn)
@@ -352,7 +354,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
         return 204;
     }
 
-    #[HttpPost('/forum/topics/([0-9]+)/nuke')]
+    #[PatternRoute('POST', '/forum/topics/([0-9]+)/nuke')]
     #[UrlFormat('forum-topic-nuke', '/forum/topics/<topic>/nuke')]
     public function postTopicNuke(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         if(!$this->authInfo->loggedIn)
@@ -432,7 +434,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
         return 204;
     }
 
-    #[HttpPost('/forum/topics/([0-9]+)/bump')]
+    #[PatternRoute('POST', '/forum/topics/([0-9]+)/bump')]
     #[UrlFormat('forum-topic-bump', '/forum/topics/<topic>/bump')]
     public function postTopicBump(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         if(!$this->authInfo->loggedIn)
@@ -512,7 +514,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
         return 204;
     }
 
-    #[HttpPost('/forum/topics/([0-9]+)/lock')]
+    #[PatternRoute('POST', '/forum/topics/([0-9]+)/lock')]
     #[UrlFormat('forum-topic-lock', '/forum/topics/<topic>/lock')]
     public function postTopicLock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         if(!$this->authInfo->loggedIn)
@@ -602,7 +604,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
         return 204;
     }
 
-    #[HttpPost('/forum/topics/([0-9]+)/unlock')]
+    #[PatternRoute('POST', '/forum/topics/([0-9]+)/unlock')]
     #[UrlFormat('forum-topic-unlock', '/forum/topics/<topic>/unlock')]
     public function postTopicUnlock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         if(!$this->authInfo->loggedIn)
diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php
index 9b9cdbf8..df374802 100644
--- a/src/Home/HomeRoutes.php
+++ b/src/Home/HomeRoutes.php
@@ -7,7 +7,8 @@ use Index\Config\Config;
 use Index\Colour\Colour;
 use Index\Db\{DbConnection,DbTools};
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\ExactRoute;
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
 use Misuzu\Auth\AuthInfo;
@@ -198,12 +199,10 @@ class HomeRoutes implements RouteHandler, UrlSource {
         return $topics;
     }
 
-    #[HttpGet('/')]
+    #[ExactRoute('GET', '/')]
     #[UrlFormat('index', '/')]
-    public function getIndex(HttpResponseBuilder $response, HttpRequest $request): string {
-        return $this->authInfo->loggedIn
-            ? $this->getHome()
-            : $this->getLanding($response, $request);
+    public function getIndex(): string {
+        return $this->authInfo->loggedIn ? $this->getHome() : $this->getLanding();
     }
 
     public function getHome(): string {
@@ -246,8 +245,8 @@ class HomeRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/_landing')]
-    public function getLanding(HttpResponseBuilder $response, HttpRequest $request): string {
+    #[ExactRoute('GET', '/_landing')]
+    public function getLanding(): string {
         $config = $this->config->getValues([
             ['social.embed_linked:b'],
             ['landing.forum_categories:a'],
diff --git a/src/Info/InfoRoutes.php b/src/Info/InfoRoutes.php
index e191bbc0..002f68fe 100644
--- a/src/Info/InfoRoutes.php
+++ b/src/Info/InfoRoutes.php
@@ -3,7 +3,8 @@ namespace Misuzu\Info;
 
 use Index\Index;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{Misuzu,Template};
 use Misuzu\Parsers\{Parsers,TextFormat};
@@ -23,13 +24,13 @@ class InfoRoutes implements RouteHandler, UrlSource {
         'rpcii' => 'RPCii » %s',
     ];
 
-    #[HttpGet('/info')]
+    #[ExactRoute('GET', '/info')]
     #[UrlFormat('info-index', '/info')]
     public function getIndex(): string {
         return Template::renderRaw('info.index');
     }
 
-    #[HttpGet('/info/([A-Za-z0-9_]+)')]
+    #[PatternRoute('GET', '/info/([A-Za-z0-9_]+)')]
     #[UrlFormat('info', '/info/<title>')]
     #[UrlFormat('info-doc', '/info/<title>')]
     public function getDocsPage(HttpResponseBuilder $response, HttpRequest $request, string $name): string {
@@ -76,7 +77,7 @@ class InfoRoutes implements RouteHandler, UrlSource {
         return '';
     }
 
-    #[HttpGet('/info/([A-Za-z0-9_]+)/([A-Za-z0-9_]+)')]
+    #[PatternRoute('GET', '/info/([A-Za-z0-9_]+)/([A-Za-z0-9_]+)')]
     #[UrlFormat('info-project-doc', '/info/<project>/<title>')]
     public function getProjectPage(HttpResponseBuilder $response, HttpRequest $request, string $project, string $name): int|string {
         if(!array_key_exists($project, self::PROJECT_PATHS))
diff --git a/src/LegacyRoutes.php b/src/LegacyRoutes.php
index 1f901915..0c4d4b9c 100644
--- a/src/LegacyRoutes.php
+++ b/src/LegacyRoutes.php
@@ -3,6 +3,7 @@ namespace Misuzu;
 
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource};
 
 class LegacyRoutes implements RouteHandler, UrlSource {
@@ -110,126 +111,123 @@ class LegacyRoutes implements RouteHandler, UrlSource {
         $urls->register('manage-role',  '/manage/users/role.php',  ['r' => '<role>']);
     }
 
-    #[HttpGet('/index.php')]
+    #[ExactRoute('GET', '/index.php')]
     public function getIndexPHP(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('index'), true);
     }
 
-    #[HttpGet('/info.php')]
+    #[ExactRoute('GET', '/info.php')]
     public function getInfoPHP(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('info'), true);
     }
 
-    #[HttpGet('/info.php/([A-Za-z0-9_]+)')]
+    #[PatternRoute('GET', '/info.php/([A-Za-z0-9_]+)')]
     public function getInfoDocsPHP(HttpResponseBuilder $response, HttpRequest $request, string $name): void {
         $response->redirect($this->urls->format('info', ['title' => $name]), true);
     }
 
-    #[HttpGet('/info.php/([A-Za-z0-9_]+)/([A-Za-z0-9_]+)')]
+    #[PatternRoute('GET', '/info.php/([A-Za-z0-9_]+)/([A-Za-z0-9_]+)')]
     public function getInfoProjectPHP(HttpResponseBuilder $response, HttpRequest $request, string $project, string $name): void {
         $response->redirect($this->urls->format('info', ['title' => $project . '/' . $name]), true);
     }
 
-    #[HttpGet('/news.php')]
+    #[ExactRoute('GET', '/news.php')]
     public function getNewsPHP(HttpResponseBuilder $response, HttpRequest $request): void {
-        $postId = (int)($request->getParam('n', FILTER_SANITIZE_NUMBER_INT) ?? $request->getParam('p', FILTER_SANITIZE_NUMBER_INT));
+        $postId = (int)($request->getFilteredParam('n', FILTER_SANITIZE_NUMBER_INT) ?? $request->getFilteredParam('p', FILTER_SANITIZE_NUMBER_INT));
 
         if($postId > 0)
             $location = $this->urls->format('news-post', ['post' => $postId]);
         else {
-            $catId = (int)$request->getParam('c', FILTER_SANITIZE_NUMBER_INT);
-            $pageId = $request->getParam('page', FILTER_SANITIZE_NUMBER_INT);
+            $catId = (int)$request->getFilteredParam('c', FILTER_SANITIZE_NUMBER_INT);
+            $pageId = $request->getFilteredParam('page', FILTER_SANITIZE_NUMBER_INT);
             $location = $this->urls->format($catId > 0 ? 'news-category' : 'news-index', ['category' => $catId, 'page' => $pageId]);
         }
 
         $response->redirect($location, true);
     }
 
-    #[HttpGet('/news/index.php')]
+    #[ExactRoute('GET', '/news/index.php')]
     public function getNewsIndexPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         $response->redirect($this->urls->format('news-index', [
-            'page' => $request->getParam('page', FILTER_SANITIZE_NUMBER_INT),
+            'page' => $request->getFilteredParam('page', FILTER_SANITIZE_NUMBER_INT),
         ]), true);
     }
 
-    #[HttpGet('/news/category.php')]
+    #[ExactRoute('GET', '/news/category.php')]
     public function getNewsCategoryPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         $response->redirect($this->urls->format('news-category', [
-            'category' => $request->getParam('c', FILTER_SANITIZE_NUMBER_INT),
-            'page' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT),
+            'category' => $request->getFilteredParam('c', FILTER_SANITIZE_NUMBER_INT),
+            'page' => $request->getFilteredParam('p', FILTER_SANITIZE_NUMBER_INT),
         ]), true);
     }
 
-    #[HttpGet('/news/post.php')]
+    #[ExactRoute('GET', '/news/post.php')]
     public function getNewsPostPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         $response->redirect($this->urls->format('news-post', [
-            'post' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT),
+            'post' => $request->getFilteredParam('p', FILTER_SANITIZE_NUMBER_INT),
         ]), true);
     }
 
-    #[HttpGet('/news.php/rss')]
-    #[HttpGet('/news.php/atom')]
-    #[HttpGet('/news/feed.php')]
-    #[HttpGet('/news/feed.php/rss')]
-    #[HttpGet('/news/feed.php/atom')]
+    #[PatternRoute('GET', '/news.php/(?:atom|rss)')]
+    #[PatternRoute('GET', '/news/feed.php(?:/(?:atom|rss)?)?')]
     public function getNewsFeedPHP(HttpResponseBuilder $response, HttpRequest $request): void {
-        $catId = (int)$request->getParam('c', FILTER_SANITIZE_NUMBER_INT);
+        $catId = (int)$request->getFilteredParam('c', FILTER_SANITIZE_NUMBER_INT);
         $response->redirect($this->urls->format(
             $catId > 0 ? 'news-category-feed' : 'news-feed',
             ['category' => $catId]
         ), true);
     }
 
-    #[HttpGet('/forum/index.php')]
+    #[ExactRoute('GET', '/forum/index.php')]
     public function getForumIndexPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         $response->redirect($this->urls->format('forum-index'), true);
     }
 
-    #[HttpGet('/forum/forum.php')]
+    #[ExactRoute('GET', '/forum/forum.php')]
     public function getForumForumPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         $response->redirect($this->urls->format('forum-category', [
-            'forum' => $request->getParam('f', FILTER_SANITIZE_NUMBER_INT),
-            'page' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT),
+            'forum' => $request->getFilteredParam('f', FILTER_SANITIZE_NUMBER_INT),
+            'page' => $request->getFilteredParam('p', FILTER_SANITIZE_NUMBER_INT),
         ]), true);
     }
 
-    #[HttpGet('/forum/topic.php')]
+    #[ExactRoute('GET', '/forum/topic.php')]
     public function getForumTopicPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         if($request->hasParam('p'))
             $response->redirect($this->urls->format('forum-post', [
-                'post' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT),
+                'post' => $request->getFilteredParam('p', FILTER_SANITIZE_NUMBER_INT),
             ]), true);
         else
             $response->redirect($this->urls->format('forum-topic', [
-                'topic' => $request->getParam('t', FILTER_SANITIZE_NUMBER_INT),
-                'page' => $request->getParam('page', FILTER_SANITIZE_NUMBER_INT),
+                'topic' => $request->getFilteredParam('t', FILTER_SANITIZE_NUMBER_INT),
+                'page' => $request->getFilteredParam('page', FILTER_SANITIZE_NUMBER_INT),
             ]), true);
     }
 
-    #[HttpGet('/forum/post.php')]
+    #[ExactRoute('GET', '/forum/post.php')]
     public function getForumPostPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         $response->redirect($this->urls->format('forum-post', [
-            'post' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT),
+            'post' => $request->getFilteredParam('p', FILTER_SANITIZE_NUMBER_INT),
         ]), true);
     }
 
-    #[HttpGet('/changelog.php')]
+    #[ExactRoute('GET', '/changelog.php')]
     public function getChangelogPHP(HttpResponseBuilder $response, HttpRequest $request): void {
-        $changeId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT);
+        $changeId = $request->getFilteredParam('c', FILTER_SANITIZE_NUMBER_INT);
         if($changeId) {
             $response->redirect($this->urls->format('changelog-change', ['change' => $changeId]), true);
             return;
         }
 
         $response->redirect($this->urls->format('changelog-index', [
-            'date' => $request->getParam('d'),
-            'user' => $request->getParam('u', FILTER_SANITIZE_NUMBER_INT),
+            'date' => $request->getFilteredParam('d'),
+            'user' => $request->getFilteredParam('u', FILTER_SANITIZE_NUMBER_INT),
         ]), true);
     }
 
-    #[HttpGet('/auth.php')]
+    #[ExactRoute('GET', '/auth.php')]
     public function getAuthPHP(HttpResponseBuilder $response, HttpRequest $request): void {
-        $response->redirect($this->urls->format(match($request->getParam('m')) {
+        $response->redirect($this->urls->format(match($request->getFilteredParam('m')) {
             'logout' => 'auth-logout',
             'reset' => 'auth-reset',
             'forgot' => 'auth-forgot',
@@ -238,44 +236,44 @@ class LegacyRoutes implements RouteHandler, UrlSource {
         }), true);
     }
 
-    #[HttpGet('/auth')]
-    #[HttpGet('/auth/index.php')]
+    #[ExactRoute('GET', '/auth')]
+    #[ExactRoute('GET', '/auth/index.php')]
     public function getAuthIndexPHP(HttpResponseBuilder $response, HttpRequest $request): void {
         $response->redirect($this->urls->format('auth-login'), true);
     }
 
-    #[HttpGet('/settings.php')]
+    #[ExactRoute('GET', '/settings.php')]
     public function getSettingsPHP(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('settings-index'), true);
     }
 
-    #[HttpGet('/settings')]
+    #[ExactRoute('GET', '/settings')]
     public function getSettingsIndex(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('settings-account'));
     }
 
-    #[HttpGet('/settings/index.php')]
+    #[ExactRoute('GET', '/settings/index.php')]
     public function getSettingsIndexPHP(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('settings-account'), true);
     }
 
-    #[HttpGet('/manage')]
+    #[ExactRoute('GET', '/manage')]
     #[UrlFormat('manage-index', '/manage')]
     public function getManageIndex(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('manage-general-overview'));
     }
 
-    #[HttpGet('/manage/index.php')]
+    #[ExactRoute('GET', '/manage/index.php')]
     public function getManageIndexPHP(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('manage-general-overview'), true);
     }
 
-    #[HttpGet('/manage/news')]
+    #[ExactRoute('GET', '/manage/news')]
     public function getManageNewsIndex(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('manage-news-categories'));
     }
 
-    #[HttpGet('/manage/news/index.php')]
+    #[ExactRoute('GET', '/manage/news/index.php')]
     public function getManageNewsIndexPHP(HttpResponseBuilder $response): void {
         $response->redirect($this->urls->format('manage-news-categories'), true);
     }
diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php
index be47d717..6adc5d60 100644
--- a/src/Messages/MessagesRoutes.php
+++ b/src/Messages/MessagesRoutes.php
@@ -8,7 +8,11 @@ use Index\XString;
 use Index\Config\Config;
 use Index\Colour\Colour;
 use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,HttpMiddleware,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Filters\PrefixFilter;
+use Index\Http\Routing\Processors\{After,Before};
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{CSRF,Misuzu,Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
@@ -38,7 +42,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     private bool $canSendMessages;
 
     /** @return void|int|array{error: array{name: string, text: string}} */
-    #[HttpMiddleware('/messages')]
+    #[PrefixFilter('/messages')]
     public function checkAccess(HttpResponseBuilder $response, HttpRequest $request) {
         // should probably be a permission or something too
         if(!$this->authInfo->loggedIn)
@@ -56,9 +60,6 @@ class MessagesRoutes implements RouteHandler, UrlSource {
             && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo);
 
         if($request->method === 'POST') {
-            if(!($request->content instanceof FormHttpContent))
-                return 400;
-
             if(!CSRF::validate($request->getHeaderLine('x-csrf-token')))
                 return [
                     'error' => [
@@ -92,9 +93,9 @@ class MessagesRoutes implements RouteHandler, UrlSource {
         return $message;
     }
 
-    #[HttpGet('/messages')]
+    #[ExactRoute('GET', '/messages')]
     #[UrlFormat('messages-index', '/messages', ['folder' => '<folder>', 'page' => '<page>'])]
-    public function getIndex(HttpResponseBuilder $response, HttpRequest $request, string $folderName = ''): int|string {
+    public function getIndex(HttpRequest $request, string $folderName = ''): int|string {
         $folderName = (string)$request->getParam('folder');
         if($folderName === '')
             $folderName = 'inbox';
@@ -146,7 +147,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return array{unread: int} */
-    #[HttpGet('/messages/stats')]
+    #[ExactRoute('GET', '/messages/stats')]
     #[UrlFormat('messages-stats', '/messages/stats')]
     public function getStats(): array {
         $selfInfo = $this->authInfo->userInfo;
@@ -171,16 +172,14 @@ class MessagesRoutes implements RouteHandler, UrlSource {
      *  avatar: string
      * }
      */
-    #[HttpPost('/messages/recipient')]
+    #[ExactRoute('POST', '/messages/recipient')]
+    #[Before('input:urlencoded')]
     #[UrlFormat('messages-recipient', '/messages/recipient')]
-    public function postRecipient(HttpResponseBuilder $response, HttpRequest $request): int|array {
+    public function postRecipient(FormContent $content): int|array {
         if(!$this->canSendMessages)
             return 403;
 
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $name = trim((string)$request->content->getParam('name'));
+        $name = trim((string)$content->getParam('name'));
 
         // flappy hacks
         if(str_starts_with(mb_strtolower($name), 'flappyzor'))
@@ -211,9 +210,9 @@ class MessagesRoutes implements RouteHandler, UrlSource {
         ];
     }
 
-    #[HttpGet('/messages/compose')]
+    #[ExactRoute('GET', '/messages/compose')]
     #[UrlFormat('messages-compose', '/messages/compose', ['recipient' => '<recipient>'])]
-    public function getEditor(HttpResponseBuilder $response, HttpRequest $request): int|string {
+    public function getEditor(HttpRequest $request): int|string {
         if(!$this->canSendMessages)
             return 403;
 
@@ -222,9 +221,9 @@ class MessagesRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/messages/([A-Za-z0-9]+)')]
+    #[PatternRoute('GET', '/messages/([A-Za-z0-9]+)')]
     #[UrlFormat('messages-view', '/messages/<message>')]
-    public function getView(HttpResponseBuilder $response, HttpRequest $request, string $messageId): int|string {
+    public function getView(string $messageId): int|string {
         if(strlen($messageId) !== 8)
             return 404;
 
@@ -355,21 +354,19 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */
-    #[HttpPost('/messages/create')]
+    #[ExactRoute('POST', '/messages/create')]
+    #[Before('input:multipart')]
     #[UrlFormat('messages-create', '/messages/create')]
-    public function postCreate(HttpResponseBuilder $response, HttpRequest $request): int|array {
+    public function postCreate(FormContent $content): int|array {
         if(!$this->canSendMessages)
             return 403;
 
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $recipient = (string)$request->content->getParam('recipient');
-        $replyTo = (string)$request->content->getParam('reply');
-        $title = (string)$request->content->getParam('title');
-        $body = (string)$request->content->getParam('body');
-        $format = TextFormat::tryFrom((string)$request->content->getParam('format'));
-        $draft = !empty($request->content->getParam('draft'));
+        $recipient = (string)$content->getParam('recipient');
+        $replyTo = (string)$content->getParam('reply');
+        $title = (string)$content->getParam('title');
+        $body = (string)$content->getParam('body');
+        $format = TextFormat::tryFrom((string)$content->getParam('format'));
+        $draft = !empty($content->getParam('draft'));
 
         $error = $this->checkMessageFields($title, $body, $format);
         if($error !== null)
@@ -459,19 +456,17 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */
-    #[HttpPost('/messages/([A-Za-z0-9]+)')]
+    #[PatternRoute('PATCH', '/messages/([A-Za-z0-9]+)')]
+    #[Before('input:multipart')]
     #[UrlFormat('messages-update', '/messages/<message>')]
-    public function postUpdate(HttpResponseBuilder $response, HttpRequest $request, string $messageId): int|array {
+    public function patchUpdate(FormContent $content, string $messageId): int|array {
         if(!$this->canSendMessages)
             return 403;
 
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $title = (string)$request->content->getParam('title');
-        $body = (string)$request->content->getParam('body');
-        $format = TextFormat::tryFrom((string)$request->content->getParam('format'));
-        $draft = !empty($request->content->getParam('draft'));
+        $title = (string)$content->getParam('title');
+        $body = (string)$content->getParam('body');
+        $format = TextFormat::tryFrom((string)$content->getParam('format'));
+        $draft = !empty($content->getParam('draft'));
 
         $error = $this->checkMessageFields($title, $body, $format);
         if($error !== null)
@@ -551,14 +546,12 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
-    #[HttpPost('/messages/mark')]
+    #[ExactRoute('POST', '/messages/mark')]
+    #[Before('input:urlencoded')]
     #[UrlFormat('messages-mark', '/messages/mark')]
-    public function postMark(HttpResponseBuilder $response, HttpRequest $request): int|array {
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $type = (string)$request->content->getParam('type');
-        $messages = explode(',', (string)$request->content->getParam('messages'));
+    public function postMark(FormContent $content): int|array {
+        $type = (string)$content->getParam('type');
+        $messages = explode(',', (string)$content->getParam('messages'));
 
         if($type !== 'read' && $type !== 'unread')
             return [
@@ -582,13 +575,11 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
-    #[HttpPost('/messages/delete')]
+    #[ExactRoute('POST', '/messages/delete')]
+    #[Before('input:urlencoded')]
     #[UrlFormat('messages-delete', '/messages/delete')]
-    public function postDelete(HttpResponseBuilder $response, HttpRequest $request): int|array {
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $messages = (string)$request->content->getParam('messages');
+    public function postDelete(FormContent $content): int|array {
+        $messages = (string)$content->getParam('messages');
         if($messages === '')
             return [
                 'error' => [
@@ -608,13 +599,11 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
-    #[HttpPost('/messages/restore')]
+    #[ExactRoute('POST', '/messages/restore')]
+    #[Before('input:urlencoded')]
     #[UrlFormat('messages-restore', '/messages/restore')]
-    public function postRestore(HttpResponseBuilder $response, HttpRequest $request) {
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $messages = (string)$request->content->getParam('messages');
+    public function postRestore(FormContent $content) {
+        $messages = (string)$content->getParam('messages');
         if($messages === '')
             return [
                 'error' => [
@@ -634,13 +623,11 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
-    #[HttpPost('/messages/nuke')]
+    #[ExactRoute('POST', '/messages/nuke')]
+    #[Before('input:urlencoded')]
     #[UrlFormat('messages-nuke', '/messages/nuke')]
-    public function postNuke(HttpResponseBuilder $response, HttpRequest $request) {
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $messages = (string)$request->content->getParam('messages');
+    public function postNuke(FormContent $content) {
+        $messages = (string)$content->getParam('messages');
         if($messages === '')
             return [
                 'error' => [
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index 3d5e6a79..ed374e37 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -9,7 +9,7 @@ use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo};
 use Index\Http\HttpRequest;
 use Index\Templating\TplEnvironment;
 use Index\Urls\UrlRegistry;
-use Misuzu\Routing\{BackedRoutingContext,RoutingContext};
+use Misuzu\Routing\RoutingContext;
 use Misuzu\Users\UserInfo;
 use RPCii\HmacVerificationProvider;
 use RPCii\Server\{HttpRpcServer,RpcServer};
@@ -139,36 +139,30 @@ class MisuzuContext {
         Template::init($this->templating);
     }
 
-    public function createRouting(HttpRequest $request): BackedRoutingContext {
+    public function createRouting(HttpRequest $request): RoutingContext {
         $host = $request->getHeaderLine('Host');
         $roles = $this->domainRoles->getRoles($host);
 
-        $routingCtx = new BackedRoutingContext;
+        $routingCtx = new RoutingContext;
         $this->deps->register($this->urls = $routingCtx->urls);
 
-        $rpcServer = new HttpRpcServer;
-        $routingCtx->register($rpcServer->createRouteHandler(
-            new HmacVerificationProvider(fn() => $this->config->getString('aleister.secret'))
-        ));
-
         if(in_array('main', $roles))
-            $this->registerMainRoutes(
-                $routingCtx->scopeTo($this->domainRoles->getPrefix($host, 'main')),
-                $rpcServer
-            );
+            $this->registerMainRoutes($routingCtx);
 
         if(in_array('redirect', $roles))
-            $this->registerRedirectorRoutes(
-                $routingCtx->scopeTo($this->domainRoles->getPrefix($host, 'redirect'))
-            );
+            $this->registerRedirectorRoutes($routingCtx);
 
         return $routingCtx;
     }
 
     public function registerMainRoutes(
-        RoutingContext $routingCtx,
-        RpcServer $rpcServer
+        RoutingContext $routingCtx
     ): void {
+        $rpcServer = new HttpRpcServer;
+        $routingCtx->register($rpcServer->createRouteHandler(
+            new HmacVerificationProvider(fn() => $this->config->getString('aleister.secret'))
+        ));
+
         $this->deps->register($wf = new WebFinger\WebFingerRegistry);
         $wf->register($this->deps->constructLazy(
             Users\UsersWebFingerResolver::class,
diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php
index 3448383f..f4805bee 100644
--- a/src/News/NewsRoutes.php
+++ b/src/News/NewsRoutes.php
@@ -4,7 +4,8 @@ namespace Misuzu\News;
 use RuntimeException;
 use Index\Colour\Colour;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Syndication\FeedBuilder;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
@@ -98,7 +99,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
         return $posts;
     }
 
-    #[HttpGet('/news')]
+    #[ExactRoute('GET', '/news')]
     #[UrlFormat('news-index', '/news', ['p' => '<page>'])]
     public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string {
         $categories = $this->news->getCategories(hidden: false);
@@ -116,7 +117,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/news/([0-9]+)(?:\.(xml|rss|atom))?')]
+    #[PatternRoute('GET', '/news/([0-9]+)(?:\.(xml|rss|atom))?')]
     #[UrlFormat('news-category', '/news/<category>', ['p' => '<page>'])]
     public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryId, string $type = ''): int|string {
         try {
@@ -141,7 +142,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/news/post/([0-9]+)')]
+    #[PatternRoute('GET', '/news/post/([0-9]+)')]
     #[UrlFormat('news-post', '/news/post/<post>')]
     #[UrlFormat('news-post-comments', '/news/post/<post>', fragment: 'comments')]
     public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $postId): int|string {
@@ -168,7 +169,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
         ]);
     }
 
-    #[HttpGet('/news.(?:xml|rss|atom)')]
+    #[PatternRoute('GET', '/news.(?:xml|rss|atom)')]
     #[UrlFormat('news-feed', '/news.xml')]
     #[UrlFormat('news-category-feed', '/news/<category>.xml')]
     public function getFeed(HttpResponseBuilder $response, HttpRequest $request, ?NewsCategoryInfo $categoryInfo = null): string {
diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php
index 141eb2f5..3d10765e 100644
--- a/src/OAuth2/OAuth2ApiRoutes.php
+++ b/src/OAuth2/OAuth2ApiRoutes.php
@@ -4,8 +4,12 @@ namespace Misuzu\OAuth2;
 use RuntimeException;
 use Index\XArray;
 use Index\Colour\{Colour,ColourRgb};
-use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
-use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\{HttpResponseBuilder,HttpRequest};
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\AccessControl\AccessControl;
+use Index\Http\Routing\Processors\Before;
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\SiteInfo;
 use Misuzu\Apps\AppsContext;
@@ -52,20 +56,16 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
     }
 
     /**
-     * @return int|array{
+     * @return array{
      *  resource: string,
      *  authorization_servers: string[],
      *  scopes_supported: string[],
      *  bearer_methods_supported: string[],
      * }
      */
-    #[HttpOptions('/.well-known/oauth-protected-resource')]
-    #[HttpGet('/.well-known/oauth-protected-resource')]
-    public function getWellKnownProtectedResource(HttpResponseBuilder $response, HttpRequest $request): array|int {
-        $response->setHeader('Access-Control-Allow-Origin', '*');
-        if($request->method === 'OPTIONS')
-            return 204;
-
+    #[AccessControl]
+    #[ExactRoute('GET', '/.well-known/oauth-protected-resource')]
+    public function getWellKnownProtectedResource(HttpResponseBuilder $response, HttpRequest $request): array {
         return [
             'resource' => $this->siteInfo->url,
             'authorization_servers' => [$this->siteInfo->url],
@@ -75,7 +75,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
     }
 
     /**
-     * @return int|array{
+     * @return array{
      *  issuer: string,
      *  authorization_endpoint: string,
      *  token_endpoint: string,
@@ -92,13 +92,9 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
      *  code_challenge_methods_supported: string[],
      * }
      */
-    #[HttpOptions('/.well-known/oauth-authorization-server')]
-    #[HttpGet('/.well-known/oauth-authorization-server')]
-    public function getWellKnownAuthorizationServer(HttpResponseBuilder $response, HttpRequest $request): array|int {
-        $response->setHeader('Access-Control-Allow-Origin', '*');
-        if($request->method === 'OPTIONS')
-            return 204;
-
+    #[AccessControl]
+    #[ExactRoute('GET', '/.well-known/oauth-authorization-server')]
+    public function getWellKnownAuthorizationServer(HttpResponseBuilder $response, HttpRequest $request): array {
         return [
             'issuer' => $this->siteInfo->url,
             'authorization_endpoint' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('oauth2-authorise')),
@@ -134,7 +130,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
     }
 
     /**
-     * @return int|array{
+     * @return array{
      *  issuer: string,
      *  authorization_endpoint: string,
      *  token_endpoint: string,
@@ -148,13 +144,9 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
      *  token_endpoint_auth_methods_supported: string[],
      * }
      */
-    #[HttpOptions('/.well-known/openid-configuration')]
-    #[HttpGet('/.well-known/openid-configuration')]
-    public function getWellKnown(HttpResponseBuilder $response, HttpRequest $request): array|int {
-        $response->setHeader('Access-Control-Allow-Origin', '*');
-        if($request->method === 'OPTIONS')
-            return 204;
-
+    #[AccessControl]
+    #[ExactRoute('GET', '/.well-known/openid-configuration')]
+    public function getWellKnown(HttpResponseBuilder $response, HttpRequest $request): array {
         $signingAlgs = array_values(array_unique(XArray::select(
             $this->oauth2Ctx->keys->getPublicKeySet()->keys,
             fn($key) => $key->algo
@@ -185,11 +177,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
     #[HttpOptions('/oauth2/jwks.json')]
     #[HttpGet('/oauth2/jwks.json')]
     #[UrlFormat('oauth2-jwks', '/oauth2/jwks.json')]
-    public function getJwks(HttpResponseBuilder $response, HttpRequest $request): int|array {
-        $response->setHeader('Access-Control-Allow-Origin', '*');
-        if($request->method === 'OPTIONS')
-            return 204;
-
+    public function getJwks(HttpResponseBuilder $response, HttpRequest $request): array {
         return $this->oauth2Ctx->keys->getPublicKeysForJson();
     }
 
@@ -203,13 +191,13 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
      *  interval?: int
      * }|array{ error: string, error_description: string }
      */
-    #[HttpPost('/oauth2/request-authorise')]
-    #[HttpPost('/oauth2/request-authorize')]
+    #[PatternRoute('POST', '/oauth2/request-authori[sz]e')]
+    #[Before('input:urlencoded', required: false)]
     #[UrlFormat('oauth2-request-authorise', '/oauth2/request-authorize')]
-    public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
+    public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request, ?FormContent $content): array {
         $response->setHeader('Cache-Control', 'no-store');
 
-        if(!($request->content instanceof FormHttpContent))
+        if($content === null)
             return self::filter($response, $request, [
                 'error' => 'invalid_request',
                 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
@@ -226,7 +214,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
                 'error_description' => 'You must use the Basic method for Authorization parameters.',
             ], authzHeader: true);
         } else {
-            $clientId = (string)$request->content->getParam('client_id');
+            $clientId = (string)$content->getParam('client_id');
             $clientSecret = '';
         }
 
@@ -250,12 +238,12 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
 
         return self::filter($response, $request, $this->oauth2Ctx->createDeviceAuthorisationRequest(
             $appInfo,
-            $request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
+            $content->hasParam('scope') ? (string)$content->getParam('scope') : null
         ));
     }
 
     /**
-     * @return int|array{
+     * @return array{
      *  access_token: string,
      *  token_type: 'Bearer',
      *  expires_in?: int,
@@ -263,36 +251,14 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
      *  refresh_token?: string,
      * }|array{ error: string, error_description: string }
      */
-    #[HttpOptions('/oauth2/token')]
-    #[HttpPost('/oauth2/token')]
+    #[AccessControl(allowHeaders: ['Authorization'], exposeHeaders: ['WWW-Authenticate'])]
+    #[ExactRoute('POST', '/oauth2/token')]
+    #[Before('input:urlencoded', required: false)]
     #[UrlFormat('oauth2-token', '/oauth2/token')]
-    public function postToken(HttpResponseBuilder $response, HttpRequest $request): array|int {
+    public function postToken(HttpResponseBuilder $response, HttpRequest $request, ?FormContent $content): array {
         $response->setHeader('Cache-Control', 'no-store');
 
-        $originHeaders = ['Origin', 'X-Origin', 'Referer'];
-        $origins = [];
-        foreach($originHeaders as $originHeader) {
-            $originHeader = $request->getHeaderFirstLine($originHeader);
-            if($originHeader !== '' && !in_array($originHeader, $origins))
-                $origins[] = $originHeader;
-        }
-
-        if(!empty($origins)) {
-            // TODO: check if none of the provided origins is on a blocklist or something
-            // different origins being specified for each header should probably also be considered suspect...
-
-            $response->setHeader('Access-Control-Allow-Origin', $origins[0]);
-            $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST');
-            $response->setHeader('Access-Control-Allow-Headers', 'Authorization');
-            $response->setHeader('Access-Control-Expose-Headers', 'Vary');
-            foreach($originHeaders as $originHeader)
-                $response->setHeader('Vary', $originHeader);
-        }
-
-        if($request->method === 'OPTIONS')
-            return 204;
-
-        if(!($request->content instanceof FormHttpContent))
+        if($content === null)
             return self::filter($response, $request, [
                 'error' => 'invalid_request',
                 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
@@ -310,8 +276,8 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
                 'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.',
             ], authzHeader: true);
         } else {
-            $clientId = (string)$request->content->getParam('client_id');
-            $clientSecret = (string)$request->content->getParam('client_secret');
+            $clientId = (string)$content->getParam('client_id');
+            $clientSecret = (string)$content->getParam('client_secret');
         }
 
         try {
@@ -334,36 +300,36 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
                 ], authzHeader: $authzHeader[0] !== '');
         }
 
-        $type = (string)$request->content->getParam('grant_type');
+        $type = (string)$content->getParam('grant_type');
 
         if($type === 'authorization_code')
             return self::filter($response, $request, $this->oauth2Ctx->redeemAuthorisationCode(
                 $appInfo,
                 $isAuthed,
-                (string)$request->content->getParam('code'),
-                (string)$request->content->getParam('code_verifier')
+                (string)$content->getParam('code'),
+                (string)$content->getParam('code_verifier')
             ));
 
         if($type === 'refresh_token')
             return self::filter($response, $request, $this->oauth2Ctx->redeemRefreshToken(
                 $appInfo,
                 $isAuthed,
-                (string)$request->content->getParam('refresh_token'),
-                $request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
+                (string)$content->getParam('refresh_token'),
+                $content->hasParam('scope') ? (string)$content->getParam('scope') : null
             ));
 
         if($type === 'client_credentials')
             return self::filter($response, $request, $this->oauth2Ctx->redeemClientCredentials(
                 $appInfo,
                 $isAuthed,
-                $request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
+                $content->hasParam('scope') ? (string)$content->getParam('scope') : null
             ));
 
         if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code')
             return self::filter($response, $request, $this->oauth2Ctx->redeemDeviceCode(
                 $appInfo,
                 $isAuthed,
-                (string)$request->content->getParam('device_code')
+                (string)$content->getParam('device_code')
             ));
 
         return self::filter($response, $request, [
@@ -373,7 +339,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
     }
 
     /**
-     * @return int|array{
+     * @return array{
      *  sub: string,
      *  name?: string,
      *  nickname?: string,
diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php
index 6f17d0ec..5033b24c 100644
--- a/src/OAuth2/OAuth2WebRoutes.php
+++ b/src/OAuth2/OAuth2WebRoutes.php
@@ -4,8 +4,11 @@ namespace Misuzu\OAuth2;
 use InvalidArgumentException;
 use RuntimeException;
 use Index\XArray;
-use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
-use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\{HttpResponseBuilder,HttpRequest};
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{CSRF,Template};
 use Misuzu\Auth\AuthInfo;
@@ -24,15 +27,14 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
         private AuthInfo $authInfo,
     ) {}
 
-    #[HttpGet('/oauth2/authorise')]
-    #[HttpGet('/oauth2/authorize')]
+    #[PatternRoute('GET', '/oauth2/authori[sz]e')]
     #[UrlFormat('oauth2-authorise', '/oauth2/authorize')]
     public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request): string {
         return Template::renderRaw('oauth2.authorise');
     }
 
     /**
-     * @return int|array{
+     * @return array{
      *  error: 'auth'|'csrf'|'method'|'length'|'client'|'scope'|'format'|'required'|'authorise'|'resptype',
      *  scope?: string,
      *  reason?: string,
@@ -41,11 +43,9 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
      *  redirect: string,
      * }
      */
-    #[HttpPost('/oauth2/authorise')]
-    public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request): int|array {
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
+    #[ExactRoute('POST', '/oauth2/authorize')]
+    #[Before('input:urlencoded')]
+    public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
         // TODO: RATE LIMITING
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -56,20 +56,20 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
         if(!$this->authInfo->loggedIn)
             return ['error' => 'auth'];
 
-        $responseTypes = explode(' ', (string)$request->content->getParam('rt'), 2);
+        $responseTypes = explode(' ', (string)$content->getParam('rt'), 2);
         sort($responseTypes);
         $responseType = implode(' ', $responseTypes);
         if(!in_array($responseType, self::RESPONSE_TYPES))
             return ['error' => 'resptype'];
 
         $codeChallengeMethod = 'plain';
-        if($request->content->hasParam('ccm')) {
-            $codeChallengeMethod = $request->content->getParam('ccm');
+        if($content->hasParam('ccm')) {
+            $codeChallengeMethod = $content->getParam('ccm');
             if(!in_array($codeChallengeMethod, ['plain', 'S256']))
                 return ['error' => 'method'];
         }
 
-        $codeChallenge = $request->content->getParam('cc');
+        $codeChallenge = $content->getParam('cc');
         $codeChallengeLength = strlen($codeChallenge);
         if($codeChallengeMethod === 'S256') {
             if($codeChallengeLength !== 43)
@@ -81,16 +81,16 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
 
         try {
             $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
-                clientId: (string)$request->content->getParam('client'),
+                clientId: (string)$content->getParam('client'),
                 deleted: false
             );
         } catch(RuntimeException $ex) {
             return ['error' => 'client'];
         }
 
-        if($request->content->hasParam('scope')) {
+        if($content->hasParam('scope')) {
             $scope = [];
-            $scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->content->getParam('scope'));
+            $scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$content->getParam('scope'));
 
             foreach($scopeInfos as $scopeName => $scopeInfo) {
                 if(is_string($scopeInfo))
@@ -102,8 +102,8 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
             $scope = implode(' ', $scope);
         } else $scope = '';
 
-        if($request->content->hasParam('redirect')) {
-            $redirectUri = (string)$request->content->getParam('redirect');
+        if($content->hasParam('redirect')) {
+            $redirectUri = (string)$content->getParam('redirect');
             $redirectUriId = $this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri);
             if($redirectUriId === null)
                 return ['error' => 'format'];
@@ -169,7 +169,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
      *  scope: string[],
      * }
      */
-    #[HttpGet('/oauth2/resolve-authorise-app')]
+    #[ExactRoute('GET', '/oauth2/resolve-authorise-app')]
     #[UrlFormat('oauth2-resolve-authorise-app', '/oauth2/resolve-authorise-app')]
     public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
         // TODO: RATE LIMITING
@@ -243,14 +243,14 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
         return $result;
     }
 
-    #[HttpGet('/oauth2/verify')]
+    #[ExactRoute('GET', '/oauth2/verify')]
     #[UrlFormat('oauth2-verify', '/oauth2/verify')]
     public function getVerify(HttpResponseBuilder $response, HttpRequest $request): string {
         return Template::renderRaw('oauth2.verify');
     }
 
     /**
-     * @return int|array{
+     * @return array{
      *  error: 'auth'|'csrf'|'invalid'|'code'|'expired'|'approval'|'code'|'scope',
      *  scope?: string,
      *  reason?: string,
@@ -258,11 +258,9 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
      *  approval: 'approved'|'denied',
      * }
      */
-    #[HttpPost('/oauth2/verify')]
-    public function postVerify(HttpResponseBuilder $response, HttpRequest $request): int|array {
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
+    #[ExactRoute('POST', '/oauth2/verify')]
+    #[Before('input:urlencoded')]
+    public function postVerify(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
         // TODO: RATE LIMITING
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -273,13 +271,13 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
         if(!$this->authInfo->loggedIn)
             return ['error' => 'auth'];
 
-        $approve = (string)$request->content->getParam('approve');
+        $approve = (string)$content->getParam('approve');
         if(!in_array($approve, ['yes', 'no']))
             return ['error' => 'invalid'];
 
         try {
             $deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(
-                userCode: (string)$request->content->getParam('code')
+                userCode: (string)$content->getParam('code')
             );
         } catch(RuntimeException $ex) {
             return ['error' => 'code'];
@@ -357,7 +355,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
      *  scope: string[],
      * }
      */
-    #[HttpGet('/oauth2/resolve-verify')]
+    #[ExactRoute('GET', '/oauth2/resolve-verify')]
     #[UrlFormat('oauth2-resolve-verify', '/oauth2/resolve-verify')]
     public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
         // TODO: RATE LIMITING
diff --git a/src/Pagination.php b/src/Pagination.php
index 0e7edc2a..238603ac 100644
--- a/src/Pagination.php
+++ b/src/Pagination.php
@@ -57,7 +57,7 @@ final class Pagination {
     ): self {
         return self::fromPage(
             $count,
-            (int)(filter_input(INPUT_GET, $pageParam, FILTER_SANITIZE_NUMBER_INT) ?? $firstPage),
+            !empty($_GET[$pageParam]) && is_scalar($_GET[$pageParam]) ? (int)$_GET[$pageParam] : $firstPage,
             $range,
             $firstPage
         );
@@ -72,7 +72,7 @@ final class Pagination {
     ): Pagination {
         return self::fromPage(
             $count,
-            (int)($request->getParam($pageParam, FILTER_SANITIZE_NUMBER_INT) ?? $firstPage),
+            (int)($request->getFilteredParam($pageParam, FILTER_SANITIZE_NUMBER_INT) ?? $firstPage),
             $range,
             $firstPage
         );
diff --git a/src/Perm.php b/src/Perm.php
index cc5e65d8..59e7b6de 100644
--- a/src/Perm.php
+++ b/src/Perm.php
@@ -423,7 +423,7 @@ final class Perm {
 
                 $item->perms[] = $permItem = new stdClass;
                 $permItem->category = $categoryName;
-                $permItem->name = sprintf('perms[%s:%d]', $categoryName, $perm);
+                $permItem->name = sprintf('perm_%s_%d', $categoryName, $perm);
                 $permItem->title = self::label($categoryName, $perm);
                 $permItem->value = $perm;
             }
@@ -437,11 +437,18 @@ final class Perm {
      * @param string[] $categories
      * @return array<string, array<string, int>>
      */
-    public static function convertSubmission(array $raw, array $categories): array {
+    public static function convertSubmission(array $raw, array $categories, string $prefix = 'perm_'): array {
         $apply = [];
 
         foreach($raw as $name => $mode) {
-            $nameParts = explode(':', $name, 2);
+            if($prefix !== '') {
+                if(!str_starts_with($name, $prefix))
+                    continue;
+
+                $name = substr($name, strlen($prefix));
+            }
+
+            $nameParts = explode('_', $name, 2);
             if(count($nameParts) !== 2 || !ctype_alpha($nameParts[0]) || !ctype_digit($nameParts[1]))
                 continue;
 
@@ -449,12 +456,11 @@ final class Perm {
             if(!in_array($category, $categories))
                 continue;
 
-            if($mode === 'yes' || $mode === 'never') {
-                if(!array_key_exists($category, $apply))
-                    $apply[$category] = ['allow' => 0, 'deny' => 0];
+            if(!array_key_exists($category, $apply))
+                $apply[$category] = ['allow' => 0, 'deny' => 0];
 
+            if($mode === 'yes' || $mode === 'never')
                 $apply[$category][$mode === 'yes' ? 'allow' : 'deny'] |= (int)$value;
-            }
         }
 
         return $apply;
diff --git a/src/Redirects/AliasRedirectsRoutes.php b/src/Redirects/AliasRedirectsRoutes.php
index aa52a178..0d120a53 100644
--- a/src/Redirects/AliasRedirectsRoutes.php
+++ b/src/Redirects/AliasRedirectsRoutes.php
@@ -3,7 +3,8 @@ namespace Misuzu\Redirects;
 
 use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\PatternRoute;
 
 class AliasRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
@@ -24,23 +25,23 @@ class AliasRedirectsRoutes implements RouteHandler {
         $response->redirect($url, true);
     }
 
-    #[HttpGet('/[up]([0-9]+)')]
-    #[HttpGet('/[up]/([A-Za-z0-9\-_]+)')]
+    #[PatternRoute('GET', '/[up]([0-9]+)')]
+    #[PatternRoute('GET', '/[up]/([A-Za-z0-9\-_]+)')]
     public function getProfileRedirect(HttpResponseBuilder $response, HttpRequest $request, string $userId): void {
         $this->redirect($response, $request, 'user_profile', $userId);
     }
 
-    #[HttpGet('/fc?/?([0-9]+)')]
+    #[PatternRoute('GET', '/fc?/?([0-9]+)')]
     public function getForumCategoryRedirect(HttpResponseBuilder $response, HttpRequest $request, string $categoryId): void {
         $this->redirect($response, $request, 'forum_category', $categoryId);
     }
 
-    #[HttpGet('/ft/?([0-9]+)')]
+    #[PatternRoute('GET', '/ft/?([0-9]+)')]
     public function getForumTopicRedirect(HttpResponseBuilder $response, HttpRequest $request, string $topicId): void {
         $this->redirect($response, $request, 'forum_topic', $topicId);
     }
 
-    #[HttpGet('/fp/?([0-9]+)')]
+    #[PatternRoute('GET', '/fp/?([0-9]+)')]
     public function getForumPostRedirect(HttpResponseBuilder $response, HttpRequest $request, string $postId): void {
         $this->redirect($response, $request, 'forum_post', $postId);
     }
diff --git a/src/Redirects/IncrementalRedirectsRoutes.php b/src/Redirects/IncrementalRedirectsRoutes.php
index ea308935..5d75dcfc 100644
--- a/src/Redirects/IncrementalRedirectsRoutes.php
+++ b/src/Redirects/IncrementalRedirectsRoutes.php
@@ -3,8 +3,11 @@ namespace Misuzu\Redirects;
 
 use RuntimeException;
 use Index\XNumber;
-use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\HttpResponseBuilder;
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 
 class IncrementalRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
@@ -13,8 +16,8 @@ class IncrementalRedirectsRoutes implements RouteHandler {
         private RedirectsContext $redirectsCtx,
     ) {}
 
-    #[HttpGet('/[bg]/([A-Za-z0-9]+)')]
-    public function getIncrementalRedirect(HttpResponseBuilder $response, HttpRequest $request, string $linkId): int {
+    #[PatternRoute('GET', '/[bg]/([A-Za-z0-9]+)')]
+    public function getIncrementalRedirect(HttpResponseBuilder $response, string $linkId): int {
         $linkId = XNumber::fromBase62($linkId);
         if($linkId === false)
             return 400;
@@ -30,15 +33,13 @@ class IncrementalRedirectsRoutes implements RouteHandler {
     }
 
     /** @return int|array{url: string} */
-    #[HttpPost('/satori/create')]
-    public function postIncrementalRedirect(HttpResponseBuilder $response, HttpRequest $request): int|array {
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
+    #[ExactRoute('POST', '/satori/create')]
+    #[Before('input:urlencoded')]
+    public function postIncrementalRedirect(FormContent $content): int|array {
         $config = $this->redirectsCtx->config->scopeTo('incremental');
-        $url = (string)$request->content->getParam('u');
-        $time = (int)$request->content->getParam('t', FILTER_SANITIZE_NUMBER_INT);
-        $sign = base64_decode((string)$request->content->getParam('s'));
+        $url = (string)$content->getParam('u');
+        $time = (int)$content->getFilteredParam('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))
diff --git a/src/Redirects/LandingRedirectsRoutes.php b/src/Redirects/LandingRedirectsRoutes.php
index 19535e50..01b45e61 100644
--- a/src/Redirects/LandingRedirectsRoutes.php
+++ b/src/Redirects/LandingRedirectsRoutes.php
@@ -1,13 +1,14 @@
 <?php
 namespace Misuzu\Redirects;
 
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\ExactRoute;
 use Misuzu\Template;
 
 class LandingRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
-    #[HttpGet('/')]
+    #[ExactRoute('GET', '/')]
     public function getIndex(): string {
         return Template::renderRaw('redirects.landing');
     }
diff --git a/src/Redirects/NamedRedirectsRoutes.php b/src/Redirects/NamedRedirectsRoutes.php
index 4dad14bc..d6c6d54d 100644
--- a/src/Redirects/NamedRedirectsRoutes.php
+++ b/src/Redirects/NamedRedirectsRoutes.php
@@ -2,9 +2,9 @@
 namespace Misuzu\Redirects;
 
 use RuntimeException;
-use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\PatternRoute;
 
 class NamedRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
@@ -13,7 +13,7 @@ class NamedRedirectsRoutes implements RouteHandler {
         private RedirectsContext $redirectsCtx,
     ) {}
 
-    #[HttpGet('/([A-Za-z0-9\-_]+)')]
+    #[PatternRoute('GET', '/([A-Za-z0-9\-_]+)')]
     public function getNamedRedirect(HttpResponseBuilder $response, HttpRequest $request, string $name): int {
         try {
             $redirectInfo = $this->redirectsCtx->named->getNamedRedirect(
diff --git a/src/Redirects/SocialRedirectsRoutes.php b/src/Redirects/SocialRedirectsRoutes.php
index 2b921296..173ed7bd 100644
--- a/src/Redirects/SocialRedirectsRoutes.php
+++ b/src/Redirects/SocialRedirectsRoutes.php
@@ -1,8 +1,9 @@
 <?php
 namespace Misuzu\Redirects;
 
-use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\HttpResponseBuilder;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\PatternRoute;
 use Misuzu\Template;
 
 class SocialRedirectsRoutes implements RouteHandler {
@@ -12,8 +13,8 @@ class SocialRedirectsRoutes implements RouteHandler {
         private RedirectsContext $redirectsCtx
     ) {}
 
-    #[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): int|string {
+    #[PatternRoute('GET', '/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, string $handle): int|string {
         $did = null;
 
         if(str_starts_with($handle, 'did:'))
@@ -47,8 +48,8 @@ class SocialRedirectsRoutes implements RouteHandler {
         ]);
     }
 
-    #[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): string {
+    #[PatternRoute('GET', '/fedi/([A-Za-z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})')]
+    public function getFediverseRedirect(string $userName, string $instance): string {
         return Template::renderRaw('redirects.fedi', [
             'fedi_username' => $userName,
             'fedi_instance' => $instance,
diff --git a/src/Routing/BackedRoutingContext.php b/src/Routing/BackedRoutingContext.php
deleted file mode 100644
index a52623ea..00000000
--- a/src/Routing/BackedRoutingContext.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-namespace Misuzu\Routing;
-
-use Index\Http\HttpRequest;
-use Index\Http\Routing\{HttpRouter,RouteHandler};
-use Index\Urls\{ArrayUrlRegistry,UrlRegistry,UrlSource};
-
-class BackedRoutingContext implements RoutingContext {
-    public private(set) HttpRouter $router;
-    public private(set) UrlRegistry $urls;
-
-    public function __construct(
-        ?HttpRouter $router = null,
-        ?UrlRegistry $urls = null
-    ) {
-        $this->urls = $urls ?? new ArrayUrlRegistry;
-        $this->router = $router ?? new HttpRouter(errorHandler: new RoutingErrorHandler);
-        $this->router->use('/', fn($resp) => $resp->setPoweredBy('Misuzu'));
-    }
-
-    public function register(RouteHandler|UrlSource $handler): void {
-        if($handler instanceof RouteHandler)
-            $this->router->register($handler);
-        if($handler instanceof UrlSource)
-            $this->urls->register($handler);
-    }
-
-    public function scopeTo(string $prefix): RoutingContext {
-        return new ScopedRoutingContext(
-            $this->router->scopeTo($prefix),
-            $this->urls->scopeTo(pathPrefix: $prefix)
-        );
-    }
-
-    /** @param mixed[] $args */
-    public function dispatch(?HttpRequest $request = null, array $args = []): void {
-        $this->router->dispatch($request, $args);
-    }
-}
diff --git a/src/Routing/RoutingContext.php b/src/Routing/RoutingContext.php
index 7c2ffcd0..d40fd9f3 100644
--- a/src/Routing/RoutingContext.php
+++ b/src/Routing/RoutingContext.php
@@ -1,11 +1,32 @@
 <?php
 namespace Misuzu\Routing;
 
-use Index\Http\HttpRequest;
-use Index\Http\Routing\RouteHandler;
-use Index\Urls\UrlSource;
+use Index\Http\{HttpRequest,HttpResponseBuilder};
+use Index\Http\Routing\{Router,RouteHandler};
+use Index\Http\Routing\Filters\FilterInfo;
+use Index\Urls\{ArrayUrlRegistry,UrlRegistry,UrlSource};
 
-interface RoutingContext {
-    public function register(RouteHandler|UrlSource $handler): void;
-    public function scopeTo(string $prefix): RoutingContext;
+class RoutingContext {
+    public private(set) Router $router;
+    public private(set) UrlRegistry $urls;
+
+    public function __construct(
+        ?Router $router = null,
+        ?UrlRegistry $urls = null
+    ) {
+        $this->urls = $urls ?? new ArrayUrlRegistry;
+        $this->router = $router ?? new Router(errorHandler: new RoutingErrorHandler);
+        $this->router->filter(FilterInfo::prefix('/', fn(HttpResponseBuilder $response) => $response->setPoweredBy('Misuzu')));
+    }
+
+    public function register(RouteHandler|UrlSource $handler): void {
+        if($handler instanceof RouteHandler)
+            $this->router->register($handler);
+        if($handler instanceof UrlSource)
+            $this->urls->register($handler);
+    }
+
+    public function dispatch(?HttpRequest $request = null): void {
+        $this->router->dispatch($request);
+    }
 }
diff --git a/src/Routing/RoutingErrorHandler.php b/src/Routing/RoutingErrorHandler.php
index ecd45513..a01cf401 100644
--- a/src/Routing/RoutingErrorHandler.php
+++ b/src/Routing/RoutingErrorHandler.php
@@ -1,14 +1,19 @@
 <?php
 namespace Misuzu\Routing;
 
-use Index\Http\{HtmlHttpErrorHandler,HttpResponseBuilder,HttpRequest};
-use Misuzu\{Misuzu,Template};
+use Index\Http\Routing\HandlerContext;
+use Index\Http\Routing\ErrorHandling\HtmlErrorHandler;
+use Index\Http\Streams\Stream;
+use Misuzu\Misuzu;
 
-class RoutingErrorHandler extends HtmlHttpErrorHandler {
-    public function handle(HttpResponseBuilder $response, HttpRequest $request, int $code, string $message): void {
-        if(str_starts_with($request->path, '/_')) {
-            $response->setTypePlain();
-            $response->content = sprintf('HTTP %03d', $code);
+class RoutingErrorHandler extends HtmlErrorHandler {
+    public function handle(HandlerContext $context): void {
+        if(!$context->response->needsBody)
+            return;
+
+        if(str_starts_with($context->request->requestTarget, '/_')) {
+            $context->response->setTypePlain();
+            $context->response->body = Stream::createStream(sprintf('HTTP %03d', $context->response->statusCode));
             return;
         }
 
@@ -23,11 +28,11 @@ class RoutingErrorHandler extends HtmlHttpErrorHandler {
 
         $path = sprintf('%s/error-%03d.html', Misuzu::PATH_PUBLIC, $code);
         if(is_file($path)) {
-            $response->setTypeHTML();
-            $response->content = file_get_contents($path);
+            $context->response->setTypeHTML();
+            $context->response->body = Stream::createStreamFromFile($path, 'rb');
             return;
         }
 
-        parent::handle($response, $request, $code, $message);
+        parent::handle($context);
     }
 }
diff --git a/src/Routing/ScopedRoutingContext.php b/src/Routing/ScopedRoutingContext.php
deleted file mode 100644
index 2669bde0..00000000
--- a/src/Routing/ScopedRoutingContext.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-namespace Misuzu\Routing;
-
-use Index\Http\HttpRequest;
-use Index\Http\Routing\{RouteHandler,Router};
-use Index\Urls\{UrlRegistry,UrlSource};
-
-class ScopedRoutingContext implements RoutingContext {
-    public function __construct(
-        private Router $router,
-        private UrlRegistry $urls
-    ) {}
-
-    public function register(RouteHandler|UrlSource $handler): void {
-        if($handler instanceof RouteHandler)
-            $this->router->register($handler);
-        if($handler instanceof UrlSource)
-            $this->urls->register($handler);
-    }
-
-    public function scopeTo(string $prefix): RoutingContext {
-        return new ScopedRoutingContext(
-            $this->router->scopeTo($prefix),
-            $this->urls->scopeTo(pathPrefix: $prefix)
-        );
-    }
-}
diff --git a/src/Satori/SatoriRoutes.php b/src/Satori/SatoriRoutes.php
index 45667b1b..aa133853 100644
--- a/src/Satori/SatoriRoutes.php
+++ b/src/Satori/SatoriRoutes.php
@@ -5,7 +5,12 @@ use RuntimeException;
 use Index\Colour\Colour;
 use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,HttpMiddleware,RouteHandler,RouteHandlerCommon};
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\AccessControl\AccessControl;
+use Index\Http\Routing\Filters\PrefixFilter;
+use Index\Http\Routing\Processors\Before;
+use Index\Http\Routing\Routes\ExactRoute;
 use Misuzu\Pagination;
 use Misuzu\Forum\ForumContext;
 use Misuzu\Profile\ProfileContext;
@@ -22,8 +27,8 @@ final class SatoriRoutes implements RouteHandler {
     ) {}
 
     /** @return void|int */
-    #[HttpMiddleware('/_satori')]
-    public function verifyRequest(HttpResponseBuilder $response, HttpRequest $request) {
+    #[PrefixFilter('/_satori')]
+    public function verifyRequest(HttpRequest $request) {
         $secretKey = $this->config->getString('secret');
 
         if(!empty($secretKey)) {
@@ -34,7 +39,7 @@ final class SatoriRoutes implements RouteHandler {
             if(empty($userHash) || $userTime < $currentTime - 60 || $userTime > $currentTime + 60)
                 return 403;
 
-            $verifyText = (string)$userTime . '#' . $request->path . '?' . $request->getParamString();
+            $verifyText = (string)$userTime . '#' . $request->requestTarget . '?' . $request->getParamString(true);
             $verifyHash = hash_hmac('sha256', $verifyText, $secretKey, true);
 
             if(!hash_equals($verifyHash, $userHash))
@@ -43,10 +48,10 @@ final class SatoriRoutes implements RouteHandler {
     }
 
     /** @return array{error: int}|array{field_value: string} */
-    #[HttpGet('/_satori/get-profile-field')]
-    public function getProfileField(HttpResponseBuilder $response, HttpRequest $request): array {
-        $userId = (string)$request->getParam('user', FILTER_SANITIZE_NUMBER_INT);
-        $fieldId = (string)$request->getParam('field', FILTER_SANITIZE_NUMBER_INT);
+    #[ExactRoute('GET', '/_satori/get-profile-field')]
+    public function getProfileField(HttpRequest $request): array {
+        $userId = (string)$request->getFilteredParam('user', FILTER_SANITIZE_NUMBER_INT);
+        $fieldId = (string)$request->getFilteredParam('field', FILTER_SANITIZE_NUMBER_INT);
 
         try {
             $fieldValue = $this->profileCtx->fields->getFieldValue($fieldId, $userId);
@@ -72,15 +77,15 @@ final class SatoriRoutes implements RouteHandler {
      *  is_opening_post: int
      * }[]
      */
-    #[HttpGet('/_satori/get-recent-forum-posts')]
-    public function getRecentForumPosts(HttpResponseBuilder $response, HttpRequest $request): array {
+    #[ExactRoute('GET', '/_satori/get-recent-forum-posts')]
+    public function getRecentForumPosts(HttpRequest $request): array {
         $categoryIds = $this->config->getArray('forum.categories');
         if(empty($categoryIds))
             return [];
 
         $batchSize = $this->config->getInteger('forum.batch', 6);
         $backlogDays = $this->config->getInteger('forum.backlog', 7);
-        $startId = (string)$request->getParam('start', FILTER_SANITIZE_NUMBER_INT);
+        $startId = (string)$request->getFilteredParam('start', FILTER_SANITIZE_NUMBER_INT);
 
         $posts = [];
         $postInfos = $this->forumCtx->posts->getPosts(
@@ -120,11 +125,11 @@ final class SatoriRoutes implements RouteHandler {
      *  username: string
      * }[]
      */
-    #[HttpGet('/_satori/get-recent-registrations')]
-    public function getRecentRegistrations(HttpResponseBuilder $response, HttpRequest $request): array {
+    #[ExactRoute('GET', '/_satori/get-recent-registrations')]
+    public function getRecentRegistrations(HttpRequest $request): array {
         $batchSize = $this->config->getInteger('users.batch', 10);
         $backlogDays = $this->config->getInteger('users.backlog', 7);
-        $startId = (string)$request->getParam('start', FILTER_SANITIZE_NUMBER_INT);
+        $startId = (string)$request->getFilteredParam('start', FILTER_SANITIZE_NUMBER_INT);
 
         $userInfos = $this->usersCtx->users->getUsers(
             after: $startId,
diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php
index 8fa23872..c9516fe3 100644
--- a/src/SharpChat/SharpChatRoutes.php
+++ b/src/SharpChat/SharpChatRoutes.php
@@ -4,8 +4,12 @@ namespace Misuzu\SharpChat;
 use RuntimeException;
 use Index\Colour\Colour;
 use Index\Config\Config;
-use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HandlerAttribute,HttpDelete,HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Http\{HttpRequest,HttpResponseBuilder};
+use Index\Http\Content\{FormContent,UrlEncodedFormContent};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\AccessControl\AccessControl;
+use Index\Http\Routing\Processors\Before;
+use Index\Http\Routing\Routes\ExactRoute;
 use Index\Urls\UrlRegistry;
 use Misuzu\Auth\{AuthContext,AuthInfo,Sessions};
 use Misuzu\Counters\CountersData;
@@ -34,17 +38,10 @@ final class SharpChatRoutes implements RouteHandler {
         $this->hashKey = $this->config->getString('hashKey', 'woomy');
     }
 
-    /** @return int|array{Text: string[], Image: string, Hierarchy: int}[] */
-    #[HttpOptions('/_sockchat/emotes')]
-    #[HttpGet('/_sockchat/emotes')]
-    public function getEmotes(HttpResponseBuilder $response, HttpRequest $request): array|int {
-        $response->setHeader('Access-Control-Allow-Origin', '*');
-        $response->setHeader('Access-Control-Allow-Methods', 'GET');
-        $response->setHeader('Access-Control-Allow-Headers', 'Cache-Control');
-
-        if($request->method === 'OPTIONS')
-            return 204;
-
+    /** @return array{Text: string[], Image: string, Hierarchy: int}[] */
+    #[AccessControl(allowHeaders: ['Cache-Control'])]
+    #[ExactRoute('GET', '/_sockchat/emotes')]
+    public function getEmotes(): array {
         $this->counters->increment('dev:legacy_emotes_loads');
 
         $emotes = $this->emotes->getEmotes(orderBy: 'order');
@@ -66,7 +63,7 @@ final class SharpChatRoutes implements RouteHandler {
         return $out;
     }
 
-    #[HttpGet('/_sockchat/login')]
+    #[ExactRoute('GET', '/_sockchat/login')]
     public function getLogin(HttpResponseBuilder $response, HttpRequest $request): void {
         if(!$this->authInfo->loggedIn) {
             $response->redirect($this->urls->format('auth-login'));
@@ -87,34 +84,11 @@ final class SharpChatRoutes implements RouteHandler {
         return in_array($targetId, $whitelist, true);
     }
 
-    /** @return int|array{ok: false, err: string}|array{ok: true, usr: int, tkn: string} */
-    #[HttpOptions('/_sockchat/token')]
-    #[HttpGet('/_sockchat/token')]
-    public function getToken(HttpResponseBuilder $response, HttpRequest $request): int|array {
-        $host = $request->hasHeader('Host') ? $request->getHeaderFirstLine('Host') : '';
-        $origin = $request->hasHeader('Origin') ? $request->getHeaderFirstLine('Origin') : '';
-        $originHost = strtolower(parse_url($origin, PHP_URL_HOST) ?? '');
-
-        if(!empty($originHost) && $originHost !== $host) {
-            $whitelist = $this->config->getArray('origins', []);
-
-            if(!in_array($originHost, $whitelist))
-                return 403;
-
-            $originProto = strtolower(parse_url($origin, PHP_URL_SCHEME));
-            $origin = $originProto . '://' . $originHost;
-
-            $response->setHeader('Access-Control-Allow-Origin', $origin);
-            $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET');
-            $response->setHeader('Access-Control-Allow-Credentials', 'true');
-            $response->setHeader('Vary', 'Origin');
-        }
-
-        if($request->method === 'OPTIONS')
-            return 204;
-
+    /** @return array{ok: false, err: string}|array{ok: true, usr: int, tkn: string} */
+    #[AccessControl(credentials: true)]
+    #[ExactRoute('GET', '/_sockchat/token')]
+    public function getToken(HttpRequest $request): array {
         $tokenInfo = $this->authInfo->tokenInfo;
-
         if(!$tokenInfo->hasSessionToken)
             return ['ok' => false, 'err' => 'token'];
 
@@ -144,25 +118,28 @@ final class SharpChatRoutes implements RouteHandler {
     }
 
     /** @return int|void */
-    #[HttpPost('/_sockchat/bump')]
-    public function postBump(HttpResponseBuilder $response, HttpRequest $request) {
+    #[ExactRoute('POST', '/_sockchat/bump')]
+    #[Before('input:urlencoded')]
+    public function postBump(HttpRequest $request, UrlEncodedFormContent $content) {
         if(!$request->hasHeader('X-SharpChat-Signature'))
             return 400;
 
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
+        $bumpList = [];
+        foreach($content->params as $name => $value) {
+            if(count($value) < 1)
+                continue;
 
-        $bumpList = $request->content->getParam('u', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
-        if(!is_array($bumpList))
-            return 400;
+            if(str_starts_with($name, 'u[') && str_ends_with($name, ']'))
+                $bumpList[substr($name, 2, -1)] = $value[0];
+        }
 
-        $userTime = (int)$request->content->getParam('t', FILTER_SANITIZE_NUMBER_INT);
+        $userTime = (int)$content->getFilteredParam('t', FILTER_SANITIZE_NUMBER_INT);
         $signature = "bump#{$userTime}";
 
         foreach($bumpList as $userId => $ipAddr)
             $signature .= "#{$userId}:{$ipAddr}";
 
-        $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
+        $userHash = (string)$request->getHeaderLine('X-SharpChat-Signature');
         $realHash = hash_hmac('sha256', $signature, $this->hashKey);
         if(!hash_equals($realHash, $userHash))
             return 403;
@@ -185,19 +162,17 @@ final class SharpChatRoutes implements RouteHandler {
      *  super: bool
      * }
      */
-    #[HttpPost('/_sockchat/verify')]
-    public function postVerify(HttpResponseBuilder $response, HttpRequest $request): int|array {
+    #[ExactRoute('POST', '/_sockchat/verify')]
+    #[Before('input:urlencoded')]
+    public function postVerify(HttpRequest $request, FormContent $content): int|array {
         if(!$request->hasHeader('X-SharpChat-Signature'))
             return 400;
 
-        if(!($request->content instanceof FormHttpContent))
-            return ['success' => false, 'reason' => 'request'];
+        $authMethod = (string)$content->getParam('method');
+        $authToken = (string)$content->getParam('token');
+        $ipAddress = (string)$content->getParam('ipaddr');
 
-        $authMethod = (string)$request->content->getParam('method');
-        $authToken = (string)$request->content->getParam('token');
-        $ipAddress = (string)$request->content->getParam('ipaddr');
-
-        $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
+        $userHash = (string)$request->getHeaderLine('X-SharpChat-Signature');
         if(strlen($userHash) !== 64)
             return ['success' => false, 'reason' => 'length'];
 
@@ -291,13 +266,13 @@ final class SharpChatRoutes implements RouteHandler {
      *  expires: string
      * }[]
      */
-    #[HttpGet('/_sockchat/bans/list')]
-    public function getBanList(HttpResponseBuilder $response, HttpRequest $request): int|array {
+    #[ExactRoute('GET', '/_sockchat/bans/list')]
+    public function getBanList(HttpRequest $request): int|array {
         if(!$request->hasHeader('X-SharpChat-Signature'))
             return 400;
 
-        $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
-        $userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
+        $userHash = (string)$request->getHeaderLine('X-SharpChat-Signature');
+        $userTime = (int)$request->getFilteredParam('x', FILTER_SANITIZE_NUMBER_INT);
 
         $realHash = hash_hmac('sha256', "list#{$userTime}", $this->hashKey);
         if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
@@ -335,16 +310,16 @@ final class SharpChatRoutes implements RouteHandler {
      *  expires: string
      * }
      */
-    #[HttpGet('/_sockchat/bans/check')]
-    public function getBanCheck(HttpResponseBuilder $response, HttpRequest $request): int|array {
+    #[ExactRoute('GET', '/_sockchat/bans/check')]
+    public function getBanCheck(HttpRequest $request): int|array {
         if(!$request->hasHeader('X-SharpChat-Signature'))
             return 400;
 
-        $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
-        $userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
+        $userHash = (string)$request->getHeaderLine('X-SharpChat-Signature');
+        $userTime = (int)$request->getFilteredParam('x', FILTER_SANITIZE_NUMBER_INT);
         $ipAddress = (string)$request->getParam('a');
         $userId = (string)$request->getParam('u');
-        $userIdIsName = (int)$request->getParam('n', FILTER_SANITIZE_NUMBER_INT);
+        $userIdIsName = (int)$request->getFilteredParam('n', FILTER_SANITIZE_NUMBER_INT);
 
         $realHash = hash_hmac('sha256', "check#{$userTime}#{$userId}#{$ipAddress}#{$userIdIsName}", $this->hashKey);
         if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
@@ -371,23 +346,21 @@ final class SharpChatRoutes implements RouteHandler {
         ];
     }
 
-    #[HttpPost('/_sockchat/bans/create')]
-    public function postBanCreate(HttpResponseBuilder $response, HttpRequest $request): int {
+    #[ExactRoute('POST', '/_sockchat/bans/create')]
+    #[Before('input:urlencoded')]
+    public function postBanCreate(HttpRequest $request, FormContent $content): int {
         if(!$request->hasHeader('X-SharpChat-Signature'))
             return 400;
 
-        if(!($request->content instanceof FormHttpContent))
-            return 400;
-
-        $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
-        $userTime = (int)$request->content->getParam('t', FILTER_SANITIZE_NUMBER_INT);
-        $userId = (string)$request->content->getParam('ui', FILTER_SANITIZE_NUMBER_INT);
-        $userAddr = (string)$request->content->getParam('ua');
-        $modId = (string)$request->content->getParam('mi', FILTER_SANITIZE_NUMBER_INT);
-        $modAddr = (string)$request->content->getParam('ma');
-        $duration = (int)$request->content->getParam('d', FILTER_SANITIZE_NUMBER_INT);
-        $isPermanent = (int)$request->content->getParam('p', FILTER_SANITIZE_NUMBER_INT);
-        $reason = (string)$request->content->getParam('r');
+        $userHash = (string)$request->getHeaderLine('X-SharpChat-Signature');
+        $userTime = (int)$content->getFilteredParam('t', FILTER_SANITIZE_NUMBER_INT);
+        $userId = (string)$content->getFilteredParam('ui', FILTER_SANITIZE_NUMBER_INT);
+        $userAddr = (string)$content->getParam('ua');
+        $modId = (string)$content->getFilteredParam('mi', FILTER_SANITIZE_NUMBER_INT);
+        $modAddr = (string)$content->getParam('ma');
+        $duration = (int)$content->getFilteredParam('d', FILTER_SANITIZE_NUMBER_INT);
+        $isPermanent = (int)$content->getFilteredParam('p', FILTER_SANITIZE_NUMBER_INT);
+        $reason = (string)$content->getParam('r');
 
         $signature = implode('#', [
             'create', $userTime, $userId, $userAddr,
@@ -447,13 +420,13 @@ final class SharpChatRoutes implements RouteHandler {
         return 201;
     }
 
-    #[HttpDelete('/_sockchat/bans/revoke')]
-    public function deleteBanRevoke(HttpResponseBuilder $response, HttpRequest $request): int {
+    #[ExactRoute('DELETE', '/_sockchat/bans/revoke')]
+    public function deleteBanRevoke(HttpRequest $request): int {
         if(!$request->hasHeader('X-SharpChat-Signature'))
             return 400;
 
-        $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
-        $userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
+        $userHash = (string)$request->getHeaderLine('X-SharpChat-Signature');
+        $userTime = (int)$request->getFilteredParam('x', FILTER_SANITIZE_NUMBER_INT);
         $type = (string)$request->getParam('t');
         $subject = (string)$request->getParam('s');
 
diff --git a/src/Tools.php b/src/Tools.php
index a57e7a5b..b3a641d4 100644
--- a/src/Tools.php
+++ b/src/Tools.php
@@ -16,7 +16,7 @@ final class Tools {
             return false;
 
         if(isset($parsed['scheme'])) {
-            $isSecure ??= filter_has_var(INPUT_SERVER, 'HTTPS');
+            $isSecure ??= !empty($_SERVER['HTTPS']);
 
             // only allow https when secure
             if($isSecure && $parsed['scheme'] !== 'https')
@@ -28,7 +28,7 @@ final class Tools {
         }
 
         if(isset($parsed['host'])) {
-            $host ??= filter_input(INPUT_SERVER, 'HTTP_HOST');
+            $host ??= $_SERVER['HTTP_HOST'];
 
             // ensure host is identical
             if($parsed['host'] !== $host)
diff --git a/src/Users/Assets/AssetsRoutes.php b/src/Users/Assets/AssetsRoutes.php
index a0693c3e..b2dc7ff3 100644
--- a/src/Users/Assets/AssetsRoutes.php
+++ b/src/Users/Assets/AssetsRoutes.php
@@ -5,6 +5,7 @@ use InvalidArgumentException;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Misuzu,Perm};
 use Misuzu\Auth\AuthInfo;
@@ -24,14 +25,14 @@ class AssetsRoutes implements RouteHandler, UrlSource {
             // allow staff viewing profile to still see banned user assets
             // should change the Referer check with some query param only applied when needed
             return $this->authInfo->getPerms('user')->check(Perm::U_USERS_MANAGE)
-                && parse_url($request->getHeaderFirstLine('Referer'), PHP_URL_PATH) === $this->urls->format('user-profile');
+                && parse_url($request->getHeaderLine('Referer'), PHP_URL_PATH) === $this->urls->format('user-profile');
 
         return true;
     }
 
     /** @return void */
-    #[HttpGet('/assets/avatar')]
-    #[HttpGet('/assets/avatar/([0-9]+)(?:\.[a-z]+)?')]
+    #[ExactRoute('GET', '/assets/avatar')]
+    #[PatternRoute('GET', '/assets/avatar/([0-9]+)(?:\.[a-z]+)?')]
     #[UrlFormat('user-avatar', '/assets/avatar/<user>', ['res' => '<res>'])]
     public function getAvatar(HttpResponseBuilder $response, HttpRequest $request, string $userId = '') {
         $assetInfo = new StaticUserImageAsset(Misuzu::PATH_PUBLIC . '/images/no-avatar.png', Misuzu::PATH_PUBLIC);
@@ -53,8 +54,8 @@ class AssetsRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return string|void */
-    #[HttpGet('/assets/profile-background')]
-    #[HttpGet('/assets/profile-background/([0-9]+)(?:\.[a-z]+)?')]
+    #[ExactRoute('GET', '/assets/profile-background')]
+    #[PatternRoute('GET', '/assets/profile-background/([0-9]+)(?:\.[a-z]+)?')]
     #[UrlFormat('user-background', '/assets/profile-background/<user>')]
     public function getProfileBackground(HttpResponseBuilder $response, HttpRequest $request, string $userId = '') {
         try {
@@ -78,9 +79,9 @@ class AssetsRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return string|void */
-    #[HttpGet('/user-assets.php')]
+    #[ExactRoute('GET', '/user-assets.php')]
     public function getUserAssets(HttpResponseBuilder $response, HttpRequest $request) {
-        $userId = (string)$request->getParam('u', FILTER_SANITIZE_NUMBER_INT);
+        $userId = (string)$request->getFilteredParam('u', FILTER_SANITIZE_NUMBER_INT);
         $mode = (string)$request->getParam('m');
 
         if($mode === 'avatar') {
@@ -102,8 +103,8 @@ class AssetsRoutes implements RouteHandler, UrlSource {
         $fileName = $assetInfo->getFileName();
 
         if($assetInfo instanceof UserAssetScalableInterface) {
-            $dimensions = (int)($request->getParam('res', FILTER_SANITIZE_NUMBER_INT)
-                ?? $request->getParam('r', FILTER_SANITIZE_NUMBER_INT));
+            $dimensions = (int)($request->getFilteredParam('res', FILTER_SANITIZE_NUMBER_INT)
+                ?? $request->getFilteredParam('r', FILTER_SANITIZE_NUMBER_INT));
 
             if($dimensions > 0) {
                 $assetInfo->ensureScaledExists($dimensions);
diff --git a/src/WebFinger/WebFingerRoutes.php b/src/WebFinger/WebFingerRoutes.php
index e1f62338..81de66a3 100644
--- a/src/WebFinger/WebFingerRoutes.php
+++ b/src/WebFinger/WebFingerRoutes.php
@@ -3,7 +3,9 @@ namespace Misuzu\WebFinger;
 
 use Index\XArray;
 use Index\Http\{HttpResponseBuilder,HttpRequest};
-use Index\Http\Routing\{HttpGet,HttpOptions,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\AccessControl\AccessControl;
+use Index\Http\Routing\Routes\ExactRoute;
 
 class WebFingerRoutes implements RouteHandler {
     use RouteHandlerCommon;
@@ -69,13 +71,9 @@ class WebFingerRoutes implements RouteHandler {
      *  }[]
      * }
      */
-    #[HttpOptions('/.well-known/webfinger')]
-    #[HttpGet('/.well-known/webfinger')]
+    #[AccessControl]
+    #[ExactRoute('GET', '/.well-known/webfinger')]
     public function getWebFinger(HttpResponseBuilder $response, HttpRequest $request): array|int {
-        $response->setHeader('Access-Control-Allow-Origin', '*');
-        if($request->method === 'OPTIONS')
-            return 204;
-
         $resInfos = $this->registry->resolve(trim((string)$request->getParam('resource')));
         if(empty($resInfos))
             return 404;
diff --git a/templates/auth/login.twig b/templates/auth/login.twig
index b749b4a5..3574a769 100644
--- a/templates/auth/login.twig
+++ b/templates/auth/login.twig
@@ -7,7 +7,7 @@
 {% block content %}
     <form class="container auth__container auth__login js-login-form" method="post" action="{{ url('auth-login') }}">
         {{ input_csrf() }}
-        {{ input_hidden('login[redirect]', login_redirect) }}
+        {{ input_hidden('redirect', login_redirect) }}
 
         <div class="container__title">
             <div class="container__title__background"></div>
@@ -45,7 +45,7 @@
                 Username
             </div>
             <div class="auth__label__value">
-                {{ input_text('login[username]', 'auth__label__input js-login-username', login_username, 'text', '', true, null, 1, not login_welcome) }}
+                {{ input_text('username', 'auth__label__input js-login-username', login_username, 'text', '', true, null, 1, not login_welcome) }}
             </div>
         </label>
 
@@ -57,7 +57,7 @@
                 {% endif %}
             </div>
             <div class="auth__label__value">
-                {{ input_text('login[password]', 'auth__label__input', '', 'password', '', true, null, 2, login_welcome) }}
+                {{ input_text('password', 'auth__label__input', '', 'password', '', true, null, 2, login_welcome) }}
             </div>
         </label>
 
diff --git a/templates/auth/password_forgot.twig b/templates/auth/password_forgot.twig
index 23b715c4..c280e05c 100644
--- a/templates/auth/password_forgot.twig
+++ b/templates/auth/password_forgot.twig
@@ -24,7 +24,7 @@
                 E-mail
             </div>
             <div class="auth__label__value">
-                {{ input_text('forgot[email]', 'auth__label__input', password_email, 'text', '', true, null, 1) }}
+                {{ input_text('email', 'auth__label__input', password_email, 'text', '', true, null, 1) }}
             </div>
         </label>
 
diff --git a/templates/auth/password_reset.twig b/templates/auth/password_reset.twig
index ef4b371d..4ce053c1 100644
--- a/templates/auth/password_reset.twig
+++ b/templates/auth/password_reset.twig
@@ -8,7 +8,7 @@
     <form class="container auth__container auth__password" method="post" action="{{ url('auth-reset') }}">
         {{ container_title('<i class="fas fa-user-lock fa-fw"></i> Resetting password for ' ~ password_user.name) }}
 
-        {{ input_hidden('reset[user]', password_user.id) }}
+        {{ input_hidden('user', password_user.id) }}
         {{ input_csrf() }}
 
         {% if password_notices|length > 0 %}
@@ -28,14 +28,14 @@
         {% endif %}
 
         {% if password_verification|length == 12 %}
-            {{ input_hidden('reset[verification]', password_verification) }}
+            {{ input_hidden('verification', password_verification) }}
         {% else %}
             <label class="auth__label">
                 <div class="auth__label__text">
                    Verification Code
                 </div>
                 <div class="auth__label__value">
-                    {{ input_text('reset[verification]', 'input__text--monospace auth__label__input', '', 'text', '', true, {'maxlength':12}, 1) }}
+                    {{ input_text('verification', 'input__text--monospace auth__label__input', '', 'text', '', true, {'maxlength':12}, 1) }}
                 </div>
             </label>
         {% endif %}
@@ -45,7 +45,7 @@
                New Password
             </div>
             <div class="auth__label__value">
-                {{ input_text('reset[password][new]', 'auth__label__input', '', 'password', '', true, null, 2) }}
+                {{ input_text('password_new', 'auth__label__input', '', 'password', '', true, null, 2) }}
             </div>
         </label>
 
@@ -54,7 +54,7 @@
                Confirm Password
             </div>
             <div class="auth__label__value">
-                {{ input_text('reset[password][confirm]', 'auth__label__input', '', 'password', '', true, null, 3) }}
+                {{ input_text('password_confirm', 'auth__label__input', '', 'password', '', true, null, 3) }}
             </div>
         </label>
 
diff --git a/templates/auth/register.twig b/templates/auth/register.twig
index b99717de..381d4f46 100644
--- a/templates/auth/register.twig
+++ b/templates/auth/register.twig
@@ -55,7 +55,7 @@
                             Username
                         </div>
                         <div class="auth__label__value">
-                            {{ input_text('register[username]', 'auth__label__input', register_username, 'text', '', true, null, 10, true) }}
+                            {{ input_text('username', 'auth__label__input', register_username, 'text', '', true, null, 10, true) }}
                         </div>
                     </label>
 
@@ -64,7 +64,7 @@
                             Password
                         </div>
                         <div class="auth__label__value">
-                            {{ input_text('register[password]', 'auth__label__input', '', 'password', '', true, null, 20) }}
+                            {{ input_text('password', 'auth__label__input', '', 'password', '', true, null, 20) }}
                         </div>
                     </label>
 
@@ -73,7 +73,7 @@
                             Confirm Password
                         </div>
                         <div class="auth__label__value">
-                            {{ input_text('register[password_confirm]', 'auth__label__input', '', 'password', '', true, null, 30) }}
+                            {{ input_text('password_confirm', 'auth__label__input', '', 'password', '', true, null, 30) }}
                         </div>
                     </label>
 
@@ -82,7 +82,7 @@
                             E-mail
                         </div>
                         <div class="auth__label__value">
-                            {{ input_text('register[email]', 'auth__label__input', register_email, 'text', '', true, null, 40) }}
+                            {{ input_text('email', 'auth__label__input', register_email, 'text', '', true, null, 40) }}
                         </div>
                     </label>
 
@@ -91,7 +91,7 @@
                             What is the outcome of nine plus ten?
                         </div>
                         <div class="auth__label__value">
-                            {{ input_text('register[question]', 'auth__label__input', '', 'text', '', true, null, 50) }}
+                            {{ input_text('question', 'auth__label__input', '', 'text', '', true, null, 50) }}
                         </div>
                     </label>
 
diff --git a/templates/auth/twofactor.twig b/templates/auth/twofactor.twig
index 83a8525b..e2a89d0e 100644
--- a/templates/auth/twofactor.twig
+++ b/templates/auth/twofactor.twig
@@ -9,8 +9,8 @@
         {{ container_title('<i class="fas fa-user-shield fa-fw"></i> Two Factor Authentication') }}
 
         {{ input_csrf() }}
-        {{ input_hidden('twofactor[redirect]', twofactor_redirect) }}
-        {{ input_hidden('twofactor[token]', twofactor_token) }}
+        {{ input_hidden('redirect', twofactor_redirect) }}
+        {{ input_hidden('token', twofactor_token) }}
 
         {% if twofactor_notices|length > 0 %}
             <div class="warning auth__warning">
@@ -27,7 +27,7 @@
                Code
             </div>
             <div class="auth__label__value">
-                {{ input_text('twofactor[code]', 'input__text--monospace input__text--centre auth__label__input', '', 'text', '', true, {'maxlength': 6, 'inputmode': 'numeric'}, 1) }}
+                {{ input_text('code', 'input__text--monospace input__text--centre auth__label__input', '', 'text', '', true, {'maxlength': 6, 'inputmode': 'numeric'}, 1) }}
             </div>
         </label>
 
diff --git a/templates/forum/posting.twig b/templates/forum/posting.twig
index 633e7f55..0b09491d 100644
--- a/templates/forum/posting.twig
+++ b/templates/forum/posting.twig
@@ -8,15 +8,15 @@
 {% set is_opening = not is_reply or posting_post.isOriginalPost|default(false) %}
 
 {% block content %}
-    <form method="post" action="{{ url('forum-' ~ (is_reply ? 'post' : 'topic') ~ '-create') }}" class="js-forum-posting">
-        {{ input_hidden('post[' ~ (is_reply ? 'topic' : 'forum') ~ ']', is_reply ? posting_topic.id : posting_forum.id) }}
-        {{ input_hidden('post[mode]', posting_mode) }}
+    <form method="post" action="{{ url('forum-' ~ (is_reply ? 'post' : 'topic') ~ '-create') }}" class="js-forum-posting" enctype="multipart/form-data">
+        {{ input_hidden((is_reply ? 'topic' : 'forum'), is_reply ? posting_topic.id : posting_forum.id) }}
+        {{ input_hidden('mode', posting_mode) }}
         {{ input_csrf() }}
         {{ forum_header(
             is_reply and not is_opening
                 ? posting_topic.title
                 : input_text(
-                    'post[title]',
+                    'title',
                     'forum__header__input',
                     posting_defaults.title|default(posting_topic.title|default('')),
                     'text',
@@ -30,7 +30,7 @@
         ) }}
 
         {% if posting_post is defined %}
-            {{ input_hidden('post[id]', posting_post.info.id) }}
+            {{ input_hidden('id', posting_post.info.id) }}
         {% endif %}
 
         {% if posting_notices|length > 0 %}
@@ -74,25 +74,25 @@
                         {% endif %}
                     </span>
                 </div>
-                <textarea name="post[text]" class="forum__post__text forum__post__text--edit js-forum-posting-text js-ctrl-enter-submit" placeholder="Type your post content here...">{{ posting_defaults.text|default(posting_post.info.body|default('')) }}</textarea>
+                <textarea name="text" class="forum__post__text forum__post__text--edit js-forum-posting-text js-ctrl-enter-submit" placeholder="Type your post content here...">{{ posting_defaults.text|default(posting_post.info.body|default('')) }}</textarea>
                 <div class="forum__post__text js-forum-posting-preview" hidden></div>
                 <div class="forum__post__actions js-forum-posting-actions"></div>
                 <div class="forum__post__options">
                     <div class="forum__post__settings">
                         {{ input_select(
-                            'post[parser]', parser_options(),
+                            'parser', parser_options(),
                             posting_defaults.parser|default(posting_post.info.bodyFormat|default(posting_user_preferred_parser)).value,
                             null, null, false, 'forum__post__dropdown js-forum-posting-parser'
                         ) }}
                         {% if is_opening and posting_types|length > 1 %}
-                            <select class="input__select forum__post__dropdown" name="post[type]">
+                            <select class="input__select forum__post__dropdown" name="type">
                             {% for type_name, type_title in posting_types %}
                                 <option value="{{ type_name }}"{% if type_name == posting_topic.typeString|default('discussion') %} selected{% endif %}>{{ type_title }}</option>
                             {% endfor %}
                             </select>
                         {% endif %}
                         {{ input_checkbox(
-                            'post[signature]',
+                            'signature',
                             'Display Signature',
                             posting_defaults.signature is not null
                                 ? posting_defaults.signature : (
diff --git a/templates/manage/users/ban.twig b/templates/manage/users/ban.twig
index 79e320da..33861a7a 100644
--- a/templates/manage/users/ban.twig
+++ b/templates/manage/users/ban.twig
@@ -6,7 +6,7 @@
     <div class="container">
         {{ container_title('<i class="fas fa-ban fa-fw"></i> Issuing a ban on user #' ~ ban_user.id ~ ' ' ~ ban_user.name) }}
 
-        <form method="post" enctype="multipart/form-data" action="{{ url('manage-users-ban', {'user': ban_user.id}) }}" class="manage__ban">
+        <form method="post" action="{{ url('manage-users-ban', {'user': ban_user.id}) }}" class="manage__ban">
             {{ input_csrf() }}
 
             <div class="manage__ban__field">
diff --git a/templates/manage/users/note.twig b/templates/manage/users/note.twig
index 89145f78..4ff0f492 100644
--- a/templates/manage/users/note.twig
+++ b/templates/manage/users/note.twig
@@ -6,7 +6,7 @@
     <div class="container">
         {{ container_title('<i class="fas fa-sticky-note fa-fw"></i> ' ~ (note_new ? ('Adding mod note to ' ~ note_user.name) : ('Editing mod note #' ~ note_info.id))) }}
 
-        <form method="post" enctype="multipart/form-data" action="{{ url('manage-users-note', note_new ? {'user': note_user.id} : {'note': note_info.id}) }}" class="manage__note {{ note_new ? 'manage__note--edit' : 'manage__note--view' }}">
+        <form method="post" action="{{ url('manage-users-note', note_new ? {'user': note_user.id} : {'note': note_info.id}) }}" class="manage__note {{ note_new ? 'manage__note--edit' : 'manage__note--view' }}">
             {{ input_csrf() }}
 
             <div class="manage__note__header">
diff --git a/templates/manage/users/warning.twig b/templates/manage/users/warning.twig
index 505a9ea2..3b718ecf 100644
--- a/templates/manage/users/warning.twig
+++ b/templates/manage/users/warning.twig
@@ -6,7 +6,7 @@
     <div class="container">
         {{ container_title('<i class="fas fa-exclamation-circle fa-fw"></i> Issuing a warning to user #' ~ warn_user.id ~ ' ' ~ warn_user.name) }}
 
-        <form method="post" enctype="multipart/form-data" action="{{ url('manage-users-warning', {'user': warn_user.id}) }}" class="manage__warning">
+        <form method="post" action="{{ url('manage-users-warning', {'user': warn_user.id}) }}" class="manage__warning">
             {{ input_csrf() }}
 
             <div class="manage__warning__field">
diff --git a/templates/profile/_layout/header.twig b/templates/profile/_layout/header.twig
index 73a60318..f6a47007 100644
--- a/templates/profile/_layout/header.twig
+++ b/templates/profile/_layout/header.twig
@@ -16,7 +16,7 @@
                         Select
                     </label>
 
-                    {{ input_checkbox_raw('avatar[delete]', false, 'profile__header__avatar__check', '', false, {'id':'avatar-delete'}) }}
+                    {{ input_checkbox_raw('avatar_delete', false, 'profile__header__avatar__check', null, false, {'id':'avatar-delete'}) }}
                     <label class="input__button profile__header__avatar__option profile__header__avatar__option--delete"
                         for="avatar-delete">
                         Remove
diff --git a/templates/profile/index.twig b/templates/profile/index.twig
index 800304ff..21dfef8e 100644
--- a/templates/profile/index.twig
+++ b/templates/profile/index.twig
@@ -15,7 +15,7 @@
             {{ input_csrf() }}
 
             {% if perms.edit_avatar %}
-                {{ input_file_raw('avatar[file]', 'profile__hidden', ['image/png', 'image/jpeg', 'image/gif'], {'id':'avatar-selection'}) }}
+                {{ input_file_raw('avatar_file', 'profile__hidden', ['image/png', 'image/jpeg', 'image/gif'], {'id':'avatar-selection'}) }}
 
                 <script>
                     function updateAvatarPreview(name, url, preview) {
@@ -120,7 +120,7 @@
                                                 </div>
 
                                                 {% if profile_is_editing %}
-                                                    {{ input_text('profile[' ~ fieldInfo.name ~ ']', 'profile__accounts__input', profile_fields_raw_values[fieldInfo.name]|default('')) }}
+                                                    {{ input_text('profile_' ~ fieldInfo.name, 'profile__accounts__input', profile_fields_raw_values[fieldInfo.name]|default('')) }}
                                                 {% else %}
                                                     <div class="profile__accounts__value">
                                                         {% if profile_fields_link_values[fieldInfo.name] is defined %}
@@ -212,14 +212,14 @@
                                             <div class="profile__birthdate__title">
                                                 Day
                                             </div>
-                                            {{ input_select('birthdate[day]', ['-']|merge(range(1, 31)), birthdate_info.day|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--day') }}
+                                            {{ input_select('birth_day', ['-']|merge(range(1, 31)), birthdate_info.day|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--day') }}
                                         </label>
 
                                         <label class="profile__birthdate__label">
                                             <div class="profile__birthdate__title">
                                                 Month
                                             </div>
-                                            {{ input_select('birthdate[month]', ['-']|merge(range(1, 12)), birthdate_info.month|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--month') }}
+                                            {{ input_select('birth_month', ['-']|merge(range(1, 12)), birthdate_info.month|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--month') }}
                                         </label>
                                     </div>
 
@@ -228,7 +228,7 @@
                                             <div class="profile__birthdate__title">
                                                 Year (may be left empty)
                                             </div>
-                                            {{ input_select('birthdate[year]', ['-']|merge(range(null|date('Y'), null|date('Y') - 100)), birthdate_info.year|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--year') }}
+                                            {{ input_select('birth_year', ['-']|merge(range(null|date('Y'), null|date('Y') - 100)), birthdate_info.year|default(0), '', '', true, 'profile__birthdate__select profile__birthdate__select--year') }}
                                         </label>
                                     </div>
                                 </div>
diff --git a/tools/render-tpl b/tools/render-tpl
index 5b2f1bc3..9207b77a 100755
--- a/tools/render-tpl
+++ b/tools/render-tpl
@@ -2,7 +2,8 @@
 <?php
 namespace Misuzu;
 
-use Index\Http\{HttpHeader,HttpHeaders,HttpRequest};
+use Index\Http\{HttpRequest,HttpUri};
+use Index\Http\Streams\NullStream;
 
 require_once __DIR__ . '/../misuzu.php';
 
@@ -76,10 +77,7 @@ handleValue:
 $hostName ??= 'localhost';
 
 // this should really not be necessary, mostly done to make sure the url registry is available
-$msz->createRouting(new HttpRequest('::1', true, 'XX', '1.1', 'GET', '/', [], [], new HttpHeaders([
-    new HttpHeader('Host', $hostName),
-]), null));
-
+$msz->createRouting(new HttpRequest('1.1', ['Host' => [$hostName]], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []));
 $msz->startTemplating(false);
 
 $ctx = $msz->templating->load(implode(' ', array_slice($argv, $pathIndex)));