Skip to content

Conversation

@ljonesfl
Copy link
Member

@ljonesfl ljonesfl commented Nov 29, 2025

Upgrades the blog editor to be a block based editor.
A ton of refactoring.

Summary by CodeRabbit

  • New Features

    • EditorJS rich-content editor for posts with image/embed support and admin image upload endpoints (Cloudinary-backed)
    • CMS upgrade command to install migrations/config examples; support for 2FA recovery codes and per-user timezones
  • Bug Fixes / Improvements

    • Improved post content rendering and styling
  • Chores

    • Removed email-template generator and queue-install CLI tools
  • Documentation

    • Added migration guide, upgrade notes, and release manifest
  • Tests

    • New media uploader/validator tests and ORM-enabled test updates

✏️ Tip: You can customize this high-level summary in your review settings.

@ljonesfl
Copy link
Member Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 29, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 29, 2025

Walkthrough

Adds EditorJS content support (storage, editor UI, renderer), Cloudinary-backed media upload + validation and admin endpoints, many namespace updates to Neuron\Data\Settings, ORM adoption across repositories and tests, new events and emissions for auth/maintenance flows, upgrade tooling/CLI, migrations, CI/docs updates, and assorted view/test changes.

Changes

Cohort / File(s) Summary
Composer / CI / Docs
composer.json, .github/workflows/ci.yml, .gitignore, readme.md, MIGRATIONS.md, UPGRADE_NOTES.md, resources/.cms-manifest.json
Dependency additions/updates (neuron-php/data, cloudinary), post-update script, CI workflow changes, docs/manifest/upgrade notes, README badge and .gitignore update.
Namespace & bootstrap
src/Bootstrap.php, resources/app/Initializers/*, src/Cms/*, tests/**
Bulk import path migration from Neuron\Data\Setting\*Neuron\Data\Settings\*, minor object namespace fixes, and ORM initialization added in bootstrap/tests.
EditorJS integration (models, services, views, migration)
src/Cms/Models/Post.php, src/Cms/Services/Post/Creator.php, src/Cms/Services/Post/Updater.php, resources/views/admin/posts/create.php, resources/views/admin/posts/edit.php, resources/database/migrate/20251119224525_add_content_raw_to_posts.php
Add content_raw storage and accessors (setContent, getContent, getContentRaw, setContentArray`), plain-text extraction, service API renamed to accept EditorJS JSON, EditorJS UI and save logic in admin views, migration to add column.
Rendering & blog view
src/Cms/Controllers/Blog.php, resources/views/blog/show.php
Introduce EditorJsRenderer + ShortcodeParser + WidgetRenderer usage in Blog controller; view now uses renderedContent and includes post-content CSS.
Media upload: Cloudinary & admin endpoints
src/Cms/Services/Media/CloudinaryUploader.php, src/Cms/Services/Media/IMediaUploader.php, src/Cms/Services/Media/MediaValidator.php, src/Cms/Controllers/Admin/Media.php, src/Cms/Dtos/MediaUploadDto.yaml, resources/config/routes.yaml
New media uploader interface and Cloudinary implementation, validator, DTO, admin controller endpoints (/admin/upload/image, /admin/upload/featured-image) returning EditorJS-compatible JSON; route additions.
Events & emissions
src/Cms/Events/*.php, src/Cms/Services/Auth/*, src/Cms/Maintenance/MaintenanceManager.php, src/Cms/Services/Auth/EmailVerifier.php, src/Cms/Services/Auth/PasswordResetter.php
New event classes (login, logout, login_failed, password reset requested/completed, email verified, maintenance enabled/disabled) and emission points added across auth, email verification, password reset, and maintenance manager (disable signature updated).
CLI & upgrade tooling
src/Cms/Cli/Commands/Install/UpgradeCommand.php, src/Cms/Cli/Provider.php, removed src/Cms/Cli/Commands/Generate/EmailCommand.php, src/Cms/Cli/Commands/Generate/stubs/email.stub, src/Cms/Cli/Commands/Queue/InstallCommand.php
Added cms:upgrade command and manifest-driven upgrade flow; removed mail:generate and queue:install commands and their stubs; provider command registration updated.
Repository conversions to ORM
src/Cms/Repositories/Database*.php (Post, Page, Tag, Category, User, etc.)
Major shift from raw PDO SQL to Neuron ORM for CRUD and queries across many repositories; some join/aggregation paths retain raw SQL.
Admin / install adjustments
src/Cms/Cli/Commands/Install/InstallCommand.php, resources/app/Initializers/*.php, resources/config/neuron.yaml.example, removed resources/config/*.yaml.example
Install command creates upload dirs; example config files removed/replaced; neuron.yaml.example extended with cloudinary, database, migrations, email, maintenance sections.
Migrations
resources/database/migrate/*.php (new migration files listed)
New migrations added (content_raw, two_factor_recovery_codes, timezone, etc.) referenced in manifest/upgrade notes.
Views & error page
resources/views/http_codes/500.php, resources/views/blog/show.php
Added static 500 error page and enhanced post styling.
Tests updated / new tests
tests/**, tests/Cms/Services/Media/*
Tests updated for namespace changes and ORM initialization; EditorJS-related test updates; new Media validator and Cloudinary uploader tests added.

Sequence Diagram(s)

sequenceDiagram
    participant Browser as User (Browser)
    participant Editor as EditorJS
    participant Form as Admin Form
    participant AdminCtrl as Admin Media/Post Controller
    participant Validator as MediaValidator
    participant Uploader as CloudinaryUploader
    participant PostModel as Post Model
    participant DB as Database
    participant Renderer as EditorJsRenderer
    participant View as Blog View

    Browser->>Editor: edit blocks / add image
    Editor->>Form: populate hidden content-json
    Browser->>Form: submit (content-json + file)
    Form->>AdminCtrl: POST /admin/upload/image or save post
    AdminCtrl->>Validator: validate(file)
    Validator-->>AdminCtrl: result (ok / errors)
    alt valid image upload
        AdminCtrl->>Uploader: upload(tempFile)
        Uploader-->>AdminCtrl: {url, public_id,...}
        AdminCtrl-->>Editor: return EditorJS success JSON
    end
    AdminCtrl->>PostModel: setContent(content-json)
    PostModel->>PostModel: decode content_raw, extract plain text into _body
    PostModel->>DB: persist post with content_raw
    DB-->>PostModel: saved
    PostModel->>Renderer: render(content_raw)
    Renderer-->>View: rendered HTML
    View-->>Browser: display formatted post
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Areas needing extra attention:
    • EditorJS JSON handling and extractPlainText edge cases (malformed blocks, HTML stripping).
    • Media upload security (MIME validation, file-size limits, temp-file lifecycle) and Cloudinary error handling.
    • Mixed ORM/raw-SQL repository code paths (joins/aggregations) and migration impacts.
    • Event emission correctness and payload contents (IP, timestamps, user objects).
    • CLI upgrade idempotency, manifest parsing, and file-copy safety.
    • Test adjustments (ORM init in tests, schema changes) and their consistency.

Possibly related PRs

Poem

🐰 I hopped through EditorJS blocks with glee,
I carried pictures up to Cloudinary,
I planted migrations, events, and routes,
I nudged the ORM — then polished the spouts,
A happy twitch — the CMS grows free!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.77% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Feature/blog editor upgrade" is directly related to the changeset's primary objective of upgrading the blog editor to a block-based editor with Editor.js integration.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/blog-editor-upgrade

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Cms/Services/Auth/Authentication.php (1)

226-249: Fix infinite recursion on stale sessions in user() / logout()

In user():

if( !$user )
{
	// Clear stale session if user no longer exists
	$this->logout();
}

If the session contains a user_id for a user that has been deleted, this calls logout(), which in turn calls $this->check() and $this->user(), triggering the same path again and causing infinite recursion (now with additional event logic on each loop).

Instead of delegating to logout(), clear the stale session inline:

-		$user = $this->_userRepository->findById( $userId );
-
-		if( !$user )
-		{
-			// Clear stale session if user no longer exists
-			$this->logout();
-		}
-
-		return $user;
+		$user = $this->_userRepository->findById( $userId );
+
+		if( !$user )
+		{
+			// Clear stale session and remember-me cookie without recursion
+			$this->_sessionManager->destroy();
+
+			if( isset( $_COOKIE['remember_token'] ) )
+			{
+				setcookie( 'remember_token', '', time() - 3600, '/', '', true, true );
+			}
+
+			return null;
+		}
+
+		return $user;

This preserves the intended behavior (dropping an invalid session and remember-me cookie) without risking a stack overflow or runaway event emissions when the underlying user record is missing.

src/Cms/Controllers/Blog.php (1)

87-93: "Not found" post will have empty content for rendering.

When a post is not found, a dummy Post object is created with setBody() but the new rendering pipeline uses getContent() which will return the default empty EditorJS structure {"blocks":[]}. This means the "not found" message won't be displayed.

Update the "not found" post to use the new content structure:

 		if( !$post )
 		{
 			$post = new Post();
 			$post->setTitle( 'Article Not Found' );
-			$post->setBody( 'The requested article does not exist.' );
+			$post->setContent( json_encode([
+				'blocks' => [
+					[
+						'type' => 'paragraph',
+						'data' => ['text' => 'The requested article does not exist.']
+					]
+				]
+			]) );
 			$post->setSlug( $slug );
 		}
🧹 Nitpick comments (16)
resources/views/http_codes/500.php (2)

8-20: Inline styles are appropriate for error pages.

Using inline styles ensures the page displays correctly even if external stylesheets fail to load, which is a good practice for error pages.

Optional: Consider adding responsive design for smaller screens:

 	.centered {
 		text-align: center;
-		margin-top: 100px; /* Adjust this value as needed */
+		margin-top: 10vh;
+		padding: 0 15px;
 	}
 
 	.centered-text {
 		text-align: center;
 		margin-top: 20px;
 		font-size: 16px;
 		color: #666;
+		padding: 0 15px;
 	}

1-6: Consider adding accessibility attributes and user escape hatches.

The error page would benefit from:

  • ARIA landmark roles for screen readers
  • A link back to the home page or contact information
-<div class="row">
-	<div class="col">
+<div class="row" role="main">
+	<div class="col">
 		<h1 class="centered">Something went wrong on our end.</h1>
 		<p class="centered-text">The server encountered an unexpected error. Please try again later.</p>
+		<p class="centered-text"><a href="/" style="color: #0066cc;">Return to Home</a></p>
 	</div>
 </div>
src/Cms/Repositories/DatabaseCategoryRepository.php (1)

211-217: GROUP BY may break on PostgreSQL

allWithPostCount() selects c.* but groups only by c.id; PostgreSQL’s stricter GROUP BY rules will reject this query. If you intend this repository to work against PostgreSQL (per the class doc), you’ll need to either:

  • Group by all selected categories columns, or
  • Select only grouped/aggregated columns.

Please confirm whether PostgreSQL is a supported target in your deployments; if so, consider adjusting the SELECT/GROUP BY to be portable.

composer.json (1)

15-27: Dependency updates align with the Settings refactor; confirm version ranges

The move to neuron-php/mvc: "0.9.*" and addition of neuron-php/data: "0.9.*" and neuron-php/scaffolding: "0.8.*" fit the new Settings namespace and tooling story.

One nuance: "0.9.*" can allow earlier 0.9.x releases than ^0.9.5 would have; in practice Composer will usually pick the latest matching version, but if you depend on features/bugfixes introduced in later 0.9.x, you may prefer a caret or a higher minimum.

Please make sure composer.lock is updated and CI (including migrations/tests that touch Settings) passes with these new constraints. If you want to avoid accidentally downgrading, consider tightening the constraints (e.g., ^0.9.5 / ^0.9.0) before merging.

src/Cms/Repositories/DatabasePostRepository.php (1)

84-103: Verify handling of the new content_raw column in posts

The new migration adds a non‑nullable content_raw column intended for Editor.js JSON, but this repository currently:

  • Inserts/updates only body/excerpt (no content_raw in the column/value lists), and
  • Does not include content_raw in the $data array passed to Post::fromArray().

Because the DB default will populate content_raw, this won’t break inserts, but any Editor.js JSON stored on the entity may not be persisted or hydrated unless other layers handle it.

Please double‑check how the Post model exposes raw Editor.js content (e.g., getContentRaw() / setContentRaw() or similar), and consider extending:

  • the INSERT/UPDATE statements, and
  • mapRowToPost()’s $data array
    to read/write content_raw if that field is meant to be first‑class in the domain.

Also applies to: 141-168, 440-455

src/Cms/Services/Auth/PasswordResetter.php (1)

106-110: LGTM! Event emissions enhance observability.

The addition of PasswordResetRequestedEvent and PasswordResetCompletedEvent provides valuable audit trails for password reset workflows. The fallback to 'unknown' for $_SERVER['REMOTE_ADDR'] appropriately handles CLI and testing contexts.

Consider importing the event classes at the top of the file rather than using fully qualified names for improved readability:

+use Neuron\Cms\Events\PasswordResetRequestedEvent;
+use Neuron\Cms\Events\PasswordResetCompletedEvent;
+use Neuron\Application\CrossCutting\Event;

Then use:

-		\Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\PasswordResetRequestedEvent(
+		Event::emit( new PasswordResetRequestedEvent(

Also applies to: 175-179

resources/views/admin/posts/edit.php (2)

24-31: Harden handling of existing EditorJS content JSON before initialization

Current parsing only falls back on JSON syntax errors; valid-but-unexpected JSON (e.g. "null" or an object without blocks) will flow into data and may confuse EditorJS.

You can defensively ensure a valid blocks array after parsing:

-let existingContent;
-try {
-	existingContent = JSON.parse(existingContentJson);
-} catch (error) {
-	console.error('Failed to parse existing content:', error);
-	existingContent = { blocks: [] };
-}
+let existingContent;
+try {
+	existingContent = JSON.parse(existingContentJson);
+
+	// Ensure we always pass a valid Editor.js data structure
+	if( !existingContent || !Array.isArray( existingContent.blocks ) )
+	{
+		existingContent = { blocks: [] };
+	}
+} catch (error) {
+	console.error('Failed to parse existing content:', error);
+	existingContent = { blocks: [] };
+}

This keeps the editor resilient to legacy or malformed content_raw values while preserving your existing fallback behavior.

Also applies to: 93-103, 104-109


82-90: Consider pinning EditorJS CDN versions and reducing save() frequency

The EditorJS integration and submit flow look solid. Two optional robustness points:

  • The @latest CDN URLs can introduce breaking changes when a new major EditorJS (or tool) version ships. Consider pinning to a specific major/minor version (e.g. @2.x or an exact version) and upgrading deliberately.
  • onChange currently calls editor.save() on every change, which is relatively heavy. If this becomes a performance issue for long posts, you could debounce/throttle the save into #content-json or rely solely on the submit handler to serialize content.

Both are optional, but would make the editor behavior more predictable in production.

Also applies to: 145-149, 151-163

tests/Cms/Services/Post/CreatorTest.php (1)

45-69: Centralize EditorJS content JSON in a helper/fixture for test readability

The tests correctly assert both getContentRaw() and the derived getBody(). The same EditorJS JSON pattern ({"blocks":[{"type":"paragraph","data":{"text":"Body"}}]} and variants) is repeated across multiple tests, though.

To keep things DRY and easier to tweak later, consider extracting small helpers/constants, e.g.:

private function makeEditorJsContent(string $text = 'Body'): string
{
	return json_encode([
		'blocks' => [
			[
				'type' => 'paragraph',
				'data' => ['text' => $text],
			],
		],
	]);
}

Then use makeEditorJsContent('Test body content') / makeEditorJsContent() throughout the tests instead of hardcoding JSON strings.

Also applies to: 90-95, 118-123, 148-153, 177-182, 218-223, 263-273, 297-305

src/Cms/Maintenance/MaintenanceManager.php (1)

61-82: Emit maintenance events without risking core enable/disable operations

The new enabled/disabled events are wired in cleanly, and the optional $disabledBy argument keeps the public API backward compatible.

One robustness refinement to consider: if any event listener hooked into MaintenanceModeEnabledEvent / MaintenanceModeDisabledEvent throws, callers of enable() / disable() will see an exception even though the maintenance file has already been written or deleted. To keep state changes reliable, you might wrap the emits:

-		$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
-			) );
-		}
+		$result = $this->writeMaintenanceFile( $data );
+
+		// Emit maintenance mode enabled event (non-fatal)
+		if( $result )
+		{
+			try
+			{
+				\Neuron\Application\CrossCutting\Event::emit(
+					new \Neuron\Cms\Events\MaintenanceModeEnabledEvent(
+						$data['enabled_by'],
+						$message
+					)
+				);
+			}
+			catch( \Throwable $e )
+			{
+				// Optional: log but do not affect enable() result
+			}
+		}

and similarly in disable(). That keeps maintenance mode toggling deterministic even if downstream listeners misbehave.

Also applies to: 87-107

src/Cms/Services/Auth/Authentication.php (1)

41-59: Event emissions for login attempts look good; consider small refactors

The new UserLoginFailedEvent, UserLoginEvent, and UserLogoutEvent hooks are well-placed and give you good observability over auth flows. A couple of low-friction refinements:

  • The four failure branches emit UserLoginFailedEvent with only the reason differing. You could extract a small private helper like emitLoginFailed(string $username, string $reason): void to reduce duplication and keep changes localized if you later enrich the payload.
  • In login(), you call microtime( true ) both when setting login_time in the session and when emitting UserLoginEvent. Reusing a single timestamp would keep those values perfectly aligned:
-		// 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 ) );
+		// Store user ID in session
+		$this->_sessionManager->set( 'user_id', $user->getId() );
+		$this->_sessionManager->set( 'user_role', $user->getRole() );
+		$loginTime = microtime( true );
+		$this->_sessionManager->set( 'login_time', $loginTime );
@@
-		// Emit user login event
-		\Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginEvent(
-			$user,
-			$_SERVER['REMOTE_ADDR'] ?? 'unknown',
-			microtime( true )
-		) );
+		// Emit user login event
+		\Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginEvent(
+			$user,
+			$_SERVER['REMOTE_ADDR'] ?? 'unknown',
+			$loginTime
+		) );

Overall the behavior is sound; these are just to keep things DRY and metrics-friendly.

Also applies to: 61-85, 87-110, 134-156, 161-201

tests/Cms/Services/Post/UpdaterTest.php (1)

39-42: Mirror CreatorTest by centralizing EditorJS JSON for updates

These tests correctly validate that Updater::update() preserves both the raw EditorJS JSON (getContentRaw()) and the derived body text (getBody()), and that other fields (slug, status, categories, tags, etc.) behave as expected.

As in CreatorTest, you might want to centralize the repeated JSON snippets into a small helper (on this class or a shared trait) so any future changes to your EditorJS block shape only need to be made in one place.

Also applies to: 54-70, 97-99, 127-128, 171-172, 218-219, 254-255, 285-286, 321-322

resources/views/admin/posts/create.php (1)

131-142: Consider potential issues with programmatic form submission.

The submit handler prevents the default form submission, saves editor content, then calls e.target.submit(). This bypasses normal HTML5 validation and could cause issues if there are other submit handlers or validation logic.

Consider using FormData to submit the form programmatically while maintaining validation:

-document.getElementById('post-form').addEventListener('submit', async (e) => {
-	e.preventDefault();
-
-	try {
-		const savedData = await editor.save();
-		document.getElementById('content-json').value = JSON.stringify(savedData);
-		e.target.submit();
-	} catch (error) {
-		console.error('Error saving editor content:', error);
-		alert('Error preparing content. Please try again.');
-	}
-});
+document.getElementById('post-form').addEventListener('submit', async (e) => {
+	// Don't prevent default initially - let HTML5 validation run first
+	if (!e.target.checkValidity()) {
+		return; // Let browser handle validation
+	}
+	
+	e.preventDefault();
+	
+	try {
+		const savedData = await editor.save();
+		document.getElementById('content-json').value = JSON.stringify(savedData);
+		e.target.submit();
+	} catch (error) {
+		console.error('Error saving editor content:', error);
+		alert('Error preparing content. Please try again.');
+	}
+});
src/Cms/Controllers/Blog.php (1)

106-108: Add error handling for content rendering.

The renderer should handle malformed JSON or rendering errors gracefully to prevent the page from breaking when post content is invalid.

Wrap the rendering in a try-catch block:

-		// Render content from Editor.js JSON
-		$content = $post->getContent();
-		$renderedContent = $this->_renderer->render( $content );
+		// Render content from Editor.js JSON
+		$content = $post->getContent();
+		try {
+			$renderedContent = $this->_renderer->render( $content );
+		} catch (\Exception $e) {
+			error_log("Error rendering post content for slug '{$slug}': " . $e->getMessage());
+			$renderedContent = '<p>Content temporarily unavailable.</p>';
+		}
src/Cms/Models/Post.php (2)

125-128: Add error handling for malformed JSON in getContent().

If _contentRaw contains invalid JSON, json_decode() will return null and the ?? operator will correctly return the default, but this happens silently. Consider logging the error for debugging.

 	public function getContent(): array
 	{
-		return json_decode( $this->_contentRaw, true ) ?? ['blocks' => []];
+		$decoded = json_decode( $this->_contentRaw, true );
+		if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
+			error_log("Invalid JSON in Post content_raw (ID: {$this->_id}): " . json_last_error_msg());
+		}
+		return $decoded ?? ['blocks' => []];
 	}

607-646: Consider XSS risks in extractPlainText() for 'raw' block type.

Line 633 includes raw HTML from raw blocks without sanitization when extracting plain text. While strip_tags() is applied at line 640, malicious HTML could still be present in the intermediate processing.

Since this is meant to be plain text extraction, consider whether raw blocks should be included at all, or strip tags immediately:

 			$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'] ?? '',
+				'raw' => strip_tags($block['data']['html'] ?? ''),  // Strip tags immediately for raw HTML
 				default => ''
 			};
 
 			if( $blockText !== '' )
 			{
 				// Strip HTML tags from text
 				$blockText = strip_tags( $blockText );
 				$text[] = trim( $blockText );
 			}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 67ec356 and 9ebd824.

📒 Files selected for processing (61)
  • composer.json (2 hunks)
  • resources/app/Initializers/AuthInitializer.php (1 hunks)
  • resources/app/Initializers/MaintenanceInitializer.php (1 hunks)
  • resources/app/Initializers/PasswordResetInitializer.php (1 hunks)
  • resources/app/Initializers/RegistrationInitializer.php (1 hunks)
  • resources/database/migrate/20251119224525_add_content_raw_to_posts.php (1 hunks)
  • resources/views/admin/posts/create.php (3 hunks)
  • resources/views/admin/posts/edit.php (3 hunks)
  • resources/views/blog/show.php (2 hunks)
  • resources/views/http_codes/500.php (1 hunks)
  • src/Bootstrap.php (1 hunks)
  • src/Cms/Cli/Commands/Generate/EmailCommand.php (0 hunks)
  • src/Cms/Cli/Commands/Generate/stubs/email.stub (0 hunks)
  • src/Cms/Cli/Commands/Install/InstallCommand.php (1 hunks)
  • src/Cms/Cli/Commands/Maintenance/EnableCommand.php (1 hunks)
  • src/Cms/Cli/Commands/Queue/InstallCommand.php (0 hunks)
  • src/Cms/Cli/Provider.php (0 hunks)
  • src/Cms/Controllers/Admin/Categories.php (1 hunks)
  • src/Cms/Controllers/Admin/Profile.php (1 hunks)
  • src/Cms/Controllers/Admin/Users.php (1 hunks)
  • src/Cms/Controllers/Blog.php (4 hunks)
  • src/Cms/Controllers/Content.php (4 hunks)
  • src/Cms/Database/ConnectionFactory.php (1 hunks)
  • src/Cms/Email/helpers.php (1 hunks)
  • src/Cms/Events/EmailVerifiedEvent.php (1 hunks)
  • src/Cms/Events/MaintenanceModeDisabledEvent.php (1 hunks)
  • src/Cms/Events/MaintenanceModeEnabledEvent.php (1 hunks)
  • src/Cms/Events/PasswordResetCompletedEvent.php (1 hunks)
  • src/Cms/Events/PasswordResetRequestedEvent.php (1 hunks)
  • src/Cms/Events/UserLoginEvent.php (1 hunks)
  • src/Cms/Events/UserLoginFailedEvent.php (1 hunks)
  • src/Cms/Events/UserLogoutEvent.php (1 hunks)
  • src/Cms/Maintenance/MaintenanceConfig.php (1 hunks)
  • src/Cms/Maintenance/MaintenanceManager.php (1 hunks)
  • src/Cms/Models/Post.php (5 hunks)
  • src/Cms/Repositories/DatabaseCategoryRepository.php (1 hunks)
  • src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php (1 hunks)
  • src/Cms/Repositories/DatabasePageRepository.php (1 hunks)
  • src/Cms/Repositories/DatabasePasswordResetTokenRepository.php (1 hunks)
  • src/Cms/Repositories/DatabasePostRepository.php (1 hunks)
  • src/Cms/Repositories/DatabaseTagRepository.php (1 hunks)
  • src/Cms/Repositories/DatabaseUserRepository.php (1 hunks)
  • src/Cms/Services/Auth/Authentication.php (5 hunks)
  • src/Cms/Services/Auth/EmailVerifier.php (2 hunks)
  • src/Cms/Services/Auth/PasswordResetter.php (3 hunks)
  • src/Cms/Services/Email/Sender.php (1 hunks)
  • src/Cms/Services/Member/RegistrationService.php (1 hunks)
  • src/Cms/Services/Post/Creator.php (3 hunks)
  • src/Cms/Services/Post/Updater.php (3 hunks)
  • tests/Cms/BlogControllerTest.php (1 hunks)
  • tests/Cms/Cli/Commands/Install/InstallCommandTest.php (1 hunks)
  • tests/Cms/ContentControllerTest.php (1 hunks)
  • tests/Cms/Maintenance/MaintenanceConfigTest.php (1 hunks)
  • tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php (1 hunks)
  • tests/Cms/Repositories/DatabaseUserRepositoryTest.php (1 hunks)
  • tests/Cms/Services/AuthenticationTest.php (1 hunks)
  • tests/Cms/Services/EmailVerifierTest.php (1 hunks)
  • tests/Cms/Services/PasswordResetterTest.php (1 hunks)
  • tests/Cms/Services/Post/CreatorTest.php (9 hunks)
  • tests/Cms/Services/Post/UpdaterTest.php (10 hunks)
  • tests/Cms/Services/RegistrationServiceTest.php (1 hunks)
💤 Files with no reviewable changes (4)
  • src/Cms/Cli/Provider.php
  • src/Cms/Cli/Commands/Generate/EmailCommand.php
  • src/Cms/Cli/Commands/Queue/InstallCommand.php
  • src/Cms/Cli/Commands/Generate/stubs/email.stub
🧰 Additional context used
🧬 Code graph analysis (15)
src/Cms/Events/PasswordResetRequestedEvent.php (2)
src/Cms/Events/EmailVerifiedEvent.php (2)
  • __construct (29-33)
  • getName (35-38)
src/Cms/Events/PasswordResetCompletedEvent.php (2)
  • __construct (30-35)
  • getName (37-40)
resources/views/admin/posts/create.php (1)
src/Cms/View/helpers.php (1)
  • route_path (70-79)
src/Cms/Events/MaintenanceModeDisabledEvent.php (2)
src/Cms/Events/EmailVerifiedEvent.php (2)
  • __construct (29-33)
  • getName (35-38)
src/Cms/Events/MaintenanceModeEnabledEvent.php (2)
  • __construct (30-35)
  • getName (37-40)
src/Cms/Events/UserLoginEvent.php (3)
src/Cms/Events/EmailVerifiedEvent.php (2)
  • __construct (29-33)
  • getName (35-38)
src/Cms/Events/UserLoginFailedEvent.php (2)
  • __construct (31-38)
  • getName (40-43)
src/Cms/Events/UserLogoutEvent.php (2)
  • __construct (30-35)
  • getName (37-40)
src/Cms/Events/UserLoginFailedEvent.php (3)
src/Cms/Events/EmailVerifiedEvent.php (2)
  • __construct (29-33)
  • getName (35-38)
src/Cms/Events/UserLoginEvent.php (2)
  • __construct (31-37)
  • getName (39-42)
src/Cms/Events/UserLogoutEvent.php (2)
  • __construct (30-35)
  • getName (37-40)
src/Cms/Maintenance/MaintenanceManager.php (2)
src/Cms/Events/MaintenanceModeEnabledEvent.php (1)
  • MaintenanceModeEnabledEvent (24-41)
src/Cms/Events/MaintenanceModeDisabledEvent.php (1)
  • MaintenanceModeDisabledEvent (24-39)
tests/Cms/Services/Post/CreatorTest.php (2)
src/Cms/Controllers/Content.php (1)
  • getTitle (141-144)
src/Cms/Models/Post.php (2)
  • getTitle (74-77)
  • getContentRaw (133-136)
src/Cms/Events/MaintenanceModeEnabledEvent.php (2)
src/Cms/Events/EmailVerifiedEvent.php (2)
  • __construct (29-33)
  • getName (35-38)
src/Cms/Events/MaintenanceModeDisabledEvent.php (2)
  • __construct (29-33)
  • getName (35-38)
src/Cms/Services/Post/Creator.php (1)
src/Cms/Models/Post.php (1)
  • setContent (142-147)
src/Cms/Services/Auth/EmailVerifier.php (2)
src/Cms/Events/EmailVerifiedEvent.php (1)
  • EmailVerifiedEvent (24-39)
src/Cms/Services/Auth/Authentication.php (1)
  • user (226-249)
resources/views/admin/posts/edit.php (2)
src/Cms/View/helpers.php (1)
  • route_path (70-79)
src/Cms/Models/Post.php (2)
  • getId (57-60)
  • getContentRaw (133-136)
src/Cms/Services/Auth/Authentication.php (6)
src/Cms/Events/UserLoginFailedEvent.php (1)
  • UserLoginFailedEvent (23-44)
src/Cms/Models/User.php (2)
  • isLockedOut (332-340)
  • isActive (187-190)
src/Cms/Auth/SessionManager.php (1)
  • set (99-103)
src/Cms/Events/UserLoginEvent.php (1)
  • UserLoginEvent (24-43)
src/Cms/Controllers/Auth/Login.php (1)
  • logout (139-143)
src/Cms/Events/UserLogoutEvent.php (1)
  • UserLogoutEvent (24-41)
resources/database/migrate/20251119224525_add_content_raw_to_posts.php (1)
src/Cms/Services/Post/Updater.php (1)
  • update (48-83)
tests/Cms/Services/Post/UpdaterTest.php (2)
src/Cms/Models/Post.php (5)
  • setContent (142-147)
  • getTitle (74-77)
  • getContentRaw (133-136)
  • getBody (108-111)
  • getStatus (246-249)
src/Cms/Repositories/DatabasePostRepository.php (1)
  • update (127-187)
src/Cms/Services/Post/Updater.php (1)
src/Cms/Models/Post.php (1)
  • setContent (142-147)
🔇 Additional comments (54)
src/Cms/Maintenance/MaintenanceConfig.php (1)

5-5: Namespace migration is complete and verified.

The import change from Neuron\Data\Setting to Neuron\Data\Settings on line 5 is correct. Verification confirms no remaining old namespace references exist in the codebase, and the new namespace is consistently used across 20 files (production code and tests).

src/Cms/Cli/Commands/Maintenance/EnableCommand.php (1)

8-8: Yaml settings source namespace update looks correct

The Yaml import now targets Neuron\Data\Settings\Source\Yaml, which matches how it’s used in loadConfiguration() and aligns with the Settings namespace migration; no further changes needed here as long as the installed neuron-php/data version exposes this namespace.

src/Cms/Services/Email/Sender.php (1)

7-7: SettingManager import aligned with Settings namespace

Switching to Neuron\Data\Settings\SettingManager is consistent with the property/ctor type hints and the rest of the Settings migration; the email config access remains unchanged.

src/Cms/Services/Member/RegistrationService.php (1)

9-9: RegistrationService now correctly targets Settings\SettingManager

The import of Neuron\Data\Settings\SettingManager matches the constructor and property types and keeps the member settings lookups intact.

src/Cms/Controllers/Admin/Categories.php (1)

11-11: Categories controller import updated to Settings\SettingManager

The controller now imports Neuron\Data\Settings\SettingManager, keeping it consistent with the rest of the Settings namespace migration in the project.

tests/Cms/Maintenance/MaintenanceConfigTest.php (1)

6-6: Test Yaml source import matches new Settings namespace

Using Neuron\Data\Settings\Source\Yaml in this test is consistent with the production MaintenanceConfig usage and the broader Settings namespace move.

src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php (1)

7-7: Repository constructor now depends on Settings\SettingManager

The repository correctly imports Neuron\Data\Settings\SettingManager and passes it to ConnectionFactory::createFromSettings(), keeping database configuration behavior unchanged under the new namespace.

tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php (1)

8-8: Test mock updated to Settings\SettingManager

The test now mocks Neuron\Data\Settings\SettingManager, matching the repository’s constructor signature and ensuring the database config wiring remains valid.

tests/Cms/ContentControllerTest.php (1)

6-6: In-memory settings source switched to Settings\Source\Memory

The test now uses Neuron\Data\Settings\Source\Memory, which is consistent with the Settings namespace refactor and how the controller reads settings via the Registry.

src/Cms/Repositories/DatabaseUserRepository.php (1)

7-7: Repository constructor now aligned with Settings namespace

The repository’s dependency on SettingManager is correctly switched to the Neuron\Data\Settings namespace, keeping the constructor type-hint and ConnectionFactory::createFromSettings() usage consistent.

Please run your user-related tests (e.g., DatabaseUserRepositoryTest, auth flows) to confirm there are no remaining references to the old Neuron\Data\Setting\SettingManager type.

src/Cms/Repositories/DatabasePageRepository.php (1)

8-8: Page repository SettingManager import migrated cleanly

The constructor’s SettingManager dependency now points at Neuron\Data\Settings\SettingManager, matching the rest of the PR’s namespace migration without altering any repository behavior.

Confirm that any code instantiating DatabasePageRepository (controllers/services) is also using the updated Settings-namespace SettingManager instance from the container/registry.

src/Cms/Repositories/DatabaseTagRepository.php (1)

7-7: Tag repository settings dependency updated to new namespace

DatabaseTagRepository now correctly imports Neuron\Data\Settings\SettingManager while keeping the constructor contract and internals the same, so behavior should be unaffected.

As part of your test run, ensure any install/seed or tag-related CLI flows still resolve the correct SettingManager implementation.

tests/Cms/Cli/Commands/Install/InstallCommandTest.php (1)

8-9: Install command test imports aligned with Settings namespace

The test now imports Neuron\Data\Settings\SettingManager and Neuron\Data\Settings\Source\Yaml, matching the production code’s updated namespaces and avoiding mismatch with the newer data/settings package layout.

If these imports are no longer needed by the test, they can be safely removed in a later cleanup pass once static analysis (e.g., phpstan/psalm) is green.

src/Cms/Controllers/Admin/Users.php (1)

13-13: Admin Users controller now consistent with Settings-based repositories

The controller’s import of Neuron\Data\Settings\SettingManager matches the updated constructor dependency of DatabaseUserRepository, keeping the Registry-provided 'Settings' instance aligned with the repository’s expected type.

Please verify that the Registry is registering an instance of the new Neuron\Data\Settings\SettingManager class under the 'Settings' key in your bootstrap/initializer code.

src/Bootstrap.php (1)

4-5: Bootstrap imports updated to new data library namespaces

Version and Yaml now import from Neuron\Data\Objects\Version and Neuron\Data\Settings\Source\Yaml, which matches the reorganized data library structure without changing the boot() logic.

After updating composer dependencies, run a full application bootstrap (and any bootstrap-related tests) to confirm there are no unresolved references to the old Neuron\Data\Object\Version or Neuron\Data\Setting\Source\Yaml classes.

src/Cms/Controllers/Admin/Profile.php (1)

10-10: Admin Profile controller settings import matches repository expectations

The profile controller’s import now targets Neuron\Data\Settings\SettingManager, aligning its dependency graph with the updated DatabaseUserRepository and the broader Settings namespace migration.

As with the Users controller, double‑check that the Registry’s 'Settings' entry is an instance of the new Settings\SettingManager to avoid subtle type mismatches at runtime.

src/Cms/Database/ConnectionFactory.php (1)

5-5: Namespace migration verified as complete

The import change to Neuron\Data\Settings\SettingManager is correct and actively used. Verification confirms:

  • No lingering references to the old Neuron\Data\Setting namespace exist
  • The type hint in createFromSettings() (line 26) properly references the new SettingManager class
  • The method implementation correctly delegates to createFromConfig() as expected

The migration is consistent and safe to merge.

src/Cms/Repositories/DatabaseCategoryRepository.php (1)

7-7: Settings namespace update is consistent

Switching to Neuron\Data\Settings\SettingManager here matches the broader Settings namespace refactor and should be a no‑op at runtime as long as the updated neuron‑php/data dependency is installed.

Please run the test suite after composer update with the new dependencies to confirm there are no autoloading or wiring issues around SettingManager.

tests/Cms/Repositories/DatabaseUserRepositoryTest.php (1)

8-8: Updated SettingManager import in tests looks good

Using Neuron\Data\Settings\SettingManager in the mocks keeps the test suite aligned with the refactored Settings namespace; behavior remains unchanged because the class is mocked.

After updating dependencies, please run this test class to ensure the new namespace resolves correctly under your autoloader.

src/Cms/Repositories/DatabasePostRepository.php (1)

10-10: Settings namespace import updated correctly

Referencing Neuron\Data\Settings\SettingManager here keeps this repository in sync with the Settings refactor and ConnectionFactory usage; the constructor contract remains the same.

After dependency updates, please run a small smoke test that instantiates DatabasePostRepository via your usual bootstrap path to confirm the new namespace resolves as expected.

src/Cms/Email/helpers.php (1)

4-4: Email helpers now reference the new Settings namespace

Updating the type to Neuron\Data\Settings\ISettingSource keeps these helpers compatible with the refactored Settings package without changing their runtime behavior.

Please ensure the updated Settings package is present in all environments where these helpers are used (including CLI contexts) and that any existing call sites still pass a compatible settings source.

resources/database/migrate/20251119224525_add_content_raw_to_posts.php (1)

13-22: Migration for content_raw is straightforward; verify adapter support for options

Adding content_raw as non‑nullable TEXT with a default empty Editor.js document is reasonable and should keep existing rows valid.

The use of 'after' => 'body' and 'comment' => 'Editor.js JSON content' is typically MySQL‑specific in Phinx; depending on how strictly you target SQLite/PostgreSQL as well, those options may be ignored or could need adapter‑specific handling.

Please confirm that this migration runs cleanly against all database engines you support (especially in CI and local dev). If you see adapter‑specific issues, you may want to conditionally set after/comment or drop them for non‑MySQL adapters.

src/Cms/Events/EmailVerifiedEvent.php (1)

24-38: EmailVerifiedEvent implementation looks solid

The event cleanly encapsulates the verified User via a readonly promoted property and exposes a clear, stable name ('email.verified') for subscribers. The design is consistent with the other event classes in this PR.

src/Cms/Events/MaintenanceModeEnabledEvent.php (1)

24-40: MaintenanceModeEnabledEvent is consistent with the event model

Readonly constructor‑promoted properties for enabledBy and message plus a stable name 'maintenance.enabled' match the existing disabled‑event pattern and give listeners all relevant context.

src/Cms/Events/UserLoginEvent.php (1)

24-42: UserLoginEvent structure is consistent and ready for consumers

The event payload (User, IP, timestamp) and name user.login align with the other event types you’ve added and give downstream listeners enough context without extra coupling. No issues spotted.

src/Cms/Controllers/Content.php (2)

3-4: Version loading via factory is a clean upgrade

Switching to Factories\Version::fromFile("../.version.json") while keeping the broad \Exception catch preserves existing behavior and centralizes version deserialization in the data library. As long as the working directory is stable for web/CLI entry points, this looks good.

Also applies to: 51-55, 85-94


201-213: Defaulting markdown page route to index improves resilience

Using $request->getRouteParameter('page') ?? 'index' avoids failures when the route omits page and matches the typical “root markdown page” expectation. Just confirm your router/front-controller has an index.md (or equivalent) to back this default.

src/Cms/Repositories/DatabasePasswordResetTokenRepository.php (1)

5-8: Settings namespace refactor is consistent

Using Neuron\Data\Settings\SettingManager here matches the broader Settings namespace migration and keeps the existing constructor contract intact. No repository behavior changes introduced.

Also applies to: 23-32

tests/Cms/Services/PasswordResetterTest.php (1)

11-13: Test settings imports now align with production namespaces

Updating to Settings\Source\Memory and Settings\SettingManager keeps the test configuration in sync with the data layer refactor. The in-memory setup logic remains the same and should continue to exercise PasswordResetter as before.

Also applies to: 34-42

tests/Cms/Services/EmailVerifierTest.php (1)

10-12: EmailVerifier tests correctly track the Settings namespace change

The move to Neuron\Data\Settings\Source\Memory and Settings\SettingManager mirrors the production code and leaves the test behavior intact (test-mode email, same keys). Looks good.

Also applies to: 31-39

resources/app/Initializers/RegistrationInitializer.php (1)

31-38: Registration initializer now guards against the correct Settings manager type

Switching the instanceof check to \Neuron\Data\Settings\SettingManager matches the new Settings namespace and preserves the early-exit behavior when configuration isn’t wired. No additional issues here.

src/Cms/Services/Auth/EmailVerifier.php (1)

10-11: I'll help you verify the review comment about email verification event emission safety. Let me start by exploring the codebase to understand how the Event::emit() method handles missing emitters and listener failures.
<function_calls>


#!/bin/bash

Find the Event class definition

rg -n "class Event" --type=php

Find emit method definitions

rg -n "function emit|public function emit" --type=php -A 5

</function_calls>

src/Cms/Services/Post/Creator.php (1)

35-40: Confirmed: API change is complete and well-implemented internally, but lacks external documentation

The migration is thorough within the repository:

  • Post model correctly implements setContent() with EditorJS JSON, auto-extracting plain text to _body for backward compatibility
  • Creator service properly uses setContent()
  • Admin form is fully updated with Editor.js frontend that serializes to EditorJS JSON
  • Database schema includes content_raw column for JSON storage
  • Tests pass EditorJS JSON correctly

However, this package is publicly distributed via Composer ("neuron-php/cms" library type), making Creator::create() a public API. The breaking change—requiring EditorJS JSON instead of rendered body strings—lacks a migration guide or changelog. No backward compatibility wrapper exists.

tests/Cms/Services/RegistrationServiceTest.php (1)

10-11: LGTM! Namespace migration applied correctly.

The import paths have been updated to use the new Neuron\Data\Settings namespace, aligning with the project-wide refactoring.

resources/app/Initializers/PasswordResetInitializer.php (1)

9-9: LGTM! Namespace migration applied correctly.

The import path has been updated to use the new Neuron\Data\Settings\SettingManager namespace.

src/Cms/Cli/Commands/Install/InstallCommand.php (1)

9-10: LGTM! Namespace migration applied correctly.

The import paths have been updated to use the new Neuron\Data\Settings namespace.

tests/Cms/Services/AuthenticationTest.php (1)

11-11: LGTM! Namespace migration applied correctly.

The import path has been updated to use the new Neuron\Data\Settings\SettingManager namespace.

tests/Cms/BlogControllerTest.php (1)

13-14: LGTM! Namespace migration applied correctly.

The import paths have been updated to use the new Neuron\Data\Settings namespace.

resources/app/Initializers/AuthInitializer.php (1)

32-32: LGTM! Type check updated for namespace migration.

The instanceof check has been updated to reference the new Neuron\Data\Settings\SettingManager namespace.

resources/views/blog/show.php (2)

51-160: LGTM! Comprehensive styling for EditorJS content.

The CSS additions provide proper styling for post content and EditorJS widgets, including headings, lists, blockquotes, code blocks, images, and custom widgets.


39-39: I'll help you verify the concerns raised in the review comment. Let me search the codebase for the Blog controller and examine how $renderedContent is set.
<function_calls>

#!/bin/bash

Find the Blog controller

fd -t f -i "blog" --type=php | head -20


</function_calls>

Let me search more specifically for the Blog controller:
<function_calls>

#!/bin/bash

Search for Blog controller class definition

rg -n "class Blog" --type=php -A 5


</function_calls>

Now let me search for where the show view is rendered:
<function_calls>

#!/bin/bash

Search for show view rendering and renderedContent variable

rg -n "renderedContent|show.php|blog/show" --type=php -B 3 -A 3


</function_calls>

Let me also check the structure of the resources/views/blog directory:
<function_calls>

#!/bin/bash

List files in blog views directory

fd -t f "show.php" && echo "---" && fd -t f "blog" resources/views/ 2>/dev/null | head -10


</function_calls>

Let me check the full repository structure to understand the layout:
<function_calls>

#!/bin/bash

Get an overview of the repository structure

git ls-files | grep -E "(controller|blog)" | head -20


</function_calls>

src/Cms/Services/Auth/PasswordResetter.php (1)

10-10: LGTM! Namespace migration applied correctly.

The import path has been updated to use the new Neuron\Data\Settings\SettingManager namespace.

resources/app/Initializers/MaintenanceInitializer.php (1)

45-59: Updated Settings manager namespace check looks correct

Switching the instanceof check to \Neuron\Data\Settings\SettingManager aligns this initializer with the new Settings namespace without changing runtime behavior. No further changes needed here.

src/Cms/Events/PasswordResetCompletedEvent.php (1)

24-40: PasswordResetCompletedEvent structure and naming are consistent

Readonly payload (User + IP) and the password.reset_completed name match the existing event style in this module. Good to go.

src/Cms/Events/PasswordResetRequestedEvent.php (1)

24-40: PasswordResetRequestedEvent aligns with the existing event model

Readonly User + IP payload and the password.reset_requested name follow the same pattern as your other events. Looks good as a foundation for security monitoring and auditing.

src/Cms/Events/UserLogoutEvent.php (1)

1-41: LGTM!

The event class is well-structured with clear documentation, follows the established event pattern, and properly uses constructor property promotion. The inclusion of sessionDuration is a valuable addition for analytics.

src/Cms/Controllers/Blog.php (1)

40-44: LGTM! Well-structured dependency initialization.

The renderer initialization properly chains dependencies (WidgetRenderer → ShortcodeParser → EditorJsRenderer), creating a clean content processing pipeline.

src/Cms/Events/UserLoginFailedEvent.php (1)

1-44: LGTM!

The event class is well-designed for security monitoring and audit trails. The inclusion of reason, ip, and timestamp provides comprehensive context for failed login attempts, which is essential for security analysis and compliance.

src/Cms/Events/MaintenanceModeDisabledEvent.php (1)

8-39: LGTM! Event structure is appropriate.

The event class is well-documented and follows the established pattern. Using a string for $disabledBy (username/identifier) is consistent with the EnabledEvent and provides flexibility.

src/Cms/Services/Post/Updater.php (1)

39-62: LGTM! Clean migration to EditorJS content handling.

The parameter rename from $body to $content and the corresponding method call change from setBody() to setContent() properly reflect the shift to EditorJS JSON format. The updated docblock clearly documents this change.

src/Cms/Models/Post.php (3)

156-169: LGTM! Good error handling for JSON encoding.

The setContentArray() method properly handles JSON encoding failures with an explicit exception, which is better than silent failures. The use of \JsonException is appropriate.


501-523: Well-designed content handling in fromArray().

The method properly handles both content_raw and content keys, and supports both string and array formats for each. This provides good flexibility for different data sources while maintaining backward compatibility.


625-635: Use match expression correctly handles EditorJS block types.

The match expression properly extracts text from various EditorJS block types. The handling of list items with implode() and the fallback to empty string for unknown types is appropriate.

resources/views/admin/posts/create.php (1)

104-108: The image upload endpoint handler for /admin/upload/image is not implemented in the codebase.

While the codebase has a robust CSRF protection system in place (CsrfFilter and CsrfToken service), the actual route handler for the /admin/upload/image endpoint referenced in the frontend editor configuration (create.php and edit.php) could not be located. The endpoint is configured in the views but no corresponding PHP route handler exists in src/Cms/Controllers/ or elsewhere.

Recommendation: Implement the /admin/upload/image route handler and ensure it is protected with the CsrfFilter to validate CSRF tokens before processing uploads.


namespace Neuron\Cms\Events;

use Neuron\Cms\Models\User;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused import.

The User class is imported but not used in this event class. The $disabledBy property is a string, not a User object.

-use Neuron\Cms\Models\User;
 use Neuron\Events\IEvent;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
use Neuron\Cms\Models\User;
use Neuron\Events\IEvent;
🤖 Prompt for AI Agents
In src/Cms/Events/MaintenanceModeDisabledEvent.php around line 5, the file
imports Neuron\Cms\Models\User but never uses it (the $disabledBy property is a
string). Remove the unused import line and confirm there are no remaining
references to the User class in this file.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Cms/Repositories/DatabaseUserRepository.php (1)

20-32: PDO connection is created but never used.

The $_pdo property is initialized via ConnectionFactory::createFromSettings() but no method in this class uses it. This wastes a database connection. Either remove the PDO initialization or add a comment explaining if it's needed for future use or external access.

-	private ?PDO $_pdo = null;
-
 	/**
 	 * Constructor
 	 *
 	 * @param SettingManager $settings Settings manager with database configuration
 	 * @throws Exception if database configuration is missing or adapter is unsupported
 	 */
 	public function __construct( SettingManager $settings )
 	{
-		// Keep PDO property for backwards compatibility with tests
-		$this->_pdo = ConnectionFactory::createFromSettings( $settings );
+		// ORM is initialized in Bootstrap - no direct PDO needed
 	}
src/Cms/Repositories/DatabasePostRepository.php (1)

260-282: getByCategory and getByTag don't load relations.

Unlike findById and findBySlug, these methods return posts without loading categories and tags. This inconsistency may cause issues when consuming code expects relations to be populated.

-	return array_map( fn( $row ) => Post::fromArray( $row ), $rows );
+	$posts = array_map( fn( $row ) => Post::fromArray( $row ), $rows );
+	foreach( $posts as $post )
+	{
+		$this->loadRelations( $post );
+	}
+	return $posts;

Apply to both getByCategory and getByTag. Note: this introduces N+1 queries; consider batching if performance is critical.

Also applies to: 287-309

♻️ Duplicate comments (1)
resources/views/admin/posts/create.php (1)

99-106: Plugin load check is incomplete (same issue as edit.php).

Similar to edit.php, the plugin availability check only validates EditorJS, Header, and List, but the configuration uses additional plugins (ImageTool, Quote, CodeTool, Delimiter, RawTool).

Apply the same fix suggested for edit.php to ensure all required plugins are validated before initialization.

🧹 Nitpick comments (17)
resources/config/neuron.yaml.example (4)

79-90: Cloudinary config is clear; add a note about handling credentials securely

The structure and defaults look good and match typical Cloudinary usage. To avoid people committing real secrets into VCS, it’s worth explicitly calling that out in the example.

Consider this small tweak:

   cloudinary:
     cloud_name: your-cloud-name  # Your Cloudinary cloud name (required)
     api_key: your-api-key  # API key from Cloudinary console (required)
-    api_secret: your-api-secret  # API secret from Cloudinary console (required)
+    api_secret: your-api-secret  # API secret from Cloudinary console (required)
+    # NOTE: In production, do not commit real Cloudinary credentials to version control.
+    #       Prefer environment-specific config or environment variables for secrets.
     folder: neuron-cms/images  # Default folder for uploads (optional)
     max_file_size: 5242880  # Maximum file size in bytes (5MB default)
     allowed_formats: [jpg, jpeg, png, gif, webp]  # Allowed image formats

Also double‑check that max_file_size and allowed_formats align with whatever validation the uploader middleware/enforcer is using so the UX is consistent.


91-101: Database example looks fine; verify adapter strings match actual driver identifiers

The keys and comments are straightforward and useful. The only potential footgun is the adapter string values and how SQLite is meant to be configured.

  • Confirm that mysql, pgsql, and sqlite exactly match what your DB layer expects (no pdo_mysql/pdo_pgsql vs custom names mismatch).
  • For sqlite, host, port, user, and pass are typically ignored; consider adding a brief comment or a second example to show what a SQLite config should look like to avoid confusion.

110-128: Email/PHPMailer config is well documented; double‑check driver mapping and test mode behavior

The email section is nicely annotated and gives practical SMTP examples. The main risk is drift between these keys and the actual mailer implementation.

  • Ensure that driver: mail|sendmail|smtp maps cleanly to the PHPMailer setup (or equivalent) so users don’t hit silent fallbacks.
  • Confirm that test_mode behaves exactly as described (log but do not send) across all drivers.
  • Optionally, you might add a short note that Gmail typically requires an app password and 2FA, which you already hint at via your-app-password but could call out explicitly in a comment.

129-138: Maintenance config is solid; consider clarifying IP/CIDR support

Defaults here look good (localhost allowlist, clear retry semantics, and optional custom view).

  • If the implementation supports CIDR ranges and IPv6 ranges, it may be worth turning the commented 192.168.1.0/24 example into a one‑line explanation in the comment to make that capability explicit.
  • Double‑check that retry_after is indeed interpreted as seconds (matching HTTP Retry-After) and that custom_view paths are resolved consistently with the rest of the view system.
.github/workflows/ci.yml (1)

10-11: Consider updating actions/checkout to v4.

actions/checkout@v3 is functional but v4 is the current version with performance improvements and Node.js 20 runtime.

     - name: Checkout
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
resources/database/migrate/20251205000000_add_two_factor_and_timezone_to_users.php (1)

45-45: update() is called unconditionally.

When both columns already exist, update() is called without any pending changes. While harmless, you could wrap it in a conditional to avoid the no-op database call.

-		$table->update();
+		if( !$table->hasColumn( 'two_factor_recovery_codes' ) || !$table->hasColumn( 'timezone' ) )
+		{
+			$table->update();
+		}

Alternatively, track whether any column was added and only call update() if needed.

src/Cms/Repositories/DatabasePageRepository.php (1)

24-27: Constructor parameter is unused.

The $settings parameter is no longer used since ORM initialization moved to Bootstrap. Consider either removing the parameter (breaking change) or documenting why it's retained for interface compatibility.

src/Cms/Repositories/DatabasePostRepository.php (1)

411-426: Duplicate code: attachCategories/attachTags overlap with sync methods.

The attach methods duplicate the INSERT logic from syncCategories/syncTags. Consider refactoring to have sync call a shared insert helper, or remove the attach methods if sync is the canonical approach.

Also applies to: 442-457

resources/views/admin/posts/edit.php (1)

119-126: Plugin load check is incomplete.

The interval only checks for EditorJS, Header, and List, but the editor configuration also uses ImageTool, Quote, CodeTool, Delimiter, and RawTool. If any of these fail to load, the editor will initialize but those tools will be unavailable or cause errors.

Consider extending the check to include all required plugins:

 		if (typeof EditorJS !== 'undefined' &&
 		    typeof Header !== 'undefined' &&
-		    typeof List !== 'undefined') {
+		    typeof List !== 'undefined' &&
+		    typeof ImageTool !== 'undefined' &&
+		    typeof Quote !== 'undefined' &&
+		    typeof CodeTool !== 'undefined' &&
+		    typeof Delimiter !== 'undefined' &&
+		    typeof RawTool !== 'undefined') {
resources/views/admin/posts/create.php (1)

118-194: Consider extracting shared EditorJS initialization to a reusable script.

The EditorJS initialization logic is nearly identical to edit.php. While acceptable for view files, extracting the shared initialization code to a dedicated JavaScript file (e.g., /js/admin/editor-init.js) would reduce duplication and simplify maintenance.

src/Cms/Models/Post.php (1)

156-169: Proper error handling in setContentArray.

Using json_last_error_msg() and throwing JsonException provides clear feedback when JSON encoding fails. However, using the JSON_THROW_ON_ERROR flag with json_encode() would be more idiomatic for PHP 7.3+.

 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}" );
-	}
+	$encoded = json_encode( $content, JSON_THROW_ON_ERROR );

 	$this->_contentRaw = $encoded;
 	$this->_body = $this->extractPlainText( $encoded );
 	return $this;
 }
src/Cms/Services/Media/MediaValidator.php (2)

108-150: MIME type allowlist is hardcoded and doesn't align with configurable allowed_formats.

The allowed_formats setting is configurable and used for extension validation, but the $allowedMimeTypes array is hardcoded. If someone configures additional formats (e.g., svg, bmp), the MIME check will reject them even though the extension check passes. Also, image/jpg is not a valid MIME type (should only be image/jpeg).

Consider deriving the MIME types from the allowed formats or making both configurable:

-		$allowedMimeTypes = [
-			'image/jpeg',
-			'image/jpg',
-			'image/png',
-			'image/gif',
-			'image/webp'
-		];
+		$mimeTypeMap = [
+			'jpg' => 'image/jpeg',
+			'jpeg' => 'image/jpeg',
+			'png' => 'image/png',
+			'gif' => 'image/gif',
+			'webp' => 'image/webp'
+		];
+
+		$allowedMimeTypes = array_values( array_intersect_key( $mimeTypeMap, array_flip( $allowedFormats ) ) );

81-99: Zero-byte check should precede max-size check.

A zero-byte file will trigger both the $size > $maxSize condition (false) and then the $size === 0 condition. While logically correct, checking for zero-byte first provides a more specific error message and is slightly more efficient.

 	private function validateFileSize( int $size ): bool
 	{
+		if( $size === 0 )
+		{
+			$this->_errors[] = 'File is empty';
+			return false;
+		}
+
 		$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;
 	}
tests/Cms/Services/Media/MediaValidatorTest.php (1)

163-167: Consider adding a test for valid image upload and MIME type mismatch.

The comment acknowledges that full validation with real image files is complex, but you could use a minimal valid JPEG/PNG byte sequence to test the happy path. Additionally, testing a file with a valid extension but invalid MIME type (e.g., a .jpg file containing text) would improve coverage.

Would you like me to provide example code for creating a minimal valid image in vfsStream for the happy path test?

src/Cms/Controllers/Admin/Media.php (2)

133-144: Using exit makes the controller difficult to unit test.

The exit calls prevent proper testing and don't align well with MVC patterns. Consider returning early or throwing an exception that the framework catches, or using a response object pattern.

If refactoring is not feasible now, you could at least extract the exit to a protected method that can be mocked in tests:

+	/**
+	 * Terminate the response (can be overridden in tests)
+	 */
+	protected function terminate(): void
+	{
+		exit;
+	}
+
 	private function returnEditorJsSuccess( array $result ): void
 	{
 		echo json_encode( [
 			'success' => 1,
 			'file' => [
 				'url' => $result['url'],
 				'width' => $result['width'],
 				'height' => $result['height']
 			]
 		] );
-		exit;
+		$this->terminate();
 	}

Also applies to: 168-175


49-82: Consider extracting common upload logic to reduce duplication.

Both uploadImage and uploadFeaturedImage share the same validation and upload logic, differing only in response format. This could be refactored to a private helper.

private function processUpload(): array
{
    if( !isset( $_FILES['image'] ) )
    {
        throw new \InvalidArgumentException( 'No file was uploaded' );
    }

    $file = $_FILES['image'];

    if( !$this->_validator->validate( $file ) )
    {
        throw new \InvalidArgumentException( $this->_validator->getFirstError() );
    }

    return $this->_uploader->upload( $file['tmp_name'] );
}

Also applies to: 92-125

src/Cms/Cli/Commands/Install/UpgradeCommand.php (1)

424-437: runMigrations() is misleading - it doesn't run migrations.

The method name suggests it runs migrations, but it only outputs instructions. Either rename it to displayMigrationInstructions() or implement actual migration execution.

-	private function runMigrations(): bool
+	private function displayMigrationInstructions(): void
 	{
-		$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;
 	}

Update the call site at line 145 accordingly.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9ebd824 and 0e0d43e.

📒 Files selected for processing (38)
  • .github/workflows/ci.yml (1 hunks)
  • .gitignore (1 hunks)
  • MIGRATIONS.md (1 hunks)
  • UPGRADE_NOTES.md (1 hunks)
  • composer.json (2 hunks)
  • readme.md (1 hunks)
  • resources/.cms-manifest.json (1 hunks)
  • resources/config/database.yaml.example (0 hunks)
  • resources/config/email.yaml.example (0 hunks)
  • resources/config/maintenance.yaml.example (0 hunks)
  • resources/config/neuron.yaml.example (1 hunks)
  • resources/config/routes.yaml (1 hunks)
  • resources/database/migrate/20250111000000_create_users_table.php (1 hunks)
  • resources/database/migrate/20251205000000_add_two_factor_and_timezone_to_users.php (1 hunks)
  • resources/views/admin/posts/create.php (3 hunks)
  • resources/views/admin/posts/edit.php (3 hunks)
  • src/Cms/Cli/Commands/Install/InstallCommand.php (2 hunks)
  • src/Cms/Cli/Commands/Install/UpgradeCommand.php (1 hunks)
  • src/Cms/Cli/Provider.php (1 hunks)
  • src/Cms/Controllers/Admin/Media.php (1 hunks)
  • src/Cms/Dtos/MediaUploadDto.yaml (1 hunks)
  • src/Cms/Models/Post.php (5 hunks)
  • src/Cms/Repositories/DatabaseCategoryRepository.php (7 hunks)
  • src/Cms/Repositories/DatabasePageRepository.php (5 hunks)
  • src/Cms/Repositories/DatabasePostRepository.php (13 hunks)
  • src/Cms/Repositories/DatabaseTagRepository.php (6 hunks)
  • src/Cms/Repositories/DatabaseUserRepository.php (5 hunks)
  • src/Cms/Services/Media/CloudinaryUploader.php (1 hunks)
  • src/Cms/Services/Media/IMediaUploader.php (1 hunks)
  • src/Cms/Services/Media/MediaValidator.php (1 hunks)
  • tests/Cms/BlogControllerTest.php (3 hunks)
  • tests/Cms/Repositories/DatabaseCategoryRepositoryTest.php (2 hunks)
  • tests/Cms/Repositories/DatabasePostRepositoryTest.php (3 hunks)
  • tests/Cms/Repositories/DatabaseTagRepositoryTest.php (2 hunks)
  • tests/Cms/Repositories/DatabaseUserRepositoryTest.php (3 hunks)
  • tests/Cms/Services/AuthenticationTest.php (3 hunks)
  • tests/Cms/Services/Media/CloudinaryUploaderTest.php (1 hunks)
  • tests/Cms/Services/Media/MediaValidatorTest.php (1 hunks)
💤 Files with no reviewable changes (3)
  • resources/config/maintenance.yaml.example
  • resources/config/email.yaml.example
  • resources/config/database.yaml.example
✅ Files skipped from review due to trivial changes (2)
  • resources/.cms-manifest.json
  • .gitignore
🚧 Files skipped from review as they are similar to previous changes (1)
  • composer.json
🧰 Additional context used
🧬 Code graph analysis (13)
resources/views/admin/posts/edit.php (2)
src/Cms/View/helpers.php (1)
  • route_path (70-79)
src/Cms/Models/Post.php (1)
  • getContentRaw (133-136)
src/Cms/Services/Media/IMediaUploader.php (2)
src/Cms/Controllers/Admin/Media.php (1)
  • Media (18-192)
src/Cms/Services/Media/CloudinaryUploader.php (3)
  • upload (67-88)
  • uploadFromUrl (98-120)
  • delete (129-142)
src/Cms/Services/Media/MediaValidator.php (2)
src/Cms/Controllers/Admin/Media.php (2)
  • Media (18-192)
  • __construct (26-39)
src/Cms/Services/Media/CloudinaryUploader.php (1)
  • __construct (27-31)
src/Cms/Controllers/Admin/Media.php (3)
src/Cms/Services/Media/CloudinaryUploader.php (3)
  • CloudinaryUploader (16-197)
  • __construct (27-31)
  • upload (67-88)
src/Cms/Services/Media/MediaValidator.php (3)
  • MediaValidator (14-192)
  • __construct (24-27)
  • getFirstError (188-191)
src/Cms/Services/Media/IMediaUploader.php (1)
  • upload (22-22)
tests/Cms/Services/Media/CloudinaryUploaderTest.php (2)
src/Cms/Services/Media/CloudinaryUploader.php (3)
  • CloudinaryUploader (16-197)
  • upload (67-88)
  • uploadFromUrl (98-120)
src/Cms/Services/Media/IMediaUploader.php (2)
  • upload (22-22)
  • uploadFromUrl (32-32)
src/Cms/Repositories/DatabasePostRepository.php (5)
src/Cms/Models/Post.php (9)
  • fromArray (488-585)
  • getId (57-60)
  • setCategories (376-380)
  • setTags (436-440)
  • toArray (592-609)
  • setId (65-69)
  • getCategories (366-369)
  • getTags (426-429)
  • incrementViewCount (321-325)
src/Cms/Models/Category.php (4)
  • fromArray (143-175)
  • getId (37-40)
  • toArray (182-192)
  • setId (45-49)
src/Cms/Models/Tag.php (4)
  • fromArray (125-156)
  • getId (36-39)
  • toArray (163-172)
  • setId (44-48)
src/Cms/Models/User.php (4)
  • fromArray (438-489)
  • getId (61-64)
  • toArray (413-433)
  • setId (69-73)
src/Cms/Repositories/IPostRepository.php (9)
  • findBySlug (22-22)
  • create (27-27)
  • count (104-104)
  • delete (37-37)
  • all (47-47)
  • getByAuthor (56-56)
  • getByCategory (65-65)
  • getByTag (74-74)
  • incrementViewCount (109-109)
src/Cms/Services/Media/CloudinaryUploader.php (3)
src/Cms/Controllers/Admin/Media.php (2)
  • Media (18-192)
  • __construct (26-39)
src/Cms/Services/Media/MediaValidator.php (1)
  • __construct (24-27)
src/Cms/Services/Media/IMediaUploader.php (3)
  • upload (22-22)
  • uploadFromUrl (32-32)
  • delete (41-41)
src/Cms/Repositories/DatabaseUserRepository.php (4)
src/Cms/Repositories/DatabaseCategoryRepository.php (4)
  • create (77-98)
  • delete (131-137)
  • all (142-145)
  • count (150-153)
src/Cms/Repositories/DatabasePostRepository.php (4)
  • create (115-144)
  • delete (212-218)
  • all (223-240)
  • count (338-348)
src/Cms/Repositories/DatabaseTagRepository.php (4)
  • create (61-82)
  • delete (115-121)
  • all (126-129)
  • count (134-137)
src/Cms/Models/User.php (3)
  • toArray (413-433)
  • setId (69-73)
  • getId (61-64)
src/Cms/Repositories/DatabaseCategoryRepository.php (2)
src/Cms/Repositories/DatabaseTagRepository.php (4)
  • create (61-82)
  • all (126-129)
  • count (134-137)
  • allWithPostCount (142-163)
src/Cms/Models/Category.php (3)
  • toArray (182-192)
  • setId (45-49)
  • getId (37-40)
src/Cms/Cli/Commands/Install/UpgradeCommand.php (1)
src/Cms/Cli/Commands/Install/InstallCommand.php (2)
  • __construct (25-32)
  • getName (37-40)
tests/Cms/Services/Media/MediaValidatorTest.php (1)
src/Cms/Services/Media/MediaValidator.php (4)
  • MediaValidator (14-192)
  • validate (35-73)
  • getErrors (178-181)
  • getFirstError (188-191)
src/Cms/Repositories/DatabaseTagRepository.php (1)
src/Cms/Models/Tag.php (3)
  • toArray (163-172)
  • setId (44-48)
  • getId (36-39)
resources/views/admin/posts/create.php (1)
src/Cms/View/helpers.php (1)
  • route_path (70-79)
🪛 actionlint (1.7.9)
.github/workflows/ci.yml

27-27: the runner of "codecov/codecov-action@v3" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🪛 LanguageTool
UPGRADE_NOTES.md

[style] ~44-~44: The double modal “Required Run” is nonstandard (only accepted in certain dialects). Consider “to be Run”.
Context: ... Changes - None ### Action Required 1. Run php neuron cms:upgrade to copy new mi...

(NEEDS_FIXED)

🪛 PHPMD (2.15.0)
src/Cms/Cli/Commands/Install/UpgradeCommand.php

61-61: Avoid unused parameters such as '$parameters'. (undefined)

(UnusedFormalParameter)


406-406: Avoid unused local variables such as '$installedMigrations'. (undefined)

(UnusedLocalVariable)

🔇 Additional comments (55)
src/Cms/Cli/Commands/Install/InstallCommand.php (2)

9-10: Namespace migration verified as complete.

No references to the old Neuron\Data\Setting\... namespace remain in the codebase. The migration to Neuron\Data\Settings\... is consistently applied across all importing files, including multiple repositories, services, and tests. The imports at lines 9-10 are correct and aligned with the rest of the codebase.


185-186: The upload directories are unused; files are uploaded to Cloudinary, not stored locally.

The /storage/uploads and /storage/uploads/temp directories are created during installation but are never used. The actual upload flow validates files via MediaValidator (which checks MIME type, extension, and image integrity) and then uploads directly to Cloudinary. No local file storage, caching, or temporary handling occurs.

The security concerns and cleanup mechanisms mentioned in the original review are not applicable since files don't reside on disk. If these directories are intended for future local storage fallback or caching, that capability should be implemented and explicitly documented.

Likely an incorrect or invalid review comment.

resources/config/neuron.yaml.example (1)

102-108: Migrations config: ensure paths/table name match the migration tool’s expectations

The migrations block is sensible and clearly documented. Correctness depends on how your migration tooling is wired.

  • Verify that path, seeds_path, and schema_file are resolved relative to project root in the same way the migration runner expects.
  • Confirm that table: phinx_log is consistent with how your migration library is configured elsewhere (many Phinx setups use phinxlog), or that you’ve intentionally standardized on this custom name.
  • Make sure auto_dump_schema is wired into the migration command so toggling it here has the intended effect.
readme.md (1)

2-2: LGTM!

The Codecov badge addition aligns well with the CI workflow changes that now upload coverage reports.

.github/workflows/ci.yml (1)

13-24: LGTM!

The PHP setup with pcov for coverage and the PHPUnit configuration are well-structured. Using --coverage-filter src ensures only source code is measured.

src/Cms/Repositories/DatabaseCategoryRepository.php (8)

37-40: LGTM!

Clean ORM usage for the findById method.


45-56: LGTM!

The findBySlug and findByName methods correctly use the ORM's fluent query builder pattern.


64-72: LGTM!

Good defensive check for empty array before querying, and proper use of whereIn for batch lookups.


103-126: LGTM!

The update method properly handles duplicate detection while excluding the current record, and uses the ORM's save() method correctly.


131-137: LGTM!

The delete method correctly uses the query builder and returns a meaningful boolean result.


142-153: LGTM!

The all() and count() methods are concise and leverage the ORM effectively.


158-179: LGTM!

The raw SQL approach for the JOIN + aggregation query is consistent with DatabaseTagRepository::allWithPostCount(). The TODO comment appropriately documents the ORM limitation.


77-98: The code is correct as-is. While toArray() does return dates as 'Y-m-d H:i:s' strings, the Category model's fromArray() method explicitly handles string-to-DateTimeImmutable conversion (lines 142-158), which the ORM's create() method uses internally. Tests confirm this works correctly without any changes needed.

src/Cms/Repositories/DatabasePageRepository.php (1)

32-36: LGTM on eager loading for author.

Using Page::with('author') for eager loading is the correct approach to avoid N+1 queries when accessing author data.

src/Cms/Repositories/DatabaseUserRepository.php (2)

133-136: ORM query pattern looks correct.

The User::orderBy('created_at', 'DESC')->all() pattern is consistent with other repositories and provides expected ordering.


83-88: No action needed; password hashing is handled correctly.

The password is already hashed in the service layer (via PasswordHasher) before being set on the User model through setPasswordHash(). The User model's setPasswordHash() method (line 120-124) simply stores the hash value without modification. When User::create($user->toArray()) is called, it persists the pre-computed hash as-is; there is no re-hashing mechanism in the model or ORM layer.

src/Cms/Repositories/DatabaseTagRepository.php (3)

142-163: Raw SQL for allWithPostCount() is appropriate.

Keeping raw SQL for the JOIN with aggregation is the right pragmatic choice. The TODO comment clearly indicates future work.


108-109: Ensure save() is called on an ORM-hydrated instance.

If $tag was instantiated via new Tag() rather than fetched from the database, save() may not work correctly depending on ORM implementation. Verify this path is only reached with ORM-loaded entities.


75-79: This pattern is standard and working across all models in the codebase.

The concern about passing null id in toArray() to create() is unfounded. The exact same pattern (Model::create($entity->toArray())) is used consistently across all repositories (User, Post, Page, Category, Tag) and is functioning properly. The Neuron ORM (v0.1.*) handles null values in mass assignment gracefully, treating null values as absent fields that don't override database defaults like auto-increment IDs. No special filtering is needed.

Likely an incorrect or invalid review comment.

src/Cms/Repositories/DatabasePostRepository.php (2)

163-196: Raw SQL UPDATE with ORM CREATE is intentional but worth documenting.

The comment on lines 163-164 explains why raw SQL is used for updates. This asymmetry (ORM for create, raw SQL for update) is acceptable given the ORM limitation, but consider adding a TODO to revisit when the ORM supports dirty tracking.


76-110: loadRelations helper is well-structured.

The method cleanly separates loading categories and tags via JOINs. Good use of array_map with arrow functions.

resources/database/migrate/20251205000000_add_two_factor_and_timezone_to_users.php (1)

26-43: The after column references are safe—remove unnecessary idempotency checks.

The columns two_factor_secret and last_login_at are created in the initial migration, so the after placement is valid. However, two_factor_recovery_codes and timezone are already defined in 20250111000000_create_users_table.php. The hasColumn() guards make this migration safe for existing installations, but the column definitions are redundant for new installs. Either consolidate these columns into the initial migration, or clarify in code comments why this migration exists as a separate patch.

resources/database/migrate/20250111000000_create_users_table.php (1)

25-25: LGTM! Verify recovery codes are encrypted at the application layer.

The two_factor_recovery_codes column is appropriately defined as nullable TEXT. Since recovery codes are sensitive authentication credentials, ensure they are encrypted before storage at the application layer.

tests/Cms/Repositories/DatabaseTagRepositoryTest.php (1)

8-8: LGTM! ORM initialization follows consistent test pattern.

The ORM initialization with Model::setPdo() in setUp is consistent with the broader migration to ORM-based data access across repository tests.

Also applies to: 33-34

tests/Cms/Repositories/DatabaseCategoryRepositoryTest.php (1)

8-8: LGTM! ORM initialization follows consistent test pattern.

The ORM initialization matches the pattern used in other repository tests, supporting the migration to ORM-based data access.

Also applies to: 33-34

tests/Cms/Repositories/DatabaseUserRepositoryTest.php (1)

8-9: LGTM! Test updates align with ORM migration and 2FA support.

The changes correctly reflect:

  • Namespace migration for SettingManager
  • ORM initialization consistent with other repository tests
  • Schema update for two_factor_recovery_codes matching the migration

Also applies to: 47-48, 66-66

src/Cms/Cli/Provider.php (1)

21-30: LGTM! Upgrade command registration follows CLI conventions.

The new cms:upgrade command is properly registered using the same pattern as existing commands. The comment update to plural form is appropriate.

src/Cms/Dtos/MediaUploadDto.yaml (1)

1-19: LGTM! Validation rules are appropriate for media uploads.

The DTO configuration correctly defines:

  • Required file field with NotEmpty validation
  • Optional folder field with reasonable 255-character limit
tests/Cms/BlogControllerTest.php (3)

13-16: LGTM! ORM initialization and Settings migration applied correctly.

The test setup now properly initializes the ORM and uses the migrated Settings namespace, consistent with other test files.

Also applies to: 54-55


100-120: LGTM! Users table creation supports blog author functionality.

The addition of the users table in test setup is appropriate if the Blog controller needs to access author information. The schema correctly includes the new 2FA fields.


129-129: LGTM! Editor.js default structure is correctly formatted.

The content_raw column default value '{"blocks":[]}' is a valid empty Editor.js document structure, supporting the block-based editor upgrade.

tests/Cms/Services/Media/CloudinaryUploaderTest.php (3)

17-29: LGTM! Test setup correctly initializes Cloudinary configuration.

The in-memory settings with test Cloudinary credentials are appropriate for unit testing the uploader service.


31-61: LGTM! Unit tests appropriately cover error paths.

The tests correctly validate exception handling for:

  • Missing configuration
  • Non-existent files
  • Invalid URLs

68-113: LGTM! Integration tests appropriately marked incomplete.

Marking integration tests as incomplete is correct since they require actual Cloudinary credentials and external resources. The commented examples provide clear guidance for future integration testing.

UPGRADE_NOTES.md (1)

1-151: Well-structured upgrade documentation.

The upgrade notes provide clear instructions for the upgrade workflow, version-specific changes, and troubleshooting guidance. The section headers and formatting are appropriate for developer documentation.

One consideration: ensure the version numbers (2025.12.5, 2025.11.7) align with actual release tags in the repository to avoid confusion.

tests/Cms/Repositories/DatabasePostRepositoryTest.php (2)

35-36: LGTM - ORM initialization in test setup.

Initializing the ORM with the test PDO connection ensures the repository tests can properly exercise ORM-backed data access.


56-85: Schema updates align with migration requirements.

The test schema correctly includes the new two_factor_recovery_codes, timezone columns for users, and content_raw for posts. The default '{"blocks":[]}' matches the Editor.js empty content structure used in the Post model.

resources/views/admin/posts/edit.php (2)

93-103: Good XSS prevention in content loading.

Using JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT flags ensures the JSON output is safe for embedding in a script context, preventing injection attacks via malicious post content.


199-211: Form submission pattern is correct.

Calling e.target.submit() after e.preventDefault() correctly bypasses the event listener and performs a native form submission, avoiding an infinite loop.

tests/Cms/Services/AuthenticationTest.php (2)

11-12: LGTM - Updated imports for ORM and Settings namespace.

The namespace migration from Neuron\Data\Setting to Neuron\Data\Settings and the addition of ORM initialization align with the broader refactoring in this PR.


50-51: ORM initialization added correctly.

Initializing the ORM with the test PDO connection before creating tables ensures proper ORM functionality during authentication tests.

resources/views/admin/posts/create.php (1)

75-82: CDN versions are now pinned - previous feedback addressed.

The script versions are pinned to specific versions (e.g., @2.29.1, @2.8.1), which addresses the previous review concern about using @latest.

src/Cms/Services/Media/IMediaUploader.php (1)

12-42: Well-designed media uploader interface.

The IMediaUploader interface provides a clean contract for media operations with appropriate PHPDoc documentation. The return array structure (url, public_id, width, height, format) is well-documented and aligns with the CloudinaryUploader implementation shown in the relevant snippets.

src/Cms/Models/Post.php (2)

21-22: Good design for dual content storage.

Maintaining both _body (plain text) and _contentRaw (Editor.js JSON) provides backward compatibility for code that expects plain text while supporting the new block-based editor format.


499-533: Content initialization in fromArray is well-structured.

The logic correctly handles multiple input formats (content_raw as string/array, content as string/array) and properly falls back to extracting plain text when body is not explicitly provided.

src/Cms/Services/Media/MediaValidator.php (1)

14-73: LGTM!

The overall validation flow is well-structured with proper early returns, error accumulation, and comprehensive checks including upload errors, file existence, size, and type validation. The use of is_uploaded_file() could provide additional security for production environments, but file_exists() is acceptable for general validation.

tests/Cms/Services/Media/MediaValidatorTest.php (2)

94-111: Test relies on $file['size'] from array, not actual file size.

The test creates a file with 'fake content' (12 bytes) but passes size => 10485760 in the array. This works because MediaValidator trusts $file['size'] from the $_FILES superglobal. This is correct behavior for testing the validator, but consider adding a comment clarifying that PHP populates $_FILES['size'] at upload time.


20-34: LGTM!

Good test setup with virtual filesystem and in-memory settings. The configuration matches the validator's expected defaults, enabling consistent test behavior.

src/Cms/Controllers/Admin/Media.php (1)

26-39: LGTM!

Good defensive check for Settings in the constructor with a clear exception message. The dependency injection via Registry is appropriate for this framework.

src/Cms/Services/Media/CloudinaryUploader.php (3)

39-57: LGTM!

Good validation of required Cloudinary configuration with a clear error message. The exception during construction ensures fast-fail behavior.


184-196: LGTM!

Clean normalization of Cloudinary response with sensible defaults for missing fields. Preferring secure_url over url is the correct approach.


67-88: LGTM!

Good file existence validation before upload attempt, and proper exception chaining preserves the original error context.

src/Cms/Cli/Commands/Install/UpgradeCommand.php (3)

61-61: Unused $parameters is acceptable for interface compliance.

The static analysis flags $parameters as unused, but this is a common pattern for CLI command implementations where the method signature is dictated by the parent class. No action needed.


21-28: LGTM!

Constructor follows the same pattern as InstallCommand, using getcwd() for project path and relative path traversal for component path.


284-290: LGTM!

Clean use of array_diff to identify new migrations by comparing package and installed lists.

Comment on lines +1 to +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
<?php

use Phinx\Migration\AbstractMigration;

/**
* Brief description of what this migration does
*/
class AddTimezoneToUsers extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* For more information see documentation:
* https://book.cakephp.org/phinx/0/en/migrations.html
*/
public function change()
{
$table = $this->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`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the referenced CLAUDE.md file exists
fd -t f 'CLAUDE.md' 2>/dev/null || echo "CLAUDE.md not found"

# Also check for any other file references in MIGRATIONS.md
echo "=== Checking referenced files in MIGRATIONS.md ==="
rg '(README\.md|/CLAUDE\.md)' MIGRATIONS.md

Repository: Neuron-PHP/cms

Length of output: 203


🏁 Script executed:

#!/bin/bash
# Verify README.md exists
fd -t f 'README.md' | head -5

Repository: Neuron-PHP/cms

Length of output: 87


Remove or update broken reference to non-existent CLAUDE.md file.

The documentation is comprehensive and well-structured, but the "Additional Resources" section references /CLAUDE.md which does not exist in the repository. Either remove this reference or verify it should point to an existing file.

🤖 Prompt for AI Agents
MIGRATIONS.md around lines 1 to 464: the "Additional Resources" section
references a non-existent /CLAUDE.md; remove that entry or replace it with the
correct, existing document path (e.g., CONTRIBUTING.md, README.md, or another
repository doc) after verifying via repo search, then update the Additional
Resources list and commit the change.

Comment on lines +254 to +265
# Media Upload Routes
admin_media_upload:
method: POST
route: /admin/upload/image
controller: Neuron\Cms\Controllers\Admin\Media@uploadImage
filter: auth

admin_media_featured_upload:
method: POST
route: /admin/upload/featured-image
controller: Neuron\Cms\Controllers\Admin\Media@uploadFeaturedImage
filter: auth
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, locate the Media controller
find . -type f -name "*.php" | grep -i media | head -20

Repository: Neuron-PHP/cms

Length of output: 335


🏁 Script executed:

# Search for the Media controller class definition
rg -l "class Media" --type=php

Repository: Neuron-PHP/cms

Length of output: 183


🏁 Script executed:

# Check for CSRF protection patterns in the codebase
rg -n "csrf|CSRF|VerifyCsrfToken|CsrfToken" --type=php --type=yaml | head -30

Repository: Neuron-PHP/cms

Length of output: 2706


🏁 Script executed:

# Look for middleware configuration and auth filter implementation
rg -n "filter.*auth|auth.*filter" resources/config/routes.yaml | head -20

Repository: Neuron-PHP/cms

Length of output: 467


🏁 Script executed:

# Read the Media controller to check for CSRF validation
wc -l ./src/Cms/Controllers/Admin/Media.php

Repository: Neuron-PHP/cms

Length of output: 99


🏁 Script executed:

# Get the Media controller content
cat -n ./src/Cms/Controllers/Admin/Media.php

Repository: Neuron-PHP/cms

Length of output: 5155


🏁 Script executed:

# Check for auth filter implementation
find . -type f -name "*.php" -path "*/filters/*" -o -name "*Filter*" -o -name "*Auth*" | grep -i filter | head -20

Repository: Neuron-PHP/cms

Length of output: 366


🏁 Script executed:

# Search for where CSRF is actually applied - middleware or filter
rg -l "VerifyCsrfToken|csrf.*middleware|middleware.*csrf" --type=php

Repository: Neuron-PHP/cms

Length of output: 40


🏁 Script executed:

# Read the AuthenticationFilter to see if it handles CSRF
cat -n ./src/Cms/Auth/Filters/AuthenticationFilter.php

Repository: Neuron-PHP/cms

Length of output: 2272


🏁 Script executed:

# Read the CsrfFilter to understand how CSRF is handled
cat -n ./src/Cms/Auth/Filters/CsrfFilter.php

Repository: Neuron-PHP/cms

Length of output: 2589


🏁 Script executed:

# Check the routes configuration to see which filters are applied globally or to upload routes
cat -n resources/config/routes.yaml | sed -n '240,280p'

Repository: Neuron-PHP/cms

Length of output: 1248


CSRF protection missing: Apply CsrfFilter to media upload endpoints.

The routes use filter: auth but should also include CSRF protection for POST endpoints. A CsrfFilter exists in the codebase that validates CSRF tokens on state-changing requests, but these upload routes do not apply it. Add csrf filter to both routes (e.g., filter: auth csrf or configure a composite filter) to protect against Cross-Site Request Forgery attacks on file uploads.

🤖 Prompt for AI Agents
In resources/config/routes.yaml around lines 254 to 265 the POST routes for
admin_media_upload and admin_media_featured_upload only apply the auth filter
but lack CSRF protection; update both route definitions to include the
CsrfFilter by adding the csrf filter alongside auth (e.g., "filter: auth csrf"
or equivalent composite filter) so POST upload endpoints validate CSRF tokens
before processing file uploads.

Comment on lines +171 to +178
$packageManifestJson = file_get_contents( $packageManifestPath );
$this->_packageManifest = json_decode( $packageManifestJson, true );

if( !$this->_packageManifest )
{
$this->output->error( "Failed to parse package manifest" );
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSON decode failure not properly detected.

json_decode returns null on parse error, but an empty JSON object {} or null literal also decodes to falsy values. Use json_last_error() for reliable error detection.

 		$packageManifestJson = file_get_contents( $packageManifestPath );
 		$this->_packageManifest = json_decode( $packageManifestJson, true );

-		if( !$this->_packageManifest )
+		if( json_last_error() !== JSON_ERROR_NONE )
 		{
-			$this->output->error( "Failed to parse package manifest" );
+			$this->output->error( "Failed to parse package manifest: " . json_last_error_msg() );
 			return false;
 		}

Apply similar fix at line 186 for installed manifest parsing.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$packageManifestJson = file_get_contents( $packageManifestPath );
$this->_packageManifest = json_decode( $packageManifestJson, true );
if( !$this->_packageManifest )
{
$this->output->error( "Failed to parse package manifest" );
return false;
}
$packageManifestJson = file_get_contents( $packageManifestPath );
$this->_packageManifest = json_decode( $packageManifestJson, true );
if( json_last_error() !== JSON_ERROR_NONE )
{
$this->output->error( "Failed to parse package manifest: " . json_last_error_msg() );
return false;
}
🤖 Prompt for AI Agents
In src/Cms/Cli/Commands/Install/UpgradeCommand.php around lines 171-178, the
code treats any falsy return from json_decode as a parse failure; instead, check
json_last_error() after json_decode to reliably detect parse errors and only
treat it as a failure when json_last_error() !== JSON_ERROR_NONE. Update the
error handling to call json_last_error_msg() (or include json_last_error()) in
the output error for better diagnostics. Also apply the same change at the
installed manifest parsing around line 186 so both manifest parses validate JSON
via json_last_error() rather than truthiness.

Comment on lines +401 to +422
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove unused $installedMigrations variable.

As flagged by static analysis, $installedMigrations is assigned but never used. The manifest is updated to use $packageMigrations directly.

 	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;
🧰 Tools
🪛 PHPMD (2.15.0)

406-406: Avoid unused local variables such as '$installedMigrations'. (undefined)

(UnusedLocalVariable)

🤖 Prompt for AI Agents
In src/Cms/Cli/Commands/Install/UpgradeCommand.php around lines 401 to 422, the
local variable $installedMigrations is assigned but never used; remove the
unused assignment and any related dead code so the function only gathers
$packageMigrations and writes the manifest, ensuring no other references to
$installedMigrations remain in this method.

Comment on lines +638 to +640
'list' => isset( $block['data']['items'] ) && is_array( $block['data']['items'] )
? implode( "\n", $block['data']['items'] )
: '',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Editor.js List plugin v1.9 nested items data structure content property

💡 Result:

Short answer: In Editor.js List (v1.9 / nested-list family) each list item is an object { content: string, meta: {...}, items: Item[] } — the content property is a plain string containing the item's text/HTML (not an array of blocks). Nested items are stored in the items array (recursive Item objects). Example:

{
"type": "list",
"data": {
"style": "unordered",
"items": [
{
"content": "Apples",
"meta": {},
"items": [
{ "content": "Red", "meta": {}, "items": [] }
]
}
]
}
}

Sources: Editor.js List README (item shape & examples). [1][2]

References
[1] editor-js/list (README) — GitHub.
[2] @editorjs/nested-list (README) — npm.


🏁 Script executed:

# Locate and examine the Post.php file
fd "Post.php" -t f

Repository: Neuron-PHP/cms

Length of output: 81


🏁 Script executed:

# Search for list handling in the codebase
rg "extractListItems|extractPlainText" -t php -B 3 -A 10

Repository: Neuron-PHP/cms

Length of output: 2868


🏁 Script executed:

# Check for any tests related to list extraction
rg "list.*items|nested.*list" -t php --max-count 20

Repository: Neuron-PHP/cms

Length of output: 167


🏁 Script executed:

# Get the full Post.php file with line numbers to see context around lines 638-640
wc -l src/Cms/Models/Post.php

Repository: Neuron-PHP/cms

Length of output: 85


🏁 Script executed:

# Read the extractPlainText method - estimate lines 600-700
sed -n '600,700p' src/Cms/Models/Post.php | cat -n

Repository: Neuron-PHP/cms

Length of output: 1995


List item extraction will produce "Array" strings for nested lists.

The Editor.js List plugin (v1.9+) stores list items as objects with content and items properties for nested structures, not simple strings. The current implode() call at lines 639-640 will convert nested item objects to the literal string "Array", losing the actual content.

Consider handling nested list structures:

'list' => isset( $block['data']['items'] ) && is_array( $block['data']['items'] )
-	? implode( "\n", $block['data']['items'] )
+	? $this->extractListItems( $block['data']['items'] )
 	: '',

With a helper method:

private function extractListItems( array $items ): string
{
    $texts = [];
    foreach( $items as $item )
    {
        if( is_string( $item ) )
        {
            $texts[] = $item;
        }
        elseif( is_array( $item ) && isset( $item['content'] ) )
        {
            $texts[] = $item['content'];
            if( isset( $item['items'] ) && is_array( $item['items'] ) )
            {
                $texts[] = $this->extractListItems( $item['items'] );
            }
        }
    }
    return implode( "\n", $texts );
}
🤖 Prompt for AI Agents
In src/Cms/Models/Post.php around lines 638 to 640, the list handling uses
implode() on $block['data']['items'] which will convert nested item arrays to
the string "Array"; replace this with a recursive extractor that walks items and
nested 'items' arrays, concatenates their 'content' or string values with "\n",
and return that string, then call that helper instead of implode(). Ensure the
helper accepts an array, checks for string items, array items with 'content' and
optional 'items', recurses for nested lists, and returns the joined string.

Comment on lines 169 to 180
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();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Race condition in incrementViewCount.

The fetch-increment-save pattern is not atomic. Under concurrent requests, view counts can be lost. Consider using a direct SQL UPDATE posts SET view_count = view_count + 1 WHERE id = ? for atomicity.

 public function incrementViewCount( int $id ): bool
 {
-	$page = Page::find( $id );
-
-	if( !$page )
-	{
-		return false;
-	}
-
-	$page->incrementViewCount();
-	return $page->save();
+	// Atomic increment to avoid race conditions
+	$affected = Page::query()
+		->where( 'id', $id )
+		->increment( 'view_count' );
+	return $affected > 0;
 }

If the ORM doesn't support increment(), use raw SQL as a fallback.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
}
public function incrementViewCount( int $id ): bool
{
// Atomic increment to avoid race conditions
$affected = Page::query()
->where( 'id', $id )
->increment( 'view_count' );
return $affected > 0;
}
🤖 Prompt for AI Agents
In src/Cms/Repositories/DatabasePageRepository.php around lines 169 to 180, the
current fetch-increment-save pattern causes a race condition and lost view
counts under concurrency; replace it with an atomic update by using the
model/query builder's increment method (e.g. Page::where('id',
$id)->increment('view_count')) or, if unavailable, execute a raw SQL update
("UPDATE pages SET view_count = view_count + 1 WHERE id = ?") and then check the
affected rows to return true/false; ensure you handle the case where no row
matches the id and return false in that case.

Comment on lines 353 to +364
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();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Race condition in incrementViewCount.

Same issue as in DatabasePageRepository: the fetch-increment-save pattern loses increments under concurrency. Use an atomic SQL update.

 public function incrementViewCount( int $id ): bool
 {
-	$post = Post::find( $id );
-
-	if( !$post )
-	{
-		return false;
-	}
-
-	$post->incrementViewCount();
-	return $post->save();
+	$stmt = $this->_pdo->prepare( "UPDATE posts SET view_count = view_count + 1 WHERE id = ?" );
+	$stmt->execute( [ $id ] );
+	return $stmt->rowCount() > 0;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
}
public function incrementViewCount( int $id ): bool
{
$stmt = $this->_pdo->prepare( "UPDATE posts SET view_count = view_count + 1 WHERE id = ?" );
$stmt->execute( [ $id ] );
return $stmt->rowCount() > 0;
}
🤖 Prompt for AI Agents
In src/Cms/Repositories/DatabasePostRepository.php around lines 353 to 364, the
current fetch-increment-save flow causes lost increments under concurrency;
replace it with an atomic SQL increment. Use the query builder/Eloquent's
incremental update (e.g., Post::where('id', $id)->increment('view_count')) so
the database performs the increment atomically, then return true if the affected
row count is > 0 (false otherwise); remove the find-and-save sequence to avoid
the race condition.

Comment on lines +369 to 406
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 ] );
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sync operations are not atomic.

syncCategories and syncTags perform DELETE followed by INSERTs without a transaction. If the process fails mid-way, you'll have a post with no categories/tags. Wrap in a transaction for consistency.

 private function syncCategories( int $postId, array $categoryIds ): void
 {
+	$this->_pdo->beginTransaction();
+	try
+	{
 		// 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 ] );
 			}
 		}
+		$this->_pdo->commit();
+	}
+	catch( \Exception $e )
+	{
+		$this->_pdo->rollBack();
+		throw $e;
+	}
 }

Apply the same pattern to syncTags.

Comment on lines +98 to +120
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 );
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential SSRF vulnerability in uploadFromUrl.

The FILTER_VALIDATE_URL check doesn't prevent SSRF attacks. An attacker could provide URLs to internal services (e.g., http://localhost:8080/admin, http://169.254.169.254/ for cloud metadata). Consider restricting to HTTPS only and/or validating against an allowlist of domains.

 	public function uploadFromUrl( string $url, array $options = [] ): array
 	{
 		// Validate URL
 		if( !filter_var( $url, FILTER_VALIDATE_URL ) )
 		{
 			throw new \Exception( "Invalid URL: {$url}" );
 		}

+		// Security: Only allow HTTPS URLs from external hosts
+		$parsedUrl = parse_url( $url );
+		$host = strtolower( $parsedUrl['host'] ?? '' );
+		
+		if( ( $parsedUrl['scheme'] ?? '' ) !== 'https' )
+		{
+			throw new \Exception( "Only HTTPS URLs are allowed" );
+		}
+		
+		// Block internal/private IP ranges
+		$ip = gethostbyname( $host );
+		if( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false )
+		{
+			throw new \Exception( "URL points to a private or reserved IP range" );
+		}
🤖 Prompt for AI Agents
In src/Cms/Services/Media/CloudinaryUploader.php around lines 98-120, the
uploadFromUrl method only uses FILTER_VALIDATE_URL which allows SSRF to internal
addresses; update validation to only accept HTTPS URLs and enforce a safe
destination by resolving the URL host to an IP and rejecting addresses in
private, loopback, link-local or cloud metadata ranges (e.g., 10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1, fc00::/7,
fe80::/10), or alternatively compare the host against a configured allowlist; if
the URL fails these checks throw an exception; keep existing option merging and
upload logic unchanged after validation.

@codecov
Copy link

codecov bot commented Dec 8, 2025

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)

26-33: Reconsider fail_ci_if_error: true for codecov upload.

The codecov-action has been updated to v5 (addressing the prior review concern), but the fail_ci_if_error setting differs from the previously suggested value. Setting this to true means your CI will fail if the codecov service experiences outages, network issues, or slowdowns—external factors outside your control.

Industry best practice is to set this to false to prevent codecov availability issues from blocking merges. Consider reverting unless you have a specific requirement for codecov success to be mandatory.

Apply this diff to align with best practices:

       - 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
+          fail_ci_if_error: false
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0e0d43e and ab623e7.

📒 Files selected for processing (2)
  • .github/workflows/ci.yml (1 hunks)
  • readme.md (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • readme.md
🔇 Additional comments (1)
.github/workflows/ci.yml (1)

13-24: Modern CI setup with solid coverage configuration.

The workflow updates to use shivammathur/setup-php@v2 with PHP 8.4 is a clean improvement over container-based execution. The Composer install flags (--optimize-autoloader, --no-interaction) and PHPUnit coverage command (with --coverage-clover and --coverage-filter src) are both well-configured and follow CI best practices.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants