diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index c246d7f..0000000 --- a/TESTING.md +++ /dev/null @@ -1,116 +0,0 @@ -# Testing Guide - -## Running Tests - -### Quick Start - -Run all tests: -```bash -./vendor/bin/phpunit tests -``` - -Run tests with coverage: -```bash -./vendor/bin/phpunit tests --coverage-text --coverage-filter=src -``` - -Run a specific test file: -```bash -./vendor/bin/phpunit tests/Unit/Cms/Services/Media/CloudinaryUploaderTest.php -``` - -## Integration Tests - -### Cloudinary Integration Tests - -By default, the 3 Cloudinary integration tests are **skipped** because they require real Cloudinary API credentials. - -Cloudinary credentials can be configured in **two ways**: - -#### Option 1: During Installation (Recommended) - -When you run the CMS installation: -```bash -php neuron cms:install -``` - -The installer will optionally prompt you for Cloudinary credentials and save them to `config/neuron.yaml`. - -#### Option 2: For Testing Only (Using .env.testing) - -To run Cloudinary integration tests locally without affecting your production config: - -1. **Copy the example environment file:** - ```bash - cp .env.testing.example .env.testing - ``` - -2. **Get your Cloudinary credentials:** - - Sign up for a free account at https://cloudinary.com - - Go to your dashboard: https://console.cloudinary.com/ - - Copy your credentials - -3. **Edit `.env.testing` and add your credentials:** - ```bash - # Option 1: Use CLOUDINARY_URL (easiest - copy from dashboard) - CLOUDINARY_URL=cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyz@your-cloud-name - - # Option 2: Use individual credentials - CLOUDINARY_CLOUD_NAME=your-cloud-name - CLOUDINARY_API_KEY=123456789012345 - CLOUDINARY_API_SECRET=abcdefghijklmnopqrstuvwxyz - CLOUDINARY_FOLDER=test-uploads - ``` - -4. **Run the tests:** - ```bash - ./vendor/bin/phpunit tests/Unit/Cms/Services/Media/CloudinaryUploaderTest.php - ``` - -#### Important Notes: - -- ✅ `.env.testing` is git-ignored and will **never be committed** -- ✅ You can use a free Cloudinary account for testing -- ✅ Set `CLOUDINARY_FOLDER=test-uploads` to keep test files organized -- ⚠️ Integration tests will upload actual files to your Cloudinary account -- ⚠️ Remember to clean up test uploads periodically - -### CI/CD Integration - -For continuous integration (GitHub Actions, GitLab CI, etc.), set the `CLOUDINARY_URL` environment variable as a secret in your CI platform: - -**GitHub Actions:** -1. Go to repository Settings → Secrets and variables → Actions -2. Add a new repository secret: `CLOUDINARY_URL` -3. The tests will automatically run when the secret is set - -**GitLab CI:** -```yaml -# .gitlab-ci.yml -test: - script: - - composer install - - ./vendor/bin/phpunit tests - variables: - CLOUDINARY_URL: $CLOUDINARY_URL # Set in GitLab CI/CD variables -``` - -## Test Categories - -- **Unit Tests**: Fast tests that mock dependencies (run on every commit) -- **Integration Tests**: Tests that connect to external services (run when credentials are available) -- **Skipped Tests**: Integration tests without credentials (3 Cloudinary tests) - -## Expected Test Results - -**Without Cloudinary credentials:** -- Tests: 727 -- Assertions: ~1,775 -- Skipped: 3 (Cloudinary integration tests) -- Status: ✅ All passing - -**With Cloudinary credentials:** -- Tests: 727 -- Assertions: ~1,790 -- Skipped: 0 -- Status: ✅ All passing (including integration tests) diff --git a/debug-maintenance.php b/debug-maintenance.php deleted file mode 100644 index f472d03..0000000 --- a/debug-maintenance.php +++ /dev/null @@ -1,28 +0,0 @@ -isEnabled() ? 'Yes' : 'No') . "\n"; -echo "Message: " . $manager->getMessage() . "\n"; -echo "Retry After: " . $manager->getRetryAfter() . "\n\n"; - -$status = $manager->getStatus(); -echo "Full Status:\n"; -print_r($status); diff --git a/resources/app/Initializers/AuthInitializer.php b/resources/app/Initializers/AuthInitializer.php index e40c601..8f0c430 100644 --- a/resources/app/Initializers/AuthInitializer.php +++ b/resources/app/Initializers/AuthInitializer.php @@ -59,7 +59,7 @@ public function run( array $argv = [] ): mixed $sessionManager = new SessionManager(); $passwordHasher = new PasswordHasher(); $authentication = new Authentication( $userRepository, $sessionManager, $passwordHasher ); - $csrfToken = new CsrfToken(); + $csrfToken = new CsrfToken( $sessionManager ); // Create filters $authFilter = new AuthenticationFilter( $authentication, '/login' ); diff --git a/resources/config/routes.yaml b/resources/config/routes.yaml index ae5e49e..891cddd 100644 --- a/resources/config/routes.yaml +++ b/resources/config/routes.yaml @@ -17,7 +17,7 @@ routes: method: POST route: /logout controller: Neuron\Cms\Controllers\Auth\Login@logout - filter: auth + filter: auth-csrf # Password Reset routes forgot_password: @@ -70,7 +70,7 @@ routes: method: POST route: /admin/users controller: Neuron\Cms\Controllers\Admin\Users@store - filter: auth + filter: auth-csrf admin_users_edit: method: GET @@ -82,13 +82,13 @@ routes: method: PUT route: /admin/users/:id controller: Neuron\Cms\Controllers\Admin\Users@update - filter: auth + filter: auth-csrf admin_users_destroy: method: DELETE route: /admin/users/:id controller: Neuron\Cms\Controllers\Admin\Users@destroy - filter: auth + filter: auth-csrf # Profile Management admin_profile: @@ -101,7 +101,7 @@ routes: method: PUT route: /admin/profile controller: Neuron\Cms\Controllers\Admin\Profile@update - filter: auth + filter: auth-csrf # Blog Post Management admin_posts: @@ -120,7 +120,7 @@ routes: method: POST route: /admin/posts controller: Neuron\Cms\Controllers\Admin\Posts@store - filter: auth + filter: auth-csrf admin_posts_edit: method: GET @@ -132,13 +132,13 @@ routes: method: PUT route: /admin/posts/:id controller: Neuron\Cms\Controllers\Admin\Posts@update - filter: auth + filter: auth-csrf admin_posts_destroy: method: DELETE route: /admin/posts/:id controller: Neuron\Cms\Controllers\Admin\Posts@destroy - filter: auth + filter: auth-csrf # Blog Category Management admin_categories: @@ -157,7 +157,7 @@ routes: method: POST route: /admin/categories controller: Neuron\Cms\Controllers\Admin\Categories@store - filter: auth + filter: auth-csrf admin_categories_edit: method: GET @@ -169,13 +169,13 @@ routes: method: PUT route: /admin/categories/:id controller: Neuron\Cms\Controllers\Admin\Categories@update - filter: auth + filter: auth-csrf admin_categories_destroy: method: DELETE route: /admin/categories/:id controller: Neuron\Cms\Controllers\Admin\Categories@destroy - filter: auth + filter: auth-csrf # Blog Tag Management admin_tags: @@ -194,7 +194,7 @@ routes: method: POST route: /admin/tags controller: Neuron\Cms\Controllers\Admin\Tags@store - filter: auth + filter: auth-csrf admin_tags_edit: method: GET @@ -206,13 +206,13 @@ routes: method: PUT route: /admin/tags/:id controller: Neuron\Cms\Controllers\Admin\Tags@update - filter: auth + filter: auth-csrf admin_tags_destroy: method: DELETE route: /admin/tags/:id controller: Neuron\Cms\Controllers\Admin\Tags@destroy - filter: auth + filter: auth-csrf # Page Management admin_pages: @@ -231,7 +231,7 @@ routes: method: POST route: /admin/pages controller: Neuron\Cms\Controllers\Admin\Pages@store - filter: auth + filter: auth-csrf admin_pages_edit: method: GET @@ -243,15 +243,21 @@ routes: method: PUT route: /admin/pages/:id controller: Neuron\Cms\Controllers\Admin\Pages@update - filter: auth + filter: auth-csrf admin_pages_destroy: method: DELETE route: /admin/pages/:id controller: Neuron\Cms\Controllers\Admin\Pages@destroy + filter: auth-csrf + + # Media Management Routes + admin_media: + method: GET + route: /admin/media + controller: Neuron\Cms\Controllers\Admin\Media@index filter: auth - # Media Upload Routes admin_media_upload: method: POST route: /admin/upload/image @@ -332,6 +338,7 @@ routes: method: POST route: /resend-verification controller: Neuron\Cms\Controllers\Member\Registration@resendVerification + filter: csrf # Member Dashboard Routes member: @@ -357,7 +364,7 @@ routes: method: PUT route: /member/profile controller: Neuron\Cms\Controllers\Member\Profile@update - filter: member + filter: member-csrf # Event Category Management admin_event_categories: @@ -376,7 +383,7 @@ routes: method: POST route: /admin/event-categories controller: Neuron\Cms\Controllers\Admin\EventCategories@store - filter: auth + filter: auth-csrf admin_event_categories_edit: method: GET @@ -388,13 +395,13 @@ routes: method: PUT route: /admin/event-categories/:id controller: Neuron\Cms\Controllers\Admin\EventCategories@update - filter: auth + filter: auth-csrf admin_event_categories_destroy: method: DELETE route: /admin/event-categories/:id controller: Neuron\Cms\Controllers\Admin\EventCategories@destroy - filter: auth + filter: auth-csrf # Event Management admin_events: @@ -413,7 +420,7 @@ routes: method: POST route: /admin/events controller: Neuron\Cms\Controllers\Admin\Events@store - filter: auth + filter: auth-csrf admin_events_edit: method: GET @@ -425,13 +432,13 @@ routes: method: PUT route: /admin/events/:id controller: Neuron\Cms\Controllers\Admin\Events@update - filter: auth + filter: auth-csrf admin_events_destroy: method: DELETE route: /admin/events/:id controller: Neuron\Cms\Controllers\Admin\Events@destroy - filter: auth + filter: auth-csrf # Public Calendar Routes calendar: diff --git a/resources/public/index.php b/resources/public/index.php index 446f0d5..9a39794 100644 --- a/resources/public/index.php +++ b/resources/public/index.php @@ -6,6 +6,9 @@ use function Neuron\Cms\boot; use function Neuron\Mvc\dispatch; +use Neuron\Cms\Exceptions\CsrfValidationException; +use Neuron\Cms\Exceptions\UnauthenticatedException; +use Neuron\Cms\Exceptions\EmailVerificationRequiredException; require '../vendor/autoload.php'; @@ -13,4 +16,27 @@ $app = boot( '../config' ); -dispatch( $app ); +try +{ + dispatch( $app ); +} +catch( UnauthenticatedException $e ) +{ + // Handle authentication failures by redirecting to login + header( 'Location: ' . $e->getRedirectUrl() ); + exit; +} +catch( EmailVerificationRequiredException $e ) +{ + // Handle email verification requirement by redirecting to verification page + header( 'Location: ' . $e->getVerificationUrl() ); + exit; +} +catch( CsrfValidationException $e ) +{ + // Handle CSRF validation failures with 403 response + http_response_code( 403 ); + echo '
' . htmlspecialchars( $e->getUserMessage() ) . '
'; + exit; +} diff --git a/resources/views/admin/dashboard/index.php b/resources/views/admin/dashboard/index.php index ea9aac9..bbde71b 100644 --- a/resources/views/admin/dashboard/index.php +++ b/resources/views/admin/dashboard/index.php @@ -4,15 +4,71 @@No images uploaded yet.
+ +' . htmlspecialchars( $message ) . '
'; - exit; - } } diff --git a/src/Cms/Auth/Filters/MemberAuthenticationFilter.php b/src/Cms/Auth/Filters/MemberAuthenticationFilter.php index 829391b..7e1b729 100644 --- a/src/Cms/Auth/Filters/MemberAuthenticationFilter.php +++ b/src/Cms/Auth/Filters/MemberAuthenticationFilter.php @@ -5,6 +5,8 @@ use Neuron\Routing\Filter; use Neuron\Routing\RouteMap; use Neuron\Cms\Services\Auth\Authentication; +use Neuron\Cms\Exceptions\UnauthenticatedException; +use Neuron\Cms\Exceptions\EmailVerificationRequiredException; use Neuron\Patterns\Registry; /** @@ -42,6 +44,9 @@ function( RouteMap $route ) { $this->checkAuthentication( $route ); }, /** * Check if user is authenticated and verified + * + * @throws UnauthenticatedException When user is not authenticated + * @throws EmailVerificationRequiredException When email verification is required but not completed */ protected function checkAuthentication( RouteMap $route ): void { @@ -53,21 +58,25 @@ protected function checkAuthentication( RouteMap $route ): void // Store intended URL for post-login redirect $intendedUrl = $_SERVER['REQUEST_URI'] ?? $route->getPath(); - // Redirect to login page + // Build redirect URL to login page $separator = ( strpos( $this->_loginUrl, '?' ) === false ) ? '?' : '&'; $query = http_build_query( [ $this->_redirectParam => $intendedUrl ] ); $redirectUrl = $this->_loginUrl . $separator . $query; - header( 'Location: ' . $redirectUrl ); - exit; + throw new UnauthenticatedException( + $redirectUrl, + $intendedUrl, + 'User not authenticated, redirect required to: ' . $redirectUrl + ); } // Check if email verification is required if( $this->_requireEmailVerification && !$user->isEmailVerified() ) { - // Redirect to email verification required page - header( 'Location: ' . $this->_verifyEmailUrl ); - exit; + throw new EmailVerificationRequiredException( + $this->_verifyEmailUrl, + 'Email verification required for user ID: ' . $user->getId() + ); } // Set user in Registry for controllers to access diff --git a/src/Cms/Cli/Commands/Install/InstallCommand.php b/src/Cms/Cli/Commands/Install/InstallCommand.php index 4aab44e..02b8fb4 100644 --- a/src/Cms/Cli/Commands/Install/InstallCommand.php +++ b/src/Cms/Cli/Commands/Install/InstallCommand.php @@ -479,8 +479,11 @@ private function setupDatabase(): bool // Optionally configure Cloudinary $cloudinaryConfig = $this->configureCloudinary(); + // Optionally configure Email + $emailConfig = $this->configureEmail(); + // Merge and save complete configuration - return $this->saveCompleteConfig( $config, $appConfig, $cloudinaryConfig ); + return $this->saveCompleteConfig( $config, $appConfig, $cloudinaryConfig, $emailConfig ); } /** @@ -596,10 +599,161 @@ private function configureCloudinary(): array ]; } + /** + * Configure Email (optional) + */ + private function configureEmail(): array + { + $this->output->writeln( "\n╔═══════════════════════════════════════╗" ); + $this->output->writeln( "║ Email Configuration (Optional) ║" ); + $this->output->writeln( "╚═══════════════════════════════════════╝\n" ); + + $this->output->writeln( "Email is used for:" ); + $this->output->writeln( " • Email verification for new accounts" ); + $this->output->writeln( " • Password reset requests" ); + $this->output->writeln( " • User notifications and welcome emails" ); + $this->output->writeln( "" ); + + if( !$this->input->confirm( "Would you like to configure email now?", false ) ) + { + $this->output->info( "Skipping email configuration." ); + $this->output->writeln( "Email will be in test mode (emails logged, not sent)" ); + $this->output->writeln( "You can configure email later in config/neuron.yaml" ); + + return [ + 'email' => [ + 'driver' => 'mail', + 'test_mode' => true, + 'from_address' => 'noreply@example.com', + 'from_name' => 'Neuron CMS' + ] + ]; + } + + $this->output->writeln( "\n--- Email Driver ---\n" ); + + $driver = $this->input->choice( + "Select email driver:", + [ + 'smtp' => 'SMTP - Recommended for production (Gmail, SendGrid, Mailgun, etc.)', + 'mail' => 'PHP mail() - Simple but less reliable (may go to spam)' + ], + 'smtp' + ); + + $config = [ + 'driver' => $driver, + 'test_mode' => false + ]; + + // SMTP-specific configuration + if( $driver === 'smtp' ) + { + $this->output->writeln( "\n--- SMTP Settings ---\n" ); + $this->output->writeln( "Common SMTP providers:" ); + $this->output->writeln( " • Gmail: smtp.gmail.com:587 (TLS)" ); + $this->output->writeln( " • SendGrid: smtp.sendgrid.net:587 (TLS)" ); + $this->output->writeln( " • Mailgun: smtp.mailgun.org:587 (TLS)" ); + $this->output->writeln( " • Amazon SES: email-smtp.[region].amazonaws.com:587 (TLS)" ); + $this->output->writeln( "" ); + + $host = $this->input->ask( "SMTP host (e.g., smtp.gmail.com)" ); + + if( !$host ) + { + $this->output->warning( "SMTP host is required. Falling back to test mode." ); + return [ + 'email' => [ + 'driver' => 'mail', + 'test_mode' => true, + 'from_address' => 'noreply@example.com', + 'from_name' => 'Neuron CMS' + ] + ]; + } + + $port = $this->input->ask( "SMTP port", "587" ); + $encryption = $this->input->choice( + "Encryption type:", + [ + 'tls' => 'TLS (recommended, port 587)', + 'ssl' => 'SSL (port 465)', + 'none' => 'None (not recommended)' + ], + 'tls' + ); + + $username = $this->input->ask( "SMTP username (usually your email address)" ); + + if( !$username ) + { + $this->output->warning( "SMTP username is required. Falling back to test mode." ); + return [ + 'email' => [ + 'driver' => 'mail', + 'test_mode' => true, + 'from_address' => 'noreply@example.com', + 'from_name' => 'Neuron CMS' + ] + ]; + } + + $password = $this->input->askSecret( "SMTP password (or app-specific password)" ); + + if( !$password ) + { + $this->output->warning( "SMTP password is required. Falling back to test mode." ); + return [ + 'email' => [ + 'driver' => 'mail', + 'test_mode' => true, + 'from_address' => 'noreply@example.com', + 'from_name' => 'Neuron CMS' + ] + ]; + } + + $config['host'] = $host; + $config['port'] = (int)$port; + $config['username'] = $username; + $config['password'] = $password; + $config['encryption'] = $encryption; + + $this->_messages[] = "Email: SMTP ($host:$port)"; + } + else + { + $this->_messages[] = "Email: PHP mail()"; + } + + // Common configuration + $this->output->writeln( "\n--- Sender Information ---\n" ); + + $fromAddress = $this->input->ask( "From email address", "noreply@example.com" ); + $fromName = $this->input->ask( "From name", "Neuron CMS" ); + + $config['from_address'] = $fromAddress; + $config['from_name'] = $fromName; + + // Test mode option + $this->output->writeln( "" ); + $testMode = $this->input->confirm( "Enable test mode? (emails logged, not sent - useful for development)", false ); + + if( $testMode ) + { + $config['test_mode'] = true; + $this->_messages[] = "Email test mode: ENABLED (emails will be logged, not sent)"; + } + + return [ + 'email' => $config + ]; + } + /** * Save complete configuration with all required sections */ - private function saveCompleteConfig( array $databaseConfig, array $appConfig, array $cloudinaryConfig = [] ): bool + private function saveCompleteConfig( array $databaseConfig, array $appConfig, array $cloudinaryConfig = [], array $emailConfig = [] ): bool { // Build complete configuration $config = [ @@ -639,6 +793,12 @@ private function saveCompleteConfig( array $databaseConfig, array $appConfig, ar $config = array_merge( $config, $cloudinaryConfig ); } + // Merge Email configuration if provided + if( !empty( $emailConfig ) ) + { + $config = array_merge( $config, $emailConfig ); + } + // Save configuration return $this->saveConfig( $config ); } diff --git a/src/Cms/Controllers/Admin/Categories.php b/src/Cms/Controllers/Admin/Categories.php index 746fcd8..5a066fc 100644 --- a/src/Cms/Controllers/Admin/Categories.php +++ b/src/Cms/Controllers/Admin/Categories.php @@ -28,20 +28,39 @@ class Categories extends Content /** * @param Application|null $app + * @param DatabaseCategoryRepository|null $categoryRepository + * @param Creator|null $categoryCreator + * @param Updater|null $categoryUpdater + * @param Deleter|null $categoryDeleter * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabaseCategoryRepository $categoryRepository = null, + ?Creator $categoryCreator = null, + ?Updater $categoryUpdater = null, + ?Deleter $categoryDeleter = null + ) { parent::__construct( $app ); - // Get settings and initialize repository - $settings = Registry::getInstance()->get( 'Settings' ); - $this->_categoryRepository = new DatabaseCategoryRepository( $settings ); + // Use injected dependencies if provided (for testing), otherwise create them (for production) + if( $categoryRepository === null ) + { + // Get settings and initialize repository + $settings = Registry::getInstance()->get( 'Settings' ); + $categoryRepository = new DatabaseCategoryRepository( $settings ); + + // Initialize services + $categoryCreator = new Creator( $categoryRepository ); + $categoryUpdater = new Updater( $categoryRepository ); + $categoryDeleter = new Deleter( $categoryRepository ); + } - // Initialize services - $this->_categoryCreator = new Creator( $this->_categoryRepository ); - $this->_categoryUpdater = new Updater( $this->_categoryRepository ); - $this->_categoryDeleter = new Deleter( $this->_categoryRepository ); + $this->_categoryRepository = $categoryRepository; + $this->_categoryCreator = $categoryCreator; + $this->_categoryUpdater = $categoryUpdater; + $this->_categoryDeleter = $categoryDeleter; } /** @@ -52,28 +71,15 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - $categoriesWithCount = $this->_categoryRepository->allWithPostCount(); - - $viewData = [ - 'Title' => 'Categories | Admin | ' . $this->getName(), - 'Description' => 'Manage blog categories', - 'User' => $user, - 'CategoriesWithCount' => $categoriesWithCount - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'categories' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Categories | Admin' ) + ->description( 'Manage blog categories' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'CategoriesWithCount', $this->_categoryRepository->allWithPostCount() ) + ->render( 'index', 'categories' ); } /** @@ -84,25 +90,14 @@ public function index( Request $request ): string */ public function create( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - $viewData = [ - 'Title' => 'Create Category | Admin | ' . $this->getName(), - 'Description' => 'Create a new blog category', - 'User' => $user - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'create', - 'categories' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Create Category | Admin' ) + ->description( 'Create a new blog category' ) + ->withCurrentUser() + ->withCsrfToken() + ->render( 'create', 'categories' ); } /** @@ -136,13 +131,6 @@ public function store( Request $request ): never */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $categoryId = (int)$request->getRouteParameter( 'id' ); $category = $this->_categoryRepository->findById( $categoryId ); @@ -151,19 +139,15 @@ public function edit( Request $request ): string throw new \RuntimeException( 'Category not found' ); } - $viewData = [ - 'Title' => 'Edit Category | Admin | ' . $this->getName(), - 'Description' => 'Edit blog category', - 'User' => $user, - 'Category' => $category - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'categories' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Edit Category | Admin' ) + ->description( 'Edit blog category' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'Category', $category ) + ->render( 'edit', 'categories' ); } /** diff --git a/src/Cms/Controllers/Admin/Dashboard.php b/src/Cms/Controllers/Admin/Dashboard.php index 8ecbeb6..db752ab 100644 --- a/src/Cms/Controllers/Admin/Dashboard.php +++ b/src/Cms/Controllers/Admin/Dashboard.php @@ -3,13 +3,10 @@ namespace Neuron\Cms\Controllers\Admin; use Neuron\Cms\Controllers\Content; -use Neuron\Cms\Services\Auth\CsrfToken; -use Neuron\Cms\Auth\SessionManager; use Neuron\Core\Exceptions\NotFound; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; -use Neuron\Patterns\Registry; /** * Admin dashboard controller. @@ -37,31 +34,13 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - // Get authenticated user from Registry - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found in Registry' ); - } - - // Generate CSRF token and store in Registry for helper functions - $sessionManager = new SessionManager(); - $sessionManager->start(); - $csrfToken = new CsrfToken( $sessionManager ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Dashboard | ' . $this->getName(), - 'Description' => 'Admin Dashboard', - 'User' => $user - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Dashboard' ) + ->description( 'Admin Dashboard' ) + ->withCurrentUser() + ->withCsrfToken() + ->render( 'index', 'admin' ); } } diff --git a/src/Cms/Controllers/Admin/EventCategories.php b/src/Cms/Controllers/Admin/EventCategories.php index 53114a7..98e8e27 100644 --- a/src/Cms/Controllers/Admin/EventCategories.php +++ b/src/Cms/Controllers/Admin/EventCategories.php @@ -7,7 +7,6 @@ use Neuron\Cms\Services\EventCategory\Creator; use Neuron\Cms\Services\EventCategory\Updater; use Neuron\Cms\Services\EventCategory\Deleter; -use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Log\Log; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; @@ -28,18 +27,37 @@ class EventCategories extends Content /** * @param Application|null $app + * @param DatabaseEventCategoryRepository|null $repository + * @param Creator|null $creator + * @param Updater|null $updater + * @param Deleter|null $deleter * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabaseEventCategoryRepository $repository = null, + ?Creator $creator = null, + ?Updater $updater = null, + ?Deleter $deleter = null + ) { parent::__construct( $app ); - $settings = Registry::getInstance()->get( 'Settings' ); + // Use injected dependencies if provided (for testing), otherwise create them (for production) + if( $repository === null ) + { + $settings = Registry::getInstance()->get( 'Settings' ); + + $repository = new DatabaseEventCategoryRepository( $settings ); + $creator = new Creator( $repository ); + $updater = new Updater( $repository ); + $deleter = new Deleter( $repository ); + } - $this->_repository = new DatabaseEventCategoryRepository( $settings ); - $this->_creator = new Creator( $this->_repository ); - $this->_updater = new Updater( $this->_repository ); - $this->_deleter = new Deleter( $this->_repository ); + $this->_repository = $repository; + $this->_creator = $creator; + $this->_updater = $updater; + $this->_deleter = $deleter; } /** @@ -47,34 +65,20 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } + $this->initializeCsrfToken(); $sessionManager = $this->getSessionManager(); - $csrfToken = new CsrfToken( $sessionManager ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $categories = $this->_repository->all(); - - $viewData = [ - 'Title' => 'Event Categories | ' . $this->getName(), - 'Description' => 'Manage event categories', - 'User' => $user, - 'categories' => $categories, - 'Success' => $sessionManager->getFlash( 'success' ), - 'Error' => $sessionManager->getFlash( 'error' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'admin' - ); + return $this->view() + ->title( 'Event Categories' ) + ->description( 'Manage event categories' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'categories' => $this->_repository->all(), + 'Success' => $sessionManager->getFlash( 'success' ), + 'Error' => $sessionManager->getFlash( 'error' ) + ]) + ->render( 'index', 'admin' ); } /** @@ -82,31 +86,19 @@ public function index( Request $request ): string */ public function create( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } + $this->initializeCsrfToken(); $sessionManager = $this->getSessionManager(); - $csrfToken = new CsrfToken( $sessionManager ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Create Event Category | ' . $this->getName(), - 'Description' => 'Create a new event category', - 'User' => $user, - 'errors' => $sessionManager->getFlash( 'errors' ) ?: [], - 'old' => $sessionManager->getFlash( 'old' ) ?: [] - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'create', - 'admin' - ); + return $this->view() + ->title( 'Create Event Category' ) + ->description( 'Create a new event category' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'errors' => $sessionManager->getFlash( 'errors' ) ?: [], + 'old' => $sessionManager->getFlash( 'old' ) ?: [] + ]) + ->render( 'create', 'admin' ); } /** @@ -114,23 +106,6 @@ public function create( Request $request ): string */ public function store( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - // Validate CSRF token before any state changes - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for event category creation by user {$user->getId()}" ); - $this->redirect( 'admin_event_categories_create', [], ['error', 'Invalid security token. Please try again.'] ); - } - $name = $request->post( 'name', '' ); $slug = $request->post( 'slug', '' ); $color = $request->post( 'color', '#3b82f6' ); @@ -168,13 +143,6 @@ public function store( Request $request ): never */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $categoryId = (int)$request->getRouteParameter( 'id' ); $category = $this->_repository->findById( $categoryId ); @@ -183,22 +151,15 @@ public function edit( Request $request ): string $this->redirect( 'admin_event_categories', [], ['error', 'Category not found'] ); } - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Edit Event Category | ' . $this->getName(), - 'Description' => 'Edit event category', - 'User' => $user, - 'category' => $category - ]; + $this->initializeCsrfToken(); - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'admin' - ); + return $this->view() + ->title( 'Edit Event Category' ) + ->description( 'Edit event category' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'category', $category ) + ->render( 'edit', 'admin' ); } /** @@ -206,13 +167,6 @@ public function edit( Request $request ): string */ public function update( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $categoryId = (int)$request->getRouteParameter( 'id' ); $category = $this->_repository->findById( $categoryId ); @@ -221,16 +175,6 @@ public function update( Request $request ): never $this->redirect( 'admin_event_categories', [], ['error', 'Category not found'] ); } - // Validate CSRF token before any state changes - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for event category update: Category {$categoryId}, user {$user->getId()}" ); - $this->redirect( 'admin_event_categories_edit', ['id' => $categoryId], ['error', 'Invalid security token. Please try again.'] ); - } - try { $name = $request->post( 'name', '' ); @@ -259,13 +203,6 @@ public function update( Request $request ): never */ public function destroy( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $categoryId = (int)$request->getRouteParameter( 'id' ); $category = $this->_repository->findById( $categoryId ); @@ -274,16 +211,6 @@ public function destroy( Request $request ): never $this->redirect( 'admin_event_categories', [], ['error', 'Category not found'] ); } - // Validate CSRF token before any state changes - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for event category deletion: Category {$categoryId}, user {$user->getId()}" ); - $this->redirect( 'admin_event_categories', [], ['error', 'Invalid security token. Please try again.'] ); - } - try { $this->_deleter->delete( $category ); diff --git a/src/Cms/Controllers/Admin/Events.php b/src/Cms/Controllers/Admin/Events.php index c4932ae..22452a1 100644 --- a/src/Cms/Controllers/Admin/Events.php +++ b/src/Cms/Controllers/Admin/Events.php @@ -31,19 +31,41 @@ class Events extends Content /** * @param Application|null $app + * @param DatabaseEventRepository|null $eventRepository + * @param DatabaseEventCategoryRepository|null $categoryRepository + * @param Creator|null $creator + * @param Updater|null $updater + * @param Deleter|null $deleter * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabaseEventRepository $eventRepository = null, + ?DatabaseEventCategoryRepository $categoryRepository = null, + ?Creator $creator = null, + ?Updater $updater = null, + ?Deleter $deleter = null + ) { parent::__construct( $app ); - $settings = Registry::getInstance()->get( 'Settings' ); + // Use injected dependencies if provided (for testing), otherwise create them (for production) + if( $eventRepository === null ) + { + $settings = Registry::getInstance()->get( 'Settings' ); + + $eventRepository = new DatabaseEventRepository( $settings ); + $categoryRepository = new DatabaseEventCategoryRepository( $settings ); + $creator = new Creator( $eventRepository, $categoryRepository ); + $updater = new Updater( $eventRepository, $categoryRepository ); + $deleter = new Deleter( $eventRepository ); + } - $this->_eventRepository = new DatabaseEventRepository( $settings ); - $this->_categoryRepository = new DatabaseEventCategoryRepository( $settings ); - $this->_creator = new Creator( $this->_eventRepository, $this->_categoryRepository ); - $this->_updater = new Updater( $this->_eventRepository, $this->_categoryRepository ); - $this->_deleter = new Deleter( $this->_eventRepository ); + $this->_eventRepository = $eventRepository; + $this->_categoryRepository = $categoryRepository; + $this->_creator = $creator; + $this->_updater = $updater; + $this->_deleter = $deleter; } /** @@ -51,42 +73,30 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - $sessionManager = $this->getSessionManager(); - $csrfToken = new CsrfToken( $sessionManager ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); + $this->initializeCsrfToken(); // Get all events or filter by creator if not admin/editor - if( $user->isAdmin() || $user->isEditor() ) + if( is_admin() || is_editor() ) { $events = $this->_eventRepository->all(); } else { - $events = $this->_eventRepository->getByCreator( $user->getId() ); + $events = $this->_eventRepository->getByCreator( user_id() ); } - $viewData = [ - 'Title' => 'Events | ' . $this->getName(), - 'Description' => 'Manage calendar events', - 'User' => $user, - 'events' => $events, - 'Success' => $sessionManager->getFlash( 'success' ), - 'Error' => $sessionManager->getFlash( 'error' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'admin' - ); + $sessionManager = $this->getSessionManager(); + return $this->view() + ->title( 'Events' ) + ->description( 'Manage calendar events' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'events' => $events, + 'Success' => $sessionManager->getFlash( 'success' ), + 'Error' => $sessionManager->getFlash( 'error' ) + ]) + ->render( 'index', 'admin' ); } /** @@ -94,29 +104,15 @@ public function index( Request $request ): string */ public function create( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Create Event | ' . $this->getName(), - 'Description' => 'Create a new calendar event', - 'User' => $user, - 'categories' => $this->_categoryRepository->all() - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'create', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Create Event' ) + ->description( 'Create a new calendar event' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'categories', $this->_categoryRepository->all() ) + ->render( 'create', 'admin' ); } /** @@ -124,23 +120,6 @@ public function create( Request $request ): string */ public function store( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - // Validate CSRF token before any state changes or processing - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for event creation by user {$user->getId()}" ); - $this->redirect( 'admin_events_create', [], ['error', 'Invalid security token. Please try again.'] ); - } - try { $title = $request->post( 'title', '' ); @@ -161,7 +140,7 @@ public function store( Request $request ): never $this->_creator->create( $title, new DateTimeImmutable( $startDate ), - $user->getId(), + user_id(), $status, $slug ?: null, $description ?: null, @@ -189,13 +168,6 @@ public function store( Request $request ): never */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $eventId = (int)$request->getRouteParameter( 'id' ); $event = $this->_eventRepository->findById( $eventId ); @@ -205,28 +177,23 @@ public function edit( Request $request ): string } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $event->getCreatedBy() !== $user->getId() ) + if( !is_admin() && !is_editor() && $event->getCreatedBy() !== user_id() ) { throw new \RuntimeException( 'Unauthorized to edit this event' ); } - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Edit Event | ' . $this->getName(), - 'Description' => 'Edit calendar event', - 'User' => $user, - 'event' => $event, - 'categories' => $this->_categoryRepository->all() - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Edit Event' ) + ->description( 'Edit calendar event' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'event' => $event, + 'categories' => $this->_categoryRepository->all() + ]) + ->render( 'edit', 'admin' ); } /** @@ -234,13 +201,6 @@ public function edit( Request $request ): string */ public function update( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $eventId = (int)$request->getRouteParameter( 'id' ); $event = $this->_eventRepository->findById( $eventId ); @@ -250,21 +210,11 @@ public function update( Request $request ): never } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $event->getCreatedBy() !== $user->getId() ) + if( !is_admin() && !is_editor() && $event->getCreatedBy() !== user_id() ) { throw new \RuntimeException( 'Unauthorized to edit this event' ); } - // Validate CSRF token before any state changes or processing - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for event update: Event {$eventId}, user {$user->getId()}" ); - $this->redirect( 'admin_events_edit', ['id' => $eventId], ['error', 'Invalid security token. Please try again.'] ); - } - try { $title = $request->post( 'title', '' ); @@ -313,13 +263,6 @@ public function update( Request $request ): never */ public function destroy( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $eventId = (int)$request->getRouteParameter( 'id' ); $event = $this->_eventRepository->findById( $eventId ); @@ -329,21 +272,11 @@ public function destroy( Request $request ): never } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $event->getCreatedBy() !== $user->getId() ) + if( !is_admin() && !is_editor() && $event->getCreatedBy() !== user_id() ) { $this->redirect( 'admin_events', [], ['error', 'Unauthorized to delete this event'] ); } - // Validate CSRF token before any state changes - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for event deletion: Event {$eventId}, user {$user->getId()}" ); - $this->redirect( 'admin_events', [], ['error', 'Invalid security token. Please try again.'] ); - } - try { $this->_deleter->delete( $event ); diff --git a/src/Cms/Controllers/Admin/Media.php b/src/Cms/Controllers/Admin/Media.php index 00509d7..dcdf458 100644 --- a/src/Cms/Controllers/Admin/Media.php +++ b/src/Cms/Controllers/Admin/Media.php @@ -2,40 +2,161 @@ namespace Neuron\Cms\Controllers\Admin; -use Neuron\Mvc\Controller; +use Neuron\Cms\Controllers\Content; use Neuron\Cms\Services\Media\CloudinaryUploader; use Neuron\Cms\Services\Media\MediaValidator; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Data\Settings\SettingManager; +use Neuron\Log\Log; +use Neuron\Mvc\Application; +use Neuron\Mvc\Requests\Request; +use Neuron\Mvc\Responses\HttpResponseStatus; use Neuron\Patterns\Registry; /** * Media upload controller. * - * Handles image uploads for the admin interface. + * Handles image uploads and media library management for the admin interface. * * @package Neuron\Cms\Controllers\Admin */ -class Media extends Controller +class Media extends Content { private CloudinaryUploader $_uploader; private MediaValidator $_validator; /** * Constructor + * + * @param Application|null $app + * @param CloudinaryUploader|null $uploader + * @param MediaValidator|null $validator + * @throws \Exception */ - public function __construct() + public function __construct( + ?Application $app = null, + ?CloudinaryUploader $uploader = null, + ?MediaValidator $validator = null + ) { - parent::__construct(); + parent::__construct( $app ); + + // Get settings once if we need to create any dependencies + $settings = null; + if( $uploader === null || $validator === null ) + { + $settings = Registry::getInstance()->get( 'Settings' ); + + if( !$settings instanceof SettingManager ) + { + throw new \Exception( 'Settings not found in Registry' ); + } + } + + // Create uploader if not provided + if( $uploader === null ) + { + $uploader = new CloudinaryUploader( $settings ); + } + + // Create validator if not provided + if( $validator === null ) + { + $validator = new MediaValidator( $settings ); + } + + $this->_uploader = $uploader; + $this->_validator = $validator; + } - $settings = Registry::getInstance()->get( 'Settings' ); + /** + * Display media library + * + * Shows all uploaded images from Cloudinary in a grid view. + * Supports pagination and search/filter. + * + * @param Request $request + * @return string Rendered view + * @throws \Exception + */ + public function index( Request $request ): string + { + // Generate CSRF token + $sessionManager = $this->getSessionManager(); + $csrfToken = new CsrfToken( $sessionManager ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - if( !$settings instanceof SettingManager ) + try { - throw new \Exception( 'Settings not found in Registry' ); + // Get pagination cursor from query string + $rawCursor = $request->get( 'cursor' ); + + // Validate cursor - must be alphanumeric plus underscore/hyphen/equals (Cloudinary format) + // Fail closed by not passing invalid cursors to the API + $validatedCursor = null; + if( !empty( $rawCursor ) ) + { + // Cloudinary cursors are typically base64-like strings with alphanumeric, _, -, and = characters + if( preg_match( '/^[a-zA-Z0-9_\-=]+$/', $rawCursor ) ) + { + $validatedCursor = $rawCursor; + } + else + { + // Log invalid cursor attempt for security monitoring + Log::warning( 'Invalid pagination cursor rejected', [ + 'cursor' => substr( $rawCursor, 0, 50 ), // Limit logged data + 'user_id' => user_id(), + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ] ); + } + } + + // Build options array - only include next_cursor if validated + $options = ['max_results' => 30]; + if( $validatedCursor !== null ) + { + $options['next_cursor'] = $validatedCursor; + } + + // List resources from Cloudinary with validated cursor + $result = $this->_uploader->listResources( $options ); + + return $this->view() + ->title( 'Media Library' ) + ->description( 'Manage uploaded images' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'resources' => $result['resources'], + 'nextCursor' => $result['next_cursor'], + 'totalCount' => $result['total_count'], + 'success' => $sessionManager->getFlash( 'success' ), + 'error' => $sessionManager->getFlash( 'error' ) + ]) + ->render( 'index', 'admin' ); } + catch( \Exception $e ) + { + Log::error( 'Error fetching media resources: ' . $e->getMessage(), [ + 'exception' => $e, + 'user_id' => user_id() + ] ); - $this->_uploader = new CloudinaryUploader( $settings ); - $this->_validator = new MediaValidator( $settings ); + return $this->view() + ->title( 'Media Library' ) + ->description( 'Manage uploaded images' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'resources' => [], + 'nextCursor' => null, + 'totalCount' => 0, + 'success' => $sessionManager->getFlash( 'success' ), + 'error' => 'Failed to load media library. Please try again.' + ]) + ->render( 'index', 'admin' ); + } } /** @@ -44,20 +165,23 @@ public function __construct() * Handles POST /admin/upload/image * Returns JSON in Editor.js format * - * @return void + * @param Request $request + * @return string JSON response */ - public function uploadImage(): void + public function uploadImage( Request $request ): string { - // Set JSON response header - header( 'Content-Type: application/json' ); - try { // Check if file was uploaded if( !isset( $_FILES['image'] ) ) { - $this->returnEditorJsError( 'No file was uploaded' ); - return; + return $this->renderJson( + HttpResponseStatus::BAD_REQUEST, + [ + 'success' => 0, + 'message' => 'No file was uploaded' + ] + ); } $file = $_FILES['image']; @@ -65,19 +189,63 @@ public function uploadImage(): void // Validate file if( !$this->_validator->validate( $file ) ) { - $this->returnEditorJsError( $this->_validator->getFirstError() ); - return; + Log::warning( 'Image upload validation failed', [ + 'user_id' => user_id(), + 'filename' => $file['name'] ?? 'unknown', + 'error' => $this->_validator->getFirstError() + ] ); + + return $this->renderJson( + HttpResponseStatus::BAD_REQUEST, + [ + 'success' => 0, + 'message' => $this->_validator->getFirstError() + ] + ); } // Upload to Cloudinary $result = $this->_uploader->upload( $file['tmp_name'] ); + Log::info( 'Image uploaded successfully', [ + 'user_id' => user_id(), + 'filename' => $file['name'], + 'public_id' => $result['public_id'], + 'url' => $result['url'] + ] ); + // Return success response in Editor.js format - $this->returnEditorJsSuccess( $result ); + return $this->renderJson( + HttpResponseStatus::OK, + [ + 'success' => 1, + 'file' => [ + 'url' => $result['url'], + 'width' => $result['width'], + 'height' => $result['height'] + ] + ] + ); } catch( \Exception $e ) { - $this->returnEditorJsError( $e->getMessage() ); + // Safely retrieve filename with explicit isset check to prevent undefined index + $filename = isset( $_FILES['image']['name'] ) ? $_FILES['image']['name'] : 'unknown'; + + Log::error( 'Image upload failed', [ + 'user_id' => user_id(), + 'filename' => $filename, + 'exception' => $e, + 'message' => $e->getMessage() + ] ); + + return $this->renderJson( + HttpResponseStatus::INTERNAL_SERVER_ERROR, + [ + 'success' => 0, + 'message' => 'Upload failed. Please try again.' + ] + ); } } @@ -87,20 +255,23 @@ public function uploadImage(): void * Handles POST /admin/upload/featured-image * Returns JSON with upload result * - * @return void + * @param Request $request + * @return string JSON response */ - public function uploadFeaturedImage(): void + public function uploadFeaturedImage( Request $request ): string { - // Set JSON response header - header( 'Content-Type: application/json' ); - try { // Check if file was uploaded if( !isset( $_FILES['image'] ) ) { - $this->returnError( 'No file was uploaded' ); - return; + return $this->renderJson( + HttpResponseStatus::BAD_REQUEST, + [ + 'success' => false, + 'error' => 'No file was uploaded' + ] + ); } $file = $_FILES['image']; @@ -108,85 +279,59 @@ public function uploadFeaturedImage(): void // Validate file if( !$this->_validator->validate( $file ) ) { - $this->returnError( $this->_validator->getFirstError() ); - return; + Log::warning( 'Featured image upload validation failed', [ + 'user_id' => user_id(), + 'filename' => $file['name'] ?? 'unknown', + 'error' => $this->_validator->getFirstError() + ] ); + + return $this->renderJson( + HttpResponseStatus::BAD_REQUEST, + [ + 'success' => false, + 'error' => $this->_validator->getFirstError() + ] + ); } // Upload to Cloudinary $result = $this->_uploader->upload( $file['tmp_name'] ); + Log::info( 'Featured image uploaded successfully', [ + 'user_id' => user_id(), + 'filename' => $file['name'], + 'public_id' => $result['public_id'], + 'url' => $result['url'] + ] ); + // Return success response - $this->returnSuccess( $result ); + return $this->renderJson( + HttpResponseStatus::OK, + [ + 'success' => true, + 'data' => $result + ] + ); } catch( \Exception $e ) { - $this->returnError( $e->getMessage() ); - } - } - - /** - * Return Editor.js success response - * - * @param array $result Upload result - * @return void - */ - private function returnEditorJsSuccess( array $result ): void - { - echo json_encode( [ - 'success' => 1, - 'file' => [ - 'url' => $result['url'], - 'width' => $result['width'], - 'height' => $result['height'] - ] - ] ); - exit; - } + // Safely retrieve filename with explicit isset check to prevent undefined index + $filename = isset( $_FILES['image']['name'] ) ? $_FILES['image']['name'] : 'unknown'; - /** - * Return Editor.js error response - * - * @param string $message Error message - * @return void - */ - private function returnEditorJsError( string $message ): void - { - http_response_code( 400 ); - echo json_encode( [ - 'success' => 0, - 'message' => $message - ] ); - exit; - } - - /** - * Return standard success response - * - * @param array $result Upload result - * @return void - */ - private function returnSuccess( array $result ): void - { - echo json_encode( [ - 'success' => true, - 'data' => $result - ] ); - exit; - } + Log::error( 'Featured image upload failed', [ + 'user_id' => user_id(), + 'filename' => $filename, + 'exception' => $e, + 'message' => $e->getMessage() + ] ); - /** - * Return standard error response - * - * @param string $message Error message - * @return void - */ - private function returnError( string $message ): void - { - http_response_code( 400 ); - echo json_encode( [ - 'success' => false, - 'error' => $message - ] ); - exit; + return $this->renderJson( + HttpResponseStatus::INTERNAL_SERVER_ERROR, + [ + 'success' => false, + 'error' => 'Upload failed. Please try again.' + ] + ); + } } } diff --git a/src/Cms/Controllers/Admin/Pages.php b/src/Cms/Controllers/Admin/Pages.php index 9341588..41609d5 100644 --- a/src/Cms/Controllers/Admin/Pages.php +++ b/src/Cms/Controllers/Admin/Pages.php @@ -29,22 +29,41 @@ class Pages extends Content /** * @param Application|null $app + * @param DatabasePageRepository|null $pageRepository + * @param Creator|null $pageCreator + * @param Updater|null $pageUpdater + * @param Deleter|null $pageDeleter * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabasePageRepository $pageRepository = null, + ?Creator $pageCreator = null, + ?Updater $pageUpdater = null, + ?Deleter $pageDeleter = null + ) { parent::__construct( $app ); - // Get settings for repositories - $settings = Registry::getInstance()->get( 'Settings' ); + // Use injected dependencies if provided (for testing), otherwise create them (for production) + if( $pageRepository === null ) + { + // Get settings for repositories + $settings = Registry::getInstance()->get( 'Settings' ); + + // Initialize repository + $pageRepository = new DatabasePageRepository( $settings ); - // Initialize repository - $this->_pageRepository = new DatabasePageRepository( $settings ); + // Initialize services + $pageCreator = new Creator( $pageRepository ); + $pageUpdater = new Updater( $pageRepository ); + $pageDeleter = new Deleter( $pageRepository ); + } - // Initialize services - $this->_pageCreator = new Creator( $this->_pageRepository ); - $this->_pageUpdater = new Updater( $this->_pageRepository ); - $this->_pageDeleter = new Deleter( $this->_pageRepository ); + $this->_pageRepository = $pageRepository; + $this->_pageCreator = $pageCreator; + $this->_pageUpdater = $pageUpdater; + $this->_pageDeleter = $pageDeleter; } /** @@ -55,43 +74,32 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - // Generate CSRF token $sessionManager = $this->getSessionManager(); $csrfToken = new CsrfToken( $sessionManager ); Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); // Get all pages or filter by author if not admin - if( $user->isAdmin() || $user->isEditor() ) + if( is_admin() || is_editor() ) { $pages = $this->_pageRepository->all(); } else { - $pages = $this->_pageRepository->getByAuthor( $user->getId() ); + $pages = $this->_pageRepository->getByAuthor( user_id() ); } - $viewData = [ - 'Title' => 'Pages | ' . $this->getName(), - 'Description' => 'Manage pages', - 'User' => $user, - 'pages' => $pages, - 'Success' => $sessionManager->getFlash( 'success' ), - 'Error' => $sessionManager->getFlash( 'error' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'pages/index', - 'admin' - ); + return $this->view() + ->title( 'Pages' ) + ->description( 'Manage pages' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'pages' => $pages, + 'Success' => $sessionManager->getFlash( 'success' ), + 'Error' => $sessionManager->getFlash( 'error' ) + ]) + ->render( 'pages/index', 'admin' ); } /** @@ -102,29 +110,14 @@ public function index( Request $request ): string */ public function create( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Create Page | ' . $this->getName(), - 'Description' => 'Create a new page', - 'User' => $user - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'pages/create', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Create Page' ) + ->description( 'Create a new page' ) + ->withCurrentUser() + ->withCsrfToken() + ->render( 'pages/create', 'admin' ); } /** @@ -135,23 +128,6 @@ public function create( Request $request ): string */ public function store( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - // Validate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for page creation by user {$user->getId()}" ); - $this->redirect( 'admin_pages_create', [], ['error', 'Invalid security token. Please try again.'] ); - } - try { // Get form data @@ -168,7 +144,7 @@ public function store( Request $request ): never $page = $this->_pageCreator->create( $title, $content, - $user->getId(), + user_id(), $status, $slug ?: null, $template, @@ -179,16 +155,16 @@ public function store( Request $request ): never if( !$page ) { - Log::error( "Page creation failed for user {$user->getId()}, title: {$title}" ); + Log::error( "Page creation failed for user " . user_id() . ", title: {$title}" ); $this->redirect( 'admin_pages_create', [], ['error', 'Failed to create page. Please try again.'] ); } - Log::info( "Page created successfully: ID {$page->getId()}, title: {$title}, by user {$user->getId()}" ); + Log::info( "Page created successfully: ID {$page->getId()}, title: {$title}, by user " . user_id() ); $this->redirect( 'admin_pages', [], ['success', 'Page created successfully'] ); } catch( \Exception $e ) { - Log::error( "Exception during page creation by user {$user->getId()}: {$e->getMessage()}", [ + Log::error( "Exception during page creation by user " . user_id() . ": {$e->getMessage()}", [ 'exception' => $e, 'trace' => $e->getTraceAsString() ] ); @@ -204,13 +180,6 @@ public function store( Request $request ): never */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $pageId = (int)$request->getRouteParameter( 'id' ); $page = $this->_pageRepository->findById( $pageId ); @@ -220,29 +189,21 @@ public function edit( Request $request ): string } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $page->getAuthorId() !== $user->getId() ) + if( !is_admin() && !is_editor() && $page->getAuthorId() !== user_id() ) { - Log::warning( "Unauthorized edit attempt by user {$user->getId()} on page {$pageId}" ); + Log::warning( "Unauthorized edit attempt by user " . user_id() . " on page {$pageId}" ); $this->redirect( 'admin_pages', [], ['error', 'Unauthorized to edit this page'] ); } - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); + $this->initializeCsrfToken(); - $viewData = [ - 'Title' => 'Edit Page | ' . $this->getName(), - 'Description' => 'Edit page', - 'User' => $user, - 'page' => $page - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'pages/edit', - 'admin' - ); + return $this->view() + ->title( 'Edit Page' ) + ->description( 'Edit page' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'page', $page ) + ->render( 'pages/edit', 'admin' ); } /** @@ -253,13 +214,6 @@ public function edit( Request $request ): string */ public function update( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $pageId = (int)$request->getRouteParameter( 'id' ); $page = $this->_pageRepository->findById( $pageId ); @@ -269,22 +223,12 @@ public function update( Request $request ): never } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $page->getAuthorId() !== $user->getId() ) + if( !is_admin() && !is_editor() && $page->getAuthorId() !== user_id() ) { - Log::warning( "Unauthorized page update attempt: User {$user->getId()} tried to edit page {$pageId}" ); + Log::warning( "Unauthorized page update attempt: User " . user_id() . " tried to edit page {$pageId}" ); $this->redirect( 'admin_pages', [], ['error', 'Unauthorized to edit this page'] ); } - // Validate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for page update: Page {$pageId}, user {$user->getId()}" ); - $this->redirect( 'admin_pages_edit', ['id' => $pageId], ['error', 'Invalid security token. Please try again.'] ); - } - try { // Get form data @@ -312,16 +256,16 @@ public function update( Request $request ): never if( !$success ) { - Log::error( "Page update failed: Page {$pageId}, user {$user->getId()}, title: {$title}" ); + Log::error( "Page update failed: Page {$pageId}, user " . user_id() . ", title: {$title}" ); $this->redirect( 'admin_pages_edit', ['id' => $pageId], ['error', 'Failed to update page. Please try again.'] ); } - Log::info( "Page updated successfully: Page {$pageId}, title: {$title}, by user {$user->getId()}" ); + Log::info( "Page updated successfully: Page {$pageId}, title: {$title}, by user " . user_id() ); $this->redirect( 'admin_pages', [], ['success', 'Page updated successfully'] ); } catch( \Exception $e ) { - Log::error( "Exception during page update: Page {$pageId}, user {$user->getId()}: {$e->getMessage()}", [ + Log::error( "Exception during page update: Page {$pageId}, user " . user_id() . ": {$e->getMessage()}", [ 'exception' => $e, 'trace' => $e->getTraceAsString() ] ); @@ -336,13 +280,6 @@ public function update( Request $request ): never */ public function destroy( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $pageId = (int)$request->getRouteParameter( 'id' ); $page = $this->_pageRepository->findById( $pageId ); @@ -352,22 +289,12 @@ public function destroy( Request $request ): never } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $page->getAuthorId() !== $user->getId() ) + if( !is_admin() && !is_editor() && $page->getAuthorId() !== user_id() ) { - Log::warning( "Unauthorized page deletion attempt: User {$user->getId()} tried to delete page {$pageId}" ); + Log::warning( "Unauthorized page deletion attempt: User " . user_id() . " tried to delete page {$pageId}" ); $this->redirect( 'admin_pages', [], ['error', 'Unauthorized to delete this page'] ); } - // Validate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - $submittedToken = $request->post( 'csrf_token', '' ); - - if( !$csrfToken->validate( $submittedToken ) ) - { - Log::warning( "CSRF validation failed for page deletion: Page {$pageId}, user {$user->getId()}" ); - $this->redirect( 'admin_pages', [], ['error', 'Invalid security token. Please try again.'] ); - } - try { $pageTitle = $page->getTitle(); // Store for logging before deletion @@ -376,16 +303,16 @@ public function destroy( Request $request ): never if( !$success ) { - Log::error( "Page deletion failed: Page {$pageId}, user {$user->getId()}" ); + Log::error( "Page deletion failed: Page {$pageId}, user " . user_id() ); $this->redirect( 'admin_pages', [], ['error', 'Failed to delete page. Please try again.'] ); } - Log::info( "Page deleted successfully: Page {$pageId}, title: {$pageTitle}, by user {$user->getId()}" ); + Log::info( "Page deleted successfully: Page {$pageId}, title: {$pageTitle}, by user " . user_id() ); $this->redirect( 'admin_pages', [], ['success', 'Page deleted successfully'] ); } catch( \Exception $e ) { - Log::error( "Exception during page deletion: Page {$pageId}, user {$user->getId()}: {$e->getMessage()}", [ + Log::error( "Exception during page deletion: Page {$pageId}, user " . user_id() . ": {$e->getMessage()}", [ 'exception' => $e, 'trace' => $e->getTraceAsString() ] ); diff --git a/src/Cms/Controllers/Admin/Posts.php b/src/Cms/Controllers/Admin/Posts.php index e260929..3f05b52 100644 --- a/src/Cms/Controllers/Admin/Posts.php +++ b/src/Cms/Controllers/Admin/Posts.php @@ -33,39 +33,86 @@ class Posts extends Content /** * @param Application|null $app + * @param DatabasePostRepository|null $postRepository + * @param DatabaseCategoryRepository|null $categoryRepository + * @param DatabaseTagRepository|null $tagRepository + * @param Creator|null $postCreator + * @param Updater|null $postUpdater + * @param Deleter|null $postDeleter * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabasePostRepository $postRepository = null, + ?DatabaseCategoryRepository $categoryRepository = null, + ?DatabaseTagRepository $tagRepository = null, + ?Creator $postCreator = null, + ?Updater $postUpdater = null, + ?Deleter $postDeleter = null + ) { parent::__construct( $app ); - // Get settings for repositories - $settings = Registry::getInstance()->get( 'Settings' ); + // Get settings once if we need to create any repositories + $settings = null; + if( $postRepository === null || $categoryRepository === null || $tagRepository === null ) + { + $settings = Registry::getInstance()->get( 'Settings' ); + } + + // Individually ensure each repository is initialized + if( $postRepository === null ) + { + $postRepository = new DatabasePostRepository( $settings ); + } + + if( $categoryRepository === null ) + { + $categoryRepository = new DatabaseCategoryRepository( $settings ); + } - // Initialize repositories - $this->_postRepository = new DatabasePostRepository( $settings ); - $this->_categoryRepository = new DatabaseCategoryRepository( $settings ); - $this->_tagRepository = new DatabaseTagRepository( $settings ); + if( $tagRepository === null ) + { + $tagRepository = new DatabaseTagRepository( $settings ); + } - // Initialize services + // Build downstream services using guaranteed non-null repositories + // Create TagResolver if needed for Creator/Updater services $tagResolver = new TagResolver( - $this->_tagRepository, - new \Neuron\Cms\Services\Tag\Creator( $this->_tagRepository ) + $tagRepository, + new \Neuron\Cms\Services\Tag\Creator( $tagRepository ) ); - $this->_postCreator = new Creator( - $this->_postRepository, - $this->_categoryRepository, - $tagResolver - ); + if( $postCreator === null ) + { + $postCreator = new Creator( + $postRepository, + $categoryRepository, + $tagResolver + ); + } - $this->_postUpdater = new Updater( - $this->_postRepository, - $this->_categoryRepository, - $tagResolver - ); + if( $postUpdater === null ) + { + $postUpdater = new Updater( + $postRepository, + $categoryRepository, + $tagResolver + ); + } - $this->_postDeleter = new Deleter( $this->_postRepository ); + if( $postDeleter === null ) + { + $postDeleter = new Deleter( $postRepository ); + } + + // Assign to properties with defensive checks + $this->_postRepository = $postRepository; + $this->_categoryRepository = $categoryRepository; + $this->_tagRepository = $tagRepository; + $this->_postCreator = $postCreator; + $this->_postUpdater = $postUpdater; + $this->_postDeleter = $postDeleter; } /** @@ -76,43 +123,30 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - // Generate CSRF token - $sessionManager = $this->getSessionManager(); - $csrfToken = new CsrfToken( $sessionManager ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); + $this->initializeCsrfToken(); // Get all posts or filter by author if not admin - if( $user->isAdmin() || $user->isEditor() ) + if( is_admin() || is_editor() ) { $posts = $this->_postRepository->all(); } else { - $posts = $this->_postRepository->getByAuthor( $user->getUsername() ); + $posts = $this->_postRepository->getByAuthor( user_id() ); } - $viewData = [ - 'Title' => 'Posts | ' . $this->getName(), - 'Description' => 'Manage blog posts', - 'User' => $user, - 'posts' => $posts, - 'Success' => $sessionManager->getFlash( 'success' ), - 'Error' => $sessionManager->getFlash( 'error' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'admin' - ); + $sessionManager = $this->getSessionManager(); + return $this->view() + ->title( 'Posts' ) + ->description( 'Manage blog posts' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'posts' => $posts, + 'Success' => $sessionManager->getFlash( 'success' ), + 'Error' => $sessionManager->getFlash( 'error' ) + ]) + ->render( 'index', 'admin' ); } /** @@ -123,30 +157,15 @@ public function index( Request $request ): string */ public function create( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Create Post | ' . $this->getName(), - 'Description' => 'Create a new blog post', - 'User' => $user, - 'categories' => $this->_categoryRepository->all() - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'create', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Create Post' ) + ->description( 'Create a new blog post' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'categories', $this->_categoryRepository->all() ) + ->render( 'create', 'admin' ); } /** @@ -157,13 +176,6 @@ public function create( Request $request ): string */ public function store( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - try { // Get form data @@ -180,7 +192,7 @@ public function store( Request $request ): never $this->_postCreator->create( $title, $content, - $user->getId(), + user_id(), $status, $slug ?: null, $excerpt ?: null, @@ -205,13 +217,6 @@ public function store( Request $request ): never */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $postId = (int)$request->getRouteParameter( 'id' ); $post = $this->_postRepository->findById( $postId ); @@ -221,29 +226,23 @@ public function edit( Request $request ): string } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $post->getAuthorId() !== $user->getId() ) + if( !is_admin() && !is_editor() && $post->getAuthorId() !== user_id() ) { throw new \RuntimeException( 'Unauthorized to edit this post' ); } - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Edit Post | ' . $this->getName(), - 'Description' => 'Edit blog post', - 'User' => $user, - 'post' => $post, - 'categories' => $this->_categoryRepository->all() - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Edit Post' ) + ->description( 'Edit blog post' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'post' => $post, + 'categories' => $this->_categoryRepository->all() + ]) + ->render( 'edit', 'admin' ); } /** @@ -254,12 +253,6 @@ public function edit( Request $request ): string */ public function update( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } $postId = (int)$request->getRouteParameter( 'id' ); $post = $this->_postRepository->findById( $postId ); @@ -270,7 +263,7 @@ public function update( Request $request ): never } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $post->getAuthorId() !== $user->getId() ) + if( !is_admin() && !is_editor() && $post->getAuthorId() !== user_id() ) { throw new \RuntimeException( 'Unauthorized to edit this post' ); } @@ -315,12 +308,6 @@ public function update( Request $request ): never */ public function destroy( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } $postId = (int)$request->getRouteParameter( 'id' ); $post = $this->_postRepository->findById( $postId ); @@ -331,7 +318,7 @@ public function destroy( Request $request ): never } // Check permissions - if( !$user->isAdmin() && !$user->isEditor() && $post->getAuthorId() !== $user->getId() ) + if( !is_admin() && !is_editor() && $post->getAuthorId() !== user_id() ) { $this->redirect( 'admin_posts', [], ['error', 'Unauthorized to delete this post'] ); } diff --git a/src/Cms/Controllers/Admin/Profile.php b/src/Cms/Controllers/Admin/Profile.php index 74ff398..99f6126 100644 --- a/src/Cms/Controllers/Admin/Profile.php +++ b/src/Cms/Controllers/Admin/Profile.php @@ -26,19 +26,42 @@ class Profile extends Content /** * @param Application|null $app + * @param DatabaseUserRepository|null $repository + * @param PasswordHasher|null $hasher + * @param Updater|null $userUpdater * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabaseUserRepository $repository = null, + ?PasswordHasher $hasher = null, + ?Updater $userUpdater = null + ) { parent::__construct( $app ); - // Get settings and initialize repository - $settings = Registry::getInstance()->get( 'Settings' ); - $this->_repository = new DatabaseUserRepository( $settings ); - $this->_hasher = new PasswordHasher(); + // Get settings if we need to create repository + if( $repository === null ) + { + $settings = Registry::getInstance()->get( 'Settings' ); + $repository = new DatabaseUserRepository( $settings ); + } + + // Create hasher if not provided + if( $hasher === null ) + { + $hasher = new PasswordHasher(); + } - // Initialize service - $this->_userUpdater = new Updater( $this->_repository, $this->_hasher ); + // Create updater if not provided + if( $userUpdater === null ) + { + $userUpdater = new Updater( $repository, $hasher ); + } + + $this->_repository = $repository; + $this->_hasher = $hasher; + $this->_userUpdater = $userUpdater; } /** @@ -49,36 +72,30 @@ public function __construct( ?Application $app = null ) */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); + $this->initializeCsrfToken(); + // Get authenticated user once + $user = auth(); if( !$user ) { throw new \RuntimeException( 'Authenticated user not found' ); } - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - // Get available timezones grouped by region with selection state $timezones = \DateTimeZone::listIdentifiers(); $groupedTimezones = group_timezones_for_select( $timezones, $user->getTimezone() ); - $viewData = [ - 'Title' => 'Profile | ' . $this->getName(), - 'Description' => 'Edit Your Profile', - 'User' => $user, - 'groupedTimezones' => $groupedTimezones, - 'success' => $this->getSessionManager()->getFlash( 'success' ), - 'error' => $this->getSessionManager()->getFlash( 'error' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'admin' - ); + return $this->view() + ->title( 'Profile' ) + ->description( 'Edit Your Profile' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'groupedTimezones' => $groupedTimezones, + 'success' => $this->getSessionManager()->getFlash( 'success' ), + 'error' => $this->getSessionManager()->getFlash( 'error' ) + ]) + ->render( 'edit', 'admin' ); } /** @@ -89,11 +106,11 @@ public function edit( Request $request ): string */ public function update( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - + // Get authenticated user once and check for null + $user = auth(); if( !$user ) { - throw new \RuntimeException( 'Authenticated user not found' ); + $this->redirect( 'admin_profile', [], ['error', 'Authenticated user not found'] ); } // Security: Only use email from POST if provided by Account Information form diff --git a/src/Cms/Controllers/Admin/Tags.php b/src/Cms/Controllers/Admin/Tags.php index c4503b9..cab9b02 100644 --- a/src/Cms/Controllers/Admin/Tags.php +++ b/src/Cms/Controllers/Admin/Tags.php @@ -21,17 +21,27 @@ class Tags extends Content /** * @param Application|null $app + * @param DatabaseTagRepository|null $tagRepository * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabaseTagRepository $tagRepository = null + ) { parent::__construct( $app ); - // Get settings for repositories - $settings = Registry::getInstance()->get( 'Settings' ); + // Use injected dependencies if provided (for testing), otherwise create them (for production) + if( $tagRepository === null ) + { + // Get settings for repositories + $settings = Registry::getInstance()->get( 'Settings' ); + + // Initialize repository + $tagRepository = new DatabaseTagRepository( $settings ); + } - // Initialize repository - $this->_tagRepository = new DatabaseTagRepository( $settings ); + $this->_tagRepository = $tagRepository; } /** @@ -42,28 +52,15 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - $tagsWithCount = $this->_tagRepository->allWithPostCount(); - - $viewData = [ - 'Title' => 'Tags | Admin | ' . $this->getName(), - 'Description' => 'Manage blog tags', - 'User' => $user, - 'TagsWithCount' => $tagsWithCount - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'tags' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Tags | Admin' ) + ->description( 'Manage blog tags' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'TagsWithCount', $this->_tagRepository->allWithPostCount() ) + ->render( 'index', 'tags' ); } /** @@ -74,25 +71,14 @@ public function index( Request $request ): string */ public function create( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - - $viewData = [ - 'Title' => 'Create Tag | Admin | ' . $this->getName(), - 'Description' => 'Create a new blog tag', - 'User' => $user - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'create', - 'tags' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Create Tag | Admin' ) + ->description( 'Create a new blog tag' ) + ->withCurrentUser() + ->withCsrfToken() + ->render( 'create', 'tags' ); } /** @@ -134,13 +120,6 @@ public function store( Request $request ): never */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found' ); - } - $tagId = (int)$request->getRouteParameter( 'id' ); $tag = $this->_tagRepository->findById( $tagId ); @@ -149,19 +128,15 @@ public function edit( Request $request ): string throw new \RuntimeException( 'Tag not found' ); } - $viewData = [ - 'Title' => 'Edit Tag | Admin | ' . $this->getName(), - 'Description' => 'Edit blog tag', - 'User' => $user, - 'Tag' => $tag - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'tags' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Edit Tag | Admin' ) + ->description( 'Edit blog tag' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'Tag', $tag ) + ->render( 'edit', 'tags' ); } /** diff --git a/src/Cms/Controllers/Admin/Users.php b/src/Cms/Controllers/Admin/Users.php index 5a9ab06..b116632 100644 --- a/src/Cms/Controllers/Admin/Users.php +++ b/src/Cms/Controllers/Admin/Users.php @@ -30,21 +30,40 @@ class Users extends Content /** * @param Application|null $app + * @param DatabaseUserRepository|null $repository + * @param Creator|null $userCreator + * @param Updater|null $userUpdater + * @param Deleter|null $userDeleter * @throws \Exception */ - public function __construct( ?Application $app = null ) + public function __construct( + ?Application $app = null, + ?DatabaseUserRepository $repository = null, + ?Creator $userCreator = null, + ?Updater $userUpdater = null, + ?Deleter $userDeleter = null + ) { parent::__construct( $app ); - // Get settings and initialize repository - $settings = Registry::getInstance()->get( 'Settings' ); - $this->_repository = new DatabaseUserRepository( $settings ); + // Use injected dependencies if provided (for testing), otherwise create them (for production) + if( $repository === null ) + { + // Get settings and initialize repository + $settings = Registry::getInstance()->get( 'Settings' ); + $repository = new DatabaseUserRepository( $settings ); + + // Initialize services + $hasher = new PasswordHasher(); + $userCreator = new Creator( $repository, $hasher ); + $userUpdater = new Updater( $repository, $hasher ); + $userDeleter = new Deleter( $repository ); + } - // Initialize services - $hasher = new PasswordHasher(); - $this->_userCreator = new Creator( $this->_repository, $hasher ); - $this->_userUpdater = new Updater( $this->_repository, $hasher ); - $this->_userDeleter = new Deleter( $this->_repository ); + $this->_repository = $repository; + $this->_userCreator = $userCreator; + $this->_userUpdater = $userUpdater; + $this->_userDeleter = $userDeleter; } /** @@ -55,29 +74,21 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $users = $this->_repository->all(); - - $viewData = [ - 'Title' => 'Users | ' . $this->getName(), - 'Description' => 'User Management', - 'User' => $user, - 'users' => $users, - 'Success' => $this->getSessionManager()->getFlash( 'success' ), - 'Error' => $this->getSessionManager()->getFlash( 'error' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'admin' - ); + $this->initializeCsrfToken(); + + $sessionManager = $this->getSessionManager(); + + return $this->view() + ->title( 'Users' ) + ->description( 'User Management' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'users' => $this->_repository->all(), + 'Success' => $sessionManager->getFlash( 'success' ), + 'Error' => $sessionManager->getFlash( 'error' ) + ]) + ->render( 'index', 'admin' ); } /** @@ -88,25 +99,15 @@ public function index( Request $request ): string */ public function create( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); - - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Create User | ' . $this->getName(), - 'Description' => 'Create New User', - 'User' => $user, - 'roles' => [User::ROLE_ADMIN, User::ROLE_EDITOR, User::ROLE_AUTHOR, User::ROLE_SUBSCRIBER] - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'create', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Create User' ) + ->description( 'Create New User' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'roles', [User::ROLE_ADMIN, User::ROLE_EDITOR, User::ROLE_AUTHOR, User::ROLE_SUBSCRIBER] ) + ->render( 'create', 'admin' ); } /** @@ -149,7 +150,6 @@ public function store( Request $request ): never public function edit( Request $request ): string { $id = (int)$request->getRouteParameter( 'id' ); - $currentUser = Registry::getInstance()->get( 'Auth.User' ); $user = $this->_repository->findById( $id ); if( !$user ) @@ -157,24 +157,18 @@ public function edit( Request $request ): string $this->redirect( 'admin_users', [], ['error', 'User not found'] ); } - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Edit User | ' . $this->getName(), - 'Description' => 'Edit User', - 'User' => $currentUser, - 'user' => $user, - 'roles' => [User::ROLE_ADMIN, User::ROLE_EDITOR, User::ROLE_AUTHOR, User::ROLE_SUBSCRIBER] - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'admin' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Edit User' ) + ->description( 'Edit User' ) + ->withCurrentUser() + ->withCsrfToken() + ->with([ + 'user' => $user, + 'roles' => [User::ROLE_ADMIN, User::ROLE_EDITOR, User::ROLE_AUTHOR, User::ROLE_SUBSCRIBER] + ]) + ->render( 'edit', 'admin' ); } /** @@ -229,10 +223,9 @@ public function update( Request $request ): never public function destroy( Request $request ): never { $id = (int)$request->getRouteParameter( 'id' ); - $currentUser = Registry::getInstance()->get( 'Auth.User' ); // Prevent self-deletion - if( $currentUser && $currentUser->getId() === $id ) + if( user_id() === $id ) { $this->redirect( 'admin_users', [], ['error', 'Cannot delete your own account'] ); } diff --git a/src/Cms/Controllers/Auth/Login.php b/src/Cms/Controllers/Auth/Login.php index 0d65b87..a33c764 100644 --- a/src/Cms/Controllers/Auth/Login.php +++ b/src/Cms/Controllers/Auth/Login.php @@ -70,20 +70,14 @@ public function showLoginForm( Request $request ): string ? $requestedRedirect : $defaultRedirect; - $viewData = [ - 'Title' => 'Login | ' . $this->getName(), - 'Description' => 'Login to ' . $this->getName(), - 'Error' => $this->getSessionManager()->getFlash( 'error' ), - 'Success' => $this->getSessionManager()->getFlash( 'success' ), - 'RedirectUrl' => $redirectUrl - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'login', - 'auth' - ); + return $this->view() + ->title( 'Login' ) + ->description( 'Login to ' . $this->getName() ) + ->withCsrfToken() + ->with( 'Error', $this->getSessionManager()->getFlash( 'error' ) ) + ->with( 'Success', $this->getSessionManager()->getFlash( 'success' ) ) + ->with( 'RedirectUrl', $redirectUrl ) + ->render( 'login', 'auth' ); } /** diff --git a/src/Cms/Controllers/Auth/PasswordReset.php b/src/Cms/Controllers/Auth/PasswordReset.php index 9978301..3f0689b 100644 --- a/src/Cms/Controllers/Auth/PasswordReset.php +++ b/src/Cms/Controllers/Auth/PasswordReset.php @@ -58,20 +58,14 @@ public function showForgotPasswordForm( Request $request ): string // Set CSRF token in Registry so csrf_field() helper works Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfToken->getToken() ); - $viewData = [ - 'Title' => 'Forgot Password | ' . $this->getName(), - 'Description' => 'Reset your password', - 'PageSubtitle' => 'Reset Password', - 'Error' => $this->_sessionManager->getFlash( 'error' ), - 'Success' => $this->_sessionManager->getFlash( 'success' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'forgot-password', - 'auth' - ); + return $this->view() + ->title( 'Forgot Password' ) + ->description( 'Reset your password' ) + ->withCsrfToken() + ->with( 'PageSubtitle', 'Reset Password' ) + ->with( 'Error', $this->_sessionManager->getFlash( 'error' ) ) + ->with( 'Success', $this->_sessionManager->getFlash( 'success' ) ) + ->render( 'forgot-password', 'auth' ); } /** @@ -157,22 +151,16 @@ public function showResetForm( Request $request ): string // Set CSRF token in Registry so csrf_field() helper works Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfToken->getToken() ); - $viewData = [ - 'Title' => 'Reset Password | ' . $this->getName(), - 'Description' => 'Enter your new password', - 'PageSubtitle' => 'Create New Password', - 'Error' => $this->_sessionManager->getFlash( 'error' ), - 'Success' => $this->_sessionManager->getFlash( 'success' ), - 'Token' => $token, - 'Email' => $tokenObj->getEmail() - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'reset-password', - 'auth' - ); + return $this->view() + ->title( 'Reset Password' ) + ->description( 'Enter your new password' ) + ->withCsrfToken() + ->with( 'PageSubtitle', 'Create New Password' ) + ->with( 'Error', $this->_sessionManager->getFlash( 'error' ) ) + ->with( 'Success', $this->_sessionManager->getFlash( 'success' ) ) + ->with( 'Token', $token ) + ->with( 'Email', $tokenObj->getEmail() ) + ->render( 'reset-password', 'auth' ); } /** diff --git a/src/Cms/Controllers/Content.php b/src/Cms/Controllers/Content.php index bd26a1a..314f003 100644 --- a/src/Cms/Controllers/Content.php +++ b/src/Cms/Controllers/Content.php @@ -299,4 +299,16 @@ protected function flash( string $type, string $message ): void { $this->getSessionManager()->flash( $type, $message ); } + + /** + * Initialize CSRF token and store in Registry. + * Should be called by controllers that render forms requiring CSRF protection. + * + * @return void + */ + protected function initializeCsrfToken(): void + { + $csrfToken = new \Neuron\Cms\Services\Auth\CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); + } } diff --git a/src/Cms/Controllers/Member/Dashboard.php b/src/Cms/Controllers/Member/Dashboard.php index 7e3393d..24c074a 100644 --- a/src/Cms/Controllers/Member/Dashboard.php +++ b/src/Cms/Controllers/Member/Dashboard.php @@ -36,29 +36,13 @@ public function __construct( ?Application $app = null ) */ public function index( Request $request ): string { - // Get authenticated user from Registry - $user = Registry::getInstance()->get( 'Auth.User' ); - - if( !$user ) - { - throw new \RuntimeException( 'Authenticated user not found in Registry' ); - } - - // Generate CSRF token and store in Registry - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - - $viewData = [ - 'Title' => 'Member Dashboard | ' . $this->getName(), - 'Description' => 'Member Dashboard', - 'User' => $user - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'index', - 'member' - ); + $this->initializeCsrfToken(); + + return $this->view() + ->title( 'Member Dashboard' ) + ->description( 'Member Dashboard' ) + ->withCurrentUser() + ->withCsrfToken() + ->render( 'index', 'member' ); } } diff --git a/src/Cms/Controllers/Member/Profile.php b/src/Cms/Controllers/Member/Profile.php index 08ab355..3457d33 100644 --- a/src/Cms/Controllers/Member/Profile.php +++ b/src/Cms/Controllers/Member/Profile.php @@ -49,36 +49,28 @@ public function __construct( ?Application $app = null ) */ public function edit( Request $request ): string { - $user = Registry::getInstance()->get( 'Auth.User' ); + $this->initializeCsrfToken(); + // Get authenticated user once + $user = auth(); if( !$user ) { throw new \RuntimeException( 'Authenticated user not found' ); } - // Generate CSRF token - $csrfToken = new CsrfToken( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - // Get available timezones grouped by region with selection state $timezones = \DateTimeZone::listIdentifiers(); $groupedTimezones = group_timezones_for_select( $timezones, $user->getTimezone() ); - $viewData = [ - 'Title' => 'Profile | ' . $this->getName(), - 'Description' => 'Edit Your Profile', - 'User' => $user, - 'groupedTimezones' => $groupedTimezones, - 'success' => $this->getSessionManager()->getFlash( 'success' ), - 'error' => $this->getSessionManager()->getFlash( 'error' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'edit', - 'member' - ); + return $this->view() + ->title( 'Profile' ) + ->description( 'Edit Your Profile' ) + ->withCurrentUser() + ->withCsrfToken() + ->with( 'groupedTimezones', $groupedTimezones ) + ->with( 'success', $this->getSessionManager()->getFlash( 'success' ) ) + ->with( 'error', $this->getSessionManager()->getFlash( 'error' ) ) + ->render( 'edit', 'member' ); } /** @@ -90,11 +82,11 @@ public function edit( Request $request ): string */ public function update( Request $request ): never { - $user = Registry::getInstance()->get( 'Auth.User' ); - + // Get authenticated user once and check for null + $user = auth(); if( !$user ) { - throw new \RuntimeException( 'Authenticated user not found' ); + $this->redirect( 'member_profile', [], ['error', 'Authenticated user not found'] ); } // Security: Only use email from POST if provided by Account Information form diff --git a/src/Cms/Controllers/Member/Registration.php b/src/Cms/Controllers/Member/Registration.php index 4febd7e..2cb034c 100644 --- a/src/Cms/Controllers/Member/Registration.php +++ b/src/Cms/Controllers/Member/Registration.php @@ -71,35 +71,22 @@ public function showRegistrationForm( Request $request ): string // Check if registration is enabled if( !$this->_registrationService->isRegistrationEnabled() ) { - $viewData = [ - 'Title' => 'Registration Disabled | ' . $this->getName(), - 'Description' => 'User registration is currently disabled' - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'registration-disabled', - 'member' - ); + return $this->view() + ->title( 'Registration Disabled' ) + ->description( 'User registration is currently disabled' ) + ->render( 'registration-disabled', 'member' ); } // Set CSRF token in Registry Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfToken->getToken() ); - $viewData = [ - 'Title' => 'Register | ' . $this->getName(), - 'Description' => 'Create your account', - 'Error' => $this->getSessionManager()->getFlash( 'error' ), - 'Success' => $this->getSessionManager()->getFlash( 'success' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'register', - 'member' - ); + return $this->view() + ->title( 'Register' ) + ->description( 'Create your account' ) + ->withCsrfToken() + ->with( 'Error', $this->getSessionManager()->getFlash( 'error' ) ) + ->with( 'Success', $this->getSessionManager()->getFlash( 'success' ) ) + ->render( 'register', 'member' ); } /** @@ -165,18 +152,11 @@ public function processRegistration( Request $request ): never */ public function showVerificationSent( Request $request ): string { - $viewData = [ - 'Title' => 'Verify Your Email | ' . $this->getName(), - 'Description' => 'Please check your email', - 'Success' => $this->getSessionManager()->getFlash( 'success' ) - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'verify-email-sent', - 'member' - ); + return $this->view() + ->title( 'Verify Your Email' ) + ->description( 'Please check your email' ) + ->with( 'Success', $this->getSessionManager()->getFlash( 'success' ) ) + ->render( 'verify-email-sent', 'member' ); } /** @@ -193,19 +173,12 @@ public function verify( Request $request ): string if( empty( $token ) ) { - $viewData = [ - 'Title' => 'Email Verification | ' . $this->getName(), - 'Description' => 'Email verification', - 'Success' => false, - 'Message' => 'Invalid or missing verification token.' - ]; - - return $this->renderHtml( - HttpResponseStatus::BAD_REQUEST, - $viewData, - 'email-verified', - 'member' - ); + return $this->view() + ->title( 'Email Verification' ) + ->description( 'Email verification' ) + ->with( 'Success', false ) + ->with( 'Message', 'Invalid or missing verification token.' ) + ->render( 'email-verified', 'member', HttpResponseStatus::BAD_REQUEST ); } try @@ -215,52 +188,31 @@ public function verify( Request $request ): string if( $success ) { - $viewData = [ - 'Title' => 'Email Verified | ' . $this->getName(), - 'Description' => 'Your email has been verified', - 'Success' => true, - 'Message' => 'Your email has been verified successfully! You can now log in.' - ]; - - return $this->renderHtml( - HttpResponseStatus::OK, - $viewData, - 'email-verified', - 'member' - ); + return $this->view() + ->title( 'Email Verified' ) + ->description( 'Your email has been verified' ) + ->with( 'Success', true ) + ->with( 'Message', 'Your email has been verified successfully! You can now log in.' ) + ->render( 'email-verified', 'member' ); } else { - $viewData = [ - 'Title' => 'Email Verification | ' . $this->getName(), - 'Description' => 'Email verification failed', - 'Success' => false, - 'Message' => 'This verification link is invalid or has expired.' - ]; - - return $this->renderHtml( - HttpResponseStatus::BAD_REQUEST, - $viewData, - 'email-verified', - 'member' - ); + return $this->view() + ->title( 'Email Verification' ) + ->description( 'Email verification failed' ) + ->with( 'Success', false ) + ->with( 'Message', 'This verification link is invalid or has expired.' ) + ->render( 'email-verified', 'member', HttpResponseStatus::BAD_REQUEST ); } } catch( Exception $e ) { - $viewData = [ - 'Title' => 'Email Verification | ' . $this->getName(), - 'Description' => 'Email verification error', - 'Success' => false, - 'Message' => 'An error occurred during email verification. Please try again later.' - ]; - - return $this->renderHtml( - HttpResponseStatus::INTERNAL_SERVER_ERROR, - $viewData, - 'email-verified', - 'member' - ); + return $this->view() + ->title( 'Email Verification' ) + ->description( 'Email verification error' ) + ->with( 'Success', false ) + ->with( 'Message', 'An error occurred during email verification. Please try again later.' ) + ->render( 'email-verified', 'member', HttpResponseStatus::INTERNAL_SERVER_ERROR ); } } diff --git a/src/Cms/Exceptions/CsrfValidationException.php b/src/Cms/Exceptions/CsrfValidationException.php new file mode 100644 index 0000000..022e62a --- /dev/null +++ b/src/Cms/Exceptions/CsrfValidationException.php @@ -0,0 +1,36 @@ +_userMessage = $userMessage; + } + + /** + * Get user-friendly message suitable for display + */ + public function getUserMessage(): string + { + return $this->_userMessage; + } +} diff --git a/src/Cms/Exceptions/EmailVerificationRequiredException.php b/src/Cms/Exceptions/EmailVerificationRequiredException.php new file mode 100644 index 0000000..3a11129 --- /dev/null +++ b/src/Cms/Exceptions/EmailVerificationRequiredException.php @@ -0,0 +1,36 @@ +_verificationUrl = $verificationUrl; + } + + /** + * Get URL to redirect to (verification page) + */ + public function getVerificationUrl(): string + { + return $this->_verificationUrl; + } +} diff --git a/src/Cms/Exceptions/UnauthenticatedException.php b/src/Cms/Exceptions/UnauthenticatedException.php new file mode 100644 index 0000000..d52168a --- /dev/null +++ b/src/Cms/Exceptions/UnauthenticatedException.php @@ -0,0 +1,47 @@ +_redirectUrl = $redirectUrl; + $this->_intendedUrl = $intendedUrl; + } + + /** + * Get URL to redirect to (login page) + */ + public function getRedirectUrl(): string + { + return $this->_redirectUrl; + } + + /** + * Get original URL user was trying to access + */ + public function getIntendedUrl(): string + { + return $this->_intendedUrl; + } +} diff --git a/src/Cms/Services/Content/EditorJsRenderer.php b/src/Cms/Services/Content/EditorJsRenderer.php index 42ea044..aabb959 100644 --- a/src/Cms/Services/Content/EditorJsRenderer.php +++ b/src/Cms/Services/Content/EditorJsRenderer.php @@ -57,6 +57,7 @@ private function renderBlock( array $block ): string 'code' => $this->renderCode( $data ), 'delimiter' => $this->renderDelimiter( $data ), 'raw' => $this->renderRaw( $data ), + 'embed' => $this->renderEmbed( $data ), default => $this->renderUnknown( $type ) }; } @@ -246,6 +247,87 @@ private function renderRaw( array $data ): string return $this->sanitizeHtml( $html ) . "\n"; } + /** + * Render embed block + */ + private function renderEmbed( array $data ): string + { + $service = $data['service'] ?? ''; + $source = $data['source'] ?? ''; + $embed = $data['embed'] ?? ''; + $width = $data['width'] ?? 580; + $height = $data['height'] ?? 320; + $caption = htmlspecialchars( $data['caption'] ?? '' ); + + // If no embed URL, return comment + if( empty( $embed ) ) + { + return "\n"; + } + + // Sanitize embed URL - only allow trusted domains + $allowedDomains = [ + 'youtube.com', + 'youtube-nocookie.com', + 'youtu.be', + 'vimeo.com', + 'player.vimeo.com', + 'twitter.com', + 'platform.twitter.com', + 'instagram.com', + 'facebook.com', + 'codepen.io', + 'github.com', + 'gist.github.com', + 'coub.com', + 'imgur.com', + 'gfycat.com', + 'giphy.com', + 'reddit.com', + 'open.spotify.com', + 'soundcloud.com', + 'twitch.tv', + 'player.twitch.tv', + 'miro.com', + 'figma.com' + ]; + + $embedUrl = htmlspecialchars( $embed ); + $parsedUrl = parse_url( $embed ); + $host = $parsedUrl['host'] ?? ''; + + // Check if domain is allowed + $isAllowed = false; + foreach( $allowedDomains as $domain ) + { + if( $host === $domain || str_ends_with( $host, '.' . $domain ) ) + { + $isAllowed = true; + break; + } + } + + if( !$isAllowed ) + { + return "\n"; + } + + // Create responsive embed container + $html = "