| ';
+
+ // 허용된 태그만 남기기
+ $content = strip_tags($content, $allowedTags);
+
+ // 위험한 이벤트 핸들러 속성 제거 (onclick, onerror, onload 등)
+ $content = preg_replace('/\s*on\w+\s*=\s*["\'][^"\']*["\']/i', '', $content);
+ $content = preg_replace('/\s*on\w+\s*=\s*[^\s>]*/i', '', $content);
+
+ // javascript: 프로토콜 제거
+ $content = preg_replace('/href\s*=\s*["\']javascript:[^"\']*["\']/i', 'href="#"', $content);
+
+ return $content;
+ }
}
diff --git a/app/Services/PostService.php b/app/Services/PostService.php
index 7d2c85bf..6635bdc4 100644
--- a/app/Services/PostService.php
+++ b/app/Services/PostService.php
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Boards\Board;
+use App\Models\Boards\BoardComment;
use App\Models\Boards\File;
use App\Models\Boards\Post;
use App\Models\Boards\PostCustomFieldValue;
@@ -25,6 +26,7 @@ public function getPosts(int $boardId, array $filters = [], int $perPage = 15, b
$query = Post::query()
->ofBoard($boardId)
->with(['author', 'board'])
+ ->withCount(['files', 'comments'])
->published();
// 슈퍼관리자: 삭제된 게시물 포함
@@ -437,4 +439,58 @@ public function previewFile(Post $post, int $fileId)
'Content-Type' => $file->mime_type,
]);
}
+
+ // =========================================================================
+ // Comment Management
+ // =========================================================================
+
+ /**
+ * 게시글 댓글 목록 조회 (계층형)
+ */
+ public function getComments(Post $post): Collection
+ {
+ return $post->comments()
+ ->whereNull('parent_id')
+ ->with(['user', 'replies.user'])
+ ->orderBy('created_at')
+ ->get();
+ }
+
+ /**
+ * 댓글 생성
+ */
+ public function createComment(Post $post, array $data): BoardComment
+ {
+ $data['post_id'] = $post->id;
+ $data['tenant_id'] = $post->tenant_id;
+ $data['user_id'] = auth()->id();
+ $data['ip_address'] = request()->ip();
+ $data['status'] = 'active';
+
+ return BoardComment::create($data);
+ }
+
+ /**
+ * 댓글 수정
+ */
+ public function updateComment(BoardComment $comment, array $data): BoardComment
+ {
+ $comment->update([
+ 'content' => $data['content'],
+ 'updated_by' => auth()->id(),
+ ]);
+
+ return $comment->fresh();
+ }
+
+ /**
+ * 댓글 삭제 (Soft Delete)
+ */
+ public function deleteComment(BoardComment $comment): bool
+ {
+ $comment->deleted_by = auth()->id();
+ $comment->save();
+
+ return $comment->delete();
+ }
}
diff --git a/composer.json b/composer.json
index dc971500..3f46dfc8 100644
--- a/composer.json
+++ b/composer.json
@@ -12,7 +12,8 @@
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1",
- "spatie/laravel-permission": "^6.23"
+ "spatie/laravel-permission": "^6.23",
+ "stevebauman/purify": "^6.3"
},
"require-dev": {
"fakerphp/faker": "^1.23",
diff --git a/composer.lock b/composer.lock
index 4c2917ee..072a12d2 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "58e14bcea16068801af821062d7f3612",
+ "content-hash": "26a03aa10c3c63714cf032ce8573ce3a",
"packages": [
{
"name": "brick/math",
@@ -666,6 +666,67 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
+ {
+ "name": "ezyang/htmlpurifier",
+ "version": "v4.19.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ezyang/htmlpurifier.git",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
+ },
+ "require-dev": {
+ "cerdic/css-tidy": "^1.7 || ^2.0",
+ "simpletest/simpletest": "dev-master"
+ },
+ "suggest": {
+ "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
+ "ext-bcmath": "Used for unit conversion and imagecrash protection",
+ "ext-iconv": "Converts text to and from non-UTF-8 encodings",
+ "ext-tidy": "Used for pretty-printing HTML"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/HTMLPurifier.composer.php"
+ ],
+ "psr-0": {
+ "HTMLPurifier": "library/"
+ },
+ "exclude-from-classmap": [
+ "/library/HTMLPurifier/Language/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Edward Z. Yang",
+ "email": "admin@htmlpurifier.org",
+ "homepage": "http://ezyang.com"
+ }
+ ],
+ "description": "Standards compliant HTML filter written in PHP",
+ "homepage": "http://htmlpurifier.org/",
+ "keywords": [
+ "html"
+ ],
+ "support": {
+ "issues": "https://github.com/ezyang/htmlpurifier/issues",
+ "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
+ },
+ "time": "2025-10-17T16:34:55+00:00"
+ },
{
"name": "firebase/php-jwt",
"version": "v6.11.1",
@@ -3815,6 +3876,72 @@
],
"time": "2025-12-13T21:45:21+00:00"
},
+ {
+ "name": "stevebauman/purify",
+ "version": "v6.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/stevebauman/purify.git",
+ "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500",
+ "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500",
+ "shasum": ""
+ },
+ "require": {
+ "ezyang/htmlpurifier": "^4.17",
+ "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0",
+ "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Purify": "Stevebauman\\Purify\\Facades\\Purify"
+ },
+ "providers": [
+ "Stevebauman\\Purify\\PurifyServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Stevebauman\\Purify\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Steve Bauman",
+ "email": "steven_bauman@outlook.com"
+ }
+ ],
+ "description": "An HTML Purifier / Sanitizer for Laravel",
+ "keywords": [
+ "Purifier",
+ "clean",
+ "cleaner",
+ "html",
+ "laravel",
+ "purification",
+ "purify"
+ ],
+ "support": {
+ "issues": "https://github.com/stevebauman/purify/issues",
+ "source": "https://github.com/stevebauman/purify/tree/v6.3.1"
+ },
+ "time": "2025-05-21T16:53:09+00:00"
+ },
{
"name": "swagger-api/swagger-ui",
"version": "v5.31.0",
diff --git a/config/purify.php b/config/purify.php
new file mode 100644
index 00000000..678a5c16
--- /dev/null
+++ b/config/purify.php
@@ -0,0 +1,117 @@
+ 'default',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Config sets
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure various sets of configuration for differentiated use of HTMLPurifier.
+ | A specific set of configuration can be applied by calling the "config($name)" method on
+ | a Purify instance. Feel free to add/remove/customize these attributes as you wish.
+ |
+ | Documentation: http://htmlpurifier.org/live/configdoc/plain.html
+ |
+ | Core.Encoding The encoding to convert input to.
+ | HTML.Doctype Doctype to use during filtering.
+ | HTML.Allowed The allowed HTML Elements with their allowed attributes.
+ | HTML.ForbiddenElements The forbidden HTML elements. Elements that are listed in this
+ | string will be removed, however their content will remain.
+ | CSS.AllowedProperties The Allowed CSS properties.
+ | AutoFormat.AutoParagraph Newlines are converted in to paragraphs whenever possible.
+ | AutoFormat.RemoveEmpty Remove empty elements that contribute no semantic information to the document.
+ |
+ */
+
+ 'configs' => [
+
+ 'default' => [
+ 'Core.Encoding' => 'utf-8',
+ 'HTML.Doctype' => 'HTML 4.01 Transitional',
+ 'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,u,strong,i,em,s,del,a[href|title|target],ul,ol,li,p[style],br,span[style],div[style],img[width|height|alt|src|class|style],blockquote,pre,code,table,thead,tbody,tr,th,td',
+ 'HTML.ForbiddenElements' => '',
+ 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align,width,height,max-width,margin,padding,border,border-radius',
+ 'AutoFormat.AutoParagraph' => false,
+ 'AutoFormat.RemoveEmpty' => false,
+ // data: URI 허용 (이미지 인라인 첨부용)
+ 'URI.AllowedSchemes' => ['http' => true, 'https' => true, 'mailto' => true, 'data' => true],
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | HTMLPurifier definitions
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify a class that augments the HTML definitions used by
+ | HTMLPurifier. Additional HTML5 definitions are provided out of the box.
+ | When specifying a custom class, make sure it implements the interface:
+ |
+ | \Stevebauman\Purify\Definitions\Definition
+ |
+ | Note that these definitions are applied to every Purifier instance.
+ |
+ | Documentation: http://htmlpurifier.org/docs/enduser-customize.html
+ |
+ */
+
+ 'definitions' => Html5Definition::class,
+
+ /*
+ |--------------------------------------------------------------------------
+ | HTMLPurifier CSS definitions
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify a class that augments the CSS definitions used by
+ | HTMLPurifier. When specifying a custom class, make sure it implements
+ | the interface:
+ |
+ | \Stevebauman\Purify\Definitions\CssDefinition
+ |
+ | Note that these definitions are applied to every Purifier instance.
+ |
+ | CSS should be extending $definition->info['css-attribute'] = values
+ | See HTMLPurifier_CSSDefinition for further explanation
+ |
+ */
+
+ 'css-definitions' => null,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Serializer
+ |--------------------------------------------------------------------------
+ |
+ | The storage implementation where HTMLPurifier can store its serializer files.
+ | If the filesystem cache is in use, the path must be writable through the
+ | storage disk by the web server, otherwise an exception will be thrown.
+ |
+ */
+
+ 'serializer' => [
+ 'driver' => env('CACHE_STORE', env('CACHE_DRIVER', 'file')),
+ 'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class,
+ ],
+
+ // 'serializer' => [
+ // 'disk' => env('FILESYSTEM_DISK', 'local'),
+ // 'path' => 'purify',
+ // 'cache' => \Stevebauman\Purify\Cache\FilesystemDefinitionCache::class,
+ // ],
+
+];
diff --git a/resources/views/posts/index.blade.php b/resources/views/posts/index.blade.php
index 01ef395e..1d7b95ce 100644
--- a/resources/views/posts/index.blade.php
+++ b/resources/views/posts/index.blade.php
@@ -125,6 +125,22 @@ class="text-gray-900 hover:text-blue-600 font-medium">
@endif
{{ $post->title }}
+ @if($post->files_count > 0)
+
+
+ {{ $post->files_count }}
+
+ @endif
+ @if($post->comments_count > 0)
+
+
+ {{ $post->comments_count }}
+
+ @endif
@endif
|
diff --git a/resources/views/posts/show.blade.php b/resources/views/posts/show.blade.php
index b6408743..0f29ea86 100644
--- a/resources/views/posts/show.blade.php
+++ b/resources/views/posts/show.blade.php
@@ -111,7 +111,16 @@ class="w-full h-auto">
- {!! nl2br(e($post->content)) !!}
+ @if($board->editor_type === 'wysiwyg')
+ {{-- WYSIWYG: HTML 허용 (안전한 태그만) --}}
+ {!! $post->getSafeHtmlContent() !!}
+ @elseif($board->editor_type === 'markdown')
+ {{-- Markdown: 파싱 후 출력 --}}
+ {!! Str::markdown($post->content) !!}
+ @else
+ {{-- Text: 일반 텍스트 --}}
+ {!! nl2br(e($post->content)) !!}
+ @endif
@@ -168,6 +177,210 @@ class="block px-6 py-4 hover:bg-gray-50 transition">
+
+
+
+
+ 댓글 {{ $comments->count() + $comments->sum(fn($c) => $c->replies->count()) }}
+
+
+
+
+
+
+
+
+ @forelse($comments as $comment)
+
+ @empty
+
+
+ 아직 댓글이 없습니다.
+ 첫 번째 댓글을 작성해보세요!
+
+ @endforelse
+
+
+
|
|---|