Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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