Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/routes/persona.routes.ts
공개 가능
1
import { Router, Request, Response } from 'express';
2
import sanitizeHtml from 'sanitize-html';
3
import {
4
getPersonaByDiscordIdAndSlot,
5
getPersonaByUsername,
6
getPersonasByDiscordId,
7
getPersonasByUsername,
8
MAX_PERSONA_SLOTS,
9
normalizeSlot,
10
savePersona
11
} from '../services/persona.service';
12
import { spendBookmarks, getBookmarks } from '../services/bookmark.service';
13
14
const router = Router();
15
16
const PERSONA_COST = 30;
17
const PERSONA_UPDATE_COST = 20;
18
19
// 페르소나 설정 페이지
20
router.get('/hinana/persona', (req: Request, res: Response) => {
21
if (!req.session.username) return res.redirect(`/login?redirect=${encodeURIComponent(req.originalUrl)}`);
22
const username = req.session.username;
23
24
// discord_id는 Discord 봇 링크에서 최초 1회만 쿼리로 받아 세션에 저장
25
// 이후 URL 조작으로 다른 discord_id를 주입하는 것을 방지
26
if (req.query.discord_id) {
27
req.session.personaDiscordId = req.query.discord_id as string;
28
req.session.personaDiscordName = req.query.discord_name as string || '';
29
}
30
31
const discordId: string = req.session.personaDiscordId || '';
32
const discordName: string = req.session.personaDiscordName || '';
33
const selectedSlot = normalizeSlot(req.query.slot as string || 1);
34
35
const personas = discordId
36
? getPersonasByDiscordId(discordId)
37
: getPersonasByUsername(username);
38
39
const existing = discordId
40
? getPersonaByDiscordIdAndSlot(discordId, selectedSlot)
41
: getPersonaByUsername(username);
42
43
// 기존 페르소나가 있는 경우, 소유자 검증: 세션 유저의 페르소나인지 확인
44
if (personas.some(persona => persona.hinanaUsername !== username)) {
45
return res.status(403).send('본인의 페르소나만 수정할 수 있습니다.');
46
}
47
48
res.render('./hinana/persona', {
49
username,
50
theme: req.session.theme || req.cookies.theme || 'light',
51
discordId,
52
discordName: existing ? existing.discordUsername : discordName,
53
existingPersona: existing,
54
personas,
55
selectedSlot,
56
maxPersonaSlots: MAX_PERSONA_SLOTS,
57
bookmarks: getBookmarks(username),
58
personaCost: PERSONA_COST,
59
personaUpdateCost: PERSONA_UPDATE_COST,
60
saved: req.query.saved,
61
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
62
});
63
});
64
65
// 페르소나 저장
66
router.post('/hinana/persona/save', (req: Request, res: Response) => {
67
if (!req.session.username) return res.redirect('/login');
68
const username = req.session.username;
69
70
// discord_id는 반드시 세션에서만 읽음 — body 주입 불가
71
const discord_id: string = req.session.personaDiscordId || '';
72
const discord_name: string = req.session.personaDiscordName || '';
73
const { persona_name, persona_text } = req.body;
74
const personaSlot = normalizeSlot(req.body.persona_slot);
75
76
if (!discord_id || !persona_text || !persona_text.trim()) {
77
return res.redirect('/hinana/persona?error=' + encodeURIComponent('디스코드 계정 연동 및 페르소나 내용이 필요합니다.'));
78
}
79
80
// \r\n 정규화 후 길이 체크
81
const normalizedText = persona_text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
82
if (normalizedText.trim().length > 2000) {
83
return res.redirect('/hinana/persona?error=' + encodeURIComponent('페르소나는 2000자 이내여야 합니다.'));
84
}
85
86
// 기존 페르소나가 있는 경우 소유자 검증
87
const existing = getPersonaByDiscordIdAndSlot(discord_id, personaSlot);
88
if (existing && existing.hinanaUsername !== username) {
89
return res.redirect('/hinana/persona?error=' + encodeURIComponent('본인의 페르소나만 수정할 수 있습니다.'));
90
}
91
92
const trimmedName = sanitizeHtml((persona_name || '').trim().slice(0, 50), { allowedTags: [] });
93
94
const isNew = !existing;
95
const cost = isNew ? PERSONA_COST : PERSONA_UPDATE_COST;
96
97
const result = spendBookmarks(username, cost);
98
if (!result.success) {
99
return res.redirect('/hinana/persona?error=' + encodeURIComponent(`책갈피가 부족합니다. (필요: ${cost}개, 보유: ${getBookmarks(username)}개)`));
100
}
101
102
try {
103
savePersona({
104
hinanaUsername: username,
105
discordUserId: discord_id,
106
discordUsername: discord_name || 'Unknown',
107
personaSlot,
108
personaName: trimmedName,
109
persona: normalizedText.trim()
110
});
111
} catch (error) {
112
return res.redirect('/hinana/persona?error=' + encodeURIComponent('페르소나는 최대 3개까지만 저장할 수 있어요.'));
113
}
114
115
res.redirect(`/hinana/persona?saved=1&slot=${personaSlot}`);
116
});
117
118
export default router;
119