여러 화면에서 조건부(FormRequest) 검증이 우회되거나 과도하게 제한되던 결함을 한 계열로 묶어 수정한다. - 검증 규칙 확장 필터 이름 10개를 나머지 92개와 동일 규칙으로 통일 - 설명 길이를 바이트가 아닌 글자 수로 계산(한글 오검출 해소) - 비활성 언어팩 번역 보존한 채 수정 허용 - 알림 템플릿 제목 nullable 존중, 미지원 발송 수단 저장 차단 - 일정/실행 이력 목록 조회 검증 정합 - 게시판 첨부 미사용 시 빈 확장자 통과, new_display_hours/최소 길이 0 허용, 일괄 적용 override_values 범위 검증(SSoT) - 이커머스 클레임 사유 조회 검증, 용어 정정 - 원문 i18n 키 노출 다수 해소, 저장/삭제 실패 시 사유 화면 노출 - 아이콘 크기/다크모드 대비 회귀 수정 번들 확장(모듈3·플러그인6·템플릿2·언어팩5) 버전 new-bump 및 CHANGELOG 반영, ja 언어팩 동기.
939 lines
41 KiB
PHP
939 lines
41 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Http\Controllers\User;
|
|
|
|
use App\Enums\PermissionType;
|
|
use App\Http\Controllers\Api\Base\PublicBaseController;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use Modules\Sirsoft\Board\Enums\PostStatus;
|
|
use Modules\Sirsoft\Board\Enums\SecretMode;
|
|
use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException;
|
|
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
|
|
use Modules\Sirsoft\Board\Http\Requests\User\StorePostRequest;
|
|
use Modules\Sirsoft\Board\Http\Requests\User\UpdatePostRequest;
|
|
use Modules\Sirsoft\Board\Http\Requests\User\VerifyGuestPasswordRequest;
|
|
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
|
|
use Modules\Sirsoft\Board\Http\Resources\PostCollection;
|
|
use Modules\Sirsoft\Board\Http\Resources\PostResource;
|
|
use Modules\Sirsoft\Board\Models\Post;
|
|
use Modules\Sirsoft\Board\Services\BoardService;
|
|
use Modules\Sirsoft\Board\Services\CommentService;
|
|
use Modules\Sirsoft\Board\Services\PostService;
|
|
use Modules\Sirsoft\Board\Services\ReportService;
|
|
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
|
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
|
|
/**
|
|
* 사용자용 게시글 컨트롤러
|
|
*
|
|
* 게시판 게시글의 CRUD 기능을 제공합니다.
|
|
* - 비로그인 사용자도 목록/상세 조회 가능 (게시판 설정에 따름)
|
|
* - 비밀글은 권한 체크 후 조회
|
|
* - 회원/비회원 모두 게시글 작성 가능 (게시판 설정에 따름)
|
|
* - 수정/삭제는 작성자 본인 또는 비회원 비밀번호 확인 필요
|
|
*/
|
|
class PostController extends PublicBaseController
|
|
{
|
|
use ChecksBoardPermission;
|
|
|
|
/**
|
|
* PostController 생성자
|
|
*
|
|
* @param PostService $postService 게시글 서비스
|
|
* @param BoardService $boardService 게시판 서비스
|
|
* @param CommentService $commentService 댓글 서비스
|
|
* @param ReportService $reportService 신고 서비스
|
|
*/
|
|
public function __construct(
|
|
private PostService $postService,
|
|
private BoardService $boardService,
|
|
private CommentService $commentService,
|
|
private ReportService $reportService
|
|
) {}
|
|
|
|
/**
|
|
* 게시글 목록을 조회합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @return JsonResponse 게시글 목록 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 목록 조회. all()/header() 로 필터·페이징 파라미터만 읽음 (검증 불필요)
|
|
public function index(Request $request, string $slug): JsonResponse
|
|
{
|
|
try {
|
|
// 게시판 조회 및 활성화 확인
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// 목록 조회 파라미터 빌드 (필터 + perPage)
|
|
$listParams = $this->postService->buildListParams($request->all(), [
|
|
'context' => 'user',
|
|
'board' => $board,
|
|
'userAgent' => $request->header('User-Agent'),
|
|
]);
|
|
|
|
// 삭제된 게시글 포함 여부 (manager 권한 + 토글 ON 시에만 포함)
|
|
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
|
|
$withTrashed = $canViewDeleted && $request->boolean('del');
|
|
|
|
// 게시글 목록 조회 (simplePaginate — COUNT 쿼리 제거)
|
|
// board 객체 전달로 Service/Repository의 중복 Board 조회 방지
|
|
$posts = $this->postService->getPosts($slug, $listParams['filters'], $listParams['perPage'], withTrashed: $withTrashed, context: 'user', board: $board);
|
|
|
|
// board 관계 수동 주입 (목록 Repository는 board를 eager load하지 않음)
|
|
// Post::isNew()가 게시판 new_display_hours 설정을 사용하려면 board 관계가 필요하다.
|
|
// 이미 조회한 단일 $board 인스턴스를 공유 주입하므로 추가 쿼리는 없다.
|
|
foreach ($posts as $post) {
|
|
$post->setRelation('board', $board);
|
|
}
|
|
|
|
// 일반 게시글 총 건수는 캐시에서 조회 (simplePaginate는 total 미제공)
|
|
$totalNormalPosts = $this->postService->getCachedNormalPostCount($slug, $board->id, $listParams['filters'], $withTrashed, 'user');
|
|
|
|
// PostCollection 구성
|
|
$collection = new PostCollection($posts);
|
|
$collection->setTotalNormalPosts($totalNormalPosts);
|
|
$collection->setOrderDirection($listParams['filters']['order_direction']);
|
|
|
|
// BoardResource로 boardInfo 생성
|
|
$boardResource = new BoardResource($board);
|
|
|
|
return $this->success(
|
|
'sirsoft-board::messages.posts.fetch_success',
|
|
$collection->withBoardInfo($boardResource->toBoardInfoForUser())
|
|
);
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.fetch_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시글 상세 정보를 조회합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string|int $id 게시글 ID
|
|
* @return JsonResponse 게시글 상세 정보 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 상세 조회. 쿼리 파라미터만 read-only 참조 (검증 불필요)
|
|
public function show(Request $request, string $slug, string|int $id): JsonResponse
|
|
{
|
|
$id = (int) $id;
|
|
|
|
try {
|
|
// 게시판 정보 조회
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// 게시글 조회 (스코프 접근 검사 포함)
|
|
$post = $this->postService->getPost($slug, $id, context: 'user');
|
|
|
|
// 삭제된 게시글은 manager 권한 필요
|
|
if ($post->trashed()) {
|
|
if (! $this->checkBoardPermission($slug, 'manager', PermissionType::User)) {
|
|
throw new PostNotFoundException($id);
|
|
}
|
|
}
|
|
|
|
// 조회수 증가 (캐시 기반 중복 방지)
|
|
$this->postService->incrementViewCountOnce($slug, $id);
|
|
|
|
// 댓글/첨부파일/답글 포함하여 게시글 조회 (boardId 전달로 Board 재조회 방지)
|
|
$post = $this->postService->getPostWithCounts($slug, $id, $board->id);
|
|
|
|
// board 관계 수동 설정
|
|
$post->setRelation('board', $board);
|
|
|
|
// manager 권한 체크 (삭제 게시글/댓글 포함 여부 결정)
|
|
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
|
|
|
|
// 댓글 로드 (게시판 comment_order 설정 적용, manager 권한 + 토글 ON 시 삭제 댓글 포함)
|
|
$withTrashedComments = $canViewDeleted && $request->boolean('del_cmt');
|
|
$comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id);
|
|
|
|
// 신고 여부 일괄 조회 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
|
|
$user = $request->user();
|
|
if ($user) {
|
|
$commentIds = $comments->pluck('id')->all();
|
|
$reportedCommentIds = $this->reportService
|
|
->getReportedTargetIds($user->id, $board->id, 'comment', $commentIds);
|
|
|
|
foreach ($comments as $comment) {
|
|
$comment->is_already_reported_preloaded = in_array($comment->id, $reportedCommentIds);
|
|
$comment->setRelation('post', $post);
|
|
}
|
|
} else {
|
|
// 비로그인: 신고 불가이므로 모두 false
|
|
foreach ($comments as $comment) {
|
|
$comment->is_already_reported_preloaded = false;
|
|
$comment->setRelation('post', $post);
|
|
}
|
|
}
|
|
|
|
// 정렬된 댓글을 post에 설정
|
|
$post->setRelation('comments', $comments);
|
|
|
|
// 비밀글 권한 체크 및 content 필터링은 PostResource에서 처리
|
|
return $this->successWithResource(
|
|
'sirsoft-board::messages.posts.fetch_success',
|
|
new PostResource($post)
|
|
);
|
|
} catch (AccessDeniedHttpException $e) {
|
|
return $this->error('auth.scope_denied', 403);
|
|
} catch (ModelNotFoundException $e) {
|
|
throw new PostNotFoundException($id);
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.fetch_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시글의 이전/다음 네비게이션 정보를 조회합니다.
|
|
*
|
|
* 상세 API에서 분리하여 비동기 로딩 지원.
|
|
* 게시판 정렬 설정에 따라 이전/다음글을 반환합니다.
|
|
*
|
|
* 공지글·존재하지 않는 글·내부 조회 실패 등 어떤 상황에서도 500 대신
|
|
* `{prev: null, next: null}` 을 반환합니다. Board 조회 실패만 404로 처리합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string|int $id 게시글 ID
|
|
* @return JsonResponse 이전/다음 게시글 정보
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 이전/다음 글 조회. 경로 파라미터(slug/id)만 사용 (검증 불필요)
|
|
public function navigation(Request $request, string $slug, string|int $id): JsonResponse
|
|
{
|
|
$id = (int) $id;
|
|
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
$empty = ['prev' => null, 'next' => null];
|
|
|
|
// 공지글·존재하지 않는 글은 옆 글 없음으로 응답
|
|
$isNotice = $this->postService->isPostNotice($slug, $id, $board->id);
|
|
if ($isNotice === null || $isNotice === true) {
|
|
return $this->success('sirsoft-board::messages.posts.fetch_success', $empty);
|
|
}
|
|
|
|
// navigation 판별용 메타(카테고리·부모 ID) 경량 조회
|
|
$meta = $this->postService->getPostNavigationMeta($id, $board->id);
|
|
|
|
// 답글(parent_id != null)은 원글 후보 쿼리에서 제외되므로 옆 글 없음으로 응답 (47-4)
|
|
// 답글 상세 상단의 원글 인용 블록이 원글 이동 안내를 담당한다.
|
|
if (($meta['parent_id'] ?? null) !== null) {
|
|
return $this->success('sirsoft-board::messages.posts.fetch_success', $empty);
|
|
}
|
|
|
|
// manager 권한 + del=1 시 삭제된 게시글 포함
|
|
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
|
|
$withTrashed = $canViewDeleted && $request->boolean('del');
|
|
|
|
try {
|
|
$navigation = $this->postService->getAdjacentPosts($slug, $id, filters: [
|
|
'order_by' => $board->order_by instanceof \BackedEnum ? $board->order_by->value : $board->order_by,
|
|
'order_direction' => $board->order_direction instanceof \BackedEnum ? $board->order_direction->value : $board->order_direction,
|
|
// 현재 글과 동일 카테고리 글만 이전/다음 후보로 제한 (47-1).
|
|
// 미분류(null)면 getAdjacentPosts 의 when($category) 가 미적용 → 전체 원글 순회.
|
|
'category' => $meta['category'] ?? null,
|
|
], withTrashed: $withTrashed, board: $board);
|
|
} catch (\Throwable $e) {
|
|
// 내부 예외는 로그만 남기고 옆 글 없음으로 degrade
|
|
Log::warning('Post navigation fetch failed, returning empty result', [
|
|
'slug' => $slug,
|
|
'post_id' => $id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$navigation = $empty;
|
|
}
|
|
|
|
return $this->success('sirsoft-board::messages.posts.fetch_success', $navigation);
|
|
}
|
|
|
|
/**
|
|
* 게시글을 생성합니다.
|
|
*
|
|
* @param StorePostRequest $request 게시글 생성 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @return JsonResponse 생성된 게시글 응답
|
|
*/
|
|
public function store(StorePostRequest $request, string $slug): JsonResponse
|
|
{
|
|
try {
|
|
// 게시판 정보 조회
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// 파일 업로드 허용 여부 확인
|
|
if ($request->hasFile('files')) {
|
|
// 게시판에서 파일 업로드를 비활성화한 경우
|
|
if (! $board->use_file_upload) {
|
|
return $this->error('sirsoft-board::messages.posts.file_upload_not_allowed', 403);
|
|
}
|
|
|
|
// 파일 업로드 권한 확인
|
|
if (! $this->checkBoardPermission($slug, 'attachments.upload', PermissionType::User)) {
|
|
return $this->forbidden('sirsoft-board::messages.permission.attachments_upload_denied');
|
|
}
|
|
}
|
|
|
|
// 요청 데이터 준비
|
|
$data = $request->validated();
|
|
|
|
// user_id 설정 (인증 필수)
|
|
$data['user_id'] = Auth::id();
|
|
|
|
// IP 주소 설정
|
|
$data['ip_address'] = $request->ip();
|
|
|
|
// secret_mode에 따른 비밀글 설정 검증
|
|
if ($board->secret_mode === SecretMode::Disabled) {
|
|
// 비밀글 기능 비활성화된 경우 is_secret=true 요청 거부
|
|
if (! empty($data['is_secret'])) {
|
|
return $this->error('sirsoft-board::messages.posts.secret_post_not_allowed', 403);
|
|
}
|
|
} elseif ($board->secret_mode === SecretMode::Always) {
|
|
// 비밀글 필수인 경우 자동으로 is_secret=true 설정
|
|
$data['is_secret'] = true;
|
|
}
|
|
// secret_mode='enabled'인 경우 사용자 선택에 따름 (별도 처리 불필요)
|
|
|
|
// 게시글 생성
|
|
$post = $this->postService->createPost($slug, $data);
|
|
|
|
// 쿨다운 캐시 기록 (게시글 생성 성공 후)
|
|
$spamSecurity = g7_module_settings('sirsoft-board', 'spam_security', []);
|
|
$cooldown = (int) ($spamSecurity['post_cooldown_seconds'] ?? 0);
|
|
if ($cooldown > 0) {
|
|
$identifier = Auth::id() ?? $request->ip();
|
|
$this->postService->recordPostCooldown($slug, $identifier, $cooldown);
|
|
}
|
|
|
|
// board 관계 수동 설정
|
|
$post->setRelation('board', $board);
|
|
|
|
return $this->successWithResource(
|
|
'sirsoft-board::messages.posts.create_success',
|
|
new PostResource($post),
|
|
201
|
|
);
|
|
} catch (BoardNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.create_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시글을 수정합니다.
|
|
*
|
|
* @param UpdatePostRequest $request 게시글 수정 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string|int $id 게시글 ID
|
|
* @return JsonResponse 수정된 게시글 응답
|
|
*/
|
|
public function update(UpdatePostRequest $request, string $slug, string|int $id): JsonResponse
|
|
{
|
|
$id = (int) $id;
|
|
|
|
try {
|
|
// 게시판 정보 조회
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// 게시글 조회
|
|
$post = $this->postService->getPost($slug, $id, context: 'user');
|
|
|
|
// 수정 권한 확인
|
|
if (! $this->canModifyPost($post, $request)) {
|
|
return $this->error('sirsoft-board::messages.posts.modify_permission_denied', 403);
|
|
}
|
|
|
|
// 게시글 수정
|
|
$data = $request->validated();
|
|
$post = $this->postService->updatePost($slug, $id, $data);
|
|
|
|
// board 관계 수동 설정
|
|
$post->setRelation('board', $board);
|
|
|
|
return $this->successWithResource(
|
|
'sirsoft-board::messages.posts.update_success',
|
|
new PostResource($post)
|
|
);
|
|
} catch (AccessDeniedHttpException $e) {
|
|
return $this->error('auth.scope_denied', 403);
|
|
} catch (ModelNotFoundException $e) {
|
|
throw new PostNotFoundException($id);
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.update_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시글을 삭제합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string|int $id 게시글 ID
|
|
* @return JsonResponse 삭제 결과 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: DELETE 단건 삭제. force_delete 불리언 플래그만 input()으로 읽음 (검증 불필요)
|
|
public function destroy(Request $request, string $slug, string|int $id): JsonResponse
|
|
{
|
|
$id = (int) $id;
|
|
|
|
try {
|
|
// 게시판 정보 조회
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// 게시글 조회
|
|
$post = $this->postService->getPost($slug, $id, context: 'user');
|
|
|
|
// 삭제 권한 확인
|
|
if (! $this->canModifyPost($post, $request)) {
|
|
return $this->error('sirsoft-board::messages.posts.delete_permission_denied', 403);
|
|
}
|
|
|
|
// 게시글 삭제 (소프트 삭제)
|
|
$this->postService->deletePost($slug, $id, 'user');
|
|
|
|
return $this->success('sirsoft-board::messages.posts.delete_success');
|
|
} catch (AccessDeniedHttpException $e) {
|
|
return $this->error('auth.scope_denied', 403);
|
|
} catch (ModelNotFoundException $e) {
|
|
throw new PostNotFoundException($id);
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.delete_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 비밀글 조회를 위한 비밀번호를 검증합니다.
|
|
*
|
|
* 비밀글의 내용을 조회할 때 사용합니다. (posts.read 권한 필요)
|
|
* 검증 성공 시 게시글 내용과 첨부파일을 포함하여 반환합니다.
|
|
*
|
|
* @param VerifyGuestPasswordRequest $request 비밀번호 검증 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string|int $id 게시글 ID
|
|
* @return JsonResponse 검증 결과 응답 (content, attachments 포함)
|
|
*/
|
|
public function verifyPassword(VerifyGuestPasswordRequest $request, string $slug, string|int $id): JsonResponse
|
|
{
|
|
$id = (int) $id;
|
|
|
|
try {
|
|
// 게시판 정보 조회
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// 게시글 조회 (첨부파일 포함)
|
|
$post = $this->postService->getPostWithCounts($slug, $id);
|
|
$post->setRelation('board', $board);
|
|
|
|
// 비밀번호 검증 (Service 사용)
|
|
$password = $request->validated('password');
|
|
$verifyResult = $this->postService->verifyPassword($post, $password);
|
|
|
|
if (! $verifyResult['success']) {
|
|
return $this->error($verifyResult['error_key'], $verifyResult['error_code']);
|
|
}
|
|
|
|
// 검증 성공 - password_verified 플래그 설정하여 PostResource에서 content 포함
|
|
$post->password_verified = true;
|
|
|
|
// successWithResource 사용: $this->when() 조건부 필드가 올바르게 직렬화됨
|
|
// (toArray 직접 호출 시 MissingValue 객체가 반환되는 문제 방지)
|
|
return $this->successWithResource(
|
|
'sirsoft-board::messages.posts.password_verified',
|
|
new PostResource($post)
|
|
);
|
|
} catch (ModelNotFoundException $e) {
|
|
throw new PostNotFoundException($id);
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.password_verify_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 수정/삭제를 위한 비밀번호를 검증합니다.
|
|
*
|
|
* 게시글 수정 또는 삭제 전 권한 확인용입니다. (posts.write 권한 필요)
|
|
* 검증 성공 시 임시 토큰을 반환합니다.
|
|
*
|
|
* @param VerifyGuestPasswordRequest $request 비밀번호 검증 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string|int $id 게시글 ID
|
|
* @return JsonResponse 검증 결과 응답 (verification_token 포함)
|
|
*/
|
|
public function verifyPasswordForModify(VerifyGuestPasswordRequest $request, string $slug, string|int $id): JsonResponse
|
|
{
|
|
$id = (int) $id;
|
|
|
|
try {
|
|
// 게시판 정보 조회
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// 게시글 조회
|
|
$post = $this->postService->getPost($slug, $id, context: 'user');
|
|
|
|
// 비밀번호 검증 (Service 사용)
|
|
$password = $request->validated('password');
|
|
$verifyResult = $this->postService->verifyPassword($post, $password);
|
|
|
|
if (! $verifyResult['success']) {
|
|
return $this->error($verifyResult['error_key'], $verifyResult['error_code']);
|
|
}
|
|
|
|
// 검증 성공 시 임시 토큰 생성 및 캐시 저장
|
|
$verificationToken = Str::random(32);
|
|
$tokenResult = $this->postService->storeDeleteVerifyToken($slug, $id, $verificationToken);
|
|
|
|
return $this->success(
|
|
'sirsoft-board::messages.posts.password_verified',
|
|
[
|
|
'verified' => true,
|
|
'post_id' => $id,
|
|
'verification_token' => $tokenResult['token'],
|
|
'expires_at' => $tokenResult['expires_at'],
|
|
]
|
|
);
|
|
} catch (ModelNotFoundException $e) {
|
|
throw new PostNotFoundException($id);
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.password_verify_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시글 폼 메타 데이터를 반환합니다.
|
|
*
|
|
* 게시판 설정 정보, 파일 업로드 설정, 카테고리 목록 등 폼 렌더링에 필요한 메타 정보를 반환합니다.
|
|
* 수정 모드 시 기존 첨부파일 정보, 답글 모드 시 원글 정보를 포함합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @return JsonResponse 폼 메타 데이터 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 작성 폼 메타 조회. 경로 파라미터(slug)만 사용 (검증 불필요)
|
|
public function getFormMeta(Request $request, string $slug): JsonResponse
|
|
{
|
|
try {
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
// Admin과 동일하게 BoardResource 사용
|
|
// user_abilities 포함을 위해 include_user_abilities 파라미터 설정
|
|
$request->merge(['include_user_abilities' => true]);
|
|
$boardResource = new BoardResource($board);
|
|
$boardData = $boardResource->toArray($request);
|
|
|
|
// 게시글 폼에서는 게시판 이름을 로컬라이즈된 문자열로 반환
|
|
$boardData['name'] = $board->getLocalizedName();
|
|
|
|
$metaData = [
|
|
'board' => $boardData,
|
|
];
|
|
|
|
// 수정 모드: 작성자 정보, 작성일, 첨부파일 정보 포함
|
|
if ($request->filled('post_id') && $request->get('post_id') !== 'undefined' && $request->get('post_id') !== '') {
|
|
$postId = (int) $request->get('post_id');
|
|
$post = $this->postService->getPost($slug, $postId, context: 'user');
|
|
|
|
// 회원 게시글이고 본인이 아닌 경우 권한 에러
|
|
if ($post->user_id && Auth::id() !== $post->user_id) {
|
|
if (! $this->hasBoardManagePermission($slug)) {
|
|
return $this->error('sirsoft-board::messages.posts.modify_permission_denied', 403);
|
|
}
|
|
}
|
|
|
|
// 비회원 게시글인 경우 비밀번호 확인 필요
|
|
$requiresPassword = ! $post->user_id && $post->password;
|
|
|
|
// verification_token이 유효하면 비밀번호 확인 불필요
|
|
if ($requiresPassword && $request->filled('verification_token')) {
|
|
$token = $request->get('verification_token');
|
|
if ($this->isVerificationTokenValid($slug, $postId, $token)) {
|
|
$requiresPassword = false;
|
|
}
|
|
}
|
|
|
|
$metaData['requires_password'] = $requiresPassword;
|
|
$metaData['is_guest_post'] = ! $post->user_id;
|
|
|
|
// 첨부파일 관계 로드
|
|
$post->load('attachments');
|
|
|
|
// 폼용 경량 변환 (abilities/IP/신고 등 불필요한 권한 체크 생략)
|
|
$postResource = new PostResource($post);
|
|
$postData = $postResource->toFormArray($request);
|
|
|
|
$metaData['author'] = $postData['author'] ?? null;
|
|
$metaData['created_at'] = $postData['created_at'] ?? null;
|
|
$metaData['attachments'] = $postData['attachments'] ?? [];
|
|
|
|
// 수정 시 원글 정보가 있으면 포함
|
|
// 단, 부모글이 블라인드/삭제 상태이면 원문(제목/본문) 노출을 차단
|
|
// — 답글 작성 후 부모가 블라인드/삭제된 경우, 답글 수정 폼에 부모 원문이 새지 않도록 함
|
|
if (! empty($postData['parent']) && ! $this->isParentBlindedOrDeleted($post)) {
|
|
$metaData['parent_post'] = $postData['parent'];
|
|
}
|
|
}
|
|
// 답변글 모드: 원글 정보 포함
|
|
elseif ($request->filled('parent_id') && $request->get('parent_id') !== 'undefined' && $request->get('parent_id') !== '') {
|
|
$parentId = (int) $request->get('parent_id');
|
|
|
|
if (! $board->use_reply) {
|
|
return $this->error('sirsoft-board::validation.post.reply_not_allowed', 403);
|
|
}
|
|
|
|
$parentPost = $this->postService->getPost($slug, $parentId, context: 'user');
|
|
|
|
// 블라인드/삭제된 부모글에는 답글 폼 진입 자체를 차단
|
|
// 제출 단계(ParentPostValidationRule)뿐 아니라 폼 진입 단계에서도 막아
|
|
// 부모글 원문(제목/본문)이 답글 폼에 노출되지 않도록 함
|
|
$this->assertParentReplyable($parentPost, $parentId);
|
|
|
|
$parentPostResource = new PostResource($parentPost);
|
|
$metaData['parent_post'] = $parentPostResource->toFormArray($request);
|
|
}
|
|
|
|
return $this->success('sirsoft-board::messages.posts.form_meta_retrieved', $metaData);
|
|
} catch (ModelNotFoundException $e) {
|
|
throw new PostNotFoundException((int) ($request->get('post_id') ?? $request->get('parent_id') ?? 0));
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.form_meta_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시글 폼 화면용 데이터를 반환합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $slug 게시판 슬러그
|
|
* @return JsonResponse 폼 데이터 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 수정 폼 데이터 조회. 경로 파라미터(slug) + 쿼리만 read-only 참조 (검증 불필요)
|
|
public function getFormData(Request $request, string $slug): JsonResponse
|
|
{
|
|
try {
|
|
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
|
|
if (! $board || ! $board->is_active) {
|
|
throw new BoardNotFoundException($slug);
|
|
}
|
|
|
|
$formData = [];
|
|
|
|
// 수정 모드
|
|
if ($request->filled('post_id') && $request->get('post_id') !== 'undefined' && $request->get('post_id') !== '') {
|
|
$postId = (int) $request->get('post_id');
|
|
$post = $this->postService->getPost($slug, $postId, context: 'user');
|
|
|
|
// 회원 게시글이고 본인이 아니거나, 관리자도 아닌 경우 권한 에러
|
|
if ($post->user_id && Auth::id() !== $post->user_id) {
|
|
if (! $this->hasBoardManagePermission($slug)) {
|
|
return $this->error('sirsoft-board::messages.posts.modify_permission_denied', 403);
|
|
}
|
|
}
|
|
|
|
// 비밀글 또는 비회원 글의 검증 처리
|
|
// 1. verification_token으로 검증 (권장 - 비밀번호 재전송 불필요)
|
|
// 2. password로 검증 (fallback)
|
|
if ($request->filled('verification_token')) {
|
|
$token = $request->get('verification_token');
|
|
if ($this->isVerificationTokenValid($slug, $postId, $token)) {
|
|
$post->password_verified = true;
|
|
}
|
|
} elseif ($request->filled('password') && $post->password) {
|
|
$password = $request->get('password');
|
|
if (Hash::check($password, $post->password)) {
|
|
$post->password_verified = true;
|
|
}
|
|
}
|
|
|
|
// 폼용 경량 변환 (abilities/IP/신고 등 불필요한 권한 체크 생략)
|
|
$postResource = new PostResource($post);
|
|
$postData = $postResource->toFormArray($request);
|
|
|
|
$formData = [
|
|
'id' => $postData['id'] ?? null,
|
|
'title' => $postData['title'] ?? '',
|
|
'content' => $postData['content'] ?? '',
|
|
'content_mode' => $postData['content_mode'] ?? 'text',
|
|
'category' => $postData['category'] ?? null,
|
|
'is_notice' => $postData['is_notice'] ?? false,
|
|
'is_secret' => $postData['is_secret'] ?? false,
|
|
'parent_id' => $postData['parent_id'] ?? null,
|
|
'attachments' => $postData['attachments'] ?? [],
|
|
// 비회원 글 수정 시 verification_token 유지 (PUT 요청에 필요)
|
|
'verification_token' => $request->get('verification_token', ''),
|
|
];
|
|
}
|
|
// 답변글 모드
|
|
elseif ($request->filled('parent_id') && $request->get('parent_id') !== 'undefined' && $request->get('parent_id') !== '') {
|
|
$parentId = (int) $request->get('parent_id');
|
|
|
|
if (! $board->use_reply) {
|
|
return $this->error('sirsoft-board::validation.post.reply_not_allowed', 403);
|
|
}
|
|
$parentPost = $this->postService->getPost($slug, $parentId, context: 'user');
|
|
|
|
// 블라인드/삭제된 부모글에는 답글 폼 진입 자체를 차단
|
|
$this->assertParentReplyable($parentPost, $parentId);
|
|
|
|
$formData = [
|
|
'title' => 'Re: '.$parentPost->title,
|
|
'content' => '',
|
|
'content_mode' => 'text',
|
|
'category' => $parentPost->category ?? null,
|
|
'is_notice' => false,
|
|
'is_secret' => $parentPost->is_secret ?? false,
|
|
'parent_id' => $parentId,
|
|
];
|
|
}
|
|
// 생성 모드
|
|
else {
|
|
$formData = [
|
|
'title' => '',
|
|
'content' => '',
|
|
'content_mode' => 'text',
|
|
'category' => null,
|
|
'is_notice' => false,
|
|
'is_secret' => $board->secret_mode->value === 'always',
|
|
'parent_id' => null,
|
|
];
|
|
}
|
|
|
|
return $this->success('sirsoft-board::messages.posts.form_data_retrieved', $formData);
|
|
} catch (ModelNotFoundException $e) {
|
|
throw new PostNotFoundException((int) ($request->get('post_id') ?? $request->get('parent_id') ?? 0));
|
|
} catch (BoardNotFoundException|PostNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.form_data_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사용자 페이지에서 게시판 관리 권한을 보유했는지 확인합니다.
|
|
*
|
|
* 관리자 페이지 권한(admin.manage)과 사용자 페이지 관리 권한(manager)을
|
|
* 모두 인정합니다. 사용자 페이지 화면은 manager 권한으로 can_manage 를
|
|
* 노출하므로, 실제 처리도 manager 를 인정해야 화면-서버 정합이 맞습니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @return bool 관리 권한 보유 여부
|
|
*/
|
|
private function hasBoardManagePermission(string $slug): bool
|
|
{
|
|
if (! Auth::check()) {
|
|
return false;
|
|
}
|
|
|
|
return $this->checkBoardPermission($slug, 'admin.manage')
|
|
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
|
|
}
|
|
|
|
/**
|
|
* 부모 게시글이 답글 작성 가능한 상태인지 검증합니다.
|
|
*
|
|
* 블라인드/삭제된 부모글에는 답글 폼 진입 단계에서 차단하여,
|
|
* 부모글 원문(제목/본문)이 답글 폼에 노출되지 않도록 합니다.
|
|
* 차단은 PostNotFoundException(404)으로 처리하여 부모글의 존재 자체를 숨깁니다.
|
|
* (제출 단계 차단은 ParentPostValidationRule 이 별도로 수행)
|
|
*
|
|
* @param Post $parentPost 부모 게시글 모델
|
|
* @param int $parentId 부모 게시글 ID (예외 메시지용)
|
|
*
|
|
* @throws PostNotFoundException 부모글이 블라인드/삭제 상태인 경우
|
|
*/
|
|
private function assertParentReplyable(Post $parentPost, int $parentId): void
|
|
{
|
|
if ($parentPost->status === PostStatus::Blinded
|
|
|| $parentPost->status === PostStatus::Deleted
|
|
|| $parentPost->deleted_at !== null) {
|
|
throw new PostNotFoundException($parentId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 답글의 부모 게시글이 블라인드/삭제 상태인지 확인합니다.
|
|
*
|
|
* 답글 수정 폼에서 부모 원문 노출을 차단할지 판단하는 데 사용합니다.
|
|
* parent 관계가 로드되지 않았거나 부모가 없으면 false 를 반환합니다.
|
|
*
|
|
* @param Post $post 답글 게시글 모델
|
|
* @return bool 부모가 블라인드/삭제 상태인지 여부
|
|
*/
|
|
private function isParentBlindedOrDeleted(Post $post): bool
|
|
{
|
|
$parent = $post->parent;
|
|
|
|
if (! $parent) {
|
|
return false;
|
|
}
|
|
|
|
return $parent->status === PostStatus::Blinded
|
|
|| $parent->status === PostStatus::Deleted
|
|
|| $parent->deleted_at !== null;
|
|
}
|
|
|
|
/**
|
|
* 게시글 수정/삭제 권한을 확인합니다.
|
|
*
|
|
* 다음 조건 중 하나라도 만족하면 수정/삭제 가능:
|
|
* - 작성자 본인 (로그인 사용자의 user_id 일치)
|
|
* - 비회원 게시글인 경우 비밀번호 확인
|
|
* - 게시판 관리자 (admin.manage 또는 사용자 페이지 manager 권한)
|
|
* - 시스템 관리자 (Super Admin 역할)
|
|
*
|
|
* @param Post $post 게시글 모델
|
|
* @param Request $request HTTP 요청
|
|
* @return bool 수정/삭제 가능 여부
|
|
*/
|
|
private function canModifyPost($post, Request $request): bool
|
|
{
|
|
$slug = $post->board->slug;
|
|
|
|
// 1. 게시판 관리 권한 확인 (admin.manage 또는 manager)
|
|
if ($this->hasBoardManagePermission($slug)) {
|
|
return true;
|
|
}
|
|
|
|
// 2. 작성자 본인 확인 (회원 게시글)
|
|
if (Auth::check() && $post->user_id && Auth::id() === $post->user_id) {
|
|
return true;
|
|
}
|
|
|
|
// 3. 비회원 게시글인 경우 verification_token 또는 비밀번호로 확인
|
|
if (! $post->user_id && $post->password) {
|
|
// 3-1. verification_token 확인 (권장)
|
|
$token = $request->input('verification_token');
|
|
if ($token && $this->isVerificationTokenValid($slug, $post->id, $token)) {
|
|
return true;
|
|
}
|
|
|
|
// 3-2. 비밀번호 확인 (fallback)
|
|
$password = $request->input('password');
|
|
if ($password && Hash::check($password, $post->password)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Verification Token 헬퍼 메서드
|
|
// =========================================================================
|
|
|
|
/**
|
|
* verification_token이 유효한지 확인하고 소비합니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $postId 게시글 ID
|
|
* @param string $token 검증 토큰
|
|
* @return bool 토큰 유효 여부
|
|
*/
|
|
private function isVerificationTokenValid(string $slug, int $postId, string $token): bool
|
|
{
|
|
return $this->postService->consumeDeleteVerifyToken($slug, $postId, $token);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 사용자 공개 게시글 조회 메서드 (공개 프로필용)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 특정 사용자의 공개 게시글 목록을 조회합니다.
|
|
*
|
|
* 게시판 슬러그 없이 모든 게시판에서 해당 사용자의 공개 게시글을 조회합니다.
|
|
* 비밀글은 제외됩니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param User $user 사용자 모델 (Route Model Binding, uuid 기반)
|
|
* @return JsonResponse 게시글 목록 (페이지네이션)
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 사용자별 글 목록. per_page/sort 페이징 파라미터만 input()으로 읽음 (검증 불필요)
|
|
public function userPosts(Request $request, User $user): JsonResponse
|
|
{
|
|
try {
|
|
$perPage = (int) $request->input('per_page', 20);
|
|
$perPage = min(max($perPage, 1), 100);
|
|
|
|
$filters = [
|
|
'sort' => $request->input('sort', 'latest'),
|
|
];
|
|
|
|
$result = $this->postService->getUserPublicPosts($user->id, $filters, $perPage);
|
|
|
|
return $this->success('sirsoft-board::messages.posts.fetch_success', $result);
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.fetch_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 사용자의 게시글/댓글 통계를 조회합니다.
|
|
*
|
|
* 공개 프로필 페이지에서 사용됩니다.
|
|
* status=published인 게시글/댓글만 카운트합니다.
|
|
*
|
|
* @param User $user 사용자 모델 (Route Model Binding, uuid 기반)
|
|
* @return JsonResponse 통계 정보
|
|
*/
|
|
public function userPostsStats(User $user): JsonResponse
|
|
{
|
|
try {
|
|
$stats = $this->postService->getUserPublicStats($user->id);
|
|
|
|
return $this->success('common.success', $stats);
|
|
} catch (\Exception $e) {
|
|
return $this->error('sirsoft-board::messages.posts.fetch_failed', 500, $e->getMessage());
|
|
}
|
|
}
|
|
}
|