Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/routes/legacy.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
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
7
import { sanitizeOptions } from '../config/sanitize.config';
8
import { readSettings } from '../services/settings.service';
9
import { awardBookmarks, getVerifiedUsers, getUserProfileImages } from '../services/bookmark.service';
10
import { sendPushToUser } from '../services/push.service';
11
import { nl2br } from '../services/blog.service';
12
import { recordSecurityLog } from '../services/security-log.service';
13
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
14
15
const router = Router();
16
17
router.post('/like', (req: Request, res: Response) => {
18
const postId = req.body.postId;
19
const currentUser = req.session.username;
20
21
if (!currentUser) {
22
return res.status(401).json({ message: '로그인이 필요합니다.' });
23
}
24
25
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
26
if (err) return res.status(500).json({ error: '파일 읽기 오류' });
27
28
let posts: any[];
29
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
30
31
const postIndex = posts.findIndex(p => String(p.id) === String(postId));
32
33
if (postIndex !== -1) {
34
const post = posts[postIndex];
35
36
if (!post.likes) post.likes = [];
37
const likeIndex = post.likes.indexOf(currentUser);
38
let isLiked = false;
39
40
if (likeIndex === -1) {
41
post.likes.push(currentUser);
42
isLiked = true;
43
} else {
44
post.likes.splice(likeIndex, 1);
45
}
46
47
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
48
if (err) return res.status(500).json({ error: '저장 오류' });
49
return res.json({ likeCount: post.likes.length, isLiked: isLiked });
50
});
51
return;
52
}
53
54
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
55
56
let blogFound = false;
57
58
for (const file of files) {
59
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
60
let blogPosts: any[] = [];
61
try {
62
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
63
} catch (e) { continue; }
64
65
const blogIndex = blogPosts.findIndex(p => String(p.id) === String(postId));
66
67
if (blogIndex !== -1) {
68
blogFound = true;
69
const post = blogPosts[blogIndex];
70
71
if (!post.likes) post.likes = [];
72
const likeIndex = post.likes.indexOf(currentUser);
73
let isLiked = false;
74
75
if (likeIndex === -1) {
76
post.likes.push(currentUser);
77
isLiked = true;
78
} else {
79
post.likes.splice(likeIndex, 1);
80
}
81
82
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
83
return res.json({ likeCount: post.likes.length, isLiked: isLiked });
84
}
85
}
86
87
if (!blogFound) {
88
return res.status(404).json({ error: '게시글을 찾을 수 없습니다.' });
89
}
90
});
91
});
92
93
router.post('/post', (req: Request, res: Response) => {
94
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
95
const sessionUsername = req.session.username;
96
97
let postUsername: string;
98
let isPostPrivate = isPrivate === 'on';
99
100
let finalContent = sanitizeHtml(String(content || ''), sanitizeOptions);
101
102
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
103
104
if (isAnonymous === 'true') {
105
const settings = readSettings();
106
if (!settings.isAnonymousPostingEnabled) {
107
recordSecurityLog(req, {
108
type: 'access_denied',
109
actor: anonymousUsername ? `${String(anonymousUsername).slice(0, 20)} (익명)` : null,
110
action: '메인 글 익명 작성 차단',
111
detail: '익명 글 작성 비활성화 상태'
112
});
113
return res.status(403).send('익명 글쓰기 비활성화됨');
114
}
115
if (!anonymousUsername || !anonymousUsername.trim()) return res.status(400).send('닉네임 필요');
116
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
117
118
postUsername = `${anonymousUsername.substring(0, 20)} (익명)`;
119
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
120
isPostPrivate = false;
121
} else if (sessionUsername) {
122
postUsername = sessionUsername;
123
} else {
124
return res.status(401).send('게시 권한이 없습니다.');
125
}
126
127
if (finalContent.trim().length === 0) {
128
return res.status(400).send('내용이 없거나 허용되지 않는 내용(스크립트 등)만 포함되어 있습니다.');
129
}
130
131
const newPost: any = {
132
id: uuidv4(),
133
username: postUsername,
134
title: '',
135
content: finalContent,
136
timestamp: new Date().toISOString(),
137
isPrivate: isPostPrivate,
138
image: null,
139
replies: [],
140
likes: [],
141
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
142
};
143
144
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
145
if (err) return res.status(500).send('DB 오류');
146
let posts: any[] = [];
147
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
148
149
posts.unshift(newPost);
150
151
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
152
if (err) return res.status(500).send('저장 오류');
153
if (isAnonymous !== 'true' && sessionUsername) {
154
awardBookmarks(sessionUsername, 2);
155
}
156
recordSecurityLog(req, {
157
type: 'feature_use',
158
actor: isAnonymous === 'true' ? postUsername : sessionUsername,
159
target: String(newPost.id),
160
action: '메인 글 작성',
161
detail: `익명: ${isAnonymous === 'true' ? '예' : '아니오'}, 비공개: ${isPostPrivate ? '예' : '아니오'}`
162
});
163
res.redirect('/hinana/index');
164
});
165
});
166
});
167
168
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
169
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
170
171
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
172
return res.status(400).json({ message: 'post ID와 비밀번호가 필요합니다.' });
173
}
174
175
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
176
if (err) {
177
console.error('Error reading posts file:', err);
178
return res.status(500).json({ message: '서버 오류 (파일 읽기)' });
179
}
180
181
let posts: any[];
182
try {
183
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
184
} catch (parseError) {
185
console.error('Error parsing posts file:', parseError);
186
return res.status(500).json({ message: '서버 오류 (파일 파싱)' });
187
}
188
189
const post = posts.find(p => String(p.id) === String(postId));
190
191
if (!post) {
192
return res.status(404).json({ message: '게시글을 찾을 수 없습니다.' });
193
}
194
195
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
196
return res.status(403).json({ message: '이 게시글은 비밀번호로 삭제할 수 없습니다.' });
197
}
198
199
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
200
recordSecurityLog(req, {
201
type: 'access_denied',
202
actor: post.username || null,
203
target: String(postId || ''),
204
action: '메인 글 삭제 차단',
205
detail: '익명 비밀번호 불일치'
206
});
207
return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' });
208
}
209
210
const newPosts = posts.filter(p => String(p.id) !== String(postId));
211
212
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
213
if (err) {
214
console.error('Error writing posts file:', err);
215
return res.status(500).json({ message: '서버 오류 (파일 쓰기)' });
216
}
217
recordSecurityLog(req, {
218
type: 'feature_use',
219
actor: post.username || null,
220
target: String(postId),
221
action: '메인 글 삭제',
222
detail: '익명 비밀번호 삭제'
223
});
224
res.json({ success: true, message: '게시글이 삭제되었습니다.' });
225
});
226
});
227
});
228
229
router.post('/reply', (req: Request, res: Response) => {
230
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
231
232
let replyUsername: string;
233
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
234
let finalContent = sanitizeHtml(String(content || ''), sanitizeOptions);
235
236
if (finalContent.length === 0) return res.status(400).send('내용이 필요합니다.');
237
238
if (isAnonymous === 'true') {
239
const settings = readSettings();
240
if (!settings.isAnonymousPostingEnabled) {
241
recordSecurityLog(req, {
242
type: 'access_denied',
243
actor: anonymousUsername ? `${String(anonymousUsername).slice(0, 20)} (익명)` : null,
244
target: String(postId || ''),
245
action: '메인 댓글 익명 작성 차단',
246
detail: '익명 댓글 작성 비활성화 상태'
247
});
248
return res.status(403).send('현재 익명 답글 작성이 비활성화되어 있습니다.');
249
}
250
if (!anonymousUsername || anonymousUsername.trim().length === 0 || anonymousUsername.length > 20) {
251
return res.status(400).send('익명 닉네임은 1~20자리여야 합니다.');
252
}
253
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
254
return res.status(400).send('비밀번호는 1~6자리로 설정해야 합니다.');
255
}
256
replyUsername = `${anonymousUsername.substring(0, 20)} (익명)`;
257
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
258
} else {
259
if (!req.session.username) {
260
return res.status(403).send('로그인이 필요합니다.');
261
}
262
replyUsername = req.session.username;
263
}
264
265
if (finalContent.replace(/[\r\n]/g, '').length > 150) {
266
return res.status(400).send('답글은 150자를 초과할 수 없습니다.');
267
}
268
269
const newReply: any = {
270
id: uuidv4(),
271
username: replyUsername,
272
content: finalContent,
273
timestamp: new Date().toISOString(),
274
replies: [],
275
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
276
};
277
278
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
279
if (err) {
280
console.error('Error reading posts file:', err);
281
return res.status(500).send('Internal server error');
282
}
283
let posts: any[];
284
try {
285
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
286
} catch (parseError) {
287
console.error('Error parsing posts file:', parseError);
288
return res.status(500).send('Internal server error');
289
}
290
291
const post = posts.find(post => String(post.id) === String(postId));
292
if (!post) {
293
return res.status(404).send('게시물을 찾을 수 없습니다.');
294
}
295
296
if (!post.replies) {
297
post.replies = [];
298
}
299
300
let parentReplyAuthor: string | null = null;
301
if (parentReplyId) {
302
let parentFound = false;
303
function findAndAddReply(replies: any[]): void {
304
for (let reply of replies) {
305
if (String(reply.id) === String(parentReplyId)) {
306
if (!reply.replies) {
307
reply.replies = [];
308
}
309
reply.replies.push(newReply);
310
parentFound = true;
311
parentReplyAuthor = reply.username;
312
return;
313
}
314
if (reply.replies && reply.replies.length > 0) {
315
findAndAddReply(reply.replies);
316
if (parentFound) return;
317
}
318
}
319
}
320
findAndAddReply(post.replies);
321
if (!parentFound) {
322
return res.status(404).send('부모 댓글을 찾을 수 없습니다.');
323
}
324
} else {
325
post.replies.push(newReply);
326
}
327
328
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
329
if (err) {
330
console.error('게시물 저장 오류:', err);
331
return res.status(500).send('Internal server error');
332
}
333
if (isAnonymous !== 'true' && req.session.username) {
334
awardBookmarks(req.session.username, 1);
335
}
336
337
recordSecurityLog(req, {
338
type: 'feature_use',
339
actor: isAnonymous === 'true' ? replyUsername : req.session.username,
340
target: String(postId),
341
action: parentReplyId ? '메인 대댓글 작성' : '메인 댓글 작성',
342
detail: `댓글 ID: ${newReply.id}, 익명: ${isAnonymous === 'true' ? '예' : '아니오'}`
343
});
344
345
// 푸시 알림: 원글 작성자가 익명이 아니고, 답글 작성자와 다를 때만 발송
346
const postAuthor = post.username;
347
const isAuthorAnonymous = postAuthor.endsWith('(익명)');
348
const replierIsAuthor = (isAnonymous !== 'true' && req.session.username === postAuthor);
349
const replierName = isAnonymous === 'true'
350
? `${anonymousUsername} (익명)`
351
: (req.session.username || '누군가');
352
const replyText = finalContent.replace(/<[^>]+>/g, '').slice(0, 60);
353
const targetUrl = `/hinana/index?selectedId=${post.id}`;
354
355
if (!isAuthorAnonymous && !replierIsAuthor) {
356
sendPushToUser(postAuthor, {
357
title: '새 답글이 달렸어요',
358
body: `${replierName}: ${replyText}`,
359
url: targetUrl,
360
icon: '/image/title.png'
361
}).catch(() => {});
362
}
363
364
// 푸시 알림: 부모 댓글 작성자에게 알림 (답글의 답글인 경우)
365
if (parentReplyAuthor) {
366
const parentIsAnonymous = parentReplyAuthor.endsWith('(익명)');
367
const replierIsParent = (isAnonymous !== 'true' && req.session.username === parentReplyAuthor);
368
const parentIsPostAuthor = parentReplyAuthor === postAuthor; // 이미 위에서 알림 받은 경우 중복 방지
369
if (!parentIsAnonymous && !replierIsParent && !parentIsPostAuthor) {
370
sendPushToUser(parentReplyAuthor, {
371
title: '내 댓글에 답글이 달렸어요',
372
body: `${replierName}: ${replyText}`,
373
url: targetUrl,
374
icon: '/image/title.png'
375
}).catch(() => {});
376
}
377
}
378
379
res.redirect(redirectUrl || `/hinana/index`);
380
});
381
});
382
});
383
384
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
385
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
386
387
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
388
return res.status(400).json({ message: 'post ID, reply ID, 비밀번호가 모두 필요합니다.' });
389
}
390
391
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
392
if (err) return res.status(500).json({ message: '서버 오류 (파일 읽기)' });
393
394
let posts: any[];
395
try {
396
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
397
} catch (parseError) {
398
return res.status(500).json({ message: '서버 오류 (파일 파싱)' });
399
}
400
401
const post = posts.find(p => String(p.id) === String(postId));
402
if (!post) {
403
return res.status(404).json({ message: '게시글을 찾을 수 없습니다.' });
404
}
405
406
let replyFound = false;
407
let replyDeleted = false;
408
409
function findAndRemoveReply(replies: any[]): void {
410
if (!replies) return;
411
412
for (let i = 0; i < replies.length; i++) {
413
const reply = replies[i];
414
415
if (String(reply.id) === String(replyId)) {
416
replyFound = true;
417
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
418
replies.splice(i, 1);
419
replyDeleted = true;
420
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
421
replyDeleted = false;
422
} else {
423
replyDeleted = false;
424
}
425
return;
426
}
427
428
if (reply.replies && reply.replies.length > 0) {
429
findAndRemoveReply(reply.replies);
430
if (replyFound) return;
431
}
432
}
433
}
434
435
findAndRemoveReply(post.replies);
436
437
if (!replyFound) {
438
return res.status(404).json({ message: '답글을 찾을 수 없습니다.' });
439
}
440
if (!replyDeleted) {
441
recordSecurityLog(req, {
442
type: 'access_denied',
443
target: String(postId || ''),
444
action: '메인 댓글 삭제 차단',
445
detail: `댓글 ID: ${replyId}, 익명 비밀번호 불일치 또는 삭제 불가`
446
});
447
return res.status(401).json({ message: '비밀번호가 일치하지 않거나, 암호로 삭제할 수 없는 답글입니다.' });
448
}
449
450
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
451
if (err) {
452
console.error('Error writing posts file:', err);
453
return res.status(500).json({ message: '서버 오류 (파일 쓰기)' });
454
}
455
recordSecurityLog(req, {
456
type: 'feature_use',
457
target: String(postId),
458
action: '메인 댓글 삭제',
459
detail: `댓글 ID: ${replyId}, 익명 비밀번호 삭제`
460
});
461
res.json({ success: true, message: '답글이 삭제되었습니다.' });
462
});
463
});
464
});
465
466
router.get('/post/:id', (req: Request, res: Response) => {
467
const postId = req.params.id;
468
469
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
470
if (err) return res.status(500).send('파일 읽기 오류');
471
472
let posts: any[];
473
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
474
475
const post = posts.find(p => String(p.id) === String(postId));
476
477
if (!post) {
478
return res.status(404).send('Post not found');
479
}
480
481
function highlightHashtags(str: string): string {
482
return str.replace(/#([\w가-힣]+)/g, (_, tag) =>
483
`<a href="/hinana/search?keyword=${encodeURIComponent('#' + tag)}" class="hashtag">#${tag}</a>`
484
);
485
}
486
function linkifyUrls(str: string): string {
487
return str ? str.replace(/(https?:\/\/[^\s<]+)/g, '<a href="#" class="external-link" data-url="$1">$1</a>') : '';
488
}
489
post.content = highlightHashtags(linkifyUrls(nl2br(post.content)));
490
491
if (post.replies && Array.isArray(post.replies)) {
492
post.replies = post.replies.map((reply: any) => ({
493
...reply,
494
content: highlightHashtags(linkifyUrls(nl2br(reply.content)))
495
}));
496
}
497
498
const settings = readSettings();
499
500
res.render('./hinana/post', {
501
post,
502
replies: post.replies || [],
503
username: req.session.username || null,
504
theme: req.session.theme || req.cookies.theme || 'light',
505
isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
506
isGptEnabled: settings.isGptEnabled,
507
isSignupEnabled: settings.isSignupEnabled,
508
verifiedUsers: getVerifiedUsers(),
509
userProfileImages: getUserProfileImages()
510
});
511
});
512
});
513
514
export default router;
515