본문으로 건너뛰기
Public Source Viewer

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

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

Redacted View
src/routes/novel.routes.ts
공개 가능
1 import { Router, Request, Response, NextFunction } from 'express';
2 import multer from 'multer';
3 import { readerPolicy } from '../shared/novel-reader';
4 import { randomBytes } from 'crypto';
5 import { consumeHandoff, inspectHandoff, issueHandoff, parseHandoffRequest } from '../services/novel-handoff.service';
6 import { deleteNovelWork, getNovelWork, listNovelWorks, novelReader, publishNovel } from '../services/novel.service';
7 const router = Router();
8 const handoffBody = multer({ limits: { files: 0, fields: 1, fieldSize: 28 * 1024 * 1024 } }).none();
9 function requirePublisher(req: Request, res: Response, next: NextFunction) {
10 if (!req.session.username) return res.status(401).json({ error: 'hinana.moe에 로그인해 주세요.' });
11 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
12 next();
13 }
14 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
15 router.get('/hinana/works', (req, res, next) => {
16 try {
17 const query = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 120) : '';
18 const allWorks = listNovelWorks();
19 const works = allWorks.filter(w => `${w.title} ${w.author} ${w.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
20 res.render('hinana/works', { ...locals(req), works, query, totalCount: allWorks.length });
21 } catch (error) { next(error); }
22 });
23 router.get('/hinana/works/connect', (req, res) => {
24 res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'origin' });
25 try {
26 const handoff = parseHandoffRequest(req.query);
27 res.set('Content-Security-Policy', `default-src 'self'; style-src 'self'; form-action 'self' ${new URL(handoff.callback).origin}; base-uri 'none'; frame-ancestors 'self'`);
28 if (!req.session.username) return res.redirect('/login?redirect=' + encodeURIComponent(req.originalUrl));
29 res.render('hinana/worksConnect', { ...locals(req), handoff });
30 } catch (error) { res.status(400).send(error instanceof Error ? error.message : '앱에서 게시를 시작해 주세요.'); }
31 });
32 router.post('/hinana/works/connect/approve', requirePublisher, (req, res) => {
33 res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'origin' });
34 try {
35 const handoff = issueHandoff(parseHandoffRequest(req.body), req.session.username);
36 res.redirect(303, handoff.transfer);
37 } catch (error) { res.status(400).send(error instanceof Error ? error.message : '게시를 승인하지 못했습니다.'); }
38 });
39 // An exact CSRF exception exists only for this native form transfer: a short-lived,
40 // single-use browser approval authenticates the digest, metadata and loopback origin.
41 router.post('/hinana/works/desktop-publish', (req, res) => {
42 res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'origin' });
43 if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(req.get('origin') || '')) return res.status(403).send('승인한 앱의 요청이 아닙니다.');
44 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
45 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
46 catch (error) { return res.status(400).send(error instanceof Error ? error.message : '게시 승인을 확인할 수 없습니다.'); }
47 handoffBody(req, res, error => {
48 if (error) return res.status(400).send('원고 전송 형식 또는 크기가 올바르지 않습니다. 앱에서 다시 시작해 주세요.');
49 let ticket;
50 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
51 catch (error) { return res.status(400).send(error instanceof Error ? error.message : '게시 승인을 확인할 수 없습니다.'); }
52 let result;
53 try { result = { success: true, work: publishNovel(req.body.project) }; }
54 catch (error) { result = { success: false, error: error instanceof Error ? error.message : '작품을 게시하지 못했습니다.' }; }
55 const nonce = randomBytes(16).toString('base64');
56 res.set('Content-Security-Policy', `default-src 'none'; style-src 'self'; script-src 'nonce-${nonce}'; form-action ${new URL(ticket.callback).origin}; base-uri 'none'; frame-ancestors 'none'`);
57 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
58 });
59 });
60 // Older apps must stop instead of repeatedly hitting missing endpoints and accruing 404 blocks.
61 router.get('/api/novel/session', (_req, res) => res.status(410).json({ error: '최신 Novel 앱에서 외부 브라우저 게시를 사용해 주세요.' }));
62 router.post('/api/novel/works', (_req, res) => res.status(410).json({ error: '앱의 외부 브라우저 게시로 변경되었습니다.' }));
63 router.delete('/api/novel/works/:id', requirePublisher, (req, res, next) => {
64 try { if (!deleteNovelWork(String(req.params.id))) return res.status(404).json({ error: '작품을 찾을 수 없습니다.' }); res.json({ success: true }); } catch (error) { next(error); }
65 });
66 router.get('/hinana/works/:id', (req, res, next) => {
67 try { const work = getNovelWork(String(req.params.id)); if (!work) return res.status(404).send('작품을 찾을 수 없습니다.'); res.render('hinana/work', { ...locals(req), work }); } catch (error) { next(error); }
68 });
69 router.get('/hinana/works/:id/read', (req, res, next) => {
70 try {
71 const html = novelReader(String(req.params.id));
72 if (!html) return res.status(404).send('작품을 찾을 수 없습니다.');
73 res.set({ 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': readerPolicy() + "; frame-ancestors 'self'; sandbox allow-scripts allow-same-origin", 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
74 res.send(html);
75 } catch (error) { next(error); }
76 });
77 export default router;
78