diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faaf909..79ce16c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,30 +5,29 @@ on: [push] jobs: build-test: runs-on: ubuntu-latest - container: - image: php:8.4 # This forces the job to run in a Docker container steps: - name: Checkout uses: actions/checkout@v3 - - name: Install System Dependencies (Git, Zip, Unzip) - run: | - apt-get update - apt-get install -y unzip git zip - - - name: Install and Enable extensions - run: | - docker-php-ext-install sockets calendar - docker-php-ext-enable sockets calendar - - - name: Install Composer - run: | - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer --version + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: sockets, calendar, pcov + coverage: pcov - name: Install Dependencies - run: composer install --prefer-dist --no-progress - - - name: Run PHPUnit - run: vendor/bin/phpunit tests + run: composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader + + - name: Run PHPUnit with Coverage + run: vendor/bin/phpunit tests --coverage-clover coverage.xml --coverage-filter src + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: cms + slug: Neuron-PHP/cms + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index b96832e..e216470 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ cache.properties composer.lock .phpunit.result.cache examples/test.log + +coverage.xml diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..f5064d8 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,464 @@ +# Database Migrations Guide + +This document provides comprehensive guidance for working with database migrations in Neuron CMS. + +## Table of Contents + +1. [Core Principles](#core-principles) +2. [Migration Workflow](#migration-workflow) +3. [Common Scenarios](#common-scenarios) +4. [Upgrade Path Considerations](#upgrade-path-considerations) +5. [Best Practices](#best-practices) +6. [Troubleshooting](#troubleshooting) + +## Core Principles + +### Never Modify Existing Migrations + +**CRITICAL RULE: Once a migration has been committed to the repository, NEVER modify it.** + +**Why?** +- Phinx tracks which migrations have been executed using a `phinxlog` table +- Existing installations have already run the original migration +- Modifying an existing migration will NOT update those installations +- This creates schema drift between installations + +**Example of What NOT to Do:** + +```php +// ❌ WRONG: Editing cms/resources/database/migrate/20250111000000_create_users_table.php +// to add a new column after it's already been committed +public function change() +{ + $table = $this->table( 'users' ); + $table->addColumn( 'username', 'string' ) + ->addColumn( 'email', 'string' ) + ->addColumn( 'new_column', 'string' ) // DON'T ADD THIS HERE! + ->create(); +} +``` + +### Always Create New Migrations for Schema Changes + +**Correct Approach:** Create a new migration file with a new timestamp. + +```php +// ✅ CORRECT: Create cms/resources/database/migrate/20251205000000_add_new_column_to_users.php +use Phinx\Migration\AbstractMigration; + +class AddNewColumnToUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addColumn( 'new_column', 'string', [ 'null' => true ] ) + ->update(); + } +} +``` + +## Migration Workflow + +### Creating a New Migration + +1. **Generate migration file with timestamp:** + ```bash + # Format: YYYYMMDDHHMMSS_description_of_change.php + # Example: 20251205143000_add_timezone_to_users.php + ``` + +2. **Use descriptive names:** + - `add_[column]_to_[table].php` - Adding columns + - `remove_[column]_from_[table].php` - Removing columns + - `create_[table]_table.php` - Creating new tables + - `rename_[old]_to_[new]_in_[table].php` - Renaming columns + +3. **Place migrations in the correct location:** + - CMS component: `cms/resources/database/migrate/` + - Test installations: `testing/*/db/migrate/` + +### Implementing the Migration + +```php +table( 'users' ); + + $table->addColumn( 'timezone', 'string', [ + 'limit' => 50, + 'default' => 'UTC', + 'null' => false, + 'after' => 'last_login_at' // Optional: specify column position + ]) + ->update(); + } +} +``` + +### Testing the Migration + +1. **Test in development environment:** + ```bash + php neuron db:migrate + ``` + +2. **Test rollback (if applicable):** + ```bash + php neuron db:rollback + ``` + +3. **Verify schema changes:** + ```bash + # SQLite + sqlite3 storage/database.sqlite3 "PRAGMA table_info(users);" + + # MySQL + mysql -u user -p -e "DESCRIBE users;" database_name + ``` + +## Common Scenarios + +### Adding a Column to an Existing Table + +```php +class AddRecoveryCodeToUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addColumn( 'two_factor_recovery_codes', 'text', [ + 'null' => true, + 'comment' => 'JSON-encoded recovery codes for 2FA' + ]) + ->update(); + } +} +``` + +### Adding Multiple Columns + +```php +class AddUserPreferences extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addColumn( 'timezone', 'string', [ 'limit' => 50, 'default' => 'UTC' ] ) + ->addColumn( 'language', 'string', [ 'limit' => 10, 'default' => 'en' ] ) + ->addColumn( 'theme', 'string', [ 'limit' => 20, 'default' => 'light' ] ) + ->update(); + } +} +``` + +### Renaming a Column + +```php +class RenamePasswordHashInUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->renameColumn( 'password_hash', 'hashed_password' ) + ->update(); + } +} +``` + +### Adding an Index + +```php +class AddTimezoneIndexToUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addIndex( [ 'timezone' ], [ 'name' => 'idx_users_timezone' ] ) + ->update(); + } +} +``` + +### Modifying a Column (Breaking Change) + +When you need to change a column's type or constraints: + +```php +class ModifyEmailColumnInUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + + // Phinx doesn't directly support changeColumn in all cases + // You may need to use raw SQL for complex changes + $table->changeColumn( 'email', 'string', [ + 'limit' => 320, // Changed from 255 to support longer emails + 'null' => false + ]) + ->update(); + } +} +``` + +### Creating a New Table (with Foreign Keys) + +```php +class CreateSessionsTable extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'sessions' ); + + $table->addColumn( 'user_id', 'integer', [ 'null' => false ] ) + ->addColumn( 'token', 'string', [ 'limit' => 64 ] ) + ->addColumn( 'ip_address', 'string', [ 'limit' => 45, 'null' => true ] ) + ->addColumn( 'user_agent', 'string', [ 'limit' => 255, 'null' => true ] ) + ->addColumn( 'expires_at', 'timestamp', [ 'null' => false ] ) + ->addColumn( 'created_at', 'timestamp', [ 'default' => 'CURRENT_TIMESTAMP' ] ) + ->addIndex( [ 'token' ], [ 'unique' => true ] ) + ->addIndex( [ 'user_id' ] ) + ->addForeignKey( 'user_id', 'users', 'id', [ + 'delete' => 'CASCADE', + 'update' => 'CASCADE' + ]) + ->create(); + } +} +``` + +## Upgrade Path Considerations + +### Problem: Schema Drift Between Installations + +When you update the CMS code via `composer update`, the code changes (like Model classes expecting new columns) but the database schema doesn't automatically update. + +**Symptoms:** +- `SQLSTATE[HY000]: General error: 1 no such column: column_name` +- Model methods reference columns that don't exist in older installations + +### Solution: Migration-Based Upgrades + +1. **Update the initial migration for NEW installations:** + - Edit the `create_*_table.php` migration in development + - This ensures new installations get the complete schema + +2. **Create an ALTER migration for EXISTING installations:** + - Create `add_*_to_*.php` migration with the same changes + - This updates installations that already ran the original migration + +**Example Workflow:** + +```bash +# Step 1: User model now needs 'timezone' column +# Don't edit: 20250111000000_create_users_table.php (old installations already ran this) + +# Step 2: Create new migration +touch cms/resources/database/migrate/20251205000000_add_timezone_to_users.php + +# Step 3: Implement the migration +# (see examples above) + +# Step 4: Document in versionlog.md +# Version X.Y.Z +# - Added timezone column to users table (Migration: 20251205000000) + +# Step 5: Users upgrade via composer and run: +php neuron db:migrate +``` + +### cms:install Command Behavior + +The `cms:install` command (`src/Cms/Cli/Commands/Install/InstallCommand.php`): + +1. Copies ALL migration files from `cms/resources/database/migrate/` to project +2. **Skips** migrations that already exist (by filename) +3. Optionally runs migrations + +**Limitation:** When you run `composer update`, new migrations in the CMS package don't automatically copy to existing installations. + +**Workaround:** Manually copy new migrations or run `cms:install` with reinstall option (will overwrite files). + +**Future Enhancement:** Create `cms:upgrade` command to: +- Detect new migrations in CMS package +- Copy them to installation +- Optionally run them + +## Best Practices + +### 1. Use Descriptive Migration Names +``` +✅ 20251205120000_add_two_factor_recovery_codes_to_users.php +❌ 20251205120000_update_users.php +``` + +### 2. Include Comments in Migration Code +```php +/** + * Add two-factor authentication recovery codes to users table + * + * This migration adds support for 2FA recovery codes, allowing users + * to regain access if they lose their authenticator device. + */ +class AddTwoFactorRecoveryCodesToUsers extends AbstractMigration +``` + +### 3. Always Test Rollbacks +```php +// Make migrations reversible when possible +public function change() +{ + // Phinx can automatically reverse addColumn, addIndex, etc. + $table = $this->table( 'users' ); + $table->addColumn( 'timezone', 'string' )->update(); +} + +// For complex migrations, implement up/down explicitly +public function up() +{ + // Migration code +} + +public function down() +{ + // Rollback code +} +``` + +### 4. Handle NULL Values Appropriately + +When adding columns to tables with existing data: + +```php +// Good: Allow NULL or provide default value +$table->addColumn( 'timezone', 'string', [ + 'default' => 'UTC', + 'null' => false +]); + +// Alternative: Allow NULL, update later +$table->addColumn( 'timezone', 'string', [ 'null' => true ] ); +``` + +### 5. Document Breaking Changes + +If a migration requires manual intervention: + +```php +/** + * BREAKING CHANGE: Removes legacy authentication method + * + * BEFORE RUNNING: + * 1. Ensure all users have migrated to new auth system + * 2. Backup the database + * 3. Review docs at: docs/auth-migration.md + */ +class RemoveLegacyAuthColumns extends AbstractMigration +``` + +### 6. Version Documentation + +Update `versionlog.md` with migration information: + +```markdown +## Version 2.1.0 - 2025-12-05 + +### Database Changes +- Added `two_factor_recovery_codes` column to users table +- Added `timezone` column to users table with default 'UTC' +- Migration files: 20251205000000_add_two_factor_and_timezone_to_users.php + +### Upgrade Notes +Run `php neuron db:migrate` to apply schema changes. +``` + +## Troubleshooting + +### Migration Already Exists Error + +**Problem:** Migration file exists in both CMS package and installation, but with different content. + +**Solution:** +- Check which version ran (look at installation's file modification date) +- Create a new migration to reconcile differences +- Never overwrite the existing migration + +### Column Already Exists + +**Problem:** Migration tries to add a column that already exists. + +``` +SQLSTATE[HY000]: General error: 1 duplicate column name +``` + +**Solution:** +```php +public function change() +{ + $table = $this->table( 'users' ); + + // Check if column exists before adding + if( !$table->hasColumn( 'timezone' ) ) + { + $table->addColumn( 'timezone', 'string', [ 'default' => 'UTC' ] ) + ->update(); + } +} +``` + +### Migration Tracking Out of Sync + +**Problem:** Phinx thinks a migration ran, but the schema change isn't present. + +**Solution:** +```bash +# Check migration status +php neuron db:status + +# If needed, manually fix phinxlog table +sqlite3 storage/database.sqlite3 +> DELETE FROM phinxlog WHERE version = '20251205000000'; +> .quit + +# Re-run migration +php neuron db:migrate +``` + +### Data Loss Prevention + +**Always backup before:** +- Dropping columns +- Renaming columns +- Changing column types +- Dropping tables + +```bash +# SQLite backup +cp storage/database.sqlite3 storage/database.sqlite3.backup + +# MySQL backup +mysqldump -u user -p database_name > backup.sql +``` + +## Additional Resources + +- [Phinx Documentation](https://book.cakephp.org/phinx/0/en/migrations.html) +- [Neuron CMS Installation Guide](README.md) +- Project-wide migration guidelines: `/CLAUDE.md` diff --git a/UPGRADE_NOTES.md b/UPGRADE_NOTES.md new file mode 100644 index 0000000..ecfafe8 --- /dev/null +++ b/UPGRADE_NOTES.md @@ -0,0 +1,151 @@ +# Neuron CMS Upgrade Notes + +This file contains version-specific upgrade information, breaking changes, and migration instructions. + +## How to Upgrade + +After running `composer update neuron-php/cms`, follow these steps: + +1. **Run the upgrade command:** + ```bash + php neuron cms:upgrade + ``` + +2. **Review and apply migrations:** + The upgrade command will detect new migrations. Review them and run: + ```bash + php neuron db:migrate + ``` + +3. **Clear caches:** + ```bash + php neuron cache:clear # if applicable + ``` + +4. **Test your application** to ensure compatibility with the new version. + +--- + +## Version 2025.12.5 + +### Database Changes +- **New Migration:** `20251205000000_add_two_factor_and_timezone_to_users.php` + - Adds `two_factor_recovery_codes` column (TEXT, nullable) for storing 2FA backup codes + - Adds `timezone` column (VARCHAR(50), default 'UTC') for user timezone preferences + +### New Features +- Two-factor authentication recovery codes support +- Per-user timezone settings + +### Breaking Changes +- None + +### Action Required +1. Run `php neuron cms:upgrade` to copy new migrations to your installation +2. Run `php neuron db:migrate` to apply the schema changes +3. Existing user records will have `timezone` set to 'UTC' by default + +### Migration Principles Documented +- Added comprehensive migration guidelines to prevent schema drift +- See `MIGRATIONS.md` for detailed migration best practices +- **Important:** Never modify existing migrations; always create new ones for schema changes + +--- + +## Version 2025.11.7 + +### Initial Release Features +- Complete CMS installation system +- User authentication and authorization +- Post, category, and tag management +- Admin dashboard and member areas +- Email verification system +- Password reset functionality +- Maintenance mode +- Job queue integration + +### Database Tables Created +- `users` - User accounts with roles and authentication +- `posts` - Blog posts and content +- `categories` - Content categorization +- `tags` - Content tagging +- `post_categories` - Many-to-many relationship +- `post_tags` - Many-to-many relationship +- `password_reset_tokens` - Password reset token tracking +- `email_verification_tokens` - Email verification tracking +- `jobs` - Job queue +- `failed_jobs` - Failed job tracking + +### Installation +For new installations: +```bash +php neuron cms:install +``` + +--- + +## Upgrade Troubleshooting + +### Missing Column Errors + +**Error:** `SQLSTATE[HY000]: General error: 1 no such column: column_name` + +**Cause:** Your database schema is out of sync with the CMS code. + +**Solution:** +1. Check for new migrations: `php neuron cms:upgrade --check` +2. Copy new migrations: `php neuron cms:upgrade` +3. Run migrations: `php neuron db:migrate` + +### Migration Already Exists + +**Problem:** Migration file exists but wasn't run. + +**Solution:** +```bash +# Check migration status +php neuron db:status + +# If migration shows as pending, run it +php neuron db:migrate + +# If migration isn't tracked, it may need to be marked as run +# See MIGRATIONS.md for details on using --fake flag +``` + +### Customized Views Being Overwritten + +**Problem:** Running `cms:install` with reinstall overwrites customized views. + +**Solution:** +- Use `php neuron cms:upgrade` instead - it only updates new/critical files +- Use `php neuron cms:upgrade --skip-views` to skip view updates entirely +- Manually merge view changes by comparing package views with your customizations + +### Schema Drift After Composer Update + +**Problem:** After `composer update`, application breaks with database errors. + +**Cause:** New CMS code expects columns that don't exist in your database. + +**Prevention:** +1. Always run `php neuron cms:upgrade` after `composer update neuron-php/cms` +2. Review and apply any new migrations before deploying to production +3. Test in development/staging environment first + +--- + +## Version History + +| Version | Release Date | Key Changes | +|---------|--------------|-------------| +| 2025.12.5 | 2025-12-05 | Added 2FA recovery codes, user timezones, migration docs | +| 2025.11.7 | 2025-11-07 | Initial CMS release | + +--- + +## Need Help? + +- **Documentation:** See `README.md`, `MIGRATIONS.md`, and `/CLAUDE.md` +- **Issues:** Report bugs at [GitHub Issues](https://github.com/neuron-php/cms/issues) +- **Migration Help:** See `MIGRATIONS.md` for comprehensive migration guide diff --git a/composer.json b/composer.json index f223058..ab0c3a2 100644 --- a/composer.json +++ b/composer.json @@ -12,16 +12,19 @@ "require": { "ext-curl": "*", "ext-json": "*", - "neuron-php/mvc": "^0.9.5", + "neuron-php/mvc": "0.9.*", + "neuron-php/data": "0.9.*", "neuron-php/cli": "0.8.*", "neuron-php/jobs": "0.2.*", "neuron-php/orm": "0.1.*", "neuron-php/dto": "0.0.*", - "phpmailer/phpmailer": "^6.9" + "phpmailer/phpmailer": "^6.9", + "cloudinary/cloudinary_php": "^2.0" }, "require-dev": { "phpunit/phpunit": "9.*", - "mikey179/vfsstream": "^1.6" + "mikey179/vfsstream": "^1.6", + "neuron-php/scaffolding": "0.8.*" }, "autoload": { "psr-4": { @@ -43,5 +46,14 @@ "neuron": { "cli-provider": "Neuron\\Cms\\Cli\\Provider" } + }, + "scripts": { + "post-update-cmd": [ + "@php -r \"echo '\\n╔════════════════════════════════════════════════╗\\n';\"", + "@php -r \"echo '║ Neuron CMS Updated ║\\n';\"", + "@php -r \"echo '╚════════════════════════════════════════════════╝\\n';\"", + "@php -r \"echo '\\n⚠️ Run upgrade command to apply changes:\\n';\"", + "@php -r \"echo ' php neuron cms:upgrade\\n\\n';\"" + ] } } diff --git a/readme.md b/readme.md index 944e2c9..b568067 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,5 @@ [![CI](https://github.com/Neuron-PHP/cms/actions/workflows/ci.yml/badge.svg)](https://github.com/Neuron-PHP/cms/actions) +[![codecov](https://codecov.io/gh/Neuron-PHP/cms/branch/develop/graph/badge.svg)](https://codecov.io/gh/Neuron-PHP/cms) # Neuron-PHP CMS A modern, database-backed Content Management System for PHP 8.4+ built on the Neuron framework. Provides a complete blog platform with user authentication, admin panel, and content management. diff --git a/resources/.cms-manifest.json b/resources/.cms-manifest.json new file mode 100644 index 0000000..5f39cc4 --- /dev/null +++ b/resources/.cms-manifest.json @@ -0,0 +1,41 @@ +{ + "version": "2025.12.5", + "release_date": "2025-12-05", + "migrations": [ + "20250111000000_create_users_table.php", + "20250112000000_create_email_verification_tokens_table.php", + "20250113000000_create_pages_table.php", + "20250114000000_create_categories_table.php", + "20250115000000_create_tags_table.php", + "20250116000000_create_posts_table.php", + "20250117000000_create_post_categories_table.php", + "20250118000000_create_post_tags_table.php", + "20251119224525_add_content_raw_to_posts.php", + "20251205000000_add_two_factor_and_timezone_to_users.php" + ], + "config_files": [ + "auth.yaml", + "event-listeners.yaml", + "neuron.yaml", + "neuron.yaml.example", + "routes.yaml" + ], + "view_directories": [ + "admin", + "auth", + "blog", + "content", + "emails", + "home", + "http_codes", + "layouts", + "member" + ], + "public_assets": [ + "index.php", + ".htaccess" + ], + "breaking_changes": [], + "deprecations": [], + "upgrade_notes": "See UPGRADE_NOTES.md for detailed upgrade instructions" +} diff --git a/resources/app/Initializers/AuthInitializer.php b/resources/app/Initializers/AuthInitializer.php index 48e70af..b12a264 100644 --- a/resources/app/Initializers/AuthInitializer.php +++ b/resources/app/Initializers/AuthInitializer.php @@ -29,7 +29,7 @@ public function run( array $argv = [] ): mixed // Get Settings from Registry $settings = Registry::getInstance()->get( 'Settings' ); - if( !$settings || !$settings instanceof \Neuron\Data\Setting\SettingManager ) + if( !$settings || !$settings instanceof \Neuron\Data\Settings\SettingManager ) { Log::error( "Settings not found in Registry, skipping auth initialization" ); return null; diff --git a/resources/app/Initializers/MaintenanceInitializer.php b/resources/app/Initializers/MaintenanceInitializer.php index b6e711c..d35e4e4 100644 --- a/resources/app/Initializers/MaintenanceInitializer.php +++ b/resources/app/Initializers/MaintenanceInitializer.php @@ -44,7 +44,7 @@ public function run( array $argv = [] ): mixed $config = null; $settings = Registry::getInstance()->get( 'Settings' ); - if( $settings && $settings instanceof \Neuron\Data\Setting\SettingManager ) + if( $settings && $settings instanceof \Neuron\Data\Settings\SettingManager ) { try { diff --git a/resources/app/Initializers/PasswordResetInitializer.php b/resources/app/Initializers/PasswordResetInitializer.php index ab38cea..8abeaf1 100644 --- a/resources/app/Initializers/PasswordResetInitializer.php +++ b/resources/app/Initializers/PasswordResetInitializer.php @@ -6,7 +6,7 @@ use Neuron\Cms\Services\Auth\PasswordResetter; use Neuron\Cms\Repositories\DatabasePasswordResetTokenRepository; use Neuron\Cms\Repositories\DatabaseUserRepository; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; use Neuron\Patterns\Registry; use Neuron\Patterns\IRunnable; diff --git a/resources/app/Initializers/RegistrationInitializer.php b/resources/app/Initializers/RegistrationInitializer.php index 17ad73a..7b1e898 100644 --- a/resources/app/Initializers/RegistrationInitializer.php +++ b/resources/app/Initializers/RegistrationInitializer.php @@ -31,7 +31,7 @@ public function run( array $argv = [] ): mixed // Get Settings from Registry $settings = Registry::getInstance()->get( 'Settings' ); - if( !$settings || !$settings instanceof \Neuron\Data\Setting\SettingManager ) + if( !$settings || !$settings instanceof \Neuron\Data\Settings\SettingManager ) { Log::error( "Settings not found in Registry, skipping registration initialization" ); return null; diff --git a/resources/config/database.yaml.example b/resources/config/database.yaml.example deleted file mode 100644 index 1dea521..0000000 --- a/resources/config/database.yaml.example +++ /dev/null @@ -1,42 +0,0 @@ -# Database Configuration -# -# This file provides database configuration for the CMS component. -# Copy sections to your config/neuron.yaml - -database: - # Database adapter (mysql, pgsql, sqlite) - adapter: mysql - - # Database host - host: localhost - - # Database name - name: neuron_cms - - # Database username - user: root - - # Database password - pass: secret - - # Database port (3306 for MySQL, 5432 for PostgreSQL) - port: 3306 - - # Character set - charset: utf8mb4 - -# Migration Configuration -migrations: - # Path to migrations directory (relative to project root) - path: db/migrate - - # Path to seeds directory (relative to project root) - seeds_path: db/seed - - # Migration tracking table name - table: phinx_log - -# System Configuration (optional) -system: - # Environment name (development, staging, production) - environment: development diff --git a/resources/config/email.yaml.example b/resources/config/email.yaml.example deleted file mode 100644 index 405a8f5..0000000 --- a/resources/config/email.yaml.example +++ /dev/null @@ -1,68 +0,0 @@ -# Email Configuration Example -# -# Copy this file to email.yaml and configure for your environment -# -# This configuration is used by the SendWelcomeEmailListener and other -# email-sending features of the CMS powered by PHPMailer. - -email: - # Test mode - logs emails instead of sending (useful for development) - # When enabled, emails are logged to the log file instead of being sent - test_mode: false - - # Email driver: mail, sendmail, or smtp - # - mail: Uses PHP's mail() function (default, requires server mail setup) - # - sendmail: Uses sendmail binary (Linux/Mac) - # - smtp: Uses SMTP server (recommended for production) - driver: mail - - # From address and name for system emails - from_address: noreply@yourdomain.com - from_name: Your Site Name - - # SMTP Configuration (only required if driver is 'smtp') - # Examples for popular email services: - # - # Gmail: - # host: smtp.gmail.com - # port: 587 - # encryption: tls - # username: your-email@gmail.com - # password: your-app-password (not your regular password!) - # - # SendGrid: - # host: smtp.sendgrid.net - # port: 587 - # encryption: tls - # username: apikey - # password: your-sendgrid-api-key - # - # Mailgun: - # host: smtp.mailgun.org - # port: 587 - # encryption: tls - # username: postmaster@yourdomain.com - # password: your-mailgun-smtp-password - - # host: smtp.gmail.com - # port: 587 - # username: your-email@gmail.com - # password: your-app-password - # encryption: tls # or 'ssl' for port 465 - -# Email Template Customization -# -# Templates are located in: resources/views/emails/ -# -# Available templates: -# - welcome.php - Welcome email sent to new users -# -# To customize, edit the template files directly. They use standard PHP -# templating with variables like $Username, $SiteName, $SiteUrl. -# -# Example customization in welcome.php: -# - Change colors in the diff --git a/resources/views/http_codes/500.php b/resources/views/http_codes/500.php new file mode 100644 index 0000000..2540e78 --- /dev/null +++ b/resources/views/http_codes/500.php @@ -0,0 +1,20 @@ +
+
+

Something went wrong on our end.

+

The server encountered an unexpected error. Please try again later.

+
+
+ + \ No newline at end of file diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 75e83a0..b50706a 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -1,8 +1,8 @@ _projectPath = getcwd(); - $this->_componentPath = dirname( dirname( dirname( dirname( dirname( __DIR__ ) ) ) ) ); - } - - /** - * @inheritDoc - */ - public function getName(): string - { - return 'mail:generate'; - } - - /** - * @inheritDoc - */ - public function getDescription(): string - { - return 'Generate a new email template'; - } - - /** - * Configure the command - */ - public function configure(): void - { - // No additional configuration needed - } - - /** - * Execute the command - */ - public function execute( array $parameters = [] ): int - { - // Get template name from first parameter - $templateName = $parameters[0] ?? null; - - if( !$templateName ) - { - $this->output->error( "Please provide a template name" ); - $this->output->info( "Usage: php neuron mail:generate " ); - $this->output->info( "Example: php neuron mail:generate welcome" ); - return 1; - } - - // Validate template name (should be lowercase with hyphens) - if( !preg_match( '/^[a-z][a-z0-9-]*$/', $templateName ) ) - { - $this->output->error( "Template name must be lowercase and contain only letters, numbers, and hyphens" ); - $this->output->error( "Example: welcome, password-reset, order-confirmation" ); - return 1; - } - - // Create the template file - if( !$this->createTemplate( $templateName ) ) - { - return 1; - } - - $this->output->success( "Email template created successfully!" ); - $this->output->info( "Template: resources/views/emails/{$templateName}.php" ); - $this->output->info( "" ); - $this->output->info( "Usage in code:" ); - $this->output->info( " email()->to('user@example.com')" ); - $this->output->info( " ->subject('Welcome!')" ); - $this->output->info( " ->template('emails/{$templateName}', \$data)" ); - $this->output->info( " ->send();" ); - - return 0; - } - - /** - * Create the template file - */ - private function createTemplate( string $name ): bool - { - $emailsDir = $this->_projectPath . '/resources/views/emails'; - - // Create emails directory if it doesn't exist - if( !is_dir( $emailsDir ) ) - { - if( !mkdir( $emailsDir, 0755, true ) ) - { - $this->output->error( "Failed to create emails directory" ); - return false; - } - } - - $filePath = $emailsDir . '/' . $name . '.php'; - - // Check if file already exists - if( file_exists( $filePath ) ) - { - $this->output->error( "Template already exists: resources/views/emails/{$name}.php" ); - return false; - } - - // Load stub template - $stubPath = $this->_componentPath . '/src/Cms/Cli/Commands/Generate/stubs/email.stub'; - - if( !file_exists( $stubPath ) ) - { - $this->output->error( "Stub template not found: {$stubPath}" ); - return false; - } - - $content = file_get_contents( $stubPath ); - - // Create title from name (e.g., "welcome" -> "Welcome", "password-reset" -> "Password Reset") - $title = ucwords( str_replace( '-', ' ', $name ) ); - - // Replace placeholders - $replacements = [ - 'title' => $title, - 'content' => '

Your email content goes here.

' - ]; - - $content = $this->replacePlaceholders( $content, $replacements ); - - // Write the file - if( file_put_contents( $filePath, $content ) === false ) - { - $this->output->error( "Failed to create template file" ); - return false; - } - - return true; - } - - /** - * Replace placeholders in content - */ - private function replacePlaceholders( string $content, array $replacements ): string - { - foreach( $replacements as $key => $value ) - { - $content = str_replace( '{{' . $key . '}}', $value ?? '', $content ); - } - return $content; - } -} diff --git a/src/Cms/Cli/Commands/Generate/stubs/email.stub b/src/Cms/Cli/Commands/Generate/stubs/email.stub deleted file mode 100644 index 15a922d..0000000 --- a/src/Cms/Cli/Commands/Generate/stubs/email.stub +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - {{title}} - - - -
-

{{title}}

-
- -
-

Hello,

- - {{content}} - -

Best regards,
Your Team

-
- - - - diff --git a/src/Cms/Cli/Commands/Install/InstallCommand.php b/src/Cms/Cli/Commands/Install/InstallCommand.php index f8e6d81..11458c7 100644 --- a/src/Cms/Cli/Commands/Install/InstallCommand.php +++ b/src/Cms/Cli/Commands/Install/InstallCommand.php @@ -6,8 +6,8 @@ use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\DatabaseUserRepository; use Neuron\Cms\Auth\PasswordHasher; -use Neuron\Data\Setting\SettingManager; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\SettingManager; +use Neuron\Data\Settings\Source\Yaml; use Neuron\Patterns\Registry; /** @@ -182,6 +182,8 @@ private function createDirectories(): bool '/storage', '/storage/logs', '/storage/cache', + '/storage/uploads', + '/storage/uploads/temp', // Database directories '/db', diff --git a/src/Cms/Cli/Commands/Install/UpgradeCommand.php b/src/Cms/Cli/Commands/Install/UpgradeCommand.php new file mode 100644 index 0000000..4408e8b --- /dev/null +++ b/src/Cms/Cli/Commands/Install/UpgradeCommand.php @@ -0,0 +1,458 @@ +_projectPath = getcwd(); + + // Get component path + $this->_componentPath = dirname( dirname( dirname( dirname( dirname( __DIR__ ) ) ) ) ); + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return 'cms:upgrade'; + } + + /** + * @inheritDoc + */ + public function getDescription(): string + { + return 'Upgrade CMS to latest version (copy new migrations, update files)'; + } + + /** + * Configure the command + */ + public function configure(): void + { + $this->addOption( 'check', 'c', false, 'Check for available updates without applying' ); + $this->addOption( 'migrations-only', 'm', false, 'Only copy new migrations' ); + $this->addOption( 'skip-views', null, false, 'Skip updating view files' ); + $this->addOption( 'skip-migrations', null, false, 'Skip copying migrations' ); + $this->addOption( 'run-migrations', 'r', false, 'Run migrations automatically after copying' ); + } + + /** + * Execute the command + */ + public function execute( array $parameters = [] ): int + { + $this->output->writeln( "\n╔═══════════════════════════════════════╗" ); + $this->output->writeln( "║ Neuron CMS - Upgrade ║" ); + $this->output->writeln( "╚═══════════════════════════════════════╝\n" ); + + // Load manifests + if( !$this->loadManifests() ) + { + return 1; + } + + // Check if CMS is installed + if( !$this->isInstalled() ) + { + $this->output->error( "CMS is not installed. Please run 'cms:install' first." ); + return 1; + } + + // Display version information + $this->displayVersionInfo(); + + // Check for updates + $hasUpdates = $this->checkForUpdates(); + + if( !$hasUpdates ) + { + $this->output->success( "✓ CMS is already up to date!" ); + return 0; + } + + // If --check flag, exit after displaying what would be updated + if( $this->input->getOption( 'check' ) ) + { + $this->output->info( "Run 'cms:upgrade' without --check to apply updates" ); + return 0; + } + + // Confirm upgrade + $this->output->writeln( "" ); + if( !$this->input->confirm( "Proceed with upgrade?", true ) ) + { + $this->output->error( "Upgrade cancelled." ); + return 1; + } + + // Perform upgrade steps + $success = true; + + if( !$this->input->getOption( 'skip-migrations' ) ) + { + $this->output->writeln( "\n📦 Copying new migrations..." ); + $success = $success && $this->copyNewMigrations(); + } + + if( !$this->input->getOption( 'migrations-only' ) && !$this->input->getOption( 'skip-views' ) ) + { + $this->output->writeln( "\n🎨 Updating view files..." ); + $success = $success && $this->updateViews(); + } + + if( !$this->input->getOption( 'migrations-only' ) ) + { + $this->output->writeln( "\n⚙️ Updating configuration examples..." ); + $success = $success && $this->updateConfigExamples(); + } + + if( !$success ) + { + $this->output->error( "Upgrade failed!" ); + return 1; + } + + // Update installed manifest + $this->updateInstalledManifest(); + + // Display summary + $this->displaySummary(); + + // Optionally run migrations + if( $this->input->getOption( 'run-migrations' ) || + $this->input->confirm( "\nRun database migrations now?", false ) ) + { + $this->output->writeln( "" ); + $this->runMigrations(); + } + else + { + $this->output->info( "\n⚠️ Remember to run: php neuron db:migrate" ); + } + + $this->output->success( "\n✓ Upgrade complete!" ); + + return 0; + } + + /** + * Load package and installed manifests + */ + private function loadManifests(): bool + { + // Load package manifest + $packageManifestPath = $this->_componentPath . '/resources/.cms-manifest.json'; + + if( !file_exists( $packageManifestPath ) ) + { + $this->output->error( "Package manifest not found at: $packageManifestPath" ); + return false; + } + + $packageManifestJson = file_get_contents( $packageManifestPath ); + $this->_packageManifest = json_decode( $packageManifestJson, true ); + + if( !$this->_packageManifest ) + { + $this->output->error( "Failed to parse package manifest" ); + return false; + } + + // Load installed manifest (may not exist on old installations) + $installedManifestPath = $this->_projectPath . '/.cms-manifest.json'; + + if( file_exists( $installedManifestPath ) ) + { + $installedManifestJson = file_get_contents( $installedManifestPath ); + $this->_installedManifest = json_decode( $installedManifestJson, true ); + } + else + { + // No manifest = old installation, create minimal one + $this->_installedManifest = [ + 'version' => 'unknown', + 'migrations' => [] + ]; + + // Try to detect installed migrations + $migrateDir = $this->_projectPath . '/db/migrate'; + if( is_dir( $migrateDir ) ) + { + $files = glob( $migrateDir . '/*.php' ); + $this->_installedManifest['migrations'] = array_map( 'basename', $files ); + } + } + + return true; + } + + /** + * Check if CMS is installed + */ + private function isInstalled(): bool + { + // Check for key indicators + $indicators = [ + '/resources/views/admin', + '/config/routes.yaml', + '/db/migrate' + ]; + + foreach( $indicators as $path ) + { + if( !file_exists( $this->_projectPath . $path ) ) + { + return false; + } + } + + return true; + } + + /** + * Display version information + */ + private function displayVersionInfo(): void + { + $installedVersion = $this->_installedManifest['version'] ?? 'unknown'; + $packageVersion = $this->_packageManifest['version'] ?? 'unknown'; + + $this->output->writeln( "Installed Version: $installedVersion" ); + $this->output->writeln( "Package Version: $packageVersion\n" ); + } + + /** + * Check for available updates + */ + private function checkForUpdates(): bool + { + $hasUpdates = false; + + // Check for new migrations + $newMigrations = $this->getNewMigrations(); + + if( !empty( $newMigrations ) ) + { + $hasUpdates = true; + $this->output->writeln( "New Migrations Available:" ); + + foreach( $newMigrations as $migration ) + { + $this->output->writeln( " + $migration" ); + } + } + + // Check version difference + $installedVersion = $this->_installedManifest['version'] ?? '0'; + $packageVersion = $this->_packageManifest['version'] ?? '0'; + + if( $packageVersion !== $installedVersion ) + { + $hasUpdates = true; + + if( empty( $newMigrations ) ) + { + $this->output->writeln( "Version update available (no database changes)" ); + } + } + + return $hasUpdates; + } + + /** + * Get list of new migrations not in installation + */ + private function getNewMigrations(): array + { + $packageMigrations = $this->_packageManifest['migrations'] ?? []; + $installedMigrations = $this->_installedManifest['migrations'] ?? []; + + return array_diff( $packageMigrations, $installedMigrations ); + } + + /** + * Copy new migrations to project + */ + private function copyNewMigrations(): bool + { + $newMigrations = $this->getNewMigrations(); + + if( empty( $newMigrations ) ) + { + $this->output->writeln( " No new migrations to copy" ); + return true; + } + + $migrationsDir = $this->_projectPath . '/db/migrate'; + $componentMigrationsDir = $this->_componentPath . '/resources/database/migrate'; + + // Create migrations directory if it doesn't exist + if( !is_dir( $migrationsDir ) ) + { + if( !mkdir( $migrationsDir, 0755, true ) ) + { + $this->output->error( " Failed to create migrations directory!" ); + return false; + } + } + + $copied = 0; + + foreach( $newMigrations as $migration ) + { + $sourceFile = $componentMigrationsDir . '/' . $migration; + $destFile = $migrationsDir . '/' . $migration; + + if( !file_exists( $sourceFile ) ) + { + $this->output->warning( " Migration file not found: $migration" ); + continue; + } + + if( copy( $sourceFile, $destFile ) ) + { + $this->output->writeln( " ✓ Copied: $migration" ); + $this->_messages[] = "Copied migration: $migration"; + $copied++; + } + else + { + $this->output->error( " ✗ Failed to copy: $migration" ); + return false; + } + } + + if( $copied > 0 ) + { + $this->output->writeln( "\n Copied $copied new migration" . ( $copied > 1 ? 's' : '' ) . "" ); + } + + return true; + } + + /** + * Update view files (conservative - only critical updates) + */ + private function updateViews(): bool + { + // For now, just inform user that views may need manual updates + // In future versions, could implement smart view updates + + $this->output->writeln( " ℹ️ View updates require manual review to preserve customizations" ); + $this->output->writeln( " Compare package views with your installation if needed" ); + $this->output->writeln( " Package views location: " . $this->_componentPath . "/resources/views/" ); + + return true; + } + + /** + * Update configuration example files + */ + private function updateConfigExamples(): bool + { + $configSource = $this->_componentPath . '/resources/config'; + $configDest = $this->_projectPath . '/config'; + + // Only copy .example files + $exampleFiles = glob( $configSource . '/*.example' ); + + if( empty( $exampleFiles ) ) + { + $this->output->writeln( " No configuration examples to update" ); + return true; + } + + foreach( $exampleFiles as $sourceFile ) + { + $fileName = basename( $sourceFile ); + $destFile = $configDest . '/' . $fileName; + + if( copy( $sourceFile, $destFile ) ) + { + $this->output->writeln( " ✓ Updated: $fileName" ); + } + } + + return true; + } + + /** + * Update installed manifest + */ + private function updateInstalledManifest(): bool + { + $manifestPath = $this->_projectPath . '/.cms-manifest.json'; + + // Merge new migrations into installed list + $installedMigrations = $this->_installedManifest['migrations'] ?? []; + $packageMigrations = $this->_packageManifest['migrations'] ?? []; + + $this->_installedManifest['version'] = $this->_packageManifest['version']; + $this->_installedManifest['updated_at'] = date( 'Y-m-d H:i:s' ); + $this->_installedManifest['migrations'] = $packageMigrations; + + $json = json_encode( $this->_installedManifest, JSON_PRETTY_PRINT ); + + if( file_put_contents( $manifestPath, $json ) === false ) + { + $this->output->warning( "Failed to update manifest file" ); + return false; + } + + return true; + } + + /** + * Run database migrations + */ + private function runMigrations(): bool + { + $this->output->writeln( "Running migrations...\n" ); + + // For now, instruct user to run migrations manually + // In future, could integrate with MigrationManager + + $this->output->info( "Run: php neuron db:migrate" ); + + return true; + } + + /** + * Display upgrade summary + */ + private function displaySummary(): void + { + if( empty( $this->_messages ) ) + { + return; + } + + $this->output->writeln( "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" ); + $this->output->writeln( "Upgrade Summary:" ); + $this->output->writeln( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" ); + + foreach( $this->_messages as $message ) + { + $this->output->writeln( " • $message" ); + } + } +} diff --git a/src/Cms/Cli/Commands/Maintenance/EnableCommand.php b/src/Cms/Cli/Commands/Maintenance/EnableCommand.php index 58d3564..63ec944 100644 --- a/src/Cms/Cli/Commands/Maintenance/EnableCommand.php +++ b/src/Cms/Cli/Commands/Maintenance/EnableCommand.php @@ -5,7 +5,7 @@ use Neuron\Cli\Commands\Command; use Neuron\Cms\Maintenance\MaintenanceManager; use Neuron\Cms\Maintenance\MaintenanceConfig; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\Source\Yaml; /** * CLI command for enabling maintenance mode. diff --git a/src/Cms/Cli/Commands/Queue/InstallCommand.php b/src/Cms/Cli/Commands/Queue/InstallCommand.php deleted file mode 100644 index e6c70ab..0000000 --- a/src/Cms/Cli/Commands/Queue/InstallCommand.php +++ /dev/null @@ -1,413 +0,0 @@ -_projectPath = getcwd(); - } - - /** - * @inheritDoc - */ - public function getName(): string - { - return 'queue:install'; - } - - /** - * @inheritDoc - */ - public function getDescription(): string - { - return 'Install the job queue system'; - } - - /** - * Configure the command - */ - public function configure(): void - { - $this->addOption( 'force', 'f', false, 'Force installation even if already installed' ); - } - - /** - * Execute the command - */ - public function execute( array $parameters = [] ): int - { - $this->output->info( "╔═══════════════════════════════════════╗" ); - $this->output->info( "║ Job Queue Installation ║" ); - $this->output->info( "╚═══════════════════════════════════════╝" ); - $this->output->write( "\n" ); - - // Check if jobs component is available - if( !class_exists( 'Neuron\\Jobs\\Queue\\QueueManager' ) ) - { - $this->output->error( "Job queue component not found." ); - $this->output->info( "Please install it first: composer require neuron-php/jobs" ); - return 1; - } - - $force = $this->input->hasOption( 'force' ); - - // Check if already installed - if( !$force && $this->isAlreadyInstalled() ) - { - $this->output->warning( "Queue system appears to be already installed." ); - $this->output->info( " - Migration exists" ); - $this->output->info( " - Configuration exists" ); - $this->output->write( "\n" ); - - if( !$this->input->confirm( "Do you want to continue anyway?", false ) ) - { - $this->output->info( "Installation cancelled." ); - return 0; - } - } - - // Generate queue migration - $this->output->info( "Generating queue migration..." ); - - if( !$this->generateMigration() ) - { - return 1; - } - - // Add queue configuration - $this->output->info( "Adding queue configuration..." ); - - if( $this->addQueueConfig() ) - { - $this->output->success( "Queue configuration added to neuron.yaml" ); - } - else - { - $this->output->warning( "Could not add queue configuration automatically" ); - $this->output->info( "Please add the following to config/neuron.yaml:" ); - $this->output->write( "\n" ); - $this->output->write( "queue:\n" ); - $this->output->write( " driver: database\n" ); - $this->output->write( " default: default\n" ); - $this->output->write( " retry_after: 90\n" ); - $this->output->write( " max_attempts: 3\n" ); - $this->output->write( " backoff: 0\n" ); - $this->output->write( "\n" ); - } - - // Ask to run migration - $this->output->write( "\n" ); - - if( $this->input->confirm( "Would you like to run the queue migration now?", true ) ) - { - if( !$this->runMigration() ) - { - $this->output->error( "Migration failed!" ); - $this->output->info( "You can run it manually with: php neuron db:migrate" ); - return 1; - } - } - else - { - $this->output->info( "Remember to run migration with: php neuron db:migrate" ); - } - - // Display success and usage info - $this->output->write( "\n" ); - $this->output->success( "Job Queue Installation Complete!" ); - $this->output->write( "\n" ); - $this->output->info( "Queue Configuration:" ); - $this->output->info( " Driver: database" ); - $this->output->info( " Default Queue: default" ); - $this->output->info( " Max Attempts: 3" ); - $this->output->info( " Retry After: 90 seconds" ); - $this->output->write( "\n" ); - - $this->output->info( "Start a worker:" ); - $this->output->info( " php neuron jobs:work" ); - $this->output->write( "\n" ); - - $this->output->info( "Dispatch a job:" ); - $this->output->info( " dispatch(new MyJob(), ['data' => 'value']);" ); - $this->output->write( "\n" ); - - $this->output->info( "For more information, see: vendor/neuron-php/jobs/QUEUE.md" ); - - return 0; - } - - /** - * Check if queue is already installed - */ - private function isAlreadyInstalled(): bool - { - $migrationsDir = $this->_projectPath . '/db/migrate'; - $snakeCaseName = $this->camelToSnake( 'CreateQueueTables' ); - - // Check for existing migration - $existingFiles = glob( $migrationsDir . '/*_' . $snakeCaseName . '.php' ); - - if( empty( $existingFiles ) ) - { - return false; - } - - // Check for queue config - $configFile = $this->_projectPath . '/config/neuron.yaml'; - - if( !file_exists( $configFile ) ) - { - return false; - } - - try - { - $yaml = new Yaml( $configFile ); - $settings = new SettingManager( $yaml ); - $driver = $settings->get( 'queue', 'driver' ); - - return !empty( $driver ); - } - catch( \Exception $e ) - { - return false; - } - } - - /** - * Generate queue migration - */ - private function generateMigration(): bool - { - $migrationName = 'CreateQueueTables'; - $snakeCaseName = $this->camelToSnake( $migrationName ); - $migrationsDir = $this->_projectPath . '/db/migrate'; - - // Create migrations directory if it doesn't exist - if( !is_dir( $migrationsDir ) ) - { - if( !mkdir( $migrationsDir, 0755, true ) ) - { - $this->output->error( "Failed to create migrations directory!" ); - return false; - } - } - - // Check if migration already exists - $existingFiles = glob( $migrationsDir . '/*_' . $snakeCaseName . '.php' ); - - if( !empty( $existingFiles ) ) - { - $existingFile = basename( $existingFiles[0] ); - $this->output->info( "Queue migration already exists: $existingFile" ); - return true; - } - - // Create migration - $timestamp = date( 'YmdHis' ); - $className = $migrationName; - $fileName = $timestamp . '_' . $snakeCaseName . '.php'; - $filePath = $migrationsDir . '/' . $fileName; - - $template = $this->getMigrationTemplate( $className ); - - if( file_put_contents( $filePath, $template ) === false ) - { - $this->output->error( "Failed to create queue migration!" ); - return false; - } - - $this->output->success( "Created: db/migrate/$fileName" ); - return true; - } - - /** - * Get migration template - */ - private function getMigrationTemplate( string $className ): string - { - return <<table( 'jobs', [ 'id' => false, 'primary_key' => [ 'id' ] ] ); - - \$jobs->addColumn( 'id', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'queue', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'payload', 'text' ) - ->addColumn( 'attempts', 'integer', [ 'default' => 0 ] ) - ->addColumn( 'reserved_at', 'integer', [ 'null' => true ] ) - ->addColumn( 'available_at', 'integer' ) - ->addColumn( 'created_at', 'integer' ) - ->addIndex( [ 'queue' ] ) - ->addIndex( [ 'available_at' ] ) - ->addIndex( [ 'reserved_at' ] ) - ->create(); - - // Failed jobs table - \$failedJobs = \$this->table( 'failed_jobs', [ 'id' => false, 'primary_key' => [ 'id' ] ] ); - - \$failedJobs->addColumn( 'id', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'queue', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'payload', 'text' ) - ->addColumn( 'exception', 'text' ) - ->addColumn( 'failed_at', 'integer' ) - ->addIndex( [ 'queue' ] ) - ->addIndex( [ 'failed_at' ] ) - ->create(); - } -} - -PHP; - } - - /** - * Add queue configuration to neuron.yaml - */ - private function addQueueConfig(): bool - { - $configFile = $this->_projectPath . '/config/neuron.yaml'; - - if( !file_exists( $configFile ) ) - { - return false; - } - - try - { - // Read existing config - $yaml = new Yaml( $configFile ); - $settings = new SettingManager( $yaml ); - - // Check if queue config already exists - $existingDriver = $settings->get( 'queue', 'driver' ); - - if( $existingDriver ) - { - return true; // Already configured - } - - // Append queue configuration - $queueConfig = <<output->info( "Running migration..." ); - $this->output->write( "\n" ); - - try - { - // Get the CLI application from the registry - $app = Registry::getInstance()->get( 'cli.application' ); - - if( !$app ) - { - $this->output->error( "CLI application not found in registry!" ); - return false; - } - - // Check if db:migrate command exists - if( !$app->has( 'db:migrate' ) ) - { - $this->output->error( "db:migrate command not found!" ); - return false; - } - - // Get the migrate command class - $commandClass = $app->getRegistry()->get( 'db:migrate' ); - - if( !class_exists( $commandClass ) ) - { - $this->output->error( "Migrate command class not found: {$commandClass}" ); - return false; - } - - // Instantiate the migrate command - $migrateCommand = new $commandClass(); - - // Set input and output on the command - $migrateCommand->setInput( $this->input ); - $migrateCommand->setOutput( $this->output ); - - // Configure the command - $migrateCommand->configure(); - - // Execute the migrate command - $exitCode = $migrateCommand->execute(); - - if( $exitCode !== 0 ) - { - $this->output->error( "Migration failed with exit code: $exitCode" ); - return false; - } - - $this->output->write( "\n" ); - $this->output->success( "Migration completed successfully!" ); - - return true; - } - catch( \Exception $e ) - { - $this->output->error( "Error running migration: " . $e->getMessage() ); - return false; - } - } - - /** - * Convert CamelCase to snake_case - */ - private function camelToSnake( string $input ): string - { - return strtolower( preg_replace( '/(?register( 'cms:install', 'Neuron\\Cms\\Cli\\Commands\\Install\\InstallCommand' ); + $registry->register( + 'cms:upgrade', + 'Neuron\\Cms\\Cli\\Commands\\Install\\UpgradeCommand' + ); + // User management commands $registry->register( 'cms:user:create', @@ -55,17 +60,5 @@ public static function register( Registry $registry ): void 'cms:maintenance:status', 'Neuron\\Cms\\Cli\\Commands\\Maintenance\\StatusCommand' ); - - // Email template generator - $registry->register( - 'mail:generate', - 'Neuron\\Cms\\Cli\\Commands\\Generate\\EmailCommand' - ); - - // Queue installation - $registry->register( - 'queue:install', - 'Neuron\\Cms\\Cli\\Commands\\Queue\\InstallCommand' - ); } } diff --git a/src/Cms/Controllers/Admin/Categories.php b/src/Cms/Controllers/Admin/Categories.php index 268c8e1..746fcd8 100644 --- a/src/Cms/Controllers/Admin/Categories.php +++ b/src/Cms/Controllers/Admin/Categories.php @@ -8,7 +8,7 @@ use Neuron\Cms\Services\Category\Updater; use Neuron\Cms\Services\Category\Deleter; use Neuron\Core\Exceptions\NotFound; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; diff --git a/src/Cms/Controllers/Admin/Media.php b/src/Cms/Controllers/Admin/Media.php new file mode 100644 index 0000000..00509d7 --- /dev/null +++ b/src/Cms/Controllers/Admin/Media.php @@ -0,0 +1,192 @@ +get( 'Settings' ); + + if( !$settings instanceof SettingManager ) + { + throw new \Exception( 'Settings not found in Registry' ); + } + + $this->_uploader = new CloudinaryUploader( $settings ); + $this->_validator = new MediaValidator( $settings ); + } + + /** + * Upload image for Editor.js + * + * Handles POST /admin/upload/image + * Returns JSON in Editor.js format + * + * @return void + */ + public function uploadImage(): void + { + // 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; + } + + $file = $_FILES['image']; + + // Validate file + if( !$this->_validator->validate( $file ) ) + { + $this->returnEditorJsError( $this->_validator->getFirstError() ); + return; + } + + // Upload to Cloudinary + $result = $this->_uploader->upload( $file['tmp_name'] ); + + // Return success response in Editor.js format + $this->returnEditorJsSuccess( $result ); + } + catch( \Exception $e ) + { + $this->returnEditorJsError( $e->getMessage() ); + } + } + + /** + * Upload featured image + * + * Handles POST /admin/upload/featured-image + * Returns JSON with upload result + * + * @return void + */ + public function uploadFeaturedImage(): void + { + // 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; + } + + $file = $_FILES['image']; + + // Validate file + if( !$this->_validator->validate( $file ) ) + { + $this->returnError( $this->_validator->getFirstError() ); + return; + } + + // Upload to Cloudinary + $result = $this->_uploader->upload( $file['tmp_name'] ); + + // Return success response + $this->returnSuccess( $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; + } + + /** + * 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; + } + + /** + * 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; + } +} diff --git a/src/Cms/Controllers/Admin/Profile.php b/src/Cms/Controllers/Admin/Profile.php index 30fc361..74ff398 100644 --- a/src/Cms/Controllers/Admin/Profile.php +++ b/src/Cms/Controllers/Admin/Profile.php @@ -7,7 +7,7 @@ use Neuron\Cms\Services\User\Updater; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Services\Auth\CsrfToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; diff --git a/src/Cms/Controllers/Admin/Users.php b/src/Cms/Controllers/Admin/Users.php index 881af81..5a9ab06 100644 --- a/src/Cms/Controllers/Admin/Users.php +++ b/src/Cms/Controllers/Admin/Users.php @@ -10,7 +10,7 @@ use Neuron\Cms\Services\User\Deleter; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Services\Auth\CsrfToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; diff --git a/src/Cms/Controllers/Blog.php b/src/Cms/Controllers/Blog.php index 749b10e..eee87a6 100644 --- a/src/Cms/Controllers/Blog.php +++ b/src/Cms/Controllers/Blog.php @@ -6,6 +6,9 @@ use Neuron\Cms\Repositories\DatabasePostRepository; use Neuron\Cms\Repositories\DatabaseCategoryRepository; use Neuron\Cms\Repositories\DatabaseTagRepository; +use Neuron\Cms\Services\Content\EditorJsRenderer; +use Neuron\Cms\Services\Content\ShortcodeParser; +use Neuron\Cms\Services\Widget\WidgetRenderer; use Neuron\Core\Exceptions\NotFound; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; @@ -17,6 +20,7 @@ class Blog extends Content private DatabasePostRepository $_postRepository; private DatabaseCategoryRepository $_categoryRepository; private DatabaseTagRepository $_tagRepository; + private EditorJsRenderer $_renderer; /** * @param Application|null $app @@ -33,6 +37,11 @@ public function __construct( ?Application $app = null ) $this->_postRepository = new DatabasePostRepository( $settings ); $this->_categoryRepository = new DatabaseCategoryRepository( $settings ); $this->_tagRepository = new DatabaseTagRepository( $settings ); + + // Initialize renderer with shortcode support + $widgetRenderer = new WidgetRenderer( $this->_postRepository ); + $shortcodeParser = new ShortcodeParser( $widgetRenderer ); + $this->_renderer = new EditorJsRenderer( $shortcodeParser ); } /** @@ -94,12 +103,17 @@ public function show( Request $request ): string $categories = $this->_categoryRepository->all(); $tags = $this->_tagRepository->all(); + // Render content from Editor.js JSON + $content = $post->getContent(); + $renderedContent = $this->_renderer->render( $content ); + return $this->renderHtml( HttpResponseStatus::OK, [ 'Categories' => $categories, 'Tags' => $tags, 'Post' => $post, + 'renderedContent' => $renderedContent, 'Title' => $post->getTitle() . ' | ' . $this->getName() ], 'show' diff --git a/src/Cms/Controllers/Content.php b/src/Cms/Controllers/Content.php index 1a04ade..bd26a1a 100644 --- a/src/Cms/Controllers/Content.php +++ b/src/Cms/Controllers/Content.php @@ -1,5 +1,6 @@ loadFromFile( "../.version.json" ); + $version = Factories\Version::fromFile( "../.version.json" ); Registry::getInstance()->set( 'version', 'v'.$version->getAsString() ); } @@ -202,7 +202,7 @@ public function markdown( Request $request ): string { $viewData = array(); - $page = $request->getRouteParameter( 'page' ); + $page = $request->getRouteParameter( 'page' ) ?? 'index'; $viewData[ 'Title' ] = $this->getName() . ' | ' . $this->getTitle(); diff --git a/src/Cms/Database/ConnectionFactory.php b/src/Cms/Database/ConnectionFactory.php index 5e423bb..503a772 100644 --- a/src/Cms/Database/ConnectionFactory.php +++ b/src/Cms/Database/ConnectionFactory.php @@ -2,7 +2,7 @@ namespace Neuron\Cms\Database; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; diff --git a/src/Cms/Dtos/MediaUploadDto.yaml b/src/Cms/Dtos/MediaUploadDto.yaml new file mode 100644 index 0000000..a249d36 --- /dev/null +++ b/src/Cms/Dtos/MediaUploadDto.yaml @@ -0,0 +1,19 @@ +# Media Upload DTO Configuration +# Validation rules for media file uploads + +properties: + file: + type: string + required: true + validators: + - name: NotEmpty + message: "File is required" + + folder: + type: string + required: false + validators: + - name: Length + options: + max: 255 + message: "Folder path must not exceed 255 characters" diff --git a/src/Cms/Email/helpers.php b/src/Cms/Email/helpers.php index a5392f4..7fea128 100644 --- a/src/Cms/Email/helpers.php +++ b/src/Cms/Email/helpers.php @@ -1,7 +1,7 @@ $enabledBy ?? get_current_user() ]; - return $this->writeMaintenanceFile( $data ); + $result = $this->writeMaintenanceFile( $data ); + + // Emit maintenance mode enabled event + if( $result ) + { + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\MaintenanceModeEnabledEvent( + $data['enabled_by'], + $message + ) ); + } + + return $result; } /** * Disable maintenance mode * + * @param string|null $disabledBy User who disabled maintenance mode * @return bool Success status */ - public function disable(): bool + public function disable( ?string $disabledBy = null ): bool { + // Get who is disabling before we delete the file + $disabledByUser = $disabledBy ?? get_current_user(); + if( file_exists( $this->_maintenanceFilePath ) ) { - return unlink( $this->_maintenanceFilePath ); + $result = unlink( $this->_maintenanceFilePath ); + + // Emit maintenance mode disabled event + if( $result ) + { + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\MaintenanceModeDisabledEvent( + $disabledByUser + ) ); + } + + return $result; } return true; diff --git a/src/Cms/Models/Post.php b/src/Cms/Models/Post.php index 2b4db19..43136fa 100644 --- a/src/Cms/Models/Post.php +++ b/src/Cms/Models/Post.php @@ -18,7 +18,8 @@ class Post extends Model private ?int $_id = null; private string $_title; private string $_slug; - private string $_body; + private string $_body = ''; // Plain text fallback, derived from contentRaw + private string $_contentRaw = '{"blocks":[]}'; // JSON string for Editor.js private ?string $_excerpt = null; private ?string $_featuredImage = null; private int $_authorId; @@ -118,6 +119,55 @@ public function setBody( string $body ): self return $this; } + /** + * Get content as array (decoded Editor.js JSON) + */ + public function getContent(): array + { + return json_decode( $this->_contentRaw, true ) ?? ['blocks' => []]; + } + + /** + * Get raw content JSON string + */ + public function getContentRaw(): string + { + return $this->_contentRaw; + } + + /** + * Set content from Editor.js JSON string + * Also extracts plain text to _body for backward compatibility + */ + public function setContent( string $jsonContent ): self + { + $this->_contentRaw = $jsonContent; + $this->_body = $this->extractPlainText( $jsonContent ); + return $this; + } + + /** + * Set content from array (will be JSON encoded) + * Also extracts plain text to _body for backward compatibility + * @param array $content Content array to encode + * @return self + * @throws \JsonException If JSON encoding fails + */ + public function setContentArray( array $content ): self + { + $encoded = json_encode( $content ); + + if( $encoded === false ) + { + $error = json_last_error_msg(); + throw new \JsonException( "Failed to encode content array to JSON: {$error}" ); + } + + $this->_contentRaw = $encoded; + $this->_body = $this->extractPlainText( $encoded ); + return $this; + } + /** * Get excerpt */ @@ -446,7 +496,42 @@ public static function fromArray( array $data ): static $post->setTitle( $data['title'] ?? '' ); $post->setSlug( $data['slug'] ?? '' ); - $post->setBody( $data['body'] ?? '' ); + + // Handle content_raw first (without extracting plain text to body yet) + if( isset( $data['content_raw'] ) ) + { + if( is_string( $data['content_raw'] ) ) + { + $post->_contentRaw = $data['content_raw']; + } + elseif( is_array( $data['content_raw'] ) ) + { + $post->_contentRaw = json_encode( $data['content_raw'] ); + } + } + elseif( isset( $data['content'] ) ) + { + if( is_string( $data['content'] ) ) + { + $post->_contentRaw = $data['content']; + } + elseif( is_array( $data['content'] ) ) + { + $post->_contentRaw = json_encode( $data['content'] ); + } + } + + // Set body - if explicitly provided, use it; otherwise extract from content_raw + if( isset( $data['body'] ) && $data['body'] !== '' ) + { + $post->setBody( $data['body'] ); + } + else + { + // Extract plain text from content_raw as fallback + $post->setBody( $post->extractPlainText( $post->_contentRaw ) ); + } + $post->setExcerpt( $data['excerpt'] ?? null ); $post->setFeaturedImage( $data['featured_image'] ?? null ); $post->setAuthorId( (int)($data['author_id'] ?? 0) ); @@ -511,6 +596,7 @@ public function toArray(): array 'title' => $this->_title, 'slug' => $this->_slug, 'body' => $this->_body, + 'content_raw' => $this->_contentRaw, 'excerpt' => $this->_excerpt, 'featured_image' => $this->_featuredImage, 'author_id' => $this->_authorId, @@ -521,4 +607,51 @@ public function toArray(): array 'updated_at' => $this->_updatedAt?->format( 'Y-m-d H:i:s' ), ]; } + + /** + * Extract plain text from Editor.js JSON content + * + * @param string $jsonContent Editor.js JSON string + * @return string Plain text extracted from blocks + */ + private function extractPlainText( string $jsonContent ): string + { + $data = json_decode( $jsonContent, true ); + + if( !$data || !isset( $data['blocks'] ) || !is_array( $data['blocks'] ) ) + { + return ''; + } + + $text = []; + + foreach( $data['blocks'] as $block ) + { + if( !isset( $block['type'] ) || !isset( $block['data'] ) ) + { + continue; + } + + $blockText = match( $block['type'] ) + { + 'paragraph', 'header' => $block['data']['text'] ?? '', + 'list' => isset( $block['data']['items'] ) && is_array( $block['data']['items'] ) + ? implode( "\n", $block['data']['items'] ) + : '', + 'quote' => $block['data']['text'] ?? '', + 'code' => $block['data']['code'] ?? '', + 'raw' => $block['data']['html'] ?? '', + default => '' + }; + + if( $blockText !== '' ) + { + // Strip HTML tags from text + $blockText = strip_tags( $blockText ); + $text[] = trim( $blockText ); + } + } + + return implode( "\n\n", array_filter( $text ) ); + } } diff --git a/src/Cms/Repositories/DatabaseCategoryRepository.php b/src/Cms/Repositories/DatabaseCategoryRepository.php index 419a6bc..ff4466a 100644 --- a/src/Cms/Repositories/DatabaseCategoryRepository.php +++ b/src/Cms/Repositories/DatabaseCategoryRepository.php @@ -4,15 +4,14 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Category; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed category repository. + * Database-backed category repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ @@ -28,6 +27,7 @@ class DatabaseCategoryRepository implements ICategoryRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO for allWithPostCount() which uses a custom JOIN query $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -36,12 +36,7 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Category { - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - return $row ? Category::fromArray( $row ) : null; + return Category::find( $id ); } /** @@ -49,12 +44,7 @@ public function findById( int $id ): ?Category */ public function findBySlug( string $slug ): ?Category { - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE slug = ? LIMIT 1" ); - $stmt->execute( [ $slug ] ); - - $row = $stmt->fetch(); - - return $row ? Category::fromArray( $row ) : null; + return Category::where( 'slug', $slug )->first(); } /** @@ -62,12 +52,7 @@ public function findBySlug( string $slug ): ?Category */ public function findByName( string $name ): ?Category { - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE name = ? LIMIT 1" ); - $stmt->execute( [ $name ] ); - - $row = $stmt->fetch(); - - return $row ? Category::fromArray( $row ) : null; + return Category::where( 'name', $name )->first(); } /** @@ -83,13 +68,7 @@ public function findByIds( array $ids ): array return []; } - $placeholders = implode( ',', array_fill( 0, count( $ids ), '?' ) ); - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE id IN ($placeholders)" ); - $stmt->execute( $ids ); - - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Category::fromArray( $row ), $rows ); + return Category::whereIn( 'id', $ids )->get(); } /** @@ -109,20 +88,11 @@ public function create( Category $category ): Category throw new Exception( 'Category name already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO categories (name, slug, description, created_at, updated_at) - VALUES (?, ?, ?, ?, ?)" - ); - - $stmt->execute([ - $category->getName(), - $category->getSlug(), - $category->getDescription(), - $category->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + // Use ORM create method + $createdCategory = Category::create( $category->toArray() ); - $category->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original category with the new ID + $category->setId( $createdCategory->getId() ); return $category; } @@ -151,22 +121,8 @@ public function update( Category $category ): bool throw new Exception( 'Category name already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE categories SET - name = ?, - slug = ?, - description = ?, - updated_at = ? - WHERE id = ?" - ); - - return $stmt->execute([ - $category->getName(), - $category->getSlug(), - $category->getDescription(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $category->getId() - ]); + // Use ORM save method + return $category->save(); } /** @@ -175,10 +131,9 @@ public function update( Category $category ): bool public function delete( int $id ): bool { // Foreign key constraints will handle cascade delete of post relationships - $stmt = $this->_pdo->prepare( "DELETE FROM categories WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Category::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -186,10 +141,7 @@ public function delete( int $id ): bool */ public function all(): array { - $stmt = $this->_pdo->query( "SELECT * FROM categories ORDER BY name ASC" ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Category::fromArray( $row ), $rows ); + return Category::orderBy( 'name', 'ASC' )->all(); } /** @@ -197,10 +149,7 @@ public function all(): array */ public function count(): int { - $stmt = $this->_pdo->query( "SELECT COUNT(*) as total FROM categories" ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return Category::query()->count(); } /** @@ -208,6 +157,8 @@ public function count(): int */ public function allWithPostCount(): array { + // This method still uses raw SQL for the JOIN with aggregation + // TODO: Add support for joins and aggregations to ORM $stmt = $this->_pdo->query( "SELECT c.*, COUNT(pc.post_id) as post_count FROM categories c diff --git a/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php b/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php index 59cca22..dcc0852 100644 --- a/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php +++ b/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php @@ -4,7 +4,7 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\EmailVerificationToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; use DateTimeImmutable; diff --git a/src/Cms/Repositories/DatabasePageRepository.php b/src/Cms/Repositories/DatabasePageRepository.php index fbd65f9..0f00718 100644 --- a/src/Cms/Repositories/DatabasePageRepository.php +++ b/src/Cms/Repositories/DatabasePageRepository.php @@ -2,25 +2,19 @@ namespace Neuron\Cms\Repositories; -use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Page; -use Neuron\Cms\Models\User; -use Neuron\Data\Setting\SettingManager; -use PDO; +use Neuron\Data\Settings\SettingManager; use Exception; -use DateTimeImmutable; /** - * Database-backed page repository. + * Database-backed page repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ class DatabasePageRepository implements IPageRepository { - private PDO $_pdo; - /** * Constructor * @@ -29,7 +23,7 @@ class DatabasePageRepository implements IPageRepository */ public function __construct( SettingManager $settings ) { - $this->_pdo = ConnectionFactory::createFromSettings( $settings ); + // No longer need PDO - ORM is initialized in Bootstrap } /** @@ -37,17 +31,8 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Page { - $stmt = $this->_pdo->prepare( "SELECT * FROM pages WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } - - return $this->mapRowToPage( $row ); + // Use eager loading for author + return Page::with( 'author' )->find( $id ); } /** @@ -55,17 +40,8 @@ public function findById( int $id ): ?Page */ public function findBySlug( string $slug ): ?Page { - $stmt = $this->_pdo->prepare( "SELECT * FROM pages WHERE slug = ? LIMIT 1" ); - $stmt->execute( [ $slug ] ); - - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } - - return $this->mapRowToPage( $row ); + // Use eager loading for author + return Page::with( 'author' )->where( 'slug', $slug )->first(); } /** @@ -79,31 +55,11 @@ public function create( Page $page ): Page throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO pages ( - title, slug, content, template, meta_title, meta_description, - meta_keywords, author_id, status, published_at, view_count, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); - - $stmt->execute([ - $page->getTitle(), - $page->getSlug(), - $page->getContentRaw(), - $page->getTemplate(), - $page->getMetaTitle(), - $page->getMetaDescription(), - $page->getMetaKeywords(), - $page->getAuthorId(), - $page->getStatus(), - $page->getPublishedAt() ? $page->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $page->getViewCount(), - $page->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + // Use ORM create method + $createdPage = Page::create( $page->toArray() ); - $page->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original page with the new ID + $page->setId( $createdPage->getId() ); return $page; } @@ -125,40 +81,8 @@ public function update( Page $page ): bool throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE pages SET - title = ?, - slug = ?, - content = ?, - template = ?, - meta_title = ?, - meta_description = ?, - meta_keywords = ?, - author_id = ?, - status = ?, - published_at = ?, - view_count = ?, - updated_at = ? - WHERE id = ?" - ); - - $result = $stmt->execute([ - $page->getTitle(), - $page->getSlug(), - $page->getContentRaw(), - $page->getTemplate(), - $page->getMetaTitle(), - $page->getMetaDescription(), - $page->getMetaKeywords(), - $page->getAuthorId(), - $page->getStatus(), - $page->getPublishedAt() ? $page->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $page->getViewCount(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $page->getId() - ]); - - return $result; + // Use ORM save method + return $page->save(); } /** @@ -166,10 +90,9 @@ public function update( Page $page ): bool */ public function delete( int $id ): bool { - $stmt = $this->_pdo->prepare( "DELETE FROM pages WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Page::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -177,29 +100,21 @@ public function delete( int $id ): bool */ public function all( ?string $status = null, int $limit = 0, int $offset = 0 ): array { - $sql = "SELECT * FROM pages"; - $params = []; + $query = Page::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; + $query->orderBy( 'created_at', 'DESC' ); if( $limit > 0 ) { - $sql .= " LIMIT ? OFFSET ?"; - $params[] = $limit; - $params[] = $offset; + $query->limit( $limit )->offset( $offset ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPage' ], $rows ); + return $query->get(); } /** @@ -223,22 +138,14 @@ public function getDrafts(): array */ public function getByAuthor( int $authorId, ?string $status = null ): array { - $sql = "SELECT * FROM pages WHERE author_id = ?"; - $params = [ $authorId ]; + $query = Page::query()->where( 'author_id', $authorId ); if( $status ) { - $sql .= " AND status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; - - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPage' ], $rows ); + return $query->orderBy( 'created_at', 'DESC' )->get(); } /** @@ -246,20 +153,14 @@ public function getByAuthor( int $authorId, ?string $status = null ): array */ public function count( ?string $status = null ): int { - $sql = "SELECT COUNT(*) as total FROM pages"; - $params = []; + $query = Page::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return $query->count(); } /** @@ -267,70 +168,14 @@ public function count( ?string $status = null ): int */ public function incrementViewCount( int $id ): bool { - $stmt = $this->_pdo->prepare( "UPDATE pages SET view_count = view_count + 1 WHERE id = ?" ); - $stmt->execute( [ $id ] ); - - return $stmt->rowCount() > 0; - } - - /** - * Map database row to Page object - * - * @param array $row Database row - * @return Page - */ - private function mapRowToPage( array $row ): Page - { - $data = [ - 'id' => (int)$row['id'], - 'title' => $row['title'], - 'slug' => $row['slug'], - 'content' => $row['content'], - 'template' => $row['template'], - 'meta_title' => $row['meta_title'], - 'meta_description' => $row['meta_description'], - 'meta_keywords' => $row['meta_keywords'], - 'author_id' => (int)$row['author_id'], - 'status' => $row['status'], - 'view_count' => (int)$row['view_count'], - 'published_at' => $row['published_at'] ?? null, - 'created_at' => $row['created_at'], - 'updated_at' => $row['updated_at'] ?? null, - ]; - - $page = Page::fromArray( $data ); - - // Load relationships - $page->setAuthor( $this->loadAuthor( $page->getAuthorId() ) ); - - return $page; - } - - /** - * Load author for a page - * - * @param int $authorId - * @return User|null - */ - private function loadAuthor( int $authorId ): ?User - { - try - { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $authorId ] ); - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } + $page = Page::find( $id ); - return User::fromArray( $row ); - } - catch( \PDOException $e ) + if( !$page ) { - // Users table may not exist in test environments - return null; + return false; } + + $page->incrementViewCount(); + return $page->save(); } } diff --git a/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php b/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php index edfed30..7a5cfcf 100644 --- a/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php +++ b/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php @@ -4,7 +4,7 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\PasswordResetToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; use DateTimeImmutable; diff --git a/src/Cms/Repositories/DatabasePostRepository.php b/src/Cms/Repositories/DatabasePostRepository.php index 2e6b90f..95e9d03 100644 --- a/src/Cms/Repositories/DatabasePostRepository.php +++ b/src/Cms/Repositories/DatabasePostRepository.php @@ -4,18 +4,16 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Post; -use Neuron\Cms\Models\User; use Neuron\Cms\Models\Category; use Neuron\Cms\Models\Tag; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed post repository. + * Database-backed post repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ @@ -31,6 +29,7 @@ class DatabasePostRepository implements IPostRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO for methods that need raw SQL queries (getByCategory, getByTag) $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -39,9 +38,8 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Post { - $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE id = ? LIMIT 1" ); + $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE id = ?" ); $stmt->execute( [ $id ] ); - $row = $stmt->fetch(); if( !$row ) @@ -49,7 +47,10 @@ public function findById( int $id ): ?Post return null; } - return $this->mapRowToPost( $row ); + $post = Post::fromArray( $row ); + $this->loadRelations( $post ); + + return $post; } /** @@ -57,9 +58,8 @@ public function findById( int $id ): ?Post */ public function findBySlug( string $slug ): ?Post { - $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE slug = ? LIMIT 1" ); + $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE slug = ?" ); $stmt->execute( [ $slug ] ); - $row = $stmt->fetch(); if( !$row ) @@ -67,7 +67,46 @@ public function findBySlug( string $slug ): ?Post return null; } - return $this->mapRowToPost( $row ); + $post = Post::fromArray( $row ); + $this->loadRelations( $post ); + + return $post; + } + + /** + * Load categories and tags for a post + */ + private function loadRelations( Post $post ): void + { + // Load categories + $stmt = $this->_pdo->prepare( + "SELECT c.* FROM categories c + INNER JOIN post_categories pc ON c.id = pc.category_id + WHERE pc.post_id = ?" + ); + $stmt->execute( [ $post->getId() ] ); + $categoryRows = $stmt->fetchAll(); + + $categories = array_map( + fn( $row ) => Category::fromArray( $row ), + $categoryRows + ); + $post->setCategories( $categories ); + + // Load tags + $stmt = $this->_pdo->prepare( + "SELECT t.* FROM tags t + INNER JOIN post_tags pt ON t.id = pt.tag_id + WHERE pt.post_id = ?" + ); + $stmt->execute( [ $post->getId() ] ); + $tagRows = $stmt->fetchAll(); + + $tags = array_map( + fn( $row ) => Tag::fromArray( $row ), + $tagRows + ); + $post->setTags( $tags ); } /** @@ -81,41 +120,24 @@ public function create( Post $post ): Post throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO posts ( - title, slug, body, excerpt, featured_image, author_id, - status, published_at, view_count, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); + // Use ORM create method - only save the post data without relations + $createdPost = Post::create( $post->toArray() ); + + // Update the original post with the new ID + $post->setId( $createdPost->getId() ); - $stmt->execute([ - $post->getTitle(), - $post->getSlug(), - $post->getBody(), - $post->getExcerpt(), - $post->getFeaturedImage(), - $post->getAuthorId(), - $post->getStatus(), - $post->getPublishedAt() ? $post->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $post->getViewCount(), - $post->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); - - $post->setId( (int)$this->_pdo->lastInsertId() ); - - // Handle categories + // Sync categories using raw SQL (vendor ORM doesn't have relation() method yet) if( count( $post->getCategories() ) > 0 ) { $categoryIds = array_map( fn( $c ) => $c->getId(), $post->getCategories() ); - $this->attachCategories( $post->getId(), $categoryIds ); + $this->syncCategories( $post->getId(), $categoryIds ); } - // Handle tags + // Sync tags using raw SQL if( count( $post->getTags() ) > 0 ) { $tagIds = array_map( fn( $t ) => $t->getId(), $post->getTags() ); - $this->attachTags( $post->getId(), $tagIds ); + $this->syncTags( $post->getId(), $tagIds ); } return $post; @@ -138,50 +160,48 @@ public function update( Post $post ): bool throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE posts SET - title = ?, - slug = ?, - body = ?, - excerpt = ?, - featured_image = ?, - author_id = ?, - status = ?, - published_at = ?, - view_count = ?, - updated_at = ? - WHERE id = ?" - ); + // Update using raw SQL because Post model uses private properties + // that aren't tracked by ORM's attribute system + $data = $post->toArray(); + $data['updated_at'] = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); + + $sql = "UPDATE posts SET + title = ?, + slug = ?, + body = ?, + content_raw = ?, + excerpt = ?, + featured_image = ?, + author_id = ?, + status = ?, + published_at = ?, + view_count = ?, + updated_at = ? + WHERE id = ?"; - $result = $stmt->execute([ - $post->getTitle(), - $post->getSlug(), - $post->getBody(), - $post->getExcerpt(), - $post->getFeaturedImage(), - $post->getAuthorId(), - $post->getStatus(), - $post->getPublishedAt() ? $post->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $post->getViewCount(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), + $stmt = $this->_pdo->prepare( $sql ); + $result = $stmt->execute( [ + $data['title'], + $data['slug'], + $data['body'], + $data['content_raw'], + $data['excerpt'], + $data['featured_image'], + $data['author_id'], + $data['status'], + $data['published_at'], + $data['view_count'], + $data['updated_at'], $post->getId() - ]); + ] ); - // Update categories - $this->detachCategories( $post->getId() ); - if( count( $post->getCategories() ) > 0 ) - { - $categoryIds = array_map( fn( $c ) => $c->getId(), $post->getCategories() ); - $this->attachCategories( $post->getId(), $categoryIds ); - } + // Sync categories using raw SQL + $categoryIds = array_map( fn( $c ) => $c->getId(), $post->getCategories() ); + $this->syncCategories( $post->getId(), $categoryIds ); - // Update tags - $this->detachTags( $post->getId() ); - if( count( $post->getTags() ) > 0 ) - { - $tagIds = array_map( fn( $t ) => $t->getId(), $post->getTags() ); - $this->attachTags( $post->getId(), $tagIds ); - } + // Sync tags using raw SQL + $tagIds = array_map( fn( $t ) => $t->getId(), $post->getTags() ); + $this->syncTags( $post->getId(), $tagIds ); return $result; } @@ -192,10 +212,9 @@ public function update( Post $post ): bool public function delete( int $id ): bool { // Foreign key constraints will handle cascade delete of relationships - $stmt = $this->_pdo->prepare( "DELETE FROM posts WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Post::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -203,29 +222,21 @@ public function delete( int $id ): bool */ public function all( ?string $status = null, int $limit = 0, int $offset = 0 ): array { - $sql = "SELECT * FROM posts"; - $params = []; + $query = Post::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; + $query->orderBy( 'created_at', 'DESC' ); if( $limit > 0 ) { - $sql .= " LIMIT ? OFFSET ?"; - $params[] = $limit; - $params[] = $offset; + $query->limit( $limit )->offset( $offset ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return $query->get(); } /** @@ -233,22 +244,14 @@ public function all( ?string $status = null, int $limit = 0, int $offset = 0 ): */ public function getByAuthor( int $authorId, ?string $status = null ): array { - $sql = "SELECT * FROM posts WHERE author_id = ?"; - $params = [ $authorId ]; + $query = Post::query()->where( 'author_id', $authorId ); if( $status ) { - $sql .= " AND status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; - - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return $query->orderBy( 'created_at', 'DESC' )->get(); } /** @@ -256,6 +259,8 @@ public function getByAuthor( int $authorId, ?string $status = null ): array */ public function getByCategory( int $categoryId, ?string $status = null ): array { + // This still uses raw SQL for the JOIN + // TODO: Add JOIN support to ORM QueryBuilder $sql = "SELECT p.* FROM posts p INNER JOIN post_categories pc ON p.id = pc.post_id WHERE pc.category_id = ?"; @@ -273,7 +278,7 @@ public function getByCategory( int $categoryId, ?string $status = null ): array $stmt->execute( $params ); $rows = $stmt->fetchAll(); - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return array_map( fn( $row ) => Post::fromArray( $row ), $rows ); } /** @@ -281,6 +286,8 @@ public function getByCategory( int $categoryId, ?string $status = null ): array */ public function getByTag( int $tagId, ?string $status = null ): array { + // This still uses raw SQL for the JOIN + // TODO: Add JOIN support to ORM QueryBuilder $sql = "SELECT p.* FROM posts p INNER JOIN post_tags pt ON p.id = pt.post_id WHERE pt.tag_id = ?"; @@ -298,7 +305,7 @@ public function getByTag( int $tagId, ?string $status = null ): array $stmt->execute( $params ); $rows = $stmt->fetchAll(); - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return array_map( fn( $row ) => Post::fromArray( $row ), $rows ); } /** @@ -330,20 +337,14 @@ public function getScheduled(): array */ public function count( ?string $status = null ): int { - $sql = "SELECT COUNT(*) as total FROM posts"; - $params = []; + $query = Post::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return $query->count(); } /** @@ -351,10 +352,57 @@ public function count( ?string $status = null ): int */ public function incrementViewCount( int $id ): bool { - $stmt = $this->_pdo->prepare( "UPDATE posts SET view_count = view_count + 1 WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $post = Post::find( $id ); - return $stmt->rowCount() > 0; + if( !$post ) + { + return false; + } + + $post->incrementViewCount(); + return $post->save(); + } + + /** + * Sync categories for a post (removes old, adds new) + */ + private function syncCategories( int $postId, array $categoryIds ): void + { + // Delete existing categories + $this->_pdo->prepare( "DELETE FROM post_categories WHERE post_id = ?" ) + ->execute( [ $postId ] ); + + // Insert new categories + if( !empty( $categoryIds ) ) + { + $stmt = $this->_pdo->prepare( "INSERT INTO post_categories (post_id, category_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); + foreach( $categoryIds as $categoryId ) + { + $stmt->execute( [ $postId, $categoryId, $now ] ); + } + } + } + + /** + * Sync tags for a post (removes old, adds new) + */ + private function syncTags( int $postId, array $tagIds ): void + { + // Delete existing tags + $this->_pdo->prepare( "DELETE FROM post_tags WHERE post_id = ?" ) + ->execute( [ $postId ] ); + + // Insert new tags + if( !empty( $tagIds ) ) + { + $stmt = $this->_pdo->prepare( "INSERT INTO post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); + foreach( $tagIds as $tagId ) + { + $stmt->execute( [ $postId, $tagId, $now ] ); + } + } } /** @@ -367,17 +415,11 @@ public function attachCategories( int $postId, array $categoryIds ): bool return true; } - $stmt = $this->_pdo->prepare( - "INSERT INTO post_categories (post_id, category_id, created_at) VALUES (?, ?, ?)" - ); - + $stmt = $this->_pdo->prepare( "INSERT INTO post_categories (post_id, category_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); foreach( $categoryIds as $categoryId ) { - $stmt->execute([ - $postId, - $categoryId, - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + $stmt->execute( [ $postId, $categoryId, $now ] ); } return true; @@ -391,7 +433,7 @@ public function detachCategories( int $postId ): bool $stmt = $this->_pdo->prepare( "DELETE FROM post_categories WHERE post_id = ?" ); $stmt->execute( [ $postId ] ); - return true; + return $stmt->rowCount() > 0; } /** @@ -404,17 +446,11 @@ public function attachTags( int $postId, array $tagIds ): bool return true; } - $stmt = $this->_pdo->prepare( - "INSERT INTO post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?)" - ); - + $stmt = $this->_pdo->prepare( "INSERT INTO post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); foreach( $tagIds as $tagId ) { - $stmt->execute([ - $postId, - $tagId, - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + $stmt->execute( [ $postId, $tagId, $now ] ); } return true; @@ -428,107 +464,6 @@ public function detachTags( int $postId ): bool $stmt = $this->_pdo->prepare( "DELETE FROM post_tags WHERE post_id = ?" ); $stmt->execute( [ $postId ] ); - return true; - } - - /** - * Map database row to Post object - * - * @param array $row Database row - * @return Post - */ - private function mapRowToPost( array $row ): Post - { - $data = [ - 'id' => (int)$row['id'], - 'title' => $row['title'], - 'slug' => $row['slug'], - 'body' => $row['body'], - 'excerpt' => $row['excerpt'], - 'featured_image' => $row['featured_image'], - 'author_id' => (int)$row['author_id'], - 'status' => $row['status'], - 'view_count' => (int)$row['view_count'], - 'published_at' => $row['published_at'] ?? null, - 'created_at' => $row['created_at'], - 'updated_at' => $row['updated_at'] ?? null, - ]; - - $post = Post::fromArray( $data ); - - // Load relationships - $post->setAuthor( $this->loadAuthor( $post->getAuthorId() ) ); - $post->setCategories( $this->loadCategories( $post->getId() ) ); - $post->setTags( $this->loadTags( $post->getId() ) ); - - return $post; - } - - /** - * Load categories for a post - * - * @param int $postId - * @return Category[] - */ - private function loadCategories( int $postId ): array - { - $stmt = $this->_pdo->prepare( - "SELECT c.* FROM categories c - INNER JOIN post_categories pc ON c.id = pc.category_id - WHERE pc.post_id = ? - ORDER BY c.name ASC" - ); - $stmt->execute( [ $postId ] ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Category::fromArray( $row ), $rows ); - } - - /** - * Load tags for a post - * - * @param int $postId - * @return Tag[] - */ - private function loadTags( int $postId ): array - { - $stmt = $this->_pdo->prepare( - "SELECT t.* FROM tags t - INNER JOIN post_tags pt ON t.id = pt.tag_id - WHERE pt.post_id = ? - ORDER BY t.name ASC" - ); - $stmt->execute( [ $postId ] ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Tag::fromArray( $row ), $rows ); - } - - /** - * Load author for a post - * - * @param int $authorId - * @return User|null - */ - private function loadAuthor( int $authorId ): ?User - { - try - { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $authorId ] ); - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } - - return User::fromArray( $row ); - } - catch( \PDOException $e ) - { - // Users table may not exist in test environments - return null; - } + return $stmt->rowCount() > 0; } } diff --git a/src/Cms/Repositories/DatabaseTagRepository.php b/src/Cms/Repositories/DatabaseTagRepository.php index 851e4c8..47cbfcc 100644 --- a/src/Cms/Repositories/DatabaseTagRepository.php +++ b/src/Cms/Repositories/DatabaseTagRepository.php @@ -4,15 +4,14 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Tag; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed tag repository. + * Database-backed tag repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ @@ -28,6 +27,7 @@ class DatabaseTagRepository implements ITagRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO for allWithPostCount() which uses a custom JOIN query $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -36,12 +36,7 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Tag { - $stmt = $this->_pdo->prepare( "SELECT * FROM tags WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - return $row ? Tag::fromArray( $row ) : null; + return Tag::find( $id ); } /** @@ -49,12 +44,7 @@ public function findById( int $id ): ?Tag */ public function findBySlug( string $slug ): ?Tag { - $stmt = $this->_pdo->prepare( "SELECT * FROM tags WHERE slug = ? LIMIT 1" ); - $stmt->execute( [ $slug ] ); - - $row = $stmt->fetch(); - - return $row ? Tag::fromArray( $row ) : null; + return Tag::where( 'slug', $slug )->first(); } /** @@ -62,12 +52,7 @@ public function findBySlug( string $slug ): ?Tag */ public function findByName( string $name ): ?Tag { - $stmt = $this->_pdo->prepare( "SELECT * FROM tags WHERE name = ? LIMIT 1" ); - $stmt->execute( [ $name ] ); - - $row = $stmt->fetch(); - - return $row ? Tag::fromArray( $row ) : null; + return Tag::where( 'name', $name )->first(); } /** @@ -87,19 +72,11 @@ public function create( Tag $tag ): Tag throw new Exception( 'Tag name already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO tags (name, slug, created_at, updated_at) - VALUES (?, ?, ?, ?)" - ); + // Use ORM create method + $createdTag = Tag::create( $tag->toArray() ); - $stmt->execute([ - $tag->getName(), - $tag->getSlug(), - $tag->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); - - $tag->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original tag with the new ID + $tag->setId( $createdTag->getId() ); return $tag; } @@ -128,20 +105,8 @@ public function update( Tag $tag ): bool throw new Exception( 'Tag name already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE tags SET - name = ?, - slug = ?, - updated_at = ? - WHERE id = ?" - ); - - return $stmt->execute([ - $tag->getName(), - $tag->getSlug(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $tag->getId() - ]); + // Use ORM save method + return $tag->save(); } /** @@ -150,10 +115,9 @@ public function update( Tag $tag ): bool public function delete( int $id ): bool { // Foreign key constraints will handle cascade delete of post relationships - $stmt = $this->_pdo->prepare( "DELETE FROM tags WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Tag::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -161,10 +125,7 @@ public function delete( int $id ): bool */ public function all(): array { - $stmt = $this->_pdo->query( "SELECT * FROM tags ORDER BY name ASC" ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Tag::fromArray( $row ), $rows ); + return Tag::orderBy( 'name', 'ASC' )->all(); } /** @@ -172,10 +133,7 @@ public function all(): array */ public function count(): int { - $stmt = $this->_pdo->query( "SELECT COUNT(*) as total FROM tags" ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return Tag::query()->count(); } /** @@ -183,6 +141,8 @@ public function count(): int */ public function allWithPostCount(): array { + // This method still uses raw SQL for the JOIN with aggregation + // TODO: Add support for joins and aggregations to ORM $stmt = $this->_pdo->query( "SELECT t.*, COUNT(pt.post_id) as post_count FROM tags t diff --git a/src/Cms/Repositories/DatabaseUserRepository.php b/src/Cms/Repositories/DatabaseUserRepository.php index ae8419b..d57894f 100644 --- a/src/Cms/Repositories/DatabaseUserRepository.php +++ b/src/Cms/Repositories/DatabaseUserRepository.php @@ -4,21 +4,20 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\User; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed user repository. + * Database-backed user repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ class DatabaseUserRepository implements IUserRepository { - private PDO $_pdo; + private ?PDO $_pdo = null; /** * Constructor @@ -28,6 +27,7 @@ class DatabaseUserRepository implements IUserRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO property for backwards compatibility with tests $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -36,12 +36,7 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::find( $id ); } /** @@ -49,12 +44,7 @@ public function findById( int $id ): ?User */ public function findByUsername( string $username ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE username = ? LIMIT 1" ); - $stmt->execute( [ $username ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::where( 'username', $username )->first(); } /** @@ -62,12 +52,7 @@ public function findByUsername( string $username ): ?User */ public function findByEmail( string $email ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE email = ? LIMIT 1" ); - $stmt->execute( [ $email ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::where( 'email', $email )->first(); } /** @@ -75,12 +60,7 @@ public function findByEmail( string $email ): ?User */ public function findByRememberToken( string $token ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE remember_token = ? LIMIT 1" ); - $stmt->execute( [ $token ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::where( 'remember_token', $token )->first(); } /** @@ -100,32 +80,11 @@ public function create( User $user ): User throw new Exception( 'Email already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO users ( - username, email, password_hash, role, status, email_verified, - two_factor_secret, remember_token, failed_login_attempts, - locked_until, last_login_at, timezone, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); + // Use ORM create method + $createdUser = User::create( $user->toArray() ); - $stmt->execute([ - $user->getUsername(), - $user->getEmail(), - $user->getPasswordHash(), - $user->getRole(), - $user->getStatus(), - $user->isEmailVerified() ? 1 : 0, - $user->getTwoFactorSecret(), - $user->getRememberToken(), - $user->getFailedLoginAttempts(), - $user->getLockedUntil() ? $user->getLockedUntil()->format( 'Y-m-d H:i:s' ) : null, - $user->getLastLoginAt() ? $user->getLastLoginAt()->format( 'Y-m-d H:i:s' ) : null, - $user->getTimezone(), - $user->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); - - $user->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original user with the new ID + $user->setId( $createdUser->getId() ); return $user; } @@ -154,40 +113,8 @@ public function update( User $user ): bool throw new Exception( 'Email already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE users SET - username = ?, - email = ?, - password_hash = ?, - role = ?, - status = ?, - email_verified = ?, - two_factor_secret = ?, - remember_token = ?, - failed_login_attempts = ?, - locked_until = ?, - last_login_at = ?, - timezone = ?, - updated_at = ? - WHERE id = ?" - ); - - return $stmt->execute([ - $user->getUsername(), - $user->getEmail(), - $user->getPasswordHash(), - $user->getRole(), - $user->getStatus(), - $user->isEmailVerified() ? 1 : 0, - $user->getTwoFactorSecret(), - $user->getRememberToken(), - $user->getFailedLoginAttempts(), - $user->getLockedUntil() ? $user->getLockedUntil()->format( 'Y-m-d H:i:s' ) : null, - $user->getLastLoginAt() ? $user->getLastLoginAt()->format( 'Y-m-d H:i:s' ) : null, - $user->getTimezone(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $user->getId() - ]); + // Use ORM save method + return $user->save(); } /** @@ -195,10 +122,9 @@ public function update( User $user ): bool */ public function delete( int $id ): bool { - $stmt = $this->_pdo->prepare( "DELETE FROM users WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = User::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -206,10 +132,7 @@ public function delete( int $id ): bool */ public function all(): array { - $stmt = $this->_pdo->query( "SELECT * FROM users ORDER BY created_at DESC" ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToUser' ], $rows ); + return User::orderBy( 'created_at', 'DESC' )->all(); } /** @@ -217,47 +140,6 @@ public function all(): array */ public function count(): int { - $stmt = $this->_pdo->query( "SELECT COUNT(*) as total FROM users" ); - $row = $stmt->fetch(); - - return (int)$row['total']; - } - - /** - * Map database row to User object - * - * @param array $row Database row - * @return User - */ - private function mapRowToUser( array $row ): User - { - $emailVerifiedRaw = $row['email_verified'] ?? null; - $emailVerified = is_bool( $emailVerifiedRaw ) - ? $emailVerifiedRaw - : in_array( - strtolower( (string)$emailVerifiedRaw ), - [ '1', 'true', 't', 'yes', 'on' ], - true - ); - - $data = [ - 'id' => (int)$row['id'], - 'username' => $row['username'], - 'email' => $row['email'], - 'password_hash' => $row['password_hash'], - 'role' => $row['role'], - 'status' => $row['status'], - 'email_verified' => $emailVerified, - 'two_factor_secret' => $row['two_factor_secret'], - 'remember_token' => $row['remember_token'], - 'failed_login_attempts' => (int)$row['failed_login_attempts'], - 'locked_until' => $row['locked_until'] ?? null, - 'last_login_at' => $row['last_login_at'] ?? null, - 'timezone' => $row['timezone'] ?? 'UTC', - 'created_at' => $row['created_at'], - 'updated_at' => $row['updated_at'] ?? null, - ]; - - return User::fromArray( $data ); + return User::query()->count(); } } diff --git a/src/Cms/Services/Auth/Authentication.php b/src/Cms/Services/Auth/Authentication.php index b2d00bd..a87682b 100644 --- a/src/Cms/Services/Auth/Authentication.php +++ b/src/Cms/Services/Auth/Authentication.php @@ -46,18 +46,41 @@ public function attempt( string $username, string $password, bool $remember = fa { // Perform dummy hash to normalize timing $this->_passwordHasher->verify( $password, '$2y$10$dummyhashtopreventtimingattack1234567890' ); + + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'user_not_found' + ) ); + return false; } // Check if account is locked if( $user->isLockedOut() ) { + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'account_locked' + ) ); return false; } // Check if account is active if( !$user->isActive() ) { + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'account_inactive' + ) ); return false; } @@ -75,6 +98,15 @@ public function attempt( string $username, string $password, bool $remember = fa } $this->_userRepository->update( $user ); + + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'invalid_credentials' + ) ); + return false; } @@ -107,12 +139,20 @@ public function login( User $user, bool $remember = false ): void // Store user ID in session $this->_sessionManager->set( 'user_id', $user->getId() ); $this->_sessionManager->set( 'user_role', $user->getRole() ); + $this->_sessionManager->set( 'login_time', microtime( true ) ); // Handle remember me if( $remember ) { $this->setRememberToken( $user ); } + + // Emit user login event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginEvent( + $user, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ) + ) ); } /** @@ -120,6 +160,9 @@ public function login( User $user, bool $remember = false ): void */ public function logout(): void { + $user = null; + $sessionDuration = 0.0; + // Clear remember token if exists if( $this->check() ) { @@ -128,6 +171,13 @@ public function logout(): void { $user->setRememberToken( null ); $this->_userRepository->update( $user ); + + // Calculate session duration + $loginTime = $this->_sessionManager->get( 'login_time' ); + if( $loginTime ) + { + $sessionDuration = microtime( true ) - $loginTime; + } } } @@ -139,6 +189,15 @@ public function logout(): void { setcookie( 'remember_token', '', time() - 3600, '/', '', true, true ); } + + // Emit user logout event + if( $user ) + { + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLogoutEvent( + $user, + $sessionDuration + ) ); + } } /** diff --git a/src/Cms/Services/Auth/CsrfToken.php b/src/Cms/Services/Auth/CsrfToken.php index af847f6..0921be8 100644 --- a/src/Cms/Services/Auth/CsrfToken.php +++ b/src/Cms/Services/Auth/CsrfToken.php @@ -3,6 +3,8 @@ namespace Neuron\Cms\Services\Auth; use Neuron\Cms\Auth\SessionManager; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; /** * CSRF token service. @@ -16,10 +18,12 @@ class CsrfToken { private SessionManager $_sessionManager; private string $_tokenKey = 'csrf_token'; + private IRandom $random; - public function __construct( SessionManager $sessionManager ) + public function __construct( SessionManager $sessionManager, ?IRandom $random = null ) { $this->_sessionManager = $sessionManager; + $this->random = $random ?? new RealRandom(); } /** @@ -27,7 +31,7 @@ public function __construct( SessionManager $sessionManager ) */ public function generate(): string { - $token = bin2hex( random_bytes( 32 ) ); + $token = $this->random->string( 64, 'hex' ); $this->_sessionManager->set( $this->_tokenKey, $token ); return $token; } diff --git a/src/Cms/Services/Auth/EmailVerifier.php b/src/Cms/Services/Auth/EmailVerifier.php index 03073ac..7b9837c 100644 --- a/src/Cms/Services/Auth/EmailVerifier.php +++ b/src/Cms/Services/Auth/EmailVerifier.php @@ -7,7 +7,9 @@ use Neuron\Cms\Repositories\IEmailVerificationTokenRepository; use Neuron\Cms\Repositories\IUserRepository; use Neuron\Cms\Services\Email\Sender; -use Neuron\Data\Setting\SettingManager; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; use Exception; @@ -23,6 +25,7 @@ class EmailVerifier private IEmailVerificationTokenRepository $_tokenRepository; private IUserRepository $_userRepository; private SettingManager $_settings; + private IRandom $_random; private string $_basePath; private string $_verificationUrl; private int $_tokenExpirationMinutes = 60; @@ -35,13 +38,15 @@ class EmailVerifier * @param SettingManager $settings Settings manager with email configuration * @param string $basePath Base path for template loading * @param string $verificationUrl Base URL for email verification (token will be appended) + * @param IRandom|null $random Random generator (defaults to cryptographically secure) */ public function __construct( IEmailVerificationTokenRepository $tokenRepository, IUserRepository $userRepository, SettingManager $settings, string $basePath, - string $verificationUrl + string $verificationUrl, + ?IRandom $random = null ) { $this->_tokenRepository = $tokenRepository; @@ -49,6 +54,7 @@ public function __construct( $this->_settings = $settings; $this->_basePath = $basePath; $this->_verificationUrl = $verificationUrl; + $this->_random = $random ?? new RealRandom(); } /** @@ -74,8 +80,8 @@ public function sendVerificationEmail( User $user ): bool // Delete any existing tokens for this user $this->_tokenRepository->deleteByUserId( $user->getId() ); - // Generate secure random token - $plainToken = bin2hex( random_bytes( 32 ) ); + // Generate secure random token (64 hex characters = 32 bytes) + $plainToken = $this->_random->string( 64, 'hex' ); $hashedToken = hash( 'sha256', $plainToken ); // Create and store token @@ -160,6 +166,9 @@ public function verifyEmail( string $plainToken ): bool Log::info( "Email verified for user: {$user->getUsername()}" ); + // Emit email verified event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\EmailVerifiedEvent( $user ) ); + return true; } diff --git a/src/Cms/Services/Auth/PasswordResetter.php b/src/Cms/Services/Auth/PasswordResetter.php index bc3ffcd..fc491a3 100644 --- a/src/Cms/Services/Auth/PasswordResetter.php +++ b/src/Cms/Services/Auth/PasswordResetter.php @@ -7,7 +7,9 @@ use Neuron\Cms\Repositories\IPasswordResetTokenRepository; use Neuron\Cms\Repositories\IUserRepository; use Neuron\Cms\Services\Email\Sender; -use Neuron\Data\Setting\SettingManager; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; use Exception; @@ -24,6 +26,7 @@ class PasswordResetter private IUserRepository $_userRepository; private PasswordHasher $_passwordHasher; private SettingManager $_settings; + private IRandom $_random; private string $_basePath; private string $_resetUrl; private int $_tokenExpirationMinutes = 60; @@ -37,6 +40,7 @@ class PasswordResetter * @param SettingManager $settings Settings manager with email configuration * @param string $basePath Base path for template loading * @param string $resetUrl Base URL for password reset (token will be appended) + * @param IRandom|null $random Random generator (defaults to cryptographically secure) */ public function __construct( IPasswordResetTokenRepository $tokenRepository, @@ -44,7 +48,8 @@ public function __construct( PasswordHasher $passwordHasher, SettingManager $settings, string $basePath, - string $resetUrl + string $resetUrl, + ?IRandom $random = null ) { $this->_tokenRepository = $tokenRepository; @@ -53,6 +58,7 @@ public function __construct( $this->_settings = $settings; $this->_basePath = $basePath; $this->_resetUrl = $resetUrl; + $this->_random = $random ?? new RealRandom(); } /** @@ -87,8 +93,8 @@ public function requestReset( string $email ): bool // Delete any existing tokens for this email $this->_tokenRepository->deleteByEmail( $email ); - // Generate secure random token - $plainToken = bin2hex( random_bytes( 32 ) ); + // Generate secure random token (64 hex characters = 32 bytes) + $plainToken = $this->_random->string( 64, 'hex' ); $hashedToken = hash( 'sha256', $plainToken ); // Create and store token @@ -103,6 +109,12 @@ public function requestReset( string $email ): bool // Send reset email $this->sendResetEmail( $email, $plainToken ); + // Emit password reset requested event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\PasswordResetRequestedEvent( + $user, + $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ) ); + return true; } @@ -166,6 +178,12 @@ public function resetPassword( string $plainToken, string $newPassword ): bool // Delete the token $this->_tokenRepository->deleteByToken( hash( 'sha256', $plainToken ) ); + // Emit password reset completed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\PasswordResetCompletedEvent( + $user, + $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ) ); + return true; } diff --git a/src/Cms/Services/Category/Creator.php b/src/Cms/Services/Category/Creator.php index b8a1cdb..f753505 100644 --- a/src/Cms/Services/Category/Creator.php +++ b/src/Cms/Services/Category/Creator.php @@ -5,6 +5,8 @@ use Neuron\Cms\Models\Category; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Events\CategoryCreatedEvent; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use Neuron\Patterns\Registry; use DateTimeImmutable; @@ -18,10 +20,12 @@ class Creator { private ICategoryRepository $_categoryRepository; + private IRandom $_random; - public function __construct( ICategoryRepository $categoryRepository ) + public function __construct( ICategoryRepository $categoryRepository, ?IRandom $random = null ) { $this->_categoryRepository = $categoryRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -67,7 +71,7 @@ public function create( * Generate URL-friendly slug from name * * For names with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $name * @return string @@ -82,7 +86,7 @@ private function generateSlug( string $name ): string // Fallback for names with no ASCII characters if( $slug === '' ) { - $slug = 'category-' . uniqid(); + $slug = 'category-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Category/Updater.php b/src/Cms/Services/Category/Updater.php index cd80348..bd9d653 100644 --- a/src/Cms/Services/Category/Updater.php +++ b/src/Cms/Services/Category/Updater.php @@ -5,6 +5,8 @@ use Neuron\Cms\Models\Category; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Events\CategoryUpdatedEvent; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use Neuron\Patterns\Registry; use DateTimeImmutable; @@ -18,10 +20,12 @@ class Updater { private ICategoryRepository $_categoryRepository; + private IRandom $_random; - public function __construct( ICategoryRepository $categoryRepository ) + public function __construct( ICategoryRepository $categoryRepository, ?IRandom $random = null ) { $this->_categoryRepository = $categoryRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -68,7 +72,7 @@ public function update( * Generate URL-friendly slug from name * * For names with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $name * @return string @@ -83,7 +87,7 @@ private function generateSlug( string $name ): string // Fallback for names with no ASCII characters if( $slug === '' ) { - $slug = 'category-' . uniqid(); + $slug = 'category-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Email/Sender.php b/src/Cms/Services/Email/Sender.php index 8045b9f..68f7e5e 100644 --- a/src/Cms/Services/Email/Sender.php +++ b/src/Cms/Services/Email/Sender.php @@ -4,7 +4,7 @@ use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\Exception as PHPMailerException; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; /** diff --git a/src/Cms/Services/Media/CloudinaryUploader.php b/src/Cms/Services/Media/CloudinaryUploader.php new file mode 100644 index 0000000..6ceb894 --- /dev/null +++ b/src/Cms/Services/Media/CloudinaryUploader.php @@ -0,0 +1,197 @@ +_settings = $settings; + $this->_cloudinary = $this->initializeCloudinary(); + } + + /** + * Initialize Cloudinary instance + * + * @return Cloudinary + * @throws \Exception If configuration is invalid + */ + private function initializeCloudinary(): Cloudinary + { + $cloudName = $this->_settings->get( 'cloudinary', 'cloud_name' ); + $apiKey = $this->_settings->get( 'cloudinary', 'api_key' ); + $apiSecret = $this->_settings->get( 'cloudinary', 'api_secret' ); + + if( !$cloudName || !$apiKey || !$apiSecret ) + { + throw new \Exception( 'Cloudinary configuration is incomplete. Please set cloud_name, api_key, and api_secret in config/neuron.yaml' ); + } + + return new Cloudinary( [ + 'cloud' => [ + 'cloud_name' => $cloudName, + 'api_key' => $apiKey, + 'api_secret' => $apiSecret + ] + ] ); + } + + /** + * Upload a file from local filesystem + * + * @param string $filePath Path to the file to upload + * @param array $options Upload options (folder, transformation, etc.) + * @return array Upload result with keys: url, public_id, width, height, format + * @throws \Exception If upload fails + */ + public function upload( string $filePath, array $options = [] ): array + { + if( !file_exists( $filePath ) ) + { + throw new \Exception( "File not found: {$filePath}" ); + } + + // Merge with default options from config + $uploadOptions = $this->buildUploadOptions( $options ); + + try + { + $uploadApi = $this->_cloudinary->uploadApi(); + $result = $uploadApi->upload( $filePath, $uploadOptions ); + + return $this->formatResult( $result ); + } + catch( \Exception $e ) + { + throw new \Exception( "Cloudinary upload failed: " . $e->getMessage(), 0, $e ); + } + } + + /** + * Upload a file from URL + * + * @param string $url URL of the file to upload + * @param array $options Upload options (folder, transformation, etc.) + * @return array Upload result with keys: url, public_id, width, height, format + * @throws \Exception If upload fails + */ + public function uploadFromUrl( string $url, array $options = [] ): array + { + // Validate URL + if( !filter_var( $url, FILTER_VALIDATE_URL ) ) + { + throw new \Exception( "Invalid URL: {$url}" ); + } + + // Merge with default options from config + $uploadOptions = $this->buildUploadOptions( $options ); + + try + { + $uploadApi = $this->_cloudinary->uploadApi(); + $result = $uploadApi->upload( $url, $uploadOptions ); + + return $this->formatResult( $result ); + } + catch( \Exception $e ) + { + throw new \Exception( "Cloudinary upload from URL failed: " . $e->getMessage(), 0, $e ); + } + } + + /** + * Delete a file by its public ID + * + * @param string $publicId The public ID of the file to delete + * @return bool True if deletion was successful + * @throws \Exception If deletion fails + */ + public function delete( string $publicId ): bool + { + try + { + $uploadApi = $this->_cloudinary->uploadApi(); + $result = $uploadApi->destroy( $publicId ); + + return isset( $result['result'] ) && $result['result'] === 'ok'; + } + catch( \Exception $e ) + { + throw new \Exception( "Cloudinary deletion failed: " . $e->getMessage(), 0, $e ); + } + } + + /** + * Build upload options by merging user options with config defaults + * + * @param array $options User-provided options + * @return array Complete upload options + */ + private function buildUploadOptions( array $options ): array + { + $defaultFolder = $this->_settings->get( 'cloudinary', 'folder' ) ?? 'neuron-cms/images'; + + $uploadOptions = [ + 'folder' => $options['folder'] ?? $defaultFolder, + 'resource_type' => 'image' + ]; + + // Add any additional options passed by the user + if( isset( $options['public_id'] ) ) + { + $uploadOptions['public_id'] = $options['public_id']; + } + + if( isset( $options['transformation'] ) ) + { + $uploadOptions['transformation'] = $options['transformation']; + } + + if( isset( $options['tags'] ) ) + { + $uploadOptions['tags'] = $options['tags']; + } + + return $uploadOptions; + } + + /** + * Format Cloudinary result into standardized array + * + * @param array $result Cloudinary upload result + * @return array Formatted result + */ + private function formatResult( array $result ): array + { + return [ + 'url' => $result['secure_url'] ?? $result['url'] ?? '', + 'public_id' => $result['public_id'] ?? '', + 'width' => $result['width'] ?? 0, + 'height' => $result['height'] ?? 0, + 'format' => $result['format'] ?? '', + 'bytes' => $result['bytes'] ?? 0, + 'resource_type' => $result['resource_type'] ?? 'image', + 'created_at' => $result['created_at'] ?? '' + ]; + } +} diff --git a/src/Cms/Services/Media/IMediaUploader.php b/src/Cms/Services/Media/IMediaUploader.php new file mode 100644 index 0000000..eb7b603 --- /dev/null +++ b/src/Cms/Services/Media/IMediaUploader.php @@ -0,0 +1,42 @@ +_settings = $settings; + } + + /** + * Validate an uploaded file + * + * @param array $file PHP $_FILES array entry + * @return bool True if valid, false otherwise + */ + public function validate( array $file ): bool + { + $this->_errors = []; + + // Check if file was uploaded + if( !isset( $file['error'] ) || !isset( $file['tmp_name'] ) ) + { + $this->_errors[] = 'No file was uploaded'; + return false; + } + + // Check for upload errors + if( $file['error'] !== UPLOAD_ERR_OK ) + { + $this->_errors[] = $this->getUploadErrorMessage( $file['error'] ); + return false; + } + + // Check if file exists + if( !file_exists( $file['tmp_name'] ) ) + { + $this->_errors[] = 'Uploaded file not found'; + return false; + } + + // Validate file size + if( !$this->validateFileSize( $file['size'] ) ) + { + return false; + } + + // Validate file type + if( !$this->validateFileType( $file['tmp_name'], $file['name'] ) ) + { + return false; + } + + return true; + } + + /** + * Validate file size + * + * @param int $size File size in bytes + * @return bool True if valid + */ + private function validateFileSize( int $size ): bool + { + $maxSize = $this->_settings->get( 'cloudinary', 'max_file_size' ) ?? 5242880; // 5MB default + + if( $size > $maxSize ) + { + $maxSizeMB = round( $maxSize / 1048576, 2 ); + $this->_errors[] = "File size exceeds maximum allowed size of {$maxSizeMB}MB"; + return false; + } + + if( $size === 0 ) + { + $this->_errors[] = 'File is empty'; + return false; + } + + return true; + } + + /** + * Validate file type + * + * @param string $filePath Path to the file + * @param string $fileName Original filename + * @return bool True if valid + */ + private function validateFileType( string $filePath, string $fileName ): bool + { + $allowedFormats = $this->_settings->get( 'cloudinary', 'allowed_formats' ) + ?? ['jpg', 'jpeg', 'png', 'gif', 'webp']; + + // Get file extension + $extension = strtolower( pathinfo( $fileName, PATHINFO_EXTENSION ) ); + + if( !in_array( $extension, $allowedFormats ) ) + { + $this->_errors[] = 'File type not allowed. Allowed types: ' . implode( ', ', $allowedFormats ); + return false; + } + + // Verify MIME type + $finfo = finfo_open( FILEINFO_MIME_TYPE ); + $mimeType = finfo_file( $finfo, $filePath ); + finfo_close( $finfo ); + + $allowedMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp' + ]; + + if( !in_array( $mimeType, $allowedMimeTypes ) ) + { + $this->_errors[] = 'Invalid file type. Must be a valid image file.'; + return false; + } + + // Additional security check: verify it's actually an image + $imageInfo = @getimagesize( $filePath ); + if( $imageInfo === false ) + { + $this->_errors[] = 'File is not a valid image'; + return false; + } + + return true; + } + + /** + * Get upload error message + * + * @param int $error PHP upload error code + * @return string Error message + */ + private function getUploadErrorMessage( int $error ): string + { + return match( $error ) + { + UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive in php.ini', + UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive in HTML form', + UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload', + default => 'Unknown upload error' + }; + } + + /** + * Get validation errors + * + * @return array Array of error messages + */ + public function getErrors(): array + { + return $this->_errors; + } + + /** + * Get first validation error + * + * @return string|null First error message or null if no errors + */ + public function getFirstError(): ?string + { + return $this->_errors[0] ?? null; + } +} diff --git a/src/Cms/Services/Member/RegistrationService.php b/src/Cms/Services/Member/RegistrationService.php index 236e83d..45497b4 100644 --- a/src/Cms/Services/Member/RegistrationService.php +++ b/src/Cms/Services/Member/RegistrationService.php @@ -6,7 +6,7 @@ use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\IUserRepository; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Dto\Dto; use Neuron\Events\Emitter; use Neuron\Cms\Events\UserCreatedEvent; diff --git a/src/Cms/Services/Page/Creator.php b/src/Cms/Services/Page/Creator.php index 4465b1d..229f5b1 100644 --- a/src/Cms/Services/Page/Creator.php +++ b/src/Cms/Services/Page/Creator.php @@ -4,6 +4,8 @@ use Neuron\Cms\Models\Page; use Neuron\Cms\Repositories\IPageRepository; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use DateTimeImmutable; /** @@ -16,10 +18,12 @@ class Creator { private IPageRepository $_pageRepository; + private IRandom $_random; - public function __construct( IPageRepository $pageRepository ) + public function __construct( IPageRepository $pageRepository, ?IRandom $random = null ) { $this->_pageRepository = $pageRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -73,7 +77,7 @@ public function create( * Generate URL-friendly slug from title * * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $title * @return string @@ -88,7 +92,7 @@ private function generateSlug( string $title ): string // Fallback for titles with no ASCII characters if( $slug === '' ) { - $slug = 'page-' . uniqid(); + $slug = 'page-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Post/Creator.php b/src/Cms/Services/Post/Creator.php index afe0df6..52765a0 100644 --- a/src/Cms/Services/Post/Creator.php +++ b/src/Cms/Services/Post/Creator.php @@ -6,6 +6,8 @@ use Neuron\Cms\Repositories\IPostRepository; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Services\Tag\Resolver as TagResolver; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use DateTimeImmutable; /** @@ -20,23 +22,26 @@ class Creator private IPostRepository $_postRepository; private ICategoryRepository $_categoryRepository; private TagResolver $_tagResolver; + private IRandom $_random; public function __construct( IPostRepository $postRepository, ICategoryRepository $categoryRepository, - TagResolver $tagResolver + TagResolver $tagResolver, + ?IRandom $random = null ) { $this->_postRepository = $postRepository; $this->_categoryRepository = $categoryRepository; $this->_tagResolver = $tagResolver; + $this->_random = $random ?? new RealRandom(); } /** * Create a new post * * @param string $title Post title - * @param string $body Post body content + * @param string $content Editor.js JSON content * @param int $authorId Author user ID * @param string $status Post status (draft, published, scheduled) * @param string|null $slug Optional custom slug (auto-generated if not provided) @@ -48,7 +53,7 @@ public function __construct( */ public function create( string $title, - string $body, + string $content, int $authorId, string $status, ?string $slug = null, @@ -61,7 +66,7 @@ public function create( $post = new Post(); $post->setTitle( $title ); $post->setSlug( $slug ?: $this->generateSlug( $title ) ); - $post->setBody( $body ); + $post->setContent( $content ); $post->setExcerpt( $excerpt ); $post->setFeaturedImage( $featuredImage ); $post->setAuthorId( $authorId ); @@ -89,7 +94,7 @@ public function create( * Generate URL-friendly slug from title * * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $title * @return string @@ -104,7 +109,7 @@ private function generateSlug( string $title ): string // Fallback for titles with no ASCII characters if( $slug === '' ) { - $slug = 'post-' . uniqid(); + $slug = 'post-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Post/Updater.php b/src/Cms/Services/Post/Updater.php index 3ed0eb5..c811685 100644 --- a/src/Cms/Services/Post/Updater.php +++ b/src/Cms/Services/Post/Updater.php @@ -6,6 +6,8 @@ use Neuron\Cms\Repositories\IPostRepository; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Services\Tag\Resolver as TagResolver; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; /** * Post update service. @@ -19,16 +21,19 @@ class Updater private IPostRepository $_postRepository; private ICategoryRepository $_categoryRepository; private TagResolver $_tagResolver; + private IRandom $_random; public function __construct( IPostRepository $postRepository, ICategoryRepository $categoryRepository, - TagResolver $tagResolver + TagResolver $tagResolver, + ?IRandom $random = null ) { $this->_postRepository = $postRepository; $this->_categoryRepository = $categoryRepository; $this->_tagResolver = $tagResolver; + $this->_random = $random ?? new RealRandom(); } /** @@ -36,7 +41,7 @@ public function __construct( * * @param Post $post The post to update * @param string $title Post title - * @param string $body Post body content + * @param string $content Editor.js JSON content * @param string $status Post status * @param string|null $slug Custom slug * @param string|null $excerpt Excerpt @@ -48,7 +53,7 @@ public function __construct( public function update( Post $post, string $title, - string $body, + string $content, string $status, ?string $slug = null, ?string $excerpt = null, @@ -59,7 +64,7 @@ public function update( { $post->setTitle( $title ); $post->setSlug( $slug ?: $this->generateSlug( $title ) ); - $post->setBody( $body ); + $post->setContent( $content ); $post->setExcerpt( $excerpt ); $post->setFeaturedImage( $featuredImage ); $post->setStatus( $status ); @@ -86,7 +91,7 @@ public function update( * Generate URL-friendly slug from title * * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $title * @return string @@ -101,7 +106,7 @@ private function generateSlug( string $title ): string // Fallback for titles with no ASCII characters if( $slug === '' ) { - $slug = 'post-' . uniqid(); + $slug = 'post-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Tag/Creator.php b/src/Cms/Services/Tag/Creator.php index 6135088..a50e618 100644 --- a/src/Cms/Services/Tag/Creator.php +++ b/src/Cms/Services/Tag/Creator.php @@ -4,6 +4,8 @@ use Neuron\Cms\Models\Tag; use Neuron\Cms\Repositories\ITagRepository; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; /** * Tag creation service. @@ -15,10 +17,12 @@ class Creator { private ITagRepository $_tagRepository; + private IRandom $_random; - public function __construct( ITagRepository $tagRepository ) + public function __construct( ITagRepository $tagRepository, ?IRandom $random = null ) { $this->_tagRepository = $tagRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -41,7 +45,7 @@ public function create( string $name, ?string $slug = null ): Tag * Generate URL-friendly slug from name * * For names with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $name * @return string @@ -56,7 +60,7 @@ private function generateSlug( string $name ): string // Fallback for names with no ASCII characters if( $slug === '' ) { - $slug = 'tag-' . uniqid(); + $slug = 'tag-' . $this->_random->uniqueId(); } return $slug; diff --git a/tests/Cms/BlogControllerTest.php b/tests/Cms/BlogControllerTest.php index 1839211..986ef67 100644 --- a/tests/Cms/BlogControllerTest.php +++ b/tests/Cms/BlogControllerTest.php @@ -10,9 +10,10 @@ use Neuron\Cms\Repositories\DatabasePostRepository; use Neuron\Cms\Repositories\DatabaseCategoryRepository; use Neuron\Cms\Repositories\DatabaseTagRepository; -use Neuron\Data\Setting\Source\Memory; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\Source\Memory; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Requests\Request; +use Neuron\Orm\Model; use Neuron\Patterns\Registry; use PDO; use PHPUnit\Framework\TestCase; @@ -50,6 +51,9 @@ protected function setUp(): void // Create tables $this->createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_pdo ); + // Set up Settings with database config $settings = new Memory(); $settings->set( 'site', 'name', 'Test Blog' ); @@ -93,6 +97,28 @@ protected function tearDown(): void private function createTables(): void { + // Create users table + $this->_pdo->exec( " + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'subscriber', + status VARCHAR(50) DEFAULT 'active', + email_verified BOOLEAN DEFAULT 0, + two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, + remember_token VARCHAR(255) NULL, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP NULL, + last_login_at TIMESTAMP NULL, + timezone VARCHAR(50) DEFAULT 'UTC', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + " ); + // Create posts table $this->_pdo->exec( " CREATE TABLE posts ( @@ -100,6 +126,7 @@ private function createTables(): void title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, body TEXT NOT NULL, + content_raw TEXT DEFAULT '{\"blocks\":[]}', excerpt TEXT, featured_image VARCHAR(255), author_id INTEGER NOT NULL, diff --git a/tests/Cms/Cli/Commands/Install/InstallCommandTest.php b/tests/Cms/Cli/Commands/Install/InstallCommandTest.php index 3322e69..f1b5a0d 100644 --- a/tests/Cms/Cli/Commands/Install/InstallCommandTest.php +++ b/tests/Cms/Cli/Commands/Install/InstallCommandTest.php @@ -5,8 +5,8 @@ use PHPUnit\Framework\TestCase; use Neuron\Cms\Cli\Commands\Install\InstallCommand; use org\bovigo\vfs\vfsStream; -use Neuron\Data\Setting\SettingManager; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\SettingManager; +use Neuron\Data\Settings\Source\Yaml; class InstallCommandTest extends TestCase { diff --git a/tests/Cms/ContentControllerTest.php b/tests/Cms/ContentControllerTest.php index f5fe9a4..58a0775 100644 --- a/tests/Cms/ContentControllerTest.php +++ b/tests/Cms/ContentControllerTest.php @@ -3,7 +3,7 @@ namespace Tests\Cms; use Neuron\Cms\Controllers\Content; -use Neuron\Data\Setting\Source\Memory; +use Neuron\Data\Settings\Source\Memory; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; use Neuron\Patterns\Registry; diff --git a/tests/Cms/Maintenance/MaintenanceConfigTest.php b/tests/Cms/Maintenance/MaintenanceConfigTest.php index 657c38c..b766bd6 100644 --- a/tests/Cms/Maintenance/MaintenanceConfigTest.php +++ b/tests/Cms/Maintenance/MaintenanceConfigTest.php @@ -3,7 +3,7 @@ namespace Tests\Cms\Maintenance; use Neuron\Cms\Maintenance\MaintenanceConfig; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\Source\Yaml; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\TestCase; diff --git a/tests/Cms/Repositories/DatabaseCategoryRepositoryTest.php b/tests/Cms/Repositories/DatabaseCategoryRepositoryTest.php index c1651df..dddaec9 100644 --- a/tests/Cms/Repositories/DatabaseCategoryRepositoryTest.php +++ b/tests/Cms/Repositories/DatabaseCategoryRepositoryTest.php @@ -5,6 +5,7 @@ use DateTimeImmutable; use Neuron\Cms\Models\Category; use Neuron\Cms\Repositories\DatabaseCategoryRepository; +use Neuron\Orm\Model; use PHPUnit\Framework\TestCase; use PDO; @@ -29,6 +30,9 @@ protected function setUp(): void // Create tables $this->createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_PDO ); + // Initialize repository with in-memory database // Create a test subclass that allows PDO injection $pdo = $this->_PDO; diff --git a/tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php b/tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php index 257d2bf..ee000e1 100644 --- a/tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php +++ b/tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\TestCase; use Neuron\Cms\Repositories\DatabaseEmailVerificationTokenRepository; use Neuron\Cms\Models\EmailVerificationToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use DateTimeImmutable; diff --git a/tests/Cms/Repositories/DatabasePostRepositoryTest.php b/tests/Cms/Repositories/DatabasePostRepositoryTest.php index 9737afc..3801a42 100644 --- a/tests/Cms/Repositories/DatabasePostRepositoryTest.php +++ b/tests/Cms/Repositories/DatabasePostRepositoryTest.php @@ -7,6 +7,7 @@ use Neuron\Cms\Models\Category; use Neuron\Cms\Models\Tag; use Neuron\Cms\Repositories\DatabasePostRepository; +use Neuron\Orm\Model; use PHPUnit\Framework\TestCase; use PDO; @@ -31,6 +32,9 @@ protected function setUp(): void // Create tables $this->createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_PDO ); + // Initialize repository with in-memory database // Create a test subclass that allows PDO injection $pdo = $this->_PDO; @@ -49,6 +53,28 @@ public function __construct( PDO $PDO ) private function createTables(): void { + // Create users table + $this->_PDO->exec( " + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'subscriber', + status VARCHAR(50) DEFAULT 'active', + email_verified BOOLEAN DEFAULT 0, + two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, + remember_token VARCHAR(255) NULL, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP NULL, + last_login_at TIMESTAMP NULL, + timezone VARCHAR(50) DEFAULT 'UTC', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + " ); + // Create posts table $this->_PDO->exec( " CREATE TABLE posts ( @@ -56,6 +82,7 @@ private function createTables(): void title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, body TEXT NOT NULL, + content_raw TEXT DEFAULT '{\"blocks\":[]}', excerpt TEXT, featured_image VARCHAR(255), author_id INTEGER NOT NULL, diff --git a/tests/Cms/Repositories/DatabaseTagRepositoryTest.php b/tests/Cms/Repositories/DatabaseTagRepositoryTest.php index 3f13745..c311a33 100644 --- a/tests/Cms/Repositories/DatabaseTagRepositoryTest.php +++ b/tests/Cms/Repositories/DatabaseTagRepositoryTest.php @@ -5,6 +5,7 @@ use DateTimeImmutable; use Neuron\Cms\Models\Tag; use Neuron\Cms\Repositories\DatabaseTagRepository; +use Neuron\Orm\Model; use PHPUnit\Framework\TestCase; use PDO; @@ -29,6 +30,9 @@ protected function setUp(): void // Create tables $this->createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_PDO ); + // Initialize repository with in-memory database // Create a test subclass that allows PDO injection $pdo = $this->_PDO; diff --git a/tests/Cms/Repositories/DatabaseUserRepositoryTest.php b/tests/Cms/Repositories/DatabaseUserRepositoryTest.php index a02424f..f2dd947 100644 --- a/tests/Cms/Repositories/DatabaseUserRepositoryTest.php +++ b/tests/Cms/Repositories/DatabaseUserRepositoryTest.php @@ -5,7 +5,8 @@ use PHPUnit\Framework\TestCase; use Neuron\Cms\Repositories\DatabaseUserRepository; use Neuron\Cms\Models\User; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; +use Neuron\Orm\Model; use PDO; use DateTimeImmutable; @@ -43,6 +44,9 @@ protected function setUp(): void $property->setAccessible( true ); $this->pdo = $property->getValue( $this->repository ); + // Initialize ORM with the PDO connection + Model::setPdo( $this->pdo ); + // Create users table $this->createUsersTable(); } @@ -59,6 +63,7 @@ private function createUsersTable(): void status VARCHAR(50) DEFAULT 'active', email_verified BOOLEAN DEFAULT 0, two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, remember_token VARCHAR(255) NULL, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP NULL, diff --git a/tests/Cms/Services/AuthenticationTest.php b/tests/Cms/Services/AuthenticationTest.php index 3830b7b..a05821b 100644 --- a/tests/Cms/Services/AuthenticationTest.php +++ b/tests/Cms/Services/AuthenticationTest.php @@ -8,7 +8,8 @@ use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\DatabaseUserRepository; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; +use Neuron\Orm\Model; use DateTimeImmutable; use PDO; @@ -46,6 +47,9 @@ protected function setUp(): void $property->setAccessible(true); $this->pdo = $property->getValue($this->_userRepository); + // Initialize ORM with the PDO connection + Model::setPdo( $this->pdo ); + // Create users table $this->createUsersTable(); @@ -73,6 +77,7 @@ private function createUsersTable(): void status VARCHAR(50) DEFAULT 'active', email_verified BOOLEAN DEFAULT 0, two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, remember_token VARCHAR(255) NULL, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP NULL, diff --git a/tests/Cms/Services/CsrfTokenTest.php b/tests/Cms/Services/CsrfTokenTest.php index 88d962d..4225a90 100644 --- a/tests/Cms/Services/CsrfTokenTest.php +++ b/tests/Cms/Services/CsrfTokenTest.php @@ -2,6 +2,7 @@ namespace Tests\Cms\Services; +use Neuron\Core\System\FakeRandom; use PHPUnit\Framework\TestCase; use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Cms\Auth\SessionManager; @@ -14,13 +15,20 @@ class CsrfTokenTest extends TestCase { private CsrfToken $_csrfToken; private SessionManager $sessionManager; + private FakeRandom $random; protected function setUp(): void { $this->sessionManager = new SessionManager([ 'cookie_secure' => false // Disable HTTPS requirement for tests ]); - $this->_csrfToken = new CsrfToken($this->sessionManager); + + // Use FakeRandom for deterministic testing + // Each string() call will advance through the sequence + $this->random = new FakeRandom(); + $this->random->setSeed(12345); // Seed advances with each call + + $this->_csrfToken = new CsrfToken($this->sessionManager, $this->random); $_SESSION = []; // Clear session data } @@ -48,14 +56,18 @@ public function testGenerateTokenStoresInSession(): void $this->assertEquals($token, $storedToken); } - public function testGenerateTokenIsRandom(): void + public function testGenerateTokenIsDifferentEachTime(): void { + // With FakeRandom using a seed, tokens are deterministic but unique $token1 = $this->_csrfToken->generate(); // Clear session to force new token $_SESSION = []; - $token2 = $this->_csrfToken->generate(); + // Create new instance with advanced seed + $this->random->setSeed(12346); // Different seed = different token + $csrf2 = new CsrfToken($this->sessionManager, $this->random); + $token2 = $csrf2->generate(); $this->assertNotEquals($token1, $token2); } @@ -129,25 +141,39 @@ public function testValidateUsesTimingSafeComparison(): void public function testRegenerateToken(): void { - $firstToken = $this->_csrfToken->generate(); + // Set explicit sequence so generate() and regenerate() produce different tokens + $this->random = new FakeRandom(); + $this->random->setSeed(100); + $csrf = new CsrfToken($this->sessionManager, $this->random); - $secondToken = $this->_csrfToken->regenerate(); + $firstToken = $csrf->generate(); + + // Advance seed to get different token + $this->random->setSeed(200); + $secondToken = $csrf->regenerate(); $this->assertNotEquals($firstToken, $secondToken); - $this->assertEquals($secondToken, $this->_csrfToken->getToken()); + $this->assertEquals($secondToken, $csrf->getToken()); } public function testRegenerateTokenInvalidatesOldToken(): void { - $oldToken = $this->_csrfToken->generate(); + // Set explicit sequence so generate() and regenerate() produce different tokens + $this->random = new FakeRandom(); + $this->random->setSeed(300); + $csrf = new CsrfToken($this->sessionManager, $this->random); + + $oldToken = $csrf->generate(); - $newToken = $this->_csrfToken->regenerate(); + // Advance seed to get different token + $this->random->setSeed(400); + $newToken = $csrf->regenerate(); // Old token should no longer be valid - $this->assertFalse($this->_csrfToken->validate($oldToken)); + $this->assertFalse($csrf->validate($oldToken)); // New token should be valid - $this->assertTrue($this->_csrfToken->validate($newToken)); + $this->assertTrue($csrf->validate($newToken)); } public function testTokenLength(): void diff --git a/tests/Cms/Services/EmailVerifierTest.php b/tests/Cms/Services/EmailVerifierTest.php index 8d19f83..5af3f88 100644 --- a/tests/Cms/Services/EmailVerifierTest.php +++ b/tests/Cms/Services/EmailVerifierTest.php @@ -7,8 +7,8 @@ use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\IEmailVerificationTokenRepository; use Neuron\Cms\Repositories\IUserRepository; -use Neuron\Data\Setting\Source\Memory; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\Source\Memory; +use Neuron\Data\Settings\SettingManager; use PHPUnit\Framework\TestCase; class EmailVerifierTest extends TestCase diff --git a/tests/Cms/Services/Media/CloudinaryUploaderTest.php b/tests/Cms/Services/Media/CloudinaryUploaderTest.php new file mode 100644 index 0000000..24228ed --- /dev/null +++ b/tests/Cms/Services/Media/CloudinaryUploaderTest.php @@ -0,0 +1,114 @@ +set( 'cloudinary', 'cloud_name', 'test-cloud' ); + $memory->set( 'cloudinary', 'api_key', 'test-key' ); + $memory->set( 'cloudinary', 'api_secret', 'test-secret' ); + $memory->set( 'cloudinary', 'folder', 'test-folder' ); + + $this->_settings = new SettingManager( $memory ); + } + + public function testConstructorThrowsExceptionWithMissingConfig(): void + { + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Cloudinary configuration is incomplete' ); + + // Create settings without cloudinary config + $memory = new Memory(); + $settings = new SettingManager( $memory ); + + new CloudinaryUploader( $settings ); + } + + public function testUploadThrowsExceptionForNonExistentFile(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'File not found' ); + + $uploader->upload( '/path/to/nonexistent/file.jpg' ); + } + + public function testUploadFromUrlThrowsExceptionForInvalidUrl(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Invalid URL' ); + + $uploader->uploadFromUrl( 'not-a-valid-url' ); + } + + /** + * Note: The following tests require actual Cloudinary credentials + * and are marked as incomplete. They can be enabled for integration testing. + */ + + public function testUploadWithValidFile(): void + { + $this->markTestIncomplete( + 'This test requires valid Cloudinary credentials and a test image file. ' . + 'Enable for integration testing.' + ); + + // Example integration test: + // $uploader = new CloudinaryUploader( $this->_settings ); + // $result = $uploader->upload( '/path/to/test/image.jpg' ); + // + // $this->assertIsArray( $result ); + // $this->assertArrayHasKey( 'url', $result ); + // $this->assertArrayHasKey( 'public_id', $result ); + // $this->assertArrayHasKey( 'width', $result ); + // $this->assertArrayHasKey( 'height', $result ); + } + + public function testUploadFromUrlWithValidUrl(): void + { + $this->markTestIncomplete( + 'This test requires valid Cloudinary credentials and internet connection. ' . + 'Enable for integration testing.' + ); + + // Example integration test: + // $uploader = new CloudinaryUploader( $this->_settings ); + // $result = $uploader->uploadFromUrl( 'https://example.com/test-image.jpg' ); + // + // $this->assertIsArray( $result ); + // $this->assertArrayHasKey( 'url', $result ); + } + + public function testDeleteWithValidPublicId(): void + { + $this->markTestIncomplete( + 'This test requires valid Cloudinary credentials. ' . + 'Enable for integration testing.' + ); + + // Example integration test: + // $uploader = new CloudinaryUploader( $this->_settings ); + // $result = $uploader->delete( 'test-folder/test-image' ); + // + // $this->assertIsBool( $result ); + } +} diff --git a/tests/Cms/Services/Media/MediaValidatorTest.php b/tests/Cms/Services/Media/MediaValidatorTest.php new file mode 100644 index 0000000..244a438 --- /dev/null +++ b/tests/Cms/Services/Media/MediaValidatorTest.php @@ -0,0 +1,168 @@ +vfs = vfsStream::setup( 'uploads' ); + + // Create in-memory settings for testing + $memory = new Memory(); + $memory->set( 'cloudinary', 'max_file_size', 5242880 ); // 5MB + $memory->set( 'cloudinary', 'allowed_formats', ['jpg', 'jpeg', 'png', 'gif', 'webp'] ); + + $this->_settings = new SettingManager( $memory ); + $this->_validator = new MediaValidator( $this->_settings ); + } + + public function testValidateReturnsFalseForMissingFile(): void + { + $file = []; + + $result = $this->_validator->validate( $file ); + + $this->assertFalse( $result ); + $this->assertCount( 1, $this->_validator->getErrors() ); + $this->assertEquals( 'No file was uploaded', $this->_validator->getFirstError() ); + } + + public function testValidateReturnsFalseForUploadError(): void + { + $file = [ + 'error' => UPLOAD_ERR_NO_FILE, + 'tmp_name' => '' + ]; + + $result = $this->_validator->validate( $file ); + + $this->assertFalse( $result ); + $this->assertStringContainsString( 'No file was uploaded', $this->_validator->getFirstError() ); + } + + public function testValidateReturnsFalseForNonExistentFile(): void + { + $file = [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => '/nonexistent/file.jpg', + 'name' => 'test.jpg', + 'size' => 1024 + ]; + + $result = $this->_validator->validate( $file ); + + $this->assertFalse( $result ); + $this->assertEquals( 'Uploaded file not found', $this->_validator->getFirstError() ); + } + + public function testValidateReturnsFalseForEmptyFile(): void + { + // Create empty file + $testFile = vfsStream::newFile( 'empty.jpg' )->at( $this->vfs ); + $testFile->setContent( '' ); + + $file = [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $testFile->url(), + 'name' => 'empty.jpg', + 'size' => 0 + ]; + + $result = $this->_validator->validate( $file ); + + $this->assertFalse( $result ); + $this->assertEquals( 'File is empty', $this->_validator->getFirstError() ); + } + + public function testValidateReturnsFalseForOversizedFile(): void + { + // Create a file + $testFile = vfsStream::newFile( 'large.jpg' )->at( $this->vfs ); + $testFile->setContent( 'fake content' ); + + $file = [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $testFile->url(), + 'name' => 'large.jpg', + 'size' => 10485760 // 10MB - exceeds 5MB limit + ]; + + $result = $this->_validator->validate( $file ); + + $this->assertFalse( $result ); + $this->assertStringContainsString( 'exceeds maximum allowed size', $this->_validator->getFirstError() ); + } + + public function testValidateReturnsFalseForDisallowedExtension(): void + { + // Create a file + $testFile = vfsStream::newFile( 'test.txt' )->at( $this->vfs ); + $testFile->setContent( 'not an image' ); + + $file = [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $testFile->url(), + 'name' => 'test.txt', + 'size' => 1024 + ]; + + $result = $this->_validator->validate( $file ); + + $this->assertFalse( $result ); + $this->assertStringContainsString( 'File type not allowed', $this->_validator->getFirstError() ); + } + + public function testGetErrorsReturnsAllErrors(): void + { + $file = []; + + $this->_validator->validate( $file ); + + $errors = $this->_validator->getErrors(); + + $this->assertIsArray( $errors ); + $this->assertNotEmpty( $errors ); + } + + public function testGetFirstErrorReturnsFirstError(): void + { + $file = []; + + $this->_validator->validate( $file ); + + $firstError = $this->_validator->getFirstError(); + + $this->assertIsString( $firstError ); + $this->assertEquals( 'No file was uploaded', $firstError ); + } + + public function testGetFirstErrorReturnsNullWhenNoErrors(): void + { + $firstError = $this->_validator->getFirstError(); + + $this->assertNull( $firstError ); + } + + /** + * Note: Full validation with real image files would require creating + * actual image data, which is complex in unit tests. These tests cover + * the basic validation logic. + */ +} diff --git a/tests/Cms/Services/PasswordResetterTest.php b/tests/Cms/Services/PasswordResetterTest.php index c6efc83..011190b 100644 --- a/tests/Cms/Services/PasswordResetterTest.php +++ b/tests/Cms/Services/PasswordResetterTest.php @@ -8,8 +8,8 @@ use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\IPasswordResetTokenRepository; use Neuron\Cms\Repositories\IUserRepository; -use Neuron\Data\Setting\Source\Memory; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\Source\Memory; +use Neuron\Data\Settings\SettingManager; use PHPUnit\Framework\TestCase; class PasswordResetterTest extends TestCase diff --git a/tests/Cms/Services/Post/CreatorTest.php b/tests/Cms/Services/Post/CreatorTest.php index f936088..3c2bf18 100644 --- a/tests/Cms/Services/Post/CreatorTest.php +++ b/tests/Cms/Services/Post/CreatorTest.php @@ -42,11 +42,14 @@ public function testCreatesPostWithRequiredFields(): void ->method( 'resolveFromString' ) ->willReturn( [] ); + $editorJsContent = '{"blocks":[{"type":"paragraph","data":{"text":"Test body content"}}]}'; + $this->_mockPostRepository ->expects( $this->once() ) ->method( 'create' ) - ->with( $this->callback( function( Post $post ) { + ->with( $this->callback( function( Post $post ) use ( $editorJsContent ) { return $post->getTitle() === 'Test Post' + && $post->getContentRaw() === $editorJsContent && $post->getBody() === 'Test body content' && $post->getAuthorId() === 1 && $post->getStatus() === Post::STATUS_DRAFT @@ -56,12 +59,13 @@ public function testCreatesPostWithRequiredFields(): void $result = $this->_creator->create( 'Test Post', - 'Test body content', + $editorJsContent, 1, Post::STATUS_DRAFT ); $this->assertEquals( 'Test Post', $result->getTitle() ); + $this->assertEquals( $editorJsContent, $result->getContentRaw() ); $this->assertEquals( 'Test body content', $result->getBody() ); } @@ -85,7 +89,7 @@ public function testGeneratesSlugWhenNotProvided(): void $result = $this->_creator->create( 'Test Post Title', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', 1, Post::STATUS_DRAFT ); @@ -113,7 +117,7 @@ public function testUsesCustomSlugWhenProvided(): void $result = $this->_creator->create( 'Test Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', 1, Post::STATUS_DRAFT, 'custom-slug' @@ -143,7 +147,7 @@ public function testSetsPublishedDateForPublishedPosts(): void $result = $this->_creator->create( 'Published Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', 1, Post::STATUS_PUBLISHED ); @@ -172,7 +176,7 @@ public function testDoesNotSetPublishedDateForDraftPosts(): void $result = $this->_creator->create( 'Draft Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', 1, Post::STATUS_DRAFT ); @@ -213,7 +217,7 @@ public function testAttachesCategoriesToPost(): void $result = $this->_creator->create( 'Test Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', 1, Post::STATUS_DRAFT, null, @@ -258,7 +262,7 @@ public function testResolvesTags(): void $result = $this->_creator->create( 'Test Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', 1, Post::STATUS_DRAFT, null, @@ -292,7 +296,7 @@ public function testSetsOptionalFields(): void $result = $this->_creator->create( 'Test Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', 1, Post::STATUS_DRAFT, null, diff --git a/tests/Cms/Services/Post/UpdaterTest.php b/tests/Cms/Services/Post/UpdaterTest.php index a5881b2..a4209df 100644 --- a/tests/Cms/Services/Post/UpdaterTest.php +++ b/tests/Cms/Services/Post/UpdaterTest.php @@ -36,7 +36,9 @@ public function testUpdatesPostWithRequiredFields(): void $post = new Post(); $post->setId( 1 ); $post->setTitle( 'Original Title' ); - $post->setBody( 'Original Body' ); + $post->setContent( '{"blocks":[{"type":"paragraph","data":{"text":"Original Body"}}]}' ); + + $updatedContent = '{"blocks":[{"type":"paragraph","data":{"text":"Updated Body"}}]}'; $this->_mockCategoryRepository ->method( 'findByIds' ) @@ -49,8 +51,9 @@ public function testUpdatesPostWithRequiredFields(): void $this->_mockPostRepository ->expects( $this->once() ) ->method( 'update' ) - ->with( $this->callback( function( Post $p ) { + ->with( $this->callback( function( Post $p ) use ( $updatedContent ) { return $p->getTitle() === 'Updated Title' + && $p->getContentRaw() === $updatedContent && $p->getBody() === 'Updated Body' && $p->getStatus() === Post::STATUS_PUBLISHED; } ) ); @@ -58,11 +61,12 @@ public function testUpdatesPostWithRequiredFields(): void $result = $this->_updater->update( $post, 'Updated Title', - 'Updated Body', + $updatedContent, Post::STATUS_PUBLISHED ); $this->assertEquals( 'Updated Title', $result->getTitle() ); + $this->assertEquals( $updatedContent, $result->getContentRaw() ); $this->assertEquals( 'Updated Body', $result->getBody() ); $this->assertEquals( Post::STATUS_PUBLISHED, $result->getStatus() ); } @@ -90,7 +94,7 @@ public function testGeneratesSlugWhenNotProvided(): void $result = $this->_updater->update( $post, 'New Post Title', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_DRAFT ); @@ -120,7 +124,7 @@ public function testUsesProvidedSlug(): void $result = $this->_updater->update( $post, 'Title', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_DRAFT, 'custom-slug' ); @@ -164,7 +168,7 @@ public function testUpdatesCategories(): void $result = $this->_updater->update( $post, 'Title', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_DRAFT, null, null, @@ -211,7 +215,7 @@ public function testUpdatesTags(): void $result = $this->_updater->update( $post, 'Title', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_DRAFT, null, null, @@ -247,7 +251,7 @@ public function testUpdatesOptionalFields(): void $result = $this->_updater->update( $post, 'Title', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_DRAFT, null, 'New excerpt', @@ -278,7 +282,7 @@ public function testReturnsUpdatedPost(): void $result = $this->_updater->update( $post, 'Updated', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_DRAFT ); @@ -314,7 +318,7 @@ public function testSetsPublishedAtWhenChangingToPublished(): void $result = $this->_updater->update( $post, 'Published Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_PUBLISHED ); @@ -350,7 +354,7 @@ public function testDoesNotOverwriteExistingPublishedAt(): void $result = $this->_updater->update( $post, 'Updated Published Post', - 'Body', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', Post::STATUS_PUBLISHED ); diff --git a/tests/Cms/Services/RegistrationServiceTest.php b/tests/Cms/Services/RegistrationServiceTest.php index f078bce..59933dc 100644 --- a/tests/Cms/Services/RegistrationServiceTest.php +++ b/tests/Cms/Services/RegistrationServiceTest.php @@ -7,8 +7,8 @@ use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\IUserRepository; use Neuron\Cms\Services\Member\RegistrationService; -use Neuron\Data\Setting\Source\Memory; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\Source\Memory; +use Neuron\Data\Settings\SettingManager; use Neuron\Events\Emitter; use PHPUnit\Framework\TestCase; diff --git a/versionlog.md b/versionlog.md index 992fcf0..6a0311e 100644 --- a/versionlog.md +++ b/versionlog.md @@ -1,4 +1,16 @@ ## 0.8.9 +* **Slug generation now uses system abstractions** - All content service classes refactored to use `IRandom` interface +* Refactored 6 service classes: Post/Creator, Post/Updater, Category/Creator, Category/Updater, Page/Creator, Tag/Creator +* Slug generation fallback now uses `IRandom->uniqueId()` instead of direct `uniqid()` calls +* Services support dependency injection with optional `IRandom` parameter for testability +* Maintains full backward compatibility - existing code works without changes +* All 195 tests passing (slug generation now fully deterministic in tests) +* **Security services now use system abstractions** - PasswordResetter and EmailVerifier refactored to use `IRandom` interface +* Secure token generation now uses abstraction instead of direct random_bytes() calls +* Services support dependency injection with optional `IRandom` parameter for testability +* Maintains cryptographic security with RealRandom default (using random_bytes()) +* Maintains full backward compatibility - existing code works without changes +* All tests passing (24 tests total for both services) ## 0.8.8 2025-11-16