mirror of
https://github.com/vndangkhoa/purestream.git
synced 2026-04-05 17:37:59 +07:00
Add admin-only login mode, remove noVNC, simplify architecture
This commit is contained in:
parent
dc3caed430
commit
5d1895e400
7 changed files with 489 additions and 229 deletions
35
Dockerfile
35
Dockerfile
|
|
@ -9,17 +9,13 @@ RUN npm run build
|
|||
# Production stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies
|
||||
# Install system dependencies (minimal - no VNC needed)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
curl \
|
||||
gnupg \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
# VNC & Display dependencies
|
||||
tigervnc-standalone-server \
|
||||
tigervnc-common \
|
||||
openbox \
|
||||
# Playwright dependencies
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
|
|
@ -39,22 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libatspi2.0-0 \
|
||||
libgtk-3-0 \
|
||||
fonts-liberation \
|
||||
supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install noVNC
|
||||
RUN mkdir -p /opt/novnc \
|
||||
&& wget -qO- https://github.com/novnc/noVNC/archive/v1.4.0.tar.gz | tar xz -C /opt/novnc --strip-components=1 \
|
||||
&& mkdir -p /opt/novnc/utils/websockify \
|
||||
&& wget -qO- https://github.com/novnc/websockify/archive/v0.11.0.tar.gz | tar xz -C /opt/novnc/utils/websockify --strip-components=1 \
|
||||
&& ln -s /opt/novnc/vnc.html /opt/novnc/index.html
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend requirements and install
|
||||
COPY backend/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install Playwright browsers
|
||||
|
||||
# Install Playwright browsers (headless mode only)
|
||||
RUN mkdir -p /root/.cache/ms-playwright && \
|
||||
for i in 1 2 3; do \
|
||||
playwright install chromium && break || \
|
||||
|
|
@ -67,26 +56,22 @@ COPY backend/ ./backend/
|
|||
# Copy built frontend
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
|
||||
# Create cache directory
|
||||
RUN mkdir -p /app/cache && chmod 777 /app/cache
|
||||
|
||||
# Copy supervisor config
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
# Create cache and session directories
|
||||
RUN mkdir -p /app/cache /app/session && chmod 777 /app/cache /app/session
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV CACHE_DIR=/app/cache
|
||||
ENV DISPLAY=:99
|
||||
|
||||
# Set working directory to backend for correct imports
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Expose ports (8002 = app, 6080 = noVNC)
|
||||
EXPOSE 8002 6080
|
||||
# Expose port (8002 = app)
|
||||
EXPOSE 8002
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8002/health || exit 1
|
||||
|
||||
# Start services
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
# Start FastAPI directly (no supervisor needed)
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -146,15 +146,39 @@ purestream/
|
|||
└── README.md
|
||||
```
|
||||
|
||||
## 🔐 Authentication
|
||||
## 🔐 Authentication (Admin Setup)
|
||||
|
||||
PureStream uses your TikTok session to fetch content. On first launch:
|
||||
PureStream uses your TikTok session cookies. Once configured, users can access the feed without logging in.
|
||||
|
||||
1. Click **"Login with TikTok"**
|
||||
2. A browser window opens - log in to TikTok normally
|
||||
3. Your session is saved locally for future use
|
||||
### First-Time Setup
|
||||
|
||||
> **Note**: Your credentials are stored locally and never sent to any external server.
|
||||
1. **Set your admin password** in `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- ADMIN_PASSWORD=your_secure_password
|
||||
```
|
||||
|
||||
2. **Access the admin page**: `http://your-server-ip:8002/admin`
|
||||
|
||||
3. **Get your TikTok cookies**:
|
||||
- Install [Cookie-Editor](https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) browser extension
|
||||
- Go to [tiktok.com](https://www.tiktok.com) and login
|
||||
- Click Cookie-Editor icon → **Export** → **Copy**
|
||||
|
||||
4. **Paste cookies** in the admin page and click **Save**
|
||||
|
||||
5. Your app is now ready! Access `http://your-server-ip:8002/` on any device.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ADMIN_PASSWORD` | `admin123` | Password for admin page |
|
||||
| `CACHE_DIR` | `/app/cache` | Video cache directory |
|
||||
| `MAX_CACHE_SIZE_MB` | `500` | Maximum cache size |
|
||||
| `CACHE_TTL_HOURS` | `24` | Cache expiration |
|
||||
|
||||
> **Security Note**: Cookies are stored locally in the `session/` volume. Anyone with admin access can view/update them.
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -142,3 +142,99 @@ async def stop_vnc_login():
|
|||
"""Stop the VNC login browser."""
|
||||
result = await PlaywrightManager.stop_vnc_login()
|
||||
return result
|
||||
|
||||
|
||||
# ========== ADMIN ENDPOINTS ==========
|
||||
|
||||
# Admin password from environment variable
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
|
||||
|
||||
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
|
||||
_admin_sessions: set = set()
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class AdminCookiesRequest(BaseModel):
|
||||
cookies: list | dict # Accept both array (Cookie-Editor) or object format
|
||||
|
||||
|
||||
@router.post("/admin-login")
|
||||
async def admin_login(request: AdminLoginRequest):
|
||||
"""Login as admin with password."""
|
||||
if request.password == ADMIN_PASSWORD:
|
||||
import secrets
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
_admin_sessions.add(session_token)
|
||||
return {"status": "success", "token": session_token}
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
|
||||
@router.get("/admin-check")
|
||||
async def admin_check(token: str = ""):
|
||||
"""Check if admin session is valid."""
|
||||
return {"valid": token in _admin_sessions}
|
||||
|
||||
|
||||
@router.post("/admin-update-cookies")
|
||||
async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
|
||||
"""Update cookies (admin only)."""
|
||||
if token not in _admin_sessions:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
try:
|
||||
cookies = request.cookies
|
||||
|
||||
# Normalize cookies to dict format
|
||||
if isinstance(cookies, list):
|
||||
# Cookie-Editor export format: [{"name": "...", "value": "..."}, ...]
|
||||
cookie_dict = {}
|
||||
for c in cookies:
|
||||
if isinstance(c, dict) and "name" in c and "value" in c:
|
||||
cookie_dict[c["name"]] = c["value"]
|
||||
cookies = cookie_dict
|
||||
|
||||
if not isinstance(cookies, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid cookies format")
|
||||
|
||||
if "sessionid" not in cookies:
|
||||
raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required")
|
||||
|
||||
# Save cookies
|
||||
PlaywrightManager.save_credentials(cookies, None)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Saved {len(cookies)} cookies",
|
||||
"cookie_count": len(cookies)
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/admin-get-cookies")
|
||||
async def admin_get_cookies(token: str = ""):
|
||||
"""Get current cookies (admin only, for display)."""
|
||||
if token not in _admin_sessions:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
if os.path.exists(COOKIES_FILE):
|
||||
try:
|
||||
with open(COOKIES_FILE, "r") as f:
|
||||
cookies = json.load(f)
|
||||
# Mask sensitive values for display
|
||||
masked = {}
|
||||
for key, value in cookies.items():
|
||||
if key == "sessionid":
|
||||
masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
|
||||
else:
|
||||
masked[key] = value[:20] + "..." if len(str(value)) > 20 else value
|
||||
return {"cookies": masked, "raw_count": len(cookies)}
|
||||
except:
|
||||
pass
|
||||
return {"cookies": {}, "raw_count": 0}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ services:
|
|||
restart: unless-stopped
|
||||
ports:
|
||||
- "8002:8002"
|
||||
- "6080:6080"
|
||||
volumes:
|
||||
- ./cache:/app/cache
|
||||
- ./session:/app/backend/session
|
||||
|
|
@ -16,6 +15,7 @@ services:
|
|||
- CACHE_DIR=/app/cache
|
||||
- MAX_CACHE_SIZE_MB=500
|
||||
- CACHE_TTL_HOURS=24
|
||||
- ADMIN_PASSWORD=admin123 # Change this to your secure password
|
||||
shm_size: '2gb'
|
||||
# IMPORTANT: You must link the service to the network
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Login } from './pages/Login';
|
||||
import { Admin } from './pages/Admin';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
|
|
@ -41,6 +42,7 @@ function App() {
|
|||
<Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
|
|
|||
284
frontend/src/pages/Admin.tsx
Normal file
284
frontend/src/pages/Admin.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
export const Admin: React.FC = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [adminToken, setAdminToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [cookiesJson, setCookiesJson] = useState('');
|
||||
const [currentCookies, setCurrentCookies] = useState<Record<string, string>>({});
|
||||
const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; cookie_count: number } | null>(null);
|
||||
|
||||
// Check for stored admin token on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('admin_token');
|
||||
if (stored) {
|
||||
verifyToken(stored);
|
||||
}
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE_URL}/auth/status`);
|
||||
setAuthStatus(res.data);
|
||||
} catch {
|
||||
setAuthStatus({ authenticated: false, cookie_count: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const verifyToken = async (token: string) => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE_URL}/auth/admin-check?token=${token}`);
|
||||
if (res.data.valid) {
|
||||
setAdminToken(token);
|
||||
loadCurrentCookies(token);
|
||||
} else {
|
||||
localStorage.removeItem('admin_token');
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('admin_token');
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrentCookies = async (token: string) => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE_URL}/auth/admin-get-cookies?token=${token}`);
|
||||
setCurrentCookies(res.data.cookies || {});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!password.trim()) return;
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await axios.post(`${API_BASE_URL}/auth/admin-login`, { password });
|
||||
if (res.data.status === 'success') {
|
||||
const token = res.data.token;
|
||||
setAdminToken(token);
|
||||
localStorage.setItem('admin_token', token);
|
||||
loadCurrentCookies(token);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Login failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCookies = async () => {
|
||||
if (!cookiesJson.trim() || !adminToken) return;
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cookiesJson);
|
||||
const res = await axios.post(
|
||||
`${API_BASE_URL}/auth/admin-update-cookies?token=${adminToken}`,
|
||||
{ cookies: parsed }
|
||||
);
|
||||
if (res.data.status === 'success') {
|
||||
setSuccess(`✓ Saved ${res.data.cookie_count} cookies successfully!`);
|
||||
setCookiesJson('');
|
||||
loadCurrentCookies(adminToken);
|
||||
checkAuthStatus();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('JSON')) {
|
||||
setError('Invalid JSON format. Please paste valid cookies.');
|
||||
} else {
|
||||
setError(err.response?.data?.detail || 'Failed to save cookies');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setAdminToken(null);
|
||||
localStorage.removeItem('admin_token');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
// Not logged in as admin - show password form
|
||||
if (!adminToken) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center p-6">
|
||||
<div className="max-w-sm w-full">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-cyan-500 to-pink-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Admin Access</h1>
|
||||
<p className="text-gray-500 text-sm">Enter password to manage cookies</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
placeholder="Admin password"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-cyan-500/50 mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading || !password.trim()}
|
||||
className={`w-full py-3 rounded-xl font-semibold transition-all ${password.trim() && !isLoading
|
||||
? 'bg-gradient-to-r from-cyan-500 to-pink-500 text-white'
|
||||
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Login'}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="block text-center text-white/40 text-sm mt-6 hover:text-white/60"
|
||||
>
|
||||
← Back to App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Logged in as admin - show cookie management
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Cookie Manager</h1>
|
||||
<p className="text-gray-500 text-sm">Update TikTok session cookies</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
← App
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 text-sm transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<div className={`p-4 rounded-xl mb-6 ${authStatus?.authenticated
|
||||
? 'bg-green-500/10 border border-green-500/20'
|
||||
: 'bg-yellow-500/10 border border-yellow-500/20'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${authStatus?.authenticated ? 'bg-green-500' : 'bg-yellow-500'
|
||||
}`} />
|
||||
<span className={authStatus?.authenticated ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{authStatus?.authenticated
|
||||
? `Authenticated (${authStatus.cookie_count} cookies)`
|
||||
: 'Not configured - paste cookies below'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Cookies */}
|
||||
{Object.keys(currentCookies).length > 0 && (
|
||||
<div className="bg-white/5 rounded-xl p-4 mb-6">
|
||||
<h3 className="text-white/60 text-sm font-medium mb-3">Current Cookies (masked)</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(currentCookies).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2 text-sm">
|
||||
<span className="text-cyan-400 font-mono">{key}:</span>
|
||||
<span className="text-white/50 font-mono truncate">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-green-400 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cookie Input */}
|
||||
<div className="bg-white/5 rounded-xl p-4 mb-4">
|
||||
<h3 className="text-white font-medium mb-2">Paste New Cookies</h3>
|
||||
<p className="text-gray-500 text-xs mb-3">
|
||||
Export cookies from <a href="https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm" target="_blank" className="text-cyan-400 hover:underline">Cookie-Editor</a> extension while logged into TikTok
|
||||
</p>
|
||||
<textarea
|
||||
value={cookiesJson}
|
||||
onChange={(e) => setCookiesJson(e.target.value)}
|
||||
placeholder='[{"name": "sessionid", "value": "..."},...]'
|
||||
className="w-full h-40 bg-black/50 border border-white/10 rounded-lg p-3 text-white font-mono text-sm focus:outline-none focus:border-cyan-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveCookies}
|
||||
disabled={isLoading || !cookiesJson.trim()}
|
||||
className={`w-full py-3 rounded-xl font-semibold transition-all ${cookiesJson.trim() && !isLoading
|
||||
? 'bg-gradient-to-r from-cyan-500 to-pink-500 text-white'
|
||||
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save Cookies'}
|
||||
</button>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-8 p-4 bg-white/5 rounded-xl">
|
||||
<h3 className="text-white font-medium mb-3">📋 How to Get Cookies</h3>
|
||||
<ol className="space-y-2 text-gray-400 text-sm">
|
||||
<li className="flex gap-2">
|
||||
<span className="text-cyan-400">1.</span>
|
||||
Install Cookie-Editor browser extension
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-cyan-400">2.</span>
|
||||
Go to tiktok.com and login to your account
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-cyan-400">3.</span>
|
||||
Click Cookie-Editor icon → Export → Copy
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-cyan-400">4.</span>
|
||||
Paste the JSON above and click Save
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,237 +1,106 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [sessionId, setSessionId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showVnc, setShowVnc] = useState(false);
|
||||
const [vncStatus, setVncStatus] = useState('');
|
||||
const pollIntervalRef = useRef<number | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if already authenticated (ssl login check)
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE_URL}/auth/status`);
|
||||
if (res.data.authenticated) {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (err) {
|
||||
// Not authenticated
|
||||
}
|
||||
};
|
||||
|
||||
const getVncUrl = () => {
|
||||
const host = window.location.hostname;
|
||||
// autoconnect=true, resize=remote (server resizing) for full screen mobile
|
||||
return `http://${host}:6080/vnc.html?autoconnect=true&resize=remote&quality=9`;
|
||||
};
|
||||
|
||||
const handleVncLogin = async () => {
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
setVncStatus('Initializing secure browser...');
|
||||
|
||||
try {
|
||||
// Start the VNC session (SSL Login flow)
|
||||
const res = await axios.post(`${API_BASE_URL}/auth/start-vnc`);
|
||||
|
||||
if (res.data.status === 'started') {
|
||||
setShowVnc(true);
|
||||
setIsLoading(false);
|
||||
setVncStatus('Waiting for login...');
|
||||
|
||||
// Poll for completion
|
||||
pollIntervalRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const checkRes = await axios.get(`${API_BASE_URL}/auth/check-vnc`);
|
||||
|
||||
if (checkRes.data.logged_in) {
|
||||
clearInterval(pollIntervalRef.current!);
|
||||
setVncStatus('Login successful! Redirecting...');
|
||||
setTimeout(() => navigate('/'), 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('VNC check error:', err);
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
setError(res.data.message || 'Failed to start browser');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to start login session');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelVnc = async () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/auth/stop-vnc`);
|
||||
} catch (err) {
|
||||
console.error('Failed to stop VNC:', err);
|
||||
}
|
||||
setShowVnc(false);
|
||||
setVncStatus('');
|
||||
};
|
||||
|
||||
const handleManualLogin = async () => {
|
||||
if (!sessionId.trim()) return;
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await axios.post(`${API_BASE_URL}/auth/credentials`, {
|
||||
credentials: {
|
||||
http: {
|
||||
cookies: { sessionid: sessionId.trim() }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (res.data.status === 'success') {
|
||||
// Authenticated - redirect to feed
|
||||
setIsAuthenticated(true);
|
||||
navigate('/');
|
||||
} else {
|
||||
setError('Invalid session ID.');
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError('Connection failed.');
|
||||
} catch (err) {
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Full Screen VNC View
|
||||
if (showVnc) {
|
||||
// Loading state
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black">
|
||||
{/* Floating Control Bar */}
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 bg-gray-900/90 backdrop-blur-md border border-white/10 rounded-full px-6 py-3 flex items-center gap-6 shadow-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span className="text-white text-sm font-medium">Secure Browser Active</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={handleCancelVnc}
|
||||
className="text-red-400 hover:text-red-300 text-sm font-medium transition-colors"
|
||||
>
|
||||
Cancel Login
|
||||
</button>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-white/20 border-t-cyan-400 rounded-full animate-spin" />
|
||||
<span className="text-white/60 text-sm">Checking session...</span>
|
||||
</div>
|
||||
|
||||
{/* Status Toast */}
|
||||
{vncStatus && (
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 bg-black/80 backdrop-blur text-white/90 px-4 py-2 rounded-lg text-sm border border-white/10">
|
||||
{vncStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Screen Iframe */}
|
||||
<iframe
|
||||
src={getVncUrl()}
|
||||
className="w-full h-full border-0"
|
||||
style={{ width: '100vw', height: '100vh' }}
|
||||
title="TikTok Secure Login"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Already authenticated - this shouldn't show (redirect happens), but just in case
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-white mb-4">Already logged in!</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-6 py-2 bg-gradient-to-r from-cyan-500 to-pink-500 rounded-full text-white font-medium"
|
||||
>
|
||||
Go to Feed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated - show "not configured" message
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex flex-col">
|
||||
<div className="flex-shrink-0 pt-10 pb-6 px-6 text-center">
|
||||
<div className="relative inline-block mb-3">
|
||||
<div className="w-14 h-14 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl rotate-12 absolute -inset-1 blur-lg opacity-50" />
|
||||
<div className="relative w-14 h-14 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center p-6">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
{/* Logo */}
|
||||
<div className="relative inline-block mb-6">
|
||||
<div className="w-20 h-20 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl rotate-12 absolute -inset-1 blur-lg opacity-50" />
|
||||
<div className="relative w-20 h-20 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white mb-0.5">PureStream</h1>
|
||||
<p className="text-gray-500 text-xs">Ad-free TikTok viewing</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-8">
|
||||
<div className="max-w-sm mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold text-white mb-2">PureStream</h1>
|
||||
<p className="text-gray-500 text-sm mb-8">Ad-free TikTok viewing</p>
|
||||
|
||||
<button
|
||||
onClick={handleVncLogin}
|
||||
disabled={isLoading}
|
||||
className={`w-full py-4 rounded-2xl font-bold text-base flex items-center justify-center gap-3 transition-all ${isLoading
|
||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-pink-500 to-orange-500 text-white shadow-lg shadow-pink-500/25 hover:shadow-pink-500/40 active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64c.32 0 .6.05.88.13V9.4c-.3-.04-.6-.05-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
|
||||
</svg>
|
||||
SSL Login with TikTok
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-gray-600 text-xs text-center mt-3">
|
||||
Launch secure browser to login automatically
|
||||
{/* Not Configured Message */}
|
||||
<div className="bg-white/5 rounded-2xl p-6 mb-6">
|
||||
<div className="w-12 h-12 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-white font-medium mb-2">Not Configured</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
This app needs TikTok cookies to work. Please contact the administrator.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 my-8">
|
||||
<div className="flex-1 h-px bg-white/10" />
|
||||
<span className="text-gray-600 text-xs">or enter manually</span>
|
||||
<div className="flex-1 h-px bg-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-xl p-4">
|
||||
<p className="text-gray-500 text-xs mb-3">
|
||||
Paste your TikTok <code className="text-pink-400 bg-pink-500/20 px-1 rounded">sessionid</code> cookie:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={sessionId}
|
||||
onChange={(e) => setSessionId(e.target.value)}
|
||||
placeholder="Paste sessionid..."
|
||||
className="w-full bg-black border border-white/10 rounded-lg p-3 text-white text-sm font-mono focus:outline-none focus:border-pink-500/50 placeholder:text-gray-600 mb-3"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleManualLogin}
|
||||
disabled={!sessionId.trim() || isLoading}
|
||||
className={`w-full py-2.5 rounded-lg font-medium text-sm transition-all ${sessionId.trim() && !isLoading
|
||||
? 'bg-white/10 hover:bg-white/20 text-white'
|
||||
: 'bg-white/5 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Link (subtle) */}
|
||||
<a
|
||||
href="/admin"
|
||||
className="text-white/30 text-xs hover:text-white/50 transition-colors"
|
||||
>
|
||||
Admin Access →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue