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