Public Source Viewer

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

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

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