diff --git a/.env.example b/.env.example index 82ee18a..eac2337 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ +PROXY_STREAMS=true PROXY_URL= -PROXY_STREAMS=true +TMDB_API_KEY= ENABLE_DEBUG_ROUTE=false -TMDB_API_KEY= - GA_MEASUREMENT_ID= GA_API_SECRET= -TOKEN_SECRET= - -BYPASS_AUTH= \ No newline at end of file +PORT=7860 +WORKER_COUNT=1 \ No newline at end of file diff --git a/.gitlab-ci.yml b/.github/old_workflows/.gitlab-ci.yml similarity index 100% rename from .gitlab-ci.yml rename to .github/old_workflows/.gitlab-ci.yml diff --git a/add-key.mjs b/add-key.mjs deleted file mode 100644 index 4efbac2..0000000 --- a/add-key.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import crypto from 'crypto'; - -const [, , type, rpmArg, label] = process.argv; - -if (!type || !rpmArg) { - console.log('Usage: node database/add-key.mjs [label]'); - console.log('Example: node database/add-key.mjs standard 500 flixora'); - process.exit(1); -} - -const rpm = parseInt(rpmArg, 10); -if (isNaN(rpm) || rpm <= 0) { - console.log('rpm must be a positive number'); - process.exit(1); -} - -const prefix = type === 'partner' ? 'pk' : type === 'public' ? 'pub' : 'sk'; -const suffix = crypto.randomBytes(16).toString('hex'); -const key = label ? `${prefix}_${label}_${suffix}` : `${prefix}_${suffix}`; - -const db = new DatabaseSync('./data/api_keys.db'); -db.exec('PRAGMA journal_mode = WAL;'); - -db.prepare(` - INSERT INTO api_keys (key, type, rpm, active) - VALUES (?, ?, ?, 1) -`).run(key, type, rpm); - -console.log('Key created:'); -console.log(key); \ No newline at end of file diff --git a/db.js b/db.js deleted file mode 100644 index 38ced73..0000000 --- a/db.js +++ /dev/null @@ -1,60 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const DB_DIR = path.join(__dirname, 'data'); -const DB_PATH = path.join(DB_DIR, 'api_keys.db'); - -if (!fs.existsSync(DB_DIR)) { - fs.mkdirSync(DB_DIR, { recursive: true }); -} - -const db = new DatabaseSync(DB_PATH); -db.exec('PRAGMA journal_mode = WAL;'); -db.exec('PRAGMA busy_timeout = 5000;'); - -export async function ensureApiKeysTable() { - db.exec(` - CREATE TABLE IF NOT EXISTS api_keys ( - key TEXT PRIMARY KEY, - type TEXT NOT NULL DEFAULT 'standard', - rpm INTEGER NOT NULL DEFAULT 100, - active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - `); -} - -export async function fetchDisabledApiKeys() { - return db.prepare(` - SELECT key - FROM api_keys - WHERE active = 0 - `).all(); -} - -export async function fetchActiveApiKeys() { - return db.prepare(` - SELECT key, type, rpm - FROM api_keys - WHERE active = 1 - `).all(); -} - -export async function ensurePublicKey() { - const existing = db.prepare(` - SELECT key FROM api_keys WHERE key = 'public_api_key' - `).get(); - - if (!existing) { - db.prepare(` - INSERT INTO api_keys (key, type, rpm, active) - VALUES ('public_api_key', 'public', 10, 1) - `).run(); - } -} - -export default db; \ No newline at end of file diff --git a/export-db.mjs b/export-db.mjs deleted file mode 100644 index 59b122d..0000000 --- a/export-db.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const DB_PATH = path.join(__dirname, "data", "api_keys.db"); -const OUTPUT = path.join(__dirname, "db.json"); - -const db = new DatabaseSync(DB_PATH); - -const keys = db.prepare(` - SELECT * - FROM api_keys -`).all(); - -fs.writeFileSync( - OUTPUT, - JSON.stringify(keys, null, 4) -); - -console.log(`Exported ${keys.length} API keys to db.json`); \ No newline at end of file diff --git a/package.json b/package.json index 9555032..815f133 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "Stream API — multi-provider stream scraper", "type": "module", "dependencies": { - "@vyla-entertainment/sdk": "^1.9.0", - "dotenv": "^17.4.2" + "@vyla-entertainment/sdk": "^1.12.0", + "dotenv": "^17.4.2", + "latest": "^0.2.0" } } \ No newline at end of file diff --git a/server.js b/server.js index e5dd43d..94039d3 100644 --- a/server.js +++ b/server.js @@ -9,7 +9,6 @@ import { CACHE_TTL } from './config.js'; import { handleSubtitleMovie, handleSubtitleTv } from './src/routes/subtitles.js'; import { handleDownloadMovie, handleDownloadTv } from './src/routes/downloads/main.js'; import { handleHealth } from './src/routes/health.js'; -import { authenticateRequest, checkRateLimit, canAccess, issueSessionToken, refreshSessionToken, initAuth } from './src/middleware/auth.js'; import { wrapUrl } from './src/utils/proxy.js'; import { handleTestRoute, handleDebugRoute } from './src/routes/test.js'; import { getUA, validateTmdbId } from './src/utils/helpers.js'; @@ -635,51 +634,192 @@ function normalizeCandidates(rawResult) { return candidates; } +const LOOPBACK = `http://127.0.0.1:${PORT}`; +const MAX_STREAMS_PER_SOURCE = 8; +const VERIFY_BUDGET_MS = 15_000; + +function toLoopback(url, absoluteBase) { + const variants = new Set([ + absoluteBase, + absoluteBase.replace(/^http:\/\//, 'https://'), + absoluteBase.replace(/^https:\/\//, 'http://'), + ]); + for (const b of variants) { + if (url.startsWith(`${b}/api?`)) return LOOPBACK + url.slice(b.length); + } + return url; +} + +async function readHead(res, limit) { + const reader = res.body?.getReader(); + if (!reader) return Buffer.alloc(0); + const chunks = []; + let total = 0; + try { + while (total < limit) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + } finally { + reader.cancel().catch(() => { }); + } + return Buffer.concat(chunks).subarray(0, limit); +} + +function looksLikeMedia(buf) { + if (buf.length < 8) return false; + if (buf[0] === 0x47 && (buf.length < 189 || buf[188] === 0x47)) return true; + const box = buf.subarray(4, 8).toString('latin1'); + if (['ftyp', 'styp', 'moof', 'moov', 'sidx', 'mdat', 'free', 'wide'].includes(box)) return true; + if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return true; + if (buf[0] === 0xFF && (buf[1] & 0xF0) === 0xF0) return true; + if (buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3) return true; + return false; +} + +function firstUri(text, base) { + for (const line of text.split('\n')) { + const t = line.trim(); + if (t && t.charCodeAt(0) !== 35) return new URL(t, base).href; + } + return null; +} + +async function verifyServed(finalUrl, headers, absoluteBase) { + const cacheKey = `served-${finalUrl}`; + const cached = hlsVerifyCache.get(cacheKey); + if (cached !== undefined) return cached; + const store = ok => { hlsVerifyCache.set(cacheKey, ok); return ok; }; + + const get = url => _nativeFetch(toLoopback(url, absoluteBase), { + headers: { 'User-Agent': getUA(), ...headers }, + redirect: 'follow', + signal: AbortSignal.timeout(10_000), + }); + + const accept = async (r, limit) => { + if (!r.ok && r.status !== 206) { r.body?.cancel(); return null; } + const ct = (r.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('text/html') || ct.includes('json')) { r.body?.cancel(); return null; } + return readHead(r, limit); + }; + + try { + let res = await get(finalUrl); + let buf = await accept(res, 65536); + if (!buf) return store(false); + let text = buf.toString('utf8'); + let base = res.url || finalUrl; + + if (!/^\s*#EXTM3U/.test(text)) { + if (text.includes(' /^#EXT-X-KEY:/.test(l.trim()) && !/METHOD=NONE/.test(l)); + const encrypted = !!keyLine; + if (keyLine) { + const m = /URI="([^"]+)"/.exec(keyLine); + if (m && !m[1].startsWith('data:')) { + const kres = await get(new URL(m[1], base).href); + const kb = await accept(kres, 64); + if (!kb || kb.length !== 16) return store(false); + } + } + + const segUrl = firstUri(text, base); + if (!segUrl) return store(false); + const sres = await get(segUrl); + const sb = await accept(sres, 4096); + if (!sb || sb.length < 188) return store(false); + + if (encrypted) return store(!/^\s*[<{]/.test(sb.toString('latin1', 0, 16))); + return store(looksLikeMedia(sb)); + } catch { + return false; + } +} + +async function verifyCandidate(candidate, sourceKey, absoluteBase) { + if (!candidate?.url) return null; + const wrapped = wrapUrl(candidate, sourceKey, absoluteBase, sdk); + if (!wrapped) return null; + + const viaProxy = wrapped !== candidate.url || toLoopback(wrapped, absoluteBase) !== wrapped; + const ok = await withTimeout( + verifyServed(wrapped, viaProxy ? {} : (candidate.headers ?? {}), absoluteBase), + VERIFY_BUDGET_MS + ); + if (!ok) return null; + + const out = { url: wrapped, raw_url: candidate.url, proxied: viaProxy }; + if (!viaProxy && candidate.headers && Object.keys(candidate.headers).length) out.headers = candidate.headers; + for (const k of ['quality', 'type', 'name', 'language']) { + if (candidate[k] !== undefined) out[k] = candidate[k]; + } + return out; +} + async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) { const start = Date.now(); - const sources = sdk.getSources(); - const cfg = sources.find(c => c.key === sourceKey); + const cfg = sdk.getSources().find(c => c.key === sourceKey); const absoluteBase = getAbsoluteBase(host); - const respond = (ok, url, raw_url, error, debug) => ({ + const respond = result => ({ status: 200, body: JSON.stringify({ source: sourceKey, id, s: s || null, e: e || null, - ok, - url: ok ? url : null, - raw_url, + ok: !!result?.ok, + url: result?.ok ? result.url : null, + streams: result?.ok ? result.streams : [], + raw_url: result?.raw_url ?? null, elapsed_ms: Date.now() - start, - error: ok ? null : error, - ...(debug ? { debug } : {}), + error: result?.ok ? null : (result?.error ?? null), + ...(result?.debug ? { debug: result.debug } : {}), }, null, 2), contentType: 'application/json', }); - if (cfg?.disabled) return respond(false, null, null, 'source disabled'); + if (!cfg) return respond({ ok: false, error: 'unknown source' }); + if (cfg.disabled) return respond({ ok: false, error: 'source disabled' }); - const cacheKey = `test-${sourceKey}-${id}-${s ?? ''}-${e ?? ''}`; + const cacheKey = `test-${sourceKey}-${id}-${s ?? ''}-${e ?? ''}-${host}`; const localCached = testResultCache.get(cacheKey); - if (localCached !== undefined) return respond(localCached.ok, localCached.url, localCached.raw_url, localCached.error); + if (localCached !== undefined) return respond(localCached); const shared = await sharedCacheGet(cacheKey); - if (shared !== undefined) { testResultCache.set(cacheKey, shared); return respond(shared.ok, shared.url, shared.raw_url, shared.error); } + if (shared !== undefined) { testResultCache.set(cacheKey, shared); return respond(shared); } const inflightKey = `inflight-${cacheKey}`; const existing = sharedInflight.get(inflightKey); if (existing) { try { - const result = await existing; - return respond(result?.ok ?? false, result?.url ?? null, result?.raw_url ?? null, result?.error ?? null); + return respond((await existing) ?? { ok: false, error: 'timed out' }); } catch { - return respond(false, null, null, 'deduped request failed'); + return respond({ ok: false, error: 'deduped request failed' }); } } if (sharedInflight.size >= MAX_SHARED_INFLIGHT) { - return respond(false, null, null, 'server busy, try again shortly'); + return respond({ ok: false, error: 'server busy, try again shortly' }); } await acquireTestSlot(); @@ -687,147 +827,41 @@ async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) { const testPromise = withTimeout((async () => { try { let rawResult = null, fetchError = null; - const audio = /dub$/.test(cfg.key) ? 'dub' : 'sub'; try { rawResult = await fetchSource(cfg, `${id}-${s ?? ''}-${e ?? ''}`, id, s, e, clientIP, absoluteBase); if (!rawResult) rawResult = await withTimeout(sdk.getStream(sourceKey, id, s, e), 20_000); } catch (err) { fetchError = err.message; } - const candidates = normalizeCandidates(rawResult); + const seen = new Set(); + const candidates = normalizeCandidates(rawResult) + .filter(c => { + const u = typeof c?.url === 'string' ? c.url : null; + if (!u || seen.has(u)) return false; + seen.add(u); + return true; + }) + .slice(0, MAX_STREAMS_PER_SOURCE); - for (const candidate of candidates) { - const wrappedUrl = wrapUrl(candidate, sourceKey, absoluteBase, sdk); - if (!wrappedUrl) continue; + const verified = (await Promise.all( + candidates.map(c => verifyCandidate(c, sourceKey, absoluteBase).catch(() => null)) + )).filter(Boolean); - if (candidate.type === 'dash' || /\.mpd(\?|$)/i.test(candidate.url)) { - try { - const headRes = await _nativeFetch(candidate.url, { - method: 'HEAD', - headers: { 'User-Agent': getUA(), ...(candidate.headers ?? {}) }, - signal: AbortSignal.timeout(6_000), - redirect: 'follow', - }); - headRes.body?.cancel(); - if (headRes.status < 400) { - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - testResultCache.set(cacheKey, result); - sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000); - return result; - } - } catch { } - continue; - } - - if (candidate?.skipProxy) { - const check = await verifyPlayable(candidate.url, candidate.headers ?? {}, true); - if (check.ok || /timeout|aborted/i.test(check.error ?? '')) { - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - testResultCache.set(cacheKey, result); - sharedCacheSet(cacheKey, result, 90_000); - return result; - } - try { - const headRes = await _nativeFetch(candidate.url, { - method: 'HEAD', - headers: { 'User-Agent': getUA(), ...(candidate.headers ?? {}) }, - signal: AbortSignal.timeout(6_000), - redirect: 'follow', - }); - headRes.body?.cancel(); - const ct = (headRes.headers.get('content-type') || '').toLowerCase(); - if (headRes.status < 400 && (!ct || /video|octet-stream|mp4/.test(ct) && !ct.includes('mpegurl'))) { - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - testResultCache.set(cacheKey, result); - sharedCacheSet(cacheKey, result, 90_000); - return result; - } - } catch { } - continue; - } - - if (candidate?.skipHlsCheck) { - try { - const r = await _nativeFetch(wrappedUrl, { - signal: AbortSignal.timeout(8_000), - headers: { 'User-Agent': getUA() }, - }); - if (!r.ok) { - const body = await r.text(); - return { ok: false, url: null, raw_url: candidate.url, error: `skipHlsCheck proxy failed: ${r.status} - ${body.slice(0, 100)}` }; - } - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - testResultCache.set(cacheKey, result); - sharedCacheSet(cacheKey, result, 90_000); - return result; - } catch (err) { - return { ok: false, url: null, raw_url: candidate.url, error: `skipHlsCheck exception: ${err.message}` }; - } - } - - if (cfg.skipVerify || cfg.multiUrl) { - const checkUrl = IS_HF ? candidate.url : wrappedUrl; - const checkHeaders = IS_HF ? (candidate.headers ?? {}) : {}; - - const check = await verifyPlayable(checkUrl, checkHeaders, false); - - if (check.ok) { - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - if (!rawResult?.skipCache) { testResultCache.set(cacheKey, result); sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000); } - return result; - } - - if (/timeout|aborted/i.test(check.error ?? '')) { - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - if (!rawResult?.skipCache) { testResultCache.set(cacheKey, result); sharedCacheSet(cacheKey, result, 15_000); } - return result; - } - - try { - const headRes = await _nativeFetch(candidate.url, { - method: 'HEAD', - headers: { 'User-Agent': getUA(), ...(candidate.headers ?? {}) }, - signal: AbortSignal.timeout(6_000), - redirect: 'follow', - }); - headRes.body?.cancel(); - const ct = (headRes.headers.get('content-type') || '').toLowerCase(); - if (headRes.status < 400 && /video|octet-stream|mp4/.test(ct) && !ct.includes('mpegurl')) { - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - testResultCache.set(cacheKey, result); - sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000); - return result; - } - } catch { } - continue; - } - - if (!(await verifyStream(candidate.url, sourceKey, candidate?.headers ?? {}))) continue; - - const verifyUrl = IS_HF ? candidate.url : wrappedUrl; - const verifyHeaders = IS_HF ? (candidate.headers ?? {}) : {}; - const check = await verifyPlayable(verifyUrl, verifyHeaders, IS_HF); - - if (!check.ok) { - const rawHeaders = candidate?.headers ?? {}; - const [proxiedBody, rawCheck] = await Promise.all([ - _nativeFetch(wrappedUrl, { signal: AbortSignal.timeout(10_000), headers: { 'User-Agent': getUA() } }) - .then(r => r.text()).then(t => t.slice(0, 200)).catch(e => e.message), - verifyPlayable(candidate.url, rawHeaders, true), - ]); - return { - ok: false, url: null, raw_url: candidate.url, error: check.error, - debug: { proxy_failed: true, proxy_error: check.error, proxy_body_preview: proxiedBody, raw_reachable: rawCheck.ok, raw_error: rawCheck.error, raw_headers_used: rawHeaders, proxied_url: wrappedUrl }, - }; - } - - const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; - testResultCache.set(cacheKey, result); - sharedCacheSet(cacheKey, result, 90_000); - return result; + if (!verified.length) { + return { + ok: false, + url: null, + raw_url: candidates[0]?.url || null, + error: fetchError || (candidates.length ? 'no playable streams' : 'no streams returned'), + }; } - return { ok: false, url: null, raw_url: candidates[0]?.url || null, error: fetchError }; + const result = { ok: true, url: verified[0].url, raw_url: verified[0].raw_url, streams: verified }; + if (!rawResult?.skipCache) { + testResultCache.set(cacheKey, result); + sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000); + } + return result; } finally { releaseTestSlot(); sharedInflight.delete(inflightKey); @@ -836,7 +870,7 @@ async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) { sharedInflight.set(inflightKey, testPromise); const result = await testPromise; - return respond(result?.ok ?? false, result?.url ?? null, result?.raw_url ?? null, result?.error ?? null, result?.debug ?? null); + return respond(result ?? { ok: false, error: 'timed out' }); } async function streamSources(sources, id, s, e, clientIP, absoluteBase, res) { @@ -860,10 +894,31 @@ async function streamSources(sources, id, s, e, clientIP, absoluteBase, res) { const result = await handleTestSource(sdk, cfg.key, id, s, e, clientIP, host); if (closed) return; const parsed = JSON.parse(result.body); - debugResults.push({ source: cfg.key, ok: parsed.ok, error: parsed.error || null, elapsed_ms: parsed.elapsed_ms }); - if (parsed.ok && parsed.url && !sent.has(parsed.url)) { - sent.add(parsed.url); - safeWrite(`data: ${JSON.stringify({ type: 'source', source: { source: cfg.key, label: cfg.label ?? cfg.key, url: parsed.url } })}\n\n`); + debugResults.push({ + source: cfg.key, + ok: parsed.ok, + streams: parsed.streams?.length ?? 0, + error: parsed.error || null, + elapsed_ms: parsed.elapsed_ms, + }); + if (!parsed.ok) return; + + const label = cfg.label ?? cfg.key; + let n = 0; + for (const stream of parsed.streams || []) { + if (sent.has(stream.url)) continue; + sent.add(stream.url); + n++; + const { raw_url, ...publicStream } = stream; + safeWrite(`data: ${JSON.stringify({ + type: 'source', + source: { + source: cfg.key, + label: n > 1 ? `${label} ${n}` : label, + index: n - 1, + ...publicStream, + }, + })}\n\n`); } } catch (err) { debugResults.push({ source: cfg.key, ok: false, error: err.message }); @@ -895,47 +950,6 @@ async function handleRequest(req, res) { if (req.method === 'OPTIONS') return { status: 204, body: '', headers: CORS_HEADERS }; - const PUBLIC_ROUTES = new Set(['/', '']); - const isPublicRoute = PUBLIC_ROUTES.has(pathname) && req.method === 'GET'; - - const authResult = authenticateRequest(req); - - if (!isPublicRoute) { - if (!authResult.valid) return respondJson(401, { error: authResult.error }); - if (!canAccess(authResult.type, req, pathname)) return respondJson(403, { error: 'Access denied' }); - - const authHeader = req.headers['authorization']; - const apiKey = authHeader?.replace('Bearer ', '')?.trim() || req.headers['x-api-key']?.trim() || authResult.key; - - if (apiKey && !authResult.bypassed && !authResult.internal) { - const rateLimitResult = checkRateLimit(apiKey, clientIP); - if (!rateLimitResult.allowed) { - return respondJson(429, { error: rateLimitResult.error, resetAt: rateLimitResult.resetAt, limit: rateLimitResult.limit, window: rateLimitResult.window }); - } - } - - } - - if (pathname === '/api/auth' && req.method === 'POST') { - if (authResult.bypassed) { - return respondJson(200, { token: issueSessionToken('standard', 'bypassed') }); - } - if (authResult.type === 'player') { - return respondJson(401, { error: 'API key required for session token generation' }); - } - return respondJson(200, { token: issueSessionToken(authResult.type, authResult.key) }); - } - - if (pathname === '/api/auth/refresh' && req.method === 'POST') { - const existingToken = req.headers['x-session-token']?.trim(); - if (!existingToken) return respondJson(400, { error: 'Missing session token' }); - - const refreshed = refreshSessionToken(existingToken); - if (!refreshed) return respondJson(401, { error: 'Session token cannot be refreshed. Re-authenticate via /api/auth.' }); - - return respondJson(200, { token: refreshed }); - } - if (pathname === '/' || pathname === '') { return { status: 200, @@ -1254,5 +1268,4 @@ server.timeout = 90_000; server.on('error', err => { if (err.code !== 'EADDRINUSE') console.error('server error', err.message); }); await validateGAConfig(); -await initAuth(); server.listen(PORT, '0.0.0.0', () => console.log(`http://localhost:${PORT}`)); diff --git a/src/middleware/auth.js b/src/middleware/auth.js deleted file mode 100644 index ec40409..0000000 --- a/src/middleware/auth.js +++ /dev/null @@ -1,363 +0,0 @@ -import dotenv from 'dotenv'; -import crypto from 'crypto'; - -import { ensureApiKeysTable, fetchActiveApiKeys, fetchDisabledApiKeys, ensurePublicKey } from '../../db.js'; - -dotenv.config(); - -const BYPASS_AUTH = process.env.BYPASS_AUTH === 'true'; - -const API_KEYS = new Map(); -const DISABLED_KEYS = new Set(); -const rateLimitMap = new Map(); - -const KEY_REFRESH_INTERVAL_MS = 5 * 60 * 1000; - -function setKeysFromRows(rows) { - API_KEYS.clear(); - - for (const row of rows) { - API_KEYS.set(row.key, { - type: row.type, - rpm: Number(row.rpm) || 100 - }); - } -} - -export async function loadKeysFromDB() { - try { - const [activeRows, disabledRows] = await Promise.all([ - fetchActiveApiKeys(), - fetchDisabledApiKeys() - ]); - - setKeysFromRows(activeRows); - - DISABLED_KEYS.clear(); - for (const row of disabledRows) { - DISABLED_KEYS.add(row.key); - } - } catch (err) { - if (API_KEYS.size === 0) { - throw err; - } - } -} - -let authInitialized = false; - -export async function initAuth() { - if (authInitialized) return; - authInitialized = true; - - await ensureApiKeysTable(); - await ensurePublicKey(); - await loadKeysFromDB(); - - setInterval(() => { - loadKeysFromDB().catch(console.error); - }, KEY_REFRESH_INTERVAL_MS); -} - -const TOKEN_SECRET = process.env.TOKEN_SECRET; -if (!TOKEN_SECRET || TOKEN_SECRET.length < 32) { - throw new Error('TOKEN_SECRET must be set and at least 32 characters'); -} - -const TOKEN_TTL_MS = 30 * 60 * 1000; -const REFRESH_GRACE_MS = 10 * 60 * 1000; -const TOKEN_VERSION = 'v1'; - -function signPayload(payload) { - return crypto.createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex'); -} - -function buildToken(expires, type, sourceKey, issuedAt) { - const payload = `${TOKEN_VERSION}.${expires}.${type}.${sourceKey}.${issuedAt}`; - const sig = signPayload(payload); - return `${TOKEN_VERSION}.${expires}.${type}.${sourceKey}.${issuedAt}.${sig}`; -} - -export function issueSessionToken(type = 'player', sourceKey = '') { - const now = Date.now(); - const expires = now + TOKEN_TTL_MS; - return buildToken(expires, type, sourceKey, now); -} - -export function validateSessionToken(token) { - if (!token || typeof token !== 'string' || token.length > 512) return false; - - try { - const parts = token.split('.'); - if (parts.length !== 6) return false; - - const [version, expires, type, sourceKey, issuedAt, sig] = parts; - - if (version !== TOKEN_VERSION) return false; - if (!/^\d+$/.test(expires) || !/^\d+$/.test(issuedAt)) return false; - - const expiresNum = Number(expires); - const issuedAtNum = Number(issuedAt); - - if (issuedAtNum > Date.now()) return false; - if (expiresNum - issuedAtNum !== TOKEN_TTL_MS) return false; - - const payload = `${version}.${expires}.${type}.${sourceKey}.${issuedAt}`; - const expected = signPayload(payload); - - const sigBuf = Buffer.from(sig, 'hex'); - const expectedBuf = Buffer.from(expected, 'hex'); - - if (sigBuf.length !== expectedBuf.length) return false; - if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return false; - - if (Date.now() > expiresNum) return false; - - return { type, sourceKey, expires: expiresNum, issuedAt: issuedAtNum }; - } catch { - return false; - } -} - -export function refreshSessionToken(token) { - if (!token || typeof token !== 'string' || token.length > 512) return null; - - try { - const parts = token.split('.'); - if (parts.length !== 6) return null; - - const [version, expires, type, sourceKey, issuedAt, sig] = parts; - - if (version !== TOKEN_VERSION) return null; - if (!/^\d+$/.test(expires) || !/^\d+$/.test(issuedAt)) return null; - - const expiresNum = Number(expires); - const issuedAtNum = Number(issuedAt); - - if (issuedAtNum > Date.now()) return null; - if (expiresNum - issuedAtNum !== TOKEN_TTL_MS) return null; - - const payload = `${version}.${expires}.${type}.${sourceKey}.${issuedAt}`; - const expected = signPayload(payload); - - const sigBuf = Buffer.from(sig, 'hex'); - const expectedBuf = Buffer.from(expected, 'hex'); - - if (sigBuf.length !== expectedBuf.length) return null; - if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return null; - - if (Date.now() > expiresNum + REFRESH_GRACE_MS) return null; - - if (type !== 'player') { - const entry = API_KEYS.get(sourceKey); - if (!entry || entry.type !== type || DISABLED_KEYS.has(sourceKey)) return null; - } - - return issueSessionToken(type, sourceKey); - } catch { - return null; - } -} - -function parseKey(apiKey) { - if (!apiKey) return null; - return apiKey.includes(':') ? apiKey.split(':').pop().trim() : apiKey; -} - -function isLocalRequest(req) { - const ip = req.socket.remoteAddress || ''; - return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; -} - -export function authenticateRequest(req) { - if (BYPASS_AUTH && isLocalRequest(req)) { - return { - valid: true, - error: null, - type: 'standard', - bypassed: true - }; - } - - let internalToken = null; - try { - const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); - internalToken = url.searchParams.get('internal_token'); - } catch { } - - if (internalToken) { - const decoded = validateSessionToken(internalToken); - - if (decoded && decoded.type === 'internal') { - return { - valid: true, - error: null, - type: 'standard', - key: decoded.sourceKey || null, - bypassed: false, - internal: true - }; - } - - return { - valid: false, - error: 'Invalid or expired internal token' - }; - } - - const sessionToken = req.headers['x-session-token']?.trim(); - - if (sessionToken) { - const decoded = validateSessionToken(sessionToken); - - if (decoded) { - return { - valid: true, - error: null, - type: decoded.type, - key: decoded.sourceKey || null, - bypassed: false - }; - } - - return { - valid: false, - error: 'Invalid or expired session token' - }; - } - - const authHeader = req.headers.authorization; - const rawKey = - authHeader?.replace(/^Bearer\s+/i, '').trim() || - req.headers['x-api-key']?.trim(); - - const cleanKey = parseKey(rawKey); - - if (!cleanKey) { - return { - valid: false, - error: 'Missing API key. Provide via Authorization or X-API-Key. You can get one at https://docs.vyla.cc/authentication' - }; - } - - if (DISABLED_KEYS.has(cleanKey)) { - return { - valid: false, - error: 'This API key has been disabled. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.' - }; - } - - const entry = API_KEYS.get(cleanKey); - - if (!entry) { - return { - valid: false, - error: 'Invalid API key. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.' - }; - } - - return { - valid: true, - error: null, - type: entry.type, - key: cleanKey - }; -} - -function isStreamProxy(req, pathname) { - if (pathname !== '/api' && pathname !== '/api/') return false; - - const url = new URL( - req.url, - `http://${req.headers.host || 'localhost'}` - ); - - return ( - url.searchParams.has('url') || - url.searchParams.has('proxy') - ); -} - -export function canAccess(type, req, pathname) { - if (type === 'public') { - if (pathname === '/movie' || pathname === '/api/movie' || - pathname === '/tv' || pathname === '/api/tv') { - return false; - } - if (isStreamProxy(req, pathname)) { - return false; - } - return true; - } - - return ( - type === 'standard' || - type === 'partner' || - type === 'player' - ); -} - -export function checkRateLimit(apiKey) { - if (!apiKey) { - return { allowed: true }; - } - - const cleanKey = parseKey(apiKey); - const entry = API_KEYS.get(cleanKey); - - if (!entry) { - return { - allowed: false, - error: 'Invalid API key. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.' - }; - } - - const rpm = entry.rpm; - const window = 60_000; - const now = Date.now(); - - let current = rateLimitMap.get(cleanKey); - - if (!current || now > current.resetAt) { - current = { - count: 0, - resetAt: now + window - }; - } - - if (current.count >= rpm) { - rateLimitMap.set(cleanKey, current); - - return { - allowed: false, - error: 'Rate limit exceeded. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.', - resetAt: current.resetAt, - limit: rpm, - window - }; - } - - current.count++; - - rateLimitMap.set(cleanKey, current); - - return { - allowed: true, - remaining: rpm - current.count, - resetAt: current.resetAt - }; -} - -export function clearRateLimitCache() { - rateLimitMap.clear(); -} - -setInterval(() => { - const now = Date.now(); - - for (const [key, value] of rateLimitMap) { - if (now > value.resetAt) { - rateLimitMap.delete(key); - } - } -}, 60_000).unref(); \ No newline at end of file diff --git a/src/utils/proxy.js b/src/utils/proxy.js index 1bce9b9..7c71fb0 100644 --- a/src/utils/proxy.js +++ b/src/utils/proxy.js @@ -1,5 +1,3 @@ -import { issueSessionToken } from '../middleware/auth.js'; - const PROXY_STREAMS = process.env.PROXY_STREAMS === "true"; const EXTERNAL_PROXY_URL = (process.env.PROXY_URL || "").replace(/\/+$/, ""); @@ -37,8 +35,6 @@ export function wrapUrl(rawUrl, sourceKey, absoluteBase, sdk) { const params = new URLSearchParams({ url: normalized, [proxyParam]: "1" }); if (typeof rawUrl === "object" && rawUrl.headers) params.set("proxyHeaders", JSON.stringify(rawUrl.headers)); - params.set("internal_token", issueSessionToken("internal", sourceKey)); - return buildProxyUrl(`${safeBase}/api`, params); } diff --git a/update-db.mjs b/update-db.mjs deleted file mode 100644 index b9d38e0..0000000 --- a/update-db.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const DB_PATH = path.join(__dirname, "data", "api_keys.db"); -const INPUT = path.join(__dirname, "db.json"); - -const db = new DatabaseSync(DB_PATH); - -const data = JSON.parse( - fs.readFileSync(INPUT, "utf8") -); - -db.exec('BEGIN TRANSACTION'); -try { - const existing = db.prepare(` - SELECT key FROM api_keys - `).all().map(x => x.key); - - const incoming = data.map(x => x.key); - - for (const key of existing) { - if (!incoming.includes(key)) { - db.prepare(` - DELETE FROM api_keys - WHERE key = ? - `).run(key); - - console.log(`Deleted ${key}`); - } - } - - const upsert = db.prepare(` - INSERT INTO api_keys ( - key, - type, - rpm, - active, - created_at - ) - VALUES ( - ?, - ?, - ?, - ?, - ? - ) - ON CONFLICT(key) DO UPDATE SET - type = excluded.type, - rpm = excluded.rpm, - active = excluded.active - `); - - for (const row of data) { - upsert.run( - row.key, - row.type ?? "standard", - row.rpm ?? 100, - row.active ?? 1, - row.created_at ?? new Date().toISOString() - ); - - console.log(`Updated ${row.key}`); - } - db.exec('COMMIT'); -} catch (err) { - db.exec('ROLLBACK'); - throw err; -} - -console.log("Database synced from db.json"); \ No newline at end of file