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 @@
[](https://github.com/Neuron-PHP/cms/actions)
+[](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}}
-
-
-
-
-
-
-
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