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) : ``; 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 right after opening tag so relative URLs resolve to CDN body = body.replace(/(]*>)/, `$1\n ${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;