mirror of
https://gitlab.com/vyla-entertainment/stream-api.git
synced 2026-11-14 22:57:21 -05:00
rewrote the stream api
This commit is contained in:
10
.env.example
10
.env.example
@@ -1,14 +1,12 @@
|
|||||||
|
PROXY_STREAMS=true
|
||||||
PROXY_URL=
|
PROXY_URL=
|
||||||
|
|
||||||
PROXY_STREAMS=true
|
TMDB_API_KEY=
|
||||||
|
|
||||||
ENABLE_DEBUG_ROUTE=false
|
ENABLE_DEBUG_ROUTE=false
|
||||||
|
|
||||||
TMDB_API_KEY=
|
|
||||||
|
|
||||||
GA_MEASUREMENT_ID=
|
GA_MEASUREMENT_ID=
|
||||||
GA_API_SECRET=
|
GA_API_SECRET=
|
||||||
|
|
||||||
TOKEN_SECRET=
|
PORT=7860
|
||||||
|
WORKER_COUNT=1
|
||||||
BYPASS_AUTH=
|
|
||||||
31
add-key.mjs
31
add-key.mjs
@@ -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
60
db.js
@@ -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;
|
|
||||||
@@ -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`);
|
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"description": "Stream API — multi-provider stream scraper",
|
"description": "Stream API — multi-provider stream scraper",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vyla-entertainment/sdk": "^1.9.0",
|
"@vyla-entertainment/sdk": "^1.12.0",
|
||||||
"dotenv": "^17.4.2"
|
"dotenv": "^17.4.2",
|
||||||
|
"latest": "^0.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
403
server.js
403
server.js
@@ -9,7 +9,6 @@ import { CACHE_TTL } from './config.js';
|
|||||||
import { handleSubtitleMovie, handleSubtitleTv } from './src/routes/subtitles.js';
|
import { handleSubtitleMovie, handleSubtitleTv } from './src/routes/subtitles.js';
|
||||||
import { handleDownloadMovie, handleDownloadTv } from './src/routes/downloads/main.js';
|
import { handleDownloadMovie, handleDownloadTv } from './src/routes/downloads/main.js';
|
||||||
import { handleHealth } from './src/routes/health.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 { wrapUrl } from './src/utils/proxy.js';
|
||||||
import { handleTestRoute, handleDebugRoute } from './src/routes/test.js';
|
import { handleTestRoute, handleDebugRoute } from './src/routes/test.js';
|
||||||
import { getUA, validateTmdbId } from './src/utils/helpers.js';
|
import { getUA, validateTmdbId } from './src/utils/helpers.js';
|
||||||
@@ -635,51 +634,192 @@ function normalizeCandidates(rawResult) {
|
|||||||
return candidates;
|
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) {
|
async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const sources = sdk.getSources();
|
const cfg = sdk.getSources().find(c => c.key === sourceKey);
|
||||||
const cfg = sources.find(c => c.key === sourceKey);
|
|
||||||
const absoluteBase = getAbsoluteBase(host);
|
const absoluteBase = getAbsoluteBase(host);
|
||||||
|
|
||||||
const respond = (ok, url, raw_url, error, debug) => ({
|
const respond = result => ({
|
||||||
status: 200,
|
status: 200,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
source: sourceKey,
|
source: sourceKey,
|
||||||
id,
|
id,
|
||||||
s: s || null,
|
s: s || null,
|
||||||
e: e || null,
|
e: e || null,
|
||||||
ok,
|
ok: !!result?.ok,
|
||||||
url: ok ? url : null,
|
url: result?.ok ? result.url : null,
|
||||||
raw_url,
|
streams: result?.ok ? result.streams : [],
|
||||||
|
raw_url: result?.raw_url ?? null,
|
||||||
elapsed_ms: Date.now() - start,
|
elapsed_ms: Date.now() - start,
|
||||||
error: ok ? null : error,
|
error: result?.ok ? null : (result?.error ?? null),
|
||||||
...(debug ? { debug } : {}),
|
...(result?.debug ? { debug: result.debug } : {}),
|
||||||
}, null, 2),
|
}, null, 2),
|
||||||
contentType: 'application/json',
|
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);
|
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);
|
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 inflightKey = `inflight-${cacheKey}`;
|
||||||
const existing = sharedInflight.get(inflightKey);
|
const existing = sharedInflight.get(inflightKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
try {
|
try {
|
||||||
const result = await existing;
|
return respond((await existing) ?? { ok: false, error: 'timed out' });
|
||||||
return respond(result?.ok ?? false, result?.url ?? null, result?.raw_url ?? null, result?.error ?? null);
|
|
||||||
} catch {
|
} catch {
|
||||||
return respond(false, null, null, 'deduped request failed');
|
return respond({ ok: false, error: 'deduped request failed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sharedInflight.size >= MAX_SHARED_INFLIGHT) {
|
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();
|
await acquireTestSlot();
|
||||||
@@ -687,147 +827,41 @@ async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) {
|
|||||||
const testPromise = withTimeout((async () => {
|
const testPromise = withTimeout((async () => {
|
||||||
try {
|
try {
|
||||||
let rawResult = null, fetchError = null;
|
let rawResult = null, fetchError = null;
|
||||||
const audio = /dub$/.test(cfg.key) ? 'dub' : 'sub';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rawResult = await fetchSource(cfg, `${id}-${s ?? ''}-${e ?? ''}`, id, s, e, clientIP, absoluteBase);
|
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);
|
if (!rawResult) rawResult = await withTimeout(sdk.getStream(sourceKey, id, s, e), 20_000);
|
||||||
} catch (err) { fetchError = err.message; }
|
} 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 verified = (await Promise.all(
|
||||||
const wrappedUrl = wrapUrl(candidate, sourceKey, absoluteBase, sdk);
|
candidates.map(c => verifyCandidate(c, sourceKey, absoluteBase).catch(() => null))
|
||||||
if (!wrappedUrl) continue;
|
)).filter(Boolean);
|
||||||
|
|
||||||
if (candidate.type === 'dash' || /\.mpd(\?|$)/i.test(candidate.url)) {
|
if (!verified.length) {
|
||||||
try {
|
return {
|
||||||
const headRes = await _nativeFetch(candidate.url, {
|
ok: false,
|
||||||
method: 'HEAD',
|
url: null,
|
||||||
headers: { 'User-Agent': getUA(), ...(candidate.headers ?? {}) },
|
raw_url: candidates[0]?.url || null,
|
||||||
signal: AbortSignal.timeout(6_000),
|
error: fetchError || (candidates.length ? 'no playable streams' : 'no streams returned'),
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
} finally {
|
||||||
releaseTestSlot();
|
releaseTestSlot();
|
||||||
sharedInflight.delete(inflightKey);
|
sharedInflight.delete(inflightKey);
|
||||||
@@ -836,7 +870,7 @@ async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) {
|
|||||||
|
|
||||||
sharedInflight.set(inflightKey, testPromise);
|
sharedInflight.set(inflightKey, testPromise);
|
||||||
const result = await 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) {
|
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);
|
const result = await handleTestSource(sdk, cfg.key, id, s, e, clientIP, host);
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
const parsed = JSON.parse(result.body);
|
const parsed = JSON.parse(result.body);
|
||||||
debugResults.push({ source: cfg.key, ok: parsed.ok, error: parsed.error || null, elapsed_ms: parsed.elapsed_ms });
|
debugResults.push({
|
||||||
if (parsed.ok && parsed.url && !sent.has(parsed.url)) {
|
source: cfg.key,
|
||||||
sent.add(parsed.url);
|
ok: parsed.ok,
|
||||||
safeWrite(`data: ${JSON.stringify({ type: 'source', source: { source: cfg.key, label: cfg.label ?? cfg.key, url: parsed.url } })}\n\n`);
|
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) {
|
} catch (err) {
|
||||||
debugResults.push({ source: cfg.key, ok: false, error: err.message });
|
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 };
|
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 === '') {
|
if (pathname === '/' || pathname === '') {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -1254,5 +1268,4 @@ server.timeout = 90_000;
|
|||||||
|
|
||||||
server.on('error', err => { if (err.code !== 'EADDRINUSE') console.error('server error', err.message); });
|
server.on('error', err => { if (err.code !== 'EADDRINUSE') console.error('server error', err.message); });
|
||||||
await validateGAConfig();
|
await validateGAConfig();
|
||||||
await initAuth();
|
|
||||||
server.listen(PORT, '0.0.0.0', () => console.log(`http://localhost:${PORT}`));
|
server.listen(PORT, '0.0.0.0', () => console.log(`http://localhost:${PORT}`));
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { issueSessionToken } from '../middleware/auth.js';
|
|
||||||
|
|
||||||
const PROXY_STREAMS = process.env.PROXY_STREAMS === "true";
|
const PROXY_STREAMS = process.env.PROXY_STREAMS === "true";
|
||||||
const EXTERNAL_PROXY_URL = (process.env.PROXY_URL || "").replace(/\/+$/, "");
|
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" });
|
const params = new URLSearchParams({ url: normalized, [proxyParam]: "1" });
|
||||||
if (typeof rawUrl === "object" && rawUrl.headers) params.set("proxyHeaders", JSON.stringify(rawUrl.headers));
|
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);
|
return buildProxyUrl(`${safeBase}/api`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
Reference in New Issue
Block a user