Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
77
.claude/settings.local.json
Normal file
77
.claude/settings.local.json
Normal 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
8
.dockerignore
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
client/dist/
|
||||
data/
|
||||
*.har
|
||||
.DS_Store
|
||||
.env
|
||||
seed-auth.json
|
||||
dani/
|
||||
95
CLAUDE.md
Normal file
95
CLAUDE.md
Normal 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
24
Dockerfile
Normal 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
18
client/index.html
Normal 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
2678
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
client/package.json
Normal file
24
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
170
client/src/App.jsx
Normal file
170
client/src/App.jsx
Normal 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
116
client/src/api.js
Normal 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}`);
|
||||
}
|
||||
123
client/src/components/DrmVideo.jsx
Normal file
123
client/src/components/DrmVideo.jsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
71
client/src/components/HlsVideo.jsx
Normal file
71
client/src/components/HlsVideo.jsx
Normal 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} />
|
||||
}
|
||||
40
client/src/components/LoadMoreButton.jsx
Normal file
40
client/src/components/LoadMoreButton.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
client/src/components/MediaGrid.jsx
Normal file
237
client/src/components/MediaGrid.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
client/src/components/PostCard.jsx
Normal file
110
client/src/components/PostCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
client/src/components/Spinner.jsx
Normal file
26
client/src/components/Spinner.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
client/src/components/UserCard.jsx
Normal file
109
client/src/components/UserCard.jsx
Normal 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
71
client/src/index.css
Normal 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
13
client/src/main.jsx
Normal 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>
|
||||
)
|
||||
258
client/src/pages/Downloads.jsx
Normal file
258
client/src/pages/Downloads.jsx
Normal 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
119
client/src/pages/Feed.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
564
client/src/pages/Gallery.jsx
Normal file
564
client/src/pages/Gallery.jsx
Normal 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} · {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
279
client/src/pages/Login.jsx
Normal 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
295
client/src/pages/Search.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
client/src/pages/UserPosts.jsx
Normal file
267
client/src/pages/UserPosts.jsx
Normal 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
147
client/src/pages/Users.jsx
Normal 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
39
client/tailwind.config.js
Normal 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
12
client/vite.config.js
Normal 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
16
docker-compose.local.yml
Normal 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
18
docker-compose.yml
Normal 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
16
package.json
Normal 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
5
run-local.sh
Executable 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
129
server/db.js
Normal 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
256
server/download.js
Normal 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
143
server/gallery.js
Normal 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
116
server/hls.js
Normal 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
90
server/index.js
Normal 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
1369
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
server/package.json
Normal file
15
server/package.json
Normal 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
397
server/proxy.js
Normal 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
21
server/settings.js
Normal 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
84
server/signing.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user