Public Source Viewer

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

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

Redacted View
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