Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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