Files
OFApp/server/proxy.js
Trey t c60de19348 Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:07:06 -06:00

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;