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 { 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