From 273121a49f0816fa159817c7d4c8563a93d7525b Mon Sep 17 00:00:00 2001 From: Yury Pikhtarev Date: Sun, 22 Jun 2025 02:26:25 +0400 Subject: [PATCH] feat(http): implement HTTP routing infrastructure with middleware support - Add Router class with route registration and dispatching - Implement RequestFactory and ResponseFactory for HTTP handling - Create BaseMiddleware and CorsMiddleware for request processing - Add Kernel class for HTTP request/response lifecycle management - Introduce HelloWorldController and LegacyController for web routes - Move terms.php to controllers directory following new structure - Add route definitions in config/routes.php and web.php - Update dependency injection definitions for HTTP components - Integrate routing system with existing application entry points - Add comprehensive test coverage for new HTTP components This establishes a modern HTTP routing foundation that supports: - Middleware pipeline execution - RESTful route definitions - Proper separation of concerns between routing and business logic - Backward compatibility through LegacyController --- common.php | 3 - composer.json | 1 + composer.lock | 255 +++++++++- config/routes.php | 102 ++++ controllers/index.php | 437 +++++++++++++++++ terms.php => controllers/terms.php | 5 +- index.php | 447 +----------------- library/includes/page_header.php | 11 +- phinx.php | 14 +- .../Definitions/InfrastructureDefinitions.php | 15 + .../Definitions/PresentationDefinitions.php | 5 + .../Http/Middleware/BaseMiddleware.php | 30 ++ .../Http/Middleware/CorsMiddleware.php | 58 +++ src/Infrastructure/Http/RequestFactory.php | 28 ++ src/Infrastructure/Http/ResponseFactory.php | 54 +++ src/Infrastructure/Http/Router.php | 91 ++++ .../Controllers/Web/HelloWorldController.php | 108 +++++ .../Http/Controllers/Web/LegacyController.php | 104 ++++ src/Presentation/Http/Kernel.php | 152 ++++++ src/Presentation/Http/Routes/web.php | 37 ++ .../InfrastructureDefinitionsTest.php | 19 +- .../PresentationDefinitionsTest.php | 14 +- tests/Unit/Infrastructure/Http/RouterTest.php | 251 ++++++++++ .../Controllers/Web/LegacyControllerTest.php | 58 +++ tests/Unit/Presentation/Http/KernelTest.php | 201 ++++++++ 25 files changed, 2051 insertions(+), 449 deletions(-) create mode 100644 config/routes.php create mode 100644 controllers/index.php rename terms.php => controllers/terms.php (82%) create mode 100644 src/Infrastructure/Http/Middleware/BaseMiddleware.php create mode 100644 src/Infrastructure/Http/Middleware/CorsMiddleware.php create mode 100644 src/Infrastructure/Http/RequestFactory.php create mode 100644 src/Infrastructure/Http/ResponseFactory.php create mode 100644 src/Infrastructure/Http/Router.php create mode 100644 src/Presentation/Http/Controllers/Web/HelloWorldController.php create mode 100644 src/Presentation/Http/Controllers/Web/LegacyController.php create mode 100644 src/Presentation/Http/Kernel.php create mode 100644 src/Presentation/Http/Routes/web.php create mode 100644 tests/Unit/Infrastructure/Http/RouterTest.php create mode 100644 tests/Unit/Presentation/Http/Controllers/Web/LegacyControllerTest.php create mode 100644 tests/Unit/Presentation/Http/KernelTest.php diff --git a/common.php b/common.php index de0a8cab4..a5f2ae3d4 100644 --- a/common.php +++ b/common.php @@ -34,9 +34,6 @@ if (empty($_SERVER['SERVER_ADDR'])) { if (!defined('BB_ROOT')) { define('BB_ROOT', './'); } -if (!defined('BB_SCRIPT')) { - define('BB_SCRIPT', null); -} header('X-Frame-Options: SAMEORIGIN'); date_default_timezone_set('UTC'); diff --git a/composer.json b/composer.json index 97cf8ec23..d8e73149b 100644 --- a/composer.json +++ b/composer.json @@ -62,6 +62,7 @@ "jacklul/monolog-telegram": "^3.1", "josantonius/cookie": "^2.0", "league/flysystem": "^3.28", + "league/route": "^6.2", "longman/ip-tools": "1.2.1", "monolog/monolog": "^3.4", "nette/caching": "^3.3", diff --git a/composer.lock b/composer.lock index a513013f6..ad808f575 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": "57713d8849e71683b70d934a81f7e18c", + "content-hash": "8334ef08115fb688baf7bde81a18031c", "packages": [ { "name": "arokettu/bencode", @@ -2271,6 +2271,96 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/route", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/route.git", + "reference": "38775ed32d49ff1ce98d88adaa06a8d66b923436" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/route/zipball/38775ed32d49ff1ce98d88adaa06a8d66b923436", + "reference": "38775ed32d49ff1ce98d88adaa06a8d66b923436", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^2.0.0", + "nikic/fast-route": "^1.3", + "php": "^8.1", + "psr/container": "^2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0.1", + "psr/http-server-middleware": "^1.0.1", + "psr/simple-cache": "^3.0" + }, + "replace": { + "orno/http": "~1.0", + "orno/route": "~1.0" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.5", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^10.2", + "roave/security-advisories": "dev-latest", + "scrutinizer/ocular": "^1.8", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev", + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-6.x": "6.x-dev", + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Route\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phil Bennett", + "email": "mail@philbennett.co.uk", + "role": "Developer" + } + ], + "description": "Fast routing and dispatch component including PSR-15 middleware, built on top of FastRoute.", + "homepage": "https://github.com/thephpleague/route", + "keywords": [ + "dispatcher", + "league", + "psr-15", + "psr-7", + "psr15", + "psr7", + "route", + "router" + ], + "support": { + "issues": "https://github.com/thephpleague/route/issues", + "source": "https://github.com/thephpleague/route/tree/6.2.0" + }, + "funding": [ + { + "url": "https://github.com/philipobenito", + "type": "github" + } + ], + "time": "2024-11-25T08:10:15+00:00" + }, { "name": "longman/ip-tools", "version": "1.2.1", @@ -2669,6 +2759,56 @@ }, "time": "2025-06-03T04:55:08+00:00" }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, { "name": "nikic/iter", "version": "v2.4.1", @@ -3315,6 +3455,119 @@ }, "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/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.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 middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 000000000..54bc665ee --- /dev/null +++ b/config/routes.php @@ -0,0 +1,102 @@ + [ + 'enabled' => env('ROUTE_CACHE_ENABLED', false), + 'path' => env('ROUTE_CACHE_PATH', __DIR__ . '/../internal_data/cache/routes.php'), + 'ttl' => env('ROUTE_CACHE_TTL', 3600), // 1 hour + ], + + // Global middleware (applied to all routes) + 'middleware' => [ + // 'TorrentPier\Infrastructure\Http\Middleware\CorsMiddleware', + // 'TorrentPier\Infrastructure\Http\Middleware\SecurityHeadersMiddleware', + ], + + // Route groups configuration + 'groups' => [ + // Web routes (HTML responses) + 'web' => [ + 'prefix' => '', + 'middleware' => [ + // 'TorrentPier\Infrastructure\Http\Middleware\WebMiddleware', + // 'TorrentPier\Infrastructure\Http\Middleware\StartSession', + // 'TorrentPier\Infrastructure\Http\Middleware\VerifyCsrfToken', + ], + ], + + // API routes (JSON responses) + 'api' => [ + 'prefix' => '/api', + 'middleware' => [ + // 'TorrentPier\Infrastructure\Http\Middleware\ApiMiddleware', + // 'TorrentPier\Infrastructure\Http\Middleware\RateLimitMiddleware', + // 'TorrentPier\Infrastructure\Http\Middleware\AuthenticationMiddleware', + ], + ], + + // Admin routes (Administrative interface) + 'admin' => [ + 'prefix' => '/admin', + 'middleware' => [ + // 'TorrentPier\Infrastructure\Http\Middleware\AdminMiddleware', + // 'TorrentPier\Infrastructure\Http\Middleware\RequireAdminAuth', + // 'TorrentPier\Infrastructure\Http\Middleware\AuditLoggingMiddleware', + ], + ], + ], + + // Route defaults + 'defaults' => [ + 'namespace' => 'TorrentPier\Presentation\Http\Controllers', + 'timeout' => 30, // seconds + 'methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + ], + + // Route constraints + 'constraints' => [ + 'id' => '\d+', + 'hash' => '[a-fA-F0-9]+', + 'slug' => '[a-z0-9-]+', + 'uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', + ], + + // Route patterns for common use cases + 'patterns' => [ + // Forum routes + 'forum' => '/forum/{forum_id:\d+}', + 'topic' => '/topic/{topic_id:\d+}', + 'post' => '/post/{post_id:\d+}', + + // Torrent routes + 'torrent' => '/torrent/{torrent_id:\d+}', + 'torrent_hash' => '/torrent/{info_hash:[a-fA-F0-9]+}', + + // User routes + 'user' => '/user/{user_id:\d+}', + 'profile' => '/profile/{username:[a-zA-Z0-9_-]+}', + ], + + // Fallback routes + 'fallback' => [ + 'enabled' => true, + 'handler' => 'TorrentPier\Presentation\Http\Controllers\Web\FallbackController@handle', + ], +]; +*/ diff --git a/controllers/index.php b/controllers/index.php new file mode 100644 index 000000000..fefe9728e --- /dev/null +++ b/controllers/index.php @@ -0,0 +1,437 @@ +enqueue([ + 'stats', + 'moderators', + 'cat_forums' +]); + +if (config()->get('show_latest_news')) { + $datastore->enqueue([ + 'latest_news' + ]); +} +if (config()->get('show_network_news')) { + $datastore->enqueue([ + 'network_news' + ]); +} + +// Init userdata +$user->session_start(); + +// Set meta description +$page_cfg['meta_description'] = config()->get('site_desc'); + +// Init main vars +$viewcat = isset($_GET[POST_CAT_URL]) ? (int)$_GET[POST_CAT_URL] : 0; +$lastvisit = IS_GUEST ? TIMENOW : $userdata['user_lastvisit']; + +// Caching output +$req_page = 'index_page'; +$req_page .= $viewcat ? "_c{$viewcat}" : ''; + +define('REQUESTED_PAGE', $req_page); +caching_output(IS_GUEST, 'send', REQUESTED_PAGE . '_guest_' . config()->get('default_lang')); + +$hide_cat_opt = isset($user->opt_js['h_cat']) ? (string)$user->opt_js['h_cat'] : 0; +$hide_cat_user = array_flip(explode('-', $hide_cat_opt)); +$showhide = isset($_GET['sh']) ? (int)$_GET['sh'] : 0; + +// Topics read tracks +$tracking_topics = get_tracks('topic'); +$tracking_forums = get_tracks('forum'); + +// Statistics +$stats = $datastore->get('stats'); +if ($stats === false) { + $datastore->update('stats'); + $stats = $datastore->get('stats'); +} + +// Forums data +$forums = $datastore->get('cat_forums'); +if ($forums === false) { + $datastore->update('cat_forums'); + $forums = $datastore->get('cat_forums'); +} +$cat_title_html = $forums['cat_title_html']; +$forum_name_html = $forums['forum_name_html']; + +$anon = GUEST_UID; +$excluded_forums_csv = $user->get_excluded_forums(AUTH_VIEW); +$excluded_forums_array = $excluded_forums_csv ? explode(',', $excluded_forums_csv) : []; +$only_new = $user->opt_js['only_new']; + +// Validate requested category id +if ($viewcat && !($viewcat =& $forums['c'][$viewcat]['cat_id'])) { + redirect('index.php'); +} + +// Forums +$forums_join_sql = 'f.cat_id = c.cat_id'; +$forums_join_sql .= $viewcat ? " + AND f.cat_id = $viewcat +" : ''; +$forums_join_sql .= $excluded_forums_csv ? " + AND f.forum_id NOT IN($excluded_forums_csv) + AND f.forum_parent NOT IN($excluded_forums_csv) +" : ''; + +// Posts +$posts_join_sql = 'p.post_id = f.forum_last_post_id'; +$posts_join_sql .= ($only_new == ONLY_NEW_POSTS) ? " + AND p.post_time > $lastvisit +" : ''; +$join_p_type = ($only_new == ONLY_NEW_POSTS) ? 'INNER JOIN' : 'LEFT JOIN'; + +// Topics +$topics_join_sql = 't.topic_last_post_id = p.post_id'; +$topics_join_sql .= ($only_new == ONLY_NEW_TOPICS) ? " + AND t.topic_time > $lastvisit +" : ''; +$join_t_type = ($only_new == ONLY_NEW_TOPICS) ? 'INNER JOIN' : 'LEFT JOIN'; + +$sql = " + SELECT f.cat_id, f.forum_id, f.forum_status, f.forum_parent, f.show_on_index, + p.post_id AS last_post_id, p.post_time AS last_post_time, + t.topic_id AS last_topic_id, t.topic_title AS last_topic_title, + u.user_id AS last_post_user_id, u.user_rank AS last_post_user_rank, + IF(p.poster_id = $anon, p.post_username, u.username) AS last_post_username + FROM " . BB_CATEGORIES . ' c + INNER JOIN ' . BB_FORUMS . " f ON($forums_join_sql) + $join_p_type " . BB_POSTS . " p ON($posts_join_sql) + $join_t_type " . BB_TOPICS . " t ON($topics_join_sql) + LEFT JOIN " . BB_USERS . ' u ON(u.user_id = p.poster_id) + ORDER BY c.cat_order, f.forum_order +'; + +$replace_in_parent = [ + 'last_post_id', + 'last_post_time', + 'last_post_user_id', + 'last_post_username', + 'last_post_user_rank', + 'last_topic_title', + 'last_topic_id' +]; + +$cache_name = 'index_sql_' . hash('xxh128', $sql); +if (!$cat_forums = CACHE('bb_cache')->get($cache_name)) { + $cat_forums = []; + foreach (DB()->fetch_rowset($sql) as $row) { + if (!($cat_id = $row['cat_id']) || !($forum_id = $row['forum_id'])) { + continue; + } + + if ($parent_id = $row['forum_parent']) { + if (!$parent =& $cat_forums[$cat_id]['f'][$parent_id]) { + $parent = $forums['f'][$parent_id]; + $parent['last_post_time'] = 0; + } + if ($row['last_post_time'] > $parent['last_post_time']) { + foreach ($replace_in_parent as $key) { + $parent[$key] = $row[$key]; + } + } + if ($show_subforums && $row['show_on_index']) { + $parent['last_sf_id'] = $forum_id; + } else { + continue; + } + } else { + $f =& $forums['f'][$forum_id]; + $row['forum_desc'] = $f['forum_desc']; + $row['forum_posts'] = $f['forum_posts']; + $row['forum_topics'] = $f['forum_topics']; + } + $cat_forums[$cat_id]['f'][$forum_id] = $row; + } + CACHE('bb_cache')->set($cache_name, $cat_forums, 180); + unset($row, $forums); + $datastore->rm('cat_forums'); +} + +// Obtain list of moderators +$moderators = []; +$mod = $datastore->get('moderators'); +if ($mod === false) { + $datastore->update('moderators'); + $mod = $datastore->get('moderators'); +} + +if (!empty($mod)) { + foreach ($mod['mod_users'] as $forum_id => $user_ids) { + foreach ($user_ids as $user_id) { + $moderators[$forum_id][] = '' . $mod['name_users'][$user_id] . ''; + } + } + foreach ($mod['mod_groups'] as $forum_id => $group_ids) { + foreach ($group_ids as $group_id) { + $moderators[$forum_id][] = '' . $mod['name_groups'][$group_id] . ''; + } + } +} + +unset($mod); +$datastore->rm('moderators'); + +// Build index page +$forums_count = 0; +foreach ($cat_forums as $cid => $c) { + $template->assign_block_vars('h_c', [ + 'H_C_ID' => $cid, + 'H_C_TITLE' => $cat_title_html[$cid], + 'H_C_CHEKED' => in_array($cid, preg_split('/[-]+/', $hide_cat_opt)) ? 'checked' : '', + ]); + + $template->assign_vars(['H_C_AL_MESS' => $hide_cat_opt && !$showhide]); + + if (!$showhide && isset($hide_cat_user[$cid]) && !$viewcat) { + continue; + } + + $template->assign_block_vars('c', [ + 'CAT_ID' => $cid, + 'CAT_TITLE' => $cat_title_html[$cid], + 'U_VIEWCAT' => CAT_URL . $cid, + ]); + + foreach ($c['f'] as $fid => $f) { + if (!$fname_html =& $forum_name_html[$fid]) { + continue; + } + $is_sf = $f['forum_parent']; + + $forums_count++; + $new = is_unread($f['last_post_time'], $f['last_topic_id'], $f['forum_id']) ? '_new' : ''; + $folder_image = $is_sf ? $images["icon_minipost{$new}"] : $images["forum{$new}"]; + + if ($f['forum_status'] == FORUM_LOCKED) { + $folder_image = $is_sf ? $images['icon_minipost'] : $images['forum_locked']; + } + + if ($is_sf) { + $template->assign_block_vars('c.f.sf', [ + 'SF_ID' => $fid, + 'SF_NAME' => $fname_html, + 'SF_NEW' => $new ? ' new' : '' + ]); + continue; + } + + $template->assign_block_vars('c.f', [ + 'FORUM_FOLDER_IMG' => $folder_image, + 'FORUM_ID' => $fid, + 'FORUM_NAME' => $fname_html, + 'FORUM_DESC' => $f['forum_desc'], + 'POSTS' => commify($f['forum_posts']), + 'TOPICS' => commify($f['forum_topics']), + 'LAST_SF_ID' => $f['last_sf_id'] ?? null, + 'MODERATORS' => isset($moderators[$fid]) ? implode(', ', $moderators[$fid]) : '', + 'FORUM_FOLDER_ALT' => $new ? $lang['NEW'] : $lang['OLD'] + ]); + + if ($f['last_post_id']) { + $template->assign_block_vars('c.f.last', [ + 'LAST_TOPIC_ID' => $f['last_topic_id'], + 'LAST_TOPIC_TIP' => $f['last_topic_title'], + 'LAST_TOPIC_TITLE' => str_short($f['last_topic_title'], $last_topic_max_len), + 'LAST_POST_TIME' => bb_date($f['last_post_time'], config()->get('last_post_date_format')), + 'LAST_POST_USER' => profile_url(['username' => str_short($f['last_post_username'], 15), 'user_id' => $f['last_post_user_id'], 'user_rank' => $f['last_post_user_rank']]), + ]); + } + } +} + +$template->assign_vars([ + 'SHOW_FORUMS' => $forums_count, + 'SHOW_MAP' => isset($_GET['map']) && !IS_GUEST, + 'PAGE_TITLE' => $viewcat ? $cat_title_html[$viewcat] : $lang['HOME'], + 'NO_FORUMS_MSG' => $only_new ? $lang['NO_NEW_POSTS'] : $lang['NO_FORUMS'], + + 'TOTAL_TOPICS' => sprintf($lang['POSTED_TOPICS_TOTAL'], $stats['topiccount']), + 'TOTAL_POSTS' => sprintf($lang['POSTED_ARTICLES_TOTAL'], $stats['postcount']), + 'TOTAL_USERS' => sprintf($lang['REGISTERED_USERS_TOTAL'], $stats['usercount']), + 'TOTAL_GENDER' => config()->get('gender') ? sprintf( + $lang['USERS_TOTAL_GENDER'], + $stats['male'], + $stats['female'], + $stats['unselect'] + ) : '', + 'NEWEST_USER' => sprintf($lang['NEWEST_USER'], profile_url($stats['newestuser'])), + + // Tracker stats + 'TORRENTS_STAT' => config()->get('tor_stats') ? sprintf( + $lang['TORRENTS_STAT'], + $stats['torrentcount'], + humn_size($stats['size']) + ) : '', + 'PEERS_STAT' => config()->get('tor_stats') ? sprintf( + $lang['PEERS_STAT'], + $stats['peers'], + $stats['seeders'], + $stats['leechers'] + ) : '', + 'SPEED_STAT' => config()->get('tor_stats') ? sprintf( + $lang['SPEED_STAT'], + humn_size($stats['speed']) . '/s' + ) : '', + 'SHOW_MOD_INDEX' => config()->get('show_mod_index'), + 'FORUM_IMG' => $images['forum'], + 'FORUM_NEW_IMG' => $images['forum_new'], + 'FORUM_LOCKED_IMG' => $images['forum_locked'], + + 'SHOW_ONLY_NEW_MENU' => true, + 'ONLY_NEW_POSTS_ON' => $only_new == ONLY_NEW_POSTS, + 'ONLY_NEW_TOPICS_ON' => $only_new == ONLY_NEW_TOPICS, + + 'U_SEARCH_NEW' => 'search.php?new=1', + 'U_SEARCH_SELF_BY_MY' => "search.php?uid={$userdata['user_id']}&o=1", + 'U_SEARCH_LATEST' => 'search.php?search_id=latest', + 'U_SEARCH_UNANSWERED' => 'search.php?search_id=unanswered', + 'U_ATOM_FEED' => is_file(config()->get('atom.path') . '/f/0.atom') ? make_url(config()->get('atom.url') . '/f/0.atom') : false, + + 'SHOW_LAST_TOPIC' => $show_last_topic, + 'BOARD_START' => config()->get('show_board_start_index') ? ($lang['BOARD_STARTED'] . ': ' . '' . bb_date(config()->get('board_startdate')) . '') : false, +]); + +// Set tpl vars for bt_userdata +if (config()->get('bt_show_dl_stat_on_index') && !IS_GUEST) { + show_bt_userdata($userdata['user_id']); +} + +// Latest news +if (config()->get('show_latest_news')) { + $latest_news = $datastore->get('latest_news'); + if ($latest_news === false) { + $datastore->update('latest_news'); + $latest_news = $datastore->get('latest_news'); + } + + $template->assign_vars(['SHOW_LATEST_NEWS' => true]); + + foreach ($latest_news as $news) { + if (in_array($news['forum_id'], $excluded_forums_array)) { + continue; + } + + $template->assign_block_vars('news', [ + 'NEWS_TOPIC_ID' => $news['topic_id'], + 'NEWS_TITLE' => str_short(censor()->censorString($news['topic_title']), config()->get('max_news_title')), + 'NEWS_TIME' => bb_date($news['topic_time'], 'd-M', false), + 'NEWS_IS_NEW' => is_unread($news['topic_time'], $news['topic_id'], $news['forum_id']), + ]); + } +} + +// Network news +if (config()->get('show_network_news')) { + $network_news = $datastore->get('network_news'); + if ($network_news === false) { + $datastore->update('network_news'); + $network_news = $datastore->get('network_news'); + } + + $template->assign_vars(['SHOW_NETWORK_NEWS' => true]); + + foreach ($network_news as $net) { + if (in_array($net['forum_id'], $excluded_forums_array)) { + continue; + } + + $template->assign_block_vars('net', [ + 'NEWS_TOPIC_ID' => $net['topic_id'], + 'NEWS_TITLE' => str_short(censor()->censorString($net['topic_title']), config()->get('max_net_title')), + 'NEWS_TIME' => bb_date($net['topic_time'], 'd-M', false), + 'NEWS_IS_NEW' => is_unread($net['topic_time'], $net['topic_id'], $net['forum_id']), + ]); + } +} + +if (config()->get('birthday_check_day') && config()->get('birthday_enabled')) { + $week_list = $today_list = []; + $week_all = $today_all = false; + + if (!empty($stats['birthday_week_list'])) { + shuffle($stats['birthday_week_list']); + foreach ($stats['birthday_week_list'] as $i => $week) { + if ($i >= 5) { + $week_all = true; + continue; + } + $week_list[] = profile_url($week) . ' (' . birthday_age(date('Y-m-d', strtotime('-1 year', strtotime($week['user_birthday'])))) . ')'; + } + $week_all = $week_all ? ' ...' : ''; + $week_list = sprintf($lang['BIRTHDAY_WEEK'], config()->get('birthday_check_day'), implode(', ', $week_list)) . $week_all; + } else { + $week_list = sprintf($lang['NOBIRTHDAY_WEEK'], config()->get('birthday_check_day')); + } + + if (!empty($stats['birthday_today_list'])) { + shuffle($stats['birthday_today_list']); + foreach ($stats['birthday_today_list'] as $i => $today) { + if ($i >= 5) { + $today_all = true; + continue; + } + $today_list[] = profile_url($today) . ' (' . birthday_age($today['user_birthday']) . ')'; + } + $today_all = $today_all ? ' ...' : ''; + $today_list = $lang['BIRTHDAY_TODAY'] . implode(', ', $today_list) . $today_all; + } else { + $today_list = $lang['NOBIRTHDAY_TODAY']; + } + + $template->assign_vars([ + 'WHOSBIRTHDAY_WEEK' => $week_list, + 'WHOSBIRTHDAY_TODAY' => $today_list + ]); +} + +// Allow cron +if (IS_AM) { + if (is_file(CRON_RUNNING)) { + if (is_file(CRON_ALLOWED)) { + unlink(CRON_ALLOWED); + } + rename(CRON_RUNNING, CRON_ALLOWED); + } +} + +// Display page +define('SHOW_ONLINE', $show_online_users); + +if (isset($_GET['map'])) { + $template->assign_vars(['PAGE_TITLE' => $lang['FORUM_MAP']]); +} + +print_page('index.tpl'); diff --git a/terms.php b/controllers/terms.php similarity index 82% rename from terms.php rename to controllers/terms.php index e3598073f..c4900eb24 100644 --- a/terms.php +++ b/controllers/terms.php @@ -9,7 +9,10 @@ define('BB_SCRIPT', 'terms'); -require __DIR__ . '/common.php'; +// Skip loading common.php if already loaded (when run through routing system) +if (!defined('IN_TORRENTPIER')) { + require __DIR__ . '/../common.php'; +} require INC_DIR . '/bbcode.php'; // Start session management diff --git a/index.php b/index.php index 2cf22e305..27192ad80 100644 --- a/index.php +++ b/index.php @@ -1,434 +1,35 @@ -get(Kernel::class); -$datastore->enqueue([ - 'stats', - 'moderators', - 'cat_forums' -]); +// Load web routes +$routesFile = __DIR__ . '/src/Presentation/Http/Routes/web.php'; +$kernel->loadRoutes($routesFile); -if (config()->get('show_latest_news')) { - $datastore->enqueue([ - 'latest_news' - ]); -} -if (config()->get('show_network_news')) { - $datastore->enqueue([ - 'network_news' - ]); -} - -// Init userdata -$user->session_start(); - -// Set meta description -$page_cfg['meta_description'] = config()->get('site_desc'); - -// Init main vars -$viewcat = isset($_GET[POST_CAT_URL]) ? (int)$_GET[POST_CAT_URL] : 0; -$lastvisit = IS_GUEST ? TIMENOW : $userdata['user_lastvisit']; - -// Caching output -$req_page = 'index_page'; -$req_page .= $viewcat ? "_c{$viewcat}" : ''; - -define('REQUESTED_PAGE', $req_page); -caching_output(IS_GUEST, 'send', REQUESTED_PAGE . '_guest_' . config()->get('default_lang')); - -$hide_cat_opt = isset($user->opt_js['h_cat']) ? (string)$user->opt_js['h_cat'] : 0; -$hide_cat_user = array_flip(explode('-', $hide_cat_opt)); -$showhide = isset($_GET['sh']) ? (int)$_GET['sh'] : 0; - -// Topics read tracks -$tracking_topics = get_tracks('topic'); -$tracking_forums = get_tracks('forum'); - -// Statistics -$stats = $datastore->get('stats'); -if ($stats === false) { - $datastore->update('stats'); - $stats = $datastore->get('stats'); -} - -// Forums data -$forums = $datastore->get('cat_forums'); -if ($forums === false) { - $datastore->update('cat_forums'); - $forums = $datastore->get('cat_forums'); -} -$cat_title_html = $forums['cat_title_html']; -$forum_name_html = $forums['forum_name_html']; - -$anon = GUEST_UID; -$excluded_forums_csv = $user->get_excluded_forums(AUTH_VIEW); -$excluded_forums_array = $excluded_forums_csv ? explode(',', $excluded_forums_csv) : []; -$only_new = $user->opt_js['only_new']; - -// Validate requested category id -if ($viewcat && !($viewcat =& $forums['c'][$viewcat]['cat_id'])) { - redirect('index.php'); -} - -// Forums -$forums_join_sql = 'f.cat_id = c.cat_id'; -$forums_join_sql .= $viewcat ? " - AND f.cat_id = $viewcat -" : ''; -$forums_join_sql .= $excluded_forums_csv ? " - AND f.forum_id NOT IN($excluded_forums_csv) - AND f.forum_parent NOT IN($excluded_forums_csv) -" : ''; - -// Posts -$posts_join_sql = 'p.post_id = f.forum_last_post_id'; -$posts_join_sql .= ($only_new == ONLY_NEW_POSTS) ? " - AND p.post_time > $lastvisit -" : ''; -$join_p_type = ($only_new == ONLY_NEW_POSTS) ? 'INNER JOIN' : 'LEFT JOIN'; - -// Topics -$topics_join_sql = 't.topic_last_post_id = p.post_id'; -$topics_join_sql .= ($only_new == ONLY_NEW_TOPICS) ? " - AND t.topic_time > $lastvisit -" : ''; -$join_t_type = ($only_new == ONLY_NEW_TOPICS) ? 'INNER JOIN' : 'LEFT JOIN'; - -$sql = " - SELECT f.cat_id, f.forum_id, f.forum_status, f.forum_parent, f.show_on_index, - p.post_id AS last_post_id, p.post_time AS last_post_time, - t.topic_id AS last_topic_id, t.topic_title AS last_topic_title, - u.user_id AS last_post_user_id, u.user_rank AS last_post_user_rank, - IF(p.poster_id = $anon, p.post_username, u.username) AS last_post_username - FROM " . BB_CATEGORIES . ' c - INNER JOIN ' . BB_FORUMS . " f ON($forums_join_sql) - $join_p_type " . BB_POSTS . " p ON($posts_join_sql) - $join_t_type " . BB_TOPICS . " t ON($topics_join_sql) - LEFT JOIN " . BB_USERS . ' u ON(u.user_id = p.poster_id) - ORDER BY c.cat_order, f.forum_order -'; - -$replace_in_parent = [ - 'last_post_id', - 'last_post_time', - 'last_post_user_id', - 'last_post_username', - 'last_post_user_rank', - 'last_topic_title', - 'last_topic_id' -]; - -$cache_name = 'index_sql_' . hash('xxh128', $sql); -if (!$cat_forums = CACHE('bb_cache')->get($cache_name)) { - $cat_forums = []; - foreach (DB()->fetch_rowset($sql) as $row) { - if (!($cat_id = $row['cat_id']) || !($forum_id = $row['forum_id'])) { - continue; - } - - if ($parent_id = $row['forum_parent']) { - if (!$parent =& $cat_forums[$cat_id]['f'][$parent_id]) { - $parent = $forums['f'][$parent_id]; - $parent['last_post_time'] = 0; - } - if ($row['last_post_time'] > $parent['last_post_time']) { - foreach ($replace_in_parent as $key) { - $parent[$key] = $row[$key]; - } - } - if ($show_subforums && $row['show_on_index']) { - $parent['last_sf_id'] = $forum_id; - } else { - continue; - } - } else { - $f =& $forums['f'][$forum_id]; - $row['forum_desc'] = $f['forum_desc']; - $row['forum_posts'] = $f['forum_posts']; - $row['forum_topics'] = $f['forum_topics']; - } - $cat_forums[$cat_id]['f'][$forum_id] = $row; - } - CACHE('bb_cache')->set($cache_name, $cat_forums, 180); - unset($row, $forums); - $datastore->rm('cat_forums'); -} - -// Obtain list of moderators -$moderators = []; -$mod = $datastore->get('moderators'); -if ($mod === false) { - $datastore->update('moderators'); - $mod = $datastore->get('moderators'); -} - -if (!empty($mod)) { - foreach ($mod['mod_users'] as $forum_id => $user_ids) { - foreach ($user_ids as $user_id) { - $moderators[$forum_id][] = '' . $mod['name_users'][$user_id] . ''; - } - } - foreach ($mod['mod_groups'] as $forum_id => $group_ids) { - foreach ($group_ids as $group_id) { - $moderators[$forum_id][] = '' . $mod['name_groups'][$group_id] . ''; - } - } -} - -unset($mod); -$datastore->rm('moderators'); - -// Build index page -$forums_count = 0; -foreach ($cat_forums as $cid => $c) { - $template->assign_block_vars('h_c', [ - 'H_C_ID' => $cid, - 'H_C_TITLE' => $cat_title_html[$cid], - 'H_C_CHEKED' => in_array($cid, preg_split('/[-]+/', $hide_cat_opt)) ? 'checked' : '', - ]); - - $template->assign_vars(['H_C_AL_MESS' => $hide_cat_opt && !$showhide]); - - if (!$showhide && isset($hide_cat_user[$cid]) && !$viewcat) { - continue; - } - - $template->assign_block_vars('c', [ - 'CAT_ID' => $cid, - 'CAT_TITLE' => $cat_title_html[$cid], - 'U_VIEWCAT' => CAT_URL . $cid, - ]); - - foreach ($c['f'] as $fid => $f) { - if (!$fname_html =& $forum_name_html[$fid]) { - continue; - } - $is_sf = $f['forum_parent']; - - $forums_count++; - $new = is_unread($f['last_post_time'], $f['last_topic_id'], $f['forum_id']) ? '_new' : ''; - $folder_image = $is_sf ? $images["icon_minipost{$new}"] : $images["forum{$new}"]; - - if ($f['forum_status'] == FORUM_LOCKED) { - $folder_image = $is_sf ? $images['icon_minipost'] : $images['forum_locked']; - } - - if ($is_sf) { - $template->assign_block_vars('c.f.sf', [ - 'SF_ID' => $fid, - 'SF_NAME' => $fname_html, - 'SF_NEW' => $new ? ' new' : '' - ]); - continue; - } - - $template->assign_block_vars('c.f', [ - 'FORUM_FOLDER_IMG' => $folder_image, - 'FORUM_ID' => $fid, - 'FORUM_NAME' => $fname_html, - 'FORUM_DESC' => $f['forum_desc'], - 'POSTS' => commify($f['forum_posts']), - 'TOPICS' => commify($f['forum_topics']), - 'LAST_SF_ID' => $f['last_sf_id'] ?? null, - 'MODERATORS' => isset($moderators[$fid]) ? implode(', ', $moderators[$fid]) : '', - 'FORUM_FOLDER_ALT' => $new ? $lang['NEW'] : $lang['OLD'] - ]); - - if ($f['last_post_id']) { - $template->assign_block_vars('c.f.last', [ - 'LAST_TOPIC_ID' => $f['last_topic_id'], - 'LAST_TOPIC_TIP' => $f['last_topic_title'], - 'LAST_TOPIC_TITLE' => str_short($f['last_topic_title'], $last_topic_max_len), - 'LAST_POST_TIME' => bb_date($f['last_post_time'], config()->get('last_post_date_format')), - 'LAST_POST_USER' => profile_url(['username' => str_short($f['last_post_username'], 15), 'user_id' => $f['last_post_user_id'], 'user_rank' => $f['last_post_user_rank']]), - ]); - } - } -} - -$template->assign_vars([ - 'SHOW_FORUMS' => $forums_count, - 'SHOW_MAP' => isset($_GET['map']) && !IS_GUEST, - 'PAGE_TITLE' => $viewcat ? $cat_title_html[$viewcat] : $lang['HOME'], - 'NO_FORUMS_MSG' => $only_new ? $lang['NO_NEW_POSTS'] : $lang['NO_FORUMS'], - - 'TOTAL_TOPICS' => sprintf($lang['POSTED_TOPICS_TOTAL'], $stats['topiccount']), - 'TOTAL_POSTS' => sprintf($lang['POSTED_ARTICLES_TOTAL'], $stats['postcount']), - 'TOTAL_USERS' => sprintf($lang['REGISTERED_USERS_TOTAL'], $stats['usercount']), - 'TOTAL_GENDER' => config()->get('gender') ? sprintf( - $lang['USERS_TOTAL_GENDER'], - $stats['male'], - $stats['female'], - $stats['unselect'] - ) : '', - 'NEWEST_USER' => sprintf($lang['NEWEST_USER'], profile_url($stats['newestuser'])), - - // Tracker stats - 'TORRENTS_STAT' => config()->get('tor_stats') ? sprintf( - $lang['TORRENTS_STAT'], - $stats['torrentcount'], - humn_size($stats['size']) - ) : '', - 'PEERS_STAT' => config()->get('tor_stats') ? sprintf( - $lang['PEERS_STAT'], - $stats['peers'], - $stats['seeders'], - $stats['leechers'] - ) : '', - 'SPEED_STAT' => config()->get('tor_stats') ? sprintf( - $lang['SPEED_STAT'], - humn_size($stats['speed']) . '/s' - ) : '', - 'SHOW_MOD_INDEX' => config()->get('show_mod_index'), - 'FORUM_IMG' => $images['forum'], - 'FORUM_NEW_IMG' => $images['forum_new'], - 'FORUM_LOCKED_IMG' => $images['forum_locked'], - - 'SHOW_ONLY_NEW_MENU' => true, - 'ONLY_NEW_POSTS_ON' => $only_new == ONLY_NEW_POSTS, - 'ONLY_NEW_TOPICS_ON' => $only_new == ONLY_NEW_TOPICS, - - 'U_SEARCH_NEW' => 'search.php?new=1', - 'U_SEARCH_SELF_BY_MY' => "search.php?uid={$userdata['user_id']}&o=1", - 'U_SEARCH_LATEST' => 'search.php?search_id=latest', - 'U_SEARCH_UNANSWERED' => 'search.php?search_id=unanswered', - 'U_ATOM_FEED' => is_file(config()->get('atom.path') . '/f/0.atom') ? make_url(config()->get('atom.url') . '/f/0.atom') : false, - - 'SHOW_LAST_TOPIC' => $show_last_topic, - 'BOARD_START' => config()->get('show_board_start_index') ? ($lang['BOARD_STARTED'] . ': ' . '' . bb_date(config()->get('board_startdate')) . '') : false, -]); - -// Set tpl vars for bt_userdata -if (config()->get('bt_show_dl_stat_on_index') && !IS_GUEST) { - show_bt_userdata($userdata['user_id']); -} - -// Latest news -if (config()->get('show_latest_news')) { - $latest_news = $datastore->get('latest_news'); - if ($latest_news === false) { - $datastore->update('latest_news'); - $latest_news = $datastore->get('latest_news'); - } - - $template->assign_vars(['SHOW_LATEST_NEWS' => true]); - - foreach ($latest_news as $news) { - if (in_array($news['forum_id'], $excluded_forums_array)) { - continue; - } - - $template->assign_block_vars('news', [ - 'NEWS_TOPIC_ID' => $news['topic_id'], - 'NEWS_TITLE' => str_short(censor()->censorString($news['topic_title']), config()->get('max_news_title')), - 'NEWS_TIME' => bb_date($news['topic_time'], 'd-M', false), - 'NEWS_IS_NEW' => is_unread($news['topic_time'], $news['topic_id'], $news['forum_id']), - ]); - } -} - -// Network news -if (config()->get('show_network_news')) { - $network_news = $datastore->get('network_news'); - if ($network_news === false) { - $datastore->update('network_news'); - $network_news = $datastore->get('network_news'); - } - - $template->assign_vars(['SHOW_NETWORK_NEWS' => true]); - - foreach ($network_news as $net) { - if (in_array($net['forum_id'], $excluded_forums_array)) { - continue; - } - - $template->assign_block_vars('net', [ - 'NEWS_TOPIC_ID' => $net['topic_id'], - 'NEWS_TITLE' => str_short(censor()->censorString($net['topic_title']), config()->get('max_net_title')), - 'NEWS_TIME' => bb_date($net['topic_time'], 'd-M', false), - 'NEWS_IS_NEW' => is_unread($net['topic_time'], $net['topic_id'], $net['forum_id']), - ]); - } -} - -if (config()->get('birthday_check_day') && config()->get('birthday_enabled')) { - $week_list = $today_list = []; - $week_all = $today_all = false; - - if (!empty($stats['birthday_week_list'])) { - shuffle($stats['birthday_week_list']); - foreach ($stats['birthday_week_list'] as $i => $week) { - if ($i >= 5) { - $week_all = true; - continue; - } - $week_list[] = profile_url($week) . ' (' . birthday_age(date('Y-m-d', strtotime('-1 year', strtotime($week['user_birthday'])))) . ')'; - } - $week_all = $week_all ? ' ...' : ''; - $week_list = sprintf($lang['BIRTHDAY_WEEK'], config()->get('birthday_check_day'), implode(', ', $week_list)) . $week_all; - } else { - $week_list = sprintf($lang['NOBIRTHDAY_WEEK'], config()->get('birthday_check_day')); - } - - if (!empty($stats['birthday_today_list'])) { - shuffle($stats['birthday_today_list']); - foreach ($stats['birthday_today_list'] as $i => $today) { - if ($i >= 5) { - $today_all = true; - continue; - } - $today_list[] = profile_url($today) . ' (' . birthday_age($today['user_birthday']) . ')'; - } - $today_all = $today_all ? ' ...' : ''; - $today_list = $lang['BIRTHDAY_TODAY'] . implode(', ', $today_list) . $today_all; - } else { - $today_list = $lang['NOBIRTHDAY_TODAY']; - } - - $template->assign_vars([ - 'WHOSBIRTHDAY_WEEK' => $week_list, - 'WHOSBIRTHDAY_TODAY' => $today_list - ]); -} - -// Allow cron -if (IS_AM) { - if (is_file(CRON_RUNNING)) { - if (is_file(CRON_ALLOWED)) { - unlink(CRON_ALLOWED); - } - rename(CRON_RUNNING, CRON_ALLOWED); - } -} - -// Display page -define('SHOW_ONLINE', $show_online_users); - -if (isset($_GET['map'])) { - $template->assign_vars(['PAGE_TITLE' => $lang['FORUM_MAP']]); -} - -print_page('index.tpl'); +// Handle the request and send response +$kernel->run(); diff --git a/library/includes/page_header.php b/library/includes/page_header.php index e3bdc3e12..46ccbe4a6 100644 --- a/library/includes/page_header.php +++ b/library/includes/page_header.php @@ -108,13 +108,16 @@ $template->assign_vars([ 'HAVE_UNREAD_PM' => $have_unread_pm ]); +// Defines the current displayed controller to hide frontend blocks +$bb_script = defined('BB_SCRIPT') ? BB_SCRIPT : null; + // The following assigns all _common_ variables that may be used at any point in a template $template->assign_vars([ 'SIMPLE_HEADER' => !empty($gen_simple_header), 'CONTENT_ENCODING' => DEFAULT_CHARSET, 'IN_ADMIN' => defined('IN_ADMIN'), - 'USER_HIDE_CAT' => (BB_SCRIPT == 'index'), + 'USER_HIDE_CAT' => ($bb_script == 'index'), 'USER_LANG' => $userdata['user_lang'], @@ -170,8 +173,8 @@ $template->assign_vars([ 'U_TERMS' => config()->get('terms_and_conditions_url'), 'U_TRACKER' => 'tracker.php', - 'SHOW_SIDEBAR1' => !empty(config()->get('page.show_sidebar1')[BB_SCRIPT]) || config()->get('show_sidebar1_on_every_page'), - 'SHOW_SIDEBAR2' => !empty(config()->get('page.show_sidebar2')[BB_SCRIPT]) || config()->get('show_sidebar2_on_every_page'), + 'SHOW_SIDEBAR1' => !empty(config()->get('page.show_sidebar1')[$bb_script]) || config()->get('show_sidebar1_on_every_page'), + 'SHOW_SIDEBAR2' => !empty(config()->get('page.show_sidebar2')[$bb_script]) || config()->get('show_sidebar2_on_every_page'), 'HTML_AGREEMENT' => LANG_DIR . 'html/user_agreement.html', 'HTML_COPYRIGHT' => LANG_DIR . 'html/copyright_holders.html', @@ -208,7 +211,7 @@ $template->assign_vars([ 'U_WATCHED_TOPICS' => 'profile.php?mode=watch' ]); -if (!empty(config()->get('page.show_torhelp')[BB_SCRIPT]) && !empty($userdata['torhelp'])) { +if (!empty(config()->get('page.show_torhelp')[$bb_script]) && !empty($userdata['torhelp'])) { $ignore_time = !empty($_COOKIE['torhelp']) ? (int)$_COOKIE['torhelp'] : 0; if (TIMENOW > $ignore_time) { diff --git a/phinx.php b/phinx.php index 880494b10..c3f182cc2 100644 --- a/phinx.php +++ b/phinx.php @@ -23,13 +23,15 @@ if (file_exists(__DIR__ . '/.env')) { } // Helper function for environment variables -function env(string $key, mixed $default = null): mixed -{ - $value = $_ENV[$key] ?? getenv($key); - if ($value === false) { - return $default; +if (!function_exists('env')) { + function env(string $key, mixed $default = null): mixed + { + $value = $_ENV[$key] ?? getenv($key); + if ($value === false) { + return $default; + } + return $value; } - return $value; } return [ diff --git a/src/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitions.php b/src/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitions.php index ee64f60a1..b5c4efed4 100644 --- a/src/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitions.php +++ b/src/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitions.php @@ -11,12 +11,27 @@ use Nette\Caching\Storages\MemcachedStorage; use Nette\Caching\Storages\SQLiteStorage; use Nette\Database\Connection; use Psr\Container\ContainerInterface; +use TorrentPier\Config; class InfrastructureDefinitions { public static function getDefinitions(array $config = []): array { return [ + // Configuration + Config::class => DI\factory(function () { + // Use the config singleton that's already initialized in common.php + return config(); + }), + + // HTTP Infrastructure + 'TorrentPier\Infrastructure\Http\Router' => DI\autowire(), + 'TorrentPier\Infrastructure\Http\RequestFactory' => DI\autowire(), + 'TorrentPier\Infrastructure\Http\ResponseFactory' => DI\autowire(), + + // Middleware + 'TorrentPier\Infrastructure\Http\Middleware\CorsMiddleware' => DI\autowire(), + // TODO: Add infrastructure service definitions as they are implemented // Example: Database Connection (implement when Nette Database integration is ready) diff --git a/src/Infrastructure/DependencyInjection/Definitions/PresentationDefinitions.php b/src/Infrastructure/DependencyInjection/Definitions/PresentationDefinitions.php index 78a7e0566..39af7d211 100644 --- a/src/Infrastructure/DependencyInjection/Definitions/PresentationDefinitions.php +++ b/src/Infrastructure/DependencyInjection/Definitions/PresentationDefinitions.php @@ -12,8 +12,13 @@ class PresentationDefinitions public static function getDefinitions(): array { return [ + // HTTP Kernel + 'TorrentPier\Presentation\Http\Kernel' => DI\autowire(), + // HTTP Controllers // Controllers are typically autowired with their dependencies + 'TorrentPier\Presentation\Http\Controllers\Web\HelloWorldController' => DI\autowire(), + 'TorrentPier\Presentation\Http\Controllers\Web\LegacyController' => DI\autowire(), // Web Controllers // 'TorrentPier\Presentation\Http\Controllers\Web\HomeController' => DI\autowire(), diff --git a/src/Infrastructure/Http/Middleware/BaseMiddleware.php b/src/Infrastructure/Http/Middleware/BaseMiddleware.php new file mode 100644 index 000000000..96184dcb4 --- /dev/null +++ b/src/Infrastructure/Http/Middleware/BaseMiddleware.php @@ -0,0 +1,30 @@ +before($request); + $response = $handler->handle($request); + return $this->after($request, $response); + } + + protected function before(ServerRequestInterface $request): ServerRequestInterface + { + return $request; + } + + protected function after(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + return $response; + } +} diff --git a/src/Infrastructure/Http/Middleware/CorsMiddleware.php b/src/Infrastructure/Http/Middleware/CorsMiddleware.php new file mode 100644 index 000000000..a2ec4d43a --- /dev/null +++ b/src/Infrastructure/Http/Middleware/CorsMiddleware.php @@ -0,0 +1,58 @@ +allowedOrigins = $allowedOrigins; + $this->allowedHeaders = $allowedHeaders; + $this->allowedMethods = $allowedMethods; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($request->getMethod() === 'OPTIONS') { + return $this->createPreflightResponse(); + } + + $response = $handler->handle($request); + return $this->addCorsHeaders($response, $request); + } + + private function createPreflightResponse(): ResponseInterface + { + $response = new \GuzzleHttp\Psr7\Response(200); + return $this->addCorsHeaders($response); + } + + private function addCorsHeaders(ResponseInterface $response, ?ServerRequestInterface $request = null): ResponseInterface + { + $origin = $request ? $request->getHeaderLine('Origin') : ''; + + if (in_array('*', $this->allowedOrigins) || in_array($origin, $this->allowedOrigins)) { + $response = $response->withHeader('Access-Control-Allow-Origin', $origin ?: '*'); + } + + $response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $this->allowedMethods)); + $response = $response->withHeader('Access-Control-Allow-Headers', implode(', ', $this->allowedHeaders)); + $response = $response->withHeader('Access-Control-Max-Age', '86400'); + + return $response; + } +} diff --git a/src/Infrastructure/Http/RequestFactory.php b/src/Infrastructure/Http/RequestFactory.php new file mode 100644 index 000000000..152533037 --- /dev/null +++ b/src/Infrastructure/Http/RequestFactory.php @@ -0,0 +1,28 @@ +withProtocolVersion($protocolVersion); + } + + public static function json( + array $data, + int $status = 200, + array $headers = [], + int $encodingOptions = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT + ): ResponseInterface + { + $json = json_encode($data, $encodingOptions); + $headers['content-type'] = 'application/json; charset=utf-8'; + return new Response($status, $headers, $json); + } + + public static function html( + string $html, + int $status = 200, + array $headers = [] + ): ResponseInterface + { + $headers['content-type'] = 'text/html; charset=utf-8'; + return new Response($status, $headers, $html); + } + + public static function redirect( + string $uri, + int $status = 302, + array $headers = [] + ): ResponseInterface + { + $headers['location'] = $uri; + return new Response($status, $headers); + } +} diff --git a/src/Infrastructure/Http/Router.php b/src/Infrastructure/Http/Router.php new file mode 100644 index 000000000..00761b395 --- /dev/null +++ b/src/Infrastructure/Http/Router.php @@ -0,0 +1,91 @@ +container = $container; + $this->router = new LeagueRouter(); + + // Set up the application strategy with DI container + $strategy = new ApplicationStrategy(); + $strategy->setContainer($this->container->getWrappedContainer()); + $this->router->setStrategy($strategy); + } + + public function get(string $path, $handler): \League\Route\Route + { + return $this->router->map('GET', $path, $handler); + } + + public function post(string $path, $handler): \League\Route\Route + { + return $this->router->map('POST', $path, $handler); + } + + public function put(string $path, $handler): \League\Route\Route + { + return $this->router->map('PUT', $path, $handler); + } + + public function patch(string $path, $handler): \League\Route\Route + { + return $this->router->map('PATCH', $path, $handler); + } + + public function delete(string $path, $handler): \League\Route\Route + { + return $this->router->map('DELETE', $path, $handler); + } + + public function options(string $path, $handler): \League\Route\Route + { + return $this->router->map('OPTIONS', $path, $handler); + } + + public function head(string $path, $handler): \League\Route\Route + { + return $this->router->map('HEAD', $path, $handler); + } + + public function any(string $path, $handler): \League\Route\Route + { + return $this->router->map(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], $path, $handler); + } + + public function group(string $prefix, callable $callback): RouteGroup + { + return $this->router->group($prefix, $callback); + } + + public function middleware(MiddlewareInterface $middleware): self + { + $this->router->middleware($middleware); + return $this; + } + + public function dispatch(ServerRequestInterface $request): ResponseInterface + { + return $this->router->dispatch($request); + } + + public function getLeagueRouter(): LeagueRouter + { + return $this->router; + } +} diff --git a/src/Presentation/Http/Controllers/Web/HelloWorldController.php b/src/Presentation/Http/Controllers/Web/HelloWorldController.php new file mode 100644 index 000000000..a4117d3d4 --- /dev/null +++ b/src/Presentation/Http/Controllers/Web/HelloWorldController.php @@ -0,0 +1,108 @@ +config = $config; + } + + public function index(ServerRequestInterface $request): ResponseInterface + { + $siteName = $this->config->get('sitename', 'TorrentPier'); + $currentTime = date('Y-m-d H:i:s'); + + $html = " + + + + + + Hello World - {$siteName} + + + +
+

Hello World from {$siteName}!

+

This is a test route demonstrating the new hexagonal architecture routing system.

+ +
+ Route Information:
+ URI: {$request->getUri()}
+ Method: {$request->getMethod()}
+ Time: {$currentTime}
+ Controller: HelloWorldController +
+ +

The routing system is working correctly! This response was generated using:

+ + +

← Back to main site

+
+ + "; + + return ResponseFactory::html($html); + } + + public function json(ServerRequestInterface $request): ResponseInterface + { + $siteName = $this->config->get('sitename', 'TorrentPier'); + + return ResponseFactory::json([ + 'message' => 'Hello World!', + 'site' => $siteName, + 'timestamp' => time(), + 'datetime' => date('c'), + 'route' => [ + 'uri' => (string)$request->getUri(), + 'method' => $request->getMethod(), + 'controller' => self::class, + ], + 'architecture' => [ + 'pattern' => 'Hexagonal Architecture', + 'router' => 'League/Route', + 'psr' => 'PSR-7 HTTP Messages', + 'di' => 'PHP-DI Container' + ] + ]); + } +} diff --git a/src/Presentation/Http/Controllers/Web/LegacyController.php b/src/Presentation/Http/Controllers/Web/LegacyController.php new file mode 100644 index 000000000..2d6ddc49e --- /dev/null +++ b/src/Presentation/Http/Controllers/Web/LegacyController.php @@ -0,0 +1,104 @@ +config = $config; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + // Get the legacy controller name from the URL path + $path = $request->getUri()->getPath(); + $controller = null; + + // Extract controller name from .php files or without extension + if (preg_match('/\/([^\/]+)\.php$/', $path, $matches)) { + // URL like /terms.php + $controller = $matches[1]; + } elseif (preg_match('/\/([^\/]+)$/', $path, $matches)) { + // URL like /terms (without extension) + $controller = $matches[1]; + } elseif ($path === '/') { + // Root path should serve index.php + $controller = 'index'; + } + + if (!$controller) { + return ResponseFactory::html('Legacy controller not specified', 404); + } + + $rootPath = dirname(__DIR__, 5); + $controllerPath = $rootPath . '/controllers/' . $controller . '.php'; + + if (!file_exists($controllerPath)) { + return ResponseFactory::html( + "

404 - Not Found

Legacy controller '{$controller}' not found

", + 404 + ); + } + + // Capture the legacy controller output + $output = ''; + $originalObLevel = ob_get_level(); + + try { + ob_start(); + + // Save current globals state (in case they're modified) + $originalServer = $_SERVER; + $originalGet = $_GET; + $originalPost = $_POST; + + // Make legacy globals available in the included file's scope + global $bb_cfg, $config, $user, $template, $datastore, $lang, $userdata, $userinfo, $images; + + // Include the legacy controller + // Note: We don't use require_once to allow multiple includes if needed + include $controllerPath; + + // Get the captured output - make sure we only clean our own buffer + $output = ob_get_clean(); + + // Restore globals if needed + $_SERVER = $originalServer; + $_GET = $originalGet; + $_POST = $originalPost; + + // Return the output as HTML response + return ResponseFactory::html($output); + + } catch (\Throwable $e) { + // Clean up any extra output buffers that were started, but preserve original level + while (ob_get_level() > $originalObLevel) { + ob_end_clean(); + } + + // Return error response + $errorHtml = " +

Legacy Controller Error

+

Controller: {$controller}

+

Error: " . htmlspecialchars($e->getMessage()) . "

+

File: " . htmlspecialchars($e->getFile()) . ":" . $e->getLine() . "

+ "; + + if (function_exists('dev') && dev()->isDebugEnabled()) { + $errorHtml .= "
" . htmlspecialchars($e->getTraceAsString()) . "
"; + } + + return ResponseFactory::html($errorHtml, 500); + } + } +} diff --git a/src/Presentation/Http/Kernel.php b/src/Presentation/Http/Kernel.php new file mode 100644 index 000000000..60c069946 --- /dev/null +++ b/src/Presentation/Http/Kernel.php @@ -0,0 +1,152 @@ +container = $container; + $this->router = $container->get(Router::class); + } + + public function addMiddleware(MiddlewareInterface $middleware): self + { + $this->middleware[] = $middleware; + return $this; + } + + public function loadRoutes(string $routesFile): self + { + if (!file_exists($routesFile)) { + throw new \RuntimeException("Routes file not found: {$routesFile}"); + } + + $routeLoader = require $routesFile; + if (is_callable($routeLoader)) { + $routeLoader($this->router); + } + + return $this; + } + + public function handle(?ServerRequestInterface $request = null): ResponseInterface + { + try { + $request = $request ?: RequestFactory::fromGlobals(); + + // Apply middleware to router + foreach ($this->middleware as $middleware) { + $this->router->middleware($middleware); + } + + return $this->router->dispatch($request); + + } catch (NotFoundException $e) { + return ResponseFactory::html( + '

404 - Not Found

The requested page could not be found.

', + 404 + ); + } catch (MethodNotAllowedException $e) { + return ResponseFactory::html( + '

405 - Method Not Allowed

The request method is not allowed for this endpoint.

', + 405 + ); + } catch (Throwable $e) { + return $this->handleException($e, $request); + } + } + + public function run(?ServerRequestInterface $request = null): void + { + $response = $this->handle($request); + $this->sendResponse($response); + } + + private function handleException(Throwable $e, ?ServerRequestInterface $request = null): ResponseInterface + { + // TODO: Replace bb_log() with injected PSR-3 LoggerInterface + // This is a temporary coupling to the legacy logging system + if (function_exists('bb_log')) { + bb_log($e->getMessage() . "\n" . $e->getTraceAsString(), 'kernel_errors'); + } + + // TODO: Replace legacy dev() singleton with injected EnvironmentInterface + // This is a temporary coupling to the legacy system during the migration period + // Once all legacy controllers are migrated, inject a proper debug/environment service + if (function_exists('dev') && dev()->isDebugEnabled()) { + $html = " + + + + Application Error + + + +
+

Application Error

+

Message: " . htmlspecialchars($e->getMessage()) . "

+

File: " . htmlspecialchars($e->getFile()) . ":" . $e->getLine() . "

+
+ Stack Trace: +
" . htmlspecialchars($e->getTraceAsString()) . "
+
+
+ + "; + + return ResponseFactory::html($html, 500); + } + + // Production error response + return ResponseFactory::html(' + + + Server Error + +

500 - Internal Server Error

+

Something went wrong. Please try again later.

+ + + ', 500); + } + + private function sendResponse(ResponseInterface $response): void + { + // Send status line + http_response_code($response->getStatusCode()); + + // Send headers + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header("{$name}: {$value}", false); + } + } + + // Send body + echo $response->getBody()->getContents(); + } +} diff --git a/src/Presentation/Http/Routes/web.php b/src/Presentation/Http/Routes/web.php new file mode 100644 index 000000000..f006ebd24 --- /dev/null +++ b/src/Presentation/Http/Routes/web.php @@ -0,0 +1,37 @@ +get('/hello', [HelloWorldController::class, 'index']); + $router->get('/hello/json', [HelloWorldController::class, 'json']); + + // Legacy controller routes (hacky but organized approach) + $legacyRoutes = [ + 'index.php', + 'terms.php', + // Add more legacy controllers here as needed: + // 'login.php', + // 'search.php', + // 'tracker.php', + ]; + + foreach ($legacyRoutes as $route) { + // Route with .php extension + $router->get('/' . $route, [LegacyController::class, 'handle']); + + // Route without .php extension (e.g., /terms for /terms.php) + $routeWithoutExtension = str_replace('.php', '', $route); + $router->get('/' . $routeWithoutExtension, [LegacyController::class, 'handle']); + } + + // Root route should serve the legacy index.php controller + $router->get('/', [LegacyController::class, 'handle']); + + // Future modern routes will be added here +}; diff --git a/tests/Unit/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitionsTest.php b/tests/Unit/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitionsTest.php index 64ba55cee..8a24c6a1b 100644 --- a/tests/Unit/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitionsTest.php +++ b/tests/Unit/Infrastructure/DependencyInjection/Definitions/InfrastructureDefinitionsTest.php @@ -15,12 +15,16 @@ describe('InfrastructureDefinitions', function () { expect($definitions)->toBeArray(); }); - it('returns empty array when no infrastructure services are implemented yet', function () { + it('returns infrastructure service definitions', function () { $definitions = InfrastructureDefinitions::getDefinitions(); - // Since we're in Phase 1 and infrastructure services aren't implemented yet, - // the definitions should be empty (all examples are commented out) - expect($definitions)->toBe([]); + // Should contain HTTP infrastructure services that have been implemented + expect($definitions)->toBeArray(); + expect($definitions)->toHaveKey('TorrentPier\Config'); + expect($definitions)->toHaveKey('TorrentPier\Infrastructure\Http\Router'); + expect($definitions)->toHaveKey('TorrentPier\Infrastructure\Http\RequestFactory'); + expect($definitions)->toHaveKey('TorrentPier\Infrastructure\Http\ResponseFactory'); + expect($definitions)->toHaveKey('TorrentPier\Infrastructure\Http\Middleware\CorsMiddleware'); }); it('follows infrastructure layer principles', function () { @@ -43,7 +47,9 @@ describe('InfrastructureDefinitions', function () { $definitions1 = InfrastructureDefinitions::getDefinitions(); $definitions2 = InfrastructureDefinitions::getDefinitions(); - expect($definitions1)->toBe($definitions2); + // Should return same structure (though objects may be different instances) + expect(array_keys($definitions1))->toBe(array_keys($definitions2)); + expect(count($definitions1))->toBe(count($definitions2)); }); it('can handle different configurations', function () { @@ -79,7 +85,8 @@ describe('InfrastructureDefinitions', function () { // Connection::class => DI\get('database.connection.default'), // For now, verify the method works without breaking - expect(count($definitions))->toBeGreaterThanOrEqual(0); + // Should at least contain the HTTP infrastructure services + expect(count($definitions))->toBeGreaterThanOrEqual(5); }); it('is prepared for future cache services', function () { diff --git a/tests/Unit/Infrastructure/DependencyInjection/Definitions/PresentationDefinitionsTest.php b/tests/Unit/Infrastructure/DependencyInjection/Definitions/PresentationDefinitionsTest.php index b751b70ba..3c950ce25 100644 --- a/tests/Unit/Infrastructure/DependencyInjection/Definitions/PresentationDefinitionsTest.php +++ b/tests/Unit/Infrastructure/DependencyInjection/Definitions/PresentationDefinitionsTest.php @@ -9,12 +9,14 @@ describe('PresentationDefinitions', function () { expect($definitions)->toBeArray(); }); - it('returns empty array when no presentation services are implemented yet', function () { + it('returns presentation service definitions', function () { $definitions = PresentationDefinitions::getDefinitions(); - // Since we're in Phase 1 and presentation services aren't implemented yet, - // the definitions should be empty (all examples are commented out) - expect($definitions)->toBe([]); + // Should contain HTTP presentation services that have been implemented + expect($definitions)->toBeArray(); + expect($definitions)->toHaveKey('TorrentPier\Presentation\Http\Kernel'); + expect($definitions)->toHaveKey('TorrentPier\Presentation\Http\Controllers\Web\HelloWorldController'); + expect($definitions)->toHaveKey('TorrentPier\Presentation\Http\Controllers\Web\LegacyController'); }); it('follows presentation layer principles', function () { @@ -37,7 +39,9 @@ describe('PresentationDefinitions', function () { $definitions1 = PresentationDefinitions::getDefinitions(); $definitions2 = PresentationDefinitions::getDefinitions(); - expect($definitions1)->toBe($definitions2); + // Should return same structure (though objects may be different instances) + expect(array_keys($definitions1))->toBe(array_keys($definitions2)); + expect(count($definitions1))->toBe(count($definitions2)); }); it('is prepared for future HTTP controllers', function () { diff --git a/tests/Unit/Infrastructure/Http/RouterTest.php b/tests/Unit/Infrastructure/Http/RouterTest.php new file mode 100644 index 000000000..921fc5b2c --- /dev/null +++ b/tests/Unit/Infrastructure/Http/RouterTest.php @@ -0,0 +1,251 @@ +container = new Container($diContainer); + + // Create router instance + $this->router = new Router($this->container); + }); + + describe('route registration', function () { + it('registers GET routes', function () { + $this->router->get('/test', function () { + return new Response(200, [], 'GET test'); + }); + + $request = new ServerRequest('GET', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('GET test'); + }); + + it('registers POST routes', function () { + $this->router->post('/test', function () { + return new Response(200, [], 'POST test'); + }); + + $request = new ServerRequest('POST', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('POST test'); + }); + + it('registers PUT routes', function () { + $this->router->put('/test', function () { + return new Response(200, [], 'PUT test'); + }); + + $request = new ServerRequest('PUT', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('PUT test'); + }); + + it('registers PATCH routes', function () { + $this->router->patch('/test', function () { + return new Response(200, [], 'PATCH test'); + }); + + $request = new ServerRequest('PATCH', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('PATCH test'); + }); + + it('registers DELETE routes', function () { + $this->router->delete('/test', function () { + return new Response(200, [], 'DELETE test'); + }); + + $request = new ServerRequest('DELETE', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('DELETE test'); + }); + + it('registers HEAD routes', function () { + $this->router->head('/test', function () { + return new Response(200); + }); + + $request = new ServerRequest('HEAD', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + }); + + it('registers OPTIONS routes', function () { + $this->router->options('/test', function () { + return new Response(200, ['Allow' => 'GET, POST, OPTIONS']); + }); + + $request = new ServerRequest('OPTIONS', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect($response->getHeader('Allow'))->toBe(['GET, POST, OPTIONS']); + }); + }); + + describe('route parameters', function () { + it('captures route parameters', function () { + $this->router->get('/user/{id}', function ($request, $args) { + return new Response(200, [], 'User ID: ' . $args['id']); + }); + + $request = new ServerRequest('GET', '/user/123'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('User ID: 123'); + }); + + it('captures multiple route parameters', function () { + $this->router->get('/post/{year}/{month}/{slug}', function ($request, $args) { + return new Response(200, [], sprintf( + 'Post: %s/%s/%s', + $args['year'], + $args['month'], + $args['slug'] + )); + }); + + $request = new ServerRequest('GET', '/post/2024/06/hello-world'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('Post: 2024/06/hello-world'); + }); + }); + + describe('controller resolution', function () { + it('resolves controller from DI container', function () { + // Create a test controller + $testController = new class { + public function index() + { + return new Response(200, [], 'Controller response'); + } + }; + + // Register controller in container using the wrapped DI container + $this->container->getWrappedContainer()->set('TestController', $testController); + + // Register route with controller + $this->router->get('/test', ['TestController', 'index']); + + $request = new ServerRequest('GET', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('Controller response'); + }); + + it('resolves controller by class name', function () { + // Define a test controller class + $controllerClass = 'TestControllerClass' . uniqid(); + eval(" + class $controllerClass { + public function handle() { + return new \GuzzleHttp\Psr7\Response(200, [], 'Class-based controller'); + } + } + "); + + $this->router->get('/test', [$controllerClass, 'handle']); + + $request = new ServerRequest('GET', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('Class-based controller'); + }); + }); + + describe('middleware support', function () { + it('applies middleware to routes', function () { + // Create a simple middleware that implements PSR-15 + $middleware = new class implements \Psr\Http\Server\MiddlewareInterface { + public function process( + \Psr\Http\Message\ServerRequestInterface $request, + \Psr\Http\Server\RequestHandlerInterface $handler + ): \Psr\Http\Message\ResponseInterface + { + $response = $handler->handle($request); + return $response->withHeader('X-Middleware', 'Applied'); + } + }; + + $this->router->middleware($middleware) + ->get('/test', function () { + return new Response(200, [], 'With middleware'); + }); + + $request = new ServerRequest('GET', '/test'); + $response = $this->router->dispatch($request); + + expect($response->getStatusCode())->toBe(200); + expect($response->getHeader('X-Middleware'))->toBe(['Applied']); + expect((string)$response->getBody())->toBe('With middleware'); + }); + }); + + describe('error handling', function () { + it('throws NotFoundException for undefined routes', function () { + $request = new ServerRequest('GET', '/undefined'); + + expect(fn() => $this->router->dispatch($request)) + ->toThrow(League\Route\Http\Exception\NotFoundException::class); + }); + + it('throws MethodNotAllowedException for wrong HTTP method', function () { + $this->router->post('/test', function () { + return new Response(200); + }); + + $request = new ServerRequest('GET', '/test'); + + expect(fn() => $this->router->dispatch($request)) + ->toThrow(League\Route\Http\Exception\MethodNotAllowedException::class); + }); + }); + + describe('route groups', function () { + it('supports route groups with prefix', function () { + $this->router->group('/api', function ($router) { + $router->get('/users', function () { + return new Response(200, [], 'Users list'); + }); + $router->get('/posts', function () { + return new Response(200, [], 'Posts list'); + }); + }); + + // Test /api/users + $request = new ServerRequest('GET', '/api/users'); + $response = $this->router->dispatch($request); + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('Users list'); + + // Test /api/posts + $request = new ServerRequest('GET', '/api/posts'); + $response = $this->router->dispatch($request); + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('Posts list'); + }); + }); +}); diff --git a/tests/Unit/Presentation/Http/Controllers/Web/LegacyControllerTest.php b/tests/Unit/Presentation/Http/Controllers/Web/LegacyControllerTest.php new file mode 100644 index 000000000..079593828 --- /dev/null +++ b/tests/Unit/Presentation/Http/Controllers/Web/LegacyControllerTest.php @@ -0,0 +1,58 @@ +shouldReceive('all')->andReturn([]); + $mockConfig->shouldReceive('get')->andReturn('/fake/path/'); + + // Create controller with mock config + $this->controller = new LegacyController($mockConfig); + }); + + afterEach(function () { + Mockery::close(); + }); + + describe('basic functionality', function () { + it('can be instantiated', function () { + expect($this->controller)->toBeInstanceOf(LegacyController::class); + }); + + it('implements proper method signature', function () { + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('handle'); + + expect($method->getParameters())->toHaveCount(1); + expect($method->getParameters()[0]->getType()->getName())->toBe('Psr\Http\Message\ServerRequestInterface'); + }); + + it('returns PSR-7 response interface', function () { + $request = new ServerRequest('GET', 'http://example.com/nonexistent'); + + $response = $this->controller->handle($request); + expect($response)->toBeInstanceOf(\Psr\Http\Message\ResponseInterface::class); + }); + + it('returns 404 for non-existent files', function () { + $request = new ServerRequest('GET', 'http://example.com/nonexistent'); + + $response = $this->controller->handle($request); + expect($response->getStatusCode())->toBe(404); + }); + }); + + describe('security', function () { + it('prevents directory traversal', function () { + $request = new ServerRequest('GET', 'http://example.com/../../../etc/passwd'); + + $response = $this->controller->handle($request); + expect($response->getStatusCode())->toBe(404); + }); + }); +}); diff --git a/tests/Unit/Presentation/Http/KernelTest.php b/tests/Unit/Presentation/Http/KernelTest.php new file mode 100644 index 000000000..857f3d9a6 --- /dev/null +++ b/tests/Unit/Presentation/Http/KernelTest.php @@ -0,0 +1,201 @@ +container = new Container($diContainer); + + // Create a real router instance and register it in the container + $this->router = new Router($this->container); + $this->container->getWrappedContainer()->set(Router::class, $this->router); + + // Create kernel instance + $this->kernel = new Kernel($this->container); + + // Store original superglobals + $this->originalServer = $_SERVER; + $this->originalGet = $_GET; + $this->originalPost = $_POST; + }); + + afterEach(function () { + // Restore original superglobals + $_SERVER = $this->originalServer; + $_GET = $this->originalGet; + $_POST = $this->originalPost; + }); + + describe('route loading', function () { + it('loads routes from a file', function () { + // Create a temporary routes file + $routesFile = tempnam(sys_get_temp_dir(), 'routes'); + file_put_contents($routesFile, 'get("/test", function () { + return new \GuzzleHttp\Psr7\Response(200, [], "Test route"); + }); + }; + '); + + $this->kernel->loadRoutes($routesFile); + + // Clean up + unlink($routesFile); + + // Verify route was loaded by trying to handle it + $request = new ServerRequest('GET', 'http://example.com/test'); + $response = $this->kernel->handle($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('Test route'); + }); + + it('throws exception for non-existent routes file', function () { + $nonExistentFile = '/path/to/nonexistent/routes.php'; + + expect(fn() => $this->kernel->loadRoutes($nonExistentFile)) + ->toThrow(RuntimeException::class); + }); + }); + + describe('request handling', function () { + beforeEach(function () { + // Set up a test route + $routesFile = tempnam(sys_get_temp_dir(), 'routes'); + file_put_contents($routesFile, 'get("/hello", function () { + return new \GuzzleHttp\Psr7\Response(200, [], "Hello World"); + }); + $router->post("/submit", function ($request) { + $body = $request->getParsedBody(); + return new \GuzzleHttp\Psr7\Response(200, [], json_encode($body)); + }); + }; + '); + $this->kernel->loadRoutes($routesFile); + $this->routesFile = $routesFile; + }); + + afterEach(function () { + if (isset($this->routesFile)) { + unlink($this->routesFile); + } + }); + + it('handles GET requests', function () { + $request = new ServerRequest('GET', 'http://example.com/hello'); + $response = $this->kernel->handle($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('Hello World'); + }); + + it('handles POST requests', function () { + $request = new ServerRequest('POST', 'http://example.com/submit'); + $request = $request->withParsedBody(['name' => 'John']); + + $response = $this->kernel->handle($request); + + expect($response->getStatusCode())->toBe(200); + expect((string)$response->getBody())->toBe('{"name":"John"}'); + }); + + it('returns 404 for undefined routes', function () { + $request = new ServerRequest('GET', 'http://example.com/undefined'); + $response = $this->kernel->handle($request); + + expect($response->getStatusCode())->toBe(404); + }); + + it('returns 405 for wrong HTTP method', function () { + $request = new ServerRequest('POST', 'http://example.com/hello'); + $response = $this->kernel->handle($request); + + expect($response->getStatusCode())->toBe(405); + }); + }); + + describe('error handling', function () { + it('handles exceptions gracefully', function () { + // Set up a route that throws an exception + $routesFile = tempnam(sys_get_temp_dir(), 'routes'); + file_put_contents($routesFile, 'get("/error", function () { + throw new \RuntimeException("Test exception"); + }); + }; + '); + $this->kernel->loadRoutes($routesFile); + + $request = new ServerRequest('GET', 'http://example.com/error'); + $response = $this->kernel->handle($request); + + expect($response->getStatusCode())->toBe(500); + + // Clean up + unlink($routesFile); + }); + + it('logs exceptions when bb_log function exists', function () { + // This is hard to test without actually defining bb_log + // We'll just verify the error response structure + $routesFile = tempnam(sys_get_temp_dir(), 'routes'); + file_put_contents($routesFile, 'get("/error", function () { + throw new \Exception("Test error"); + }); + }; + '); + $this->kernel->loadRoutes($routesFile); + + $request = new ServerRequest('GET', 'http://example.com/error'); + $response = $this->kernel->handle($request); + + expect($response->getStatusCode())->toBe(500); + expect($response->getBody()->getContents())->toContain('Internal Server Error'); + + unlink($routesFile); + }); + }); + + describe('run method', function () { + it('creates request from globals and sends response', function () { + // Set up globals + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/hello'; + $_SERVER['HTTP_HOST'] = 'example.com'; + $_GET = ['foo' => 'bar']; + + // Set up a route + $routesFile = tempnam(sys_get_temp_dir(), 'routes'); + file_put_contents($routesFile, 'get("/hello", function ($request) { + $query = $request->getQueryParams(); + return new \GuzzleHttp\Psr7\Response(200, [], $query["foo"] ?? "no foo"); + }); + }; + '); + $this->kernel->loadRoutes($routesFile); + + // Capture output + ob_start(); + $this->kernel->run(); + $output = ob_get_clean(); + + expect($output)->toContain('bar'); + + unlink($routesFile); + }); + }); +});