feat: add installer, ranks/groups enhancements, and founder protections
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -14,31 +15,31 @@ class ForumController extends Controller
|
||||
{
|
||||
$query = Forum::query()
|
||||
->withoutTrashed()
|
||||
->withCount(['threads', 'posts'])
|
||||
->withSum('threads', 'views_count');
|
||||
->withCount(relations: ['threads', 'posts'])
|
||||
->withSum(relation: 'threads', column: 'views_count');
|
||||
|
||||
$parentParam = $request->query('parent');
|
||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
$parentParam = $request->query(key: 'parent');
|
||||
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
|
||||
if ($exists === false) {
|
||||
$query->whereNull('parent_id');
|
||||
$query->whereNull(columns: 'parent_id');
|
||||
} elseif ($exists === true) {
|
||||
$query->whereNotNull('parent_id');
|
||||
$query->whereNotNull(columns: 'parent_id');
|
||||
}
|
||||
} elseif (is_string($parentParam)) {
|
||||
$parentId = $this->parseIriId($parentParam);
|
||||
} elseif (is_string(value: $parentParam)) {
|
||||
$parentId = $this->parseIriId(value: $parentParam);
|
||||
if ($parentId !== null) {
|
||||
$query->where('parent_id', $parentId);
|
||||
$query->where(column: 'parent_id', operator: $parentId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->where('type', $request->query('type'));
|
||||
if ($request->filled(key: 'type')) {
|
||||
$query->where(column: 'type', operator: $request->query(key: 'type'));
|
||||
}
|
||||
|
||||
$forums = $query
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->orderBy(column: 'position')
|
||||
->orderBy(column: 'name')
|
||||
->get();
|
||||
|
||||
$forumIds = $forums->pluck('id')->all();
|
||||
@@ -216,6 +217,8 @@ class ForumController extends Controller
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -234,7 +237,7 @@ class ForumController extends Controller
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->with(['user.rank', 'user.roles'])
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
@@ -256,8 +259,28 @@ class ForumController extends Controller
|
||||
->where('threads.forum_id', $forumId)
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->orderByDesc(column: 'posts.created_at')
|
||||
->with(relations: ['user.rank', 'user.roles'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy(callback: 'name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
140
app/Http/Controllers/InstallerController.php
Normal file
140
app/Http/Controllers/InstallerController.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class InstallerController extends Controller
|
||||
{
|
||||
public function show(Request $request): View|RedirectResponse
|
||||
{
|
||||
if ($this->envExists()) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return view('installer', [
|
||||
'appUrl' => $request->getSchemeAndHttpHost(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): View|RedirectResponse
|
||||
{
|
||||
if ($this->envExists()) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'app_url' => ['required', 'url'],
|
||||
'db_host' => ['required', 'string', 'max:255'],
|
||||
'db_port' => ['nullable', 'integer'],
|
||||
'db_database' => ['required', 'string', 'max:255'],
|
||||
'db_username' => ['required', 'string', 'max:255'],
|
||||
'db_password' => ['nullable', 'string'],
|
||||
'admin_name' => ['required', 'string', 'max:255'],
|
||||
'admin_email' => ['required', 'email', 'max:255'],
|
||||
'admin_password' => ['required', 'string', 'min:8'],
|
||||
]);
|
||||
|
||||
$appKey = 'base64:' . base64_encode(random_bytes(32));
|
||||
|
||||
$envLines = [
|
||||
'APP_NAME="speedBB"',
|
||||
'APP_ENV=production',
|
||||
'APP_DEBUG=false',
|
||||
'APP_URL=' . $data['app_url'],
|
||||
'APP_KEY=' . $appKey,
|
||||
'',
|
||||
'DB_CONNECTION=mysql',
|
||||
'DB_HOST=' . $data['db_host'],
|
||||
'DB_PORT=' . ($data['db_port'] ?: 3306),
|
||||
'DB_DATABASE=' . $data['db_database'],
|
||||
'DB_USERNAME=' . $data['db_username'],
|
||||
'DB_PASSWORD=' . ($data['db_password'] ?? ''),
|
||||
'',
|
||||
'MAIL_MAILER=sendmail',
|
||||
'MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"',
|
||||
'MAIL_FROM_ADDRESS="hello@example.com"',
|
||||
'MAIL_FROM_NAME="speedBB"',
|
||||
];
|
||||
|
||||
$this->writeEnv(implode("\n", $envLines) . "\n");
|
||||
|
||||
config([
|
||||
'app.key' => $appKey,
|
||||
'app.url' => $data['app_url'],
|
||||
'database.default' => 'mysql',
|
||||
'database.connections.mysql.host' => $data['db_host'],
|
||||
'database.connections.mysql.port' => (int) ($data['db_port'] ?: 3306),
|
||||
'database.connections.mysql.database' => $data['db_database'],
|
||||
'database.connections.mysql.username' => $data['db_username'],
|
||||
'database.connections.mysql.password' => $data['db_password'] ?? '',
|
||||
'mail.default' => 'sendmail',
|
||||
'mail.mailers.sendmail.path' => '/usr/sbin/sendmail -bs -i',
|
||||
]);
|
||||
|
||||
DB::purge('mysql');
|
||||
|
||||
try {
|
||||
DB::connection('mysql')->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->removeEnv();
|
||||
return view('installer', [
|
||||
'appUrl' => $data['app_url'],
|
||||
'error' => 'Database connection failed: ' . $e->getMessage(),
|
||||
'old' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
$migrateExit = Artisan::call('migrate', ['--force' => true]);
|
||||
if ($migrateExit !== 0) {
|
||||
$this->removeEnv();
|
||||
return view('installer', [
|
||||
'appUrl' => $data['app_url'],
|
||||
'error' => 'Migration failed. Please check your database credentials.',
|
||||
'old' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
$adminRole = Role::firstOrCreate(['name' => 'ROLE_ADMIN']);
|
||||
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER']);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['admin_name'],
|
||||
'name_canonical' => Str::lower(trim($data['admin_name'])),
|
||||
'email' => $data['admin_email'],
|
||||
'password' => Hash::make($data['admin_password']),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$user->roles()->sync([$adminRole->id, $founderRole->id]);
|
||||
|
||||
return view('installer-success');
|
||||
}
|
||||
|
||||
private function envExists(): bool
|
||||
{
|
||||
return file_exists(base_path('.env'));
|
||||
}
|
||||
|
||||
private function writeEnv(string $contents): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
file_put_contents($path, $contents);
|
||||
}
|
||||
|
||||
private function removeEnv(): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,9 @@ class PortalController extends Controller
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query->withCount('posts')->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])
|
||||
->latest('created_at')
|
||||
->limit(12)
|
||||
@@ -62,7 +63,9 @@ class PortalController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
@@ -82,6 +85,8 @@ class PortalController extends Controller
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -109,12 +114,18 @@ class PortalController extends Controller
|
||||
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||
? Storage::url($thread->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -133,7 +144,7 @@ class PortalController extends Controller
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->with(['user.rank', 'user.roles'])
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
@@ -146,4 +157,24 @@ class PortalController extends Controller
|
||||
|
||||
return $byForum;
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ class PostController extends Controller
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Post::query()->withoutTrashed()->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
@@ -49,7 +51,9 @@ class PostController extends Controller
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post), 201);
|
||||
@@ -95,14 +99,38 @@ class PostController extends Controller
|
||||
'user_posts_count' => $post->user?->posts_count,
|
||||
'user_created_at' => $post->user?->created_at?->toIso8601String(),
|
||||
'user_location' => $post->user?->location,
|
||||
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0,
|
||||
'user_rank_name' => $post->user?->rank?->name,
|
||||
'user_rank_badge_type' => $post->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $post->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
|
||||
? Storage::url($post->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $post->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
122
app/Http/Controllers/PostThankController.php
Normal file
122
app/Http/Controllers/PostThankController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostThank;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PostThankController extends Controller
|
||||
{
|
||||
public function store(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
$thank = PostThank::firstOrCreate([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $thank->id,
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
PostThank::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function given(User $user): JsonResponse
|
||||
{
|
||||
$thanks = PostThank::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['post.thread', 'post.user.rank', 'post.user.roles'])
|
||||
->latest('created_at')
|
||||
->get()
|
||||
->map(fn (PostThank $thank) => $this->serializeGiven($thank));
|
||||
|
||||
return response()->json($thanks);
|
||||
}
|
||||
|
||||
public function received(User $user): JsonResponse
|
||||
{
|
||||
$thanks = PostThank::query()
|
||||
->whereHas('post', fn ($query) => $query->where('user_id', $user->id))
|
||||
->with(['post.thread', 'user.rank', 'user.roles'])
|
||||
->latest('created_at')
|
||||
->get()
|
||||
->map(fn (PostThank $thank) => $this->serializeReceived($thank));
|
||||
|
||||
return response()->json($thanks);
|
||||
}
|
||||
|
||||
private function serializeGiven(PostThank $thank): array
|
||||
{
|
||||
return [
|
||||
'id' => $thank->id,
|
||||
'post_id' => $thank->post_id,
|
||||
'thread_id' => $thank->post?->thread_id,
|
||||
'thread_title' => $thank->post?->thread?->title,
|
||||
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
|
||||
'post_author_id' => $thank->post?->user_id,
|
||||
'post_author_name' => $thank->post?->user?->name,
|
||||
'post_author_rank_color' => $thank->post?->user?->rank?->color,
|
||||
'post_author_group_color' => $this->resolveGroupColor($thank->post?->user),
|
||||
'thanked_at' => $thank->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeReceived(PostThank $thank): array
|
||||
{
|
||||
return [
|
||||
'id' => $thank->id,
|
||||
'post_id' => $thank->post_id,
|
||||
'thread_id' => $thank->post?->thread_id,
|
||||
'thread_title' => $thank->post?->thread?->title,
|
||||
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
|
||||
'thanker_id' => $thank->user_id,
|
||||
'thanker_name' => $thank->user?->name,
|
||||
'thanker_rank_color' => $thank->user?->rank?->color,
|
||||
'thanker_group_color' => $this->resolveGroupColor($thank->user),
|
||||
'thanked_at' => $thank->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ class RankController extends Controller
|
||||
private function ensureAdmin(Request $request): ?JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(data: ['message' => 'Forbidden'], status: 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -29,6 +29,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
@@ -45,19 +46,24 @@ class RankController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
|
||||
$rank = Rank::create([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
'color' => $data['color'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -65,6 +71,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => null,
|
||||
], 201);
|
||||
}
|
||||
@@ -77,16 +84,21 @@ class RankController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
$color = array_key_exists('color', $data) ? $data['color'] : $rank->color;
|
||||
|
||||
if ($badgeType === 'text' && $rank->badge_image_path) {
|
||||
if ($badgeType !== 'image' && $rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
$rank->badge_image_path = null;
|
||||
}
|
||||
@@ -95,6 +107,7 @@ class RankController extends Controller
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
'color' => $color,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -102,6 +115,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
|
||||
141
app/Http/Controllers/RoleController.php
Normal file
141
app/Http/Controllers/RoleController.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
private const CORE_ROLES = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_FOUNDER'];
|
||||
|
||||
private function ensureAdmin(Request $request): ?JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$roles = Role::query()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Role $role) => [
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'color' => $role->color,
|
||||
]);
|
||||
|
||||
return response()->json($roles);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', 'unique:roles,name'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$normalizedName = $this->normalizeRoleName($data['name']);
|
||||
if (Role::query()->where('name', $normalizedName)->exists()) {
|
||||
return response()->json(['message' => 'Role already exists.'], 422);
|
||||
}
|
||||
|
||||
$role = Role::create([
|
||||
'name' => $normalizedName,
|
||||
'color' => $data['color'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'color' => $role->color,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, Role $role): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', "unique:roles,name,{$role->id}"],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$normalizedName = $this->normalizeRoleName($data['name']);
|
||||
if (Role::query()
|
||||
->where('id', '!=', $role->id)
|
||||
->where('name', $normalizedName)
|
||||
->exists()
|
||||
) {
|
||||
return response()->json(['message' => 'Role already exists.'], 422);
|
||||
}
|
||||
|
||||
if (in_array($role->name, self::CORE_ROLES, true) && $normalizedName !== $role->name) {
|
||||
return response()->json(['message' => 'Core roles cannot be renamed.'], 422);
|
||||
}
|
||||
|
||||
$color = array_key_exists('color', $data) ? $data['color'] : $role->color;
|
||||
|
||||
$role->update([
|
||||
'name' => $normalizedName,
|
||||
'color' => $color,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'color' => $role->color,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Role $role): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if (in_array($role->name, self::CORE_ROLES, true)) {
|
||||
return response()->json(['message' => 'Core roles cannot be deleted.'], 422);
|
||||
}
|
||||
|
||||
if ($role->users()->exists()) {
|
||||
return response()->json(['message' => 'Role is assigned to users.'], 422);
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
private function normalizeRoleName(string $value): string
|
||||
{
|
||||
$raw = strtoupper(trim($value));
|
||||
$raw = preg_replace('/\s+/', '_', $raw);
|
||||
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||
$raw = preg_replace('/_+/', '_', $raw);
|
||||
$raw = trim($raw, '_');
|
||||
if ($raw === '') {
|
||||
return 'ROLE_';
|
||||
}
|
||||
if (str_starts_with($raw, 'ROLE_')) {
|
||||
return $raw;
|
||||
}
|
||||
return "ROLE_{$raw}";
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThreadController extends Controller
|
||||
@@ -15,9 +16,13 @@ class ThreadController extends Controller
|
||||
$query = Thread::query()
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->withMax('posts', 'created_at')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
]);
|
||||
|
||||
$forumParam = $request->query('forum');
|
||||
@@ -29,7 +34,7 @@ class ThreadController extends Controller
|
||||
}
|
||||
|
||||
$threads = $query
|
||||
->latest('created_at')
|
||||
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
|
||||
->get()
|
||||
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||
|
||||
@@ -41,8 +46,11 @@ class ThreadController extends Controller
|
||||
$thread->increment('views_count');
|
||||
$thread->refresh();
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
@@ -70,8 +78,11 @@ class ThreadController extends Controller
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread), 201);
|
||||
@@ -120,20 +131,48 @@ class ThreadController extends Controller
|
||||
'user_posts_count' => $thread->user?->posts_count,
|
||||
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||
'user_location' => $thread->user?->location,
|
||||
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0,
|
||||
'user_rank_name' => $thread->user?->rank?->name,
|
||||
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||
? Storage::url($thread->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -26,7 +27,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles->pluck('name')->values(),
|
||||
]);
|
||||
|
||||
@@ -50,7 +53,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -65,7 +70,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'created_at' => $user->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
@@ -101,7 +108,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -112,6 +121,9 @@ class UserController extends Controller
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
@@ -127,7 +139,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -137,6 +151,9 @@ class UserController extends Controller
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
@@ -148,8 +165,18 @@ class UserController extends Controller
|
||||
Rule::unique('users', 'email')->ignore($user->id),
|
||||
],
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
'roles' => ['nullable', 'array'],
|
||||
'roles.*' => ['string', 'exists:roles,name'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('roles', $data) && !$this->isFounder($actor)) {
|
||||
$requested = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name));
|
||||
if ($requested->contains('ROLE_FOUNDER')) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$nameCanonical = Str::lower(trim($data['name']));
|
||||
$nameConflict = User::query()
|
||||
->where('id', '!=', $user->id)
|
||||
@@ -171,6 +198,19 @@ class UserController extends Controller
|
||||
'rank_id' => $data['rank_id'] ?? null,
|
||||
])->save();
|
||||
|
||||
if (array_key_exists('roles', $data)) {
|
||||
$roleNames = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$roleIds = Role::query()
|
||||
->whereIn('name', $roleNames)
|
||||
->pluck('id')
|
||||
->all();
|
||||
$user->roles()->sync($roleIds);
|
||||
}
|
||||
|
||||
$user->loadMissing('rank');
|
||||
|
||||
return response()->json([
|
||||
@@ -181,7 +221,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -194,4 +236,42 @@ class UserController extends Controller
|
||||
|
||||
return Storage::url($user->avatar_path);
|
||||
}
|
||||
|
||||
private function resolveGroupColor(User $user): ?string
|
||||
{
|
||||
$user->loadMissing('roles');
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeRoleName(string $value): string
|
||||
{
|
||||
$raw = strtoupper(trim($value));
|
||||
$raw = preg_replace('/\s+/', '_', $raw);
|
||||
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||
$raw = preg_replace('/_+/', '_', $raw);
|
||||
$raw = trim($raw, '_');
|
||||
if ($raw === '') {
|
||||
return 'ROLE_';
|
||||
}
|
||||
if (str_starts_with($raw, 'ROLE_')) {
|
||||
return $raw;
|
||||
}
|
||||
return "ROLE_{$raw}";
|
||||
}
|
||||
|
||||
private function isFounder(User $user): bool
|
||||
{
|
||||
return $user->roles()->where('name', 'ROLE_FOUNDER')->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -45,4 +46,9 @@ class Post extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function thanks(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
}
|
||||
|
||||
24
app/Models/PostThank.php
Normal file
24
app/Models/PostThank.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostThank extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class Rank extends Model
|
||||
'badge_type',
|
||||
'badge_text',
|
||||
'badge_image_path',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
|
||||
@@ -25,6 +25,7 @@ class Role extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): BelongsToMany
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Notifications\DatabaseNotificationCollection;
|
||||
@@ -106,6 +107,16 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function thanksGiven(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
|
||||
public function thanksReceived(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(PostThank::class, Post::class, 'user_id', 'post_id');
|
||||
}
|
||||
|
||||
public function rank()
|
||||
{
|
||||
return $this->belongsTo(Rank::class);
|
||||
|
||||
Reference in New Issue
Block a user