added attchments
This commit is contained in:
58
app/Actions/BbcodeFormatter.php
Normal file
58
app/Actions/BbcodeFormatter.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use s9e\TextFormatter\Configurator;
|
||||
use s9e\TextFormatter\Parser;
|
||||
use s9e\TextFormatter\Renderer;
|
||||
|
||||
class BbcodeFormatter
|
||||
{
|
||||
private static ?Parser $parser = null;
|
||||
private static ?Renderer $renderer = null;
|
||||
|
||||
public static function format(?string $text): string
|
||||
{
|
||||
if ($text === null || $text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!self::$parser || !self::$renderer) {
|
||||
[$parser, $renderer] = self::build();
|
||||
self::$parser = $parser;
|
||||
self::$renderer = $renderer;
|
||||
}
|
||||
|
||||
$xml = self::$parser->parse($text);
|
||||
|
||||
return self::$renderer->render($xml);
|
||||
}
|
||||
|
||||
private static function build(): array
|
||||
{
|
||||
$configurator = new Configurator();
|
||||
$bbcodes = $configurator->plugins->load('BBCodes');
|
||||
$bbcodes->addFromRepository('B');
|
||||
$bbcodes->addFromRepository('I');
|
||||
$bbcodes->addFromRepository('U');
|
||||
$bbcodes->addFromRepository('S');
|
||||
$bbcodes->addFromRepository('URL');
|
||||
$bbcodes->addFromRepository('IMG');
|
||||
$bbcodes->addFromRepository('QUOTE');
|
||||
$bbcodes->addFromRepository('CODE');
|
||||
$bbcodes->addFromRepository('LIST');
|
||||
$bbcodes->addFromRepository('*');
|
||||
|
||||
$configurator->tags->add('BR')->template = '<br/>';
|
||||
|
||||
$bundle = $configurator->finalize();
|
||||
$parser = $bundle['parser'] ?? null;
|
||||
$renderer = $bundle['renderer'] ?? null;
|
||||
|
||||
if (!$parser || !$renderer) {
|
||||
throw new \RuntimeException('Unable to initialize BBCode formatter.');
|
||||
}
|
||||
|
||||
return [$parser, $renderer];
|
||||
}
|
||||
}
|
||||
300
app/Http/Controllers/AttachmentController.php
Normal file
300
app/Http/Controllers/AttachmentController.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Attachment::query()
|
||||
->with(['extension', 'group'])
|
||||
->withoutTrashed();
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
$postParam = $request->query('post');
|
||||
|
||||
if ($threadParam) {
|
||||
$threadId = $this->parseThreadId($threadParam);
|
||||
if ($threadId !== null) {
|
||||
$query->where('thread_id', $threadId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($postParam) {
|
||||
$postId = $this->parsePostId($postParam);
|
||||
if ($postId !== null) {
|
||||
$query->where('post_id', $postId);
|
||||
}
|
||||
}
|
||||
|
||||
$attachments = $query
|
||||
->latest('created_at')
|
||||
->get()
|
||||
->map(fn (Attachment $attachment) => $this->serializeAttachment($attachment));
|
||||
|
||||
return response()->json($attachments);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'thread' => ['nullable', 'string'],
|
||||
'post' => ['nullable', 'string'],
|
||||
'file' => ['required', 'file'],
|
||||
]);
|
||||
|
||||
$threadId = $this->parseThreadId($data['thread'] ?? null);
|
||||
$postId = $this->parsePostId($data['post'] ?? null);
|
||||
|
||||
if (($threadId && $postId) || (!$threadId && !$postId)) {
|
||||
return response()->json(['message' => 'Provide either thread or post.'], 422);
|
||||
}
|
||||
|
||||
$thread = null;
|
||||
$post = null;
|
||||
if ($threadId) {
|
||||
$thread = Thread::query()->findOrFail($threadId);
|
||||
if (!$this->canManageAttachments($user, $thread->user_id)) {
|
||||
return response()->json(['message' => 'Not authorized to add attachments.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
if ($postId) {
|
||||
$post = Post::query()->findOrFail($postId);
|
||||
if (!$this->canManageAttachments($user, $post->user_id)) {
|
||||
return response()->json(['message' => 'Not authorized to add attachments.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
if (!$file) {
|
||||
return response()->json(['message' => 'File missing.'], 422);
|
||||
}
|
||||
|
||||
$mime = $file->getMimeType() ?? 'application/octet-stream';
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
|
||||
$extensionRow = $this->resolveExtension($extension);
|
||||
if (!$extensionRow || !$extensionRow->group || !$extensionRow->group->is_active) {
|
||||
return response()->json(['message' => 'File type not allowed.'], 422);
|
||||
}
|
||||
|
||||
$group = $extensionRow->group;
|
||||
if (!$this->matchesAllowed($mime, $extensionRow->allowed_mimes)) {
|
||||
return response()->json(['message' => 'File type not allowed.'], 422);
|
||||
}
|
||||
|
||||
$maxSizeBytes = (int) $group->max_size_kb * 1024;
|
||||
if ($file->getSize() > $maxSizeBytes) {
|
||||
return response()->json(['message' => 'File exceeds allowed size.'], 422);
|
||||
}
|
||||
|
||||
$scopeFolder = $threadId ? "threads/{$threadId}" : "posts/{$postId}";
|
||||
$filename = Str::uuid()->toString();
|
||||
if ($extension !== '') {
|
||||
$filename .= ".{$extension}";
|
||||
}
|
||||
|
||||
$disk = 'local';
|
||||
$path = "attachments/{$scopeFolder}/{$filename}";
|
||||
Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $threadId,
|
||||
'post_id' => $postId,
|
||||
'attachment_extension_id' => $extensionRow->id,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'extension' => $extension !== '' ? $extension : null,
|
||||
'mime_type' => $mime,
|
||||
'size_bytes' => (int) $file->getSize(),
|
||||
]);
|
||||
|
||||
$attachment->loadMissing(['extension', 'group']);
|
||||
|
||||
return response()->json($this->serializeAttachment($attachment), 201);
|
||||
}
|
||||
|
||||
public function show(Attachment $attachment): JsonResponse
|
||||
{
|
||||
if (!$this->canViewAttachment($attachment)) {
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
$attachment->loadMissing(['extension', 'group']);
|
||||
|
||||
return response()->json($this->serializeAttachment($attachment));
|
||||
}
|
||||
|
||||
public function download(Attachment $attachment): Response
|
||||
{
|
||||
if (!$this->canViewAttachment($attachment)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$disk = Storage::disk($attachment->disk);
|
||||
if (!$disk->exists($attachment->path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mime = $attachment->mime_type ?: 'application/octet-stream';
|
||||
|
||||
return $disk->download($attachment->path, $attachment->original_name, [
|
||||
'Content-Type' => $mime,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Attachment $attachment): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
if (!$this->canManageAttachments($user, $attachment->user_id)) {
|
||||
return response()->json(['message' => 'Not authorized to delete attachments.'], 403);
|
||||
}
|
||||
|
||||
$attachment->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
private function resolveExtension(string $extension): ?AttachmentExtension
|
||||
{
|
||||
if ($extension === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AttachmentExtension::query()
|
||||
->where('extension', strtolower($extension))
|
||||
->with('group')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function matchesAllowed(string $value, ?array $allowed): bool
|
||||
{
|
||||
if (!$allowed || count($allowed) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($value));
|
||||
|
||||
foreach ($allowed as $entry) {
|
||||
if (strtolower(trim((string) $entry)) === $normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function parseThreadId(?string $value): ?int
|
||||
{
|
||||
if (!$value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('#/threads/(\d+)$#', $value, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parsePostId(?string $value): ?int
|
||||
{
|
||||
if (!$value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('#/posts/(\d+)$#', $value, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function canViewAttachment(Attachment $attachment): bool
|
||||
{
|
||||
if ($attachment->trashed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($attachment->thread_id) {
|
||||
$thread = Thread::withTrashed()->find($attachment->thread_id);
|
||||
return $thread && !$thread->trashed();
|
||||
}
|
||||
|
||||
if ($attachment->post_id) {
|
||||
$post = Post::withTrashed()->find($attachment->post_id);
|
||||
if (!$post || $post->trashed()) {
|
||||
return false;
|
||||
}
|
||||
$thread = Thread::withTrashed()->find($post->thread_id);
|
||||
return $thread && !$thread->trashed();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function canManageAttachments($user, ?int $ownerId): bool
|
||||
{
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $ownerId !== null && $ownerId === $user->id;
|
||||
}
|
||||
|
||||
private function serializeAttachment(Attachment $attachment): array
|
||||
{
|
||||
return [
|
||||
'id' => $attachment->id,
|
||||
'thread_id' => $attachment->thread_id,
|
||||
'post_id' => $attachment->post_id,
|
||||
'extension' => $attachment->extension,
|
||||
'group' => $attachment->group ? [
|
||||
'id' => $attachment->group->id,
|
||||
'name' => $attachment->group->name,
|
||||
'category' => $attachment->group->category,
|
||||
'max_size_kb' => $attachment->group->max_size_kb,
|
||||
] : null,
|
||||
'original_name' => $attachment->original_name,
|
||||
'mime_type' => $attachment->mime_type,
|
||||
'size_bytes' => $attachment->size_bytes,
|
||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
||||
'created_at' => $attachment->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
147
app/Http/Controllers/AttachmentExtensionController.php
Normal file
147
app/Http/Controllers/AttachmentExtensionController.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AttachmentExtensionController 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);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$extensions = AttachmentExtension::query()
|
||||
->with('group')
|
||||
->orderBy('extension')
|
||||
->get()
|
||||
->map(fn (AttachmentExtension $extension) => $this->serializeExtension($extension));
|
||||
|
||||
return response()->json($extensions);
|
||||
}
|
||||
|
||||
public function publicIndex(): JsonResponse
|
||||
{
|
||||
$extensions = AttachmentExtension::query()
|
||||
->whereNotNull('attachment_group_id')
|
||||
->whereHas('group', fn ($query) => $query->where('is_active', true))
|
||||
->orderBy('extension')
|
||||
->pluck('extension')
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return response()->json($extensions);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $this->validatePayload($request, true);
|
||||
$extension = $this->normalizeExtension($data['extension']);
|
||||
if ($extension === '') {
|
||||
return response()->json(['message' => 'Invalid extension.'], 422);
|
||||
}
|
||||
|
||||
if (AttachmentExtension::query()->where('extension', $extension)->exists()) {
|
||||
return response()->json(['message' => 'Extension already exists.'], 422);
|
||||
}
|
||||
|
||||
$created = AttachmentExtension::create([
|
||||
'extension' => $extension,
|
||||
'attachment_group_id' => $data['attachment_group_id'] ?? null,
|
||||
'allowed_mimes' => $data['allowed_mimes'] ?? null,
|
||||
]);
|
||||
|
||||
$created->load('group');
|
||||
|
||||
return response()->json($this->serializeExtension($created), 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, AttachmentExtension $attachmentExtension): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $this->validatePayload($request, false);
|
||||
|
||||
if (array_key_exists('attachment_group_id', $data)) {
|
||||
$attachmentExtension->attachment_group_id = $data['attachment_group_id'];
|
||||
}
|
||||
if (array_key_exists('allowed_mimes', $data)) {
|
||||
$attachmentExtension->allowed_mimes = $data['allowed_mimes'];
|
||||
}
|
||||
|
||||
$attachmentExtension->save();
|
||||
$attachmentExtension->load('group');
|
||||
|
||||
return response()->json($this->serializeExtension($attachmentExtension));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, AttachmentExtension $attachmentExtension): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if (Attachment::query()->where('attachment_extension_id', $attachmentExtension->id)->exists()) {
|
||||
return response()->json(['message' => 'Extension is in use.'], 422);
|
||||
}
|
||||
|
||||
$attachmentExtension->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
private function validatePayload(Request $request, bool $requireExtension): array
|
||||
{
|
||||
$rules = [
|
||||
'attachment_group_id' => ['nullable', 'integer', 'exists:attachment_groups,id'],
|
||||
'allowed_mimes' => ['nullable', 'array'],
|
||||
'allowed_mimes.*' => ['string', 'max:150'],
|
||||
];
|
||||
|
||||
if ($requireExtension) {
|
||||
$rules['extension'] = ['required', 'string', 'max:30'];
|
||||
}
|
||||
|
||||
return $request->validate($rules);
|
||||
}
|
||||
|
||||
private function normalizeExtension(string $value): string
|
||||
{
|
||||
return ltrim(strtolower(trim($value)), '.');
|
||||
}
|
||||
|
||||
private function serializeExtension(AttachmentExtension $extension): array
|
||||
{
|
||||
return [
|
||||
'id' => $extension->id,
|
||||
'extension' => $extension->extension,
|
||||
'attachment_group_id' => $extension->attachment_group_id,
|
||||
'allowed_mimes' => $extension->allowed_mimes,
|
||||
'group' => $extension->group ? [
|
||||
'id' => $extension->group->id,
|
||||
'name' => $extension->group->name,
|
||||
'is_active' => $extension->group->is_active,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/AttachmentGroupController.php
Normal file
190
app/Http/Controllers/AttachmentGroupController.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentGroup;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AttachmentGroupController 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);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$groups = AttachmentGroup::query()
|
||||
->withCount('extensions')
|
||||
->orderBy('parent_id')
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (AttachmentGroup $group) => $this->serializeGroup($group));
|
||||
|
||||
return response()->json($groups);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $this->validatePayload($request);
|
||||
$name = trim($data['name']);
|
||||
$parentId = $this->normalizeParentId($data['parent_id'] ?? null);
|
||||
|
||||
if (AttachmentGroup::query()->whereRaw('LOWER(name) = ?', [strtolower($name)])->exists()) {
|
||||
return response()->json(['message' => 'Attachment group already exists.'], 422);
|
||||
}
|
||||
|
||||
$position = (AttachmentGroup::query()
|
||||
->where('parent_id', $parentId)
|
||||
->max('position') ?? 0) + 1;
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => $name,
|
||||
'parent_id' => $parentId,
|
||||
'position' => $position,
|
||||
'max_size_kb' => $data['max_size_kb'],
|
||||
'is_active' => $data['is_active'],
|
||||
]);
|
||||
|
||||
$group->loadCount('extensions');
|
||||
|
||||
return response()->json($this->serializeGroup($group), 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, AttachmentGroup $attachmentGroup): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $this->validatePayload($request);
|
||||
$name = trim($data['name']);
|
||||
$parentId = $this->normalizeParentId($data['parent_id'] ?? null);
|
||||
$position = $attachmentGroup->position ?? 1;
|
||||
|
||||
if (AttachmentGroup::query()
|
||||
->where('id', '!=', $attachmentGroup->id)
|
||||
->whereRaw('LOWER(name) = ?', [strtolower($name)])
|
||||
->exists()
|
||||
) {
|
||||
return response()->json(['message' => 'Attachment group already exists.'], 422);
|
||||
}
|
||||
|
||||
if ($attachmentGroup->parent_id !== $parentId) {
|
||||
$position = (AttachmentGroup::query()
|
||||
->where('parent_id', $parentId)
|
||||
->max('position') ?? 0) + 1;
|
||||
}
|
||||
|
||||
$attachmentGroup->update([
|
||||
'name' => $name,
|
||||
'parent_id' => $parentId,
|
||||
'position' => $position,
|
||||
'max_size_kb' => $data['max_size_kb'],
|
||||
'is_active' => $data['is_active'],
|
||||
]);
|
||||
|
||||
$attachmentGroup->loadCount('extensions');
|
||||
|
||||
return response()->json($this->serializeGroup($attachmentGroup));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, AttachmentGroup $attachmentGroup): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if ($attachmentGroup->extensions()->exists()) {
|
||||
return response()->json(['message' => 'Attachment group has extensions.'], 422);
|
||||
}
|
||||
|
||||
if (Attachment::query()->where('attachment_group_id', $attachmentGroup->id)->exists()) {
|
||||
return response()->json(['message' => 'Attachment group is in use.'], 422);
|
||||
}
|
||||
|
||||
$attachmentGroup->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'parentId' => ['nullable'],
|
||||
'orderedIds' => ['required', 'array'],
|
||||
'orderedIds.*' => ['integer'],
|
||||
]);
|
||||
|
||||
$parentId = $data['parentId'] ?? null;
|
||||
if ($parentId === '' || $parentId === 'null') {
|
||||
$parentId = null;
|
||||
} elseif ($parentId !== null) {
|
||||
$parentId = (int) $parentId;
|
||||
}
|
||||
|
||||
foreach ($data['orderedIds'] as $index => $groupId) {
|
||||
AttachmentGroup::where('id', $groupId)
|
||||
->where('parent_id', $parentId)
|
||||
->update(['position' => $index + 1]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
private function validatePayload(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'name' => ['required', 'string', 'max:150'],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:attachment_groups,id'],
|
||||
'max_size_kb' => ['required', 'integer', 'min:1', 'max:512000'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function serializeGroup(AttachmentGroup $group): array
|
||||
{
|
||||
return [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'parent_id' => $group->parent_id,
|
||||
'position' => $group->position,
|
||||
'max_size_kb' => $group->max_size_kb,
|
||||
'is_active' => $group->is_active,
|
||||
'extensions_count' => $group->extensions_count ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeParentId($value): ?int
|
||||
{
|
||||
if ($value === '' || $value === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\BbcodeFormatter;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -16,6 +17,8 @@ class PostController extends Controller
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
]);
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
@@ -54,6 +57,8 @@ class PostController extends Controller
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post), 201);
|
||||
@@ -87,9 +92,12 @@ class PostController extends Controller
|
||||
|
||||
private function serializePost(Post $post): array
|
||||
{
|
||||
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
|
||||
$bodyHtml = $this->renderBody($post->body, $attachments);
|
||||
return [
|
||||
'id' => $post->id,
|
||||
'body' => $post->body,
|
||||
'body_html' => $bodyHtml,
|
||||
'thread' => "/api/threads/{$post->thread_id}",
|
||||
'user_id' => $post->user_id,
|
||||
'user_name' => $post->user?->name,
|
||||
@@ -111,9 +119,69 @@ class PostController extends Controller
|
||||
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||
'attachments' => $post->relationLoaded('attachments')
|
||||
? $attachments
|
||||
->map(fn ($attachment) => [
|
||||
'id' => $attachment->id,
|
||||
'group' => $attachment->group ? [
|
||||
'id' => $attachment->group->id,
|
||||
'name' => $attachment->group->name,
|
||||
] : null,
|
||||
'original_name' => $attachment->original_name,
|
||||
'extension' => $attachment->extension,
|
||||
'mime_type' => $attachment->mime_type,
|
||||
'size_bytes' => $attachment->size_bytes,
|
||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
||||
'created_at' => $attachment->created_at?->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
private function renderBody(string $body, $attachments): string
|
||||
{
|
||||
$replaced = $this->replaceAttachmentTags($body, $attachments);
|
||||
return BbcodeFormatter::format($replaced);
|
||||
}
|
||||
|
||||
private function replaceAttachmentTags(string $body, $attachments): string
|
||||
{
|
||||
if (!$attachments || count($attachments) === 0) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($attachments as $attachment) {
|
||||
$name = strtolower($attachment->original_name ?? '');
|
||||
if ($name !== '') {
|
||||
$map[$name] = [
|
||||
'url' => "/api/attachments/{$attachment->id}/download",
|
||||
'mime' => $attachment->mime_type ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$map) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
|
||||
$rawName = trim($matches[1]);
|
||||
$key = strtolower($rawName);
|
||||
if (!array_key_exists($key, $map)) {
|
||||
return $matches[0];
|
||||
}
|
||||
$entry = $map[$key];
|
||||
$url = $entry['url'];
|
||||
$mime = $entry['mime'] ?? '';
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
return "[img]{$url}[/img]";
|
||||
}
|
||||
return "[url={$url}]{$rawName}[/url]";
|
||||
}, $body) ?? $body;
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
|
||||
20
app/Http/Controllers/PreviewController.php
Normal file
20
app/Http/Controllers/PreviewController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\BbcodeFormatter;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PreviewController extends Controller
|
||||
{
|
||||
public function preview(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'html' => BbcodeFormatter::format($data['body']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use App\Actions\BbcodeFormatter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -49,6 +50,8 @@ class ThreadController extends Controller
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
@@ -81,6 +84,8 @@ class ThreadController extends Controller
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
@@ -120,6 +125,8 @@ class ThreadController extends Controller
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
@@ -146,10 +153,13 @@ class ThreadController extends Controller
|
||||
|
||||
private function serializeThread(Thread $thread): array
|
||||
{
|
||||
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
|
||||
$bodyHtml = $this->renderBody($thread->body, $attachments);
|
||||
return [
|
||||
'id' => $thread->id,
|
||||
'title' => $thread->title,
|
||||
'body' => $thread->body,
|
||||
'body_html' => $bodyHtml,
|
||||
'solved' => (bool) $thread->solved,
|
||||
'forum' => "/api/forums/{$thread->forum_id}",
|
||||
'user_id' => $thread->user_id,
|
||||
@@ -184,9 +194,69 @@ class ThreadController extends Controller
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
'attachments' => $thread->relationLoaded('attachments')
|
||||
? $attachments
|
||||
->map(fn ($attachment) => [
|
||||
'id' => $attachment->id,
|
||||
'group' => $attachment->group ? [
|
||||
'id' => $attachment->group->id,
|
||||
'name' => $attachment->group->name,
|
||||
] : null,
|
||||
'original_name' => $attachment->original_name,
|
||||
'extension' => $attachment->extension,
|
||||
'mime_type' => $attachment->mime_type,
|
||||
'size_bytes' => $attachment->size_bytes,
|
||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
||||
'created_at' => $attachment->created_at?->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
private function renderBody(string $body, $attachments): string
|
||||
{
|
||||
$replaced = $this->replaceAttachmentTags($body, $attachments);
|
||||
return BbcodeFormatter::format($replaced);
|
||||
}
|
||||
|
||||
private function replaceAttachmentTags(string $body, $attachments): string
|
||||
{
|
||||
if (!$attachments || count($attachments) === 0) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($attachments as $attachment) {
|
||||
$name = strtolower($attachment->original_name ?? '');
|
||||
if ($name !== '') {
|
||||
$map[$name] = [
|
||||
'url' => "/api/attachments/{$attachment->id}/download",
|
||||
'mime' => $attachment->mime_type ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$map) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
|
||||
$rawName = trim($matches[1]);
|
||||
$key = strtolower($rawName);
|
||||
if (!array_key_exists($key, $map)) {
|
||||
return $matches[0];
|
||||
}
|
||||
$entry = $map[$key];
|
||||
$url = $entry['url'];
|
||||
$mime = $entry['mime'] ?? '';
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
return "[img]{$url}[/img]";
|
||||
}
|
||||
return "[url={$url}]{$rawName}[/url]";
|
||||
}, $body) ?? $body;
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
|
||||
70
app/Models/Attachment.php
Normal file
70
app/Models/Attachment.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int|null $thread_id
|
||||
* @property int|null $post_id
|
||||
* @property int|null $attachment_extension_id
|
||||
* @property int|null $attachment_group_id
|
||||
* @property int|null $user_id
|
||||
* @property string $disk
|
||||
* @property string $path
|
||||
* @property string $original_name
|
||||
* @property string|null $extension
|
||||
* @property string $mime_type
|
||||
* @property int $size_bytes
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class Attachment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'thread_id',
|
||||
'post_id',
|
||||
'attachment_extension_id',
|
||||
'attachment_group_id',
|
||||
'user_id',
|
||||
'disk',
|
||||
'path',
|
||||
'original_name',
|
||||
'extension',
|
||||
'mime_type',
|
||||
'size_bytes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'size_bytes' => 'int',
|
||||
];
|
||||
|
||||
public function thread(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Thread::class);
|
||||
}
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function extension(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AttachmentExtension::class, 'attachment_extension_id');
|
||||
}
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
31
app/Models/AttachmentExtension.php
Normal file
31
app/Models/AttachmentExtension.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $extension
|
||||
* @property int|null $attachment_group_id
|
||||
* @property array|null $allowed_mimes
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class AttachmentExtension extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'extension',
|
||||
'attachment_group_id',
|
||||
'allowed_mimes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'allowed_mimes' => 'array',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
|
||||
}
|
||||
}
|
||||
46
app/Models/AttachmentGroup.php
Normal file
46
app/Models/AttachmentGroup.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property int|null $parent_id
|
||||
* @property int|null $position
|
||||
* @property int $max_size_kb
|
||||
* @property bool $is_active
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class AttachmentGroup extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'parent_id',
|
||||
'position',
|
||||
'max_size_kb',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'bool',
|
||||
];
|
||||
|
||||
public function extensions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AttachmentExtension::class, 'attachment_group_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property string $body
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
||||
* @property-read \App\Models\Thread $thread
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
||||
@@ -51,4 +52,9 @@ class Post extends Model
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attachment::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Forum $forum
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
||||
* @property-read int|null $posts_count
|
||||
* @property-read \App\Models\User|null $user
|
||||
@@ -64,6 +65,11 @@ class Thread extends Model
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attachment::class);
|
||||
}
|
||||
|
||||
public function latestPost(): HasOne
|
||||
{
|
||||
return $this->hasOne(Post::class)->latestOfMany();
|
||||
|
||||
Reference in New Issue
Block a user