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 { 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