Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| face70b3e9 | |||
| 651c63faa6 | |||
| f392842d7f | |||
| 00344084c7 | |||
| 704d58d3ac |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ conversations/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지.
|
||||
.session-secret
|
||||
# 운영 계정(해시 비밀번호) 파일. 추적되는 account.json 대신 이 파일을 사용. 커밋 금지.
|
||||
account.local.json
|
||||
|
||||
3
account.example.json
Normal file
3
account.example.json
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{ "id": "admin", "password": "여기에-비밀번호를-넣으세요" }
|
||||
]
|
||||
@@ -44,8 +44,6 @@
|
||||
"cleanup": "이전 UPnP 매핑 정리 중…",
|
||||
"externalIpHttp": "외부 IP(HTTP): {{ip}}",
|
||||
"externalIpHttpFail": "외부 IP HTTP 조회 실패 → UPnP 게이트웨이로 폴백",
|
||||
"externalIpUpnp": "외부 IP(UPnP): {{ip}}",
|
||||
"externalIpFail": "외부 IP 확인 실패",
|
||||
"probeStart": "외부 포트 점검 시작…",
|
||||
"probeResult": "점검 결과: {{verdict}} ({{detail}})",
|
||||
"preForwarded": "이미 외부에서 접속 가능한 상태입니다.",
|
||||
|
||||
@@ -191,7 +191,6 @@
|
||||
"fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.",
|
||||
"fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.",
|
||||
"portAllocFail": "포트를 할당할 수 없습니다.",
|
||||
"upnpTimeout": "UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.",
|
||||
"parseResponseFailed": "응답 파싱 실패: {{snippet}}"
|
||||
},
|
||||
"log": {
|
||||
@@ -222,15 +221,10 @@
|
||||
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
|
||||
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
|
||||
"serverInstallPath": "서버 설치 경로: {{path}}",
|
||||
"runBatMissing": "run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.",
|
||||
"runBatAlreadyInjected": "run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.",
|
||||
"runBatNoJava": "run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.",
|
||||
"runBatInjected": "run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.",
|
||||
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
|
||||
"eulaAccepted": "EULA 동의 저장 완료.",
|
||||
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
|
||||
"portCheckStart": "포트포워딩 점검 시작: 포트 {{port}}",
|
||||
"upnpCleanup": "이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...",
|
||||
"externalIpHttp": "외부 IP 확인(HTTP): {{ip}}",
|
||||
"externalIpHttpFail": "외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...",
|
||||
"externalIpUpnp": "외부 IP 확인(UPnP): {{ip}}",
|
||||
@@ -242,15 +236,6 @@
|
||||
"probeVerdictUnknown": "확인 불가",
|
||||
"probePreForwarded": "외부에서 {{addr}}:{{port}} 접근 확인됨. 사용자 규칙으로 포워딩 됨.",
|
||||
"ipUnknown": "(IP 미상)",
|
||||
"upnpTryOpen": "UPnP로 포트 {{port}} 자동 개방 시도(TCP)...",
|
||||
"upnpReqOk": "UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.",
|
||||
"upnpTryFail": "UPnP 시도 실패: {{message}}",
|
||||
"upnpFailDetail": "UPnP 실패: {{message}}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.",
|
||||
"upnpRecheck": "UPnP 적용 후 재점검 {{attempt}}/3...",
|
||||
"upnpDone": "UPnP로 포트 {{port}} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).",
|
||||
"upnpCleanupTest": "테스트용 UPnP 매핑을 정리합니다.",
|
||||
"upnpFailReason1": "UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.",
|
||||
"upnpFailReason2": "외부 포트체크 결과를 받지 못했습니다({{detail}}). UPnP 매핑은 등록됐을 수 있습니다.",
|
||||
"upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}",
|
||||
"upnpExternalTimeout": "UPnP externalIp 조회 타임아웃(8s).",
|
||||
"upnpExternalErr": "UPnP externalIp 오류: {{message}}",
|
||||
@@ -261,10 +246,6 @@
|
||||
"detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}",
|
||||
"detailIfconfigFail": "ifconfig.co 실패={{error}}",
|
||||
"detailNone": "결과 없음",
|
||||
"upnpClientFailRemove": "UPnP 클라이언트 생성 실패(매핑 제거 단계): {{message}}",
|
||||
"upnpRemoveTimeout": "UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.",
|
||||
"upnpRemoveAttempt": "UPnP 매핑 제거 시도 결과: {{message}} (없으면 정상)",
|
||||
"upnpRemoveDone": "UPnP 매핑 제거 완료(포트 {{port}}).",
|
||||
"platformDownload": "플랫폼({{type}}) 다운로드: {{url}}",
|
||||
"platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)",
|
||||
"platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.",
|
||||
@@ -301,7 +282,8 @@
|
||||
"launcherAppsFolderFail": "AppsFolder 실행 실패: {{message}}",
|
||||
"launcherUrlSchemeFallback": "마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).",
|
||||
"launcherUrlSchemeFail": "URL 스킴 실행 실패: {{message}}.",
|
||||
"launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요."
|
||||
"launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요.",
|
||||
"internalError": "내부 오류(무시하고 계속): {{message}}"
|
||||
},
|
||||
"candidates": {
|
||||
"winProgramFiles86": "Win32 설치(Program Files (x86))",
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"title": "관리자 로그인",
|
||||
"password": "비밀번호",
|
||||
"submit": "로그인",
|
||||
"wrongPassword": "비밀번호가 올바르지 않습니다."
|
||||
"wrongPassword": "비밀번호가 올바르지 않습니다.",
|
||||
"tooManyAttempts": "로그인 시도가 너무 많습니다. 약 {{minutes}}분 후 다시 시도해 주세요."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "음악퀴즈 목록",
|
||||
@@ -229,6 +230,7 @@
|
||||
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
|
||||
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
|
||||
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다.",
|
||||
"invalidUrl": "올바르지 않은 URL 입니다. http/https 주소만 허용됩니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.3.22",
|
||||
"version": "0.3.23",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -98,6 +98,16 @@ function sendLog(line: string): void {
|
||||
mainWindow.webContents.send('log', stamped)
|
||||
}
|
||||
|
||||
// nat-upnp(detectExternalIpUpnp) 등이 콜백 밖에서 비동기 소켓 오류를 내면 처리되지
|
||||
// 않아 Electron 메인이 종료되며 창이 갑자기 닫힐 수 있다. 전역 가드로 잡아 로그만
|
||||
// 남기고 앱은 계속 살려 둔다(포트포워딩 전용 도구 v0.3.18 과 동일한 대비책).
|
||||
process.on('uncaughtException', (err) => {
|
||||
try { sendLog(t('log.internalError', { message: (err as Error)?.message || String(err) })) } catch {}
|
||||
})
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {}
|
||||
})
|
||||
|
||||
function fetchBuffer(url: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = new URL(url)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import crypto from 'node:crypto'
|
||||
import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
fileDirPath, viewsDirPath, publicDirPath
|
||||
fileDirPath, viewsDirPath, publicDirPath, projectRoot,
|
||||
accountFilePath, accountLocalFilePath
|
||||
} from '../shared/paths.js'
|
||||
import {
|
||||
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
|
||||
@@ -25,7 +28,24 @@ const app = express()
|
||||
|
||||
app.set('view engine', 'ejs')
|
||||
app.set('views', viewsDirPath)
|
||||
app.set('trust proxy', 1)
|
||||
// 리버스 프록시 뒤일 때만 켠다. 항상 켜두면 직접 노출 시 X-Forwarded-For 조작으로
|
||||
// req.ip 를 위조해 로그인 rate limit 을 우회할 수 있다. 프록시 뒤라면 TRUST_PROXY=true.
|
||||
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : false)
|
||||
|
||||
// 추적되는 account.json(과거 평문 노출)을 gitignore 된 account.local.json 으로 시드한다.
|
||||
// 로컬 파일이 이미 있으면 건드리지 않음. 이후 계정 쓰기/자동 해시 업그레이드는 로컬
|
||||
// 파일에만 반영되어, 재배포로 account.json 을 추적 해제해도 로그인이 유지된다.
|
||||
function seedLocalAccounts(): void {
|
||||
try {
|
||||
if (fs.existsSync(accountLocalFilePath)) return
|
||||
if (!fs.existsSync(accountFilePath)) return
|
||||
fs.copyFileSync(accountFilePath, accountLocalFilePath)
|
||||
fs.chmodSync(accountLocalFilePath, 0o600)
|
||||
} catch {
|
||||
// 실패해도 readAccounts 가 account.json 으로 폴백하므로 치명적이지 않음.
|
||||
}
|
||||
}
|
||||
seedLocalAccounts()
|
||||
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json())
|
||||
@@ -38,13 +58,36 @@ app.use((_req, res, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
// 세션 시크릿: 환경변수 우선, 없으면 하드코딩(위조 위험) 대신 영구 랜덤 시크릿을
|
||||
// 파일로 생성/보관한다(재시작해도 세션 유지). 파일 접근 불가 시엔 프로세스 수명 동안만
|
||||
// 유효한 랜덤값으로 폴백(그래도 하드코딩보다 안전).
|
||||
function resolveSessionSecret(): string {
|
||||
const fromEnv = process.env.SESSION_SECRET
|
||||
if (fromEnv && fromEnv.length >= 16) return fromEnv
|
||||
const secretPath = path.join(projectRoot, '.session-secret')
|
||||
try {
|
||||
if (fs.existsSync(secretPath)) {
|
||||
const existing = fs.readFileSync(secretPath, 'utf8').trim()
|
||||
if (existing.length >= 16) return existing
|
||||
}
|
||||
const generated = crypto.randomBytes(32).toString('hex')
|
||||
fs.writeFileSync(secretPath, generated, { mode: 0o600 })
|
||||
return generated
|
||||
} catch {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
}
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
||||
secret: resolveSessionSecret(),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
// HTTPS 전용 배포면 SESSION_COOKIE_SECURE=true 로 secure 쿠키 활성화.
|
||||
// HTTP 접근이 섞이면 로그인 쿠키가 안 실리므로 기본값은 false.
|
||||
secure: process.env.SESSION_COOKIE_SECURE === 'true',
|
||||
maxAge: 1000 * 60 * 60 * 8
|
||||
}
|
||||
}))
|
||||
@@ -143,7 +186,7 @@ app.use((req, res, next) => {
|
||||
})
|
||||
|
||||
// 모드 폴더 안의 .jar 파일 목록을 JSON으로 반환. 설치기가 자동 다운로드용으로 사용.
|
||||
app.get('/file/mods/:folder/index.json', async (req, res) => {
|
||||
app.get('/file/mods/:folder/index.json', async (req, res, next) => {
|
||||
const folder = req.params.folder
|
||||
if (!/^[a-zA-Z0-9_\-]+$/.test(folder)) {
|
||||
res.status(404).json({ files: [] })
|
||||
@@ -162,7 +205,8 @@ app.get('/file/mods/:folder/index.json', async (req, res) => {
|
||||
res.status(404).json({ files: [] })
|
||||
return
|
||||
}
|
||||
throw error
|
||||
// async 핸들러의 throw 는 Express4 가 잡지 못해 unhandledRejection 이 되므로 next 로 위임.
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
48
src/server/password.ts
Normal file
48
src/server/password.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// 운영자 비밀번호 저장/검증. 외부 의존성 없이 Node 내장 scrypt 사용.
|
||||
// 저장 형식: `scrypt$<saltHex>$<hashHex>`. 검증은 항상 상수시간 비교.
|
||||
// 기존 account.json 의 평문 비밀번호는 verifyPassword 가 그대로 검증할 수 있고,
|
||||
// 로그인 성공 시 호출측에서 hashPassword 로 재저장(자동 업그레이드)한다.
|
||||
|
||||
const SCHEME = 'scrypt'
|
||||
const KEY_LEN = 32
|
||||
const SALT_LEN = 16
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
const salt = crypto.randomBytes(SALT_LEN)
|
||||
const hash = crypto.scryptSync(plain, salt, KEY_LEN)
|
||||
return `${SCHEME}$${salt.toString('hex')}$${hash.toString('hex')}`
|
||||
}
|
||||
|
||||
export function isHashed(stored: string): boolean {
|
||||
return typeof stored === 'string' && stored.startsWith(`${SCHEME}$`)
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, stored: string): boolean {
|
||||
if (typeof stored !== 'string' || stored.length === 0) return false
|
||||
if (isHashed(stored)) {
|
||||
const parts = stored.split('$')
|
||||
if (parts.length !== 3) return false
|
||||
let salt: Buffer
|
||||
let expected: Buffer
|
||||
try {
|
||||
salt = Buffer.from(parts[1], 'hex')
|
||||
expected = Buffer.from(parts[2], 'hex')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (expected.length === 0) return false
|
||||
let derived: Buffer
|
||||
try {
|
||||
derived = crypto.scryptSync(plain, salt, expected.length)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return derived.length === expected.length && crypto.timingSafeEqual(derived, expected)
|
||||
}
|
||||
// 레거시 평문: 길이 노출을 피하려 양쪽을 sha256 으로 고정 길이화한 뒤 상수시간 비교.
|
||||
const a = crypto.createHash('sha256').update(plain, 'utf8').digest()
|
||||
const b = crypto.createHash('sha256').update(stored, 'utf8').digest()
|
||||
return crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
@@ -8,9 +8,9 @@ indexRouter.get('/', async (_req, res, next) => {
|
||||
const manifest = await readManifest()
|
||||
const definitionMap = new Map<string, Awaited<ReturnType<typeof loadPackDefinition>>>()
|
||||
const keys = await listPackKeys()
|
||||
for (const key of keys) {
|
||||
definitionMap.set(key, await loadPackDefinition(key))
|
||||
}
|
||||
// 팩 정의를 병렬 로드(op.ts 와 동일 패턴). 순차 await 보다 빠름.
|
||||
const definitions = await Promise.all(keys.map((key) => loadPackDefinition(key)))
|
||||
keys.forEach((key, i) => definitionMap.set(key, definitions[i]))
|
||||
const packs = manifest.packs.map((entry) => ({
|
||||
name: entry.name,
|
||||
file: entry.file,
|
||||
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
saveTerm,
|
||||
savePackList,
|
||||
setPackPublic,
|
||||
setTermVisibility
|
||||
setTermVisibility,
|
||||
writeAccounts
|
||||
} from '../../shared/store.js'
|
||||
import { hashPassword, isHashed, verifyPassword } from '../password.js'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
@@ -33,6 +35,40 @@ import { buildSongsMcfunction } from '../datapack.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
// 로그인 브루트포스 + scrypt CPU 남용 방지용 IP 기준 인메모리 실패 제한.
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000
|
||||
const LOGIN_MAX_FAILS = 10
|
||||
const loginFails = new Map<string, { count: number; first: number; blockedUntil: number }>()
|
||||
|
||||
function loginClientKey(req: { ip?: string; socket?: { remoteAddress?: string } }): string {
|
||||
return req.ip || req.socket?.remoteAddress || 'unknown'
|
||||
}
|
||||
|
||||
/** 차단 중이면 남은 ms, 아니면 0. 접근 시 만료된 항목은 정리. */
|
||||
function loginBlockedMs(key: string): number {
|
||||
const now = Date.now()
|
||||
const entry = loginFails.get(key)
|
||||
if (!entry) return 0
|
||||
if (entry.blockedUntil > now) return entry.blockedUntil - now
|
||||
if (now - entry.first > LOGIN_WINDOW_MS) loginFails.delete(key)
|
||||
return 0
|
||||
}
|
||||
|
||||
function recordLoginFail(key: string): void {
|
||||
const now = Date.now()
|
||||
let entry = loginFails.get(key)
|
||||
if (!entry || now - entry.first > LOGIN_WINDOW_MS) entry = { count: 0, first: now, blockedUntil: 0 }
|
||||
entry.count += 1
|
||||
if (entry.count >= LOGIN_MAX_FAILS) entry.blockedUntil = now + LOGIN_WINDOW_MS
|
||||
loginFails.set(key, entry)
|
||||
// 맵 무한 성장 방지(분산 시도 대비): 상한 초과 시 만료 항목 정리.
|
||||
if (loginFails.size > 5000) {
|
||||
for (const [k, v] of loginFails) {
|
||||
if (v.blockedUntil <= now && now - v.first > LOGIN_WINDOW_MS) loginFails.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickFirstValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
|
||||
return typeof value === 'string' ? value : ''
|
||||
@@ -56,13 +92,32 @@ opRouter.get('/op', (req, res) => {
|
||||
|
||||
opRouter.post('/op', async (req, res, next) => {
|
||||
try {
|
||||
const clientKey = loginClientKey(req)
|
||||
const blockedMs = loginBlockedMs(clientKey)
|
||||
if (blockedMs > 0) {
|
||||
res.status(429).render('op/login', {
|
||||
error: t('login.tooManyAttempts', { minutes: Math.ceil(blockedMs / 60000) })
|
||||
})
|
||||
return
|
||||
}
|
||||
const password = pickFirstValue(req.body.password)
|
||||
const accounts = await readAccounts()
|
||||
const matched = accounts.find((entry) => entry.password === password)
|
||||
const matched = accounts.find((entry) => verifyPassword(password, entry.password))
|
||||
if (!matched) {
|
||||
recordLoginFail(clientKey)
|
||||
res.status(401).render('op/login', { error: t('login.wrongPassword') })
|
||||
return
|
||||
}
|
||||
loginFails.delete(clientKey)
|
||||
// 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드.
|
||||
if (!isHashed(matched.password)) {
|
||||
try {
|
||||
matched.password = hashPassword(password)
|
||||
await writeAccounts(accounts)
|
||||
} catch {
|
||||
// 업그레이드 실패는 로그인 자체에 영향 주지 않음.
|
||||
}
|
||||
}
|
||||
req.session.userId = matched.id
|
||||
res.redirect('/op/dashboard')
|
||||
} catch (error) {
|
||||
|
||||
@@ -299,7 +299,15 @@ async function runYtDlp(args: string[], makeError: (code: string, detail: string
|
||||
* 단일 영상 URL 의 메타데이터를 가져온다.
|
||||
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
|
||||
*/
|
||||
/** 운영자 입력 URL 이 yt-dlp 인자(플래그)로 오인되지 않도록 http(s) 스킴만 허용. */
|
||||
function assertHttpUrl(url: string): void {
|
||||
if (!/^https?:\/\//i.test(url.trim())) {
|
||||
throw new Error(t('youtube.invalidUrl'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
|
||||
assertHttpUrl(url)
|
||||
const stdout = await runYtDlp(
|
||||
['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url],
|
||||
(code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail }))
|
||||
@@ -327,6 +335,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
|
||||
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
||||
*/
|
||||
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
||||
assertHttpUrl(url)
|
||||
const stdout = await runYtDlp(
|
||||
['--flat-playlist', '--dump-json', '--no-warnings', url],
|
||||
(code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))
|
||||
|
||||
@@ -6,7 +6,10 @@ export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const manifestTermsDirPath = path.join(manifestDirPath, 'terms')
|
||||
// 추적되는 account.json(과거 평문 노출)을 대체할, gitignore 된 운영 계정 파일.
|
||||
// readAccounts 는 이 파일을 우선 사용하고, 없을 때만 account.json 을 시드로 읽는다.
|
||||
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||
export const accountLocalFilePath = path.join(projectRoot, 'account.local.json')
|
||||
export const fileDirPath = path.join(projectRoot, 'file')
|
||||
export const fileListDirPath = path.join(fileDirPath, 'list')
|
||||
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
|
||||
|
||||
@@ -3,7 +3,7 @@ import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
accountFilePath, fileListDirPath
|
||||
accountFilePath, accountLocalFilePath, fileListDirPath
|
||||
} from './paths.js'
|
||||
import type {
|
||||
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
|
||||
@@ -778,14 +778,24 @@ export function isPublicTermsFile(packKey: string, fileName: string): boolean {
|
||||
}
|
||||
|
||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((entry): entry is AccountEntry =>
|
||||
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
// gitignore 된 account.local.json 우선. 없으면(ENOENT) 추적되는 account.json 을 시드로.
|
||||
for (const filePath of [accountLocalFilePath, accountFilePath]) {
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((entry): entry is AccountEntry =>
|
||||
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지).
|
||||
// 비밀번호(해시) 파일이므로 소유자만 읽기/쓰기(0o600).
|
||||
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
|
||||
await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user