diff --git a/public-legacy/forum/index.php b/public-legacy/forum/index.php index 45cd0b2..a95896b 100644 --- a/public-legacy/forum/index.php +++ b/public-legacy/forum/index.php @@ -9,7 +9,26 @@ $currentUserId = $currentUser === null ? '0' : $currentUser->getId(); switch($indexMode) { case 'mark': - url_redirect($forumId < 1 ? 'forum-mark-global' : 'forum-mark-single', ['forum' => $forumId]); + if(!$msz->isLoggedIn()) { + echo render_error(403); + break; + } + + if($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { + forum_mark_read($forumId, (int)$msz->getAuthInfo()->getUserId()); + $redirect = url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]); + redirect($redirect); + break; + } + + Template::render('confirm', [ + 'title' => 'Mark forum as read', + 'message' => 'Are you sure you want to mark ' . ($forumId === 0 ? 'the entire' : 'this') . ' forum as read?', + 'return' => url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]), + 'params' => [ + 'forum' => $forumId, + ] + ]); break; default: diff --git a/public/index.php b/public/index.php index 2eed00b..aeb8c90 100644 --- a/public/index.php +++ b/public/index.php @@ -208,5 +208,5 @@ if(!empty($mszLegacyPath) && str_starts_with($mszLegacyPath, $mszLegacyPathPrefi } } -$msz->setUpHttp(str_contains($mszRequestPath, '.php')); +$msz->setUpHttp(); $msz->dispatchHttp($request); diff --git a/src/Http/Handlers/ChangelogHandler.php b/src/Changelog/ChangelogRoutes.php similarity index 57% rename from src/Http/Handlers/ChangelogHandler.php rename to src/Changelog/ChangelogRoutes.php index c920464..db9c153 100644 --- a/src/Http/Handlers/ChangelogHandler.php +++ b/src/Changelog/ChangelogRoutes.php @@ -1,26 +1,73 @@ getParam('date'); - $filterUser = (int)$request->getParam('user', FILTER_SANITIZE_NUMBER_INT); - $filterTags = (string)$request->getParam('tags'); + public function __construct( + IRouter $router, + IConfig $config, + Changelog $changelog, + Users $users, + AuthInfo $authInfo, + Comments $comments + ) { + $this->config = $config; + $this->changelog = $changelog; + $this->users = $users; + $this->authInfo = $authInfo; + $this->comments = $comments; - $users = $this->context->getUsers(); + $router->get('/changelog', [$this, 'getIndex']); + $router->get('/changelog.rss', [$this, 'getFeedRSS']); + $router->get('/changelog.atom', [$this, 'getFeedAtom']); + $router->get('/changelog/change/:id', [$this, 'getChange']); + + $router->get('/changelog.php', function($response, $request) { + $changeId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); + if($changeId) { + $response->redirect(url('changelog-change', ['change' => $changeId]), true); + return; + } + + $response->redirect(url('changelog-index', [ + 'date' => $request->getParam('d'), + 'user' => $request->getParam('u', FILTER_SANITIZE_NUMBER_INT), + ]), true); + }); + } + + private function getCommentsInfo(string $categoryName): object { + $comments = new CommentsEx($this->authInfo, $this->comments, $this->users, $this->userInfos, $this->userColours); + return $comments->getCommentsForLayout($categoryName); + } + + public function getIndex($response, $request) { + $filterDate = (string)$request->getParam('date'); + $filterUser = (string)$request->getParam('user', FILTER_SANITIZE_NUMBER_INT); + $filterTags = (string)$request->getParam('tags'); if(empty($filterDate)) $filterDate = null; @@ -32,14 +79,14 @@ class ChangelogHandler extends Handler { return 404; } - if($filterUser > 0) + if(empty($filterUser)) + $filterUser = null; + else try { - $filterUser = $users->getUser((string)$filterUser, 'id'); + $filterUser = $this->users->getUser($filterUser, 'id'); } catch(RuntimeException $ex) { return 404; } - else - $filterUser = null; if(empty($filterTags)) $filterTags = null; @@ -49,13 +96,12 @@ class ChangelogHandler extends Handler { $tag = trim($tag); } - $changelog = $this->context->getChangelog(); - $count = $changelog->countAllChanges($filterUser, $filterDate, $filterTags); + $count = $this->changelog->countAllChanges($filterUser, $filterDate, $filterTags); $pagination = new Pagination($count, 30); if(!$pagination->hasValidOffset()) return 404; - $changeInfos = $changelog->getAllChanges(userInfo: $filterUser, dateTime: $filterDate, tags: $filterTags, pagination: $pagination); + $changeInfos = $this->changelog->getAllChanges(userInfo: $filterUser, dateTime: $filterDate, tags: $filterTags, pagination: $pagination); if(empty($changeInfos)) return 404; @@ -69,8 +115,8 @@ class ChangelogHandler extends Handler { $userColour = $this->userColours[$userId]; } else { try { - $userInfo = $users->getUser($userId, 'id'); - $userColour = $users->getUserColour($userInfo); + $userInfo = $this->users->getUser($userId, 'id'); + $userColour = $this->users->getUserColour($userInfo); } catch(RuntimeException $ex) { $userInfo = null; $userColour = null; @@ -87,49 +133,42 @@ class ChangelogHandler extends Handler { ]; } - $response->setContent(Template::renderRaw('changelog.index', [ + return Template::renderRaw('changelog.index', [ 'changelog_infos' => $changes, 'changelog_date' => $filterDate, 'changelog_user' => $filterUser, 'changelog_tags' => $filterTags, 'changelog_pagination' => $pagination, 'comments_info' => empty($filterDate) ? null : $this->getCommentsInfo($changeInfos[0]->getCommentsCategoryName()), - ])); + ]); } - private function getCommentsInfo(string $categoryName): object { - $comments = new CommentsEx($this->context, $this->context->getComments(), $this->context->getUsers(), $this->userInfos, $this->userColours); - return $comments->getCommentsForLayout($categoryName); - } - - public function change($response, $request, string $changeId) { + public function getChange($response, $request, string $changeId) { try { - $changeInfo = $this->context->getChangelog()->getChangeById($changeId, withTags: true); + $changeInfo = $this->changelog->getChangeById($changeId, withTags: true); } catch(RuntimeException $ex) { return 404; } - $users = $this->context->getUsers(); - try { - $userInfo = $users->getUser($changeInfo->getUserId(), 'id'); - $userColour = $users->getUserColour($userInfo); + $userInfo = $this->users->getUser($changeInfo->getUserId(), 'id'); + $userColour = $this->users->getUserColour($userInfo); } catch(RuntimeException $ex) { $userInfo = null; $userColour = null; } - $response->setContent(Template::renderRaw('changelog.change', [ + return Template::renderRaw('changelog.change', [ 'change_info' => $changeInfo, 'change_user_info' => $userInfo, 'change_user_colour' => $userColour, 'comments_info' => $this->getCommentsInfo($changeInfo->getCommentsCategoryName()), - ])); + ]); } private function createFeed(string $feedMode): Feed { - $siteName = $this->context->getConfig()->getString('site.name', 'Misuzu'); - $changes = $this->context->getChangelog()->getAllChanges(pagination: new Pagination(10)); + $siteName = $this->config->getString('site.name', 'Misuzu'); + $changes = $this->changelog->getAllChanges(pagination: new Pagination(10)); $feed = (new Feed) ->setTitle($siteName . ' » Changelog') @@ -154,13 +193,13 @@ class ChangelogHandler extends Handler { return $feed; } - public function feedAtom($response, $request) { - $response->setContentType('application/atom+xml; charset=utf-8'); - return (new AtomFeedSerializer)->serializeFeed(self::createFeed('atom')); + public function getFeedRSS($response) { + $response->setContentType('application/rss+xml; charset=utf-8'); + return (new RssFeedSerializer)->serializeFeed($this->createFeed('rss')); } - public function feedRss($response, $request) { - $response->setContentType('application/rss+xml; charset=utf-8'); - return (new RssFeedSerializer)->serializeFeed(self::createFeed('rss')); + public function getFeedAtom($response) { + $response->setContentType('application/atom+xml; charset=utf-8'); + return (new AtomFeedSerializer)->serializeFeed($this->createFeed('atom')); } } diff --git a/src/Comments/CommentsEx.php b/src/Comments/CommentsEx.php index e8716fd..0b5fbe3 100644 --- a/src/Comments/CommentsEx.php +++ b/src/Comments/CommentsEx.php @@ -4,11 +4,12 @@ namespace Misuzu\Comments; use stdClass; use RuntimeException; use Misuzu\MisuzuContext; +use Misuzu\Auth\AuthInfo; use Misuzu\Users\Users; class CommentsEx { public function __construct( - private MisuzuContext $context, + private AuthInfo $authInfo, private Comments $comments, private Users $users, private array $userInfos = [], @@ -20,8 +21,8 @@ class CommentsEx { if(is_string($category)) $category = $this->comments->ensureCategory($category); - $hasUser = $this->context->isLoggedIn(); - $info->user = $hasUser ? $this->context->getActiveUser() : null; + $hasUser = $this->authInfo->isLoggedIn(); + $info->user = $hasUser ? $this->authInfo->getUserInfo() : null; $info->colour = $hasUser ? $this->users->getUserColour($info->user) : null; $info->perms = $hasUser ? perms_for_comments($info->user->getId()) : []; $info->category = $category; diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php new file mode 100644 index 0000000..2efea8d --- /dev/null +++ b/src/Home/HomeRoutes.php @@ -0,0 +1,283 @@ +config = $config; + $this->dbConn = $dbConn; + $this->authInfo = $authInfo; + $this->changelog = $changelog; + $this->comments = $comments; + $this->counters = $counters; + $this->news = $news; + $this->users = $users; + + $router->get('/', [$this, 'getIndex']); + + if(MSZ_DEBUG) + $router->get('/dev-landing', [$this, 'getLanding']); + + $router->get('/index.php', function($response) { + $response->redirect(url('index'), true); + }); + } + + private function getStats(): array { + return $this->counters->get([ + 'users:active', + 'users:online:recent', + 'users:online:today', + 'comments:posts:visible', + 'forum:topics:visible', + 'forum:posts:visible', + ]); + } + + private function getOnlineUsers(): array { + return $this->users->getUsers( + lastActiveInMinutes: 5, + deleted: false, + orderBy: 'random', + ); + } + + private array $userInfos = []; + private array $userColours = []; + private array $newsCategoryInfos = []; + + private function getFeaturedNewsPosts(int $amount, bool $decorate): array { + $postInfos = $this->news->getAllPosts( + onlyFeatured: true, + pagination: new Pagination($amount) + ); + + if(!$decorate) + return $postInfos; + + $posts = []; + + foreach($postInfos as $postInfo) { + $userId = $postInfo->getUserId(); + $categoryId = $postInfo->getCategoryId(); + + if(array_key_exists($userId, $this->userInfos)) { + $userInfo = $this->userInfos[$userId]; + $userColour = $this->userColours[$userId]; + } else { + try { + $userInfo = $this->users->getUser($userId, 'id'); + $userColour = $this->users->getUserColour($userInfo); + } catch(RuntimeException $ex) { + $userInfo = null; + $userColour = null; + } + + $this->userInfos[$userId] = $userInfo; + $this->userColours[$userId] = $userColour; + } + + if(array_key_exists($categoryId, $this->newsCategoryInfos)) + $categoryInfo = $this->newsCategoryInfos[$categoryId]; + else + $this->newsCategoryInfos[$categoryId] = $categoryInfo = $this->news->getCategoryByPost($postInfo); + + $commentsCount = $postInfo->hasCommentsCategoryId() + ? $this->comments->countPosts($postInfo->getCommentsCategoryId(), includeReplies: true) : 0; + + $posts[] = [ + 'post' => $postInfo, + 'category' => $categoryInfo, + 'user' => $userInfo, + 'user_colour' => $userColour, + 'comments_count' => $commentsCount, + ]; + } + + return $posts; + } + + public function getPopularForumTopics(array $categoryIds): array { + $args = 0; + $stmt = $this->dbConn->prepare( + 'SELECT t.topic_id, c.forum_id, t.topic_title, c.forum_icon, t.topic_count_views' + . ', (SELECT COUNT(*) FROM msz_forum_posts AS p WHERE p.topic_id = t.topic_id AND post_deleted IS NULL)' + . ' FROM msz_forum_topics AS t' + . ' LEFT JOIN msz_forum_categories AS c ON c.forum_id = t.forum_id' + . ' WHERE c.forum_id IN (' . DbTools::prepareListString($categoryIds) . ') AND topic_deleted IS NULL AND topic_locked IS NULL' + . ' ORDER BY (SELECT COUNT(*) FROM msz_forum_posts AS p WHERE p.topic_id = t.topic_id AND post_deleted IS NULL AND post_created > NOW() - INTERVAL 3 MONTH) DESC' + . ' LIMIT 10' + ); + + foreach($categoryIds as $categoryId) + $stmt->addParameter(++$args, (string)$categoryId); + $stmt->execute(); + + $topics = []; + $result = $stmt->getResult(); + + while($result->next()) + $topics[] = [ + 'topic_id' => $result->getInteger(0), + 'forum_id' => $result->getInteger(1), + 'topic_title' => $result->getString(2), + 'forum_icon' => $result->getString(3), + 'topic_count_views' => $result->getInteger(4), + 'topic_count_posts' => $result->getInteger(5), + ]; + + return $topics; + } + + public function getActiveForumTopics(array $categoryIds): array { + $args = 0; + $stmt = $this->dbConn->prepare( + 'SELECT t.topic_id, c.forum_id, t.topic_title, c.forum_icon, t.topic_count_views' + . ', (SELECT COUNT(*) FROM msz_forum_posts AS p WHERE p.topic_id = t.topic_id AND post_deleted IS NULL)' + . ', (SELECT MAX(post_id) FROM msz_forum_posts AS p WHERE p.topic_id = t.topic_id AND post_deleted IS NULL)' + . ' FROM msz_forum_topics AS t' + . ' LEFT JOIN msz_forum_categories AS c ON c.forum_id = t.forum_id' + . ' WHERE c.forum_id IN (' . DbTools::prepareListString($categoryIds) . ') AND topic_deleted IS NULL AND topic_locked IS NULL' + . ' ORDER BY topic_bumped DESC' + . ' LIMIT 10' + ); + + foreach($categoryIds as $categoryId) + $stmt->addParameter(++$args, (string)$categoryId); + $stmt->execute(); + + $topics = []; + $result = $stmt->getResult(); + + while($result->next()) + $topics[] = [ + 'topic_id' => $result->getInteger(0), + 'forum_id' => $result->getInteger(1), + 'topic_title' => $result->getString(2), + 'forum_icon' => $result->getString(3), + 'topic_count_views' => $result->getInteger(4), + 'topic_count_posts' => $result->getInteger(5), + 'latest_post_id' => $result->getInteger(6), + ]; + + return $topics; + } + + public function getIndex(...$args) { + return $this->authInfo->isLoggedIn() + ? $this->getHome(...$args) + : $this->getLanding(...$args); + } + + public function getHome() { + $stats = $this->getStats(); + $onlineUserInfos = $this->getOnlineUsers(); + $featuredNews = $this->getFeaturedNewsPosts(5, true); + $changelog = $this->changelog->getAllChanges(pagination: new Pagination(10)); + + $stats['users:online:recent'] = count($onlineUserInfos); + + $birthdays = []; + $birthdayInfos = $this->users->getUsers(deleted: false, birthdate: DateTime::now(), orderBy: 'random'); + foreach($birthdayInfos as $birthdayInfo) + $birthdays[] = [ + 'info' => $birthdayInfo, + 'colour' => $this->users->getUserColour($birthdayInfo), + ]; + + $newestMember = []; + if(empty($birthdays)) { + $newestMemberId = $this->config->getString('users.newest'); + if(!empty($newestMemberId)) + try { + $newestMemberInfo = $this->users->getUser($newestMemberId, 'id'); + $newestMemberColour = $this->users->getUserColour($newestMemberInfo); + $newestMember['info'] = $newestMemberInfo; + $newestMember['colour'] = $newestMemberColour; + } catch(RuntimeException $ex) { + $newestMember = []; + $config->removeValues('users.newest'); + } + } + + return Template::renderRaw('home.home', [ + 'statistics' => $stats, + 'newest_member' => $newestMember, + 'online_users' => $onlineUserInfos, + 'birthdays' => $birthdays, + 'featured_changelog' => $changelog, + 'featured_news' => $featuredNews, + ]); + } + + public function getLanding() { + $config = $this->config->getValues([ + ['social.embed_linked:b'], + ['landing.forum_categories:a'], + ['site.name:s', 'Misuzu'], + 'site.url:s', + 'site.ext_logo:s', + 'social.linked:a' + ]); + + if($config['social.embed_linked']) { + $linkedData = [ + '@context' => 'http://schema.org', + '@type' => 'Organization', + 'name' => $config['site.name'], + 'url' => $config['site.url'], + 'logo' => $config['site.ext_logo'], + 'same_as' => $config['social.linked'], + ]; + } else $linkedData = null; + + $stats = $this->getStats(); + $onlineUserInfos = $this->getOnlineUsers(); + $featuredNews = $this->getFeaturedNewsPosts(3, false); + $popularTopics = $this->getPopularForumTopics($config['landing.forum_categories']); + $activeTopics = $this->getActiveForumTopics($config['landing.forum_categories']); + + $stats['users:online:recent'] = count($onlineUserInfos); + + return Template::renderRaw('home.landing', [ + 'statistics' => $stats, + 'online_users' => $onlineUserInfos, + 'featured_news' => $featuredNews, + 'linked_data' => $linkedData, + 'forum_popular' => $popularTopics, + 'forum_active' => $activeTopics, + ]); + } +} diff --git a/src/Http/Handlers/AssetsHandler.php b/src/Http/Handlers/AssetsHandler.php deleted file mode 100644 index 52d8e12..0000000 --- a/src/Http/Handlers/AssetsHandler.php +++ /dev/null @@ -1,107 +0,0 @@ -context->hasActiveBan($assetUser) || ( - $this->context->isLoggedIn() - && parse_url($request->getHeaderFirstLine('Referer'), PHP_URL_PATH) === url('user-profile') - && perms_check_user(MSZ_PERMS_USER, $this->context->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_USERS) - ); - } - - private function serveUserAsset($response, $request, UserImageAssetInterface $assetInfo): void { - $contentType = $assetInfo->getMimeType(); - $publicPath = $assetInfo->getPublicPath(); - $fileName = $assetInfo->getFileName(); - - if($assetInfo instanceof UserAssetScalableInterface) { - $dimensions = (int)($request->getParam('res', FILTER_SANITIZE_NUMBER_INT) ?? $request->getParam('r', FILTER_SANITIZE_NUMBER_INT)); - - if($dimensions > 0) { - $assetInfo->ensureScaledExists($dimensions); - $contentType = $assetInfo->getScaledMimeType($dimensions); - $publicPath = $assetInfo->getPublicScaledPath($dimensions); - $fileName = $assetInfo->getScaledFileName($dimensions); - } - } - - $response->accelRedirect($publicPath); - $response->setContentType($contentType); - $response->setFileName($fileName, false); - } - - public function serveAvatar($response, $request, string $fileName) { - $userId = pathinfo($fileName, PATHINFO_FILENAME); - $type = pathinfo($fileName, PATHINFO_EXTENSION); - - if($type !== '' && $type !== 'png') - return 404; - - $assetInfo = new StaticUserImageAsset(MSZ_PUBLIC . '/images/no-avatar.png', MSZ_PUBLIC); - - try { - $userInfo = $this->context->getUsers()->getUser($userId, 'id'); - - if(!$this->canViewAsset($request, $userInfo)) { - $assetInfo = new StaticUserImageAsset(MSZ_PUBLIC . '/images/banned-avatar.png', MSZ_PUBLIC); - } else { - $userAssetInfo = new UserAvatarAsset($userInfo); - if($userAssetInfo->isPresent()) - $assetInfo = $userAssetInfo; - } - } catch(RuntimeException $ex) {} - - $this->serveUserAsset($response, $request, $assetInfo); - } - - public function serveProfileBackground($response, $request, string $fileName) { - $userId = pathinfo($fileName, PATHINFO_FILENAME); - $type = pathinfo($fileName, PATHINFO_EXTENSION); - - if($type !== '' && $type !== 'png') - return 404; - - try { - $userInfo = $this->context->getUsers()->getUser($userId, 'id'); - } catch(RuntimeException $ex) {} - - if(!empty($userInfo)) { - $userAssetInfo = new UserBackgroundAsset($userInfo); - if($userAssetInfo->isPresent() && $this->canViewAsset($request, $userInfo)) - $assetInfo = $userAssetInfo; - } - - if(!isset($assetInfo)) { - $response->setContent(''); - return 404; - } - - $this->serveUserAsset($response, $request, $assetInfo); - } - - public function serveLegacy($response, $request) { - $assetUserId = $request->getParam('u', FILTER_SANITIZE_NUMBER_INT); - - switch($request->getParam('m')) { - case 'avatar': - $this->serveAvatar($response, $request, $assetUserId); - return; - case 'background': - $this->serveProfileBackground($response, $request, $assetUserId); - return; - } - - $response->setContent(''); - return 404; - } -} diff --git a/src/Http/Handlers/ForumHandler.php b/src/Http/Handlers/ForumHandler.php deleted file mode 100644 index 65201f5..0000000 --- a/src/Http/Handlers/ForumHandler.php +++ /dev/null @@ -1,40 +0,0 @@ -context->isLoggedIn()) - return 403; - - $forumId = (int)$request->getParam('forum', FILTER_SANITIZE_NUMBER_INT); - $response->setContent(Template::renderRaw('confirm', [ - 'title' => 'Mark forum as read', - 'message' => 'Are you sure you want to mark ' . ($forumId === 0 ? 'the entire' : 'this') . ' forum as read?', - 'return' => url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]), - 'params' => [ - 'forum' => $forumId, - ] - ])); - } - - public function markAsReadPOST($response, $request) { - if(!$this->context->isLoggedIn()) - return 403; - - if(!$request->isFormContent()) - return 400; - - $token = $request->getContent()->getParam('_csrf'); - if(empty($token) || !CSRF::validate($token)) - return 400; - - $forumId = (int)$request->getContent()->getParam('forum', FILTER_SANITIZE_NUMBER_INT); - forum_mark_read($forumId, (int)$this->context->getActiveUser()->getId()); - $redirect = url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]); - - $response->redirect($redirect, false); - } -} diff --git a/src/Http/Handlers/Handler.php b/src/Http/Handlers/Handler.php deleted file mode 100644 index d54d6fe..0000000 --- a/src/Http/Handlers/Handler.php +++ /dev/null @@ -1,12 +0,0 @@ -context = $context; - } -} diff --git a/src/Http/Handlers/HomeHandler.php b/src/Http/Handlers/HomeHandler.php deleted file mode 100644 index 0ebe912..0000000 --- a/src/Http/Handlers/HomeHandler.php +++ /dev/null @@ -1,208 +0,0 @@ -context->isLoggedIn()) - $this->home($response, $request); - else - $this->landing($response, $request); - } - - public function landing($response, $request): void { - $users = $this->context->getUsers(); - $config = $this->context->getConfig(); - $counters = $this->context->getCounters(); - - if($config->getBoolean('social.embed_linked')) { - $ldr = $config->getValues([ - ['site.name:s', 'Misuzu'], - 'site.url:s', - 'site.ext_logo:s', - 'social.linked:a' - ]); - $linkedData = [ - 'name' => $ldr['site.name'], - 'url' => $ldr['site.url'], - 'logo' => $ldr['site.ext_logo'], - 'same_as' => $ldr['social.linked'], - ]; - } else $linkedData = null; - - $featuredNews = $this->context->getNews()->getAllPosts( - onlyFeatured: true, - pagination: new Pagination(3) - ); - - $stats = $counters->get(self::STATS); - $onlineUserInfos = $users->getUsers( - lastActiveInMinutes: 5, - deleted: false, - orderBy: 'random', - ); - - // can also cheat here, whoa - $stats['users:online:recent'] = count($onlineUserInfos); - - // TODO: don't hardcode forum ids - $featuredForums = $config->getArray('landing.forum_categories'); - - $popularTopics = []; - $activeTopics = []; - - if(!empty($featuredForums)) { - $getPopularTopics = DB::prepare( - 'SELECT t.`topic_id`, c.`forum_id`, t.`topic_title`, c.`forum_icon`, t.`topic_count_views`' - . ', (SELECT COUNT(*) FROM `msz_forum_posts` AS p WHERE p.`topic_id` = t.`topic_id` AND `post_deleted` IS NULL) AS `topic_count_posts`' - . ' FROM `msz_forum_topics` AS t' - . ' LEFT JOIN `msz_forum_categories` AS c ON c.`forum_id` = t.`forum_id`' - . ' WHERE c.`forum_id` IN (' . implode(',', $featuredForums) . ') AND `topic_deleted` IS NULL AND `topic_locked` IS NULL' - . ' ORDER BY (SELECT COUNT(*) FROM `msz_forum_posts` AS p WHERE p.`topic_id` = t.`topic_id` AND `post_deleted` IS NULL AND `post_created` > NOW() - INTERVAL 3 MONTH) DESC' - )->stmt; - $getPopularTopics->execute(); - for($i = 0; $i < 10; ++$i) { - $topicInfo = $getPopularTopics->fetchObject(); - if(empty($topicInfo)) - break; - $popularTopics[] = $topicInfo; - } - - $getActiveTopics = DB::prepare( - 'SELECT t.`topic_id`, c.`forum_id`, t.`topic_title`, c.`forum_icon`, t.`topic_count_views`' - . ', (SELECT COUNT(*) FROM `msz_forum_posts` AS p WHERE p.`topic_id` = t.`topic_id` AND `post_deleted` IS NULL) AS `topic_count_posts`' - . ', (SELECT MAX(`post_id`) FROM `msz_forum_posts` AS p WHERE p.`topic_id` = t.`topic_id` AND `post_deleted` IS NULL) AS `latest_post_id`' - . ' FROM `msz_forum_topics` AS t' - . ' LEFT JOIN `msz_forum_categories` AS c ON c.`forum_id` = t.`forum_id`' - . ' WHERE c.`forum_id` IN (' . implode(',', $featuredForums) . ') AND `topic_deleted` IS NULL AND `topic_locked` IS NULL' - . ' ORDER BY `topic_bumped` DESC' - )->stmt; - $getActiveTopics->execute(); - for($i = 0; $i < 10; ++$i) { - $topicInfo = $getActiveTopics->fetchObject(); - if(empty($topicInfo)) - break; - $activeTopics[] = $topicInfo; - } - } - - $response->setContent(Template::renderRaw('home.landing', [ - 'statistics' => $stats, - 'online_users' => $onlineUserInfos, - 'featured_news' => $featuredNews, - 'linked_data' => $linkedData, - 'forum_popular' => $popularTopics, - 'forum_active' => $activeTopics, - ])); - } - - public function home($response, $request): void { - $news = $this->context->getNews(); - $users = $this->context->getUsers(); - $config = $this->context->getConfig(); - $comments = $this->context->getComments(); - $counters = $this->context->getCounters(); - $featuredNews = []; - $userInfos = []; - $userColours = []; - $categoryInfos = []; - $featuredNewsInfos = $news->getAllPosts( - onlyFeatured: true, - pagination: new Pagination(5) - ); - - foreach($featuredNewsInfos as $postInfo) { - $userId = $postInfo->getUserId(); - $categoryId = $postInfo->getCategoryId(); - - if(array_key_exists($userId, $userInfos)) { - $userInfo = $userInfos[$userId]; - $userColour = $userColours[$userId]; - } else { - try { - $userInfo = $users->getUser($userId, 'id'); - $userColour = $userColours[$userId] = $users->getUserColour($userInfo); - } catch(RuntimeException $ex) { - $userInfo = null; - $userColour = $userColours[$userId] = null; - } - - $userInfos[$userId] = $userInfo; - } - - if(array_key_exists($categoryId, $categoryInfos)) - $categoryInfo = $categoryInfos[$categoryId]; - else - $categoryInfos[$categoryId] = $categoryInfo = $news->getCategoryByPost($postInfo); - - $commentsCount = $postInfo->hasCommentsCategoryId() - ? $comments->countPosts($postInfo->getCommentsCategoryId(), includeReplies: true) : 0; - - $featuredNews[] = [ - 'post' => $postInfo, - 'category' => $categoryInfo, - 'user' => $userInfo, - 'user_colour' => $userColour, - 'comments_count' => $commentsCount, - ]; - } - - $stats = $counters->get(self::STATS); - $changelog = $this->context->getChangelog()->getAllChanges(pagination: new Pagination(10)); - - $birthdays = []; - $birthdayInfos = $users->getUsers(deleted: false, birthdate: DateTime::now(), orderBy: 'random'); - foreach($birthdayInfos as $birthdayInfo) - $birthdays[] = [ - 'info' => $birthdayInfo, - 'colour' => $users->getUserColour($birthdayInfo), - ]; - - $newestMember = []; - if(empty($birthdays)) { - $newestMemberId = $config->getString('users.newest'); - if(!empty($newestMemberId)) - try { - $newestMemberInfo = $users->getUser($newestMemberId, 'id'); - $newestMember['info'] = $newestMemberInfo; - $newestMember['colour'] = $users->getUserColour($newestMemberInfo); - } catch(RuntimeException $ex) { - $newestMember = []; - $config->removeValues('users.newest'); - } - } - - $onlineUserInfos = $users->getUsers( - lastActiveInMinutes: 5, - deleted: false, - orderBy: 'random', - ); - - // today we cheat - $stats['users:online:recent'] = count($onlineUserInfos); - - $response->setContent(Template::renderRaw('home.home', [ - 'statistics' => $stats, - 'newest_member' => $newestMember, - 'online_users' => $onlineUserInfos, - 'birthdays' => $birthdays, - 'featured_changelog' => $changelog, - 'featured_news' => $featuredNews, - ])); - } -} diff --git a/src/Http/Handlers/InfoHandler.php b/src/Http/Handlers/InfoHandler.php deleted file mode 100644 index cc2f5e9..0000000 --- a/src/Http/Handlers/InfoHandler.php +++ /dev/null @@ -1,71 +0,0 @@ -setContent(Template::renderRaw('info.index')); - } - - public function page($response, $request, string ...$parts) { - $name = implode('/', $parts); - $document = [ - 'content' => '', - 'title' => '', - ]; - - $isIndexDoc = $name === 'index' || str_starts_with($name, 'index/'); - $isMisuzuDoc = $name === 'misuzu' || str_starts_with($name, 'misuzu/'); - - if($isMisuzuDoc) { - $fileName = substr($name, 7); - $fileName = empty($fileName) ? 'README' : strtoupper($fileName); - if($fileName !== 'README') - $titleSuffix = ' - Misuzu Project'; - } elseif($isIndexDoc) { - $fileName = substr($name, 6); - $fileName = empty($fileName) ? 'README' : strtoupper($fileName); - if($fileName !== 'README') - $titleSuffix = ' - Index Project'; - } else $fileName = strtolower($name); - - if(!preg_match('#^([A-Za-z0-9_]+)$#', $fileName)) - return 404; - - if($fileName !== 'LICENSE' && $fileName !== 'LICENCE') - $fileName .= '.md'; - - $pfx = ''; - - if($isIndexDoc) - $pfx = '/vendor/flashwave/index'; - elseif(!$isMisuzuDoc) - $pfx = '/docs'; - - $fileName = MSZ_ROOT . $pfx . '/' . $fileName; - $document['content'] = is_file($fileName) ? file_get_contents($fileName) : ''; - - if(empty($document['content'])) - return 404; - - if($document['title'] === '') { - if(str_starts_with($document['content'], '# ')) { - $titleOffset = strpos($document['content'], "\n"); - $document['title'] = trim(substr($document['content'], 2, $titleOffset - 1)); - $document['content'] = substr($document['content'], $titleOffset); - } else - $document['title'] = ucfirst(basename($fileName)); - - if(!empty($titleSuffix)) - $document['title'] .= $titleSuffix; - } - - $document['content'] = Parser::instance(Parser::MARKDOWN)->parseText($document['content']); - - $response->setContent(Template::renderRaw('info.view', [ - 'document' => $document, - ])); - } -} diff --git a/src/Http/Handlers/NewsHandler.php b/src/Http/Handlers/NewsHandler.php deleted file mode 100644 index 0008785..0000000 --- a/src/Http/Handlers/NewsHandler.php +++ /dev/null @@ -1,282 +0,0 @@ -context->getNews(); - $users = $this->context->getUsers(); - $comments = $this->context->getComments(); - $posts = []; - $userInfos = []; - $userColours = []; - - foreach($postInfos as $postInfo) { - $userId = $postInfo->getUserId(); - $categoryId = $postInfo->getCategoryId(); - - if(array_key_exists($userId, $userInfos)) { - $userInfo = $userInfos[$userId]; - $userColour = $userColours[$userId]; - } else { - try { - $userInfo = $users->getUser($userId, 'id'); - $userColour = $users->getUserColour($userInfo); - } catch(RuntimeException $ex) { - $userInfo = null; - $userColour = null; - } - - $userInfos[$userId] = $userInfo; - $userColours[$userId] = $userColour; - } - - if(array_key_exists($categoryId, $categoryInfos)) - $categoryInfo = $categoryInfos[$categoryId]; - else - $categoryInfos[$categoryId] = $categoryInfo = $news->getCategoryByPost($postInfo); - - $commentsCount = $postInfo->hasCommentsCategoryId() - ? $comments->countPosts($postInfo->getCommentsCategoryId(), includeReplies: true) : 0; - - $posts[] = [ - 'post' => $postInfo, - 'category' => $categoryInfo, - 'user' => $userInfo, - 'user_colour' => $userColour, - 'comments_count' => $commentsCount, - ]; - } - - return $posts; - } - - public function index($response, $request) { - $news = $this->context->getNews(); - - $categories = $news->getAllCategories(); - $pagination = new Pagination($news->countAllPosts(onlyFeatured: true), 5); - - if(!$pagination->hasValidOffset()) - return 404; - - $postInfos = $news->getAllPosts(onlyFeatured: true, pagination: $pagination); - $posts = $this->fetchPostInfo($postInfos); - - $response->setContent(Template::renderRaw('news.index', [ - 'news_categories' => $categories, - 'news_posts' => $posts, - 'news_pagination' => $pagination, - ])); - } - - public function viewCategory($response, $request, string $fileName) { - $news = $this->context->getNews(); - - $categoryId = pathinfo($fileName, PATHINFO_FILENAME); - $type = pathinfo($fileName, PATHINFO_EXTENSION); - - try { - $categoryInfo = $news->getCategoryById($categoryId); - } catch(RuntimeException $ex) { - return 404; - } - - if($type === 'atom') - return $this->feedCategoryAtom($response, $request, $categoryInfo); - elseif($type === 'rss') - return $this->feedCategoryRss($response, $request, $categoryInfo); - elseif($type !== '') - return 404; - - $pagination = new Pagination($news->countPostsByCategory($categoryInfo), 5); - if(!$pagination->hasValidOffset()) - return 404; - - $postInfos = $news->getPostsByCategory($categoryInfo, pagination: $pagination); - $posts = $this->fetchPostInfo($postInfos, [$categoryInfo->getId() => $categoryInfo]); - - $response->setContent(Template::renderRaw('news.category', [ - 'news_category' => $categoryInfo, - 'news_posts' => $posts, - 'news_pagination' => $pagination, - ])); - } - - public function viewPost($response, $request, string $postId) { - $news = $this->context->getNews(); - $users = $this->context->getUsers(); - $comments = $this->context->getComments(); - - try { - $postInfo = $news->getPostById($postId); - } catch(RuntimeException $ex) { - return 404; - } - - if(!$postInfo->isPublished() || $postInfo->isDeleted()) - return 404; - - $categoryInfo = $news->getCategoryByPost($postInfo); - - $comments = $this->context->getComments(); - - if($postInfo->hasCommentsCategoryId()) - try { - $commentsCategory = $comments->getCategoryById($postInfo->getCommentsCategoryId()); - } catch(RuntimeException $ex) {} - - if(!isset($commentsCategory)) { - $commentsCategory = $comments->ensureCategory($postInfo->getCommentsCategoryName()); - $news->updatePostCommentCategory($postInfo, $commentsCategory); - } - - $userInfo = null; - $userColour = null; - if($postInfo->hasUserId()) - try { - $userInfo = $users->getUser($postInfo->getUserId(), 'id'); - $userColour = $users->getUserColour($userInfo); - } catch(RuntimeException $ex) {} - - $comments = new CommentsEx($this->context, $comments, $users); - - $response->setContent(Template::renderRaw('news.post', [ - 'post_info' => $postInfo, - 'post_category_info' => $categoryInfo, - 'post_user_info' => $userInfo, - 'post_user_colour' => $userColour, - 'comments_info' => $comments->getCommentsForLayout($commentsCategory), - ])); - } - - private function createFeed(string $feedMode, ?NewsCategoryInfo $categoryInfo, array $posts): Feed { - $hasCategory = $categoryInfo !== null; - $siteName = $this->context->getConfig()->getString('site.name', 'Misuzu'); - - $feed = (new Feed) - ->setTitle($siteName . ' » ' . ($hasCategory ? $categoryInfo->getName() : 'Featured News')) - ->setDescription($hasCategory ? $categoryInfo->getDescription() : 'A live featured news feed.') - ->setContentUrl(url_prefix(false) . ($hasCategory ? url('news-category', ['category' => $categoryInfo->getId()]) : url('news-index'))) - ->setFeedUrl(url_prefix(false) . ($hasCategory ? url("news-category-feed-{$feedMode}", ['category' => $categoryInfo->getId()]) : url("news-feed-{$feedMode}"))); - - foreach($posts as $post) { - $postInfo = $post['post']; - $userInfo = $post['user']; - - $userId = 0; - $userName = 'Author'; - if($userInfo !== null) { - $userId = $userInfo->getId(); - $userName = $userInfo->getName(); - } - - $postUrl = url_prefix(false) . url('news-post', ['post' => $postInfo->getId()]); - $commentsUrl = url_prefix(false) . url('news-post-comments', ['post' => $postInfo->getId()]); - $authorUrl = url_prefix(false) . url('user-profile', ['user' => $userId]); - - $feedItem = (new FeedItem) - ->setTitle($postInfo->getTitle()) - ->setSummary($postInfo->getFirstParagraph()) - ->setContent(Parser::instance(Parser::MARKDOWN)->parseText($postInfo->getBody())) - ->setCreationDate($postInfo->getCreatedTime()) - ->setUniqueId($postUrl) - ->setContentUrl($postUrl) - ->setCommentsUrl($commentsUrl) - ->setAuthorName($userName) - ->setAuthorUrl($authorUrl); - - if(!$feed->hasLastUpdate() || $feed->getLastUpdate() < $feedItem->getCreationDate()) - $feed->setLastUpdate($feedItem->getCreationDate()); - - $feed->addItem($feedItem); - } - - return $feed; - } - - private function fetchPostInfoForFeed(array $postInfos): array { - $news = $this->context->getNews(); - $users = $this->context->getUsers(); - $posts = []; - $userInfos = []; - - foreach($postInfos as $postInfo) { - $userId = $postInfo->getUserId(); - - if(array_key_exists($userId, $userInfos)) { - $userInfo = $userInfos[$userId]; - } else { - try { - $userInfo = $users->getUser($userId, 'id'); - } catch(RuntimeException $ex) { - $userInfo = null; - } - - $userInfos[$userId] = $userInfo; - } - - $posts[] = [ - 'post' => $postInfo, - 'user' => $userInfo, - ]; - } - - return $posts; - } - - private function getFeaturedPostsForFeed(): array { - return $this->fetchPostInfoForFeed( - $this->context->getNews()->getAllPosts( - onlyFeatured: true, - pagination: new Pagination(10) - ) - ); - } - - public function feedIndexAtom($response, $request) { - $response->setContentType('application/atom+xml; charset=utf-8'); - return (new AtomFeedSerializer)->serializeFeed( - self::createFeed('atom', null, $this->getFeaturedPostsForFeed()) - ); - } - - public function feedIndexRss($response, $request) { - $response->setContentType('application/rss+xml; charset=utf-8'); - return (new RssFeedSerializer)->serializeFeed( - self::createFeed('rss', null, $this->getFeaturedPostsForFeed()) - ); - } - - private function getCategoryPostsForFeed(NewsCategoryInfo $categoryInfo): array { - return $this->fetchPostInfoForFeed( - $this->context->getNews()->getPostsByCategory($categoryInfo, pagination: new Pagination(10)) - ); - } - - public function feedCategoryAtom($response, $request, NewsCategoryInfo $categoryInfo) { - $response->setContentType('application/atom+xml; charset=utf-8'); - return (new AtomFeedSerializer)->serializeFeed( - self::createFeed('atom', $categoryInfo, $this->getCategoryPostsForFeed($categoryInfo)) - ); - } - - public function feedCategoryRss($response, $request, NewsCategoryInfo $categoryInfo) { - $response->setContentType('application/rss+xml; charset=utf-8'); - return (new RssFeedSerializer)->serializeFeed( - self::createFeed('rss', $categoryInfo, $this->getCategoryPostsForFeed($categoryInfo)) - ); - } -} diff --git a/src/Info/InfoRoutes.php b/src/Info/InfoRoutes.php new file mode 100644 index 0000000..5cb5afd --- /dev/null +++ b/src/Info/InfoRoutes.php @@ -0,0 +1,122 @@ + MSZ_ROOT, + 'index' => MSZ_ROOT . '/vendor/flashwave/index', + ]; + private const PROJECT_SUFFIXES = [ + 'misuzu' => 'Misuzu Project » %s', + 'index' => 'Index Project » %s', + ]; + + public function __construct(IRouter $router) { + $router->get('/info', [$this, 'getIndex']); + $router->get('/info/:name', [$this, 'getDocsPage']); + $router->get('/info/:project/:name', [$this, 'getProjectPage']); + + $router->get('/info.php', function($response) { + $response->redirect(url('info'), true); + }); + $router->get('/info.php/:name', function($response, $request, string $name) { + $response->redirect(url('info', ['title' => $name]), true); + }); + $router->get('/info.php/:project/:name', function($response, $request, string $project, string $name) { + $response->redirect(url('info', ['title' => $project . '/' . $name]), true); + }); + } + + private static function checkName(string $name): bool { + return preg_match('#^([A-Za-z0-9_]+)$#', $name) === 1; + } + + public function getIndex() { + return Template::renderRaw('info.index'); + } + + public function getDocsPage($response, $request, string $name) { + if(!self::checkName($name)) + return 404; + + return $this->serveMarkdownDocument( + sprintf('%s/%s.md', self::DOCS_PATH, $name) + ); + } + + public function getProjectPage($response, $request, string $project, string $name) { + if(!array_key_exists($project, self::PROJECT_PATHS)) + return 404; + if(!self::checkName($name)) + return 404; + + $projectPath = self::PROJECT_PATHS[$project]; + $titleSuffix = array_key_exists($project, self::PROJECT_SUFFIXES) ? self::PROJECT_SUFFIXES[$project] : ''; + + $attempts = 0; + $licenceHack = false; + for(;;) { + $path = match(++$attempts) { + 1 => sprintf('%s/%s', $projectPath, $name), + 2 => sprintf('%s/%s.md', $projectPath, $name), + 3 => sprintf('%s/%s', $projectPath, strtoupper($name)), + 4 => sprintf('%s/%s.md', $projectPath, strtoupper($name)), + 5 => sprintf('%s/%s', $projectPath, strtolower($name)), + 6 => sprintf('%s/%s.md', $projectPath, strtolower($name)), + default => '', + }; + + if($path === '') { + if(!$licenceHack) { + $isBritish = strtolower($name) === 'licence'; + $isAmerican = strtolower($name) === 'license'; + + if($isBritish || $isAmerican) { + $attempts = 0; + $licenceHack = true; + $name = $isAmerican ? 'licence' : 'license'; + continue; + } + } + + return 404; + } + + if(is_file($path)) + break; + } + + return $this->serveMarkdownDocument($path, $titleSuffix); + } + + private function serveMarkdownDocument(string $path, string $titleFormat = '') { + if(!is_file($path)) + return 404; + + $body = file_get_contents($path); + + if(str_starts_with($body, '#')) { + $offset = strpos($body, "\n"); + $title = trim(substr($body, 1, $offset)); + $body = substr($body, $offset); + } else + $title = ucfirst(basename($path)); + + if($titleFormat !== '') + $title = sprintf($titleFormat, $title); + + $body = Parser::instance(Parser::MARKDOWN)->parseText($body); + + return Template::renderRaw('info.view', [ + 'document' => [ + 'title' => $title, + 'content' => $body, + ], + ]); + } +} diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 6e61533..c2f7727 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -1,6 +1,13 @@ router = new HttpFx; $this->router->use('/', function($response) { $response->setPoweredBy('Misuzu'); }); $this->registerErrorPages(); - - if($legacy) - $this->registerLegacyRedirects(); - else - $this->registerHttpRoutes(); + $this->registerHttpRoutes(); } public function dispatchHttp(?HttpRequest $request = null): void { @@ -377,66 +378,39 @@ class MisuzuContext { } private function registerHttpRoutes(): void { - $mszCompatHandler = fn($className, $method) => fn(...$args) => (new ("\\Misuzu\\Http\\Handlers\\{$className}Handler")($this))->{$method}(...$args); + new HomeRoutes( + $this->router, $this->config, $this->dbConn, $this->authInfo, + $this->changelog, $this->comments, $this->counters, $this->news, + $this->users + ); - $this->router->get('/', $mszCompatHandler('Home', 'index')); + new AssetsRoutes($this->router, $this->authInfo, $this->bans, $this->users); - $this->router->get('/assets/avatar/:filename', $mszCompatHandler('Assets', 'serveAvatar')); - $this->router->get('/assets/profile-background/:filename', $mszCompatHandler('Assets', 'serveProfileBackground')); + new InfoRoutes($this->router); - $this->router->get('/info', $mszCompatHandler('Info', 'index')); - $this->router->get('/info/:name', $mszCompatHandler('Info', 'page')); - $this->router->get('/info/:project/:name', $mszCompatHandler('Info', 'page')); + new NewsRoutes( + $this->router, $this->config, $this->authInfo, + $this->news, $this->users, $this->comments + ); - $this->router->get('/changelog', $mszCompatHandler('Changelog', 'index')); - $this->router->get('/changelog.rss', $mszCompatHandler('Changelog', 'feedRss')); - $this->router->get('/changelog.atom', $mszCompatHandler('Changelog', 'feedAtom')); - $this->router->get('/changelog/change/:id', $mszCompatHandler('Changelog', 'change')); + new ChangelogRoutes( + $this->router, $this->config, $this->changelog, + $this->users, $this->authInfo, $this->comments + ); - $this->router->get('/news', $mszCompatHandler('News', 'index')); - $this->router->get('/news.rss', $mszCompatHandler('News', 'feedIndexRss')); - $this->router->get('/news.atom', $mszCompatHandler('News', 'feedIndexAtom')); - $this->router->get('/news/:category', $mszCompatHandler('News', 'viewCategory')); - $this->router->get('/news/post/:id', $mszCompatHandler('News', 'viewPost')); + new SharpChatRoutes( + $this->router, $this->config->scopeTo('sockChat'), + $this->bans, $this->emotes, $this->users, + $this->sessions, $this->authInfo, + $this->createAuthTokenPacker(...) + ); - $this->router->get('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadGET')); - $this->router->post('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadPOST')); + new SatoriRoutes( + $this->dbConn, $this->config->scopeTo('satori'), + $this->router, $this->users, $this->profileFields + ); - new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->bans, $this->emotes, $this->users, $this->sessions, $this->authInfo, $this->createAuthTokenPacker(...)); - new SatoriRoutes($this->dbConn, $this->config->scopeTo('satori'), $this->router, $this->users, $this->profileFields); - } - - private function registerLegacyRedirects(): void { - $this->router->get('/index.php', function($response) { - $response->redirect(url('index'), true); - }); - - $this->router->get('/info.php', function($response) { - $response->redirect(url('info'), true); - }); - - $this->router->get('/settings.php', function($response) { - $response->redirect(url('settings-index'), true); - }); - - $this->router->get('/changelog.php', function($response, $request) { - $changeId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); - if($changeId) { - $response->redirect(url('changelog-change', ['change' => $changeId]), true); - return; - } - - $response->redirect(url('changelog-index', [ - 'date' => $request->getParam('d'), - 'user' => $request->getParam('u', FILTER_SANITIZE_NUMBER_INT), - ]), true); - }); - - $infoRedirect = function($response, $request, string ...$parts) { - $response->redirect(url('info', ['title' => implode('/', $parts)]), true); - }; - $this->router->get('/info.php/:name', $infoRedirect); - $this->router->get('/info.php/:project/:name', $infoRedirect); + // below is still only otherwise available as stinky php files $this->router->get('/auth.php', function($response, $request) { $response->redirect(url([ @@ -447,73 +421,8 @@ class MisuzuContext { ][$request->getParam('m')] ?? 'auth-login'), true); }); - $this->router->get('/news.php', function($response, $request) { - $postId = $request->getParam('n', FILTER_SANITIZE_NUMBER_INT) ?? $request->getParam('p', FILTER_SANITIZE_NUMBER_INT); - - if($postId > 0) - $location = url('news-post', ['post' => $postId]); - else { - $catId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); - $pageId = $request->getParam('page', FILTER_SANITIZE_NUMBER_INT); - $location = url($catId > 0 ? 'news-category' : 'news-index', ['category' => $catId, 'page' => $pageId]); - } - - $response->redirect($location, true); - }); - - $this->router->get('/news.php/rss', function($response, $request) { - $catId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); - $location = url($catId > 0 ? 'news-category-feed-rss' : 'news-feed-rss', ['category' => $catId]); - $response->redirect($location, true); - }); - - $this->router->get('/news.php/atom', function($response, $request) { - $catId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); - $location = url($catId > 0 ? 'news-category-feed-atom' : 'news-feed-atom', ['category' => $catId]); - $response->redirect($location, true); - }); - - $this->router->get('/news/index.php', function($response, $request) { - $response->redirect(url('news-index', [ - 'page' => $request->getParam('page', FILTER_SANITIZE_NUMBER_INT), - ]), true); - }); - - $this->router->get('/news/category.php', function($response, $request) { - $response->redirect(url('news-category', [ - 'category' => $request->getParam('c', FILTER_SANITIZE_NUMBER_INT), - 'page' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT), - ]), true); - }); - - $this->router->get('/news/post.php', function($response, $request) { - $response->redirect(url('news-post', [ - 'post' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT), - ]), true); - }); - - $this->router->get('/news/feed.php', function() { - return 400; - }); - - $this->router->get('/news/feed.php/rss', function($response, $request) { - $catId = (int)$request->getParam('c', FILTER_SANITIZE_NUMBER_INT); - $response->redirect(url( - $catId > 0 ? 'news-category-feed-rss' : 'news-feed-rss', - ['category' => $catId] - ), true); - }); - - $this->router->get('/news/feed.php/atom', function($response, $request) { - $catId = (int)$request->getParam('c', FILTER_SANITIZE_NUMBER_INT); - $response->redirect(url( - $catId > 0 ? 'news-category-feed-atom' : 'news-feed-atom', - ['category' => $catId] - ), true); - }); - - $this->router->get('/user-assets.php', function($response, $request) { - return (new \Misuzu\Http\Handlers\AssetsHandler($this))->serveLegacy($response, $request); + $this->router->get('/settings.php', function($response) { + $response->redirect(url('settings-index'), true); }); } } diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php new file mode 100644 index 0000000..0d740ab --- /dev/null +++ b/src/News/NewsRoutes.php @@ -0,0 +1,361 @@ +config = $config; + $this->authInfo = $authInfo; + $this->news = $news; + $this->users = $users; + $this->comments = $comments; + + $router->get('/news', [$this, 'getIndex']); + $router->get('/news.rss', [$this, 'getFeedRss']); + $router->get('/news.atom', [$this, 'getFeedAtom']); + $router->get('/news/:category', [$this, 'getCategory']); + $router->get('/news/post/:id', [$this, 'getPost']); + + $router->get('/news.php', function($response, $request) { + $postId = $request->getParam('n', FILTER_SANITIZE_NUMBER_INT) + ?? $request->getParam('p', FILTER_SANITIZE_NUMBER_INT); + + if($postId > 0) + $location = url('news-post', ['post' => $postId]); + else { + $catId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); + $pageId = $request->getParam('page', FILTER_SANITIZE_NUMBER_INT); + $location = url($catId > 0 ? 'news-category' : 'news-index', ['category' => $catId, 'page' => $pageId]); + } + + $response->redirect($location, true); + }); + + $router->get('/news.php/rss', function($response, $request) { + $catId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); + $location = url($catId > 0 ? 'news-category-feed-rss' : 'news-feed-rss', ['category' => $catId]); + $response->redirect($location, true); + }); + + $router->get('/news.php/atom', function($response, $request) { + $catId = $request->getParam('c', FILTER_SANITIZE_NUMBER_INT); + $location = url($catId > 0 ? 'news-category-feed-atom' : 'news-feed-atom', ['category' => $catId]); + $response->redirect($location, true); + }); + + $router->get('/news/index.php', function($response, $request) { + $response->redirect(url('news-index', [ + 'page' => $request->getParam('page', FILTER_SANITIZE_NUMBER_INT), + ]), true); + }); + + $router->get('/news/category.php', function($response, $request) { + $response->redirect(url('news-category', [ + 'category' => $request->getParam('c', FILTER_SANITIZE_NUMBER_INT), + 'page' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT), + ]), true); + }); + + $router->get('/news/post.php', function($response, $request) { + $response->redirect(url('news-post', [ + 'post' => $request->getParam('p', FILTER_SANITIZE_NUMBER_INT), + ]), true); + }); + + $router->get('/news/feed.php/rss', function($response, $request) { + $catId = (int)$request->getParam('c', FILTER_SANITIZE_NUMBER_INT); + $response->redirect(url( + $catId > 0 ? 'news-category-feed-rss' : 'news-feed-rss', + ['category' => $catId] + ), true); + }); + + $router->get('/news/feed.php/atom', function($response, $request) { + $catId = (int)$request->getParam('c', FILTER_SANITIZE_NUMBER_INT); + $response->redirect(url( + $catId > 0 ? 'news-category-feed-atom' : 'news-feed-atom', + ['category' => $catId] + ), true); + }); + } + + private array $userInfos = []; + private array $userColours = []; + private array $categoryInfos = []; + + private function getNewsPostsForView(Pagination $pagination, ?NewsCategoryInfo $categoryInfo = null): array { + $posts = []; + $postInfos = $categoryInfo === null + ? $this->news->getAllPosts(onlyFeatured: true, pagination: $pagination) + : $this->news->getPostsByCategory($categoryInfo, pagination: $pagination); + + foreach($postInfos as $postInfo) { + $userId = $postInfo->getUserId(); + $categoryId = $postInfo->getCategoryId(); + + if(array_key_exists($userId, $this->userInfos)) { + $userInfo = $this->userInfos[$userId]; + } else { + try { + $userInfo = $this->users->getUser($userId, 'id'); + } catch(RuntimeException $ex) { + $userInfo = null; + } + + $this->userInfos[$userId] = $userInfo; + } + + if(array_key_exists($userId, $this->userColours)) { + $userColour = $this->userColours[$userId]; + } else { + try { + $userColour = $this->users->getUserColour($userInfo); + } catch(RuntimeException $ex) { + $userColour = null; + } + + $this->userColours[$userId] = $userColour; + } + + if(array_key_exists($categoryId, $this->categoryInfos)) + $categoryInfo = $this->categoryInfos[$categoryId]; + else + $this->categoryInfos[$categoryId] = $categoryInfo = $this->news->getCategoryByPost($postInfo); + + $commentsCount = $postInfo->hasCommentsCategoryId() + ? $this->comments->countPosts($postInfo->getCommentsCategoryId(), includeReplies: true) + : 0; + + $posts[] = [ + 'post' => $postInfo, + 'category' => $categoryInfo, + 'user' => $userInfo, + 'user_colour' => $userColour, + 'comments_count' => $commentsCount, + ]; + } + + return $posts; + } + + private function getNewsPostsForFeed(?NewsCategoryInfo $categoryInfo = null): array { + $posts = []; + $postInfos = $categoryInfo === null + ? $this->news->getAllPosts(onlyFeatured: true, pagination: new Pagination(10)) + : $this->news->getPostsByCategory($categoryInfo, pagination: new Pagination(10)); + + foreach($postInfos as $postInfo) { + $userId = $postInfo->getUserId(); + $categoryId = $postInfo->getCategoryId(); + + if(array_key_exists($userId, $this->userInfos)) { + $userInfo = $this->userInfos[$userId]; + } else { + try { + $userInfo = $this->users->getUser($userId, 'id'); + } catch(RuntimeException $ex) { + $userInfo = null; + } + + $this->userInfos[$userId] = $userInfo; + } + + $posts[] = [ + 'post' => $postInfo, + 'category' => $categoryInfo, + 'user' => $userInfo, + ]; + } + + return $posts; + } + + public function getIndex() { + $categories = $this->news->getAllCategories(); + + $pagination = new Pagination($this->news->countAllPosts(onlyFeatured: true), 5); + if(!$pagination->hasValidOffset()) + return 404; + + $posts = $this->getNewsPostsForView($pagination); + + return Template::renderRaw('news.index', [ + 'news_categories' => $categories, + 'news_posts' => $posts, + 'news_pagination' => $pagination, + ]); + } + + public function getFeedRss($response) { + return $this->getFeed($response, 'rss'); + } + + public function getFeedAtom($response) { + return $this->getFeed($response, 'atom'); + } + + public function getCategory($response, $request, string $fileName) { + $categoryId = pathinfo($fileName, PATHINFO_FILENAME); + $type = pathinfo($fileName, PATHINFO_EXTENSION); + + try { + $categoryInfo = $this->news->getCategoryById($categoryId); + } catch(RuntimeException $ex) { + return 404; + } + + if($type === 'rss') + return $this->getCategoryFeedRss($response, $request, $categoryInfo); + elseif($type === 'atom') + return $this->getCategoryFeedAtom($response, $request, $categoryInfo); + elseif($type !== '') + return 404; + + $pagination = new Pagination($this->news->countPostsByCategory($categoryInfo), 5); + if(!$pagination->hasValidOffset()) + return 404; + + $posts = $this->getNewsPostsForView($pagination, $categoryInfo); + + return Template::renderRaw('news.category', [ + 'news_category' => $categoryInfo, + 'news_posts' => $posts, + 'news_pagination' => $pagination, + ]); + } + + private function getCategoryFeedRss($response, $request, NewsCategoryInfo $categoryInfo) { + return $this->getFeed($response, 'rss', $categoryInfo); + } + + private function getCategoryFeedAtom($response, $request, NewsCategoryInfo $categoryInfo) { + return $this->getFeed($response, 'atom', $categoryInfo); + } + + public function getPost($response, $request, string $postId) { + try { + $postInfo = $this->news->getPostById($postId); + } catch(RuntimeException $ex) { + return 404; + } + + if(!$postInfo->isPublished() || $postInfo->isDeleted()) + return 404; + + $categoryInfo = $this->news->getCategoryByPost($postInfo); + + if($postInfo->hasCommentsCategoryId()) + try { + $commentsCategory = $this->comments->getCategoryById($postInfo->getCommentsCategoryId()); + } catch(RuntimeException $ex) {} + + if(!isset($commentsCategory)) { + $commentsCategory = $this->comments->ensureCategory($postInfo->getCommentsCategoryName()); + $this->news->updatePostCommentCategory($postInfo, $commentsCategory); + } + + $userInfo = null; + $userColour = null; + if($postInfo->hasUserId()) + try { + $userInfo = $this->users->getUser($postInfo->getUserId(), 'id'); + $userColour = $this->users->getUserColour($userInfo); + } catch(RuntimeException $ex) {} + + $comments = new CommentsEx($this->authInfo, $this->comments, $this->users); + + return Template::renderRaw('news.post', [ + 'post_info' => $postInfo, + 'post_category_info' => $categoryInfo, + 'post_user_info' => $userInfo, + 'post_user_colour' => $userColour, + 'comments_info' => $comments->getCommentsForLayout($commentsCategory), + ]); + } + + private function getFeed($response, string $feedType, ?NewsCategoryInfo $categoryInfo = null) { + $hasCategory = $categoryInfo !== null; + $siteName = $this->config->getString('site.name', 'Misuzu'); + $posts = $this->getNewsPostsForFeed($categoryInfo); + + $serialiser = match($feedType) { + 'rss' => new RssFeedSerializer, + 'atom' => new AtomFeedSerializer, + default => throw new RuntimeException('Invalid $feedType specified.'), + }; + + $response->setContentType(sprintf('application/%s+xml; charset=utf-8', $feedType)); + $feed = (new Feed) + ->setTitle($siteName . ' » ' . ($hasCategory ? $categoryInfo->getName() : 'Featured News')) + ->setDescription($hasCategory ? $categoryInfo->getDescription() : 'A live featured news feed.') + ->setContentUrl(url_prefix(false) . ($hasCategory ? url('news-category', ['category' => $categoryInfo->getId()]) : url('news-index'))) + ->setFeedUrl(url_prefix(false) . ($hasCategory ? url("news-category-feed-{$feedType}", ['category' => $categoryInfo->getId()]) : url("news-feed-{$feedType}"))); + + foreach($posts as $post) { + $postInfo = $post['post']; + $userInfo = $post['user']; + + $userId = 0; + $userName = 'Author'; + if($userInfo !== null) { + $userId = $userInfo->getId(); + $userName = $userInfo->getName(); + } + + $postUrl = url_prefix(false) . url('news-post', ['post' => $postInfo->getId()]); + $commentsUrl = url_prefix(false) . url('news-post-comments', ['post' => $postInfo->getId()]); + $authorUrl = url_prefix(false) . url('user-profile', ['user' => $userId]); + + $feedItem = (new FeedItem) + ->setTitle($postInfo->getTitle()) + ->setSummary($postInfo->getFirstParagraph()) + ->setContent(Parser::instance(Parser::MARKDOWN)->parseText($postInfo->getBody())) + ->setCreationDate($postInfo->getCreatedTime()) + ->setUniqueId($postUrl) + ->setContentUrl($postUrl) + ->setCommentsUrl($commentsUrl) + ->setAuthorName($userName) + ->setAuthorUrl($authorUrl); + + if(!$feed->hasLastUpdate() || $feed->getLastUpdate() < $feedItem->getCreationDate()) + $feed->setLastUpdate($feedItem->getCreationDate()); + + $feed->addItem($feedItem); + } + + return $serialiser->serializeFeed($feed); + } +} diff --git a/src/Users/Assets/AssetsRoutes.php b/src/Users/Assets/AssetsRoutes.php new file mode 100644 index 0000000..eb4889a --- /dev/null +++ b/src/Users/Assets/AssetsRoutes.php @@ -0,0 +1,112 @@ +authInfo = $authInfo; + $this->bans = $bans; + $this->users = $users; + + $router->get('/assets/avatar/:filename', [$this, 'getAvatar']); + $router->get('/assets/profile-background/:filename', [$this, 'getProfileBackground']); + $router->get('/user-assets.php', [$this, 'getUserAssets']); + } + + private function canViewAsset($request, UserInfo $assetUser): bool { + if($this->bans->countActiveBans($assetUser)) + return $this->authInfo->isLoggedIn() // allow staff viewing profile to still see banned user assets + && perms_check_user(MSZ_PERMS_USER, (int)$this->authInfo->getUserId(), MSZ_PERM_USER_MANAGE_USERS) + && parse_url($request->getHeaderFirstLine('Referer'), PHP_URL_PATH) === url('user-profile'); + + return true; + } + + public function getAvatar($response, $request, string $fileName) { + $userId = pathinfo($fileName, PATHINFO_FILENAME); + $assetInfo = new StaticUserImageAsset(MSZ_PUBLIC . '/images/no-avatar.png', MSZ_PUBLIC); + + try { + $userInfo = $this->users->getUser($userId, 'id'); + + if(!$this->canViewAsset($request, $userInfo)) { + $assetInfo = new StaticUserImageAsset(MSZ_PUBLIC . '/images/banned-avatar.png', MSZ_PUBLIC); + } else { + $userAssetInfo = new UserAvatarAsset($userInfo); + if($userAssetInfo->isPresent()) + $assetInfo = $userAssetInfo; + } + } catch(RuntimeException $ex) {} + + return $this->serveAsset($response, $request, $assetInfo); + } + + public function getProfileBackground($response, $request, string $fileName) { + $userId = pathinfo($fileName, PATHINFO_FILENAME); + + try { + $userInfo = $this->users->getUser($userId, 'id'); + } catch(RuntimeException $ex) {} + + if(!empty($userInfo)) { + $userAssetInfo = new UserBackgroundAsset($userInfo); + if($userAssetInfo->isPresent() && $this->canViewAsset($request, $userInfo)) + $assetInfo = $userAssetInfo; + } + + if(!isset($assetInfo)) { + // circumvent the default error page + $response->setContent('Not Found'); + return 404; + } + + return $this->serveAsset($response, $request, $assetInfo); + } + + public function getUserAssets($response, $request) { + $userId = (string)$request->getParam('u', FILTER_SANITIZE_NUMBER_INT); + $mode = (string)$request->getParam('m'); + + if($mode === 'avatar') + return $this->getAvatar($response, $request, $userId); + + if($mode === 'background') + return $this->getProfileBackground($response, $request, $userId); + + // circumvent the default error page + $response->setContent('Not Found'); + return 404; + } + + private function serveAsset($response, $request, UserImageAssetInterface $assetInfo): void { + $contentType = $assetInfo->getMimeType(); + $publicPath = $assetInfo->getPublicPath(); + $fileName = $assetInfo->getFileName(); + + if($assetInfo instanceof UserAssetScalableInterface) { + $dimensions = (int)($request->getParam('res', FILTER_SANITIZE_NUMBER_INT) + ?? $request->getParam('r', FILTER_SANITIZE_NUMBER_INT)); + + if($dimensions > 0) { + $assetInfo->ensureScaledExists($dimensions); + $contentType = $assetInfo->getScaledMimeType($dimensions); + $publicPath = $assetInfo->getPublicScaledPath($dimensions); + $fileName = $assetInfo->getScaledFileName($dimensions); + } + } + + $response->accelRedirect($publicPath); + $response->setContentType($contentType); + $response->setFileName($fileName, false); + } +} diff --git a/src/Users/Bans.php b/src/Users/Bans.php index 2b9e9c7..21389b8 100644 --- a/src/Users/Bans.php +++ b/src/Users/Bans.php @@ -122,6 +122,23 @@ class Bans { return new BanInfo($result); } + public function countActiveBans( + UserInfo|string $userInfo, + int $minimumSeverity = self::SEVERITY_MIN + ): int { + if($userInfo instanceof UserInfo) + $userInfo = $userInfo->getId(); + + // orders by ban_expires descending with NULLs (permanent) first + $stmt = $this->cache->get('SELECT COUNT(*) FROM msz_users_bans WHERE user_id = ? AND ban_severity >= ? AND (ban_expires IS NULL OR ban_expires > NOW()) ORDER BY ban_expires IS NULL DESC, ban_expires DESC'); + $stmt->addParameter(1, $userInfo); + $stmt->addParameter(2, $minimumSeverity); + $stmt->execute(); + + $result = $stmt->getResult(); + return $result->next() ? $result->getInteger(0) : 0; + } + public function tryGetActiveBan( UserInfo|string $userInfo, int $minimumSeverity = self::SEVERITY_MIN diff --git a/src/url.php b/src/url.php index 1435802..7fcd07b 100644 --- a/src/url.php +++ b/src/url.php @@ -41,8 +41,8 @@ define('MSZ_URLS', [ 'forum-index' => ['/forum'], 'forum-leaderboard' => ['/forum/leaderboard.php', ['id' => '', 'mode' => '']], - 'forum-mark-global' => ['/forum/mark-as-read'], - 'forum-mark-single' => ['/forum/mark-as-read', ['forum' => '']], + 'forum-mark-global' => ['/forum/index.php', ['m' => 'mark']], + 'forum-mark-single' => ['/forum/index.php', ['m' => 'mark', 'f' => '']], 'forum-topic-new' => ['/forum/posting.php', ['f' => '']], 'forum-reply-new' => ['/forum/posting.php', ['t' => '']], 'forum-category' => ['/forum/forum.php', ['f' => '', 'p' => '']], diff --git a/templates/_layout/footer.twig b/templates/_layout/footer.twig index 08ebb10..5df09a8 100644 --- a/templates/_layout/footer.twig +++ b/templates/_layout/footer.twig @@ -3,7 +3,7 @@