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
|
# Production stage
|
||||||
FROM python:3.11-slim
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
wget \
|
wget \
|
||||||
curl \
|
curl \
|
||||||
gnupg \
|
gnupg \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
# VNC & Display dependencies
|
|
||||||
tigervnc-standalone-server \
|
|
||||||
tigervnc-common \
|
|
||||||
openbox \
|
|
||||||
# Playwright dependencies
|
# Playwright dependencies
|
||||||
libnss3 \
|
libnss3 \
|
||||||
libnspr4 \
|
libnspr4 \
|
||||||
|
|
@ -39,22 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libatspi2.0-0 \
|
libatspi2.0-0 \
|
||||||
libgtk-3-0 \
|
libgtk-3-0 \
|
||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
supervisor \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy backend requirements and install
|
# Copy backend requirements and install
|
||||||
COPY backend/requirements.txt ./
|
COPY backend/requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r 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 && \
|
RUN mkdir -p /root/.cache/ms-playwright && \
|
||||||
for i in 1 2 3; do \
|
for i in 1 2 3; do \
|
||||||
playwright install chromium && break || \
|
playwright install chromium && break || \
|
||||||
|
|
@ -67,26 +56,22 @@ COPY backend/ ./backend/
|
||||||
# Copy built frontend
|
# Copy built frontend
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||||
|
|
||||||
# Create cache directory
|
# Create cache and session directories
|
||||||
RUN mkdir -p /app/cache && chmod 777 /app/cache
|
RUN mkdir -p /app/cache /app/session && chmod 777 /app/cache /app/session
|
||||||
|
|
||||||
# Copy supervisor config
|
|
||||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV CACHE_DIR=/app/cache
|
ENV CACHE_DIR=/app/cache
|
||||||
ENV DISPLAY=:99
|
|
||||||
|
|
||||||
# Set working directory to backend for correct imports
|
# Set working directory to backend for correct imports
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
|
|
||||||
# Expose ports (8002 = app, 6080 = noVNC)
|
# Expose port (8002 = app)
|
||||||
EXPOSE 8002 6080
|
EXPOSE 8002
|
||||||
|
|
||||||
# Health check
|
# 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
|
CMD curl -f http://localhost:8002/health || exit 1
|
||||||
|
|
||||||
# Start services
|
# Start FastAPI directly (no supervisor needed)
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
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
|
└── 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"**
|
### First-Time Setup
|
||||||
2. A browser window opens - log in to TikTok normally
|
|
||||||
3. Your session is saved locally for future use
|
|
||||||
|
|
||||||
> **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
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,99 @@ async def stop_vnc_login():
|
||||||
"""Stop the VNC login browser."""
|
"""Stop the VNC login browser."""
|
||||||
result = await PlaywrightManager.stop_vnc_login()
|
result = await PlaywrightManager.stop_vnc_login()
|
||||||
return result
|
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
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8002:8002"
|
||||||
- "6080:6080"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./cache:/app/cache
|
- ./cache:/app/cache
|
||||||
- ./session:/app/backend/session
|
- ./session:/app/backend/session
|
||||||
|
|
@ -16,6 +15,7 @@ services:
|
||||||
- CACHE_DIR=/app/cache
|
- CACHE_DIR=/app/cache
|
||||||
- MAX_CACHE_SIZE_MB=500
|
- MAX_CACHE_SIZE_MB=500
|
||||||
- CACHE_TTL_HOURS=24
|
- CACHE_TTL_HOURS=24
|
||||||
|
- ADMIN_PASSWORD=admin123 # Change this to your secure password
|
||||||
shm_size: '2gb'
|
shm_size: '2gb'
|
||||||
# IMPORTANT: You must link the service to the network
|
# IMPORTANT: You must link the service to the network
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
|
import { Admin } from './pages/Admin';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
|
@ -41,6 +42,7 @@ function App() {
|
||||||
<Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const [sessionId, setSessionId] = useState('');
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showVnc, setShowVnc] = useState(false);
|
|
||||||
const [vncStatus, setVncStatus] = useState('');
|
|
||||||
const pollIntervalRef = useRef<number | null>(null);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Check if already authenticated (ssl login check)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
return () => {
|
|
||||||
if (pollIntervalRef.current) {
|
|
||||||
clearInterval(pollIntervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`${API_BASE_URL}/auth/status`);
|
const res = await axios.get(`${API_BASE_URL}/auth/status`);
|
||||||
if (res.data.authenticated) {
|
if (res.data.authenticated) {
|
||||||
navigate('/');
|
// Authenticated - redirect to feed
|
||||||
}
|
setIsAuthenticated(true);
|
||||||
} 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') {
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else {
|
} else {
|
||||||
setError('Invalid session ID.');
|
setIsAuthenticated(false);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setError('Connection failed.');
|
setIsAuthenticated(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsChecking(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Full Screen VNC View
|
// Loading state
|
||||||
if (showVnc) {
|
if (isChecking) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black">
|
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center">
|
||||||
{/* Floating Control Bar */}
|
<div className="flex flex-col items-center gap-4">
|
||||||
<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="w-12 h-12 border-4 border-white/20 border-t-cyan-400 rounded-full animate-spin" />
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-white/60 text-sm">Checking session...</span>
|
||||||
<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>
|
</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>
|
</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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex flex-col">
|
<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="flex-shrink-0 pt-10 pb-6 px-6 text-center">
|
<div className="max-w-sm w-full text-center">
|
||||||
<div className="relative inline-block mb-3">
|
{/* Logo */}
|
||||||
<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 inline-block mb-6">
|
||||||
<div className="relative w-14 h-14 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center">
|
<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" />
|
||||||
<svg className="w-7 h-7 text-white" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<h1 className="text-2xl font-bold text-white mb-2">PureStream</h1>
|
||||||
<div className="max-w-sm mx-auto">
|
<p className="text-gray-500 text-sm mb-8">Ad-free TikTok viewing</p>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
{/* Not Configured Message */}
|
||||||
onClick={handleVncLogin}
|
<div className="bg-white/5 rounded-2xl p-6 mb-6">
|
||||||
disabled={isLoading}
|
<div className="w-12 h-12 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
className={`w-full py-4 rounded-2xl font-bold text-base flex items-center justify-center gap-3 transition-all ${isLoading
|
<svg className="w-6 h-6 text-yellow-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
<circle cx="12" cy="12" r="10" />
|
||||||
: '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]'
|
<path d="M12 8v4M12 16h.01" />
|
||||||
}`}
|
</svg>
|
||||||
>
|
</div>
|
||||||
{isLoading ? (
|
<h2 className="text-white font-medium mb-2">Not Configured</h2>
|
||||||
<>
|
<p className="text-gray-400 text-sm">
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
This app needs TikTok cookies to work. Please contact the administrator.
|
||||||
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
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Link (subtle) */}
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="text-white/30 text-xs hover:text-white/50 transition-colors"
|
||||||
|
>
|
||||||
|
Admin Access →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue