diff --git a/Dockerfile b/Dockerfile index 13371df..fba4d75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN npm run build # Production stage FROM python:3.11-slim -# Install system dependencies for Playwright and yt-dlp +# Install system dependencies for Playwright, VNC, and noVNC RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ curl \ @@ -35,10 +35,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libatspi2.0-0 \ libgtk-3-0 \ fonts-liberation \ + # VNC and display xvfb \ xauth \ + x11vnc \ + supervisor \ + # noVNC dependencies + python3-numpy \ && rm -rf /var/lib/apt/lists/* +# Install noVNC +RUN mkdir -p /opt/noVNC/utils/websockify \ + && wget -qO- https://github.com/novnc/noVNC/archive/refs/tags/v1.4.0.tar.gz | tar xz --strip 1 -C /opt/noVNC \ + && wget -qO- https://github.com/novnc/websockify/archive/refs/tags/v0.11.0.tar.gz | tar xz --strip 1 -C /opt/noVNC/utils/websockify \ + && ln -s /opt/noVNC/vnc.html /opt/noVNC/index.html + WORKDIR /app # Copy backend requirements and install @@ -60,20 +71,23 @@ 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 + # 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 port -EXPOSE 8002 +# Expose ports (8002 = app, 6080 = noVNC) +EXPOSE 8002 6080 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8002/health || exit 1 -# Start the application with xvfb for headless browser support -CMD ["sh", "-c", "xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' python -m uvicorn main:app --host 0.0.0.0 --port 8002"] - +# Start services using supervisor +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/backend/api/routes/auth.py b/backend/api/routes/auth.py index 2a3f2ec..f7d2d12 100644 --- a/backend/api/routes/auth.py +++ b/backend/api/routes/auth.py @@ -115,3 +115,30 @@ async def logout(): if os.path.exists(COOKIES_FILE): os.remove(COOKIES_FILE) return {"status": "success", "message": "Logged out"} + + +@router.post("/start-vnc") +async def start_vnc_login(): + """ + Start VNC login - opens a visible browser via noVNC. + Users interact with the browser stream to login. + """ + result = await PlaywrightManager.start_vnc_login() + return result + + +@router.get("/check-vnc") +async def check_vnc_login(): + """ + Check if VNC login is complete (sessionid cookie detected). + Frontend polls this endpoint. + """ + result = await PlaywrightManager.check_vnc_login() + return result + + +@router.post("/stop-vnc") +async def stop_vnc_login(): + """Stop the VNC login browser.""" + result = await PlaywrightManager.stop_vnc_login() + return result diff --git a/backend/core/playwright_manager.py b/backend/core/playwright_manager.py index 55a043b..c888f19 100644 --- a/backend/core/playwright_manager.py +++ b/backend/core/playwright_manager.py @@ -26,9 +26,17 @@ class PlaywrightManager: "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-dev-shm-usage", + "--start-maximized", ] DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + # VNC login state (class-level to persist across requests) + _vnc_playwright = None + _vnc_browser = None + _vnc_context = None + _vnc_page = None + _vnc_active = False @staticmethod def parse_json_credentials(json_creds: dict) -> tuple[List[dict], str]: @@ -118,6 +126,106 @@ class PlaywrightManager: with open(USER_AGENT_FILE, "w") as f: json.dump({"user_agent": user_agent}, f) + @classmethod + async def start_vnc_login(cls) -> dict: + """ + Start a visible browser for VNC login. + The browser displays on DISPLAY=:99 which is streamed via noVNC. + Returns immediately - browser stays open for user interaction. + """ + # Close any existing VNC session + if cls._vnc_active: + await cls.stop_vnc_login() + + print("DEBUG: Starting VNC login browser...") + + try: + cls._vnc_playwright = await async_playwright().start() + cls._vnc_browser = await cls._vnc_playwright.chromium.launch( + headless=False, # Visible browser + args=cls.BROWSER_ARGS + ) + + cls._vnc_context = await cls._vnc_browser.new_context( + user_agent=cls.DEFAULT_USER_AGENT, + viewport={"width": 1200, "height": 750} + ) + + cls._vnc_page = await cls._vnc_context.new_page() + await cls._vnc_page.goto("https://www.tiktok.com/login", wait_until="domcontentloaded") + + cls._vnc_active = True + print("DEBUG: VNC browser opened with TikTok login page") + + return { + "status": "started", + "message": "Browser opened. Please login via the VNC stream." + } + + except Exception as e: + print(f"DEBUG: VNC login start error: {e}") + cls._vnc_active = False + return { + "status": "error", + "message": f"Failed to start browser: {str(e)}" + } + + @classmethod + async def check_vnc_login(cls) -> dict: + """ + Check if user has logged in by looking for sessionid cookie. + Called by frontend via polling. + """ + if not cls._vnc_active or not cls._vnc_context: + return {"status": "not_active", "logged_in": False} + + try: + all_cookies = await cls._vnc_context.cookies() + cookies_found = {} + + for cookie in all_cookies: + if cookie.get("domain", "").endswith("tiktok.com"): + cookies_found[cookie["name"]] = cookie["value"] + + if "sessionid" in cookies_found: + # Save cookies and close browser + cls.save_credentials(cookies_found, cls.DEFAULT_USER_AGENT) + await cls.stop_vnc_login() + + return { + "status": "success", + "logged_in": True, + "message": "Login successful!", + "cookie_count": len(cookies_found) + } + + return {"status": "waiting", "logged_in": False} + + except Exception as e: + print(f"DEBUG: VNC check error: {e}") + return {"status": "error", "logged_in": False, "message": str(e)} + + @classmethod + async def stop_vnc_login(cls) -> dict: + """Close the VNC browser session.""" + print("DEBUG: Stopping VNC login browser...") + + try: + if cls._vnc_browser: + await cls._vnc_browser.close() + if cls._vnc_playwright: + await cls._vnc_playwright.stop() + except Exception as e: + print(f"DEBUG: Error closing VNC browser: {e}") + + cls._vnc_browser = None + cls._vnc_context = None + cls._vnc_page = None + cls._vnc_playwright = None + cls._vnc_active = False + + return {"status": "stopped"} + @staticmethod async def credential_login(username: str, password: str, timeout_seconds: int = 60) -> dict: """ diff --git a/docker-compose.yml b/docker-compose.yml index 55e265a..ba1b1c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,19 +7,17 @@ services: restart: unless-stopped ports: - "8002:8002" + - "6080:6080" volumes: - # Persist video cache - - purestream_cache:/app/cache - # Persist login session (optional - for persistent TikTok login) - - purestream_session:/app/backend/session + - ./cache:/app/cache + - ./session:/app/backend/session environment: - PYTHONUNBUFFERED=1 - CACHE_DIR=/app/cache - MAX_CACHE_SIZE_MB=500 - CACHE_TTL_HOURS=24 - # Required for Playwright browser shm_size: '2gb' - # Use custom network to avoid IP conflicts + # IMPORTANT: You must link the service to the network networks: - purestream_net healthcheck: @@ -29,16 +27,11 @@ services: retries: 3 start_period: 60s -volumes: - purestream_cache: - driver: local - purestream_session: - driver: local - networks: purestream_net: driver: bridge ipam: driver: default config: - - subnet: 172.28.0.0/16 + # Using 10.10.0.0 is much safer and less likely to overlap + - subnet: 10.10.0.0/16 diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index b27aa93..df3bc89 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; import { API_BASE_URL } from '../config'; @@ -7,12 +7,20 @@ export const Login: React.FC = () => { const [sessionId, setSessionId] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [loginStatus, setLoginStatus] = useState(''); + const [showVnc, setShowVnc] = useState(false); + const [vncStatus, setVncStatus] = useState(''); + const pollIntervalRef = useRef(null); const navigate = useNavigate(); // Check if already authenticated useEffect(() => { checkAuth(); + return () => { + // Cleanup polling on unmount + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; }, []); const checkAuth = async () => { @@ -22,37 +30,70 @@ export const Login: React.FC = () => { navigate('/'); } } catch (err) { - // Not authenticated, stay on login + // Not authenticated } }; - // Try browser login (opens TikTok in server's Playwright browser) - const handleBrowserLogin = async () => { + // Get the VNC URL (same host, port 6080) + const getVncUrl = () => { + const host = window.location.hostname; + return `http://${host}:6080/vnc.html?autoconnect=true&resize=scale`; + }; + + // Start VNC login + const handleVncLogin = async () => { setError(''); setIsLoading(true); - setLoginStatus('Opening TikTok login... Please wait'); + setVncStatus('Starting browser...'); try { - const res = await axios.post(`${API_BASE_URL}/auth/browser-login`, {}, { - timeout: 200000 // 3+ minutes timeout - }); + const res = await axios.post(`${API_BASE_URL}/auth/start-vnc`); - if (res.data.status === 'success') { - setLoginStatus('Success! Redirecting...'); - setTimeout(() => navigate('/'), 1000); - } else { - setError(res.data.message || 'Login timed out. Please try the manual method.'); + if (res.data.status === 'started') { + setShowVnc(true); + setIsLoading(false); + setVncStatus('Login on the browser below, then wait...'); + + // Start polling for login completion + pollIntervalRef.current = window.setInterval(async () => { + try { + const checkRes = await axios.get(`${API_BASE_URL}/auth/check-vnc`); + + if (checkRes.data.logged_in) { + // Success! + clearInterval(pollIntervalRef.current!); + setVncStatus('Success! Redirecting...'); + setTimeout(() => navigate('/'), 1000); + } + } catch (err) { + console.error('VNC check error:', err); + } + }, 2000); + } else { + setError(res.data.message || 'Failed to start browser'); setIsLoading(false); - setLoginStatus(''); } } catch (err: any) { - setError('Connection failed. Please try the manual method below.'); + setError(err.response?.data?.detail || 'Failed to start VNC login'); setIsLoading(false); - setLoginStatus(''); } }; - // Manual sessionid login + // Cancel VNC login + 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(''); + }; + + // Manual sessionid login (fallback) const handleManualLogin = async () => { if (!sessionId.trim()) return; @@ -71,19 +112,57 @@ export const Login: React.FC = () => { if (res.data.status === 'success') { navigate('/'); } else { - setError('Invalid session ID. Please try again.'); + setError('Invalid session ID.'); } } catch (err: any) { - setError('Connection failed. Please check your session ID.'); + setError('Connection failed.'); } finally { setIsLoading(false); } }; + // VNC View + if (showVnc) { + return ( +
+ {/* Header */} +
+
+
+ + + +
+
+

Login to TikTok

+

{vncStatus}

+
+
+ +
+ + {/* VNC Iframe */} +
+