Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/services/novel-handoff.service.ts
공개 가능
1
import fs from 'fs';
2
import path from 'path';
3
import { createHash, randomBytes } from 'crypto';
4
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
5
import { parseProject } from '../shared/novel-reader';
6
export type HandoffRequest = { callback: string; digest: string; title: string; author: string; chapters: number; characters: number };
7
export type HandoffTicket = HandoffRequest & { expiresAt: number; username: string };
8
const TTL = 5 * 60 * 1000;
9
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
10
export function parseHandoffRequest(value: any): HandoffRequest {
11
if (!value || typeof value.callback !== 'string') throw new Error('Novel 앱에서 게시를 시작해 주세요.');
12
let callback: URL; try { callback = new URL(value.callback); } catch { throw new Error('앱의 반환 주소가 올바르지 않습니다.'); }
13
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
14
if (typeof value.digest !== 'string' || !/^[0-9a-f]{64}$/.test(value.digest)) throw new Error('원고 확인 값이 올바르지 않습니다.');
15
if (typeof value.title !== 'string' || !value.title.trim() || value.title.length > 300 || typeof value.author !== 'string' || value.author.length > 300) throw new Error('작품 정보가 올바르지 않습니다.');
16
const chapters = Number(value.chapters), characters = Number(value.characters);
17
if (!Number.isInteger(chapters) || chapters < 1 || chapters > 500 || !Number.isInteger(characters) || characters < 1 || characters > 28 * 1024 * 1024) throw new Error('작품 분량이 올바르지 않습니다.');
18
return { callback: callback.href, digest: value.digest, title: value.title, author: value.author, chapters, characters };
19
}
20
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
21
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
22
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
23
}
24
function cleanup() {
25
fs.mkdirSync(directory(), { recursive: true });
26
const cutoff = Date.now() - TTL;
27
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
28
if (!/^[a-f0-9]{64}\.(json|used)$/.test(name)) continue;
29
const file = path.join(directory(), name);
30
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
31
}
32
}
33
export function issueHandoff(input: HandoffRequest, username: string) {
34
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
35
const request = parseHandoffRequest(input); cleanup();
36
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
37
const ticket: HandoffTicket = { ...request, username, expiresAt: Date.now() + TTL };
38
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
39
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
40
}
41
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
42
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
43
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
44
if (ticket.expiresAt <= Date.now()) { fs.rmSync(file, { force: true }); throw new Error('게시 승인이 만료되었습니다. 앱에서 다시 시작해 주세요.'); }
45
if (requestOrigin !== new URL(ticket.callback).origin) throw new Error('승인한 앱에서 보낸 요청이 아닙니다.');
46
return ticket;
47
}
48
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
49
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
50
if (typeof raw !== 'string' || Buffer.byteLength(raw) > 28 * 1024 * 1024 || createHash('sha256').update(raw).digest('hex') !== ticket.digest) throw new Error('승인한 원고와 전달된 원고가 일치하지 않습니다.');
51
const p = parseProject(raw);
52
if (p.title !== ticket.title || p.author !== ticket.author || p.chapters.length !== ticket.chapters || p.chapters.reduce((sum, c) => sum + Array.from(c.text).length, 0) !== ticket.characters) throw new Error('브라우저에서 확인한 작품 정보와 원고가 일치하지 않습니다.');
53
// Atomic claim prevents two requests from consuming the same browser approval.
54
try { fs.renameSync(file, file.replace(/\.json$/, '.used')); } catch { throw new Error('이미 처리된 게시 승인입니다.'); }
55
fs.rmSync(file.replace(/\.json$/, '.used'), { force: true });
56
return ticket;
57
}
58