diff --git a/Dockerfile b/Dockerfile index b5f0906..ee955d4 100644 --- a/Dockerfile +++ b/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"] diff --git a/README.md b/README.md index 04a4b8c..8c0e318 100644 --- a/README.md +++ b/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 diff --git a/backend/api/routes/auth.py b/backend/api/routes/auth.py index f7d2d12..fc00a87 100644 --- a/backend/api/routes/auth.py +++ b/backend/api/routes/auth.py @@ -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} + diff --git a/docker-compose.yml b/docker-compose.yml index ba1b1c2..f4a6c45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8d7a2c..1738178 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + } /> { + const [password, setPassword] = useState(''); + const [adminToken, setAdminToken] = useState(null); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [cookiesJson, setCookiesJson] = useState(''); + const [currentCookies, setCurrentCookies] = useState>({}); + 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 ( +
+
+
+
+ + + + +
+

Admin Access

+

Enter password to manage cookies

+
+ + {error && ( +
+ {error} +
+ )} + + 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 + /> + + + + + ← Back to App + +
+
+ ); + } + + // Logged in as admin - show cookie management + return ( +
+
+ {/* Header */} +
+
+

Cookie Manager

+

Update TikTok session cookies

+
+
+ + ← App + + +
+
+ + {/* Status Card */} +
+
+
+ + {authStatus?.authenticated + ? `Authenticated (${authStatus.cookie_count} cookies)` + : 'Not configured - paste cookies below'} + +
+
+ + {/* Current Cookies */} + {Object.keys(currentCookies).length > 0 && ( +
+

Current Cookies (masked)

+
+ {Object.entries(currentCookies).map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+
+ )} + + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Cookie Input */} +
+

Paste New Cookies

+

+ Export cookies from Cookie-Editor extension while logged into TikTok +

+