From 3773f38f9c0d26328742100a5f09e4118260d2bc Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 20 Mar 2026 14:21:41 +0100 Subject: [PATCH] Make content title optional --- .../Admin/AdminContentController.php | 18 +- app/Http/Controllers/ContentController.php | 9 +- resources/views/admin/content/add.blade.php | 80 ++-- resources/views/admin/content/index.blade.php | 4 +- resources/views/content/index.blade.php | 4 +- tests/Feature/AdminContentControllerTest.php | 383 ++++++++++++++++++ 6 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 tests/Feature/AdminContentControllerTest.php diff --git a/app/Http/Controllers/Admin/AdminContentController.php b/app/Http/Controllers/Admin/AdminContentController.php index b11c34f6d..0d008cbc4 100644 --- a/app/Http/Controllers/Admin/AdminContentController.php +++ b/app/Http/Controllers/Admin/AdminContentController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; +use App\Http\Requests\Admin\AdminContentRequest; use App\Models\Content; use Illuminate\Foundation\Application; use Illuminate\Http\RedirectResponse; @@ -43,7 +44,7 @@ class AdminContentController extends BasePageController * * @throws \Exception */ - public function create(Request $request) + public function create(AdminContentRequest $request) { $this->setAdminPrefs(); $meta_title = 'Content Add'; @@ -73,15 +74,20 @@ class AdminContentController extends BasePageController break; case 'submit': - // Validate and add or update content + $validated = $request->validated(); + if ($request->missing('id') || empty($request->input('id'))) { - $returnid = $this->addContent($request->all()); + $returnid = $this->addContent($validated); + $message = 'Content created successfully'; } else { - $this->updateContent($request->all()); - $returnid = $request->input('id'); + $this->updateContent($validated); + $returnid = (int) $request->input('id'); + $message = 'Content updated successfully'; } - return redirect('admin/content-add?id='.$returnid); + return redirect() + ->route('admin.content-add', ['id' => $returnid]) + ->with('success', $message); case 'view': default: diff --git a/app/Http/Controllers/ContentController.php b/app/Http/Controllers/ContentController.php index 00ec9f470..839ac091a 100644 --- a/app/Http/Controllers/ContentController.php +++ b/app/Http/Controllers/ContentController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers; use App\Models\Content; use App\Models\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -60,7 +61,7 @@ class ContentController extends BasePageController $content = $this->getFrontPageContent()->all(); $index = $this->getIndexContent(); $isFront = true; - $meta_title = $index->title ?? 'Contents page'; + $meta_title = filled($index?->title) ? $index->title : 'Contents page'; $meta_keywords = $index->metakeywords ?? 'contents'; $meta_description = $index->metadescription ?? 'This is the contents page.'; } @@ -86,7 +87,7 @@ class ContentController extends BasePageController * * @return Collection */ - protected function getActiveContent(): \Illuminate\Database\Eloquent\Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType + protected function getActiveContent(): Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType { return Content::active() ->orderByRaw('contenttype, COALESCE(ordinal, 1000000)') @@ -98,7 +99,7 @@ class ContentController extends BasePageController * * @return Collection */ - protected function getAllButFront(): \Illuminate\Database\Eloquent\Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType + protected function getAllButFront(): Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType { return Content::query() ->where('id', '<>', 1) @@ -122,7 +123,7 @@ class ContentController extends BasePageController * * @return Collection */ - protected function getFrontPageContent(): \Illuminate\Database\Eloquent\Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType + protected function getFrontPageContent(): Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType { return Content::frontPage()->get(); } diff --git a/resources/views/admin/content/add.blade.php b/resources/views/admin/content/add.blade.php index b1cf7cadb..d90517396 100644 --- a/resources/views/admin/content/add.blade.php +++ b/resources/views/admin/content/add.blade.php @@ -1,6 +1,10 @@ @extends('layouts.admin') @section('content') +@php + $contentId = old('id', data_get($content, 'id', '')); + $isEditing = filled($contentId); +@endphp
@@ -11,25 +15,31 @@
-
+ @csrf - @if(!empty($content['id'])) - + @if($isEditing) + @endif
+ value="{{ old('title', data_get($content, 'title', '')) }}" + @if($isEditing) required @endif + class="w-full px-3 py-2 border {{ $errors->has('title') ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }} rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100"> +

+ {{ $isEditing ? 'Required when updating existing content.' : 'Leave blank to create content without a page title.' }} +

+ @error('title') +

{{ $message }}

+ @enderror
@@ -40,10 +50,13 @@ + class="w-full px-3 py-2 border {{ $errors->has('url') ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }} rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400">

Internal URL (e.g., /about) or external URL (e.g., https://example.com)

+ @error('url') +

{{ $message }}

+ @enderror
@@ -55,8 +68,11 @@ name="body" rows="15" data-tinymce-api-key="{{ config('tinymce.api_key', 'no-api-key') }}" - class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100">{{ is_array($content) ? trim(($content['body'] ?? ''), '\'"') : trim(($content->body ?? ''), '\'"') }} + class="w-full px-3 py-2 border {{ $errors->has('body') ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }} rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100">{{ old('body', is_string(data_get($content, 'body')) ? trim((string) data_get($content, 'body'), '\'"') : '') }}

Use the rich text editor to format your content

+ @error('body') +

{{ $message }}

+ @enderror
@@ -68,14 +84,17 @@ + @error('contenttype') +

{{ $message }}

+ @enderror
@@ -86,14 +105,17 @@ + @error('role') +

{{ $message }}

+ @enderror @@ -104,14 +126,17 @@ + @error('status') +

{{ $message }}

+ @enderror @@ -122,9 +147,12 @@ + value="{{ old('ordinal', data_get($content, 'ordinal', 0)) }}" + class="w-full px-3 py-2 border {{ $errors->has('ordinal') ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }} rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100">

Lower numbers appear first

+ @error('ordinal') +

{{ $message }}

+ @enderror @@ -136,8 +164,11 @@ + class="w-full px-3 py-2 border {{ $errors->has('metadescription') ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }} rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100">{{ old('metadescription', data_get($content, 'metadescription', '')) }}

SEO meta description

+ @error('metadescription') +

{{ $message }}

+ @enderror @@ -148,17 +179,20 @@ + value="{{ old('metakeywords', data_get($content, 'metakeywords', '')) }}" + class="w-full px-3 py-2 border {{ $errors->has('metakeywords') ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }} rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100">

Comma-separated keywords for SEO

+ @error('metakeywords') +

{{ $message }}

+ @enderror
- + Cancel
diff --git a/resources/views/admin/content/index.blade.php b/resources/views/admin/content/index.blade.php index cc044e066..3cc7932ad 100644 --- a/resources/views/admin/content/index.blade.php +++ b/resources/views/admin/content/index.blade.php @@ -28,7 +28,7 @@ {{ $item->id }} -
{{ $item->title }}
+
{{ filled($item->title) ? $item->title : 'Untitled' }}
@if(!empty($item->url)) @@ -96,7 +96,7 @@ diff --git a/resources/views/content/index.blade.php b/resources/views/content/index.blade.php index d682bd5bc..f3bb2223f 100644 --- a/resources/views/content/index.blade.php +++ b/resources/views/content/index.blade.php @@ -10,7 +10,7 @@ @foreach($content as $item)
- @if(isset($item->title)) + @if(filled($item->title))

{{ $item->title }}

@endif @@ -50,7 +50,7 @@

- {{ $item->title ?? 'Untitled' }} + {{ filled($item->title) ? $item->title : 'Untitled' }}

diff --git a/tests/Feature/AdminContentControllerTest.php b/tests/Feature/AdminContentControllerTest.php new file mode 100644 index 000000000..847abc04b --- /dev/null +++ b/tests/Feature/AdminContentControllerTest.php @@ -0,0 +1,383 @@ +databasePath = sys_get_temp_dir().'/nntmux-admin-content-test.sqlite'; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('categorizeforeign', '0'), + ('catwebdl', '0'), + ('title', 'NNTmux Test'), + ('home_link', '/')"); + + putenv('APP_ENV=testing'); + putenv('DB_CONNECTION=sqlite'); + putenv('DB_DATABASE='.$this->databasePath); + + $_ENV['APP_ENV'] = 'testing'; + $_ENV['DB_CONNECTION'] = 'sqlite'; + $_ENV['DB_DATABASE'] = $this->databasePath; + $_SERVER['APP_ENV'] = 'testing'; + $_SERVER['DB_CONNECTION'] = 'sqlite'; + $_SERVER['DB_DATABASE'] = $this->databasePath; + + $app = require __DIR__.'/../../bootstrap/app.php'; + + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $this->databasePath, + 'mail.from.address' => 'noreply@example.test', + 'mail.from.name' => 'NNTmux Tests', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + + $this->createSchema(); + $this->seedSettings(); + $this->seedCategories(); + $this->resetGlobalComposerState(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $this->withoutMiddleware(Google2FAMiddleware::class); + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + } + + public function test_admin_can_create_content_without_a_title(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $response = $this->actingAs($authenticatedAdmin)->post(route('admin.content-add'), [ + 'action' => 'submit', + 'title' => '', + 'url' => 'about', + 'body' => '

Welcome to the new content page.

', + 'metadescription' => 'About page', + 'metakeywords' => 'about,nntmux', + 'contenttype' => Content::TYPE_USEFUL, + 'status' => Content::STATUS_ENABLED, + 'ordinal' => 0, + 'role' => Content::ROLE_EVERYONE, + ]); + + $content = Content::query()->firstOrFail(); + + $response->assertRedirect(route('admin.content-add', ['id' => $content->id])); + $response->assertSessionHas('success', 'Content created successfully'); + $this->assertSame('', $content->title); + $this->assertSame('/about/', $content->url); + } + + public function test_create_form_marks_title_as_optional(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $response = $this->actingAs($authenticatedAdmin)->get(route('admin.content-add', ['action' => 'add'])); + + $response->assertOk(); + $response->assertSee('Leave blank to create content without a page title.'); + $response->assertDontSee('name="title" required', false); + } + + public function test_updating_existing_content_without_a_title_is_rejected(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $content = Content::query()->create([ + 'title' => 'Existing Content Title', + 'url' => '/existing/', + 'body' => '

Existing body

', + 'metadescription' => 'Existing description', + 'metakeywords' => 'existing', + 'contenttype' => Content::TYPE_USEFUL, + 'status' => Content::STATUS_ENABLED, + 'ordinal' => 1, + 'role' => Content::ROLE_EVERYONE, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->from(route('admin.content-add', ['id' => $content->id])) + ->actingAs($authenticatedAdmin) + ->post(route('admin.content-add'), [ + 'action' => 'submit', + 'id' => $content->id, + 'title' => '', + 'url' => 'existing', + 'body' => '

Updated body

', + 'metadescription' => 'Updated description', + 'metakeywords' => 'updated', + 'contenttype' => Content::TYPE_USEFUL, + 'status' => Content::STATUS_ENABLED, + 'ordinal' => 1, + 'role' => Content::ROLE_EVERYONE, + ]); + + $response->assertRedirect(route('admin.content-add', ['id' => $content->id])); + $response->assertSessionHasErrors('title'); + $this->assertSame('Existing Content Title', $content->fresh()->title); + } + + public function test_admin_content_list_uses_untitled_fallback_for_blank_titles(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + Content::query()->create([ + 'title' => '', + 'url' => '/untitled/', + 'body' => '

Untitled body

', + 'metadescription' => 'Untitled description', + 'metakeywords' => 'untitled', + 'contenttype' => Content::TYPE_USEFUL, + 'status' => Content::STATUS_ENABLED, + 'ordinal' => 2, + 'role' => Content::ROLE_EVERYONE, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->actingAs($authenticatedAdmin)->get(route('admin.content-list')); + + $response->assertOk(); + $response->assertSee('Untitled'); + } + + private function createSchema(): void + { + if (! Schema::hasTable('settings')) { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + } + + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->integer('rate_limit')->default(60); + $table->boolean('isdefault')->default(false); + $table->unsignedInteger('defaultinvites')->default(0); + $table->timestamps(); + }); + + Schema::create('permissions', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->integer('rate_limit')->default(60); + $table->string('api_token')->nullable(); + $table->boolean('verified')->default(true); + $table->boolean('can_post')->default(true); + $table->string('theme_preference', 10)->default('light'); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('lastlogin')->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->unsignedInteger('role_id'); + $table->primary(['permission_id', 'role_id']); + }); + + Schema::create('root_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->integer('status')->default(1); + $table->timestamps(); + }); + + Schema::create('categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->unsignedInteger('root_categories_id')->nullable(); + $table->text('description')->nullable(); + $table->integer('status')->default(1); + $table->timestamps(); + }); + + Schema::create('user_excluded_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('categories_id'); + }); + + Schema::create('content', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->string('url', 2000)->nullable(); + $table->text('body')->nullable(); + $table->string('metadescription', 1000)->default(''); + $table->string('metakeywords', 1000)->default(''); + $table->integer('contenttype')->default(Content::TYPE_USEFUL); + $table->integer('status')->default(Content::STATUS_ENABLED); + $table->integer('ordinal')->nullable(); + $table->integer('role')->default(Content::ROLE_EVERYONE); + $table->timestamps(); + }); + + Schema::create('user_activities', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('username'); + $table->string('activity_type', 50); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + private function seedSettings(): void + { + DB::table('settings')->upsert([ + ['name' => 'title', 'value' => 'NNTmux Test'], + ['name' => 'home_link', 'value' => '/'], + ['name' => 'categorizeforeign', 'value' => '0'], + ['name' => 'catwebdl', 'value' => '0'], + ], ['name'], ['value']); + } + + private function seedCategories(): void + { + DB::table('root_categories')->insert([ + 'id' => 1, + 'title' => 'General', + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('categories')->insert([ + 'id' => 1, + 'title' => 'General', + 'root_categories_id' => 1, + 'description' => 'General category', + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createUserWithRole(string $roleName): User + { + $role = Role::query()->firstOrCreate( + [ + 'name' => $roleName, + 'guard_name' => 'web', + ], + [ + 'rate_limit' => 60, + 'isdefault' => $roleName === 'User', + 'defaultinvites' => 1, + ] + ); + + /** @var User $user */ + $user = User::withoutEvents(fn () => User::query()->create([ + 'username' => strtolower($roleName).'_'.Str::random(8), + 'email' => Str::random(12).'@example.test', + 'password' => bcrypt('password'), + 'roles_id' => $role->id, + 'rate_limit' => 60, + 'api_token' => Str::random(32), + 'verified' => true, + 'email_verified_at' => now(), + 'lastlogin' => now(), + ])); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $user->assignRole($role); + + return $user->fresh(); + } + + private function resetGlobalComposerState(): void + { + $reflection = new ReflectionClass(GlobalDataComposer::class); + $property = $reflection->getProperty('resolvedData'); + $property->setValue(null, null); + } +}