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