빌더 피드 QA 수정 및 댓글 기능 추가#662
Conversation
- GNB 마이 클래스 링크 /my-page → /my-class 수정 - 완료 레슨 도장 핑크 활성화, blink 제거 - 완료-완료 커넥터 라인 핑크 실선, 레슨 order 정렬 - 스크린샷 5MB 초과 토스트 안내 추가 - S3 이미지 업로드 FormData → raw file body 수정 (무한로딩 해결) - 레슨/질문 상세 탭바 sticky 위치 수정 - 유용해요/나도궁금해요 배타적 토글 버그 수정 - 운영진 아닌 사용자 답변하기 floating 버튼 차단 - 마크다운 에디터 ctrl+v 단일 줄 패턴 삽입 시 줄바꿈 생성 버그 수정 - QnA 탭 필터 chip UI Figma 디자인 통일 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gray-400(#a4a7ae)이 Figma 지정값 #999와 불일치. gray-450 토큰 추가 후 feed-tab, qna-tab 양쪽 적용. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
운영자 PICK 칩은 Figma 설계에 없어 제거. rounded-875(토큰 없음) → rounded-full로 수정. feed/qna 두 탭 모두 적용. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OPERATOR_PICK probe 쿼리로 totalCount 확인 후 feeds가 있을 때만 칩 렌더링. 없으면 전체/내 피드만 표시. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
roleIds가 UseAuthReturn이 아닌 DecodedToken(data) 안에 있음. data?.roleIds로 접근하도록 수정. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTML 태그 없는 평문 content를 dangerouslySetInnerHTML로 렌더링 시 \n이 공백으로 처리되어 줄바꿈이 사라지던 문제 수정. isHtml 판별 후 평문은 whitespace-pre-wrap + React 텍스트 노드로 렌더링. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
📝 WalkthroughWalkthrough이 PR은 학습 플랫폼의 피드 및 QnA 기능을 강화합니다. 아티팩트 URL을 피드 응답 타입에 추가하고, 이미지/링크로 조건부 렌더링합니다. 피드 댓글에 답글 시스템과 신고 기능을 추가하며, QnA 페이지에서 HTML 렌더링 안전성을 개선하고 관리자 권한 제어를 도입합니다. 레슨 완료 처리, 동적 필터, UI 개선, 마크다운 렌더링 최적화를 함께 포함합니다. Changes학습 플랫폼 기능 강화
AI 코드 규칙 추가
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… + 아이콘 적용 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx (1)
69-87:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winHTML 여부만 판별하고 raw HTML을 그대로 주입하면 저장형 XSS가 열립니다.
/<[a-z]/i에 걸린 사용자 입력이 그대로dangerouslySetInnerHTML로 들어갑니다. QnA 내용이 사용자 작성 데이터라면<img onerror=...>같은 페이로드도 렌더링되므로, 여기서는 HTML 여부 판별이 아니라 sanitize 보장이 필요합니다. 서버에서 정제된 HTML만 받도록 계약을 두거나, 클라이언트에서 정제한 결과만 주입해주세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx around lines 69 - 87, HtmlContent currently detects HTML via /<[a-z]/i and injects user input into dangerouslySetInnerHTML (in HtmlContent), which allows stored XSS; fix by sanitizing the HTML before injection (either require server to return pre-sanitized HTML or run a sanitizer on the client inside HtmlContent), e.g. call a vetted sanitizer (DOMPurify or sanitize-html) to strip unsafe tags/attributes/event handlers and only allow an explicit safe list, then pass the sanitized string to dangerouslySetInnerHTML (or avoid dangerouslySetInnerHTML entirely and render text when HTML is not trusted).src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx (1)
46-63:⚠️ Potential issue | 🔴 Critical | ⚡ Quick win여기도 HTML 분기 후 raw 주입이 그대로 남아 있어서 XSS 위험이 같습니다.
텍스트 분기 추가로 줄바꿈 문제는 해결되지만, 태그가 포함된 입력은 여전히 정제 없이
dangerouslySetInnerHTML로 렌더링됩니다. 상세 페이지도 동일하게 sanitize된 HTML만 주입하도록 맞춰야 합니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(landing)/class/[slug]/(learning)/qa/[id]/page.tsx around lines 46 - 63, HtmlContent currently detects HTML and directly uses dangerouslySetInnerHTML, which leaves an XSS surface; update HtmlContent to sanitize the html string before passing it to dangerouslySetInnerHTML (e.g., call a shared sanitizeHtml or DOMPurify.sanitize utility) and use the sanitized output in both the HTML branch and any detail page rendering that also uses dangerouslySetInnerHTML so all injected HTML is cleaned consistently; reference the HtmlContent component and the dangerouslySetInnerHTML usage and ensure you add/ reuse a sanitizeHtml helper and import it where needed.
🧹 Nitpick comments (3)
src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx (1)
111-115: 💤 Low value
noUncheckedIndexedAccess환경 대비 안전한 인덱스 접근 권장.
allLessons[allLessons.length - 1].lessonId접근은 길이 체크로 런타임 안전성은 확보되지만, TS의noUncheckedIndexedAccess옵션이 켜져 있으면 타입 에러가 납니다.at(-1)을 사용하거나 옵셔널 체이닝으로 방어해 두면 향후 옵션 변경에도 안전합니다.♻️ 제안 변경
const isLastLesson = useMemo(() => { const allLessons = drawerChapters.flatMap((c) => c.lessons); - if (allLessons.length === 0) return false; - return allLessons[allLessons.length - 1].lessonId === lessonId; + return allLessons.at(-1)?.lessonId === lessonId; }, [drawerChapters, lessonId]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(class-lesson)/class/[slug]/lesson/[id]/page.tsx around lines 111 - 115, The isLastLesson memo computes allLessons from drawerChapters and then indexes allLessons[allLessons.length - 1].lessonId which triggers TypeScript errors under noUncheckedIndexedAccess; update the check to safely access the last element (e.g., use allLessons.at(-1) with optional chaining or check last = allLessons[allLessons.length - 1] and use last?.lessonId) so the return becomes a boolean without unsafe indexing; modify the isLastLesson callback (and any references to allLessons indexing) to use optional access to the last element while preserving the existing length check and dependency list [drawerChapters, lessonId].src/components/common/ui/rich-text/markdown-content-core.tsx (1)
215-223: 💤 Low value삼항식 내 IIFE 패턴을 단순화 고려
marked.parse의 반환 타입 좁히기를 위해 즉시 실행 함수를 삼항 연산자 내부에 두면 가독성이 떨어집니다.marked.parse는 기본 옵션에서 동기 호출이므로 반환이 string임을 안전하게 가정할 수 있고, 별도 헬퍼나 사전 분기로 추출하면 의도가 더 명확해집니다.♻️ 제안 리팩토링 예시
- const html = isHtmlContent(contentWithEmbeds) - ? contentWithEmbeds - : (() => { - const rendered = marked.parse(contentWithEmbeds, { - breaks: true, - gfm: true, - }); - return typeof rendered === 'string' ? rendered : ''; - })(); + let html: string; + if (isHtmlContent(contentWithEmbeds)) { + html = contentWithEmbeds; + } else { + const rendered = marked.parse(contentWithEmbeds, { + breaks: true, + gfm: true, + }); + html = typeof rendered === 'string' ? rendered : ''; + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/common/ui/rich-text/markdown-content-core.tsx` around lines 215 - 223, The ternary currently embeds an IIFE to narrow marked.parse's return type, which reduces readability; instead, precompute the rendered markdown before the ternary (e.g., call marked.parse(contentWithEmbeds, { breaks: true, gfm: true }) into a local variable like rendered or extract a helper function renderMarkdown) and then use that variable in the assignment to html together with isHtmlContent(contentWithEmbeds); ensure you coerce or default the parsed value to a string (e.g., fallback to '') so html gets a string in all cases.e2e/class/builder-feed.spec.ts (1)
101-117: ⚡ Quick win새 아티팩트 분기(image/link)도 이 스펙에서 한 번은 태워 주세요.
지금 fixture는
artifactUrl: null만 넣어서 이번 PR의 핵심인 아티팩트 렌더링 분기를 전혀 검증하지 못합니다. 이미지 URL 1개와 일반 링크 1개 케이스를 각각 추가해 두면 회귀를 훨씬 잘 막을 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/class/builder-feed.spec.ts` around lines 101 - 117, The fixture returned by makeFeedDetail always sets artifactUrl: null and doesn't exercise the new artifact rendering branches; update makeFeedDetail (or add two variants like makeFeedDetailWithImage and makeFeedDetailWithLink) so tests include one feed where content.imageUrls contains at least one image URL and another where artifactUrl is a non-null link string, ensuring the feedId/content structure and author fields remain identical to the existing makeFeedDetail shape so downstream assertions still work.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx:
- Around line 25-27: The current isImageUrl(url: string) in
lesson-builder-feed-detail-modal.tsx only checks file extensions and lets
feed.artifactUrl flow directly into Next <Image>, which can fail if the host is
not allowed by next.config.remotePatterns; create a shared utility (e.g.,
isAllowedImageUrl or validateArtifactImage) that (1) checks extension (reuse
isImageUrl), (2) verifies the URL host against the existing
COMMUNITY_ALLOWED_IMAGE_HOSTS list, and (3) returns a normalized result/flag so
both lesson-builder-feed-detail-modal.tsx and feed/[id]/page.tsx can use it to
conditionally render <Image src=...> only when allowed and otherwise render a
safe <a> fallback; update both components to call this util instead of using
isImageUrl directly.
In `@src/app/`(class-lesson)/class/[slug]/lesson/[id]/page.tsx:
- Around line 111-115: The button text logic currently uses isLastLesson
(computed from drawerChapters and lessonId) which can mismatch the actual
routing controlled by data.isCourseCompleted; update the button text decision to
rely on a server-provided field (e.g., data.wouldCompleteCourse /
wouldCompleteCourse) when available, falling back to checking for any incomplete
prerequisite lessons (inspect drawerChapters order and their lessons' completion
flags) before showing the "완주하고 축하 페이지로 가기" copy; adjust references in the
component so the UI uses wouldCompleteCourse (or the computed
"noIncompletePredecessors" boolean) instead of only isLastLesson, and ensure
data.isCourseCompleted remains the final routing guard.
In `@src/app/`(landing)/class/[slug]/(learning)/_components/feed-tab.tsx:
- Around line 73-77: When hasOperatorPick becomes false ensure the selected
filter state cannot remain '운영자 PICK': add an effect in the component that
watches hasOperatorPick and the current filter (the state variable named filter
and its setter setFilter) and, if hasOperatorPick === false and filter === '운영자
PICK', call setFilter('전체') to reset it; implement this logic near the
FeedFilter-related code (e.g., alongside filterOptions and where filter is
defined) so the UI chip list and filter state stay in sync.
In `@src/app/`(landing)/class/[slug]/(learning)/_components/qna-tab.tsx:
- Around line 201-204: The arbitrary hex Tailwind classes used for the answered
badge in qna-tab.tsx (the ternary branch that yields 'border-[`#02c76e`]
bg-[`#dafbe7`] text-[`#02c76e`]') must be replaced with the project's semantic token
classes from global.css; locate the conditional on q.answerStatus === 'ANSWERED'
and swap each arbitrary value (border, bg, text) for the corresponding semantic
token classes (e.g., the project's success-border, success-bg, success-text
token names) so the badge uses defined tokens instead of hex arbitrary values;
ensure you replace all three occurrences (border, background, text) and keep the
surrounding static classes ('flex items-center gap-50 rounded-full px-250 py-125
font-designer-14m') intact.
In `@src/app/`(landing)/class/[slug]/(learning)/feed/[id]/page.tsx:
- Around line 435-455: The code currently determines operator status by
comparing c.author.role to the Korean label string '운영진', which is inconsistent
with the rest of the codebase that uses a shared role enum/labels; import and
use the canonical ROLE_LABELS (or the shared role constant used elsewhere) and
change the check in the isOperator assignment from c.author.role === '운영진' to
compare against the source-of-truth value (e.g., c.author.role ===
ROLE_LABELS.OPERATOR or ROLE_LABELS['operator'] depending on how ROLE_LABELS is
exported), and make the identical change in the other place in this file where
operator styling is computed for replies so both usages use the same
ROLE_LABELS-based comparison.
---
Outside diff comments:
In
`@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx:
- Around line 69-87: HtmlContent currently detects HTML via /<[a-z]/i and
injects user input into dangerouslySetInnerHTML (in HtmlContent), which allows
stored XSS; fix by sanitizing the HTML before injection (either require server
to return pre-sanitized HTML or run a sanitizer on the client inside
HtmlContent), e.g. call a vetted sanitizer (DOMPurify or sanitize-html) to strip
unsafe tags/attributes/event handlers and only allow an explicit safe list, then
pass the sanitized string to dangerouslySetInnerHTML (or avoid
dangerouslySetInnerHTML entirely and render text when HTML is not trusted).
In `@src/app/`(landing)/class/[slug]/(learning)/qa/[id]/page.tsx:
- Around line 46-63: HtmlContent currently detects HTML and directly uses
dangerouslySetInnerHTML, which leaves an XSS surface; update HtmlContent to
sanitize the html string before passing it to dangerouslySetInnerHTML (e.g.,
call a shared sanitizeHtml or DOMPurify.sanitize utility) and use the sanitized
output in both the HTML branch and any detail page rendering that also uses
dangerouslySetInnerHTML so all injected HTML is cleaned consistently; reference
the HtmlContent component and the dangerouslySetInnerHTML usage and ensure you
add/ reuse a sanitizeHtml helper and import it where needed.
---
Nitpick comments:
In `@e2e/class/builder-feed.spec.ts`:
- Around line 101-117: The fixture returned by makeFeedDetail always sets
artifactUrl: null and doesn't exercise the new artifact rendering branches;
update makeFeedDetail (or add two variants like makeFeedDetailWithImage and
makeFeedDetailWithLink) so tests include one feed where content.imageUrls
contains at least one image URL and another where artifactUrl is a non-null link
string, ensuring the feedId/content structure and author fields remain identical
to the existing makeFeedDetail shape so downstream assertions still work.
In `@src/app/`(class-lesson)/class/[slug]/lesson/[id]/page.tsx:
- Around line 111-115: The isLastLesson memo computes allLessons from
drawerChapters and then indexes allLessons[allLessons.length - 1].lessonId which
triggers TypeScript errors under noUncheckedIndexedAccess; update the check to
safely access the last element (e.g., use allLessons.at(-1) with optional
chaining or check last = allLessons[allLessons.length - 1] and use
last?.lessonId) so the return becomes a boolean without unsafe indexing; modify
the isLastLesson callback (and any references to allLessons indexing) to use
optional access to the last element while preserving the existing length check
and dependency list [drawerChapters, lessonId].
In `@src/components/common/ui/rich-text/markdown-content-core.tsx`:
- Around line 215-223: The ternary currently embeds an IIFE to narrow
marked.parse's return type, which reduces readability; instead, precompute the
rendered markdown before the ternary (e.g., call marked.parse(contentWithEmbeds,
{ breaks: true, gfm: true }) into a local variable like rendered or extract a
helper function renderMarkdown) and then use that variable in the assignment to
html together with isHtmlContent(contentWithEmbeds); ensure you coerce or
default the parsed value to a string (e.g., fallback to '') so html gets a
string in all cases.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 874b75d9-dbfd-4a06-b3a3-d57351892865
⛔ Files ignored due to path filters (1)
public/class/qna-answered-icon.svgis excluded by!**/*.svg
📒 Files selected for processing (18)
.claude/rules/no-lucide-icon-substitution.mde2e/class/builder-feed.spec.tssrc/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsxsrc/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsxsrc/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-review-form.tsxsrc/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-screenshot-modal.tsxsrc/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsxsrc/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsxsrc/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsxsrc/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsxsrc/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsxsrc/app/global.csssrc/components/common/layout/home-header-client.tsxsrc/components/common/ui/editor/markdown-editor.tsxsrc/components/common/ui/rich-text/markdown-content-core.tsxsrc/components/pages/class/lesson-stamp.tsxsrc/components/pages/class/roadmap-tab.tsxsrc/types/api/course.types.ts
| function isImageUrl(url: string): boolean { | ||
| return /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== next.config.* ==="
fd -i 'next.config.*' -x sh -c 'echo "--- $1 ---"; sed -n "1,240p" "$1"' sh {}
echo
echo "=== existing image-url guards / remotePatterns references ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'AllowedImageUrl|is.*AllowedImageUrl|remotePatterns' src
echo
echo "=== builder-feed image render call sites ==="
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl|imageUrls|<Image' src/app src/componentsRepository: code-zero-to-one/study-platform-client
Length of output: 6993
🏁 Script executed:
#!/bin/bash
set -uo pipefail
echo "=== existing image-url guards / remotePatterns references (non-fatal) ==="
# rg returns 1 when no matches; don't fail
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'AllowedImageUrl|is.*AllowedImageUrl|remotePatterns|next/image|<Image' src || true
echo
echo "=== locate isImageUrl helper usages ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'function isImageUrl|isImageUrl\(' src || true
echo
echo "=== builder-feed image render call sites (artifactUrl + Image) ==="
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl' src/app/(class-lesson)/class/\[slug\]/lesson/\[id\]/_components/lesson-builder-feed-detail-modal.tsx || true
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl' src/app/(landing)/class/\[slug\]/\(learning\)/feed/\[id\]/page.tsx || true
rg -n --glob '**/*.{ts,tsx}' '<Image' src/app/(class-lesson)/class/\[slug\]/lesson/\[id\]/_components/lesson-builder-feed-detail-modal.tsx src/app/(landing)/class/\[slug\]/\(learning\)/feed/\[id\]/page.tsx || true
echo
echo "=== show next.config images.remotePatterns ==="
node -e "const cfg=require('./next.config.ts'); console.log(Object.keys(cfg));" 2>/dev/null || true
sed -n '1,220p' next.config.ts | sed -n '/images:/,/experimental:/p' | sed -n '1,140p'Repository: code-zero-to-one/study-platform-client
Length of output: 21184
🏁 Script executed:
#!/bin/bash
set -uo pipefail
echo "=== lesson-builder-feed-detail-modal.tsx (around isImageUrl + artifact render) ==="
FILE="src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx"
nl -ba "$FILE" | sed -n '1,140p'
echo
echo "=== landing feed page.tsx (around artifact render) ==="
FILE2="src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx"
nl -ba "$FILE2" | sed -n '1,220p'
echo
echo "=== any shared utilities around image url validation ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' '(image.*(url|host|domain|remote)|remotePatterns|AllowedImage)' src/utils src/components src/hooks || trueRepository: code-zero-to-one/study-platform-client
Length of output: 2106
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE1="src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx"
FILE2="src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx"
echo "=== lesson-builder-feed-detail-modal.tsx: isImageUrl + artifact render around ==="
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE1" | sed -n '15,160p'
echo
echo "=== landing feed page.tsx: isImageUrl + artifact render around ==="
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE2" | sed -n '1,120p'
echo "----"
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE2" | sed -n '300,430p'
echo
echo "=== next.config.ts images.remotePatterns hosts (condensed) ==="
sed -n '/images:/,/experimental:/p' next.config.ts | sed -n '1,200p' | rg -n 'hostname|protocol|pathname'
echo
echo "=== URL guard utilities search (host/allowed) ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'isAllowedUrl|allowed.*host|host.*allow|remotePatterns|nextConfig\.images\.remotePatterns|image.*(host|domain)' src || true
echo
echo "=== where feed.artifactUrl is rendered via next/image ==="
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl|<Image' "$FILE1" "$FILE2"Repository: code-zero-to-one/study-platform-client
Length of output: 21810
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/features/community/ui/community-qna-question-card.tsx"
echo "=== community-qna-question-card.tsx: allowed image host guard around matching lines ==="
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE" | sed -n '1,140p'
echo
echo "=== grep for COMMUNITY_ALLOWED_IMAGE_HOSTS declaration ==="
rg -n 'COMMUNITY_ALLOWED_IMAGE_HOSTS' "$FILE" || true
echo
echo "=== src/utils for remote image host allowlist/util ==="
rg -n --glob '**/*.{ts,tsx}' '(ALLOWED.*HOST|ALLOWED.*IMAGE_HOST|IMAGE.*HOST.*ALLOWED|COMMUNITY_ALLOWED_IMAGE_HOSTS|isAllowedUrl\(|hasAllowedImageUrl|allowed.*image.*host)' src || trueRepository: code-zero-to-one/study-platform-client
Length of output: 6612
아티팩트 feed.artifactUrl는 Next <Image>에 넣기 전에 공통 허용 호스트/URL 가드를 거치게 해주세요.
lesson-builder-feed-detail-modal.tsx와feed/[id]/page.tsx에서isImageUrl()은 확장자만 검사하고(.jpg/.png/...)feed.artifactUrl을 그대로<Image src={feed.artifactUrl} />에 넣고 있어요.next.config.ts의images.remotePatterns는 특정 호스트들만 허용하지만, 아티팩트 URL 호스트가 그 목록 밖이면 Next<Image>가 src를 거부해 렌더 실패가 날 수 있습니다.- 두 화면 모두 같은 분기(
artifactUrl→<Image>vs<a>) 로직이 중복되어 있어 공통 유틸로 “허용 호스트 + 이미지 판별 + fallback(허용 X면<a>처리)”을 묶는 편이 안전합니다. - 커뮤니티 쪽에는 허용 호스트 기반 가드(
COMMUNITY_ALLOWED_IMAGE_HOSTS)가 존재하지만, 아티팩트 경로에서는 재사용되지 않습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx
around lines 25 - 27, The current isImageUrl(url: string) in
lesson-builder-feed-detail-modal.tsx only checks file extensions and lets
feed.artifactUrl flow directly into Next <Image>, which can fail if the host is
not allowed by next.config.remotePatterns; create a shared utility (e.g.,
isAllowedImageUrl or validateArtifactImage) that (1) checks extension (reuse
isImageUrl), (2) verifies the URL host against the existing
COMMUNITY_ALLOWED_IMAGE_HOSTS list, and (3) returns a normalized result/flag so
both lesson-builder-feed-detail-modal.tsx and feed/[id]/page.tsx can use it to
conditionally render <Image src=...> only when allowed and otherwise render a
safe <a> fallback; update both components to call this util instead of using
isImageUrl directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Problem
QA 과정에서 발견된 빌더 피드 관련 버그 다수와, 댓글/대댓글 UI가 미구현 상태였다.
주요 문제:
marked.parse()가 TipTap HTML의<p>태그를 CommonMark type 6 HTML block으로 처리, 빈 줄에서 block 종료 후 코드 내용을 일반 단락으로 파싱dangerouslySetInnerHTML로 유실Solution
isHtmlContent()로 TipTap HTML 여부 판별 → HTML이면marked.parse()건너뛰고 DOMPurify 직접 sanitizefeed.artifactUrl이미지/링크 분기 렌더링 추가whitespace-pre-wrap으로 React text node 렌더링Changes
Features
src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsxBug Fixes
src/components/common/ui/rich-text/markdown-content-core.tsxsrc/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsxsrc/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsxsrc/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsxsrc/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsxsrc/types/api/course.types.tsResult
<pre><code>블록으로 정상 렌더링, highlight.js 문법 강조 적용Screenshots
<pre>블록으로 정상 렌더링Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선사항
UI/UX