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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ Start your web server and navigate to:
See:

- `docs/public-api.md` (HTTP + WebSocket + frontend entrypoints + reusable PHP APIs)
- `docs/api.md` (message reaction HTTP and WebSocket contracts)
- `docs/user-profiles.md` (profile settings, avatars, themes, and WebSocket presence)

## Troubleshooting
Expand Down
2 changes: 1 addition & 1 deletion app/Config/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class App extends BaseConfig
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
*/
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
public string $permittedURIChars = 'a-z 0-9~%.:_\-\p{S}\p{M}\x{200D}';

/**
* --------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions app/Config/RateLimit.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class RateLimit extends BaseConfig
'anonymous' => [10, 60],
'authenticated' => [30, 60],
],
'react' => [
'anonymous' => [10, 60],
'authenticated' => [120, 60],
],
];

public function __construct()
Expand Down
3 changes: 3 additions & 0 deletions app/Config/Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
$routes->get('messages', 'MessagesController::list', ['filter' => ['apiFormat', 'auth']]);
$routes->get('messages/search', 'MessagesController::search', ['filter' => ['apiFormat', 'auth']]);
$routes->post('messages', 'MessagesController::create', ['filter' => ['apiFormat', 'auth', 'rate:write', 'validate:message']]);
$routes->get('messages/(:num)/reactions', 'ReactionsController::list/$1', ['filter' => ['apiFormat', 'auth']]);
$routes->post('messages/(:num)/reactions', 'ReactionsController::create/$1', ['filter' => ['apiFormat', 'auth', 'rate:react']]);
$routes->delete('messages/(:num)/reactions/(:segment)', 'ReactionsController::delete/$1/$2', ['filter' => ['apiFormat', 'auth', 'rate:react']]);
$routes->get('messages/xml', 'MessagesController::listXml', ['filter' => 'auth']);
$routes->post('push-subscriptions', 'PushSubscriptionsController::create', ['filter' => ['apiFormat', 'auth', 'rate:write']]);
$routes->delete('push-subscriptions', 'PushSubscriptionsController::delete', ['filter' => ['apiFormat', 'auth', 'rate:write']]);
Expand Down
13 changes: 13 additions & 0 deletions app/Config/Services.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
use App\Contracts\ChatRepository;
use App\Contracts\CspReportRepository;
use App\Contracts\PushSubscriptionRepository;
use App\Contracts\ReactionRepository;
use App\Contracts\UserRepository;
use App\Controllers\Api\V1\MessagesController;
use App\Controllers\Api\V1\PushSubscriptionsController;
use App\Controllers\Api\V1\ReactionsController;
use App\Controllers\AuditLog;
use App\Controllers\Auth;
use App\Controllers\Chat;
Expand All @@ -22,6 +24,7 @@
use App\Models\AuditLogModel;
use App\Models\ChatModel;
use App\Models\CspReportModel;
use App\Models\MessageReactionModel;
use App\Models\PushSubscriptionModel;
use App\Models\UserModel;
use App\Services\AuditLogger;
Expand Down Expand Up @@ -69,6 +72,7 @@ public static function controller(string $controllerClass): Controller
Chat::class => new Chat(static::chatRepository(), static::chatFormatter()),
MessagesController::class => new MessagesController(static::chatRepository(), static::chatFormatter()),
PushSubscriptionsController::class => new PushSubscriptionsController(static::pushSubscriptionRepository()),
ReactionsController::class => new ReactionsController(static::reactionRepository()),
Auth::class => new Auth(static::userRepository(), static::auditLogger()),
Profile::class => new Profile(static::userRepository()),
CspReport::class => new CspReport(static::cspReportRepository()),
Expand Down Expand Up @@ -122,6 +126,15 @@ public static function pushSubscriptionRepository(bool $getShared = true): PushS
return new PushSubscriptionModel();
}

public static function reactionRepository(bool $getShared = true): ReactionRepository
{
if ($getShared) {
return static::getSharedInstance('reactionRepository');
}

return new MessageReactionModel();
}

public static function auditLogRepository(bool $getShared = true): AuditLogRepository
{
if ($getShared) {
Expand Down
15 changes: 15 additions & 0 deletions app/Contracts/ReactionRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Contracts;

interface ReactionRepository
{
public function messageExists(int $messageId): bool;

public function add(int $messageId, int $userId, string $emoji): bool;

public function remove(int $messageId, int $userId, string $emoji): bool;

/** @return list<array{emoji: string, count: int, users: list<string>, reacted_by_current_user: bool}> */
public function forMessage(int $messageId, ?int $currentUserId = null): array;
}
126 changes: 126 additions & 0 deletions app/Controllers/Api/V1/ReactionsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace App\Controllers\Api\V1;

use App\Contracts\ReactionRepository;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;

final class ReactionsController extends BaseController
{
public function __construct(private readonly ReactionRepository $reactions)
{
}

public function list(int $messageId): ResponseInterface
{
if (! $this->reactions->messageExists($messageId)) {
return $this->notFound();
}

return $this->reactionJson($this->reactions->forMessage($messageId, $this->currentUserId()));
}

public function create(int $messageId): ResponseInterface
{
if (! $this->reactions->messageExists($messageId)) {
return $this->notFound();
}

$payload = $this->request->getJSON(true);
$emoji = is_array($payload) ? $this->validEmoji($payload['emoji'] ?? null) : null;
if ($emoji === null) {
return $this->validationError();
}

if (! $this->reactions->add($messageId, $this->currentUserId(), $emoji)) {
return $this->reactionJson([
'error' => ['type' => 'storage', 'message' => 'The reaction could not be saved.'],
], 500);
}

return $this->reactionJson($this->state($messageId, $emoji), 201)
->setHeader('X-CSRF-TOKEN', csrf_hash());
}

public function delete(int $messageId, string $emoji): ResponseInterface
{
if (! $this->reactions->messageExists($messageId)) {
return $this->notFound();
}

$emoji = $this->validEmoji(rawurldecode($emoji));
if ($emoji === null) {
return $this->validationError();
}

$this->reactions->remove($messageId, $this->currentUserId(), $emoji);

return $this->response
->setStatusCode(204)
->setHeader('Cache-Control', 'no-store')
->setHeader('X-CSRF-TOKEN', csrf_hash());
}

/** @return array{message_id: int, reaction: array{emoji: string, count: int, users: list<string>, reacted_by_current_user: bool}} */
private function state(int $messageId, string $emoji): array
{
foreach ($this->reactions->forMessage($messageId, $this->currentUserId()) as $reaction) {
if ($reaction['emoji'] === $emoji) {
return ['message_id' => $messageId, 'reaction' => $reaction];
}
}

return [
'message_id' => $messageId,
'reaction' => ['emoji' => $emoji, 'count' => 0, 'users' => [], 'reacted_by_current_user' => false],
];
}

private function validEmoji(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}

$value = trim($value);
if (
$value === ''
|| mb_strlen($value) > 10
|| strlen($value) > 64
|| preg_match('/[\p{C}\p{Z}]/u', $value) === 1
|| preg_match('/\p{Extended_Pictographic}/u', $value) !== 1
) {
return null;
}

return $value;
}

private function currentUserId(): int
{
return (int) $this->session->get('user_id');
}

private function notFound(): ResponseInterface
{
return $this->reactionJson([
'error' => ['type' => 'not_found', 'message' => 'Message not found.'],
], 404);
}

private function validationError(): ResponseInterface
{
return $this->reactionJson([
'error' => ['type' => 'validation', 'message' => 'emoji must be a valid emoji of at most 10 characters.'],
], 422);
}

private function reactionJson(mixed $data, int $status = 200): ResponseInterface
{
$response = $this->respondWithJson($data, $status);
$response->removeHeader('Cache-Control');

return $response->setHeader('Cache-Control', 'no-store');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace App\Database\Migrations;

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

final class CreateMessageReactionsTable extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'message_id' => ['type' => 'INT', 'constraint' => 7],
'user_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'emoji' => ['type' => 'VARCHAR', 'constraint' => 64],
'created_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('message_id');
$this->forge->addKey('user_id');
$this->forge->addUniqueKey(['message_id', 'user_id', 'emoji'], 'uq_message_user_emoji');
$this->forge->addForeignKey('message_id', 'messages', 'id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('message_reactions');

if ($this->db->getPlatform() === 'MySQLi') {
$table = $this->database()->prefixTable('message_reactions');
$this->database()->query("ALTER TABLE {$table} MODIFY emoji VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL");
}
}

public function down(): void
{
$this->forge->dropTable('message_reactions');
}

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

return $this->db;
}
}
Loading
Loading