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