- @foreach ($params as $alias => $body)
+ @forelse ($params as $alias => $body)
+ @php
+ $fieldValue = (string) $body;
+ $shouldUseTextarea = strlen($fieldValue) > 50 || str_contains($fieldValue, "\n") || str_contains($fieldValue, '<');
+ @endphp
- @if (strlen($body) > 50 || str_contains($body, "\n") || str_contains($body, '<'))
+ @if ($shouldUseTextarea)
+ class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border whitespace-pre-wrap">{{ $fieldValue }}
@else
-
@endif
- @endforeach
+ @empty
+
+ 当前没有可在通用系统页维护的公共参数,请前往对应专属配置页处理敏感模块参数。
+
+ @endforelse
diff --git a/resources/views/chat/partials/layout/mobile-drawer.blade.php b/resources/views/chat/partials/layout/mobile-drawer.blade.php
index 3e41358..d6756fc 100644
--- a/resources/views/chat/partials/layout/mobile-drawer.blade.php
+++ b/resources/views/chat/partials/layout/mobile-drawer.blade.php
@@ -108,6 +108,18 @@
{{-- ── 手机端抽屉控制脚本 ── --}}
diff --git a/tests/Feature/Feature/AdminChangelogControllerSecurityTest.php b/tests/Feature/Feature/AdminChangelogControllerSecurityTest.php
new file mode 100644
index 0000000..3d836a2
--- /dev/null
+++ b/tests/Feature/Feature/AdminChangelogControllerSecurityTest.php
@@ -0,0 +1,82 @@
+create([
+ 'id' => 1,
+ 'user_level' => 100,
+ ]);
+
+ $dangerousTitle = '

';
+ $dangerousVersion = '2026-04-" onclick="alert(2)';
+
+ $response = $this->actingAs($admin)->post(route('admin.changelogs.store'), [
+ 'version' => $dangerousVersion,
+ 'title' => $dangerousTitle,
+ 'type' => 'fix',
+ 'content' => '修复说明',
+ 'is_published' => '1',
+ 'notify_chat' => '1',
+ ]);
+
+ $response->assertRedirect(route('admin.changelogs.index'));
+ $this->assertDatabaseHas('dev_changelogs', [
+ 'title' => $dangerousTitle,
+ 'version' => $dangerousVersion,
+ 'is_published' => true,
+ ]);
+
+ Event::assertDispatched(ChangelogPublished::class, function (ChangelogPublished $event) use ($dangerousTitle, $dangerousVersion) {
+ $payload = $event->broadcastWith();
+
+ $this->assertSame($dangerousTitle, $payload['title']);
+ $this->assertSame(e($dangerousTitle), $payload['safe_title']);
+ $this->assertSame(e($dangerousVersion), $payload['safe_version']);
+ $this->assertStringContainsString(rawurlencode($dangerousVersion), $payload['url']);
+ $this->assertStringNotContainsString('" onclick="', $payload['url']);
+
+ return true;
+ });
+
+ Queue::assertPushed(SaveMessageJob::class, function (SaveMessageJob $job) use ($dangerousTitle, $dangerousVersion) {
+ $content = $job->messageData['content'] ?? '';
+
+ $this->assertStringContainsString(e($dangerousTitle), $content);
+ $this->assertStringContainsString(e($dangerousVersion), $content);
+ $this->assertStringContainsString('rel="noopener"', $content);
+ $this->assertStringContainsString(rawurlencode($dangerousVersion), $content);
+ $this->assertStringNotContainsString($dangerousTitle, $content);
+
+ return true;
+ });
+ }
+}
diff --git a/tests/Feature/Feature/AdminSystemControllerTest.php b/tests/Feature/Feature/AdminSystemControllerTest.php
new file mode 100644
index 0000000..7d7b970
--- /dev/null
+++ b/tests/Feature/Feature/AdminSystemControllerTest.php
@@ -0,0 +1,137 @@
+seedSystemParams();
+ $admin = $this->createSuperAdmin();
+
+ $response = $this->actingAs($admin)->get(route('admin.system.edit'));
+
+ $response->assertOk();
+ $response->assertSee('sys_name');
+ $response->assertSee('sys_notice');
+ $response->assertDontSee('smtp_host');
+ $response->assertDontSee('vip_payment_app_secret');
+ $response->assertDontSee('wechat_bot_config');
+ $response->assertDontSee('chatbot_max_gold');
+ }
+
+ /**
+ * 验证通用系统参数页更新时只会持久化白名单字段。
+ */
+ public function test_system_page_update_only_persists_whitelisted_configs(): void
+ {
+ $this->seedSystemParams();
+ $admin = $this->createSuperAdmin();
+
+ $response = $this->actingAs($admin)->put(route('admin.system.update'), [
+ 'sys_name' => '新版聊天室',
+ 'sys_notice' => '新的公共公告',
+ 'smtp_host' => 'attacker.smtp.example',
+ 'vip_payment_app_secret' => 'tampered-secret',
+ 'wechat_bot_config' => '{"api":{"bot_key":"stolen"}}',
+ 'chatbot_max_gold' => '999999',
+ 'rogue_secret_token' => 'hacked',
+ ]);
+
+ $response->assertRedirect(route('admin.system.edit'));
+ $response->assertSessionHas('success');
+
+ $this->assertDatabaseHas('sysparam', [
+ 'alias' => 'sys_name',
+ 'body' => '新版聊天室',
+ ]);
+ $this->assertDatabaseHas('sysparam', [
+ 'alias' => 'sys_notice',
+ 'body' => '新的公共公告',
+ ]);
+
+ // 敏感配置必须保持原值,不能被通用系统页伪造请求覆盖。
+ $this->assertDatabaseHas('sysparam', [
+ 'alias' => 'smtp_host',
+ 'body' => 'owner.smtp.example',
+ ]);
+ $this->assertDatabaseHas('sysparam', [
+ 'alias' => 'vip_payment_app_secret',
+ 'body' => 'owner-secret',
+ ]);
+ $this->assertDatabaseHas('sysparam', [
+ 'alias' => 'wechat_bot_config',
+ 'body' => '{"api":{"bot_key":"owner-only"}}',
+ ]);
+ $this->assertDatabaseHas('sysparam', [
+ 'alias' => 'chatbot_max_gold',
+ 'body' => '5000',
+ ]);
+ $this->assertDatabaseMissing('sysparam', [
+ 'alias' => 'rogue_secret_token',
+ ]);
+ }
+
+ /**
+ * 创建可访问后台通用系统页的超级管理员账号。
+ */
+ private function createSuperAdmin(): User
+ {
+ return User::factory()->create([
+ 'user_level' => 100,
+ ]);
+ }
+
+ /**
+ * 预置通用系统页测试所需的公共参数与敏感参数。
+ */
+ private function seedSystemParams(): void
+ {
+ foreach ($this->systemParams() as $alias => $body) {
+ Sysparam::updateOrCreate(
+ ['alias' => $alias],
+ [
+ 'body' => $body,
+ 'guidetxt' => strtoupper($alias).' 配置说明',
+ ]
+ );
+ }
+ }
+
+ /**
+ * 返回本轮测试覆盖的系统参数样本。
+ *
+ * @return array
+ */
+ private function systemParams(): array
+ {
+ return [
+ 'sys_name' => '原始聊天室',
+ 'sys_notice' => '原始公告',
+ 'smtp_host' => 'owner.smtp.example',
+ 'vip_payment_app_secret' => 'owner-secret',
+ 'wechat_bot_config' => '{"api":{"bot_key":"owner-only"}}',
+ 'chatbot_max_gold' => '5000',
+ ];
+ }
+}
diff --git a/tests/Feature/RoomRequestSecurityTest.php b/tests/Feature/RoomRequestSecurityTest.php
new file mode 100644
index 0000000..14144f3
--- /dev/null
+++ b/tests/Feature/RoomRequestSecurityTest.php
@@ -0,0 +1,64 @@
+create(['user_level' => 10]);
+
+ $response = $this->actingAs($user)->post(route('rooms.store'), [
+ 'name' => '
',
+ 'description' => '危险名称测试',
+ ]);
+
+ $response->assertSessionHasErrors('name');
+ $this->assertDatabaseMissing('rooms', [
+ 'room_name' => '
',
+ ]);
+ }
+
+ /**
+ * 测试修改房间时同样不能把危险名称写入数据库。
+ */
+ public function test_cannot_update_room_with_html_like_name(): void
+ {
+ $owner = User::factory()->create();
+ $room = Room::create([
+ 'room_name' => '安全房间',
+ 'room_owner' => $owner->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($owner)->from(route('rooms.index'))->put(route('rooms.update', $room->id), [
+ 'name' => '