rewrote the stream api

This commit is contained in:
2026-10-02 15:17:15 -07:00
parent 1ef60fceab
commit b6cbf65359
10 changed files with 215 additions and 758 deletions

View File

@@ -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=
PORT=7860
WORKER_COUNT=1

View File

@@ -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 <type> <rpm> [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);

60
db.js
View File

@@ -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;

View File

@@ -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`);

View File

@@ -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"
}
}

395
server.js
View File

@@ -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('<MPD')) return store(true);
return store(looksLikeMedia(buf));
}
if (text.includes('#EXT-X-STREAM-INF')) {
const variantUrl = firstUri(text, base);
if (!variantUrl) return store(false);
res = await get(variantUrl);
buf = await accept(res, 65536);
if (!buf) return store(false);
text = buf.toString('utf8');
base = res.url || variantUrl;
if (!/^\s*#EXTM3U/.test(text) || text.includes('#EXT-X-STREAM-INF')) return store(false);
}
if (!text.includes('#EXTINF')) return store(false);
const keyLine = text.split('\n').find(l => /^#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),
]);
if (!verified.length) {
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 },
ok: false,
url: null,
raw_url: candidates[0]?.url || null,
error: fetchError || (candidates.length ? 'no playable streams' : 'no streams returned'),
};
}
const result = { ok: true, url: wrappedUrl, raw_url: candidate.url };
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, 90_000);
return result;
sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000);
}
return { ok: false, url: null, raw_url: candidates[0]?.url || null, error: fetchError };
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}`));

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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");