Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ jobs:
- name: Run Composer audit
run: composer audit

mysql-search:
name: MySQL 8 message search
mysql-integration:
name: MySQL 8 integration
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest

Expand Down Expand Up @@ -120,5 +120,5 @@ jobs:
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress --no-interaction

- name: Run message search integration tests against MySQL
- name: Run database integration tests against MySQL
run: composer test:integration:mysql
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ See:
- `docs/user-profiles.md` (profile settings, avatars, themes, and WebSocket presence)
- `docs/channels.md` (public channels, private DMs, APIs, WebSocket routing, and migration operations)
- `docs/operations.md` (data retention, message archiving, batching, backups, and export storage)
- `docs/database-schema.md` (core relationships, named constraints, and cross-database migration behavior)

## Troubleshooting

Expand Down
23 changes: 18 additions & 5 deletions app/Commands/SeedMessages.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,26 @@ private function replaceBenchmarkData(BaseConnection $db, int $count): void
{
$db->transException(true)->transStart();

$this->seedUsers($db);
$userIds = $this->seedUsers($db);
$general = $db->table('channels')->select('id')->where('slug', 'general')->get()->getRowArray();
if (! is_array($general)) {
throw new \LogicException('The #general channel is missing. Run database migrations.');
}
$channelId = (int) $general['id'];

$messages = $db->table('messages');
$messages->like('user', 'k6-user-', 'after')->delete();
$messages->whereIn('user_id', array_values($userIds))->delete();

for ($offset = 0; $offset < $count; $offset += self::BATCH_SIZE) {
$batchSize = min(self::BATCH_SIZE, $count - $offset);
$rows = [];

for ($index = 0; $index < $batchSize; $index++) {
$sequence = $offset + $index + 1;
$username = self::usernameFor($sequence);
$rows[] = [
'user' => self::usernameFor($sequence),
'user_id' => $userIds[$username],
'channel_id' => $channelId,
'msg' => sprintf('Benchmark message %08d', $sequence),
'time' => 1_700_000_000 + $sequence,
];
Expand All @@ -104,11 +111,13 @@ private function replaceBenchmarkData(BaseConnection $db, int $count): void
$db->transComplete();
}

private function seedUsers(BaseConnection $db): void
/** @return array<string, int> */
private function seedUsers(BaseConnection $db): array
{
$users = $db->table('users');
$password = password_hash(self::BENCHMARK_PASSWORD, PASSWORD_DEFAULT);
$now = date('Y-m-d H:i:s');
$ids = [];

for ($sequence = 1; $sequence <= self::BENCHMARK_USER_COUNT; $sequence++) {
$username = self::usernameFor($sequence);
Expand All @@ -124,14 +133,18 @@ private function seedUsers(BaseConnection $db): void
'username' => $username,
'created_at' => $now,
]);
$ids[$username] = (int) $db->insertID();
} else {
$users->where('id', $existing['id'])->update($row);
$ids[$username] = (int) $existing['id'];
}
}

return $ids;
}

private static function usernameFor(int $sequence): string
{
return sprintf('k6-user-%03d', (($sequence - 1) % self::BENCHMARK_USER_COUNT) + 1);
return sprintf('k6_user_%03d', (($sequence - 1) % self::BENCHMARK_USER_COUNT) + 1);
}
}
7 changes: 3 additions & 4 deletions app/Config/Validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,11 @@ class Validation extends BaseConfig
*/
public array $registration = [
'username' => [
'rules' => 'required|min_length[3]|max_length[50]|alpha_numeric|is_unique[users.username]',
'rules' => 'required|max_length[30]|regex_match[/^[A-Za-z0-9_]{3,30}$/]|is_unique[users.username]',
'errors' => [
'required' => 'Username is required',
'min_length' => 'Username must be at least 3 characters long',
'max_length' => 'Username cannot exceed 50 characters',
'alpha_numeric' => 'Username can only contain alphanumeric characters',
'max_length' => 'Username cannot exceed 30 characters',
'regex_match' => 'Username must be 3-30 letters, numbers, or underscores',
'is_unique' => 'Username is already taken',
],
],
Expand Down
2 changes: 1 addition & 1 deletion app/Controllers/Api/V1/ChannelsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ public function searchMessages(int $channelId): ResponseInterface
}

$textInput = $this->optionalQueryString('text', 500);
$userInput = $this->optionalQueryString('user', 255);
$userInput = $this->optionalQueryString('user', 30);
$fromInput = $this->optionalTimestamp('from');
$toInput = $this->optionalTimestamp('to');
$page = $this->positiveIntegerQuery('page', 1, PHP_INT_MAX);
Expand Down
2 changes: 1 addition & 1 deletion app/Controllers/Api/V1/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public function listXml(): ResponseInterface
private function validatedSearchInput(): array
{
$text = $this->optionalString('text', 500);
$user = $this->optionalString('user', 255);
$user = $this->optionalString('user', 30);
$from = $this->optionalUnsignedInteger('from');
$to = $this->optionalUnsignedInteger('to');
$page = $this->positiveInteger('page', 1, PHP_INT_MAX);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\Migration;
use LogicException;

final class TightenUsersConstraints extends Migration
{
private const USERNAME_CHECK = 'chk_users_username_format';
private const PASSWORD_CHECK = 'chk_users_password_hash';

public function up(): void
{
$db = $this->database();

if ($db->getPlatform() === 'MySQLi') {
$users = $db->prefixTable('users');
$db->query(
"ALTER TABLE {$users}
ADD CONSTRAINT " . self::USERNAME_CHECK . " CHECK (
CHAR_LENGTH(username) BETWEEN 3 AND 30
AND username REGEXP '^[A-Za-z0-9_]+$'
),
ADD CONSTRAINT " . self::PASSWORD_CHECK . " CHECK (
password LIKE '\$2y\$%' OR password LIKE '\$argon2%'
)",
);

return;
}

$this->rebuildSqliteUsers(true);
}

public function down(): void
{
$db = $this->database();

if ($db->getPlatform() === 'MySQLi') {
$users = $db->prefixTable('users');
$db->query("ALTER TABLE {$users} DROP CHECK " . self::PASSWORD_CHECK);
$db->query("ALTER TABLE {$users} DROP CHECK " . self::USERNAME_CHECK);

return;
}

$this->rebuildSqliteUsers(false);
}

private function rebuildSqliteUsers(bool $withChecks): void
{
$db = $this->database();
$users = $db->prefixTable('users');
$replacement = $db->prefixTable('users_constraint_rebuild');
$usernameCheck = $withChecks
? ' CONSTRAINT ' . self::USERNAME_CHECK . " CHECK (
length(username) BETWEEN 3 AND 30
AND username NOT GLOB '*[^A-Za-z0-9_]*'
)"
: '';
$passwordCheck = $withChecks
? ' CONSTRAINT ' . self::PASSWORD_CHECK . " CHECK (
password LIKE '\$2y\$%' OR password LIKE '\$argon2%'
)"
: '';

$db->disableForeignKeyChecks();

try {
$db->query("DROP TABLE IF EXISTS {$replacement}");
$db->query(
"CREATE TABLE {$replacement} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) NOT NULL UNIQUE{$usernameCheck},
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL{$passwordCheck},
created_at DATETIME NULL,
updated_at DATETIME NULL,
display_name VARCHAR(100) NULL,
avatar_path VARCHAR(255) NULL,
theme VARCHAR(16) NOT NULL DEFAULT 'system',
notification_prefs TEXT NULL,
presence VARCHAR(16) NOT NULL DEFAULT 'offline',
last_seen_at DATETIME NULL
)",
);
$db->query(
"INSERT INTO {$replacement} (
id, username, email, password, created_at, updated_at,
display_name, avatar_path, theme, notification_prefs,
presence, last_seen_at
)
SELECT
id, username, email, password, created_at, updated_at,
display_name, avatar_path, theme, notification_prefs,
presence, last_seen_at
FROM {$users}",
);
$db->query("DROP TABLE {$users}");
$db->query("ALTER TABLE {$replacement} RENAME TO {$users}");
} finally {
$db->enableForeignKeyChecks();
}

$violations = $db->query('PRAGMA foreign_key_check')->getResultArray();
if ($violations !== []) {
throw new LogicException('User constraint migration left invalid foreign keys.');
}
}

private function database(): BaseConnection
{
if (! $this->db instanceof BaseConnection) {
throw new LogicException('User constraint migrations require a database connection.');
}

return $this->db;
}
}
Loading
Loading