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