Public Source Viewer

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

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

Redacted View
src/routes/blog.routes.ts
공개 가능
1 import { Router, Request, Response } from 'express';
2 import fs from 'fs';
3 import path from 'path';
4 import { v4 as uuidv4 } from 'uuid';
5 import sanitizeHtml from 'sanitize-html';
6 import { upload } from '../config/multer.config';
7 import { sanitizeOptions } from '../config/sanitize.config';
8 import { requireLogin } from '../middleware/auth.middleware';
9 import { decodeHtmlEntities, getBlogPostFileName, loadPosts, nl2br } from '../services/blog.service';
10 import { addBlogTag, deleteBlogTag, getAvailableBlogTags, normalizeBlogTag, normalizeBlogTags, removeBlogTagFromPosts } from '../services/blog-tag.service';
11 import { readSettings } from '../services/settings.service';
12 import { getVerifiedUsers, getUserProfileImages, getProfileImage } from '../services/bookmark.service';
13 import { recordSecurityLog } from '../services/security-log.service';
14 import { Post, Reply } from '../types/models';
15 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
16 import { uploadRateLimit } from '../middleware/security.middleware';
17
18 const router = Router();
19
20 router.get('/hinana/blog', (req: Request, res: Response) => {
21 let posts = loadPosts().map(post => ({ ...post, tags: normalizeBlogTags(post.tags) }));
22 const sortOption = (req.query.sort as string) || 'new';
23 const keyword = (req.query.keyword as string) || '';
24 const selectedTag = normalizeBlogTag(req.query.tag);
25 const availableTags = getAvailableBlogTags(posts);
26
27 // 검색 필터링
28 if (keyword) {
29 const lowerKeyword = keyword.toLowerCase();
30 posts = posts.filter(post => {
31 const title = (post.title || '').toLowerCase();
32 const content = (post.content || '').toLowerCase();
33 return title.includes(lowerKeyword) || content.includes(lowerKeyword);
34 });
35 }
36
37 if (selectedTag) {
38 posts = posts.filter(post => normalizeBlogTags(post.tags).includes(selectedTag));
39 }
40
41 // 정렬 (loadPosts()는 기본 최신순, old면 뒤집기)
42 if (sortOption === 'old') {
43 posts.reverse();
44 }
45
46 const currentPage = parseInt(req.query.page as string) || 1;
47 const postsPerPage = 10;
48 const totalPages = Math.ceil(posts.length / postsPerPage);
49
50 const paginatedPosts = posts.slice((currentPage - 1) * postsPerPage, currentPage * postsPerPage);
51
52 const postsWithReplyCount = paginatedPosts.map(post => ({
53 ...post,
54 title: decodeHtmlEntities(post.title),
55 replyCount: post.replies ? post.replies.length : 0
56 }));
57
58 const username = req.session.username || null;
59 const baseUrl = `${req.protocol}://${req.get('host')}`;
60 const pageTitle = keyword ? `비나래 도서관 - ${keyword}` : '비나래 도서관';
61 const pageDescription = keyword
62 ? `"${keyword}" 검색 결과를 모아 보는 비나래 아카이브 도서관입니다.`
63 : '비나래가 기록한 이야기와 아카이브 글을 모아 보는 도서관입니다.';
64
65 res.render('hinana/blog', {
66 posts: postsWithReplyCount,
67 currentPage: currentPage,
68 totalPages: totalPages,
69 limit: postsPerPage,
70 username: username,
71 keyword: keyword,
72 sort: sortOption,
73 selectedTag: selectedTag,
74 availableTags: availableTags,
75 theme: req.session.theme || 'light',
76 userProfileImages: getUserProfileImages(),
77 metaData: {
78 title: pageTitle,
79 description: pageDescription,
80 url: `${baseUrl}${req.originalUrl}`,
81 image: `${baseUrl}/image/title.png`,
82 }
83 });
84 });
85
86 router.get('/hinana/write', (req: Request, res: Response) => {
87 const username = req.session.username;
88
89 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
90 res.render('./hinana/write', {
91 username: '비나래',
92 theme: req.session.theme || req.cookies.theme || 'light',
93 availableTags: getAvailableBlogTags(loadPosts())
94 });
95 } else {
96 res.send('게시 권한이 없습니다.');
97 }
98 });
99
100 router.post('/hinana/blog/tags', requireLogin, (req: Request, res: Response) => {
101 const username = req.session.username;
102
103 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
104 recordSecurityLog(req, {
105 type: 'access_denied',
106 action: '블로그 태그 생성 차단',
107 detail: '관리자 권한이 없는 사용자의 블로그 태그 생성 시도'
108 });
109 return res.status(403).send('태그 생성 권한이 없습니다.');
110 }
111
112 addBlogTag(req.body.tagName);
113
114 const redirectTo = typeof req.body.redirect === 'string' && req.body.redirect.startsWith('/hinana/')
115 ? req.body.redirect
116 : '/hinana/write';
117 res.redirect(redirectTo);
118 });
119
120 router.post('/hinana/blog/tags/delete', requireLogin, (req: Request, res: Response) => {
121 const username = req.session.username;
122
123 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
124 recordSecurityLog(req, {
125 type: 'access_denied',
126 action: '블로그 태그 삭제 차단',
127 detail: '관리자 권한이 없는 사용자의 블로그 태그 삭제 시도'
128 });
129 return res.status(403).send('태그 삭제 권한이 없습니다.');
130 }
131
132 const tagName = req.body.tagName;
133 deleteBlogTag(tagName);
134 removeBlogTagFromPosts(tagName);
135
136 const redirectTo = typeof req.body.redirect === 'string' && req.body.redirect.startsWith('/hinana/')
137 ? req.body.redirect
138 : '/hinana/write';
139 res.redirect(redirectTo);
140 });
141
142 router.post('/hinana/post', uploadRateLimit, (req: Request, res: Response, next) => {
143 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
144 recordSecurityLog(req, { type: 'access_denied', action: '블로그 이미지 업로드 차단' });
145 return res.status(403).send('게시 권한이 없습니다.');
146 }
147 next();
148 }, upload.single('image'), (req: Request, res: Response) => {
149 const username = req.session.username;
150
151 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
152 recordSecurityLog(req, {
153 type: 'access_denied',
154 action: '블로그 글 작성 차단',
155 detail: '관리자 권한이 없는 사용자의 블로그 글 작성 시도'
156 });
157 return res.status(403).send('게시 권한이 없습니다.');
158 }
159
160 const { title, content, isPrivate } = req.body;
161 const image = req.file ? `/uploads/${req.file.filename}` : null;
162
163 const cleanTitle = sanitizeHtml(title, { allowedTags: [] });
164 const cleanContent = sanitizeHtml(content, sanitizeOptions);
165 const selectedTags = normalizeBlogTags(req.body.tags);
166
167 if (cleanContent.trim().length === 0) {
168 return res.status(400).send('내용이 유효하지 않습니다.');
169 }
170
171 const postData: Post = {
172 id: Date.now(),
173 author: username,
174 title: cleanTitle,
175 content: cleanContent,
176 createdAt: new Date().toISOString(),
177 image: image,
178 isPrivate: !!isPrivate,
179 tags: selectedTags,
180 replies: []
181 };
182
183 const blogPostFileName = getBlogPostFileName();
184 let posts: Post[] = [];
185 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
186 try {
187 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
188 } catch (e) { console.error(e); }
189 }
190
191 posts.push(postData);
192
193 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
194
195 recordSecurityLog(req, {
196 type: 'admin_action',
197 target: String(postData.id),
198 action: '블로그 글 작성',
199 detail: `제목: ${cleanTitle.slice(0, 80)}, 비공개: ${postData.isPrivate ? '예' : '아니오'}, 이미지: ${image ? '예' : '아니오'}`
200 });
201
202 res.redirect('/hinana/blog');
203 });
204
205 router.get('/hinana/post/:id', (req: Request, res: Response) => {
206 const postId = String(req.params.id);
207 let foundPost: Post | null = null;
208
209 let files: string[];
210 try {
211 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
212 } catch (e) {
213 return res.status(500).send('데이터 폴더 오류');
214 }
215
216 for (const file of files) {
217 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
218 try {
219 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
220 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
221 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
222
223 const post = posts.find(p => String(p.id) === String(postId));
224
225 if (post) {
226 foundPost = post;
227 break;
228 }
229 } catch (e) { continue; }
230 }
231 }
232
233 if (foundPost) {
234 const updatedContent = foundPost.content.replace(/<img src="([^"]+)"/g, (match, p1) => {
235 const absoluteSrc = p1.startsWith('../') ? p1.replace('../', '/') : p1;
236 return `<img src="${absoluteSrc}"`;
237 });
238 const ogImage = foundPost.content.match(/<img src="([^"]+)"/);
239 const baseUrl = `${req.protocol}://${req.get('host')}`;
240 const imagePath = ogImage ? ogImage[1] : '/image/title.png';
241 const imageUrl = imagePath.startsWith('http') ? imagePath : `${baseUrl}${imagePath.startsWith('/') ? imagePath : `/${imagePath}`}`;
242 const decodedTitle = decodeHtmlEntities(foundPost.title);
243 const decodedDescription = decodeHtmlEntities(
244 foundPost.content.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim().substring(0, 120)
245 );
246 const ogData = {
247 title: decodedTitle,
248 description: decodedDescription || '비나래 아카이브 도서관의 글입니다.',
249 url: `${baseUrl}${req.originalUrl}`,
250 image: imageUrl,
251 };
252
253 const settings = readSettings();
254
255 res.render('./hinana/blogpost', {
256 post: { ...foundPost, title: decodedTitle, content: updatedContent },
257 ogData: ogData,
258 username: req.session.username || null,
259 replies: foundPost.replies || [],
260 theme: req.session.theme || req.cookies.theme || 'light',
261 isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
262 isSignupEnabled: settings.isSignupEnabled,
263 isGptEnabled: settings.isGptEnabled,
264 userProfileImages: getUserProfileImages(),
265 currentUserProfileImage: req.session.username ? getProfileImage(req.session.username) : null
266 });
267 } else {
268 res.status(404).send('블로그 게시물을 찾을 수 없습니다.');
269 }
270 });
271
272 router.get('/hinana/edit-post/:id', (req: Request, res: Response) => {
273 const postId = String(req.params.id);
274
275 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
276 return res.status(404).send('게시물이 없습니다.');
277 }
278
279 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
280
281 let post: Post | null = null;
282
283 for (const file of files) {
284 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
285 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
286 const foundPost = posts.find(p => p.id === parseInt(postId));
287
288 if (foundPost) {
289 post = foundPost;
290 break;
291 }
292 }
293
294 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
295 res.render('hinana/editPost', {
296 post: { ...post, tags: normalizeBlogTags(post.tags) },
297 username: req.session.username,
298 theme: req.session.theme || 'light',
299 availableTags: getAvailableBlogTags(loadPosts())
300 });
301 } else {
302 res.status(404).send('게시물을 찾을 수 없거나 수정 권한이 없습니다.');
303 }
304 });
305
306 router.post('/hinana/edit-post', (req: Request, res: Response) => {
307 const { postId, title, content } = req.body;
308 const selectedTags = normalizeBlogTags(req.body.tags);
309 const username = req.session.username;
310
311 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
312 recordSecurityLog(req, {
313 type: 'access_denied',
314 target: String(postId || ''),
315 action: '블로그 글 수정 차단',
316 detail: '관리자 권한이 없는 사용자의 블로그 글 수정 시도'
317 });
318 return res.status(403).send('게시물 수정 권한이 없습니다.');
319 }
320
321 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
322 return res.status(404).send('게시물이 없습니다.');
323 }
324
325 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
326
327 let postUpdated = false;
328
329 for (const file of files) {
330 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
331 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
332 const postToUpdateIndex = posts.findIndex(post => post.id == postId);
333
334 if (postToUpdateIndex !== -1) {
335 posts[postToUpdateIndex].title = sanitizeHtml(title, { allowedTags: [] });
336 posts[postToUpdateIndex].content = sanitizeHtml(content, sanitizeOptions);
337 posts[postToUpdateIndex].tags = selectedTags;
338 posts[postToUpdateIndex].updatedAt = new Date().toISOString();
339
340 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
341
342 postUpdated = true;
343 break;
344 }
345 }
346
347 if (postUpdated) {
348 recordSecurityLog(req, {
349 type: 'admin_action',
350 target: String(postId || ''),
351 action: '블로그 글 수정',
352 detail: `제목: ${sanitizeHtml(title, { allowedTags: [] }).slice(0, 80)}`
353 });
354 res.redirect(`/hinana/post/${postId}`);
355 } else {
356 res.status(404).send('게시물을 찾을 수 없습니다.');
357 }
358 });
359
360 router.post('/hinana/delete-post', (req: Request, res: Response) => {
361 const { postId } = req.body;
362 const username = req.session.username;
363
364 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
365 recordSecurityLog(req, {
366 type: 'access_denied',
367 target: String(postId || ''),
368 action: '블로그 글 삭제 차단',
369 detail: '관리자 권한이 없는 사용자의 블로그 글 삭제 시도'
370 });
371 return res.status(403).send('게시물 삭제 권한이 없습니다.');
372 }
373
374 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
375 return res.status(404).send('게시물이 없습니다.');
376 }
377
378 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
379
380 let postDeleted = false;
381 let deletedPostTitle = '';
382
383 for (const file of files) {
384 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
385 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
386 const postToDeleteIndex = posts.findIndex(post => post.id == postId);
387
388 if (postToDeleteIndex !== -1) {
389 const postToDelete = posts[postToDeleteIndex];
390 deletedPostTitle = postToDelete.title || '';
391
392 const imageMatches = postToDelete.content.match(/<img[^>]+src="([^">]+)"/g);
393 if (imageMatches) {
394 imageMatches.forEach(match => {
395 let imagePath = match.match(/src="([^">]+)/)![1];
396
397 try {
398 const imageUrl = new URL(imagePath);
399 imagePath = imageUrl.pathname;
400 } catch (err) {
401 if (imagePath.startsWith('/')) {
402 imagePath = imagePath.slice(1);
403 }
404 }
405
406 if (!imagePath.startsWith('uploads/')) {
407 imagePath = `uploads/${imagePath.split('uploads/')[1]}`;
408 }
409
410 const fullImagePath = path.join(PUBLIC_DIR, imagePath);
411
412 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
413 fs.unlinkSync(fullImagePath);
414 }
415 });
416 }
417
418 posts.splice(postToDeleteIndex, 1);
419
420 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
421
422 postDeleted = true;
423 break;
424 }
425 }
426
427 if (postDeleted) {
428 recordSecurityLog(req, {
429 type: 'admin_action',
430 target: String(postId || ''),
431 action: '블로그 글 삭제',
432 detail: deletedPostTitle ? `제목: ${deletedPostTitle.slice(0, 80)}` : undefined
433 });
434 res.redirect('/hinana/blog');
435 } else {
436 res.status(404).send('게시물을 찾을 수 없습니다.');
437 }
438 });
439
440 router.post('/hinana/reply/:postId', (req: Request, res: Response) => {
441 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
442
443 let replyUsername: string;
444 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
445 let finalContent = sanitizeHtml(String(content || ''), { allowedTags: [] });
446
447 if (isAnonymous === 'true') {
448 const settings = readSettings();
449 if (!settings.isAnonymousPostingEnabled) {
450 recordSecurityLog(req, {
451 type: 'access_denied',
452 actor: anonymousUsername ? `${String(anonymousUsername).slice(0, 20)} (익명)` : null,
453 target: String(postId || ''),
454 action: '블로그 댓글 익명 작성 차단',
455 detail: '익명 댓글 작성 비활성화 상태'
456 });
457 return res.status(403).send('현재 익명 답글 작성이 비활성화되어 있습니다.');
458 }
459 if (!anonymousUsername || anonymousUsername.trim().length === 0 || anonymousUsername.length > 20) {
460 return res.status(400).send('익명 닉네임은 1~20자리여야 합니다.');
461 }
462 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
463 return res.status(400).send('비밀번호는 1~6자리로 설정해야 합니다.');
464 }
465 replyUsername = `${anonymousUsername.substring(0, 20)} (익명)`;
466 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
467 } else {
468 if (!req.session.username) {
469 return res.status(403).send('로그인이 필요합니다.');
470 }
471 replyUsername = req.session.username;
472 }
473
474 if (finalContent.replace(/[\r\n]/g, '').length > 150) {
475 return res.status(400).send('답글은 150자를 초과할 수 없습니다.');
476 }
477 if (finalContent.length === 0) {
478 return res.status(400).send('내용이 필요합니다.');
479 }
480
481 const replyData: Reply = {
482 id: uuidv4(),
483 username: replyUsername,
484 content: finalContent,
485 createdAt: new Date().toISOString(),
486 replies: [],
487 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
488 };
489
490 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
491 return res.status(500).send('게시물 데이터 디렉토리가 없습니다.');
492 }
493
494 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
495
496 let postFound = false;
497 let replyAdded = false;
498
499 for (const file of files) {
500 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
501 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
502 const post = posts.find(p => p.id === parseInt(postId));
503
504 if (post) {
505 postFound = true;
506 if (!post.replies) {
507 post.replies = [];
508 }
509
510 if (parentReplyId) {
511 let parentFound = false;
512 function findAndAddReply(replies: Reply[]): void {
513 for (let reply of replies) {
514 if (String(reply.id) === String(parentReplyId)) {
515 if (!reply.replies) {
516 reply.replies = [];
517 }
518 reply.replies.push(replyData);
519 parentFound = true;
520 return;
521 }
522 if (reply.replies && reply.replies.length > 0) {
523 findAndAddReply(reply.replies);
524 if (parentFound) return;
525 }
526 }
527 }
528 findAndAddReply(post.replies);
529 if (parentFound) {
530 replyAdded = true;
531 }
532 } else {
533 post.replies.push(replyData);
534 replyAdded = true;
535 }
536
537 if (replyAdded) {
538 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
539 recordSecurityLog(req, {
540 type: 'feature_use',
541 actor: isAnonymous === 'true' ? replyUsername : req.session.username,
542 target: String(postId || ''),
543 action: parentReplyId ? '블로그 대댓글 작성' : '블로그 댓글 작성',
544 detail: `댓글 ID: ${replyData.id}, 익명: ${isAnonymous === 'true' ? '예' : '아니오'}`
545 });
546 return res.redirect(`/hinana/post/${postId}`);
547 }
548 }
549 }
550
551 if (!postFound) {
552 return res.status(404).send('게시물을 찾을 수 없습니다.');
553 }
554 if (!replyAdded) {
555 return res.status(404).send('부모 댓글을 찾을 수 없습니다.');
556 }
557 });
558
559 router.post('/hinana/delete-blog-reply', requireLogin, (req: Request, res: Response) => {
560 const { postId, replyId } = req.body;
561 const currentUser = req.session.username!;
562
563 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
564
565 let postFound = false;
566 let replyDeleted = false;
567
568 for (const file of files) {
569 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
570 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
571 const post = posts.find(p => p.id === parseInt(postId));
572
573 if (post) {
574 postFound = true;
575 let replyFound = false;
576
577 function findAndRemoveReply(replies: Reply[]): void {
578 if (!replies) return;
579 for (let i = 0; i < replies.length; i++) {
580 const reply = replies[i];
581 if (String(reply.id) === String(replyId)) {
582 replyFound = true;
583 if (currentUser === '비나래' || reply.username === currentUser) {
584 replies.splice(i, 1);
585 replyDeleted = true;
586 } else {
587 recordSecurityLog(req, {
588 type: 'access_denied',
589 target: String(postId || ''),
590 action: '블로그 댓글 삭제 차단',
591 detail: `댓글 ID: ${replyId}`
592 });
593 }
594 return;
595 }
596 if (reply.replies && reply.replies.length > 0) {
597 findAndRemoveReply(reply.replies);
598 if (replyFound) return;
599 }
600 }
601 }
602
603 findAndRemoveReply(post.replies);
604
605 if (replyDeleted) {
606 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
607 recordSecurityLog(req, {
608 type: 'feature_use',
609 target: String(postId || ''),
610 action: '블로그 댓글 삭제',
611 detail: `댓글 ID: ${replyId}`
612 });
613 return res.redirect(`/hinana/post/${postId}`);
614 } else if (replyFound) {
615 return res.status(403).send('삭제 권한이 없습니다.');
616 }
617 }
618 }
619 if (!postFound) return res.status(404).send('게시글을 찾을 수 없습니다.');
620 return res.status(404).send('답글을 찾을 수 없습니다.');
621 });
622
623 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
624 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
625
626 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
627 return res.status(400).json({ message: 'post ID, reply ID, 비밀번호가 모두 필요합니다.' });
628 }
629
630 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
631
632 let postFound = false;
633 let replyFound = false;
634 let replyDeleted = false;
635 let errorMessage = '답글을 찾을 수 없습니다.';
636
637 for (const file of files) {
638 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
639 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
640 const post = posts.find(p => p.id === parseInt(postId));
641
642 if (post) {
643 postFound = true;
644
645 function findAndRemoveReply(replies: Reply[]): void {
646 if (!replies) return;
647 for (let i = 0; i < replies.length; i++) {
648 const reply = replies[i];
649 if (String(reply.id) === String(replyId)) {
650 replyFound = true;
651 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
652 replies.splice(i, 1);
653 replyDeleted = true;
654 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
655 errorMessage = '비밀번호가 일치하지 않습니다.';
656 } else {
657 errorMessage = '암호로 삭제할 수 없는 답글입니다.';
658 }
659 return;
660 }
661 if (reply.replies && reply.replies.length > 0) {
662 findAndRemoveReply(reply.replies);
663 if (replyFound) return;
664 }
665 }
666 }
667
668 findAndRemoveReply(post.replies);
669
670 if (replyDeleted) {
671 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
672 recordSecurityLog(req, {
673 type: 'feature_use',
674 target: String(postId || ''),
675 action: '블로그 댓글 삭제',
676 detail: `댓글 ID: ${replyId}, 익명 비밀번호 삭제`
677 });
678 return res.json({ success: true, message: '답글이 삭제되었습니다.' });
679 } else if (replyFound) {
680 recordSecurityLog(req, {
681 type: 'access_denied',
682 target: String(postId || ''),
683 action: '블로그 댓글 삭제 차단',
684 detail: `댓글 ID: ${replyId}, 익명 비밀번호 불일치 또는 삭제 불가`
685 });
686 return res.status(401).json({ message: errorMessage });
687 }
688 }
689 }
690 if (!postFound) return res.status(404).json({ message: '게시글을 찾을 수 없습니다.' });
691 return res.status(404).json({ message: errorMessage });
692 });
693
694 router.get('/hinana/search', (req: Request, res: Response) => {
695 const keyword = req.query.keyword ? (req.query.keyword as string).toLowerCase() : '';
696 const page = parseInt(req.query.page as string) || 1;
697 let limit = Math.max(1, parseInt(req.query.limit as string));
698 limit = !isNaN(limit) ? limit : 10;
699 const username = req.session.username;
700 const sortOption = (req.query.sort as string) || 'new';
701
702 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
703 if (err) {
704 console.error('파일 읽기 에러:', err);
705 return res.status(500).send('Internal Server Error');
706 }
707
708 let posts: any[];
709 try {
710 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
711 } catch (parseError) {
712 console.error('JSON 파싱 에러:', parseError);
713 return res.status(500).send('Internal Server Error');
714 }
715
716 const filteredPosts = posts.filter(post => {
717 const content = (post.content || '').toLowerCase();
718 const title = (post.title || '').toLowerCase();
719 const postMatches = content.includes(keyword) || title.includes(keyword);
720
721 const repliesMatch = post.replies && post.replies.some((reply: any) =>
722 (reply.content || '').toLowerCase().includes(keyword)
723 );
724
725 const isPostVisible = !post.isPrivate || (post.username || post.author) === username;
726 return (postMatches || repliesMatch) && isPostVisible;
727 });
728
729 const getTime = (p: any) => new Date(p.timestamp || p.createdAt || 0).getTime();
730
731 if (sortOption === 'old') {
732 filteredPosts.sort((a, b) => getTime(a) - getTime(b));
733 } else if (sortOption === 'popular') {
734 filteredPosts.sort((a, b) => {
735 const aLikes = Array.isArray(a.likes) ? a.likes.length : 0;
736 const bLikes = Array.isArray(b.likes) ? b.likes.length : 0;
737 if (bLikes !== aLikes) return bLikes - aLikes;
738 return getTime(b) - getTime(a);
739 });
740 } else {
741 filteredPosts.sort((a, b) => getTime(b) - getTime(a));
742 }
743
744 const totalPages = Math.ceil(filteredPosts.length / limit);
745 const paginate = (array: any[], page: number, limit: number): any[] => {
746 return array.slice((page - 1) * limit, page * limit);
747 };
748 const paginatedPosts = paginate(filteredPosts, page, limit);
749
750 function highlightHashtags(str: string): string {
751 return str ? str.replace(/#([\w가-힣]+)/g, (_, tag) =>
752 `<a href="/hinana/search?keyword=${encodeURIComponent('#' + tag)}" class="hashtag">#${tag}</a>`
753 ) : '';
754 }
755 function linkifyUrls(str: string): string {
756 return str ? str.replace(/(https?:\/\/[^\s<]+)/g, '<a href="#" class="external-link" data-url="$1">$1</a>') : '';
757 }
758 const processContent = (content: string) => highlightHashtags(linkifyUrls(nl2br(content)));
759
760 const processedPosts = paginatedPosts.map(post => {
761 const processedPost = {
762 ...post,
763 content: processContent(post.content)
764 };
765 if (post.replies && Array.isArray(post.replies)) {
766 processedPost.replies = post.replies.map((reply: any) => ({
767 ...reply,
768 content: processContent(reply.content)
769 }));
770 }
771 return processedPost;
772 });
773
774 const settings = readSettings();
775
776 // 인기 해시태그 추출 (매일 오후 6시 KST 기준 주기)
777 const _now = new Date();
778 const _lastReset = new Date(_now);
779 _lastReset.setUTCHours(9, 0, 0, 0);
780 if (_now < _lastReset) _lastReset.setUTCDate(_lastReset.getUTCDate() - 1);
781
782 const tagCount: Record<string, number> = {};
783 posts.forEach((post: any) => {
784 const postTime = new Date(post.timestamp);
785 if (isNaN(postTime.getTime()) || postTime < _lastReset) return;
786 const matches = (post.content || '').match(/#([\w가-힣]+)/g);
787 if (matches) matches.forEach((m: string) => {
788 tagCount[m] = (tagCount[m] || 0) + 1;
789 });
790 });
791 const trendingTags = Object.entries(tagCount)
792 .map(([tag, count]) => ({ tag, count }))
793 .sort((a, b) => b.count - a.count)
794 .slice(0, 6);
795
796 const selectedId = req.query.selectedId ? String(req.query.selectedId).trim() : null;
797 let currentPost = null;
798
799 if (selectedId) {
800 const originalPost = filteredPosts.find(p => String(p.id) === selectedId);
801 if (originalPost) {
802 currentPost = {
803 ...originalPost,
804 content: processContent(originalPost.content)
805 };
806 if (originalPost.replies) {
807 currentPost.replies = originalPost.replies.map((r: any) => ({
808 ...r,
809 content: processContent(r.content)
810 }));
811 }
812 }
813 }
814
815 res.render('./hinana/index', {
816 posts: processedPosts,
817 currentPost: currentPost,
818 username: req.session.username || null,
819 currentPage: page,
820 totalPages: totalPages,
821 nl2br: nl2br,
822 limit: limit,
823 keyword: keyword,
824 sort: sortOption,
825 currentUrl: req.originalUrl,
826 basePath: '/hinana/search',
827 theme: req.session.theme || 'light',
828 isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
829 isSignupEnabled: settings.isSignupEnabled,
830 isGptEnabled: settings.isGptEnabled,
831 trendingTags: trendingTags,
832 verifiedUsers: getVerifiedUsers(),
833 userProfileImages: getUserProfileImages(),
834 currentUserProfileImage: req.session.username ? getProfileImage(req.session.username) : null
835 });
836 });
837 });
838
839 router.get('/blogsearch', (req: Request, res: Response) => {
840 const keyword = req.query.keyword ? (req.query.keyword as string).toLowerCase() : '';
841 const limit = parseInt(req.query.limit as string) || 10;
842 const page = parseInt(req.query.page as string) || 1;
843 const username = req.session.username;
844
845 let allPosts: Post[] = [];
846
847 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
848 if (err) {
849 console.error('Error reading blogpost directory:', err);
850 return res.status(500).send('Internal server error');
851 }
852
853 files.forEach(file => {
854 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
855 try {
856 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
857 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
858 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
859 allPosts = allPosts.concat(posts);
860 } catch (e) {
861 console.error(`파일 파싱 에러 (${file}):`, e);
862 }
863 }
864 });
865
866 const filteredPosts = allPosts.filter(post => {
867 const title = post.title ? post.title.toLowerCase() : '';
868 const content = post.content ? post.content.toLowerCase() : '';
869
870 return title.includes(keyword) || content.includes(keyword);
871 });
872
873 filteredPosts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
874
875 const totalPages = Math.ceil(filteredPosts.length / limit);
876 const paginatedPosts = filteredPosts.slice((page - 1) * limit, page * limit);
877
878 const processedPosts = paginatedPosts.map(post => ({
879 ...post,
880 replyCount: post.replies ? post.replies.length : 0
881 }));
882
883 const settings = readSettings();
884
885 res.render('hinana/blog', {
886 posts: processedPosts,
887 currentPage: page,
888 theme: req.session.theme || 'light',
889 totalPages: totalPages,
890 limit: limit,
891 username: username,
892 keyword: keyword,
893 sort: 'new',
894 isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
895 isSignupEnabled: settings.isSignupEnabled,
896 isGptEnabled: settings.isGptEnabled
897 });
898 });
899 });
900
901 export default router;
902