Public Source Viewer

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

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

Redacted View
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