Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
488 changes: 50 additions & 438 deletions src/domain/admin/blog/BlogCreatePage.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/domain/admin/blog/BlogEditPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';

import { BlogSchema } from '@/api/blog/blogSchema';
import { useBlogEditForm } from '@/domain/admin/blog/hooks/useBlogEditForm';
import BlogActionButtons from '@/domain/admin/blog/section/BlogActionButtons';
import BlogBasicInfoSection from '@/domain/admin/blog/section/BlogBasicInfoSection';
import BlogProgramRecommendSection from '@/domain/admin/blog/section/BlogProgramRecommendSection';
import BlogPublishDateSection from '@/domain/admin/blog/section/BlogPublishDateSection';
import BlogRecommendSection from '@/domain/admin/blog/section/BlogRecommendSection';
import BlogTagSection from '@/domain/admin/blog/section/BlogTagSection';
import { useBlogEditForm } from '@/domain/admin/blog/hooks/useBlogEditForm';
import EditorApp from '@/domain/admin/lexical/EditorApp';
import { useRouter } from 'next/navigation';

Expand Down
108 changes: 108 additions & 0 deletions src/domain/admin/blog/hooks/useBlogCreateForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { usePostBlogMutation } from '@/api/blog/blog';
import {
BlogContent,
ProgramRecommendItem,
TagDetail,
} from '@/api/blog/blogSchema';
import { useAdminSnackbar } from '@/hooks/useAdminSnackbar';
import dayjs from '@/lib/dayjs';
import { Dayjs } from 'dayjs';
import { ChangeEvent, useState } from 'react';

interface CreateBlog {
title: string;
category: string;
thumbnail: string;
description: string;
ctaLink: string;
ctaText: string;
tagList: TagDetail[];
}

const RECOMMEND_SLOT_COUNT = 4;

const initialBlog: CreateBlog = {
title: '',
category: '',
thumbnail: '',
description: '',
ctaLink: '',
ctaText: '',
tagList: [],
};

const initialContent: BlogContent = {
programRecommend: Array(RECOMMEND_SLOT_COUNT).fill({ id: null }),
blogRecommend: new Array(RECOMMEND_SLOT_COUNT).fill(null),
};

export const useBlogCreateForm = () => {
const { snackbar: setSnackbar } = useAdminSnackbar();
const createBlogMutation = usePostBlogMutation();

const [editingValue, setEditingValue] = useState<CreateBlog>(initialBlog);
const [dateTime, setDateTime] = useState<Dayjs | null>(null);
const [content, setContent] = useState<BlogContent>(initialContent);

const onChangeField = (event: ChangeEvent<HTMLInputElement>) => {
setEditingValue((prev) => ({
...prev,
[event.target.name]: event.target.value,
}));
};

const onChangeCategory = (category: string) => {
setEditingValue((prev) => ({ ...prev, category }));
};

const onChangeThumbnail = (url: string) => {
setEditingValue((prev) => ({ ...prev, thumbnail: url }));
};

const onChangeTagList = (tagList: TagDetail[]) => {
setEditingValue((prev) => ({ ...prev, tagList }));
};

const onChangeProgramRecommend = (items: ProgramRecommendItem[]) => {
setContent((prev) => ({ ...prev, programRecommend: items }));
};

const onChangeBlogRecommend = (items: (number | null)[]) => {
setContent((prev) => ({ ...prev, blogRecommend: items }));
};

const onChangeEditor = (jsonString: string) => {
setContent((prev) => ({ ...prev, lexical: jsonString }));
};

const postBlog = async (isPublish: boolean) => {
const displayDate = isPublish && !dateTime
? dayjs().format('YYYY-MM-DDTHH:mm')
: (dateTime?.format('YYYY-MM-DDTHH:mm') ?? '');

await createBlogMutation.mutateAsync({
...editingValue,
content: JSON.stringify(content),
isDisplayed: isPublish,
tagList: editingValue.tagList.map((tag) => tag.id),
displayDate,
});

setSnackbar('블로그가 생성되었습니다.');
};

return {
editingValue,
dateTime,
content,
onChangeField,
onChangeCategory,
onChangeThumbnail,
onChangeTagList,
onChangeProgramRecommend,
onChangeBlogRecommend,
onChangeEditor,
setDateTime,
postBlog,
};
};
6 changes: 3 additions & 3 deletions src/domain/admin/blog/section/BlogActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ interface BlogActionButtonsProps {
onCancel: () => void;
onSaveTemp: () => void;
onPublish: () => void;
helperText?: string;
}

const BlogActionButtons = ({
onCancel,
onSaveTemp,
onPublish,
helperText = '*임시 저장: 블로그가 숨겨집니다.',
}: BlogActionButtonsProps) => {
return (
<div className="text-right">
Expand All @@ -34,9 +36,7 @@ const BlogActionButtons = ({
발행
</Button>
</div>
<span className="text-0.875 text-neutral-35">
*임시 저장: 블로그가 숨겨집니다.
</span>
<span className="text-0.875 text-neutral-35">{helperText}</span>
</div>
);
};
Expand Down
16 changes: 11 additions & 5 deletions src/domain/admin/lexical/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -975,15 +975,20 @@ i.page-break,
text-align: center;
}

.editor-shell .editor-image .image-caption-button {
display: block;
.editor-shell .editor-image .image-action-buttons {
display: flex;
gap: 8px;
position: absolute;
bottom: 20px;
left: 0;
right: 0;
width: 30%;
justify-content: center;
}

.editor-shell .editor-image .image-caption-button,
.editor-shell .editor-image .image-link-button {
display: block;
padding: 10px;
margin: 0 auto;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.5);
Expand All @@ -993,7 +998,8 @@ i.page-break,
user-select: none;
}

.editor-shell .editor-image .image-caption-button:hover {
.editor-shell .editor-image .image-caption-button:hover,
.editor-shell .editor-image .image-link-button:hover {
background-color: rgba(60, 132, 244, 0.5);
}

Expand Down
44 changes: 43 additions & 1 deletion src/domain/admin/lexical/nodes/ImageComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
import './ImageNode.css';

import { HashtagNode } from '@lexical/hashtag';
import { LinkNode } from '@lexical/link';
import { $createLinkNode, $isLinkNode, LinkNode } from '@lexical/link';
import { useCollaborationContext } from '@lexical/react/LexicalCollaborationContext';
import { CollaborationPlugin } from '@lexical/react/LexicalCollaborationPlugin';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
Expand Down Expand Up @@ -367,6 +367,47 @@ export default function ImageComponent({
});
};

const handleLinkClick = () => {
// 현재 링크 URL 읽기
let currentUrl = '';
editor.getEditorState().read(() => {
const node = $getNodeByKey(nodeKey);
const parent = node?.getParent();
if (parent && $isLinkNode(parent)) {
currentUrl = parent.getURL();
}
});

const url = window.prompt('링크 URL을 입력하세요', currentUrl);
if (url === null) return;

editor.update(() => {
const node = $getNodeByKey(nodeKey);
if (!node) return;
const parent = node.getParent();
if (!parent) return;

if (url === '') {
// 링크 제거: LinkNode를 벗기고 자식을 꺼냄
if ($isLinkNode(parent)) {
const children = parent.getChildren();
for (const child of children) {
parent.insertBefore(child);
}
parent.remove();
}
} else if ($isLinkNode(parent)) {
// 기존 링크 URL 수정
parent.setURL(url);
} else {
// 새 링크 추가: LinkNode로 이미지를 감쌈
const linkNode = $createLinkNode(url);
node.insertBefore(linkNode);
linkNode.append(node);
}
Comment on lines +381 to +407

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

A stored Cross-Site Scripting (XSS) vulnerability exists in the handleLinkClick function. The application uses window.prompt to get a URL from the user to add a link to an image. The input from this prompt is not sanitized before being used to create or update a LinkNode. An attacker can input a malicious URL containing a javascript: payload (e.g., javascript:alert('XSS')). This payload is then stored as part of the editor's content, leading to arbitrary code execution when clicked. Beyond the security risk, using window.prompt negatively impacts user experience due to inconsistent UI and lack of styling options. It also makes it difficult to add robust URL validation. It is strongly recommended to replace window.prompt with a custom modal (e.g., using useModal) for a consistent UI/UX and to integrate proper URL validation, similar to validateUrl in LinkPlugin, to prevent such vulnerabilities.

    const url = window.prompt('링크 URL을 입력하세요', currentUrl);
    if (url === null) return;

    // TODO: Import a URL sanitization function
    const sanitizedUrl = new URL(url).href.startsWith('javascript:') ? '' : url;

    editor.update(() => {
      const node = $getNodeByKey(nodeKey);
      if (!node) return;
      const parent = node.getParent();
      if (!parent) return;

      if (sanitizedUrl === '') {
        // 링크 제거: LinkNode를 벗기고 자식을 꺼냄
        if ($isLinkNode(parent)) {
          const children = parent.getChildren();
          for (const child of children) {
            parent.insertBefore(child);
          }
          parent.remove();
        }
      } else if ($isLinkNode(parent)) {
        // 기존 링크 URL 수정
        parent.setURL(sanitizedUrl);
      } else {
        // 새 링크 추가: LinkNode로 이미지를 감쌈
        const linkNode = $createLinkNode(sanitizedUrl);
        node.insertBefore(linkNode);
        linkNode.append(node);
      }
    });

});
};

const onResizeEnd = (
nextWidth: 'inherit' | number,
nextHeight: 'inherit' | number,
Expand Down Expand Up @@ -474,6 +515,7 @@ export default function ImageComponent({
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
captionsEnabled={!isLoadError && captionsEnabled}
onLinkClick={handleLinkClick}
/>
)}
</>
Expand Down
2 changes: 1 addition & 1 deletion src/domain/admin/lexical/themes/PlaygroundEditorTheme.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
text-align: right;
}
.PlaygroundEditorTheme__paragraph {
margin: 0;
margin: 0 0 1rem 0;
position: relative;
font-weight: 300;
}
Expand Down
31 changes: 21 additions & 10 deletions src/domain/admin/lexical/ui/ImageResizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default function ImageResizer({
showCaption,
setShowCaption,
captionsEnabled,
onLinkClick,
}: {
editor: LexicalEditor;
buttonRef: {current: null | HTMLButtonElement};
Expand All @@ -43,6 +44,7 @@ export default function ImageResizer({
setShowCaption: (show: boolean) => void;
showCaption: boolean;
captionsEnabled: boolean;
onLinkClick?: () => void;
}): JSX.Element {
const controlWrapperRef = useRef<HTMLDivElement>(null);
const userSelect = useRef({
Expand Down Expand Up @@ -253,16 +255,25 @@ export default function ImageResizer({
};
return (
<div ref={controlWrapperRef}>
{!showCaption && captionsEnabled && (
<button
className="image-caption-button"
ref={buttonRef}
onClick={() => {
setShowCaption(!showCaption);
}}>
Add Caption
</button>
)}
<div className="image-action-buttons">
{!showCaption && captionsEnabled && (
<button
className="image-caption-button"
ref={buttonRef}
onClick={() => {
setShowCaption(!showCaption);
}}>
Add Caption
</button>
)}
{onLinkClick && (
<button
className="image-link-button"
onClick={onLinkClick}>
링크
</button>
)}
</div>
<div
className="image-resizer image-resizer-n"
onPointerDown={(event) => {
Expand Down