1adc4806fa
Security Fixes
File Change
.env.local Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File Change
src/app/api/leads/route.ts Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts Added limit (default 50), offset query params
src/app/api/conversations/route.ts Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File Change
src/lib/ai.ts Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx "Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below) Added console.warn("...") context messages
411 lines
15 KiB
Python
411 lines
15 KiB
Python
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse, random, time, logging
|
|
from datetime import datetime, timedelta
|
|
from bs4 import BeautifulSoup
|
|
import httpx
|
|
from fastapi import FastAPI
|
|
from pydantic import BaseModel
|
|
import uvicorn
|
|
from playwright.async_api import async_playwright
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI()
|
|
PORT = int(os.getenv("PORT", "3008"))
|
|
|
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
|
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
|
|
|
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
|
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') if FX_PROFILE else ''
|
|
|
|
HEADERS = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
}
|
|
|
|
last_scrape_time: datetime | None = None
|
|
|
|
STRICT_KEYWORDS = [
|
|
"website", "web design", "web develop", "web dev",
|
|
"build my website", "build a website", "create a website",
|
|
"need web", "looking for web", "new website",
|
|
"landing page", "wordpress",
|
|
"need a website", "my website", "business website",
|
|
"need a designer", "help with my website",
|
|
"redesign", "update my website",
|
|
]
|
|
|
|
BROAD_KEYWORDS = STRICT_KEYWORDS + [
|
|
"looking for", "need a", "need an", "looking to",
|
|
"help me build", "create my", "build me",
|
|
"design my", "make me a", "would like",
|
|
"need someone", "hire a", "looking to hire",
|
|
"want someone", "need help with",
|
|
]
|
|
|
|
def kw_match_strict(text: str) -> bool:
|
|
t = text.lower()
|
|
return any(kw in t for kw in STRICT_KEYWORDS)
|
|
|
|
def kw_match(text: str) -> bool:
|
|
t = text.lower()
|
|
return any(kw in t for kw in BROAD_KEYWORDS)
|
|
|
|
FB_SEARCHES = [
|
|
"looking for web developer",
|
|
"need a website designed",
|
|
"need website built South Africa",
|
|
"need someone to build my website",
|
|
"need web designer",
|
|
"looking for someone to create website",
|
|
"need ecommerce website built",
|
|
"want to hire web developer",
|
|
"website quote please",
|
|
"need wordpress website",
|
|
"I need a website for my business",
|
|
"need a site for my business",
|
|
]
|
|
|
|
async def get_fb_cookies():
|
|
if not FX_COOKIE_DB or not os.path.exists(FX_COOKIE_DB):
|
|
logger.warning("FX_COOKIE_DB not found or FX_PROFILE not set")
|
|
return []
|
|
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), f'cookies_tmp_{os.getpid()}.sqlite')
|
|
for attempt in range(3):
|
|
try:
|
|
shutil.copy2(FX_COOKIE_DB, tmp)
|
|
break
|
|
except PermissionError:
|
|
if attempt < 2:
|
|
await asyncio.sleep(0.5)
|
|
else:
|
|
logger.error("Failed to copy cookie DB after 3 attempts")
|
|
return []
|
|
try:
|
|
conn = sqlite3.connect(tmp)
|
|
c = conn.cursor()
|
|
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
|
|
rows = c.fetchall()
|
|
conn.close()
|
|
try:
|
|
os.remove(tmp)
|
|
except Exception:
|
|
pass
|
|
return [{
|
|
"name": name, "value": value,
|
|
"domain": host if host.startswith('.') else f'.{host}',
|
|
"path": path,
|
|
"httpOnly": True, "secure": True, "sameSite": "Lax",
|
|
} for name, value, host, path in rows]
|
|
except Exception as e:
|
|
logger.error("Cookie DB read error: %s", e)
|
|
try:
|
|
os.remove(tmp)
|
|
except Exception:
|
|
pass
|
|
return []
|
|
|
|
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
|
|
|
def _parse_fb_date(block: list[str]) -> str:
|
|
for l in block:
|
|
l = l.strip()
|
|
m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l)
|
|
if m:
|
|
hours = int(m.group(1))
|
|
d = datetime.now() - timedelta(hours=hours)
|
|
return d.strftime('%Y-%m-%d')
|
|
if l.lower() == 'yesterday':
|
|
d = datetime.now() - timedelta(days=1)
|
|
return d.strftime('%Y-%m-%d')
|
|
low = l.lower()
|
|
if low in WEEKDAY_ORDER:
|
|
day_idx = WEEKDAY_ORDER.index(low)
|
|
today_idx = datetime.now().weekday()
|
|
days_ago = (today_idx - day_idx) % 7
|
|
d = datetime.now() - timedelta(days=days_ago)
|
|
return d.strftime('%Y-%m-%d')
|
|
for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']:
|
|
try:
|
|
dt = datetime.strptime(l, fmt)
|
|
return dt.strftime('%Y-%m-%d')
|
|
except ValueError:
|
|
pass
|
|
return datetime.now().strftime('%Y-%m-%d')
|
|
|
|
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|
lines = [l.strip() for l in raw.split('\n')]
|
|
blocks = []
|
|
cur = []
|
|
fb_run = 0
|
|
for l in lines:
|
|
if 'facebook' in l.lower():
|
|
fb_run += 1
|
|
if cur and fb_run >= 2:
|
|
blocks.append(cur)
|
|
cur = []
|
|
else:
|
|
fb_run = 0
|
|
if l and len(l) >= 15:
|
|
words = re.findall(r'[A-Za-z]{2,}', l)
|
|
if len(words) >= 2:
|
|
cur.append(l)
|
|
if cur:
|
|
blocks.append(cur)
|
|
|
|
posts = []
|
|
seen_texts = set()
|
|
for block in blocks:
|
|
if not block:
|
|
continue
|
|
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
|
|
if not kw_indices:
|
|
continue
|
|
i = kw_indices[0]
|
|
start = max(0, i - 2)
|
|
end = min(len(block), i + 5)
|
|
snippet = ' '.join(block[start:end])
|
|
if len(snippet) < 40:
|
|
continue
|
|
dekey = snippet[:80]
|
|
if dekey in seen_texts:
|
|
continue
|
|
seen_texts.add(dekey)
|
|
posts.append({
|
|
"title": snippet[:300],
|
|
"content": snippet[:1000],
|
|
"author": block[start] if start < i else '',
|
|
"url": url,
|
|
"source": "facebook",
|
|
"date": _parse_fb_date(block),
|
|
})
|
|
return posts
|
|
|
|
async def search_facebook(page, query: str) -> list[dict]:
|
|
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
|
try:
|
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
|
await page.wait_for_timeout(6000)
|
|
except Exception as e:
|
|
logger.warning("Facebook search navigation failed for '%s': %s", query, e)
|
|
return []
|
|
|
|
try:
|
|
raw = await page.evaluate('document.body.innerText')
|
|
except Exception as e:
|
|
logger.warning("Failed to evaluate page text: %s", e)
|
|
return []
|
|
return _extract_posts_from_text(raw, url)
|
|
|
|
async def scrape_facebook() -> list[dict]:
|
|
all_posts = []
|
|
fb_cookies = await get_fb_cookies()
|
|
if not fb_cookies:
|
|
logger.warning("No Facebook cookies available")
|
|
return []
|
|
try:
|
|
async with async_playwright() as pw:
|
|
browser = await pw.chromium.launch(headless=True)
|
|
context = await browser.new_context(
|
|
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
|
viewport={'width': 1280, 'height': 800},
|
|
)
|
|
for c in fb_cookies:
|
|
try:
|
|
await context.add_cookies([c])
|
|
except Exception as e:
|
|
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
|
|
|
page = await context.new_page()
|
|
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
|
await page.wait_for_timeout(5000)
|
|
|
|
url = page.url
|
|
if '/login' in url.lower():
|
|
logger.warning("Facebook login page detected — cookies may be expired")
|
|
return []
|
|
|
|
for query in FB_SEARCHES[:6]:
|
|
try:
|
|
posts = await search_facebook(page, query)
|
|
all_posts.extend(posts)
|
|
delay = random.uniform(3, 7)
|
|
await page.wait_for_timeout(int(delay * 1000))
|
|
except Exception as e:
|
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
|
except Exception as e:
|
|
logger.error("Facebook scrape failed: %s", e)
|
|
return []
|
|
|
|
seen = set()
|
|
deduped = []
|
|
for p in all_posts:
|
|
key = p.get('content', '')[:100]
|
|
if key not in seen:
|
|
seen.add(key)
|
|
deduped.append(p)
|
|
return deduped[:20]
|
|
|
|
async def ask_ollama(prompt: str) -> str:
|
|
async with httpx.AsyncClient(timeout=120) as c:
|
|
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
|
"model": CLASSIFY_MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": "You classify forum posts. Return only valid JSON."},
|
|
{"role": "user", "content": prompt}
|
|
],
|
|
"stream": False,
|
|
"options": {"temperature": 0.05, "num_predict": 1024},
|
|
})
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
return data["message"]["content"]
|
|
|
|
async def scrape_warriorforum() -> list[dict]:
|
|
results = []
|
|
try:
|
|
async with httpx.AsyncClient(headers=HEADERS, timeout=15, follow_redirects=True) as c:
|
|
r = await c.get('https://www.warriorforum.com/wanted-members-looking-hire-you/')
|
|
soup = BeautifulSoup(r.text, 'lxml')
|
|
for row in soup.find_all('td', class_='FlexTable-item--title'):
|
|
a = row.find('a', href=True)
|
|
if not a:
|
|
continue
|
|
href = a['href']
|
|
title = a.text.strip()
|
|
if not title or len(title) < 15 or not kw_match_strict(title):
|
|
continue
|
|
author_div = row.find('div', class_='FlexTable-item-author')
|
|
author = author_div.get_text(strip=True).lstrip('by') if author_div else ''
|
|
date_str = ''
|
|
date_div = row.find('div', class_=lambda c: c and 'media--available' in c)
|
|
if date_div:
|
|
txt = date_div.get_text(strip=True)
|
|
m = re.search(r'(\d{10})', txt)
|
|
if m:
|
|
date_str = datetime.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d')
|
|
if not href.startswith('http'):
|
|
href = 'https://www.warriorforum.com' + href
|
|
results.append({
|
|
"title": title, "url": href,
|
|
"author": author,
|
|
"content": title[:300],
|
|
"source": "warriorforum",
|
|
"date": date_str
|
|
})
|
|
except Exception:
|
|
logger.error("WarriorForum scrape failed", exc_info=True)
|
|
return results
|
|
|
|
async def classify_leads(results: list[dict]) -> list[dict]:
|
|
if not results:
|
|
return []
|
|
briefs = [r["title"][:200] for r in results]
|
|
prompt = f"""Classify each post as LEAD or NOT.
|
|
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
|
LEAD examples: "Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"
|
|
|
|
NOT LEAD:
|
|
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
|
|
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
|
|
- Recruiting employees, hiring staff, looking for business partners
|
|
- Selling products, promoting services, affiliate offers
|
|
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
|
|
|
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
|
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
|
Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|
ai_succeeded = False
|
|
try:
|
|
raw = await ask_ollama(prompt)
|
|
raw = raw.strip()
|
|
# Strip markdown code fences
|
|
if raw.startswith("```"):
|
|
# Remove opening fence
|
|
first_nl = raw.find('\n')
|
|
if first_nl != -1:
|
|
raw = raw[first_nl + 1:]
|
|
# Remove closing fence
|
|
if raw.endswith("```"):
|
|
raw = raw[:-3]
|
|
raw = raw.strip()
|
|
answers = json.loads(raw)
|
|
if isinstance(answers, list) and len(answers) == len(results):
|
|
ai_succeeded = True
|
|
filtered = []
|
|
for i, ans in enumerate(answers):
|
|
if isinstance(ans, str) and ans.lower() == 'yes':
|
|
filtered.append(results[i])
|
|
if filtered:
|
|
return filtered[:10]
|
|
# AI successfully classified but returned all "no" — respect that decision
|
|
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
|
return []
|
|
except Exception as e:
|
|
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
|
|
|
# Fallback: only use keyword filter when AI failed
|
|
if not ai_succeeded:
|
|
filtered = []
|
|
for r in results:
|
|
t = r['title'].lower()
|
|
if not kw_match_strict(t):
|
|
continue
|
|
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
|
'affordable web', 'web hosting', 'free website',
|
|
'limited time', 'special offer', 'sign up now',
|
|
'offer']):
|
|
continue
|
|
filtered.append(r)
|
|
return filtered[:10]
|
|
return []
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
class ScrapeRequest(BaseModel):
|
|
query: str = ""
|
|
|
|
@app.post("/scrape/requests")
|
|
async def scrape_requests(req: ScrapeRequest):
|
|
global last_scrape_time
|
|
now = datetime.now()
|
|
if last_scrape_time and (now - last_scrape_time) < timedelta(seconds=15):
|
|
return {"error": "rate_limited", "retry_after": 15}
|
|
last_scrape_time = now
|
|
results = await scrape_warriorforum()
|
|
if results:
|
|
results = await classify_leads(results)
|
|
return results[:10]
|
|
|
|
@app.post("/scrape/facebook")
|
|
async def scrape_facebook_endpoint():
|
|
results = await scrape_facebook()
|
|
if results:
|
|
results = await classify_leads(results)
|
|
return results[:15]
|
|
|
|
@app.post("/scrape/all")
|
|
async def scrape_all():
|
|
results = []
|
|
try:
|
|
wf = await scrape_warriorforum()
|
|
if wf:
|
|
wf = await classify_leads(wf)
|
|
results.extend(wf[:5])
|
|
except Exception as e:
|
|
logger.error("WarriorForum scrape in /scrape/all failed: %s", e)
|
|
try:
|
|
fb = await scrape_facebook()
|
|
if fb:
|
|
fb = await classify_leads(fb)
|
|
results.extend(fb[:8])
|
|
except Exception as e:
|
|
logger.error("Facebook scrape in /scrape/all failed: %s", e)
|
|
return results[:12]
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|