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
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null

BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=pusher
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=ap2

VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

FILESYSTEM_DISK=s3
QUEUE_CONNECTION=database

Expand Down
41 changes: 41 additions & 0 deletions app/Events/ChatMessageDeleted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ChatMessageDeleted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;

public int $messageId;

/**
* Create a new event instance.
*/
public function __construct(int $messageId)
{
$this->messageId = $messageId;
}

/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('global-chat'),
];
}

public function broadcastAs(): string
{
return 'message.deleted';
}
}
57 changes: 57 additions & 0 deletions app/Events/ChatMessageSent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Events;

use App\Models\ChatMessage;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ChatMessageSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;

public array $message;

/**
* Create a new event instance.
*/
public function __construct(ChatMessage $chatMessage)
{
$chatMessage->loadMissing(['user:id,name,username,image_path,institution', 'user.roles:id,name']);

$this->message = [
'id' => $chatMessage->id,
'content' => $chatMessage->content,
'created_at' => $chatMessage->created_at->toIso8601String(),
'user' => [
'id' => $chatMessage->user->id,
'name' => $chatMessage->user->name,
'username' => $chatMessage->user->username,
'image_url' => $chatMessage->user->image_url,
'institution' => $chatMessage->user->institution,
'is_verified' => $chatMessage->user->is_verified,
'roles' => $chatMessage->user->roles->pluck('name')->toArray(),
],
];
}

/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('global-chat'),
];
}

public function broadcastAs(): string
{
return 'message.sent';
}
}
47 changes: 47 additions & 0 deletions app/Http/Controllers/Admin/ChatSettingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Models\ChatMessage;
use Illuminate\Http\Request;
use Inertia\Inertia;

class ChatSettingsController extends Controller
{
public function edit()
{
return Inertia::render('admin/ChatSettings', [
'settings' => [
'enabled' => (bool) AppSetting::get('global_chat_enabled', true),
'audience' => AppSetting::get('global_chat_audience', 'verified_members'),
'cooldown_seconds' => (int) AppSetting::get('global_chat_cooldown_seconds', 30),
],
'totalMessages' => ChatMessage::count(),
'recentMessagesCount' => ChatMessage::where('created_at', '>=', now()->subHours(24))->count(),
]);
}

public function update(Request $request)
{
$validated = $request->validate([
'enabled' => ['required', 'boolean'],
'audience' => ['required', 'string', 'in:verified_members,all,disabled'],
'cooldown_seconds' => ['required', 'integer', 'min:0', 'max:3600'],
]);

AppSetting::set('global_chat_enabled', $validated['enabled'], 'boolean');
AppSetting::set('global_chat_audience', $validated['audience'], 'string');
AppSetting::set('global_chat_cooldown_seconds', $validated['cooldown_seconds'], 'integer');

return back()->with('success', 'Global chat settings updated successfully.');
}

public function clearMessages()
{
ChatMessage::truncate();

return back()->with('success', 'All chat messages have been cleared.');
}
}
8 changes: 7 additions & 1 deletion app/Http/Controllers/Admin/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
Expand All @@ -13,7 +14,12 @@ class DashboardController extends Controller
{
public function index()
{
return Inertia::render('admin/Dashboard');
return Inertia::render('admin/Dashboard', [
'chatSettings' => [
'enabled' => AppSetting::get('global_chat_enabled', true),
'audience' => AppSetting::get('global_chat_audience', 'verified_members'),
],
]);
}

public function analytics(Request $request)
Expand Down
188 changes: 188 additions & 0 deletions app/Http/Controllers/ChatController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

namespace App\Http\Controllers;

use App\Events\ChatMessageDeleted;
use App\Events\ChatMessageSent;
use App\Models\AppSetting;
use App\Models\ChatMessage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Inertia\Inertia;

class ChatController extends Controller
{
public function index(Request $request)
{
$user = $request->user();

// Get Chat Config Status
$isEnabled = AppSetting::get('global_chat_enabled', true);
$audience = AppSetting::get('global_chat_audience', 'verified_members'); // 'verified_members', 'all', 'disabled'
$cooldownSeconds = (int) AppSetting::get('global_chat_cooldown_seconds', 30);

// Determine if user can post
$canPost = false;
$reason = null;

if (! $isEnabled || $audience === 'disabled') {
$reason = 'Global chat is currently disabled for maintenance.';
} elseif (! $user) {
$reason = 'Please sign in to join the conversation.';
} elseif ($audience === 'verified_members') {
if ($user->is_verified || $user->can('view admin')) {
$canPost = true;
} else {
$reason = 'Global chat is currently in beta for verified members and contributors.';
}
} elseif ($audience === 'all') {
$canPost = true;
}

// Fetch last 200 messages in chronological order
$messages = ChatMessage::with(['user:id,name,username,image_path,institution', 'user.roles:id,name'])
->latest('id')
->take(200)
->get()
->reverse()
->values()
->map(fn (ChatMessage $msg) => [
'id' => $msg->id,
'content' => $msg->content,
'created_at' => $msg->created_at->toIso8601String(),
'user' => [
'id' => $msg->user->id,
'name' => $msg->user->name,
'username' => $msg->user->username,
'image_url' => $msg->user->image_url,
'institution' => $msg->user->institution,
'is_verified' => $msg->user->is_verified,
'roles' => $msg->user->roles->pluck('name')->toArray(),
],
]);

if (! $request->wantsJson() && ! $request->is('api/*')) {
return Inertia::render('Chat/Index', [
'chatState' => [
'enabled' => (bool) $isEnabled,
'audience' => $audience,
'cooldown_seconds' => $cooldownSeconds,
'can_post' => $canPost,
'reason' => $reason,
'can_delete' => (bool) $user?->can('manage chat'),
'messages' => $messages,
'pusher_key' => config('broadcasting.connections.pusher.key'),
'pusher_cluster' => config('broadcasting.connections.pusher.options.cluster', 'ap2'),
],
]);
}

return response()->json([
'enabled' => (bool) $isEnabled,
'audience' => $audience,
'cooldown_seconds' => $cooldownSeconds,
'can_post' => $canPost,
'reason' => $reason,
'can_delete' => (bool) $user?->can('manage chat'),
'messages' => $messages,
'pusher_key' => config('broadcasting.connections.pusher.key'),
'pusher_cluster' => config('broadcasting.connections.pusher.options.cluster', 'ap2'),
]);
}

public function store(Request $request)
{
$user = $request->user();
abort_unless($user, 401, 'Unauthenticated');

$isEnabled = AppSetting::get('global_chat_enabled', true);
$audience = AppSetting::get('global_chat_audience', 'verified_members');
$cooldownSeconds = (int) AppSetting::get('global_chat_cooldown_seconds', 30);

// Check if chat is enabled
if (! $isEnabled || $audience === 'disabled') {
return response()->json(['message' => 'Global chat is currently disabled.'], 403);
}

// Check audience permission
if ($audience === 'verified_members' && ! $user->is_verified && ! $user->can('view admin')) {
return response()->json(['message' => 'Global chat is currently restricted to verified members.'], 403);
}

// Configurable rate limiter per user (bypass for admins/staff)
$rateLimitKey = "chat-send:{$user->id}";
if (! $user->can('view admin') && $cooldownSeconds > 0 && RateLimiter::tooManyAttempts($rateLimitKey, 1)) {
$seconds = RateLimiter::availableIn($rateLimitKey);

return response()->json([
'message' => "Please wait {$seconds} seconds before sending another message.",
'retry_after' => $seconds,
], 429);
}

$validated = $request->validate([
'content' => ['required', 'string', 'max:280'],
]);

$content = trim($validated['content']);

$message = ChatMessage::create([
'user_id' => $user->id,
'content' => $content,
]);

// Record rate limit for configured cooldown seconds
if ($cooldownSeconds > 0) {
RateLimiter::hit($rateLimitKey, $cooldownSeconds);
}

// Keep rolling buffer of latest 200 messages in DB
ChatMessage::pruneOldMessages(200);

// Broadcast to Pusher Channels
try {
broadcast(new ChatMessageSent($message))->toOthers();
} catch (\Throwable $e) {
// Log without failing response
}

$message->loadMissing(['user:id,name,username,image_path,institution', 'user.roles:id,name']);

return response()->json([
'id' => $message->id,
'content' => $message->content,
'created_at' => $message->created_at->toIso8601String(),
'user' => [
'id' => $message->user->id,
'name' => $message->user->name,
'username' => $message->user->username,
'image_url' => $message->user->image_url,
'institution' => $message->user->institution,
'is_verified' => $message->user->is_verified,
'roles' => $message->user->roles->pluck('name')->toArray(),
],
], 201);
}

public function destroy(Request $request, ChatMessage $message)
{
$user = $request->user();
abort_unless($user, 401);

// Can delete if own message or has manage chat permission
if ($message->user_id !== $user->id && ! $user->can('manage chat')) {
abort(403, 'Unauthorized');
}

$messageId = $message->id;
$message->delete();

try {
broadcast(new ChatMessageDeleted($messageId))->toOthers();
} catch (\Throwable $e) {
// Ignore
}

return response()->json(['success' => true]);
}
}
Loading