Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
src/routes/personal-note.routes.ts
공개 가능
1 import { Router, Request, Response } from 'express';
2 import { v4 as uuidv4 } from 'uuid';
3 import { deleteUserPersonalNote, getUserPersonalNotes, saveUserPersonalNote, updateUserPersonalNote } from '../services/personal-note.service';
4 import { PersonalNote } from '../types/models';
5 import { recordSecurityLog } from '../services/security-log.service';
6
7 const router = Router();
8
9 const PERSONAL_TAG_CATALOG = [
10 '일기', '아이디어', '작업', '계획', '읽을거리', '감상', '인용', '메모',
11 '건강', '공부', '여행', '음악', '미술', '기술', '사람', '기억',
12 '캐릭터', '글쓰기', '대화', '감정', '생각', '리뷰', '영화', '게임',
13 '음식', '쇼핑', '할일', '질문', '문장', '취향', '기록', '테스트'
14 ];
15
16 type AutoTagRule = {
17 strong: string[];
18 weak?: string[];
19 priority?: number;
20 };
21
22 const PERSONAL_AUTO_TAG_RULES: Record<string, AutoTagRule> = {
23 '일기': { strong: ['일기', '오늘 있었', '하루', '어제 있었', '하루를'], weak: ['오늘', '어제', '기분'], priority: 8 },
24 '아이디어': { strong: ['아이디어', '발상', '기획', '컨셉', '소재'], weak: ['떠올랐', '생각났', '해보면'], priority: 8 },
25 '작업': { strong: ['작업', '업무', '프로젝트', '마감', '수정'], weak: ['진행', '완료', '해야'], priority: 7 },
26 '계획': { strong: ['계획', '일정', '목표', '루틴'], weak: ['예정', '다음', '언젠가'], priority: 7 },
27 '읽을거리': { strong: ['책', '읽을거리', '기사', '논문', '링크'], weak: ['읽기', '읽어', '읽을'], priority: 6 },
28 '감상': { strong: ['감상', '후기', '느낀점', '귀엽', '예쁘', '멋지', '좋았', '별로'], weak: ['인상', '느낌', '진짜'], priority: 7 },
29 '인용': { strong: ['인용', '명언', '발췌'], weak: ['문장', '구절'], priority: 6 },
30 '메모': { strong: ['메모', '노트', '끄적', '적어둠'], weak: ['참고', '잊지'], priority: 5 },
31 '건강': { strong: ['건강', '수면', '운동', '병원', '약', '식단'], weak: ['몸', '컨디션', '피곤'], priority: 7 },
32 '공부': { strong: ['공부', '학습', '시험', '복습', '강의'], weak: ['배운', '외우'], priority: 7 },
33 '여행': { strong: ['여행', '숙소', '항공', '기차', '호텔'], weak: ['장소', '풍경', '가고 싶'], priority: 6 },
34 '음악': { strong: ['음악', '노래', '앨범', '멜로디', '가사'], weak: ['듣고', '플레이리스트'], priority: 6 },
35 '미술': { strong: ['미술', '전시', '작품', '화가', '그림'], weak: ['갤러리'], priority: 6 },
36 '기술': { strong: ['코드', '개발', '기술', '프로그램', '마크다운', 'api', '서버'], weak: ['버그', '테스트', '기능'], priority: 8 },
37 '사람': { strong: ['사람', '친구', '가족', '동료', '선배', '후배'], weak: ['만난', '대화한'], priority: 5 },
38 '기억': { strong: ['기억', '추억', '회상', '예전 일'], weak: ['예전', '떠오름'], priority: 6 },
39 '캐릭터': { strong: ['캐릭터', '인물', '히나나', '아이돌'], weak: ['말투', '성격'], priority: 8 },
40 '글쓰기': { strong: ['글쓰기', '소설', '문체', '서사', '문단'], weak: ['쓰고', '표현'], priority: 7 },
41 '대화': { strong: ['대화', '대사', '채팅', '말투'], weak: ['말했', '답변'], priority: 6 },
42 '감정': { strong: ['행복', '슬프', '불안', '짜증', '외롭', '설레', '화남'], weak: ['기분', '마음'], priority: 7 },
43 '생각': { strong: ['생각', '고민', '상상', '의문'], weak: ['왜', '문득'], priority: 6 },
44 '리뷰': { strong: ['리뷰', '후기', '평가'], weak: ['추천', '별점'], priority: 6 },
45 '영화': { strong: ['영화', '드라마', '애니', '시리즈'], weak: ['봤다', '본 작품'], priority: 6 },
46 '게임': { strong: ['게임', '플레이', '스테이지', '퀘스트'], weak: ['클리어'], priority: 6 },
47 '음식': { strong: ['음식', '식사', '맛집', '커피', '디저트'], weak: ['먹었', '맛있'], priority: 5 },
48 '쇼핑': { strong: ['쇼핑', '구매', '장바구니', '가격'], weak: ['사고 싶', '살까'], priority: 5 },
49 '할일': { strong: ['할 일', '해야 할', '체크리스트', 'todo'], weak: ['해야', '잊지 말'], priority: 8 },
50 '질문': { strong: ['질문', '궁금', '왜', '어떻게'], weak: ['뭐지', '될까'], priority: 6 },
51 '문장': { strong: ['문장', '표현', '단어', '어휘'], weak: ['문구'], priority: 6 },
52 '취향': { strong: ['취향', '좋아하', '싫어하', '최애'], weak: ['마음에 든'], priority: 6 },
53 '기록': { strong: ['기록', '로그', '정리'], weak: ['남겨'], priority: 5 },
54 '테스트': { strong: ['테스트', '시험해', '해보자', '검증'], weak: ['되는지'], priority: 6 }
55 };
56
57 function requireAdmin(req: Request, res: Response): string | null {
58 if (!req.session.username) {
59 res.redirect('/login?redirect=/hinana/personal-notes');
60 return null;
61 }
62
63 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
64 recordSecurityLog(req, {
65 type: 'access_denied',
66 action: '개인 노트 접근 차단',
67 detail: '관리자 전용 개인 노트 접근 시도'
68 });
69 if (req.method === 'GET' && req.accepts('html')) {
70 res.status(403).render('hinana/forbidden', {
71 theme: req.session.theme || req.cookies.theme || 'light',
72 title: '접근 권한이 없습니다',
73 message: '이 페이지는 관리자만 사용할 수 있습니다.'
74 });
75 } else {
76 res.status(403).json({ success: false, message: '관리자만 접근할 수 있습니다.' });
77 }
78 return null;
79 }
80
81 return req.session.username;
82 }
83
84 function normalizeText(value: unknown, maxLength: number): string {
85 return String(value || '').replace(/\r\n?/g, '\n').trim().slice(0, maxLength);
86 }
87
88 function normalizeTags(value: unknown): string[] {
89 if (!Array.isArray(value)) return [];
90 return value.map((tag) => String(tag || '').trim().slice(0, 24)).filter(Boolean).slice(0, 8);
91 }
92
93 function inferPersonalTags(title: string, content: string): string[] {
94 const normalized = `${title}\n${content}`.toLowerCase();
95
96 return PERSONAL_TAG_CATALOG
97 .map((tag) => {
98 const rule = PERSONAL_AUTO_TAG_RULES[tag];
99 if (!rule) return { tag, score: 0, priority: 0 };
100
101 let score = 0;
102 rule.strong.forEach((keyword) => {
103 if (normalized.includes(keyword)) score += 3;
104 });
105 (rule.weak || []).forEach((keyword) => {
106 if (normalized.includes(keyword)) score += 1;
107 });
108
109 return { tag, score, priority: rule.priority || 0 };
110 })
111 .filter((item) => item.score >= 2)
112 .sort((a, b) => b.score - a.score || b.priority - a.priority || a.tag.localeCompare(b.tag, 'ko'))
113 .slice(0, 5)
114 .map((item) => item.tag);
115 }
116
117 function mergeTags(manualTags: string[], inferredTags: string[]): string[] {
118 return Array.from(new Set([...manualTags, ...inferredTags])).slice(0, 8);
119 }
120
121 function formatLocalKstDate(isoString: string): string {
122 return new Intl.DateTimeFormat('en-CA', {
123 timeZone: 'Asia/Seoul',
124 year: 'numeric',
125 month: '2-digit',
126 day: '2-digit'
127 }).format(new Date(isoString));
128 }
129
130 router.get('/hinana/personal-notes', (req: Request, res: Response) => {
131 const username = requireAdmin(req, res);
132 if (!username) return;
133
134 const notes = getUserPersonalNotes(username);
135 const search = String(req.query.q || '').trim();
136 const date = String(req.query.date || '').trim();
137 const tag = String(req.query.tag || '').trim();
138 const sort = ['old', 'updated', 'pinned'].includes(String(req.query.sort)) ? String(req.query.sort) : 'new';
139 const filteredNotes = notes.filter((note) => {
140 const normalizedSearch = search.toLowerCase();
141 const matchesSearch = !normalizedSearch
142 || note.title.toLowerCase().includes(normalizedSearch)
143 || note.content.toLowerCase().includes(normalizedSearch)
144 || (note.tags || []).some((item) => item.toLowerCase().includes(normalizedSearch));
145 const matchesDate = !date || formatLocalKstDate(note.createdAt) === date;
146 const matchesTag = !tag || (note.tags || []).includes(tag);
147 return matchesSearch && matchesDate && matchesTag;
148 }).sort((a, b) => {
149 if (sort === 'old') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
150 if (sort === 'updated') return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime();
151 if (sort === 'pinned' && Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1;
152 return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
153 });
154 const pageSize = 10;
155 const totalPages = Math.max(1, Math.ceil(filteredNotes.length / pageSize));
156 const requestedPage = Math.max(1, Number.parseInt(String(req.query.page || '1'), 10) || 1);
157 const page = Math.min(requestedPage, totalPages);
158 const start = (page - 1) * pageSize;
159
160 res.render('hinana/personalNotes', {
161 theme: req.session.theme || req.cookies.theme || 'light',
162 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
163 notes: filteredNotes.slice(start, start + pageSize),
164 totalNotes: notes.length,
165 filteredCount: filteredNotes.length,
166 currentPage: page,
167 totalPages,
168 search,
169 date,
170 tag,
171 sort,
172 availableTags: Array.from(new Set([...PERSONAL_TAG_CATALOG, ...notes.flatMap((note) => note.tags || [])])).sort(),
173 tagCatalog: PERSONAL_TAG_CATALOG
174 });
175 });
176
177 router.post('/hinana/personal-notes', (req: Request, res: Response) => {
178 const username = requireAdmin(req, res);
179 if (!username) return;
180
181 const title = normalizeText(req.body?.title, 80);
182 const content = normalizeText(req.body?.content, 10000);
183 if (!title || !content) return res.status(400).json({ success: false, message: '제목과 내용이 필요합니다.' });
184 const manualTags = normalizeTags(req.body?.tags);
185
186 const note: PersonalNote = {
187 id: uuidv4(),
188 title,
189 content,
190 tags: mergeTags(manualTags, inferPersonalTags(title, content)),
191 pinned: false,
192 createdAt: new Date().toISOString(),
193 updatedAt: new Date().toISOString()
194 };
195 const notes = saveUserPersonalNote(username, note);
196 recordSecurityLog(req, {
197 type: 'feature_use',
198 action: '개인 노트 생성',
199 detail: title
200 });
201 return res.json({ success: true, note, notes });
202 });
203
204 router.patch('/hinana/personal-notes/:id', (req: Request, res: Response) => {
205 const username = requireAdmin(req, res);
206 if (!username) return;
207
208 const noteId = String(req.params.id || '');
209 const notes = getUserPersonalNotes(username);
210 if (!notes.some((note) => note.id === noteId)) return res.status(404).json({ success: false, message: '노트를 찾을 수 없습니다.' });
211
212 const patch: Partial<PersonalNote> = {};
213 if (req.body?.title !== undefined) patch.title = normalizeText(req.body.title, 80);
214 if (req.body?.content !== undefined) {
215 const content = normalizeText(req.body.content, 10000);
216 if (!content) return res.status(400).json({ success: false, message: '내용이 비어 있습니다.' });
217 patch.content = content;
218 }
219 if (req.body?.tags !== undefined) patch.tags = normalizeTags(req.body.tags);
220 if (req.body?.pinned !== undefined) patch.pinned = req.body.pinned === true;
221
222 const nextNotes = updateUserPersonalNote(username, noteId, patch);
223 recordSecurityLog(req, {
224 type: 'feature_use',
225 action: '개인 노트 수정',
226 target: noteId,
227 detail: patch.title || undefined
228 });
229 return res.json({ success: true, note: nextNotes.find((note) => note.id === noteId), notes: nextNotes });
230 });
231
232 router.delete('/hinana/personal-notes/:id', (req: Request, res: Response) => {
233 const username = requireAdmin(req, res);
234 if (!username) return;
235 const noteId = String(req.params.id || '');
236 recordSecurityLog(req, {
237 type: 'feature_use',
238 action: '개인 노트 삭제',
239 target: noteId
240 });
241 return res.json({ success: true, notes: deleteUserPersonalNote(username, noteId) });
242 });
243
244 router.get('/hinana/personal-notes/export/:format', (req: Request, res: Response) => {
245 const username = requireAdmin(req, res);
246 if (!username) return;
247 const format = String(req.params.format);
248 if (format !== 'txt' && format !== 'md') return res.status(400).send('지원하지 않는 형식입니다.');
249 const body = getUserPersonalNotes(username).map((note) => {
250 const tags = (note.tags || []).length ? `\nTags: ${(note.tags || []).join(', ')}` : '';
251 return format === 'md'
252 ? `## ${note.title}\n\n${note.content}${tags}\n`
253 : `${note.title}\n${note.content}${tags}\n`;
254 }).join('\n');
255 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
256 res.setHeader('Content-Disposition', `attachment; filename="personal-notes.${format}"`);
257 return res.send(body);
258 });
259
260 router.get('/hinana/personal-notes/:id/export/:format', (req: Request, res: Response) => {
261 const username = requireAdmin(req, res);
262 if (!username) return;
263 const note = getUserPersonalNotes(username).find((item) => item.id === String(req.params.id));
264 if (!note) return res.status(404).send('노트를 찾을 수 없습니다.');
265 const format = String(req.params.format);
266 if (format !== 'txt' && format !== 'md') return res.status(400).send('지원하지 않는 형식입니다.');
267 const tags = (note.tags || []).length ? `\nTags: ${(note.tags || []).join(', ')}` : '';
268 const body = format === 'md' ? `## ${note.title}\n\n${note.content}${tags}\n` : `${note.title}\n${note.content}${tags}\n`;
269 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
270 res.setHeader('Content-Disposition', `attachment; filename="personal-note-${note.id}.${format}"`);
271 return res.send(body);
272 });
273
274 export default router;
275