398 lines
14 KiB
JavaScript
398 lines
14 KiB
JavaScript
import express, { Router } from 'express';
|
|
import fetch from 'node-fetch';
|
|
import { getAuthConfig, saveAuthConfig } from './db.js';
|
|
import { createSignedHeaders, getRules } from './signing.js';
|
|
|
|
const router = Router();
|
|
const OF_BASE = 'https://onlyfans.com';
|
|
const DRM_ENTITY_TYPES = new Set(['post', 'message', 'story', 'stream']);
|
|
|
|
function normalizeDrmEntityType(entityType) {
|
|
const normalized = String(entityType || '').toLowerCase();
|
|
return DRM_ENTITY_TYPES.has(normalized) ? normalized : null;
|
|
}
|
|
|
|
function decodeBase64License(value) {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
// Allow standard and URL-safe base64 alphabets.
|
|
if (!/^[A-Za-z0-9+/_=-]+$/.test(trimmed)) return null;
|
|
try {
|
|
const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/');
|
|
return Buffer.from(normalized, 'base64');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildHeaders(authConfig, signedHeaders) {
|
|
const rules = getRules();
|
|
const headers = {
|
|
'User-Agent': authConfig.user_agent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0',
|
|
'Accept': 'application/json, text/plain, */*',
|
|
'Cookie': authConfig.cookie,
|
|
'user-id': authConfig.user_id,
|
|
'x-bc': authConfig.x_bc,
|
|
'x-of-rev': authConfig.x_of_rev,
|
|
'app-token': rules.app_token,
|
|
...signedHeaders,
|
|
};
|
|
// Respect remove_headers from dynamic rules
|
|
if (rules.remove_headers) {
|
|
for (const h of rules.remove_headers) {
|
|
delete headers[h];
|
|
}
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function proxyGet(ofPath, authConfig) {
|
|
const signedHeaders = createSignedHeaders(ofPath, authConfig.user_id);
|
|
const headers = buildHeaders(authConfig, signedHeaders);
|
|
const res = await fetch(`${OF_BASE}${ofPath}`, { headers });
|
|
const data = await res.json();
|
|
return { status: res.status, data };
|
|
}
|
|
|
|
// GET /api/auth
|
|
router.get('/api/auth', (req, res) => {
|
|
const config = getAuthConfig();
|
|
if (!config) return res.json(null);
|
|
res.json(config);
|
|
});
|
|
|
|
// POST /api/auth
|
|
router.post('/api/auth', (req, res) => {
|
|
const { user_id, cookie, x_bc, app_token, x_of_rev, user_agent } = req.body;
|
|
saveAuthConfig({ user_id, cookie, x_bc, app_token, x_of_rev, user_agent });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// GET /api/me
|
|
router.get('/api/me', async (req, res, next) => {
|
|
try {
|
|
const authConfig = getAuthConfig();
|
|
if (!authConfig) return res.status(401).json({ error: 'No auth config' });
|
|
|
|
const { status, data } = await proxyGet('/api2/v2/users/me', authConfig);
|
|
res.status(status).json(data);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/feed
|
|
router.get('/api/feed', async (req, res, next) => {
|
|
try {
|
|
const authConfig = getAuthConfig();
|
|
if (!authConfig) return res.status(401).json({ error: 'No auth config' });
|
|
|
|
let ofPath = '/api2/v2/posts?limit=10&format=infinite';
|
|
if (req.query.beforePublishTime) {
|
|
ofPath += `&beforePublishTime=${encodeURIComponent(req.query.beforePublishTime)}`;
|
|
}
|
|
|
|
const { status, data } = await proxyGet(ofPath, authConfig);
|
|
res.status(status).json(data);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/subscriptions
|
|
router.get('/api/subscriptions', async (req, res, next) => {
|
|
try {
|
|
const authConfig = getAuthConfig();
|
|
if (!authConfig) return res.status(401).json({ error: 'No auth config' });
|
|
|
|
const offset = req.query.offset || 0;
|
|
const ofPath = `/api2/v2/subscriptions/subscribes?type=active&sort=desc&field=expire_date&limit=50&offset=${offset}`;
|
|
|
|
const { status, data } = await proxyGet(ofPath, authConfig);
|
|
res.status(status).json(data);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/users/:id/posts
|
|
router.get('/api/users/:id/posts', async (req, res, next) => {
|
|
try {
|
|
const authConfig = getAuthConfig();
|
|
if (!authConfig) return res.status(401).json({ error: 'No auth config' });
|
|
|
|
let ofPath = `/api2/v2/users/${req.params.id}/posts?limit=10&order=publish_date_desc&format=infinite&pinned=0&counters=1`;
|
|
if (req.query.beforePublishTime) {
|
|
ofPath += `&beforePublishTime=${encodeURIComponent(req.query.beforePublishTime)}`;
|
|
}
|
|
|
|
const { status, data } = await proxyGet(ofPath, authConfig);
|
|
|
|
res.status(status).json(data);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/users/:username (resolve username to user object)
|
|
router.get('/api/users/:username', async (req, res, next) => {
|
|
try {
|
|
const authConfig = getAuthConfig();
|
|
if (!authConfig) return res.status(401).json({ error: 'No auth config' });
|
|
|
|
const ofPath = `/api2/v2/users/${req.params.username}`;
|
|
|
|
const { status, data } = await proxyGet(ofPath, authConfig);
|
|
|
|
// OF API sometimes returns 200 with error body instead of a proper HTTP error
|
|
if (status === 200 && data && !data.id && data.code !== undefined) {
|
|
return res.status(404).json({ error: data.message || 'User not found' });
|
|
}
|
|
|
|
res.status(status).json(data);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/media-proxy — proxy CDN media through the server
|
|
router.get('/api/media-proxy', async (req, res) => {
|
|
const url = req.query.url;
|
|
if (!url) return res.status(400).json({ error: 'Missing url parameter' });
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (!parsed.hostname.endsWith('onlyfans.com')) {
|
|
return res.status(403).json({ error: 'Only onlyfans.com URLs allowed' });
|
|
}
|
|
|
|
const headers = {};
|
|
if (req.headers.range) {
|
|
headers['Range'] = req.headers.range;
|
|
}
|
|
|
|
const upstream = await fetch(url, { headers });
|
|
if (!upstream.ok && upstream.status !== 206) return res.status(upstream.status).end();
|
|
|
|
const contentType = upstream.headers.get('content-type');
|
|
if (contentType) res.set('Content-Type', contentType);
|
|
|
|
const contentLength = upstream.headers.get('content-length');
|
|
if (contentLength) res.set('Content-Length', contentLength);
|
|
|
|
const contentRange = upstream.headers.get('content-range');
|
|
if (contentRange) res.set('Content-Range', contentRange);
|
|
|
|
const acceptRanges = upstream.headers.get('accept-ranges');
|
|
if (acceptRanges) res.set('Accept-Ranges', acceptRanges);
|
|
|
|
res.set('Cache-Control', 'public, max-age=86400');
|
|
res.status(upstream.status);
|
|
|
|
upstream.body.pipe(res);
|
|
} catch (err) {
|
|
console.error('[media-proxy] Error:', err.message);
|
|
res.status(500).json({ error: 'Proxy fetch failed' });
|
|
}
|
|
});
|
|
|
|
// POST /api/drm-license — proxy Widevine license requests through OF's DRM resolver
|
|
router.post('/api/drm-license', async (req, res) => {
|
|
const { mediaId, entityId, entityType, cp, cs, ck } = req.query;
|
|
|
|
if (!mediaId) {
|
|
return res.status(400).json({ error: 'Missing mediaId parameter' });
|
|
}
|
|
|
|
try {
|
|
const authConfig = getAuthConfig();
|
|
if (!authConfig) return res.status(401).json({ error: 'No auth config' });
|
|
|
|
// `express.raw()` handles most requests, but keep a fallback for missing content-type.
|
|
let rawBody = Buffer.isBuffer(req.body) ? req.body : null;
|
|
if (!rawBody) {
|
|
const chunks = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(chunk);
|
|
}
|
|
rawBody = Buffer.concat(chunks);
|
|
}
|
|
|
|
const normalizedEntityType = normalizeDrmEntityType(entityType);
|
|
const parsedEntityId = Number.parseInt(entityId, 10);
|
|
const hasEntityContext = normalizedEntityType
|
|
&& Number.isFinite(parsedEntityId)
|
|
&& parsedEntityId > 0;
|
|
|
|
const drmPath = hasEntityContext
|
|
? `/api2/v2/users/media/${mediaId}/drm/${normalizedEntityType}/${parsedEntityId}`
|
|
: `/api2/v2/users/media/${mediaId}/drm/`;
|
|
const ofPath = `${drmPath}?type=widevine`;
|
|
|
|
console.log(
|
|
'[drm-license] License request mediaId:', mediaId,
|
|
'entityType:', hasEntityContext ? normalizedEntityType : 'own_media',
|
|
'entityId:', hasEntityContext ? parsedEntityId : null,
|
|
'challenge size:', rawBody.length,
|
|
'content-type:', req.headers['content-type'] || 'none'
|
|
);
|
|
|
|
const signedHeaders = createSignedHeaders(ofPath, authConfig.user_id);
|
|
const headers = buildHeaders(authConfig, signedHeaders);
|
|
headers['Content-Type'] = 'application/octet-stream';
|
|
|
|
// Append CloudFront cookies
|
|
const cfParts = [];
|
|
if (cp) cfParts.push(`CloudFront-Policy=${cp}`);
|
|
if (cs) cfParts.push(`CloudFront-Signature=${cs}`);
|
|
if (ck) cfParts.push(`CloudFront-Key-Pair-Id=${ck}`);
|
|
if (cfParts.length > 0) {
|
|
headers['Cookie'] = [headers['Cookie'], ...cfParts].filter(Boolean).join('; ');
|
|
}
|
|
|
|
console.log('[drm-license] Proxying to OF:', ofPath);
|
|
const upstream = await fetch(`${OF_BASE}${ofPath}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: rawBody.length > 0 ? rawBody : undefined,
|
|
});
|
|
|
|
const responseBody = Buffer.from(await upstream.arrayBuffer());
|
|
const upstreamContentType = upstream.headers.get('content-type') || '';
|
|
const isJson = upstreamContentType.includes('application/json');
|
|
const responsePreview = isJson
|
|
? responseBody.toString('utf8').substring(0, 300)
|
|
: `<binary:${responseBody.length}>`;
|
|
console.log('[drm-license] OF response:', upstream.status, 'size:', responseBody.length,
|
|
'content-type:', upstreamContentType || 'unknown', 'body:', responsePreview);
|
|
|
|
let bodyToSend = responseBody;
|
|
let contentType = upstreamContentType || 'application/octet-stream';
|
|
|
|
// Some endpoints return a JSON wrapper with a base64 license payload.
|
|
if (upstream.ok && isJson) {
|
|
try {
|
|
const payload = JSON.parse(responseBody.toString('utf8'));
|
|
const maybeLicense =
|
|
payload?.license ||
|
|
payload?.licenseData ||
|
|
payload?.data?.license ||
|
|
payload?.data?.licenseData ||
|
|
payload?.result?.license ||
|
|
payload?.result?.licenseData ||
|
|
null;
|
|
const decoded = decodeBase64License(maybeLicense);
|
|
if (decoded) {
|
|
bodyToSend = decoded;
|
|
contentType = 'application/octet-stream';
|
|
}
|
|
} catch {
|
|
// Keep upstream response unchanged if JSON parsing fails.
|
|
}
|
|
}
|
|
|
|
res.status(upstream.status);
|
|
res.set('Content-Type', contentType);
|
|
res.send(bodyToSend);
|
|
} catch (err) {
|
|
console.error('[drm-license] Error:', err.message);
|
|
res.status(500).json({ error: 'License proxy failed' });
|
|
}
|
|
});
|
|
|
|
// GET /api/drm-hls — proxy DRM-protected HLS streams from cdn3.onlyfans.com
|
|
router.get('/api/drm-hls', async (req, res) => {
|
|
const { url, cp, cs, ck } = req.query;
|
|
if (!url) return res.status(400).json({ error: 'Missing url parameter' });
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (!parsed.hostname.endsWith('onlyfans.com')) {
|
|
return res.status(403).json({ error: 'Only onlyfans.com URLs allowed' });
|
|
}
|
|
|
|
// Attach CloudFront signed cookies
|
|
const cookieParts = [];
|
|
if (cp) cookieParts.push(`CloudFront-Policy=${cp}`);
|
|
if (cs) cookieParts.push(`CloudFront-Signature=${cs}`);
|
|
if (ck) cookieParts.push(`CloudFront-Key-Pair-Id=${ck}`);
|
|
|
|
const headers = {};
|
|
if (cookieParts.length > 0) {
|
|
headers['Cookie'] = cookieParts.join('; ');
|
|
}
|
|
if (req.headers.range) {
|
|
headers['Range'] = req.headers.range;
|
|
}
|
|
|
|
const upstream = await fetch(url, { headers });
|
|
if (!upstream.ok && upstream.status !== 206) {
|
|
console.error(`[drm-hls] Upstream ${upstream.status} for ${url}`);
|
|
return res.status(upstream.status).end();
|
|
}
|
|
|
|
const contentType = upstream.headers.get('content-type') || '';
|
|
|
|
// DASH manifest — inject BaseURL so Shaka Player resolves segment URLs to CDN
|
|
if (url.endsWith('.mpd') || contentType.includes('dash+xml')) {
|
|
let body = await upstream.text();
|
|
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1);
|
|
// Insert <BaseURL> right after <MPD ...> opening tag so relative URLs resolve to CDN
|
|
body = body.replace(/(<MPD[^>]*>)/, `$1\n <BaseURL>${baseUrl}</BaseURL>`);
|
|
res.set('Content-Type', 'application/dash+xml');
|
|
res.set('Cache-Control', 'no-cache');
|
|
res.send(body);
|
|
}
|
|
// HLS playlist — rewrite URLs to route through this proxy
|
|
else if (url.endsWith('.m3u8') || contentType.includes('mpegurl') || contentType.includes('x-mpegurl')) {
|
|
const body = await upstream.text();
|
|
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1);
|
|
|
|
const rewritten = body.split('\n').map(line => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return line;
|
|
|
|
// Rewrite URI= attributes in EXT tags (e.g., #EXT-X-KEY, #EXT-X-MAP)
|
|
// Skip non-HTTP URIs like skd:// (FairPlay key identifiers)
|
|
if (trimmed.startsWith('#')) {
|
|
if (trimmed.includes('URI="')) {
|
|
return trimmed.replace(/URI="([^"]+)"/g, (_, uri) => {
|
|
if (!uri.startsWith('http') && !uri.startsWith('/')) return `URI="${uri}"`;
|
|
const abs = uri.startsWith('http') ? uri : baseUrl + uri;
|
|
return `URI="/api/drm-hls?url=${encodeURIComponent(abs)}&cp=${encodeURIComponent(cp || '')}&cs=${encodeURIComponent(cs || '')}&ck=${encodeURIComponent(ck || '')}"`;
|
|
});
|
|
}
|
|
return line;
|
|
}
|
|
|
|
// URL line (segment or variant playlist reference)
|
|
const abs = trimmed.startsWith('http') ? trimmed : baseUrl + trimmed;
|
|
return `/api/drm-hls?url=${encodeURIComponent(abs)}&cp=${encodeURIComponent(cp || '')}&cs=${encodeURIComponent(cs || '')}&ck=${encodeURIComponent(ck || '')}`;
|
|
}).join('\n');
|
|
|
|
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
|
res.set('Cache-Control', 'no-cache');
|
|
res.send(rewritten);
|
|
} else {
|
|
// Binary content (TS segments, init segments) — pipe through
|
|
const ct = upstream.headers.get('content-type');
|
|
if (ct) res.set('Content-Type', ct);
|
|
const cl = upstream.headers.get('content-length');
|
|
if (cl) res.set('Content-Length', cl);
|
|
const cr = upstream.headers.get('content-range');
|
|
if (cr) res.set('Content-Range', cr);
|
|
const ar = upstream.headers.get('accept-ranges');
|
|
if (ar) res.set('Accept-Ranges', ar);
|
|
res.set('Cache-Control', 'public, max-age=3600');
|
|
res.status(upstream.status);
|
|
upstream.body.pipe(res);
|
|
}
|
|
} catch (err) {
|
|
console.error('[drm-hls] Error:', err.message);
|
|
res.status(500).json({ error: 'DRM HLS proxy failed' });
|
|
}
|
|
});
|
|
|
|
export default router;
|