Initial commit — OFApp client + server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-12 20:07:06 -06:00
commit c60de19348
43 changed files with 8679 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
{
"permissions": {
"allow": [
"Bash(python3:*)",
"WebSearch",
"WebFetch(domain:docs.ofauth.com)",
"WebFetch(domain:gist.github.com)",
"WebFetch(domain:github.com)",
"Bash(npm install:*)",
"Bash(npx vite build)",
"Bash(timeout 5 node:*)",
"Bash(PORT=3099 timeout 3 node:*)",
"Bash(chmod:*)",
"Bash(./run-local.sh:*)",
"Bash(lsof:*)",
"Bash(kill:*)",
"Bash(docker compose:*)",
"Bash(curl:*)",
"Bash(FULL_COOKIE='st=917c6bf494d1010f1d142455040b9b40bb466861feedd2a69e4f1beafe3c64b1; csrf=BMiTWZP4d49a2965028940bff2f6967c2e19188b; fp=8dbf86e101ff9265acbfbeb648d74e85092b6206; lang=en; sess=9ds150j2q63pq9ktg7i7sd2oeu; auth_id=101476031')",
"Bash(__NEW_LINE_e7a58eca8704cc6b__ sleep 3)",
"Bash(sqlite3:*)",
"Bash(# Try the per-origin storage instead ls \"\"/Users/treyt/Library/Application Support/Firefox/Profiles/z3dg9520.dev-edition-default-1721589916221/storage/default/\"\")",
"WebFetch(domain:www.ofscripts.tech)",
"Bash(# Search Firefox''s main HTTP cache for the OF JS bundle find \"\"/Users/treyt/Library/Application Support/Firefox/Profiles/z3dg9520.dev-edition-default-1721589916221/cache2/entries\"\" -type f -newer /tmp/ff_cookies.sqlite)",
"Bash(while read f)",
"Bash(do if grep -ql \"static_param\\\\|checksum_indexes\\\\|abcdefghijklmnop\" \"$f\")",
"Bash(then echo \"FOUND: $f\" ls -la \"$f\" fi done)",
"Bash(# Broader search - look for any cache file with signing-related content find \"\"/Users/treyt/Library/Application Support/Firefox/Profiles/z3dg9520.dev-edition-default-1721589916221/cache2/entries\"\" -type f -size +50k)",
"Bash(do if strings \"$f\")",
"Bash(then)",
"Bash(fi)",
"Bash(done)",
"Bash(# Firefox Dev Edition on macOS stores cache in Library/Caches, not Application Support find \"\"/Users/treyt/Library/Caches\"\" -maxdepth 2 -name \"\"*irefox*\"\" -o -name \"\"*dev*\"\")",
"Bash(CACHE_FILE=\"/Users/treyt/Library/Caches/Firefox/Profiles/z3dg9520.dev-edition-default-1721589916221/cache2/entries/BE6664F5408B9809D4AC98E8444FCD3C2B59EC9E\":*)",
"Bash(CACHE=\"/Users/treyt/Library/Caches/Firefox/Profiles/z3dg9520.dev-edition-default-1721589916221/cache2/entries\":*)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(docker exec:*)",
"Bash(docker restart:*)",
"Bash(for dir in /Users/treyt/Desktop/code/OFApp/data/media/70297862/*/)",
"Bash(do mv \"$dir\"*.* /Users/treyt/Desktop/code/OFApp/data/media/kaylalauren/)",
"Bash(for dir in /Users/treyt/Desktop/code/OFApp/data/media/248524139/*/)",
"Bash(do mv \"$dir\"*.* /Users/treyt/Desktop/code/OFApp/data/media/josiecatxx/)",
"Bash(rsync:*)",
"Bash(sshpass:*)",
"Bash(xargs -P 10 -I {} cp -n {} /Users/treyt/Desktop/code/OFApp/data/media/puertoricanlexi/)",
"Bash(xargs -P 100 -I {} cp -n {} /Users/treyt/Desktop/code/OFApp/data/media/puertoricanlexi/)",
"Bash(zip:*)",
"Bash(for d in thenofacegirl urfavmixedgirl urfavoritelex dicedfineapplesxo)",
"Bash(xargs -P 100 -I {} cp -n {} /Users/treyt/Desktop/code/OFApp/data/media/thenofacegirl/)",
"Bash(xargs -P 100 -I {} cp -n {} /Users/treyt/Desktop/code/OFApp/data/media/urfavmixedgirl/)",
"Bash(xargs -P 100 -I {} cp -n {} /Users/treyt/Desktop/code/OFApp/data/media/urfavoritelex/)",
"Bash(xargs -P 100 -I {} cp -n {} /Users/treyt/Desktop/code/OFApp/data/media/dicedfineapplesxo/)",
"Bash(do basename \"$f\")",
"Bash(xargs:*)",
"Bash(for f in 0gnzcd2mh2bn1jyhy3qaf_source.mp4 0goffco413303yiaetceg_source.mp4 0gor4fp3jxxln45agzxpt_source.mp4 0glkgvfg313q7wmxmq9k9_source.mp4)",
"Bash(do stat -f \"%z %N\" \"/Users/treyt/Desktop/code/OFApp/data/media/thenofacegirl/$f\")",
"Bash(echo:*)",
"Bash(SRC=\"/Users/treyt/Library/Mobile Documents/com~apple~CloudDocs/untitled folder/of\")",
"Bash(for d in fukaykin inked.mistress amberrrrbabyyyy alishabright_vip lavvenderluvv)",
"Bash(do)",
"Bash(brctl download:*)",
"Bash(ls:*)",
"Bash(ssh-add:*)",
"Bash(docker-compose down:*)",
"Bash(docker build:*)",
"Bash(docker rm:*)",
"Bash(docker run:*)",
"WebFetch(domain:pallycon.com)",
"WebFetch(domain:deepwiki.com)",
"Bash(git init:*)",
"Bash(git add:*)",
"Bash(git rm:*)",
"Bash(git check-ignore:*)",
"Bash(git commit:*)"
]
}
}

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
client/node_modules
server/node_modules
client/dist
.git
.DS_Store
calls/
*.har

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
client/dist/
data/
*.har
.DS_Store
.env
seed-auth.json
dani/

95
CLAUDE.md Normal file
View File

@@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Run
```bash
npm run install:all # Install server + client deps
npm run dev # Concurrent: client (5173) + server (3001)
npm run dev:server # Server only with --watch
npm run dev:client # Vite dev server only
npm run build # Build client to client/dist/
npm start # Production server (serves client/dist/)
```
## Deployment
Remote server: `root@10.3.3.11`, password `Intel22`, path `/mnt/user/appdata/OFApp/`
```bash
# Deploy
sshpass -p 'Intel22' rsync -avz --exclude='node_modules' --exclude='.git' --exclude='dist' --exclude='data' /Users/treyt/Desktop/code/OFApp/ root@10.3.3.11:/mnt/user/appdata/OFApp/
# Rebuild container
sshpass -p 'Intel22' ssh root@10.3.3.11 'cd /mnt/user/appdata/OFApp && docker-compose down && docker-compose up -d --build'
# Check logs
sshpass -p 'Intel22' ssh root@10.3.3.11 'docker logs ofapp 2>&1 | tail -20'
```
Port mapping: HTTP 3002->3001, HTTPS 3003->3443. Data volumes at `/mnt/user/downloads/OFApp/{db,media}`.
## Architecture
**Client**: React 18 + Vite + Tailwind. Pages in `client/src/pages/`, API wrappers in `client/src/api.js`. Vite proxies `/api` to `localhost:3001` in dev.
**Server**: Express (ESM modules). All routes are in separate router files mounted in `server/index.js`.
### Server Modules
| File | Purpose |
|------|---------|
| `proxy.js` | OF API proxy with auth headers + media/DRM proxy endpoints |
| `signing.js` | Dynamic request signing (fetches rules from GitHub, auto-refreshes hourly) |
| `download.js` | Background media download orchestration with resume support |
| `db.js` | SQLite (better-sqlite3, WAL mode) — auth, download history, cursors, settings |
| `gallery.js` | Serves downloaded media as browsable gallery |
| `hls.js` | On-demand HLS transcoding via FFmpeg |
| `settings.js` | Key-value settings API |
### Auth & Signing Flow
All OF API requests go through `proxy.js` which adds auth headers (Cookie, user-id, x-bc, x-of-rev, app-token) plus a dynamic `sign` header generated by `signing.js`. The signing uses SHA-1 with parameters from community-maintained rules fetched from GitHub repos (rafa-9, datawhores, DATAHOARDERS — tried in order).
### DRM Video Playback
OF videos use PallyCon DRM (Widevine for Chrome/Firefox, FairPlay for Safari). The flow:
1. `MediaGrid.jsx` detects `files.drm` on video items, extracts DASH manifest URL + CloudFront cookies (and keeps the parent entity context: `entityType` + `entityId`, e.g. `post/{postId}`)
2. `DrmVideo.jsx` uses Shaka Player to load the DASH manifest via `/api/drm-hls` proxy
3. Shaka's request filter rewrites CDN segment URLs through `/api/drm-hls` (attaches CloudFront cookies server-side)
4. Widevine license challenges go to `/api/drm-license` which forwards to OF's DRM resolver:
- Own media: `POST /api2/v2/users/media/{mediaId}/drm/?type=widevine`
- Posts/messages/etc: `POST /api2/v2/users/media/{mediaId}/drm/{entityType}/{entityId}?type=widevine` (e.g. `post/{postId}`)
5. The `/api/drm-hls` proxy handles DASH manifests (injects `<BaseURL>`), HLS playlists (rewrites URLs), and binary segments (pipes through)
**EME requires HTTPS** — the server auto-generates a self-signed cert at `/data/certs/` on first boot. Access DRM content via `https://10.3.3.11:3003`.
### Media Proxy
CDN media from `*.onlyfans.com` is proxied through `/api/media-proxy` to avoid CORS issues. It passes Range headers and caches responses. URLs from `public.` and `thumbs.` subdomains are NOT proxied (client-side check in `MediaGrid.jsx`).
### Download System
`download.js` runs background downloads in-memory (not persisted across restarts). It paginates through `/api2/v2/users/:id/posts/medias`, downloads each file to `MEDIA_PATH/:username/`, records in SQLite, and supports cursor-based resume for interrupted downloads.
## Environment Variables
| Variable | Default | Notes |
|----------|---------|-------|
| `PORT` | 3001 | HTTP |
| `HTTPS_PORT` | 3443 | HTTPS for DRM/EME |
| `DB_PATH` | /data/db/ofapp.db | SQLite |
| `MEDIA_PATH` | /data/media | Downloaded files |
| `DOWNLOAD_DELAY` | 1000 | ms between downloads |
| `HLS_ENABLED` | false | FFmpeg HLS transcoding |
## Key Gotchas
- Widevine license challenges must be treated as raw binary. `server/index.js` mounts `express.raw()` for `/api/drm-license` before the global `express.json()`, and `server/proxy.js` prefers `req.body` (Buffer) with a fallback stream read.
- For subscribed content, `/api/drm-license` must include `entityType` + `entityId` (e.g. `entityType=post&entityId=<postId>`). Missing entity context typically yields OF `403 User have no permissions`, which Shaka surfaces as `6007` (`LICENSE_REQUEST_FAILED`).
- Request signing rules normalize differently across GitHub sources — `signing.js` handles both old format (static_param) and new format (static-param with dashes).
- CloudFront cookies for DRM content are IP-locked to the server's public IP (47.185.183.191). They won't work from a different IP.
- The `/api/drm-hls` proxy skips `skd://` URIs in HLS manifests (FairPlay key identifiers, Safari only).
- Server log prefixes: `[signing]`, `[drm-license]`, `[drm-hls]`, `[download]`, `[media-proxy]`, `[hls]`.

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# Stage 1 — Build client
FROM node:20-alpine AS builder
WORKDIR /app
COPY client/package*.json ./client/
RUN cd client && npm install
COPY client/ ./client/
RUN cd client && npm run build
# Stage 2 — Production
FROM node:20-alpine
RUN apk add --no-cache ffmpeg openssl
WORKDIR /app
COPY server/package*.json ./server/
RUN cd server && npm install --production
COPY server/ ./server/
COPY --from=builder /app/client/dist ./client/dist
ENV PORT=3001
ENV DB_PATH=/data/db/ofapp.db
ENV MEDIA_PATH=/data/media
ENV DOWNLOAD_DELAY=1000
EXPOSE 3001 3443
CMD ["node", "server/index.js"]

18
client/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OFApp</title>
<style>
body {
background-color: #0a0a0a;
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2678
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
client/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "ofapp-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"hls.js": "^1.6.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"shaka-player": "^5.0.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"vite": "^5.4.0"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

170
client/src/App.jsx Normal file
View File

@@ -0,0 +1,170 @@
import { useState, useEffect } from 'react'
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
import { getMe } from './api'
import Login from './pages/Login'
import Feed from './pages/Feed'
import Users from './pages/Users'
import UserPosts from './pages/UserPosts'
import Downloads from './pages/Downloads'
import Search from './pages/Search'
import Gallery from './pages/Gallery'
const navItems = [
{ to: '/feed', label: 'Feed', icon: FeedIcon },
{ to: '/users', label: 'Users', icon: UsersIcon },
{ to: '/search', label: 'Search', icon: SearchIcon },
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon },
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon },
{ to: '/', label: 'Settings', icon: SettingsIcon },
]
export default function App() {
const [currentUser, setCurrentUser] = useState(null)
const location = useLocation()
useEffect(() => {
getMe().then((data) => {
if (!data.error) {
setCurrentUser(data)
}
})
}, [])
const refreshUser = () => {
getMe().then((data) => {
if (!data.error) {
setCurrentUser(data)
}
})
}
return (
<div className="flex min-h-screen bg-[#0a0a0a]">
{/* Sidebar */}
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-[#111] border-r border-[#222] flex flex-col z-50">
{/* Logo */}
<div className="p-6 border-b border-[#222]">
<h1 className="text-xl font-bold text-white tracking-tight">
<span className="text-[#0095f6]">OF</span>App
</h1>
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-3">
{navItems.map((item) => {
const Icon = item.icon
const isActive =
item.to === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.to)
return (
<NavLink
key={item.to}
to={item.to}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg mb-1 transition-all duration-200 ${
isActive
? 'bg-[#0095f6]/10 text-[#0095f6]'
: 'text-gray-400 hover:text-white hover:bg-[#1a1a1a]'
}`}
>
<Icon className="w-5 h-5" />
<span className="text-sm font-medium">{item.label}</span>
</NavLink>
)
})}
</nav>
{/* Current User */}
{currentUser && (
<div className="p-4 border-t border-[#222]">
<div className="flex items-center gap-3">
<img
src={currentUser.avatar}
alt={currentUser.name}
className="w-9 h-9 rounded-full object-cover bg-[#1a1a1a]"
onError={(e) => {
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect fill="%23333" width="40" height="40" rx="20"/><text x="20" y="25" text-anchor="middle" fill="white" font-size="16">${(currentUser.name || '?')[0]}</text></svg>`
}}
/>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">
{currentUser.name}
</p>
<p className="text-xs text-gray-500 truncate">
@{currentUser.username}
</p>
</div>
</div>
</div>
)}
</aside>
{/* Main Content */}
<main className="ml-60 flex-1 min-h-screen">
<div className="max-w-5xl mx-auto p-6">
<Routes>
<Route path="/" element={<Login onAuth={refreshUser} />} />
<Route path="/feed" element={<Feed />} />
<Route path="/users" element={<Users />} />
<Route path="/users/:userId" element={<UserPosts />} />
<Route path="/search" element={<Search />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/gallery" element={<Gallery />} />
</Routes>
</div>
</main>
</div>
)
}
/* Icon Components */
function FeedIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
)
}
function UsersIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
}
function DownloadIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
)
}
function SearchIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
)
}
function GalleryNavIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
</svg>
)
}
function SettingsIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
}

116
client/src/api.js Normal file
View File

@@ -0,0 +1,116 @@
async function request(url, options = {}) {
try {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
let errMsg = data.error || data.message || `Request failed with status ${response.status}`;
if (typeof errMsg === 'object') errMsg = errMsg.message || errMsg.error || JSON.stringify(errMsg);
return { error: String(errMsg) };
}
// OF API sometimes returns 200 with error body like {code, message} instead of proper HTTP error
if (data && typeof data.code !== 'undefined' && !data.id) {
return { error: data.message || 'Request failed' };
}
return data;
} catch (err) {
return { error: err.message || 'Network error' };
}
}
function buildQuery(params) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, value);
}
}
const str = query.toString();
return str ? `?${str}` : '';
}
export function getMe() {
return request('/api/me');
}
export function getFeed(beforePublishTime) {
const query = buildQuery({ beforePublishTime });
return request(`/api/feed${query}`);
}
export function getSubscriptions(offset) {
const query = buildQuery({ offset });
return request(`/api/subscriptions${query}`);
}
export function getUserPosts(userId, beforePublishTime) {
const query = buildQuery({ beforePublishTime });
return request(`/api/users/${userId}/posts${query}`);
}
export function getUser(username) {
return request(`/api/users/${username}`);
}
export function saveAuth(config) {
return request('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
}
export function getAuth() {
return request('/api/auth');
}
export function startDownload(userId, limit, resume, username) {
const body = {};
if (limit) body.limit = limit;
if (resume) body.resume = true;
if (username) body.username = username;
return request(`/api/download/${userId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
export function getDownloadStatus(userId) {
return request(`/api/download/${userId}/status`);
}
export function getActiveDownloads() {
return request('/api/download/active');
}
export function getDownloadCursor(userId) {
return request(`/api/download/${userId}/cursor`);
}
export function getDownloadHistory() {
return request('/api/download/history');
}
export function getGalleryFolders() {
return request('/api/gallery/folders');
}
export function getSettings() {
return request('/api/settings');
}
export function updateSettings(settings) {
return request('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
}
export function getGalleryFiles({ folder, folders, type, sort, offset, limit } = {}) {
const query = buildQuery({ folder, folders: folders ? folders.join(',') : undefined, type, sort, offset, limit });
return request(`/api/gallery/files${query}`);
}

View File

@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react'
import shaka from 'shaka-player'
export default function DrmVideo({ dashSrc, cookies, mediaId, entityId, entityType, poster, className = '' }) {
const videoRef = useRef(null)
const playerRef = useRef(null)
const [error, setError] = useState(null)
useEffect(() => {
// EME (Encrypted Media Extensions) requires a secure context
if (!window.isSecureContext) {
const httpsUrl = window.location.href.replace(/^http:/, 'https:').replace(':3002', ':3003')
setError(`DRM requires HTTPS — open ${httpsUrl}`)
return
}
shaka.polyfill.installAll()
const video = videoRef.current
if (!video || !dashSrc) return
let destroyed = false
const player = new shaka.Player()
playerRef.current = player
async function init() {
try {
await player.attach(video)
// Build license URL that routes through OF's DRM resolver
const licenseParams = new URLSearchParams()
if (mediaId) licenseParams.set('mediaId', mediaId)
if (entityId) licenseParams.set('entityId', entityId)
if (entityType) licenseParams.set('entityType', entityType)
if (cookies?.cp) licenseParams.set('cp', cookies.cp)
if (cookies?.cs) licenseParams.set('cs', cookies.cs)
if (cookies?.ck) licenseParams.set('ck', cookies.ck)
const licenseUrl = `/api/drm-license?${licenseParams.toString()}`
player.configure({
drm: {
servers: {
'com.widevine.alpha': licenseUrl,
'com.microsoft.playready': licenseUrl,
},
},
})
// Intercept segment and license requests.
player.getNetworkingEngine().registerRequestFilter((type, request) => {
if (type === shaka.net.NetworkingEngine.RequestType.LICENSE) {
request.headers = request.headers || {}
request.headers['Content-Type'] = 'application/octet-stream'
request.headers['Accept'] = 'application/json, text/plain, */*'
return
}
const url = request.uris[0]
if (!cookies || !url || url.startsWith('/api/')) return
try {
const parsed = new URL(url)
if (!parsed.hostname.endsWith('onlyfans.com')) return
request.uris[0] = `/api/drm-hls?url=${encodeURIComponent(url)}&cp=${encodeURIComponent(cookies.cp || '')}&cs=${encodeURIComponent(cookies.cs || '')}&ck=${encodeURIComponent(cookies.ck || '')}`
} catch {
// Ignore invalid/relative URLs.
}
})
player.addEventListener('error', (e) => {
console.error('[shaka] Player error:', e.detail)
if (!destroyed) setError(e.detail?.message || 'Playback error')
})
if (!destroyed) {
await player.load(dashSrc)
}
} catch (err) {
console.error('[shaka] Load error:', err)
console.error('[shaka] Error code:', err.code, 'category:', err.category, 'data:', err.data)
if (!destroyed) {
const msg = `Shaka ${err.code || 'unknown'}${err.data ? ': ' + JSON.stringify(err.data).substring(0, 200) : ''}`
setError(msg)
}
}
}
init()
return () => {
destroyed = true
if (playerRef.current) {
playerRef.current.destroy()
playerRef.current = null
}
}
}, [dashSrc, cookies, mediaId, entityId, entityType])
if (error) {
return (
<div className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden flex items-center justify-center ${className}`}>
{poster && <img src={poster} alt="" className="w-full h-full object-cover opacity-30" />}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<svg className="w-10 h-10 text-gray-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<span className="text-gray-400 text-xs text-center px-4">{error}</span>
</div>
</div>
)
}
return (
<video
ref={videoRef}
controls
preload="metadata"
playsInline
className={className}
poster={poster}
/>
)
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react'
import Hls from 'hls.js'
export default function HlsVideo({ hlsSrc, src, autoPlay, ...props }) {
const videoRef = useRef(null)
const hlsRef = useRef(null)
useEffect(() => {
const video = videoRef.current
if (!video) return
function cleanup() {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
}
function setDirectSrc(url) {
if (url) {
video.src = url
if (autoPlay) video.play().catch(() => {})
}
}
cleanup()
// No HLS URL — use direct source
if (!hlsSrc) {
setDirectSrc(src)
return cleanup
}
// Always use hls.js when supported (including Safari) for consistent behavior
if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 10,
maxMaxBufferLength: 30,
emeEnabled: true,
})
hlsRef.current = hls
hls.loadSource(hlsSrc)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoPlay) video.play().catch(() => {})
})
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
console.error('[hls.js] fatal error, falling back to direct src', data)
cleanup()
setDirectSrc(src)
}
})
return cleanup
}
// Safari without MSE (older iOS) — use native HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = hlsSrc
video.load()
if (autoPlay) video.play().catch(() => {})
return cleanup
}
// Fallback to direct source
setDirectSrc(src)
return cleanup
}, [hlsSrc, src, autoPlay])
return <video ref={videoRef} autoPlay={autoPlay} {...props} />
}

View File

@@ -0,0 +1,40 @@
export default function LoadMoreButton({ onClick, loading, hasMore }) {
if (!hasMore) return null
return (
<div className="flex justify-center py-6">
<button
onClick={onClick}
disabled={loading}
className="px-6 py-2.5 bg-[#1a1a1a] hover:bg-[#222] border border-[#333] text-gray-300 hover:text-white text-sm font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Loading...
</>
) : (
'Load More'
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,237 @@
import DrmVideo from './DrmVideo'
const DRM_ENTITY_TYPES = new Set(['post', 'message', 'story', 'stream'])
function normalizeDrmEntityType(type) {
const normalized = String(type || '').toLowerCase()
return DRM_ENTITY_TYPES.has(normalized) ? normalized : null
}
function proxyUrl(url) {
if (!url) return null
try {
const parsed = new URL(url)
if (parsed.hostname.endsWith('onlyfans.com') && !parsed.hostname.startsWith('public.') && !parsed.hostname.startsWith('thumbs.')) {
return `/api/media-proxy?url=${encodeURIComponent(url)}`
}
} catch {}
return url
}
function getMediaUrl(item) {
// For videos, source.source has the actual video URL;
// files.full/preview are poster frame images
if (item.source?.source) return item.source.source
if (item.files?.full?.url) return item.files.full.url
if (item.files?.preview?.url) return item.files.preview.url
if (item.preview) return item.preview
if (item.thumb) return item.thumb
if (item.src) return item.src
return null
}
function getMediaType(item) {
if (item.type === 'video' || item.type === 'gif') return 'video'
if (item.type === 'photo' || item.type === 'image') return 'image'
// Fallback: check file extension
const url = getMediaUrl(item) || ''
if (/\.(mp4|mov|avi|webm)/i.test(url)) return 'video'
return 'image'
}
function getDrmDashInfo(item, { entityId, entityType } = {}) {
const drm = item.files?.drm
if (!drm?.manifest?.dash || !drm?.signature?.dash) return null
const sig = drm.signature.dash
const normalizedEntityType = normalizeDrmEntityType(entityType)
const parsedEntityId = Number.parseInt(entityId, 10)
const normalizedEntityId = Number.isFinite(parsedEntityId) && parsedEntityId > 0
? String(parsedEntityId)
: null
return {
dashSrc: `/api/drm-hls?url=${encodeURIComponent(drm.manifest.dash)}&cp=${encodeURIComponent(sig['CloudFront-Policy'])}&cs=${encodeURIComponent(sig['CloudFront-Signature'])}&ck=${encodeURIComponent(sig['CloudFront-Key-Pair-Id'])}`,
cookies: {
cp: sig['CloudFront-Policy'],
cs: sig['CloudFront-Signature'],
ck: sig['CloudFront-Key-Pair-Id'],
},
mediaId: item.id,
entityId: normalizedEntityId,
entityType: normalizedEntityType,
}
}
function MediaItem({ item, className = '', entityId, entityType }) {
const rawUrl = getMediaUrl(item)
const url = proxyUrl(rawUrl)
const type = getMediaType(item)
const canView = item.canView !== false
if (!canView) {
return (
<div
className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden ${className}`}
>
{url ? (
<img
src={url}
alt=""
className="w-full h-full object-cover blur-lg scale-110"
/>
) : (
<div className="w-full h-full bg-[#1a1a1a]" />
)}
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
</div>
</div>
)
}
if (!url) {
return (
<div
className={`bg-[#1a1a1a] rounded-lg flex items-center justify-center ${className}`}
>
<span className="text-gray-600 text-xs">No preview</span>
</div>
)
}
if (type === 'video') {
const drmInfo = getDrmDashInfo(item, { entityId, entityType })
const posterUrl =
proxyUrl(item.files?.preview?.url) ||
proxyUrl(item.preview) ||
proxyUrl(item.thumb) ||
proxyUrl(item.files?.squarePreview?.url) ||
undefined
if (drmInfo) {
return (
<div
className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden ${className}`}
>
<DrmVideo
dashSrc={drmInfo.dashSrc}
cookies={drmInfo.cookies}
mediaId={drmInfo.mediaId}
entityId={drmInfo.entityId}
entityType={drmInfo.entityType}
poster={posterUrl}
className="w-full h-full object-contain"
/>
{item.duration && (
<span className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded pointer-events-none">
{formatDuration(item.duration)}
</span>
)}
</div>
)
}
return (
<div
className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden ${className}`}
>
<video
src={url}
controls
preload="metadata"
playsInline
className="w-full h-full object-contain"
poster={posterUrl}
/>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="absolute top-2 right-2 w-7 h-7 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center transition-colors"
title="Open in new tab"
>
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
{item.duration && (
<span className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded pointer-events-none">
{formatDuration(item.duration)}
</span>
)}
</div>
)
}
// Image
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={`block bg-[#1a1a1a] rounded-lg overflow-hidden cursor-pointer ${className}`}
>
<img
src={url}
alt=""
className="w-full h-auto object-contain"
loading="lazy"
/>
</a>
)
}
function formatDuration(seconds) {
if (!seconds) return ''
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export default function MediaGrid({ media, entityId, entityType }) {
if (!media || media.length === 0) return null
const count = media.length
if (count === 1) {
return (
<MediaItem
item={media[0]}
className="w-full"
entityId={entityId}
entityType={entityType}
/>
)
}
if (count === 2) {
return (
<div className="grid grid-cols-2 gap-1">
<MediaItem item={media[0]} className="w-full" entityId={entityId} entityType={entityType} />
<MediaItem item={media[1]} className="w-full" entityId={entityId} entityType={entityType} />
</div>
)
}
// 3 or more items: show all in 2-col grid
return (
<div className="grid grid-cols-2 gap-1">
{media.map((item, i) => (
<MediaItem key={item.id || i} item={item} className="w-full" entityId={entityId} entityType={entityType} />
))}
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react'
import MediaGrid from './MediaGrid'
function sanitizeHtml(html) {
if (!html) return ''
// Strip script tags and event handlers
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\son\w+\s*=\s*[^\s>]*/gi, '')
}
function timeAgo(dateStr) {
if (!dateStr) return ''
const now = Date.now()
const date = new Date(dateStr).getTime()
const diff = now - date
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const weeks = Math.floor(days / 7)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)
if (years > 0) return `${years}y ago`
if (months > 0) return `${months}mo ago`
if (weeks > 0) return `${weeks}w ago`
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return 'just now'
}
export default function PostCard({ post }) {
const author = post.author || post.fromUser || {}
const media = post.media || []
const text = post.text || post.rawText || ''
const postedAt = post.postedAt || post.createdAt || post.publishedAt
const [showText, setShowText] = useState(false)
return (
<article className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
{/* Author Row */}
<div className="flex items-center gap-3 p-4 pb-0">
<img
src={author.avatar}
alt={author.name || 'User'}
className="w-9 h-9 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
onError={(e) => {
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><rect fill="%23333" width="36" height="36" rx="18"/><text x="18" y="23" text-anchor="middle" fill="white" font-size="14">${(author.name || '?')[0]}</text></svg>`
}}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white truncate">
{author.name || 'Unknown'}
</p>
<p className="text-xs text-gray-500">
{author.username && <span>@{author.username}</span>}
{author.username && postedAt && <span className="mx-1.5">·</span>}
{postedAt && <span>{timeAgo(postedAt)}</span>}
</p>
</div>
</div>
{/* Media */}
{media.length > 0 && (
<div className="p-4 pt-3 pb-0">
<MediaGrid
media={media}
entityId={post.id}
entityType={post.responseType}
/>
</div>
)}
{/* Collapsible Post Text */}
{text && (
<div className="px-4 pt-2 pb-3">
<button
onClick={() => setShowText((v) => !v)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
<svg
className={`w-3.5 h-3.5 transition-transform duration-200 ${showText ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{showText ? 'Hide text' : 'Show text'}
</button>
{showText && (
<div
className="mt-2 text-sm text-gray-300 leading-relaxed [&>p]:mb-2 [&_a]:text-[#0095f6] [&_a]:hover:underline"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(text) }}
/>
)}
</div>
)}
{/* Bottom Padding if no text toggle */}
{!text && media.length > 0 && <div className="pb-4" />}
{!text && media.length === 0 && <div className="pb-4" />}
</article>
)
}

View File

@@ -0,0 +1,26 @@
export default function Spinner({ size = 'h-8 w-8' }) {
return (
<div className="flex items-center justify-center py-12">
<svg
className={`animate-spin ${size} text-[#0095f6]`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { Link } from 'react-router-dom'
function decodeHTML(str) {
if (!str) return str
const el = document.createElement('textarea')
el.innerHTML = str
return el.value
}
export default function UserCard({ user, onDownload, downloading }) {
const handleDownloadClick = (e) => {
e.preventDefault()
e.stopPropagation()
if (onDownload && !downloading) {
onDownload(user.id, user.username)
}
}
return (
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 hover:border-[#333] transition-colors duration-200 hover-lift">
<div className="flex items-start gap-3">
{/* Avatar */}
<img
src={user.avatar}
alt={user.name}
className="w-16 h-16 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
onError={(e) => {
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect fill="%23333" width="64" height="64" rx="32"/><text x="32" y="40" text-anchor="middle" fill="white" font-size="24">${(user.name || '?')[0]}</text></svg>`
}}
/>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">
{decodeHTML(user.name)}
</p>
<p className="text-xs text-gray-500 truncate">@{user.username}</p>
{(user.postsCount !== undefined || user.mediasCount !== undefined || user.photosCount !== undefined || user.videosCount !== undefined) && (
<p className="text-xs text-gray-600 mt-1">
{[
user.postsCount !== undefined && `${user.postsCount.toLocaleString()} posts`,
user.mediasCount !== undefined && `${user.mediasCount.toLocaleString()} media`,
user.photosCount !== undefined && `${user.photosCount.toLocaleString()} photos`,
user.videosCount !== undefined && `${user.videosCount.toLocaleString()} videos`,
].filter(Boolean).join(' · ')}
</p>
)}
</div>
{/* Download Button */}
<button
onClick={handleDownloadClick}
disabled={downloading}
className={`flex-shrink-0 p-2 rounded-lg transition-colors ${
downloading
? 'text-[#0095f6] bg-[#0095f6]/10'
: 'text-gray-500 hover:text-[#0095f6] hover:bg-[#0095f6]/10'
}`}
title={downloading ? 'Download started' : 'Download all media'}
>
{downloading ? (
<svg
className="w-4 h-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
)}
</button>
</div>
{/* View Posts Link */}
<Link
to={`/users/${user.id}`}
state={{ user }}
className="block mt-3 text-center py-2 text-sm text-[#0095f6] hover:bg-[#0095f6]/10 rounded-lg transition-colors"
>
View Posts
</Link>
</div>
)
}

71
client/src/index.css Normal file
View File

@@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #0a0a0a;
color: #ffffff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0a0a0a;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: #333 #0a0a0a;
}
/* Hide scrollbar for horizontal scroll areas */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Transition utilities */
.transition-smooth {
transition: all 0.2s ease-in-out;
}
.hover-lift {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.hover-lift:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Image loading placeholder */
img {
background-color: #1a1a1a;
}
/* Remove default link styles */
a {
color: inherit;
text-decoration: none;
}

13
client/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,258 @@
import { useState, useEffect, useRef } from 'react'
import { getDownloadHistory, getActiveDownloads, getUser } from '../api'
import Spinner from '../components/Spinner'
export default function Downloads() {
const [history, setHistory] = useState([])
const [active, setActive] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const pollRef = useRef(null)
const [usernames, setUsernames] = useState({})
useEffect(() => {
loadAll()
startPolling()
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [])
const loadAll = async () => {
setLoading(true)
setError(null)
const [histData, activeData] = await Promise.all([
getDownloadHistory(),
getActiveDownloads(),
])
if (histData.error) {
setError(histData.error)
setLoading(false)
return
}
const histList = Array.isArray(histData) ? histData : histData.list || []
setHistory(histList)
setActive(Array.isArray(activeData) ? activeData : [])
setLoading(false)
resolveUsernames(histList)
}
const startPolling = () => {
pollRef.current = setInterval(async () => {
const activeData = await getActiveDownloads()
if (activeData.error) return
const list = Array.isArray(activeData) ? activeData : []
setActive((prev) => {
// If something just finished, refresh history
if (prev.length > 0 && list.length < prev.length) {
getDownloadHistory().then((h) => {
if (!h.error) setHistory(Array.isArray(h) ? h : h.list || [])
})
}
return list
})
}, 2000)
}
const resolveUsernames = async (items) => {
const ids = [...new Set(items.map((i) => i.userId || i.user_id).filter(Boolean))]
for (const id of ids) {
if (usernames[id]) continue
const data = await getUser(id)
if (data && !data.error && data.username) {
setUsernames((prev) => ({ ...prev, [id]: data.username }))
}
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '--'
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
if (loading) return <Spinner />
if (error) {
return (
<div className="text-center py-16">
<p className="text-red-400 mb-4">{error}</p>
<button
onClick={loadAll}
className="text-[#0095f6] hover:underline text-sm"
>
Try again
</button>
</div>
)
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-white mb-1">Downloads</h1>
<p className="text-gray-500 text-sm">
Manage and monitor media downloads
</p>
</div>
{/* Active Downloads */}
{active.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
Active Downloads
</h2>
<div className="space-y-3">
{active.map((dl) => {
const uid = dl.user_id
const progress =
dl.total > 0
? Math.round((dl.completed / dl.total) * 100)
: 0
return (
<div
key={uid}
className="bg-[#161616] border border-[#222] rounded-lg p-4"
>
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm font-medium text-white">
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
</p>
<p className="text-xs text-gray-500">
{dl.completed || 0} / {dl.total || '?'} files
{dl.errors > 0 && (
<span className="text-red-400 ml-2">
({dl.errors} error{dl.errors !== 1 ? 's' : ''})
</span>
)}
</p>
</div>
<span className="text-xs text-[#0095f6] font-medium">
{progress}%
</span>
</div>
{/* Progress Bar */}
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5">
<div
className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Download History */}
<div>
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
History
</h2>
{history.length === 0 && active.length === 0 ? (
<div className="text-center py-12 bg-[#161616] border border-[#222] rounded-lg">
<svg
className="w-12 h-12 text-gray-600 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
<p className="text-gray-500 text-sm">No download history yet</p>
<p className="text-gray-600 text-xs mt-1">
Start downloading from the Users page
</p>
</div>
) : history.length === 0 ? null : (
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 border-b border-[#222] text-xs font-semibold text-gray-500 uppercase tracking-wider">
<span>User</span>
<span className="text-right">Files</span>
<span className="text-right">Status</span>
<span className="text-right">Date</span>
</div>
{/* Table Rows */}
{history.map((item, index) => {
const uid = item.userId || item.user_id
return (
<div
key={uid || index}
className={`grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 items-center ${
index < history.length - 1 ? 'border-b border-[#1a1a1a]' : ''
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
<span className="text-sm text-white truncate">
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
</span>
</div>
<span className="text-sm text-gray-400 text-right tabular-nums">
{item.fileCount || item.file_count || 0}
</span>
<span className="text-right">
<StatusBadge status="complete" />
</span>
<span className="text-xs text-gray-500 text-right whitespace-nowrap">
{formatDate(
item.lastDownload ||
item.last_download ||
item.completedAt ||
item.created_at
)}
</span>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
function StatusBadge({ status }) {
const styles = {
complete: 'bg-green-500/10 text-green-400',
completed: 'bg-green-500/10 text-green-400',
running: 'bg-blue-500/10 text-blue-400',
error: 'bg-red-500/10 text-red-400',
}
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
styles[status] || 'bg-gray-500/10 text-gray-400'
}`}
>
{status}
</span>
)
}

119
client/src/pages/Feed.jsx Normal file
View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { getFeed } from '../api'
import PostCard from '../components/PostCard'
import Spinner from '../components/Spinner'
import LoadMoreButton from '../components/LoadMoreButton'
export default function Feed() {
const [posts, setPosts] = useState([])
const [tailMarker, setTailMarker] = useState(null)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
loadFeed()
}, [])
const loadFeed = async () => {
setLoading(true)
setError(null)
const data = await getFeed()
if (data.error) {
setError(data.error)
setLoading(false)
return
}
const items = data.list || data || []
items.sort((a, b) => {
const dateA = new Date(a.postedAt || a.createdAt || a.publishedAt || 0)
const dateB = new Date(b.postedAt || b.createdAt || b.publishedAt || 0)
return dateB - dateA
})
setPosts(items)
setTailMarker(data.tailMarker || null)
setHasMore(items.length > 0 && !!data.tailMarker)
setLoading(false)
}
const loadMore = async () => {
if (!tailMarker || loadingMore) return
setLoadingMore(true)
const data = await getFeed(tailMarker)
if (!data.error) {
const items = data.list || data || []
setPosts((prev) => {
const all = [...prev, ...items]
all.sort((a, b) => {
const dateA = new Date(a.postedAt || a.createdAt || a.publishedAt || 0)
const dateB = new Date(b.postedAt || b.createdAt || b.publishedAt || 0)
return dateB - dateA
})
return all
})
setTailMarker(data.tailMarker || null)
setHasMore(items.length > 0 && !!data.tailMarker)
}
setLoadingMore(false)
}
if (loading) return <Spinner />
if (error) {
return (
<div className="text-center py-16">
<p className="text-red-400 mb-4">{error}</p>
<button
onClick={loadFeed}
className="text-[#0095f6] hover:underline text-sm"
>
Try again
</button>
</div>
)
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-white mb-1">Feed</h1>
<p className="text-gray-500 text-sm">
Recent posts from your subscriptions
</p>
</div>
{posts.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-500">No posts to show</p>
<p className="text-gray-600 text-sm mt-1">
Make sure you have active subscriptions
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{posts.map((post) => (
<div key={post.id}>
<PostCard post={post} />
</div>
))}
</div>
<div className="mt-4">
<LoadMoreButton
onClick={loadMore}
loading={loadingMore}
hasMore={hasMore}
/>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,564 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { getGalleryFolders, getGalleryFiles, getSettings } from '../api'
import Spinner from '../components/Spinner'
import LoadMoreButton from '../components/LoadMoreButton'
import HlsVideo from '../components/HlsVideo'
const PAGE_SIZE = 50
function formatShortDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const TYPE_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'image', label: 'Images' },
{ value: 'video', label: 'Videos' },
]
export default function Gallery() {
const [folders, setFolders] = useState([])
const [files, setFiles] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState(null)
const [activeFolder, setActiveFolder] = useState(null) // kept for API compat
const [checkedFolders, setCheckedFolders] = useState(new Set())
const [typeFilter, setTypeFilter] = useState('all')
const [shuffle, setShuffle] = useState(false)
const [lightbox, setLightbox] = useState(null)
const [slideshow, setSlideshow] = useState(false)
const [hlsEnabled, setHlsEnabled] = useState(false)
const [userFilterOpen, setUserFilterOpen] = useState(false)
const [userSearch, setUserSearch] = useState('')
const filterRef = useRef(null)
useEffect(() => {
getSettings().then((data) => {
if (!data.error) setHlsEnabled(data.hls_enabled === 'true')
})
getGalleryFolders().then((data) => {
if (!data.error) setFolders(Array.isArray(data) ? data : [])
})
}, [])
// Close popover on click outside
useEffect(() => {
if (!userFilterOpen) return
const handleClick = (e) => {
if (filterRef.current && !filterRef.current.contains(e.target)) {
setUserFilterOpen(false)
setUserSearch('')
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [userFilterOpen])
const togglePill = (name) => {
setActiveFolder(null)
setCheckedFolders((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
const clearFilters = () => {
setActiveFolder(null)
setCheckedFolders(new Set())
}
// Build the folder/folders param for the API
// Checked folders take priority over active (clicked) folder
const getFilterParams = useCallback(() => {
if (checkedFolders.size > 0) {
return { folders: Array.from(checkedFolders) }
}
if (activeFolder) {
return { folder: activeFolder }
}
return {}
}, [activeFolder, checkedFolders])
const loadFiles = useCallback(async (reset = true) => {
if (reset) {
setLoading(true)
setError(null)
} else {
setLoadingMore(true)
}
const offset = reset ? 0 : files.length
const data = await getGalleryFiles({
...getFilterParams(),
type: typeFilter !== 'all' ? typeFilter : undefined,
sort: shuffle ? 'shuffle' : 'latest',
offset,
limit: PAGE_SIZE,
})
if (data.error) {
setError(data.error)
} else {
setFiles((prev) => (reset ? data.files : [...prev, ...data.files]))
setTotal(data.total)
}
setLoading(false)
setLoadingMore(false)
}, [getFilterParams, typeFilter, shuffle, files.length])
useEffect(() => {
loadFiles(true)
}, [activeFolder, checkedFolders, typeFilter, shuffle])
const handleReshuffle = () => {
loadFiles(true)
}
const hasMore = files.length < total
return (
<div>
{/* Header */}
<div className="mb-4">
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-bold text-white">Gallery</h1>
<p className="text-gray-500 text-sm">
{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-6">
{/* User Filter Popover */}
<div className="relative" ref={filterRef}>
<button
onClick={() => { setUserFilterOpen((v) => !v); setUserSearch('') }}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
checkedFolders.size > 0
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
}`}
>
<UsersFilterIcon className="w-4 h-4" />
Users
{checkedFolders.size > 0 && (
<span className="bg-[#0095f6] text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-medium">
{checkedFolders.size}
</span>
)}
</button>
{userFilterOpen && (
<div className="absolute top-full left-0 mt-2 w-64 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-[#333]">
<input
type="text"
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
placeholder="Search users..."
autoFocus
className="w-full px-3 py-1.5 bg-[#111] border border-[#333] rounded-md text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6]"
/>
</div>
{/* List */}
<div className="max-h-72 overflow-y-auto">
{folders
.filter((f) => f.name.toLowerCase().includes(userSearch.toLowerCase()))
.map((f) => (
<button
key={f.name}
onClick={() => togglePill(f.name)}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-[#252525] transition-colors text-left"
>
<div className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center ${
checkedFolders.has(f.name)
? 'bg-[#0095f6] border-[#0095f6]'
: 'border-[#555]'
}`}>
{checkedFolders.has(f.name) && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-sm text-gray-300 truncate flex-1">{f.name}</span>
<span className="text-xs text-gray-600 flex-shrink-0">{f.total}</span>
</button>
))}
</div>
{/* Footer */}
{checkedFolders.size > 0 && (
<div className="p-2 border-t border-[#333]">
<button
onClick={() => { clearFilters(); setUserFilterOpen(false) }}
className="w-full py-1.5 text-xs text-gray-400 hover:text-white transition-colors"
>
Clear all
</button>
</div>
)}
</div>
)}
</div>
{/* Selected user tags */}
{checkedFolders.size > 0 && checkedFolders.size <= 5 && (
Array.from(checkedFolders).map((name) => (
<span
key={name}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs bg-[#0095f6]/10 text-[#0095f6] rounded-lg border border-[#0095f6]/30"
>
{name}
<button
onClick={() => togglePill(name)}
className="hover:text-white transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))
)}
{/* Type Filter */}
<div className="flex rounded-lg overflow-hidden border border-[#333]">
{TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={`px-3 py-2 text-sm transition-colors ${
typeFilter === opt.value
? 'bg-[#0095f6] text-white'
: 'bg-[#161616] text-gray-400 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Shuffle Toggle */}
<button
onClick={() => setShuffle((s) => !s)}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
shuffle
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
}`}
>
<ShuffleIcon className="w-4 h-4" />
Shuffle
</button>
{/* Reshuffle Button */}
{shuffle && (
<button
onClick={handleReshuffle}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
>
<RefreshIcon className="w-4 h-4" />
Reshuffle
</button>
)}
{/* Slideshow Button */}
<button
onClick={() => setSlideshow(true)}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
>
<SlideshowIcon className="w-4 h-4" />
Slideshow
</button>
</div>
{loading ? (
<Spinner />
) : error ? (
<div className="text-center py-12">
<p className="text-red-400 mb-4">{error}</p>
</div>
) : files.length === 0 ? (
<div className="text-center py-16 bg-[#161616] border border-[#222] rounded-lg">
<GalleryIcon className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No media files found</p>
<p className="text-gray-600 text-xs mt-1">
Download media from the Users or Search page
</p>
</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{files.map((file, i) => (
<div
key={`${file.folder}-${file.filename}-${i}`}
className="relative group bg-[#161616] rounded-lg overflow-hidden cursor-pointer aspect-square"
onClick={() => setLightbox(file)}
>
{file.type === 'video' ? (
<video
src={`${file.url}#t=0.5`}
preload="metadata"
muted
playsInline
className="w-full h-full object-cover"
/>
) : (
<img
src={file.url}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
)}
{/* Date badge */}
{file.postedAt && (
<div className="absolute top-2 left-2">
<span className="bg-black/50 text-white/80 text-[10px] px-1.5 py-0.5 rounded">
{formatShortDate(file.postedAt)}
</span>
</div>
)}
{/* Overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end">
<div className="w-full p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-xs text-white truncate">@{file.folder}</p>
</div>
</div>
{/* Video badge */}
{file.type === 'video' && (
<div className="absolute top-2 right-2">
<svg className="w-5 h-5 text-white drop-shadow-lg" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
)}
</div>
))}
</div>
<div className="mt-6">
<LoadMoreButton
onClick={() => loadFiles(false)}
loading={loadingMore}
hasMore={hasMore}
/>
</div>
</>
)}
{/* Lightbox */}
{lightbox && (
<Lightbox file={lightbox} hlsEnabled={hlsEnabled} onClose={() => setLightbox(null)} />
)}
{/* Slideshow */}
{slideshow && (
<Slideshow
filterParams={getFilterParams()}
onClose={() => setSlideshow(false)}
/>
)}
</div>
)
}
function Lightbox({ file, hlsEnabled, onClose }) {
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center"
onClick={onClose}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/70 hover:text-white z-10"
>
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
{file.type === 'video' ? (
<HlsVideo
hlsSrc={hlsEnabled && file.type === 'video' ? `/api/hls/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}/master.m3u8` : null}
src={file.url}
controls
autoPlay
className="max-w-full max-h-[90vh] rounded-lg"
/>
) : (
<img
src={file.url}
alt=""
className="max-w-full max-h-[90vh] rounded-lg object-contain"
/>
)}
<p className="text-center text-sm text-gray-400 mt-3">@{file.folder}</p>
</div>
</div>
)
}
function Slideshow({ filterParams, onClose }) {
const [current, setCurrent] = useState(null)
const [images, setImages] = useState([])
const [index, setIndex] = useState(0)
const [paused, setPaused] = useState(false)
// Load a large shuffled batch of images
useEffect(() => {
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
if (!data.error && data.files.length > 0) {
setImages(data.files)
setCurrent(data.files[0])
}
})
}, [])
// Auto-advance every 5 seconds
useEffect(() => {
if (images.length === 0 || paused) return
const timer = setInterval(() => {
setIndex((prev) => {
const next = prev + 1
if (next >= images.length) {
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
if (!data.error && data.files.length > 0) {
setImages(data.files)
setCurrent(data.files[0])
setIndex(0)
}
})
return prev
}
setCurrent(images[next])
return next
})
}, 5000)
return () => clearInterval(timer)
}, [images, paused])
// Keyboard controls
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p) }
if (e.key === 'ArrowRight' && images.length > 0) {
setIndex((prev) => {
const next = Math.min(prev + 1, images.length - 1)
setCurrent(images[next])
return next
})
}
if (e.key === 'ArrowLeft' && images.length > 0) {
setIndex((prev) => {
const next = Math.max(prev - 1, 0)
setCurrent(images[next])
return next
})
}
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [onClose, images])
return (
<div className="fixed inset-0 z-[100] bg-black flex items-center justify-center">
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/50 hover:text-white z-10"
>
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{paused && (
<div className="absolute top-4 left-4 text-white/50 text-sm z-10">
Paused
</div>
)}
{current ? (
<img
key={current.url}
src={current.url}
alt=""
className="max-w-full max-h-full object-contain animate-fadeIn"
/>
) : (
<Spinner />
)}
{current && (
<div className="absolute bottom-4 left-0 right-0 text-center text-white/40 text-sm">
@{current.folder} &middot; {index + 1} / {images.length}
</div>
)}
</div>
)
}
function SlideshowIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
)
}
function ShuffleIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
)
}
function RefreshIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
)
}
function UsersFilterIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
}
function GalleryIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
</svg>
)
}

279
client/src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,279 @@
import { useState, useEffect } from 'react'
import { saveAuth, getAuth, getMe, getSettings, updateSettings } from '../api'
import Spinner from '../components/Spinner'
const fields = [
{
key: 'user_id',
label: 'User ID',
placeholder: 'Your numeric user ID',
mono: true,
},
{
key: 'cookie',
label: 'Cookie',
placeholder: 'Full cookie string from browser (st=...; sess=...; auth_id=...)',
mono: true,
},
{
key: 'x_bc',
label: 'X-BC',
placeholder: 'X-BC header value',
mono: true,
},
{
key: 'app_token',
label: 'App Token',
placeholder: '33d57ade8c02dbc5a333db99ff9ae26a',
mono: true,
},
{
key: 'x_of_rev',
label: 'X-OF-Rev',
placeholder: 'Revision hash value',
mono: true,
},
{
key: 'user_agent',
label: 'User Agent',
placeholder: 'Your browser user agent (optional)',
mono: false,
},
]
export default function Login({ onAuth }) {
const [form, setForm] = useState({
user_id: '',
cookie: '',
x_bc: '',
app_token: '',
x_of_rev: '',
user_agent: '',
})
const [status, setStatus] = useState(null) // { type: 'success'|'error', message }
const [loading, setLoading] = useState(false)
const [initialLoading, setInitialLoading] = useState(true)
const [hlsEnabled, setHlsEnabled] = useState(false)
useEffect(() => {
getAuth().then((data) => {
if (!data.error && data) {
setForm((prev) => ({
...prev,
...Object.fromEntries(
Object.entries(data).filter(
([key]) => key in prev && data[key]
)
),
}))
}
setInitialLoading(false)
})
getSettings().then((data) => {
if (!data.error) {
setHlsEnabled(data.hls_enabled === 'true')
}
})
}, [])
const handleChange = (key, value) => {
setForm((prev) => ({ ...prev, [key]: value }))
setStatus(null)
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setStatus(null)
// Validate required fields
const required = ['user_id', 'cookie', 'x_bc', 'app_token']
for (const key of required) {
if (!form[key].trim()) {
setStatus({ type: 'error', message: `${key} is required` })
setLoading(false)
return
}
}
const saveResult = await saveAuth(form)
if (saveResult.error) {
setStatus({ type: 'error', message: saveResult.error })
setLoading(false)
return
}
// Validate by fetching current user
const meResult = await getMe()
if (meResult.error) {
setStatus({
type: 'error',
message: `Auth saved but validation failed: ${meResult.error}`,
})
setLoading(false)
return
}
setStatus({
type: 'success',
message: `Connected as ${meResult.name} (@${meResult.username})`,
})
setLoading(false)
if (onAuth) onAuth()
}
if (initialLoading) {
return <Spinner />
}
return (
<div className="max-w-2xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Settings</h1>
<p className="text-gray-400 text-sm">
Configure your API authentication credentials.
</p>
</div>
{/* Instructions */}
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
<h3 className="text-sm font-semibold text-gray-300 mb-2">
How to find your credentials
</h3>
<ol className="text-xs text-gray-500 space-y-1.5 list-decimal list-inside">
<li>
Open the website in your browser and log in
</li>
<li>
Open DevTools (F12) and go to the <strong className="text-gray-400">Network</strong> tab
</li>
<li>
Refresh the page and click on any API request to{' '}
<code className="text-[#0095f6] bg-[#0095f6]/10 px-1 rounded">
onlyfans.com/api2/v2/
</code>
</li>
<li>
Copy the header values from the <strong className="text-gray-400">Request Headers</strong> section
</li>
<li>
Your User ID can be found in the <code className="text-[#0095f6] bg-[#0095f6]/10 px-1 rounded">user-id</code> header
</li>
</ol>
</div>
{/* Form */}
<form onSubmit={handleSubmit}>
<div className="bg-[#161616] border border-[#222] rounded-lg p-6 space-y-5">
{fields.map((field) => (
<div key={field.key}>
<label
htmlFor={field.key}
className="block text-sm font-medium text-gray-300 mb-1.5"
>
{field.label}
{field.key !== 'user_agent' && field.key !== 'x_of_rev' && (
<span className="text-red-400 ml-1">*</span>
)}
</label>
<input
id={field.key}
type="text"
value={form[field.key]}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.placeholder}
className={`w-full px-3 py-2.5 bg-[#1a1a1a] border border-[#333] rounded-lg text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#0095f6] focus:ring-1 focus:ring-[#0095f6]/50 transition-colors ${
field.mono ? 'font-mono' : ''
}`}
autoComplete="off"
spellCheck="false"
/>
</div>
))}
{/* Status Message */}
{status && (
<div
className={`px-4 py-3 rounded-lg text-sm ${
status.type === 'success'
? 'bg-green-500/10 border border-green-500/30 text-green-400'
: 'bg-red-500/10 border border-red-500/30 text-red-400'
}`}
>
{status.message}
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
>
{loading ? (
<>
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Connecting...
</>
) : (
'Save & Connect'
)}
</button>
</div>
</form>
{/* App Settings */}
<div className="mt-8">
<h2 className="text-lg font-bold text-white mb-4">App Settings</h2>
<div className="bg-[#161616] border border-[#222] rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-300">HLS Video Streaming</p>
<p className="text-xs text-gray-500 mt-0.5">
Videos start playing instantly via segmented streaming instead of downloading the full file first
</p>
</div>
<button
type="button"
role="switch"
aria-checked={hlsEnabled}
onClick={async () => {
const next = !hlsEnabled
setHlsEnabled(next)
await updateSettings({ hls_enabled: String(next) })
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
hlsEnabled ? 'bg-[#0095f6]' : 'bg-[#333]'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
hlsEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
</div>
)
}

295
client/src/pages/Search.jsx Normal file
View File

@@ -0,0 +1,295 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { getUser, startDownload, getDownloadCursor } from '../api'
const CACHE_KEY = 'search_state'
function decodeHTML(str) {
if (!str) return str
const el = document.createElement('textarea')
el.innerHTML = str
return el.value
}
function loadCache() {
try {
const raw = sessionStorage.getItem(CACHE_KEY)
return raw ? JSON.parse(raw) : null
} catch { return null }
}
function saveCache(query, user, error, searched) {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ query, user, error, searched }))
} catch {}
}
export default function Search() {
const cached = loadCache()
const [query, setQuery] = useState(cached?.query || '')
const [user, setUser] = useState(cached?.user || null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(cached?.error || null)
const [searched, setSearched] = useState(cached?.searched || false)
const [downloading, setDownloading] = useState(false)
const [showDownloadMenu, setShowDownloadMenu] = useState(false)
const [cursorInfo, setCursorInfo] = useState(null)
useEffect(() => {
if (user?.id && user.subscribedBy) {
getDownloadCursor(user.id).then((data) => {
if (data && data.hasCursor) setCursorInfo(data)
else setCursorInfo(null)
})
}
}, [user?.id, user?.subscribedBy])
const handleSearch = async (e) => {
e.preventDefault()
const username = query.trim()
if (!username) return
setLoading(true)
setError(null)
setUser(null)
setSearched(true)
const data = await getUser(username)
if (data.error || !data.id) {
const err = data.error || data.message || 'User not found'
setError(err)
saveCache(username, null, err, true)
} else {
setUser(data)
saveCache(username, data, null, true)
}
setLoading(false)
}
const handleDownload = async (limit, resume) => {
if (!user) return
setShowDownloadMenu(false)
setDownloading(true)
const result = await startDownload(user.id, limit, resume, user.username)
if (result.error) {
console.error('Download failed:', result.error)
}
setTimeout(() => {
setDownloading(false)
if (user?.id) {
getDownloadCursor(user.id).then((data) => {
if (data && data.hasCursor) setCursorInfo(data)
else setCursorInfo(null)
})
}
}, 2000)
}
const isSubscribed = user && user.subscribedBy
return (
<div>
<h1 className="text-2xl font-bold text-white mb-6">Search User</h1>
{/* Search Form */}
<form onSubmit={handleSearch} className="flex gap-3 mb-6">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter username..."
className="flex-1 bg-[#161616] border border-[#333] rounded-lg px-4 py-2.5 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-[#0095f6] transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="px-5 py-2.5 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{loading ? 'Searching...' : 'Search'}
</button>
</form>
{/* Loading */}
{loading && (
<div className="flex justify-center py-16">
<svg className="w-6 h-6 animate-spin text-[#0095f6]" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{/* Error */}
{error && !loading && (
<div className="text-center py-16">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* No results */}
{searched && !loading && !error && !user && (
<div className="text-center py-16">
<p className="text-gray-500 text-sm">No user found</p>
</div>
)}
{/* Result Card */}
{user && !loading && (
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
{/* Banner/Header Image */}
{user.header && (
<div className="w-full h-32 bg-[#1a1a1a]">
<img
src={user.header}
alt=""
className="w-full h-full object-cover"
onError={(e) => { e.target.parentElement.style.display = 'none' }}
/>
</div>
)}
<div className="p-6">
<div className="flex items-start gap-4">
{/* Avatar */}
<img
src={user.avatar}
alt={user.name}
className="w-20 h-20 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
onError={(e) => {
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect fill="%23333" width="80" height="80" rx="40"/><text x="40" y="50" text-anchor="middle" fill="white" font-size="28">${(user.name || '?')[0]}</text></svg>`
}}
/>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-white truncate">{decodeHTML(user.name)}</h2>
{isSubscribed ? (
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full flex-shrink-0">Subscribed</span>
) : (
<span className="text-xs text-gray-500 bg-[#222] px-2 py-0.5 rounded-full flex-shrink-0">
{user.subscribePrice ? `$${Number(user.subscribePrice).toFixed(2)}/mo` : 'Free'}
</span>
)}
</div>
<p className="text-sm text-gray-500">@{user.username}</p>
{user.location && (
<p className="text-sm text-gray-400 mt-1">{user.location}</p>
)}
{user.website && (
<a
href={user.website.startsWith('http') ? user.website : `https://${user.website}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#0095f6] hover:underline mt-0.5 block truncate"
>
{user.website}
</a>
)}
{user.joinDate && (
<p className="text-xs text-gray-500 mt-1">
Joined {new Date(user.joinDate).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</p>
)}
</div>
</div>
{/* Bio */}
{user.about && (
<p className="text-sm text-gray-300 mt-4 whitespace-pre-line">{decodeHTML(user.about)}</p>
)}
{/* Stats */}
{(user.postsCount !== undefined || user.mediasCount !== undefined || user.photosCount !== undefined || user.videosCount !== undefined) && (
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-4">
{user.postsCount !== undefined && (
<span className="text-sm text-gray-400"><span className="text-white font-medium">{user.postsCount.toLocaleString()}</span> posts</span>
)}
{user.photosCount !== undefined && (
<span className="text-sm text-gray-400"><span className="text-white font-medium">{user.photosCount.toLocaleString()}</span> photos</span>
)}
{user.videosCount !== undefined && (
<span className="text-sm text-gray-400"><span className="text-white font-medium">{user.videosCount.toLocaleString()}</span> videos</span>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 mt-5">
{/* View Posts — always shown */}
<Link
to={`/users/${user.id}`}
state={{ user }}
className="px-4 py-2 text-sm text-[#0095f6] hover:bg-[#0095f6]/10 rounded-lg transition-colors"
>
View Posts
</Link>
{/* Download Dropdown — only if subscribed */}
{isSubscribed && (
<div className="relative">
<button
onClick={() => setShowDownloadMenu((v) => !v)}
disabled={downloading}
className="flex items-center gap-2 px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{downloading ? 'Starting...' : 'Download'}
<svg className="w-3 h-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{showDownloadMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowDownloadMenu(false)} />
<div className="absolute right-0 mt-2 w-52 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-20 overflow-hidden">
{cursorInfo && (
<>
<button
onClick={() => handleDownload(50, true)}
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
>
Continue (next 50)
<span className="block text-xs text-gray-500 font-normal mt-0.5">
{cursorInfo.postsDownloaded} posts downloaded
</span>
</button>
<button
onClick={() => handleDownload(100, true)}
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
>
Continue (next 100)
</button>
<div className="border-t border-[#333]" />
</>
)}
{[
{ label: 'Last 10 posts', value: 10 },
{ label: 'Last 25 posts', value: 25 },
{ label: 'Last 50 posts', value: 50 },
{ label: 'Last 100 posts', value: 100 },
{ label: 'All posts', value: null },
].map((opt) => (
<button
key={opt.label}
onClick={() => handleDownload(opt.value)}
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-[#252525] hover:text-white transition-colors"
>
{opt.label}
</button>
))}
</div>
</>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,267 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { getUserPosts, getUser, startDownload, getDownloadCursor } from '../api'
import PostCard from '../components/PostCard'
function decodeHTML(str) {
if (!str) return str
const el = document.createElement('textarea')
el.innerHTML = str
return el.value
}
import Spinner from '../components/Spinner'
import LoadMoreButton from '../components/LoadMoreButton'
export default function UserPosts() {
const { userId } = useParams()
const navigate = useNavigate()
const location = useLocation()
const [user, setUser] = useState(location.state?.user || null)
const [posts, setPosts] = useState([])
const [tailMarker, setTailMarker] = useState(null)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState(null)
const [downloading, setDownloading] = useState(false)
const [showDownloadMenu, setShowDownloadMenu] = useState(false)
const [cursorInfo, setCursorInfo] = useState(null)
const fetchCursor = () => {
getDownloadCursor(userId).then((data) => {
if (data && data.hasCursor) setCursorInfo(data)
else setCursorInfo(null)
})
}
useEffect(() => {
loadPosts()
fetchCursor()
// Fetch user info if not passed via location state
if (!user) {
getUser(userId).then((data) => {
if (!data.error) {
setUser(data)
}
})
}
}, [userId])
const loadPosts = async () => {
setLoading(true)
setError(null)
const data = await getUserPosts(userId)
if (data.error) {
setError(data.error)
setLoading(false)
return
}
const items = data.list || data || []
setPosts(items)
setTailMarker(data.tailMarker || null)
setHasMore(items.length > 0 && !!data.tailMarker)
setLoading(false)
}
const loadMore = async () => {
if (!tailMarker || loadingMore) return
setLoadingMore(true)
const data = await getUserPosts(userId, tailMarker)
if (!data.error) {
const items = data.list || data || []
setPosts((prev) => [...prev, ...items])
setTailMarker(data.tailMarker || null)
setHasMore(items.length > 0 && !!data.tailMarker)
}
setLoadingMore(false)
}
const handleDownload = async (limit, resume) => {
setShowDownloadMenu(false)
setDownloading(true)
const result = await startDownload(userId, limit, resume, user?.username)
if (result.error) {
console.error('Download failed:', result.error)
}
setTimeout(() => {
setDownloading(false)
fetchCursor()
}, 2000)
}
return (
<div>
{/* Back Button */}
<button
onClick={() => navigate('/users')}
className="flex items-center gap-2 text-gray-400 hover:text-white text-sm mb-6 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
Back to Users
</button>
{/* User Header */}
{user && (
<div className="flex items-center justify-between bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
<div className="flex items-center gap-4">
<img
src={user.avatar}
alt={user.name}
className="w-14 h-14 rounded-full object-cover bg-[#1a1a1a]"
onError={(e) => {
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><rect fill="%23333" width="56" height="56" rx="28"/><text x="28" y="35" text-anchor="middle" fill="white" font-size="20">${(user.name || '?')[0]}</text></svg>`
}}
/>
<div>
<h1 className="text-lg font-bold text-white">{decodeHTML(user.name)}</h1>
<p className="text-gray-500 text-sm">@{user.username}</p>
{(user.postsCount !== undefined || user.mediasCount !== undefined) && (
<p className="text-gray-600 text-xs mt-0.5">
{user.postsCount !== undefined && `${user.postsCount.toLocaleString()} posts`}
{user.postsCount !== undefined && user.mediasCount !== undefined && ' · '}
{user.mediasCount !== undefined && `${user.mediasCount.toLocaleString()} media`}
</p>
)}
</div>
</div>
<div className="relative">
<button
onClick={() => setShowDownloadMenu((v) => !v)}
disabled={downloading}
className="flex items-center gap-2 px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
{downloading ? 'Starting...' : 'Download Media'}
<svg
className="w-3 h-3 ml-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
{showDownloadMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowDownloadMenu(false)}
/>
<div className="absolute right-0 mt-2 w-52 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-20 overflow-hidden">
{cursorInfo && (
<>
<button
onClick={() => handleDownload(50, true)}
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
>
Continue (next 50)
<span className="block text-xs text-gray-500 font-normal mt-0.5">
{cursorInfo.postsDownloaded} posts downloaded
</span>
</button>
<button
onClick={() => handleDownload(100, true)}
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
>
Continue (next 100)
</button>
<div className="border-t border-[#333]" />
</>
)}
{[
{ label: 'Last 10 posts', value: 10 },
{ label: 'Last 25 posts', value: 25 },
{ label: 'Last 50 posts', value: 50 },
{ label: 'Last 100 posts', value: 100 },
{ label: 'All posts', value: null },
].map((opt) => (
<button
key={opt.label}
onClick={() => handleDownload(opt.value)}
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-[#252525] hover:text-white transition-colors"
>
{opt.label}
</button>
))}
</div>
</>
)}
</div>
</div>
)}
{/* Posts */}
{loading ? (
<Spinner />
) : error ? (
<div className="text-center py-16">
<p className="text-red-400 mb-4">{error}</p>
<button
onClick={loadPosts}
className="text-[#0095f6] hover:underline text-sm"
>
Try again
</button>
</div>
) : posts.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-500">No posts found</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<div className="mt-4">
<LoadMoreButton
onClick={loadMore}
loading={loadingMore}
hasMore={hasMore}
/>
</div>
</>
)}
</div>
)
}

147
client/src/pages/Users.jsx Normal file
View File

@@ -0,0 +1,147 @@
import { useState, useEffect } from 'react'
import { getSubscriptions, getUser, startDownload } from '../api'
import UserCard from '../components/UserCard'
import Spinner from '../components/Spinner'
import LoadMoreButton from '../components/LoadMoreButton'
const PAGE_SIZE = 50
export default function Users() {
const [users, setUsers] = useState([])
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState(null)
const [downloadingUsers, setDownloadingUsers] = useState(new Set())
useEffect(() => {
loadUsers()
}, [])
const enrichUsers = (items) => {
items.forEach(async (item) => {
const profile = await getUser(item.username)
if (profile && !profile.error) {
setUsers((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, postsCount: profile.postsCount, mediasCount: profile.mediasCount, photosCount: profile.photosCount, videosCount: profile.videosCount }
: u
)
)
}
})
}
const loadUsers = async () => {
setLoading(true)
setError(null)
const data = await getSubscriptions(0)
if (data.error) {
setError(data.error)
setLoading(false)
return
}
const items = data.list || data || []
setUsers(items)
setOffset(PAGE_SIZE)
setHasMore(items.length >= PAGE_SIZE)
setLoading(false)
enrichUsers(items)
}
const loadMore = async () => {
if (loadingMore) return
setLoadingMore(true)
const data = await getSubscriptions(offset)
if (!data.error) {
const items = data.list || data || []
setUsers((prev) => [...prev, ...items])
setOffset((prev) => prev + PAGE_SIZE)
setHasMore(items.length >= PAGE_SIZE)
enrichUsers(items)
}
setLoadingMore(false)
}
const handleDownload = async (userId, username) => {
setDownloadingUsers((prev) => new Set([...prev, userId]))
const result = await startDownload(userId, null, null, username)
if (result.error) {
console.error('Download failed:', result.error)
}
// Remove from downloading set after a brief delay to show feedback
setTimeout(() => {
setDownloadingUsers((prev) => {
const next = new Set(prev)
next.delete(userId)
return next
})
}, 2000)
}
if (loading) return <Spinner />
if (error) {
return (
<div className="text-center py-16">
<p className="text-red-400 mb-4">{error}</p>
<button
onClick={loadUsers}
className="text-[#0095f6] hover:underline text-sm"
>
Try again
</button>
</div>
)
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-white mb-1">Subscriptions</h1>
<p className="text-gray-500 text-sm">
{users.length} user{users.length !== 1 ? 's' : ''} found
</p>
</div>
{users.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-500">No subscriptions found</p>
<p className="text-gray-600 text-sm mt-1">
Check your auth credentials in Settings
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...users].sort((a, b) => (a.name || '').localeCompare(b.name || '')).map((user) => (
<UserCard
key={user.id}
user={user}
onDownload={handleDownload}
downloading={downloadingUsers.has(user.id)}
/>
))}
</div>
<div className="mt-6">
<LoadMoreButton
onClick={loadMore}
loading={loadingMore}
hasMore={hasMore}
/>
</div>
</>
)}
</div>
)
}

39
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
accent: {
DEFAULT: '#0095f6',
hover: '#0081d6',
light: '#47b5ff',
},
surface: {
page: '#0a0a0a',
sidebar: '#111111',
card: '#161616',
input: '#1a1a1a',
},
border: {
DEFAULT: '#222222',
light: '#333333',
},
},
animation: {
fadeIn: 'fadeIn 0.5s ease-in-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
},
fontFamily: {
mono: ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', 'monospace'],
},
},
},
plugins: [],
}

12
client/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001'
}
}
})

16
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,16 @@
version: '3.8'
services:
ofapp:
build: .
container_name: ofapp-local
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- ./data/db:/data/db
- ./data/media:/data/media
environment:
- PORT=3001
- DB_PATH=/data/db/ofapp.db
- MEDIA_PATH=/data/media
- DOWNLOAD_DELAY=1000

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
ofapp:
build: .
container_name: ofapp
restart: unless-stopped
ports:
- "3002:3001"
- "3003:3443"
volumes:
- /mnt/user/downloads/OFApp/db:/data/db
- /mnt/user/downloads/OFApp/media:/data/media
environment:
- PORT=3001
- DB_PATH=/data/db/ofapp.db
- MEDIA_PATH=/data/media
- DOWNLOAD_DELAY=1000
- HLS_ENABLED=false

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "ofapp",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev",
"build": "cd client && npm run build",
"start": "cd server && npm start",
"install:all": "cd server && npm install && cd ../client && npm install"
},
"devDependencies": {
"concurrently": "^8.2.0"
}
}

5
run-local.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
mkdir -p data/db data/media
docker compose -f docker-compose.local.yml up --build "$@"

129
server/db.js Normal file
View File

@@ -0,0 +1,129 @@
import Database from 'better-sqlite3';
import { mkdirSync, existsSync } from 'fs';
import { dirname } from 'path';
const DB_PATH = process.env.DB_PATH || './data/db/ofapp.db';
const dir = dirname(DB_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS auth_config (
user_id TEXT,
cookie TEXT,
x_bc TEXT,
app_token TEXT,
x_of_rev TEXT,
user_agent TEXT
);
CREATE TABLE IF NOT EXISTS download_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
post_id TEXT,
media_id TEXT,
media_type TEXT,
filename TEXT,
downloaded_at TEXT
);
CREATE TABLE IF NOT EXISTS download_cursors (
user_id TEXT UNIQUE,
cursor TEXT,
posts_downloaded INTEGER
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
`);
// Migration: add posted_at column if missing
const cols = db.prepare("PRAGMA table_info(download_history)").all().map((c) => c.name);
if (!cols.includes('posted_at')) {
db.exec('ALTER TABLE download_history ADD COLUMN posted_at TEXT');
}
export function getAuthConfig() {
const row = db.prepare('SELECT * FROM auth_config LIMIT 1').get();
return row || null;
}
export function saveAuthConfig(config) {
const del = db.prepare('DELETE FROM auth_config');
const ins = db.prepare(
'INSERT INTO auth_config (user_id, cookie, x_bc, app_token, x_of_rev, user_agent) VALUES (?, ?, ?, ?, ?, ?)'
);
const upsert = db.transaction((c) => {
del.run();
ins.run(c.user_id, c.cookie, c.x_bc, c.app_token, c.x_of_rev, c.user_agent);
});
upsert(config);
}
export function isMediaDownloaded(mediaId) {
const row = db.prepare('SELECT 1 FROM download_history WHERE media_id = ? LIMIT 1').get(String(mediaId));
return !!row;
}
export function recordDownload(userId, postId, mediaId, mediaType, filename, postedAt) {
db.prepare(
'INSERT INTO download_history (user_id, post_id, media_id, media_type, filename, downloaded_at, posted_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(String(userId), String(postId), String(mediaId), mediaType, filename, new Date().toISOString(), postedAt || null);
}
export function getDownloadHistory(userId) {
return db.prepare('SELECT * FROM download_history WHERE user_id = ? ORDER BY downloaded_at DESC').all(String(userId));
}
export function saveCursor(userId, cursor, postsDownloaded) {
db.prepare(
'INSERT INTO download_cursors (user_id, cursor, posts_downloaded) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET cursor = excluded.cursor, posts_downloaded = excluded.posts_downloaded'
).run(String(userId), cursor, postsDownloaded);
}
export function getCursor(userId) {
return db.prepare('SELECT cursor, posts_downloaded FROM download_cursors WHERE user_id = ?').get(String(userId)) || null;
}
export function clearCursor(userId) {
db.prepare('DELETE FROM download_cursors WHERE user_id = ?').run(String(userId));
}
export function getPostDateByFilename(filename) {
const row = db.prepare('SELECT posted_at FROM download_history WHERE filename = ? LIMIT 1').get(filename);
return row?.posted_at || null;
}
export function getSetting(key) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : null;
}
export function setSetting(key, value) {
db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
}
export function getAllSettings() {
const rows = db.prepare('SELECT key, value FROM settings').all();
const obj = {};
for (const row of rows) obj[row.key] = row.value;
return obj;
}
export function getDownloadStats() {
return db.prepare(
'SELECT user_id, COUNT(*) as file_count, MAX(downloaded_at) as last_download FROM download_history GROUP BY user_id'
).all();
}

256
server/download.js Normal file
View File

@@ -0,0 +1,256 @@
import { Router } from 'express';
import fetch from 'node-fetch';
import { mkdirSync, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
import { extname } from 'path';
import { getAuthConfig, isMediaDownloaded, recordDownload, getDownloadStats, saveCursor, getCursor, clearCursor } from './db.js';
import { createSignedHeaders, getRules } from './signing.js';
const router = Router();
const OF_BASE = 'https://onlyfans.com';
const MEDIA_PATH = process.env.MEDIA_PATH || './data/media';
const DOWNLOAD_DELAY = parseInt(process.env.DOWNLOAD_DELAY || '1000', 10);
// In-memory progress: userId -> { total, completed, errors, running }
const progressMap = new Map();
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,
};
if (rules.remove_headers) {
for (const h of rules.remove_headers) {
delete headers[h];
}
}
return headers;
}
async function fetchOF(ofPath, authConfig) {
const signedHeaders = createSignedHeaders(ofPath, authConfig.user_id);
const headers = buildHeaders(authConfig, signedHeaders);
const res = await fetch(`${OF_BASE}${ofPath}`, { headers });
return res.json();
}
function getMediaUrl(media) {
if (media.source?.source) return media.source.source;
if (media.files?.full?.url) return media.files.full.url;
if (media.files?.preview?.url) return media.files.preview.url;
return null;
}
function getExtFromUrl(url) {
try {
const pathname = new URL(url).pathname;
const ext = extname(pathname).split('?')[0];
return ext || '.bin';
} catch {
return '.bin';
}
}
function getYearMonth(dateStr) {
if (!dateStr) return 'unknown';
try {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return 'unknown';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
return `${y}-${m}`;
} catch {
return 'unknown';
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function downloadFile(url, dest) {
const res = await fetch(url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
await pipeline(res.body, createWriteStream(dest));
}
async function runDownload(userId, authConfig, postLimit, resume, username) {
const progress = { total: 0, completed: 0, errors: 0, running: true };
progressMap.set(String(userId), progress);
try {
let beforePublishTime = null;
let hasMore = true;
const allMedia = [];
let postsFetched = 0;
let priorPostsDownloaded = 0;
if (resume) {
const saved = getCursor(String(userId));
if (saved) {
beforePublishTime = saved.cursor;
priorPostsDownloaded = saved.posts_downloaded || 0;
}
}
// Phase 1: Paginate media items directly via /posts/medias
while (hasMore) {
const batchSize = postLimit ? Math.min(10, postLimit - postsFetched) : 10;
if (batchSize <= 0) break;
let ofPath = `/api2/v2/users/${userId}/posts/medias?limit=${batchSize}&order=publish_date_desc&skip_users=all&format=infinite&pinned=0`;
if (beforePublishTime) {
ofPath += `&beforePublishTime=${encodeURIComponent(beforePublishTime)}`;
}
const data = await fetchOF(ofPath, authConfig);
const mediaList = Array.isArray(data) ? data : (data.list || []);
postsFetched += mediaList.length;
for (const media of mediaList) {
const postDate = media.postedAt || media.createdAt || media.publishedAt || null;
const postId = media.postId || media.post_id || media.id;
allMedia.push({ postId, media, postDate });
}
hasMore = Array.isArray(data) ? data.length === batchSize : !!data.hasMore;
if (!Array.isArray(data)) {
beforePublishTime = data.tailMarker || null;
} else if (mediaList.length > 0) {
// For flat array responses, use the last item's date as cursor
const last = mediaList[mediaList.length - 1];
beforePublishTime = last.postedAt || last.createdAt || null;
}
// Stop if we've hit the limit
if (postLimit && postsFetched >= postLimit) break;
if (hasMore) await sleep(DOWNLOAD_DELAY);
}
// Save cursor for future "continue" downloads
if (postLimit && beforePublishTime && hasMore) {
saveCursor(String(userId), beforePublishTime, priorPostsDownloaded + postsFetched);
} else {
// Downloaded all media or reached the end — clear cursor
clearCursor(String(userId));
}
progress.total = allMedia.length;
// Phase 2: Download each media item
for (const { postId, media, postDate } of allMedia) {
try {
const mediaId = String(media.id);
if (isMediaDownloaded(mediaId)) {
progress.completed++;
continue;
}
if (media.canView === false) {
progress.completed++;
continue;
}
const url = getMediaUrl(media);
if (!url) {
progress.completed++;
continue;
}
const mediaType = media.type || 'unknown';
const ext = getExtFromUrl(url);
const filename = `${postId}_${mediaId}_${mediaType}${ext}`;
const userDir = `${MEDIA_PATH}/${username || userId}`;
mkdirSync(userDir, { recursive: true });
const dest = `${userDir}/${filename}`;
await downloadFile(url, dest);
recordDownload(userId, String(postId), mediaId, mediaType, filename, postDate);
progress.completed++;
} catch (err) {
console.error(`[download] Error downloading media ${media.id}:`, err.message);
progress.errors++;
progress.completed++;
}
await sleep(DOWNLOAD_DELAY);
}
} catch (err) {
console.error(`[download] Fatal error for user ${userId}:`, err.message);
progress.errors++;
} finally {
progress.running = false;
}
}
// POST /api/download/:userId — start background download
router.post('/api/download/:userId', (req, res, next) => {
try {
const authConfig = getAuthConfig();
if (!authConfig) return res.status(401).json({ error: 'No auth config' });
const { userId } = req.params;
const postLimit = req.body.limit ? parseInt(req.body.limit, 10) : null;
const resume = !!req.body.resume;
const username = req.body.username || null;
const existing = progressMap.get(String(userId));
if (existing?.running) {
return res.json({ status: 'already_running', userId, progress: existing });
}
runDownload(userId, authConfig, postLimit, resume, username).catch((err) =>
console.error(`[download] Unhandled error for user ${userId}:`, err.message)
);
res.json({ status: 'started', userId });
} catch (err) {
next(err);
}
});
// GET /api/download/:userId/status
router.get('/api/download/:userId/status', (req, res) => {
const progress = progressMap.get(String(req.params.userId));
if (!progress) return res.json({ status: 'not_started' });
res.json({ status: progress.running ? 'running' : 'completed', ...progress });
});
// GET /api/download/:userId/cursor
router.get('/api/download/:userId/cursor', (req, res) => {
const cursor = getCursor(String(req.params.userId));
if (!cursor) return res.json({ hasCursor: false });
res.json({ hasCursor: true, postsDownloaded: cursor.posts_downloaded });
});
// GET /api/download/active — list all running downloads
router.get('/api/download/active', (req, res) => {
const active = [];
for (const [userId, progress] of progressMap.entries()) {
if (progress.running) {
active.push({ user_id: userId, ...progress });
}
}
res.json(active);
});
// GET /api/download/history
router.get('/api/download/history', (req, res, next) => {
try {
const stats = getDownloadStats();
res.json(stats);
} catch (err) {
next(err);
}
});
export default router;

143
server/gallery.js Normal file
View File

@@ -0,0 +1,143 @@
import { Router } from 'express';
import { readdirSync, statSync } from 'fs';
import { join, extname } from 'path';
import { getPostDateByFilename, getSetting } from './db.js';
const router = Router();
const MEDIA_PATH = process.env.MEDIA_PATH || './data/media';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']);
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.webm', '.mkv', '.m4v']);
function getMediaType(filename) {
const ext = extname(filename).toLowerCase();
if (IMAGE_EXTS.has(ext)) return 'image';
if (VIDEO_EXTS.has(ext)) return 'video';
return null;
}
// GET /api/gallery/folders — list all folders with file counts
router.get('/api/gallery/folders', (req, res, next) => {
try {
const entries = readdirSync(MEDIA_PATH, { withFileTypes: true });
const folders = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
const folderPath = join(MEDIA_PATH, entry.name);
const files = readdirSync(folderPath).filter((f) => {
return !f.startsWith('.') && getMediaType(f) !== null;
});
if (files.length > 0) {
const images = files.filter((f) => getMediaType(f) === 'image').length;
const videos = files.filter((f) => getMediaType(f) === 'video').length;
folders.push({ name: entry.name, total: files.length, images, videos });
}
}
folders.sort((a, b) => a.name.localeCompare(b.name));
res.json(folders);
} catch (err) {
next(err);
}
});
// GET /api/gallery/files?folder=&type=&sort=&offset=&limit=
router.get('/api/gallery/files', (req, res, next) => {
try {
const { folder, type, sort, offset, limit } = req.query;
const typeFilter = type || 'all'; // all, image, video
const sortMode = sort || 'latest'; // latest, shuffle
const offsetNum = parseInt(offset || '0', 10);
const limitNum = parseInt(limit || '50', 10);
let allFiles = [];
const foldersParam = req.query.folders; // comma-separated list
const foldersToScan = folder
? [folder]
: foldersParam
? foldersParam.split(',').map((f) => f.trim()).filter(Boolean)
: readdirSync(MEDIA_PATH, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'))
.map((e) => e.name);
for (const dir of foldersToScan) {
const dirPath = join(MEDIA_PATH, dir);
let files;
try {
files = readdirSync(dirPath);
} catch {
continue;
}
for (const file of files) {
if (file.startsWith('.')) continue;
const mediaType = getMediaType(file);
if (!mediaType) continue;
if (typeFilter !== 'all' && mediaType !== typeFilter) continue;
const filePath = join(dirPath, file);
const stat = statSync(filePath);
const postedAt = getPostDateByFilename(file);
const fileObj = {
folder: dir,
filename: file,
type: mediaType,
size: stat.size,
modified: stat.mtimeMs,
postedAt: postedAt || null,
url: `/api/gallery/media/${encodeURIComponent(dir)}/${encodeURIComponent(file)}`,
};
if ((getSetting('hls_enabled') || process.env.HLS_ENABLED) === 'true' && mediaType === 'video') {
fileObj.hlsUrl = `/api/hls/${encodeURIComponent(dir)}/${encodeURIComponent(file)}/master.m3u8`;
}
allFiles.push(fileObj);
}
}
// Sort
if (sortMode === 'shuffle') {
for (let i = allFiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allFiles[i], allFiles[j]] = [allFiles[j], allFiles[i]];
}
} else {
allFiles.sort((a, b) => {
const aTime = a.postedAt ? new Date(a.postedAt).getTime() : a.modified;
const bTime = b.postedAt ? new Date(b.postedAt).getTime() : b.modified;
return bTime - aTime;
});
}
const total = allFiles.length;
const page = allFiles.slice(offsetNum, offsetNum + limitNum);
res.json({ total, offset: offsetNum, limit: limitNum, files: page });
} catch (err) {
next(err);
}
});
// GET /api/gallery/media/:folder/:filename — serve actual file
router.get('/api/gallery/media/:folder/:filename', (req, res) => {
const { folder, filename } = req.params;
// Prevent path traversal
if (folder.includes('..') || filename.includes('..')) {
return res.status(400).json({ error: 'Invalid path' });
}
const filePath = join(MEDIA_PATH, folder, filename);
res.sendFile(filePath, { root: '/' }, (err) => {
if (err && !res.headersSent) {
res.status(404).json({ error: 'File not found' });
}
});
});
export default router;

116
server/hls.js Normal file
View File

@@ -0,0 +1,116 @@
import { Router } from 'express';
import { join } from 'path';
import { existsSync } from 'fs';
import { execFile, spawn } from 'child_process';
import { promisify } from 'util';
import { getSetting } from './db.js';
const execFileAsync = promisify(execFile);
const router = Router();
const MEDIA_PATH = process.env.MEDIA_PATH || './data/media';
const SEGMENT_DURATION = 10;
function isHlsEnabled() {
return (getSetting('hls_enabled') || process.env.HLS_ENABLED) === 'true';
}
function validatePath(folder, filename) {
if (folder.includes('..') || filename.includes('..')) return null;
if (folder.includes('/') || folder.includes('\\')) return null;
if (filename.includes('/') || filename.includes('\\')) return null;
const filePath = join(MEDIA_PATH, folder, filename);
if (!existsSync(filePath)) return null;
return filePath;
}
// GET /api/hls/:folder/:filename/master.m3u8
router.get('/api/hls/:folder/:filename/master.m3u8', async (req, res) => {
if (!isHlsEnabled()) {
return res.status(404).json({ error: 'HLS not enabled' });
}
const { folder, filename } = req.params;
const filePath = validatePath(folder, filename);
if (!filePath) {
return res.status(400).json({ error: 'Invalid path' });
}
try {
const { stdout } = await execFileAsync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'csv=p=0',
filePath,
]);
const duration = parseFloat(stdout.trim());
if (isNaN(duration) || duration <= 0) {
return res.status(500).json({ error: 'Could not determine video duration' });
}
const segmentCount = Math.ceil(duration / SEGMENT_DURATION);
let playlist = '#EXTM3U\n#EXT-X-VERSION:3\n';
playlist += `#EXT-X-TARGETDURATION:${SEGMENT_DURATION}\n`;
playlist += '#EXT-X-MEDIA-SEQUENCE:0\n';
for (let i = 0; i < segmentCount; i++) {
const remaining = duration - i * SEGMENT_DURATION;
const segDuration = Math.min(SEGMENT_DURATION, remaining);
playlist += `#EXTINF:${segDuration.toFixed(3)},\n`;
playlist += `segment-${i}.ts\n`;
}
playlist += '#EXT-X-ENDLIST\n';
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.send(playlist);
} catch (err) {
console.error('[hls] ffprobe error:', err.message);
res.status(500).json({ error: 'Failed to probe video' });
}
});
// GET /api/hls/:folder/:filename/segment-:index.ts
router.get('/api/hls/:folder/:filename/segment-:index.ts', (req, res) => {
if (!isHlsEnabled()) {
return res.status(404).json({ error: 'HLS not enabled' });
}
const { folder, filename, index } = req.params;
const filePath = validatePath(folder, filename);
if (!filePath) {
return res.status(400).json({ error: 'Invalid path' });
}
const segIndex = parseInt(index, 10);
if (isNaN(segIndex) || segIndex < 0) {
return res.status(400).json({ error: 'Invalid segment index' });
}
const offset = segIndex * SEGMENT_DURATION;
const ffmpeg = spawn('ffmpeg', [
'-ss', String(offset),
'-i', filePath,
'-t', String(SEGMENT_DURATION),
'-c', 'copy',
'-f', 'mpegts',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'ignore'] });
res.setHeader('Content-Type', 'video/MP2T');
ffmpeg.stdout.pipe(res);
req.on('close', () => {
ffmpeg.kill('SIGKILL');
});
ffmpeg.on('error', (err) => {
console.error('[hls] ffmpeg error:', err.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Transcoding failed' });
}
});
});
export default router;

90
server/index.js Normal file
View File

@@ -0,0 +1,90 @@
import express from 'express';
import https from 'https';
import cors from 'cors';
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { initRules } from './signing.js';
import proxyRouter from './proxy.js';
import downloadRouter from './download.js';
import galleryRouter from './gallery.js';
import hlsRouter from './hls.js';
import settingsRouter from './settings.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
app.use(cors());
// Parse DRM license request bodies as raw binary BEFORE global JSON parser
// (express.json can interfere with reading the raw body stream)
app.use('/api/drm-license', express.raw({ type: '*/*', limit: '1mb' }));
app.use(express.json());
// API routes
app.use(proxyRouter);
app.use(downloadRouter);
app.use(galleryRouter);
app.use(hlsRouter);
app.use(settingsRouter);
// Serve static client build in production
const clientDist = join(__dirname, '..', 'client', 'dist');
if (existsSync(clientDist)) {
app.use(express.static(clientDist));
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
res.sendFile(join(clientDist, 'index.html'));
});
}
// Error handler
app.use((err, req, res, _next) => {
console.error('[server] Error:', err.message);
res.status(500).json({ error: err.message || 'Internal server error' });
});
async function start() {
try {
await initRules();
} catch (err) {
console.error('[server] Failed to load signing rules:', err.message);
console.error('[server] Signing will not work until rules are available');
}
app.listen(PORT, () => {
console.log(`[server] Listening on http://localhost:${PORT}`);
});
// Start HTTPS server for DRM/EME support (requires secure context)
try {
const certDir = '/data/certs';
const certPath = `${certDir}/server.crt`;
const keyPath = `${certDir}/server.key`;
if (!existsSync(certPath) || !existsSync(keyPath)) {
mkdirSync(certDir, { recursive: true });
execSync(`openssl req -x509 -newkey rsa:2048 -keyout ${keyPath} -out ${certPath} -days 3650 -nodes -subj '/CN=ofapp'`);
console.log('[server] Generated self-signed HTTPS certificate');
}
const httpsServer = https.createServer({
key: readFileSync(keyPath),
cert: readFileSync(certPath),
}, app);
httpsServer.listen(HTTPS_PORT, () => {
console.log(`[server] HTTPS listening on https://localhost:${HTTPS_PORT}`);
});
} catch (err) {
console.error('[server] HTTPS setup failed:', err.message);
console.error('[server] DRM video playback will not work without HTTPS');
}
}
start();

1369
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
server/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "ofapp-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "^4.21.0",
"better-sqlite3": "^11.0.0",
"node-fetch": "^3.3.2",
"cors": "^2.8.5"
}
}

397
server/proxy.js Normal file
View File

@@ -0,0 +1,397 @@
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;

21
server/settings.js Normal file
View File

@@ -0,0 +1,21 @@
import { Router } from 'express';
import { getAllSettings, setSetting } from './db.js';
const router = Router();
// GET /api/settings
router.get('/api/settings', (req, res) => {
const settings = getAllSettings();
res.json(settings);
});
// PUT /api/settings
router.put('/api/settings', (req, res) => {
const updates = req.body;
for (const [key, value] of Object.entries(updates)) {
setSetting(key, String(value));
}
res.json(getAllSettings());
});
export default router;

84
server/signing.js Normal file
View File

@@ -0,0 +1,84 @@
import { createHash } from 'crypto';
import fetch from 'node-fetch';
// Try multiple community-maintained rule sources in order
const RULES_URLS = [
'https://raw.githubusercontent.com/rafa-9/dynamic-rules/main/rules.json',
'https://raw.githubusercontent.com/datawhores/onlyfans-dynamic-rules/main/dynamicRules.json',
'https://raw.githubusercontent.com/DATAHOARDERS/dynamic-rules/main/onlyfans.json',
];
const REFRESH_INTERVAL = 60 * 60 * 1000; // 1 hour
let rules = null;
let lastFetch = 0;
function normalizeRules(raw) {
// Different sources use different key names — normalize them
return {
static_param: raw.static_param,
checksum_indexes: raw.checksum_indexes,
checksum_constant: raw.checksum_constant ?? 0,
checksum_constants: raw.checksum_constants ?? null, // per-index constants (some sources)
app_token: raw.app_token || raw['app-token'] || '33d57ade8c02dbc5a333db99ff9ae26a',
prefix: raw.prefix || raw.format?.split(':')[0],
suffix: raw.suffix || raw.format?.split(':').pop(),
remove_headers: raw.remove_headers ?? [],
};
}
async function fetchRules() {
for (const url of RULES_URLS) {
try {
const res = await fetch(url);
if (!res.ok) continue;
const raw = await res.json();
rules = normalizeRules(raw);
lastFetch = Date.now();
console.log(`[signing] Rules loaded from ${url} (prefix: ${rules.prefix})`);
return rules;
} catch (err) {
console.warn(`[signing] Failed to fetch from ${url}: ${err.message}`);
}
}
throw new Error('All dynamic rules sources failed');
}
export async function initRules() {
await fetchRules();
}
export function getRules() {
if (Date.now() - lastFetch > REFRESH_INTERVAL) {
fetchRules().catch((err) => console.error('[signing] Failed to refresh rules:', err.message));
}
return rules;
}
export function createSignedHeaders(path, userId) {
if (!rules) throw new Error('Signing rules not initialized');
const timestamp = Date.now().toString();
// Use "0" for userId when user-id is in remove_headers
const signUserId = rules.remove_headers?.includes('user-id') ? '0' : userId;
const message = [rules.static_param, timestamp, path, signUserId].join('\n');
const sha1Hex = createHash('sha1').update(message).digest('hex');
const hexBytes = Buffer.from(sha1Hex, 'ascii');
let checksum = 0;
for (let i = 0; i < rules.checksum_indexes.length; i++) {
const byteVal = hexBytes[rules.checksum_indexes[i]];
const perIndex = rules.checksum_constants?.[i] ?? 0;
checksum += byteVal + perIndex;
}
checksum += rules.checksum_constant;
const sign = `${rules.prefix}:${sha1Hex}:${Math.abs(checksum).toString(16)}:${rules.suffix}`;
return {
sign,
time: timestamp,
'app-token': rules.app_token,
};
}