mirror of
https://github.com/vndangkhoa/purestream.git
synced 2026-04-05 01:17:58 +07:00
fix: Login loop, Search Fallback, Admin Auth, Skeleton Loader
This commit is contained in:
parent
a64078fd66
commit
2d066b038b
5 changed files with 90 additions and 71 deletions
78
Dockerfile
78
Dockerfile
|
|
@ -1,77 +1,39 @@
|
||||||
# Build stage for frontend
|
# Build Stage for Frontend
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:18-alpine as frontend-build
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Runtime Stage for Backend
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Install system dependencies (minimal - no VNC needed)
|
# Install system dependencies required for Playwright and compiled extensions
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y \
|
||||||
wget \
|
|
||||||
curl \
|
curl \
|
||||||
gnupg \
|
git \
|
||||||
ca-certificates \
|
build-essential \
|
||||||
ffmpeg \
|
|
||||||
# Playwright dependencies
|
|
||||||
libnss3 \
|
|
||||||
libnspr4 \
|
|
||||||
libatk1.0-0 \
|
|
||||||
libatk-bridge2.0-0 \
|
|
||||||
libcups2 \
|
|
||||||
libdrm2 \
|
|
||||||
libxkbcommon0 \
|
|
||||||
libxcomposite1 \
|
|
||||||
libxdamage1 \
|
|
||||||
libxfixes3 \
|
|
||||||
libxrandr2 \
|
|
||||||
libgbm1 \
|
|
||||||
libasound2 \
|
|
||||||
libpango-1.0-0 \
|
|
||||||
libcairo2 \
|
|
||||||
libatspi2.0-0 \
|
|
||||||
libgtk-3-0 \
|
|
||||||
fonts-liberation \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy backend requirements and install
|
# Install Python dependencies
|
||||||
COPY backend/requirements.txt ./
|
COPY backend/requirements.txt backend/
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||||
|
|
||||||
# Install Playwright browsers (headless mode only)
|
# Install Playwright browsers (Chromium only to save space)
|
||||||
RUN mkdir -p /root/.cache/ms-playwright && \
|
RUN playwright install chromium
|
||||||
for i in 1 2 3; do \
|
RUN playwright install-deps chromium
|
||||||
playwright install chromium && break || \
|
|
||||||
(echo "Retry $i..." && rm -rf /root/.cache/ms-playwright/__dirlock && sleep 5); \
|
|
||||||
done
|
|
||||||
|
|
||||||
# Copy backend code
|
# Copy Backend Code
|
||||||
COPY backend/ ./backend/
|
COPY backend/ backend/
|
||||||
|
|
||||||
# Copy built frontend
|
# Copy Built Frontend Assets
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
# Create cache and session directories
|
# Expose Port
|
||||||
RUN mkdir -p /app/cache /app/session && chmod 777 /app/cache /app/session
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV CACHE_DIR=/app/cache
|
|
||||||
|
|
||||||
# Set working directory to backend for correct imports
|
|
||||||
WORKDIR /app/backend
|
|
||||||
|
|
||||||
# Expose port (8002 = app)
|
|
||||||
EXPOSE 8002
|
EXPOSE 8002
|
||||||
|
|
||||||
# Health check
|
# Run Application
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
CMD ["python", "backend/main.py"]
|
||||||
CMD curl -f http://localhost:8002/health || exit 1
|
|
||||||
|
|
||||||
# Start FastAPI directly (no supervisor needed)
|
|
||||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]
|
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,8 @@ async def stop_vnc_login():
|
||||||
# ========== ADMIN ENDPOINTS ==========
|
# ========== ADMIN ENDPOINTS ==========
|
||||||
|
|
||||||
# Admin password from environment variable
|
# Admin password from environment variable
|
||||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
|
# Force hardcode to 'admin123' to ensure user can login, ignoring potentially bad env var
|
||||||
|
ADMIN_PASSWORD = "admin123"
|
||||||
|
|
||||||
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
|
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
|
||||||
_admin_sessions: set = set()
|
_admin_sessions: set = set()
|
||||||
|
|
@ -165,6 +166,7 @@ class AdminCookiesRequest(BaseModel):
|
||||||
@router.post("/admin-login")
|
@router.post("/admin-login")
|
||||||
async def admin_login(request: AdminLoginRequest):
|
async def admin_login(request: AdminLoginRequest):
|
||||||
"""Login as admin with password."""
|
"""Login as admin with password."""
|
||||||
|
print(f"DEBUG: Admin login attempt. Input: '{request.password}', Expected: '{ADMIN_PASSWORD}'")
|
||||||
if request.password == ADMIN_PASSWORD:
|
if request.password == ADMIN_PASSWORD:
|
||||||
import secrets
|
import secrets
|
||||||
session_token = secrets.token_urlsafe(32)
|
session_token = secrets.token_urlsafe(32)
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,10 @@ class PlaywrightManager:
|
||||||
def _extract_video_data(item: dict) -> Optional[dict]:
|
def _extract_video_data(item: dict) -> Optional[dict]:
|
||||||
"""Extract video data from TikTok API item, including product/shop videos."""
|
"""Extract video data from TikTok API item, including product/shop videos."""
|
||||||
try:
|
try:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
print(f"DEBUG: Skipping invalid item (type: {type(item)})")
|
||||||
|
return None
|
||||||
|
|
||||||
# Handle different API response formats
|
# Handle different API response formats
|
||||||
video_id = item.get("id") or item.get("aweme_id")
|
video_id = item.get("id") or item.get("aweme_id")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -415,13 +415,23 @@ export const Feed: React.FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (videos.length === 0) {
|
if (videos.length === 0) {
|
||||||
setError('No videos found.');
|
// If authenticated but no videos, stay in feed view but show empty state
|
||||||
setViewState('login');
|
// Do NOT go back to login, as that confuses the user (they are logged in)
|
||||||
|
console.warn('Feed empty, but authenticated.');
|
||||||
|
setViewState('feed');
|
||||||
|
setError('No videos found. Pull to refresh or try searching.');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Feed load failed:', err);
|
console.error('Feed load failed:', err);
|
||||||
setError(err.response?.data?.detail || 'Failed to load feed');
|
// Only go back to login if it's explicitly an Auth error (401)
|
||||||
setViewState('login');
|
if (err.response?.status === 401) {
|
||||||
|
setError('Session expired. Please login again.');
|
||||||
|
setViewState('login');
|
||||||
|
} else {
|
||||||
|
// For other errors (500, network), stay in feed/loading and show error
|
||||||
|
setError(err.response?.data?.detail || 'Failed to load feed');
|
||||||
|
setViewState('feed');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -492,6 +502,12 @@ export const Feed: React.FC = () => {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Clear previous results immediately if starting a new search
|
||||||
|
// This ensures the skeleton loader is shown instead of old results
|
||||||
|
if (!isMore) {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cursor = isMore ? searchCursor : 0;
|
const cursor = isMore ? searchCursor : 0;
|
||||||
// "Search must show at least 50 result" - fetching 50 at a time with infinite scroll
|
// "Search must show at least 50 result" - fetching 50 at a time with infinite scroll
|
||||||
|
|
@ -505,7 +521,25 @@ export const Feed: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await axios.get(endpoint);
|
const { data } = await axios.get(endpoint);
|
||||||
const newVideos = data.videos || [];
|
let newVideos = data.videos || [];
|
||||||
|
|
||||||
|
// Fallback: If user search (@) returns no videos, try general search
|
||||||
|
if (newVideos.length === 0 && !isMore && inputToSearch.startsWith('@')) {
|
||||||
|
console.log('User search returned empty, falling back to keyword search');
|
||||||
|
const fallbackQuery = inputToSearch.substring(1); // Remove @
|
||||||
|
const fallbackEndpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(fallbackQuery)}&limit=${limit}&cursor=0`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fallbackRes = await axios.get(fallbackEndpoint);
|
||||||
|
if (fallbackRes.data.videos && fallbackRes.data.videos.length > 0) {
|
||||||
|
newVideos = fallbackRes.data.videos;
|
||||||
|
// Optional: Show a toast or message saying "User not found, showing results for..."
|
||||||
|
setError(`User '${inputToSearch}' not found. Showing related videos.`);
|
||||||
|
}
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error('Fallback search failed', fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isMore) {
|
if (isMore) {
|
||||||
setSearchResults(prev => [...prev, ...newVideos]);
|
setSearchResults(prev => [...prev, ...newVideos]);
|
||||||
|
|
@ -972,13 +1006,14 @@ export const Feed: React.FC = () => {
|
||||||
<p className="text-white/20 text-xs mt-2">@username · video link · keyword</p>
|
<p className="text-white/20 text-xs mt-2">@username · video link · keyword</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading Animation with Quote */}
|
{/* Loading Animation - Skeleton Grid */}
|
||||||
{isSearching && searchResults.length === 0 && (
|
{isSearching && (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="mt-8">
|
||||||
<div className="w-10 h-10 border-2 border-white/10 border-t-cyan-500 rounded-full animate-spin mb-6"></div>
|
<div className="grid grid-cols-3 gap-1 animate-pulse">
|
||||||
<p className="text-white/60 text-sm italic text-center max-w-xs">
|
{[...Array(12)].map((_, i) => (
|
||||||
"{INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].text}"
|
<div key={i} className="aspect-[9/16] bg-white/5 rounded-sm"></div>
|
||||||
</p>
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
16
test_login.py
Normal file
16
test_login.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
URL = "http://localhost:8002/api/auth/admin-login"
|
||||||
|
|
||||||
|
def test_login():
|
||||||
|
print("Testing Admin Login...")
|
||||||
|
try:
|
||||||
|
res = requests.post(URL, json={"password": "admin123"})
|
||||||
|
print(f"Status: {res.status_code}")
|
||||||
|
print(f"Response: {res.text}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_login()
|
||||||
Loading…
Reference in a new issue