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