Public Source Viewer

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

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

Redacted View
src/routes/main.routes.ts
공개 가능
1 import { Router, Request, Response } from 'express';
2 import fs from 'fs';
3 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
4 import { nl2br } from '../services/blog.service';
5 import { readSettings } from '../services/settings.service';
6 import { getVerifiedUsers, getUserProfileImages, getProfileImage, getDailyPostNotif } from '../services/bookmark.service';
7 import { fetchPublicHtml } from '../services/safe-http.service';
8 import { aiRateLimit } from '../middleware/security.middleware';
9
10 const router = Router();
11
12 function renderHome(req: Request, res: Response, classicHome = false): void {
13 const username = req.session.username || null;
14 const theme = req.session.theme || req.cookies.theme || 'light';
15 res.render('./hinana/main', { username, theme, classicHome });
16 }
17
18 router.get('/', (req: Request, res: Response) => {
19 const classicHome = ['1', 'true', 'yes'].includes(String(req.query.classic || '').toLowerCase());
20 renderHome(req, res, classicHome);
21 });
22
23 router.get('/classic', (req: Request, res: Response) => {
24 renderHome(req, res, true);
25 });
26
27 function highlightHashtags(str: string): string {
28 return str ? str.replace(/#([\w가-힣]+)/g, (_, tag) =>
29 `<a href="/hinana/search?keyword=${encodeURIComponent('#' + tag)}" class="hashtag">#${tag}</a>`
30 ) : '';
31 }
32
33 function linkifyUrls(str: string): string {
34 return str ? str.replace(/(https?:\/\/[^\s<]+)/g, '<a href="#" class="external-link" data-url="$1">$1</a>') : '';
35 }
36
37 const processContent = (content: string) => highlightHashtags(linkifyUrls(nl2br(content)));
38
39 router.get('/hinana/index', (req: Request, res: Response) => {
40 const keyword = req.query.keyword ? (req.query.keyword as string).toLowerCase() : '';
41 const page = parseInt(req.query.page as string) || 1;
42 let limit = Math.max(1, parseInt(req.query.limit as string));
43 limit = !isNaN(limit) ? limit : 10;
44 const postsPerPage = 10;
45
46 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
47 if (err) {
48 console.error('게시글 파일 읽기 오류:', err);
49 return res.status(500).send('Internal server error');
50 }
51 let posts: any[];
52 try {
53 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
54 } catch (parseError) {
55 console.error('게시글 파일 파싱 오류:', parseError);
56 return res.status(500).send('Internal server error');
57 }
58
59 posts = posts.filter(post => !post.isPrivate || (req.session.username && post.username === req.session.username));
60
61 // 인기 해시태그 추출 (매일 오후 6시 KST 기준 주기)
62 // 6 PM KST = 9 AM UTC
63 const now = new Date();
64 const lastReset = new Date(now);
65 lastReset.setUTCHours(9, 0, 0, 0);
66 if (now < lastReset) lastReset.setUTCDate(lastReset.getUTCDate() - 1);
67
68 const tagCount: Record<string, number> = {};
69 posts.forEach(post => {
70 const postTime = new Date(post.timestamp);
71 if (isNaN(postTime.getTime()) || postTime < lastReset) return;
72 const matches = (post.content || '').match(/#([\w가-힣]+)/g);
73 if (matches) matches.forEach((m: string) => {
74 tagCount[m] = (tagCount[m] || 0) + 1;
75 });
76 });
77 const trendingTags = Object.entries(tagCount)
78 .map(([tag, count]) => ({ tag, count }))
79 .sort((a, b) => b.count - a.count)
80 .slice(0, 6);
81 const sortOption = (req.query.sort as string) || 'new';
82 if (sortOption === 'old') {
83 posts.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
84 } else if (sortOption === 'new') {
85 posts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
86 } else if (sortOption === 'popular') {
87 posts.sort((a, b) => {
88 const aLikes = a.likes ? a.likes.length : 0;
89 const bLikes = b.likes ? b.likes.length : 0;
90 return bLikes - aLikes;
91 });
92 }
93
94 const totalPages = Math.ceil(posts.length / postsPerPage);
95 const startIndex = (page - 1) * postsPerPage;
96 const endIndex = startIndex + postsPerPage;
97 const paginatedPosts = posts.slice(startIndex, endIndex);
98
99 const processedPosts = paginatedPosts.map(post => {
100 const processedPost = {
101 ...post,
102 content: processContent(post.content)
103 };
104 if (post.replies && Array.isArray(post.replies)) {
105 processedPost.replies = post.replies.map((reply: any) => ({
106 ...reply,
107 content: processContent(reply.content)
108 }));
109 }
110 return processedPost;
111 });
112
113 const settings = readSettings();
114
115 const selectedId = req.query.selectedId ? String(req.query.selectedId).trim() : null;
116 let currentPost = null;
117
118 if (selectedId) {
119 currentPost = posts.find(post => String(post.id) === selectedId);
120 }
121
122 if (!currentPost) {
123 if (paginatedPosts.length > 0) {
124 currentPost = paginatedPosts[0];
125 }
126 }
127
128 res.render('./hinana/index', {
129 posts: processedPosts,
130 username: req.session.username || null,
131 theme: req.session.theme || req.cookies.theme || 'light',
132 keyword: keyword,
133 currentPage: page,
134 totalPages: totalPages,
135 sort: sortOption,
136 sortOption: sortOption,
137 limit: limit,
138 currentPost: currentPost,
139 isSignupEnabled: settings.isSignupEnabled,
140 isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
141 isGptEnabled: settings.isGptEnabled,
142 trendingTags: trendingTags,
143 basePath: '/hinana/index',
144 verifiedUsers: getVerifiedUsers(),
145 userProfileImages: getUserProfileImages(),
146 currentUserProfileImage: req.session.username ? getProfileImage(req.session.username) : null,
147 vapidPublicKey: vapidPublicKey
148 });
149 });
150 });
151
152 // 오늘 올라온 글 수 API (Android WorkManager용)
153 router.get('/api/today-count', (req: Request, res: Response) => {
154 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
155 if (err) return res.json({ count: 0, notifEnabled: false });
156 let posts: any[] = [];
157 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
158 const todayStr = new Date().toISOString().slice(0, 10);
159 const count = posts.filter(p => !p.isPrivate && (p.timestamp || '').startsWith(todayStr)).length;
160 const notifEnabled = req.session.username ? getDailyPostNotif(req.session.username) : false;
161 res.json({ count, notifEnabled });
162 });
163 });
164
165 // 링크 미리보기 캐시 (TTL 10분)
166 const linkPreviewCache = new Map<string, { data: any; timestamp: number }>();
167 const CACHE_TTL = 10 * 60 * 1000;
168
169 function extractMeta(html: string, property: string): string | null {
170 const patterns = [
171 new RegExp(`<meta[^>]+property=["']${property}["'][^>]+content=["']([^"']*)["']`, 'i'),
172 new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${property}["']`, 'i'),
173 new RegExp(`<meta[^>]+name=["']${property}["'][^>]+content=["']([^"']*)["']`, 'i'),
174 new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+name=["']${property}["']`, 'i'),
175 ];
176 for (const re of patterns) {
177 const m = html.match(re);
178 if (m) return m[1];
179 }
180 return null;
181 }
182
183 router.get('/api/link-preview', aiRateLimit, async (req: Request, res: Response) => {
184 const url = req.query.url as string;
185 if (!url || !/^https?:\/\//.test(url)) {
186 return res.json({ error: true });
187 }
188
189 // 캐시 확인
190 const cached = linkPreviewCache.get(url);
191 if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
192 return res.json(cached.data);
193 }
194
195 try {
196 const { html, finalUrl } = await fetchPublicHtml(url);
197 const title = extractMeta(html, 'og:title');
198 const description = extractMeta(html, 'og:description');
199 const image = extractMeta(html, 'og:image');
200 const color = extractMeta(html, 'theme-color');
201
202 if (!title && !description) {
203 const result = { error: true };
204 linkPreviewCache.set(url, { data: result, timestamp: Date.now() });
205 return res.json(result);
206 }
207
208 let domain = '';
209 try { domain = new URL(finalUrl).hostname; } catch {}
210
211 const result = { title, description, image, color, domain };
212 linkPreviewCache.set(url, { data: result, timestamp: Date.now() });
213 res.json(result);
214 } catch {
215 const result = { error: true };
216 linkPreviewCache.set(url, { data: result, timestamp: Date.now() });
217 res.json(result);
218 }
219 });
220
221 router.get('/hinana/ichikawa', async (req: Request, res: Response) => {
222 const render404Noctchill = () => {
223 const randomNum = Math.floor((Math.random() * 99) + 1);
224
225 const url = ['./hinana/404noctchill', './hinana/404hinana', './hinana/404madoka', './hinana/404koito', './hinana/404toru'];
226 const pbt = [1, 27, 25, 24, 23];
227 let response = '';
228
229 let cumulativeProbability = 0;
230 for (let i = 0; i < pbt.length; i++) {
231 cumulativeProbability += pbt[i];
232 if (randomNum <= cumulativeProbability) {
233 response = url[i];
234 return res.render(`${response}`, { theme: req.session.theme || 'light' });
235 }
236 }
237 };
238 render404Noctchill();
239 });
240
241 export default router;
242