diff --git a/backend/config.py b/backend/config.py index 77fd16d..f1fd0ca 100644 --- a/backend/config.py +++ b/backend/config.py @@ -56,6 +56,10 @@ class AppConfig: smtp_user: Optional[str] = None smtp_password: Optional[str] = None smtp_from: Optional[str] = None + brevo_api_key: Optional[str] = None + brevo_sender_email: Optional[str] = None + brevo_sender_name: Optional[str] = "JobHelperGuru" + def _get_or_create_local_secrets(secrets_dir: Optional[str] = None) -> dict: @@ -117,6 +121,11 @@ def load_config() -> AppConfig: smtp_password = os.getenv("SMTP_PASSWORD") smtp_from = os.getenv("SMTP_FROM") or os.getenv("SMTP_USER") + # Brevo configuration + brevo_api_key = os.getenv("BREVO_API_KEY") + brevo_sender_email = os.getenv("BREVO_SENDER_EMAIL") + brevo_sender_name = os.getenv("BREVO_SENDER_NAME") or "JobHelperGuru" + if raw_mode == "production": jwt_key = (os.getenv("JWT_SECRET_KEY") or "").strip() if not jwt_key: @@ -149,6 +158,9 @@ def load_config() -> AppConfig: smtp_user=smtp_user, smtp_password=smtp_password, smtp_from=smtp_from, + brevo_api_key=brevo_api_key, + brevo_sender_email=brevo_sender_email, + brevo_sender_name=brevo_sender_name, ) elif raw_mode == "local": @@ -171,6 +183,9 @@ def load_config() -> AppConfig: smtp_user=smtp_user, smtp_password=smtp_password, smtp_from=smtp_from, + brevo_api_key=brevo_api_key, + brevo_sender_email=brevo_sender_email, + brevo_sender_name=brevo_sender_name, ) else: # test mode @@ -192,5 +207,9 @@ def load_config() -> AppConfig: smtp_user=smtp_user, smtp_password=smtp_password, smtp_from=smtp_from, + brevo_api_key=brevo_api_key, + brevo_sender_email=brevo_sender_email, + brevo_sender_name=brevo_sender_name, ) + diff --git a/backend/main.py b/backend/main.py index 4455aa2..17cc437 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,16 +2,23 @@ import re from pathlib import Path from typing import Optional, List +from dotenv import load_dotenv + +load_dotenv(override=True) from fastapi import FastAPI, HTTPException, Response, Depends, UploadFile, File, Form, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, FileResponse from fastapi.middleware.cors import CORSMiddleware + from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.exceptions import HTTPException as StarletteHTTPException + from backend.config import load_config from backend.services.rate_limiter import RateLimiter, get_client_ip +from backend.services.resource_leases import ResourceLeases from backend.models import ( Application, ApplicationCreate, @@ -38,7 +45,9 @@ from backend.services.ai_engine import AIEngine, extract_raw_content_from_response from backend.services.excel_exporter import ExcelExporter from backend.services.object_storage import ObjectStorageService +from backend.services.resume_files import ResumeFileManager, format_content_disposition from backend.services.outbound_http import SSRFBlockedError + from backend.routers.auth import router as auth_router, get_current_user, get_optional_user, set_storage_service @@ -170,6 +179,10 @@ async def add_security_headers(request: Request, call_next): else: print("[INFO] Cloudflare R2 not configured. Using local file storage fallback.") +def get_resume_file_manager() -> ResumeFileManager: + return ResumeFileManager(storage, object_storage) + + def get_ai_engine(user_id: Optional[str] = None) -> AIEngine: if not user_id: @@ -227,8 +240,16 @@ def analyze_job( if not req.url and not job_input_text: raise HTTPException(status_code=400, detail="Either a URL or job text must be provided.") - if not current_user: - limiter = RateLimiter(storage) + limiter = RateLimiter(storage) + if current_user: + allowed, _, retry_after = limiter.check("user_ai_ops", current_user.id, limit=30, window_seconds=60) + if not allowed: + raise HTTPException( + status_code=429, + detail="Too many analysis requests. Please try again later.", + headers={"Retry-After": str(int(retry_after))}, + ) + else: cfg = load_config() client_ip = get_client_ip(request, trust_proxy_headers=getattr(cfg, "trust_proxy_headers", False)) allowed, _, retry_after = limiter.check("anon_analysis", client_ip, limit=10, window_seconds=60) @@ -239,91 +260,100 @@ def analyze_job( headers={"Retry-After": str(int(retry_after))}, ) - scraped_title = "" - scraped_company = "" - scraped_location = "" - job_text = "" - source_url = req.url or "" - - if req.url: - scraped = scraper.scrape_url(req.url) - scraped_title = scraped.title - scraped_company = scraped.company - scraped_location = scraped.location - job_text = scraped.raw_text - elif job_input_text: - parsed = scraper.parse_raw_text(job_input_text) - scraped_title = parsed.title - scraped_company = parsed.company - scraped_location = parsed.location - job_text = parsed.raw_text - - if ( - not job_text - or len(job_text.strip()) < 30 - or job_text.strip().startswith("Error fetching URL:") - or job_text.strip().startswith("Disallowed or private URL target") - ): - raise HTTPException( - status_code=400, - detail=( - "Could not extract readable text from this job URL (it may require login or block automated scraping). " - "Please copy and paste the job description text directly into the 'Paste Job Text' tab." - ), - ) + cfg = load_config() + client_ip = get_client_ip(request, trust_proxy_headers=getattr(cfg, "trust_proxy_headers", False)) + lease_service = ResourceLeases(storage) + lease_subject = current_user.id if current_user else f"anon:{client_ip}" + lease_id = lease_service.acquire(user_id=lease_subject, kind="analysis", ttl_seconds=30) - user_id = current_user.id if current_user else None - ai = get_ai_engine(user_id=user_id) try: - analysis = ai.analyze_job(job_text, source_url=source_url) - except Exception as e: - raise HTTPException( - status_code=502, - detail={ - "message": str(e), - "can_switch_offline": True, - "error_type": "ai_api_error", - "model_name": ai.model_name, - }, - ) + scraped_title = "" + scraped_company = "" + scraped_location = "" + job_text = "" + source_url = req.url or "" + + if req.url: + scraped = scraper.scrape_url(req.url) + scraped_title = scraped.title + scraped_company = scraped.company + scraped_location = scraped.location + job_text = scraped.raw_text + elif job_input_text: + parsed = scraper.parse_raw_text(job_input_text) + scraped_title = parsed.title + scraped_company = parsed.company + scraped_location = parsed.location + job_text = parsed.raw_text + + if ( + not job_text + or len(job_text.strip()) < 30 + or job_text.strip().startswith("Error fetching URL:") + or job_text.strip().startswith("Disallowed or private URL target") + ): + raise HTTPException( + status_code=400, + detail=( + "Could not extract readable text from this job URL (it may require login or block automated scraping). " + "Please copy and paste the job description text directly into the 'Paste Job Text' tab." + ), + ) - UNKNOWN_TITLES = {"", "Open Position", "Detected Role", "Unknown Role", "Role Title", "Exact Role Title"} - UNKNOWN_COMPANIES = {"", "Unknown Company", "Company", "Company Name", "Detected Company"} - UNKNOWN_LOCATIONS = {"", "Unknown", "Identified Location", "City, State or Remote/Hybrid", "Unknown Location"} + user_id = current_user.id if current_user else None + ai = get_ai_engine(user_id=user_id) + try: + analysis = ai.analyze_job(job_text, source_url=source_url) + except Exception as e: + raise HTTPException( + status_code=502, + detail={ + "message": str(e), + "can_switch_offline": True, + "error_type": "ai_api_error", + "model_name": ai.model_name, + }, + ) - # Prefer scraped title/company/location if AI/heuristic returned generic placeholders - if (not analysis.title or analysis.title in UNKNOWN_TITLES) and scraped_title and scraped_title not in UNKNOWN_TITLES: - analysis.title = scraped_title - if (not analysis.company or analysis.company in UNKNOWN_COMPANIES) and scraped_company and scraped_company not in UNKNOWN_COMPANIES: - analysis.company = scraped_company - if (not analysis.location or analysis.location in UNKNOWN_LOCATIONS) and scraped_location and scraped_location not in UNKNOWN_LOCATIONS: - analysis.location = scraped_location + UNKNOWN_TITLES = {"", "Open Position", "Detected Role", "Unknown Role", "Role Title", "Exact Role Title"} + UNKNOWN_COMPANIES = {"", "Unknown Company", "Company", "Company Name", "Detected Company"} + UNKNOWN_LOCATIONS = {"", "Unknown", "Identified Location", "City, State or Remote/Hybrid", "Unknown Location"} - analysis_dict = analysis.model_dump() - analysis_dict["title"] = analysis.title - analysis_dict["company"] = analysis.company - analysis_dict["location"] = analysis.location + # Prefer scraped title/company/location if AI/heuristic returned generic placeholders + if (not analysis.title or analysis.title in UNKNOWN_TITLES) and scraped_title and scraped_title not in UNKNOWN_TITLES: + analysis.title = scraped_title + if (not analysis.company or analysis.company in UNKNOWN_COMPANIES) and scraped_company and scraped_company not in UNKNOWN_COMPANIES: + analysis.company = scraped_company + if (not analysis.location or analysis.location in UNKNOWN_LOCATIONS) and scraped_location and scraped_location not in UNKNOWN_LOCATIONS: + analysis.location = scraped_location - return { - "analysis": analysis_dict, - "raw_text": job_text, - "source_url": source_url, - "title": analysis.title, - "company": analysis.company, - "location": analysis.location, - "salary_range": analysis.salary_range, - "work_mode": analysis.work_mode, - "experience_level": analysis.experience_level, - "experience_required": analysis.experience_required, - "is_new_grad_role": analysis.is_new_grad_role, - "new_grad_criteria": analysis.new_grad_criteria, - "required_skills": analysis.required_skills, - "preferred_skills": analysis.preferred_skills, - "tech_stack": analysis.tech_stack, - "soft_skills": analysis.soft_skills, - "ats_keywords": analysis.ats_keywords, - "summary": analysis.summary, - } + analysis_dict = analysis.model_dump() + analysis_dict["title"] = analysis.title + analysis_dict["company"] = analysis.company + analysis_dict["location"] = analysis.location + + return { + "analysis": analysis_dict, + "raw_text": job_text, + "source_url": source_url, + "title": analysis.title, + "company": analysis.company, + "location": analysis.location, + "salary_range": analysis.salary_range, + "work_mode": analysis.work_mode, + "experience_level": analysis.experience_level, + "experience_required": analysis.experience_required, + "is_new_grad_role": analysis.is_new_grad_role, + "new_grad_criteria": analysis.new_grad_criteria, + "required_skills": analysis.required_skills, + "preferred_skills": analysis.preferred_skills, + "tech_stack": analysis.tech_stack, + "soft_skills": analysis.soft_skills, + "ats_keywords": analysis.ats_keywords, + "summary": analysis.summary, + } + finally: + lease_service.release(lease_id) from backend.services.document_parser import extract_text_from_file @@ -353,13 +383,16 @@ def add_resume(req: ResumeCreate, current_user: User = Depends(get_current_user) status_code=422, detail="Resume content exceeds maximum limit of 100,000 characters.", ) - user_resume_count = storage.count_user_resumes(current_user.id) - if user_resume_count >= MAX_RESUMES_PER_USER: - raise HTTPException( - status_code=422, - detail=f"Maximum limit of {MAX_RESUMES_PER_USER} resumes reached per account.", + try: + return storage.add_resume( + name=req.name, + content=req.content, + file_key=None, + user_id=current_user.id, + max_resumes=MAX_RESUMES_PER_USER, ) - return storage.add_resume(name=req.name, content=req.content, file_key=None, user_id=current_user.id) + except ValueError as ve: + raise HTTPException(status_code=422, detail=str(ve)) @app.post("/api/resumes/upload", response_model=Resume) @@ -391,13 +424,6 @@ def upload_resume_file( detail="File exceeds maximum allowed size of 10MB.", ) - current_bytes = storage.get_user_upload_bytes(current_user.id) - if current_bytes + len(content_bytes) > MAX_USER_STORAGE_BYTES: - raise HTTPException( - status_code=422, - detail="Maximum total storage limit of 50MB reached for resumes.", - ) - if content_override and content_override.strip(): if len(content_override) > 100_000: raise HTTPException( @@ -411,40 +437,28 @@ def upload_resume_file( raise HTTPException(status_code=400, detail="No readable text could be extracted from this document.") final_content = extracted_text.strip() - # Upload binary to Cloudflare R2 or local directory scoped to user_id + resume_name = name.strip() if (name and name.strip()) else Path(file.filename).stem try: - file_key = object_storage.upload_file( - content_bytes=content_bytes, + resume_file_manager = get_resume_file_manager() + resume = resume_file_manager.reserve_and_upload( + + user_id=current_user.id, filename=file.filename, content_type=file.content_type, - user_id=current_user.id, + content_bytes=content_bytes, + resume_name=resume_name, + text_content=final_content, + max_resumes=MAX_RESUMES_PER_USER, + max_storage_bytes=MAX_USER_STORAGE_BYTES, ) + except ValueError as ve: + raise HTTPException(status_code=422, detail=str(ve)) except RuntimeError as re: - raise HTTPException(status_code=503, detail="Storage service temporarily unavailable.") - - storage_backend = "r2" if object_storage.is_configured else "local" - attachment = storage.create_attachment( - user_id=current_user.id, - storage_backend=storage_backend, - object_key=file_key, - original_filename=file.filename, - content_type=file.content_type, - size_bytes=len(content_bytes), - ) - - resume_name = name.strip() if (name and name.strip()) else Path(file.filename).stem - try: - resume = storage.add_resume( - name=resume_name, - content=final_content, - file_key=file_key, - user_id=current_user.id, - attachment_id=attachment.id, - ) + print(f"[RESUME UPLOAD ERROR] {re}") + raise HTTPException(status_code=503, detail=f"Storage service temporarily unavailable: {str(re)}") except Exception as e: - object_storage.delete_file(file_key, user_id=current_user.id) - storage.update_attachment_deletion_state(attachment.id, "failed", user_id=current_user.id) - raise HTTPException(status_code=500, detail="Failed to save resume record.") + print(f"[RESUME UPLOAD ERROR] {e}") + raise HTTPException(status_code=500, detail=f"Failed to save resume record: {str(e)}") resume.download_url = f"/api/resumes/{resume.id}/download" return resume @@ -456,6 +470,7 @@ def upload_resume_file( raise HTTPException(status_code=500, detail=f"Failed to process file: {str(e)}") + @app.post("/api/resumes/parse-file") def parse_resume_file( file: UploadFile = File(...), @@ -553,7 +568,7 @@ def download_resume_file( content=file_bytes, media_type=content_type, headers={ - "Content-Disposition": f'attachment; filename="{safe_filename}"', + "Content-Disposition": format_content_disposition(raw_filename), "Access-Control-Expose-Headers": "Content-Disposition, X-Fallback-Generated", }, ) @@ -563,12 +578,13 @@ def download_resume_file( if raw_text: fallback_bytes = generate_docx_from_text(title=resume.name or "Resume", text=raw_text) safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', resume.name or "resume").strip('_') or "resume" - fallback_filename = f"{safe_name}_generated.docx" + fallback_raw = f"{safe_name}_generated.docx" return Response( + content=fallback_bytes, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={ - "Content-Disposition": f'attachment; filename="{fallback_filename}"', + "Content-Disposition": format_content_disposition(fallback_raw), "X-Fallback-Generated": "true", "Access-Control-Expose-Headers": "Content-Disposition, X-Fallback-Generated", }, @@ -598,29 +614,18 @@ def update_resume( @app.delete("/api/resumes/{resume_id}") def delete_resume(resume_id: str, current_user: User = Depends(get_current_user)): - resume = storage.get_resume(resume_id, user_id=current_user.id) - if not resume: - raise HTTPException(status_code=404, detail="Resume not found.") + try: + resume_file_manager = get_resume_file_manager() + deleted = resume_file_manager.delete_resume(user_id=current_user.id, resume_id=resume_id) + + if not deleted: + raise HTTPException(status_code=404, detail="Resume not found.") + return {"success": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) - if resume.attachment_id or resume.file_key: - attachment = None - if resume.attachment_id: - attachment = storage.get_attachment(resume.attachment_id, user_id=current_user.id) - elif resume.file_key: - attachment = storage.get_attachment_by_key(resume.file_key, user_id=current_user.id) - - if attachment: - storage.update_attachment_deletion_state(attachment.id, "pending", user_id=current_user.id) - deleted = object_storage.delete_file(attachment.object_key, user_id=current_user.id) - if not deleted: - storage.update_attachment_deletion_state(attachment.id, "failed", user_id=current_user.id) - raise HTTPException(status_code=500, detail="Failed to delete associated storage file.") - storage.delete_attachment(attachment.id, user_id=current_user.id) - elif resume.file_key: - object_storage.delete_file(resume.file_key, user_id=current_user.id) - - storage.delete_resume(resume_id, user_id=current_user.id) - return {"success": True} class MatchResumesRequest(BaseModel): @@ -864,7 +869,31 @@ def test_ai(req: SettingsUpdate, current_user: User = Depends(get_current_user)) } +class SPAStaticFiles(StaticFiles): + """ + StaticFiles extension that serves index.html for Single Page Application + deep links (e.g. /verify-email, /reset-password) when the requested static + path does not exist and is not an API call. + """ + + async def get_response(self, path: str, scope): + try: + return await super().get_response(path, scope) + except (HTTPException, StarletteHTTPException) as ex: + normalized_path = path.replace("\\", "/").lstrip("/") + if ex.status_code == 404 and not normalized_path.startswith("api/") and normalized_path != "api": + index_path = Path(self.directory) / "index.html" + if index_path.is_file(): + return FileResponse(str(index_path)) + raise + + + + + + # --- Static frontend files mounting --- dist_path = (Path(__file__).resolve().parent.parent / "frontend" / "dist").resolve() if dist_path.exists() and dist_path.is_dir(): - app.mount("/", StaticFiles(directory=str(dist_path), html=True), name="frontend") + app.mount("/", SPAStaticFiles(directory=str(dist_path), html=True), name="frontend") + diff --git a/backend/migrate.py b/backend/migrate.py index 6949fb0..0a20d14 100644 --- a/backend/migrate.py +++ b/backend/migrate.py @@ -71,6 +71,14 @@ def get_unowned_count(table: str) -> int: "user_settings_count": user_settings_count, "global_settings_count": global_settings_count, "provider_profiles_count": provider_profiles_count, + "migrated_users": 0, + "migrated_applications": 0, + "migrated_resumes": 0, + "migrated_attachments": 0, + "migrated_user_settings": 0, + "migrated_global_settings": 0, + "migrated_provider_profiles": 0, + "errors": [], } if dry_run: @@ -78,219 +86,245 @@ def get_unowned_count(table: str) -> int: return summary # Apply migration to destination - dest_storage = StorageService(db_path=dest_target) - now = datetime.now().isoformat() + is_postgres = dest_target.startswith("postgres://") or dest_target.startswith("postgresql://") + if is_postgres: + dest_storage = StorageService(database_url=dest_target) + else: + dest_storage = StorageService(db_path=dest_target, force_sqlite=True) - with dest_storage._get_cursor() as dst_cursor: - # Create migration ledger - dst_cursor.execute(""" - CREATE TABLE IF NOT EXISTS _migration_ledger ( - source_id TEXT, - table_name TEXT, - target_id TEXT, - migrated_at TEXT, - PRIMARY KEY (table_name, source_id) - ) - """) + now = datetime.now().isoformat() - # 1. Migrate Users - try: - src_users = src_cursor.execute("SELECT * FROM users").fetchall() - for u in src_users: - # Check ledger - dst_cursor.execute( - dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), - ("users", u["id"]), - ) - if dst_cursor.fetchone(): - continue - pwd = u["hashed_password"] if "hashed_password" in u.keys() else (u["password_hash"] if "password_hash" in u.keys() else None) - ev = bool(u["email_verified"]) if "email_verified" in u.keys() else False - sv = int(u["session_version"]) if "session_version" in u.keys() else 1 - gsub = u["google_sub"] if "google_sub" in u.keys() else None - # Insert user - dst_cursor.execute( - dest_storage._format_sql( - "INSERT INTO users (id, email, hashed_password, name, avatar_url, provider, email_verified, session_version, google_sub, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") - ), - (u["id"], u["email"], pwd, u["name"], u["avatar_url"], u["provider"], ev, sv, gsub, u["created_at"], u["updated_at"]), - ) - dst_cursor.execute( - dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), - (u["id"], "users", u["id"], now), + try: + with dest_storage._get_cursor() as dst_cursor: + # Create migration ledger + dst_cursor.execute(""" + CREATE TABLE IF NOT EXISTS _migration_ledger ( + source_id TEXT, + table_name TEXT, + target_id TEXT, + migrated_at TEXT, + PRIMARY KEY (table_name, source_id) ) - except Exception as e: - print(f"[WARN] Error migrating users: {e}") + """) - # 2. Migrate Attachments - try: - src_attachments = src_cursor.execute("SELECT * FROM attachments").fetchall() - for a in src_attachments: - dst_cursor.execute( - dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), - ("attachments", a["id"]), - ) - if dst_cursor.fetchone(): - continue - target_user_id = a["user_id"] or map_unowned_to - dst_cursor.execute( - dest_storage._format_sql( - "INSERT INTO attachments (id, user_id, storage_backend, object_key, original_filename, content_type, size_bytes, deletion_state, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") - ), - (a["id"], target_user_id, a["storage_backend"], a["object_key"], a["original_filename"], a["content_type"], a["size_bytes"], a["deletion_state"], a["created_at"], a["updated_at"]), - ) - dst_cursor.execute( - dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), - (a["id"], "attachments", a["id"], now), - ) - except Exception as e: - print(f"[WARN] Error migrating attachments: {e}") + # 1. Migrate Users + try: + src_users = src_cursor.execute("SELECT * FROM users").fetchall() + for u in src_users: + # Check ledger + dst_cursor.execute( + dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), + ("users", u["id"]), + ) + if dst_cursor.fetchone(): + continue + pwd = u["hashed_password"] if "hashed_password" in u.keys() else (u["password_hash"] if "password_hash" in u.keys() else None) + ev = bool(u["email_verified"]) if "email_verified" in u.keys() else False + sv = int(u["session_version"]) if "session_version" in u.keys() else 1 + gsub = u["google_sub"] if "google_sub" in u.keys() else None + # Insert user + dst_cursor.execute( + dest_storage._format_sql( + "INSERT INTO users (id, email, hashed_password, name, avatar_url, provider, email_verified, session_version, google_sub, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") + ), + (u["id"], u["email"], pwd, u["name"], u["avatar_url"], u["provider"], ev, sv, gsub, u["created_at"], u["updated_at"]), + ) + dst_cursor.execute( + dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), + (u["id"], "users", u["id"], now), + ) + summary["migrated_users"] += 1 + except Exception as e: + summary["errors"].append(f"users: {e}") + print(f"[WARN] Error migrating users: {e}") - # 3. Migrate Resumes - try: - src_resumes = src_cursor.execute("SELECT * FROM resumes").fetchall() - for r in src_resumes: - dst_cursor.execute( - dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), - ("resumes", r["id"]), - ) - if dst_cursor.fetchone(): - continue - target_user_id = r["user_id"] or map_unowned_to - att_id = r["attachment_id"] if "attachment_id" in r.keys() else None - dst_cursor.execute( - dest_storage._format_sql( - "INSERT INTO resumes (id, user_id, name, content, file_key, attachment_id, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " - + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") - ), - (r["id"], target_user_id, r["name"], r["content"], r["file_key"], att_id, r["created_at"], r["updated_at"]), - ) - dst_cursor.execute( - dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), - (r["id"], "resumes", r["id"], now), - ) - except Exception as e: - print(f"[WARN] Error migrating resumes: {e}") + # 2. Migrate Attachments + try: + src_attachments = src_cursor.execute("SELECT * FROM attachments").fetchall() + for a in src_attachments: + dst_cursor.execute( + dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), + ("attachments", a["id"]), + ) + if dst_cursor.fetchone(): + continue + target_user_id = a["user_id"] or map_unowned_to + dst_cursor.execute( + dest_storage._format_sql( + "INSERT INTO attachments (id, user_id, storage_backend, object_key, original_filename, content_type, size_bytes, deletion_state, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") + ), + (a["id"], target_user_id, a["storage_backend"], a["object_key"], a["original_filename"], a["content_type"], a["size_bytes"], a["deletion_state"], a["created_at"], a["updated_at"]), + ) + dst_cursor.execute( + dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), + (a["id"], "attachments", a["id"], now), + ) + summary["migrated_attachments"] += 1 + except Exception as e: + summary["errors"].append(f"attachments: {e}") + print(f"[WARN] Error migrating attachments: {e}") - # 4. Migrate Applications - try: - src_apps = src_cursor.execute("SELECT * FROM applications").fetchall() - for app in src_apps: - dst_cursor.execute( - dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), - ("applications", app["id"]), - ) - if dst_cursor.fetchone(): - continue - target_user_id = app["user_id"] or map_unowned_to - date_added = app["date_added"] if "date_added" in app.keys() else app["created_at"] - app_date = app["application_date"] if "application_date" in app.keys() else None - dst_cursor.execute( - dest_storage._format_sql( - "INSERT INTO applications (id, user_id, company, role, status, location, salary, url, required_skills, ats_keywords, date_added, application_date, follow_up_date, notes, best_resume_id, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") - ), - ( - app["id"], - target_user_id, - app["company"], - app["role"], - app["status"], - app["location"], - app["salary"], - app["url"], - app["required_skills"], - app["ats_keywords"], - date_added, - app_date, - app["follow_up_date"], - app["notes"], - app["best_resume_id"], - app["created_at"], - app["updated_at"], - ), - ) - dst_cursor.execute( - dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), - (app["id"], "applications", app["id"], now), - ) - except Exception as e: - print(f"[WARN] Error migrating applications: {e}") + # 3. Migrate Resumes + try: + src_resumes = src_cursor.execute("SELECT * FROM resumes").fetchall() + for r in src_resumes: + dst_cursor.execute( + dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), + ("resumes", r["id"]), + ) + if dst_cursor.fetchone(): + continue + target_user_id = r["user_id"] or map_unowned_to + att_id = r["attachment_id"] if "attachment_id" in r.keys() else None + dst_cursor.execute( + dest_storage._format_sql( + "INSERT INTO resumes (id, user_id, name, content, file_key, attachment_id, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") + ), + (r["id"], target_user_id, r["name"], r["content"], r["file_key"], att_id, r["created_at"], r["updated_at"]), + ) + dst_cursor.execute( + dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), + (r["id"], "resumes", r["id"], now), + ) + summary["migrated_resumes"] += 1 + except Exception as e: + summary["errors"].append(f"resumes: {e}") + print(f"[WARN] Error migrating resumes: {e}") - # 5. Migrate User Settings - try: - src_user_settings = src_cursor.execute("SELECT * FROM user_settings").fetchall() - for s in src_user_settings: - dst_cursor.execute( - dest_storage._format_sql( - "INSERT INTO user_settings (user_id, key, value, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?) " - + ("ON CONFLICT (user_id, key) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(user_id, key) DO NOTHING") - ), - (s["user_id"], s["key"], s["value"], s["created_at"], s["updated_at"]), - ) - except Exception: - pass + # 4. Migrate Applications + try: + src_apps = src_cursor.execute("SELECT * FROM applications").fetchall() + for app in src_apps: + dst_cursor.execute( + dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), + ("applications", app["id"]), + ) + if dst_cursor.fetchone(): + continue + target_user_id = app["user_id"] or map_unowned_to + date_added = app["date_added"] if "date_added" in app.keys() else app["created_at"] + app_date = app["application_date"] if "application_date" in app.keys() else None + dst_cursor.execute( + dest_storage._format_sql( + "INSERT INTO applications (id, user_id, company, role, status, location, salary, url, required_skills, ats_keywords, date_added, application_date, follow_up_date, notes, best_resume_id, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") + ), + ( + app["id"], + target_user_id, + app["company"], + app["role"], + app["status"], + app["location"], + app["salary"], + app["url"], + app["required_skills"], + app["ats_keywords"], + date_added, + app_date, + app["follow_up_date"], + app["notes"], + app["best_resume_id"], + app["created_at"], + app["updated_at"], + ), + ) + dst_cursor.execute( + dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), + (app["id"], "applications", app["id"], now), + ) + summary["migrated_applications"] += 1 + except Exception as e: + summary["errors"].append(f"applications: {e}") + print(f"[WARN] Error migrating applications: {e}") - # 6. Migrate Global Settings - try: - src_settings = src_cursor.execute("SELECT * FROM settings").fetchall() - for gs in src_settings: - dst_cursor.execute( - dest_storage._format_sql( - "INSERT INTO settings (key, value) " - "VALUES (?, ?) " - + ("ON CONFLICT (key) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(key) DO NOTHING") - ), - (gs["key"], gs["value"]), - ) - except Exception: - pass + # 5. Migrate User Settings + try: + src_user_settings = src_cursor.execute("SELECT * FROM user_settings").fetchall() + for s in src_user_settings: + dst_cursor.execute( + dest_storage._format_sql( + "INSERT INTO user_settings (user_id, key, value) " + "VALUES (?, ?, ?) " + + ("ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value" if dest_storage.is_postgres else "ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value") + ), + (s["user_id"], s["key"], s["value"]), + ) + summary["migrated_user_settings"] += 1 + except Exception as e: + summary["errors"].append(f"user_settings: {e}") + print(f"[WARN] Error migrating user_settings: {e}") - # 7. Migrate Provider Profiles - try: - src_profiles = src_cursor.execute("SELECT * FROM provider_profiles").fetchall() - for p in src_profiles: - dst_cursor.execute( - dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), - ("provider_profiles", p["id"]), - ) - if dst_cursor.fetchone(): - continue - target_user_id = p["user_id"] or map_unowned_to - dst_cursor.execute( - dest_storage._format_sql( - "INSERT INTO provider_profiles (id, user_id, name, api_base_url, model_name, api_key_encrypted, key_suffix, is_active, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") - ), - ( - p["id"], - target_user_id, - p["name"], - p["api_base_url"], - p["model_name"], - p["api_key_encrypted"], - p["key_suffix"], - p["is_active"], - p["created_at"], - p["updated_at"], - ), - ) - dst_cursor.execute( - dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), - (p["id"], "provider_profiles", p["id"], now), - ) - except Exception: - pass + # 6. Migrate Global Settings + try: + src_settings = src_cursor.execute("SELECT * FROM settings").fetchall() + for gs in src_settings: + dst_cursor.execute( + dest_storage._format_sql( + "INSERT INTO settings (key, value) " + "VALUES (?, ?) " + + ("ON CONFLICT (key) DO UPDATE SET value = excluded.value" if dest_storage.is_postgres else "ON CONFLICT(key) DO UPDATE SET value = excluded.value") + ), + (gs["key"], gs["value"]), + ) + summary["migrated_global_settings"] += 1 + except Exception as e: + summary["errors"].append(f"settings: {e}") + print(f"[WARN] Error migrating settings: {e}") + + # 7. Migrate Provider Profiles + try: + src_profiles = src_cursor.execute("SELECT * FROM provider_profiles").fetchall() + for p in src_profiles: + dst_cursor.execute( + dest_storage._format_sql("SELECT 1 FROM _migration_ledger WHERE table_name = ? AND source_id = ?"), + ("provider_profiles", p["id"]), + ) + if dst_cursor.fetchone(): + continue + target_user_id = p["user_id"] or map_unowned_to + dst_cursor.execute( + dest_storage._format_sql( + "INSERT INTO provider_profiles (id, user_id, name, api_base_url, model_name, api_key_encrypted, key_suffix, is_active, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ("ON CONFLICT (id) DO NOTHING" if dest_storage.is_postgres else "ON CONFLICT(id) DO NOTHING") + ), + ( + p["id"], + target_user_id, + p["name"], + p["api_base_url"], + p["model_name"], + p["api_key_encrypted"], + p["key_suffix"], + p["is_active"], + p["created_at"], + p["updated_at"], + ), + ) + dst_cursor.execute( + dest_storage._format_sql("INSERT INTO _migration_ledger VALUES (?, ?, ?, ?)"), + (p["id"], "provider_profiles", p["id"], now), + ) + summary["migrated_provider_profiles"] += 1 + except Exception as e: + summary["errors"].append(f"provider_profiles: {e}") + print(f"[WARN] Error migrating provider_profiles: {e}") + + finally: + dest_storage.close() + src_conn.close() + + if summary["errors"]: + summary["success"] = False - src_conn.close() return summary @@ -316,13 +350,17 @@ def main(): print(f"Mode: {'DRY RUN' if res['dry_run'] else 'APPLIED'}") print(f"Source: {res['source']}") print(f"Destination: {res['destination']}") - print(f"Users: {res['users_count']}") - print(f"Applications: {res['applications_count']} (unowned: {res['unowned_applications']})") - print(f"Resumes: {res['resumes_count']} (unowned: {res['unowned_resumes']})") - print(f"Attachments: {res['attachments_count']}") - print(f"User Settings: {res['user_settings_count']}") - print(f"Global Settings: {res['global_settings_count']}") - print(f"Provider Profiles: {res['provider_profiles_count']}") + print(f"Users: {res['users_count']} (migrated: {res.get('migrated_users', 0)})") + print(f"Applications: {res['applications_count']} (migrated: {res.get('migrated_applications', 0)}, unowned: {res['unowned_applications']})") + print(f"Resumes: {res['resumes_count']} (migrated: {res.get('migrated_resumes', 0)}, unowned: {res['unowned_resumes']})") + print(f"Attachments: {res['attachments_count']} (migrated: {res.get('migrated_attachments', 0)})") + print(f"User Settings: {res['user_settings_count']} (migrated: {res.get('migrated_user_settings', 0)})") + print(f"Global Settings: {res['global_settings_count']} (migrated: {res.get('migrated_global_settings', 0)})") + print(f"Provider Profiles: {res['provider_profiles_count']} (migrated: {res.get('migrated_provider_profiles', 0)})") + if res.get("errors"): + print("Errors encountered:") + for err in res["errors"]: + print(f" - {err}") except Exception as e: print(f"Migration error: {e}", file=sys.stderr) sys.exit(1) diff --git a/backend/models.py b/backend/models.py index 294032e..7f698ec 100644 --- a/backend/models.py +++ b/backend/models.py @@ -321,6 +321,7 @@ class AuthResponse(BaseModel): user: Optional[User] = None message: Optional[str] = None is_new_user: bool = False + requires_verification: bool = False class CoverLetterDocxRequest(BaseModel): diff --git a/backend/repair_data.py b/backend/repair_data.py new file mode 100644 index 0000000..6ffa877 --- /dev/null +++ b/backend/repair_data.py @@ -0,0 +1,164 @@ +import argparse +import os +import sys +from typing import Optional, Dict, Any + +from backend.storage import StorageService +from backend.services.object_storage import ObjectStorageService + + +def repair_database( + storage: StorageService, + object_storage: Optional[ObjectStorageService] = None, + dry_run: bool = True, +) -> Dict[str, Any]: + """ + Scans and repairs data anomalies: + 1. Applications with NULL or empty status -> 'Wishlist' + 2. Applications with NULL required_skills or ats_keywords -> '[]' + 3. User settings with invalid default_follow_up_days -> 7 + 4. Orphaned active attachments not referenced by any resume -> cleaned up + """ + report = { + "corrupt_applications": 0, + "corrupt_settings": 0, + "orphaned_attachments": 0, + "applied": not dry_run, + } + + # 1. Inspect applications + with storage._get_cursor() as cursor: + cursor.execute( + storage._format_sql( + """ + SELECT id, status, required_skills, ats_keywords + FROM applications + WHERE status IS NULL OR status = '' OR required_skills IS NULL OR ats_keywords IS NULL + """ + ) + ) + corrupt_apps = cursor.fetchall() + report["corrupt_applications"] = len(corrupt_apps) + + if not dry_run and corrupt_apps: + for app_row in corrupt_apps: + app_id = app_row["id"] if isinstance(app_row, dict) else app_row[0] + cursor.execute( + storage._format_sql( + """ + UPDATE applications + SET status = COALESCE(NULLIF(status, ''), 'Wishlist'), + required_skills = COALESCE(required_skills, '[]'), + ats_keywords = COALESCE(ats_keywords, '[]') + WHERE id = ? + """ + ), + (app_id,), + ) + + # 2. Inspect user settings + with storage._get_cursor() as cursor: + cursor.execute( + storage._format_sql( + """ + SELECT user_id, key, value + FROM user_settings + WHERE key = 'default_follow_up_days' + """ + ) + ) + settings_rows = cursor.fetchall() + corrupt_user_settings = [] + for s_row in settings_rows: + uid = s_row["user_id"] if isinstance(s_row, dict) else s_row[0] + val = s_row["value"] if isinstance(s_row, dict) else s_row[2] + try: + days = int(val) + if days < 1 or days > 90: + corrupt_user_settings.append(uid) + except (ValueError, TypeError): + corrupt_user_settings.append(uid) + + # Global settings table + cursor.execute("SELECT key, value FROM settings WHERE key = 'default_follow_up_days'") + global_row = cursor.fetchone() + global_corrupt = False + if global_row: + gval = global_row["value"] if isinstance(global_row, dict) else global_row[1] + try: + gdays = int(gval) + if gdays < 1 or gdays > 90: + global_corrupt = True + except (ValueError, TypeError): + global_corrupt = True + + report["corrupt_settings"] = len(corrupt_user_settings) + (1 if global_corrupt else 0) + + if not dry_run: + for uid in corrupt_user_settings: + cursor.execute( + storage._format_sql( + "UPDATE user_settings SET value = '7' WHERE user_id = ? AND key = 'default_follow_up_days'" + ), + (uid,), + ) + if global_corrupt: + cursor.execute("UPDATE settings SET value = '7' WHERE key = 'default_follow_up_days'") + + # 3. Inspect orphaned attachments + with storage._get_cursor() as cursor: + cursor.execute( + storage._format_sql( + """ + SELECT id, user_id, object_key + FROM attachments + WHERE deletion_state = 'active' + AND id NOT IN (SELECT attachment_id FROM resumes WHERE attachment_id IS NOT NULL) + """ + ) + ) + orphans = cursor.fetchall() + report["orphaned_attachments"] = len(orphans) + + if not dry_run and orphans: + for att_row in orphans: + att_id = att_row["id"] if isinstance(att_row, dict) else att_row[0] + user_id = att_row["user_id"] if isinstance(att_row, dict) else att_row[1] + object_key = att_row["object_key"] if isinstance(att_row, dict) else att_row[2] + + if object_storage and object_key: + try: + object_storage.delete_file(object_key, user_id=user_id) + except Exception: + pass + + cursor.execute( + storage._format_sql( + "UPDATE attachments SET deletion_state = 'orphaned_cleaned' WHERE id = ?" + ), + (att_id,), + ) + + return report + + +def main_cli(): + parser = argparse.ArgumentParser(description="Repair database inconsistencies and clean orphaned attachments.") + parser.add_argument("--apply", action="store_true", help="Apply fixes directly to the database (default is dry-run).") + parser.add_argument("--db-path", default=None, help="Optional path to local SQLite database.") + args = parser.parse_args() + + db_path = args.db_path or os.getenv("JOB_HELPER_DB", "data/tracker.db") + storage = StorageService(db_path=db_path) + object_storage = ObjectStorageService() + + dry_run = not args.apply + print(f"[{'DRY RUN' if dry_run else 'APPLYING REPAIRS'}] Starting database audit...") + result = repair_database(storage, object_storage, dry_run=dry_run) + print("Audit results:", result) + if dry_run: + print("Run with --apply to commit changes to the database.") + + +if __name__ == "__main__": + main_cli() diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 947b018..96e1215 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -1,9 +1,10 @@ import hashlib -import json import os import re import secrets -import urllib.request +import requests +from google.auth.transport.requests import Request as GoogleRequest +from google.oauth2 import id_token from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional @@ -29,7 +30,7 @@ hash_password, verify_password, ) -from backend.services.email_service import EmailService, SMTPEmailTransport +from backend.services.email_service import EmailService, SMTPEmailTransport, BrevoEmailTransport router = APIRouter(prefix="/api/auth", tags=["auth"]) oauth2_scheme = HTTPBearer(auto_error=False) @@ -37,6 +38,13 @@ def _create_email_service() -> EmailService: cfg = load_config() + if cfg.brevo_api_key: + transport = BrevoEmailTransport( + api_key=cfg.brevo_api_key, + sender_email=cfg.brevo_sender_email or "noreply@jobhelper.guru", + sender_name=cfg.brevo_sender_name or "JobHelperGuru", + ) + return EmailService(transport=transport) if cfg.smtp_host: transport = SMTPEmailTransport( host=cfg.smtp_host, @@ -49,6 +57,7 @@ def _create_email_service() -> EmailService: return EmailService() + # Email service instance (mockable in tests) email_service = _create_email_service() @@ -133,11 +142,39 @@ class GoogleAuthRequest(BaseModel): def verify_google_id_token(token: str) -> Dict[str, Any]: - """Validates Google ID token against tokeninfo endpoint and returns verified claims.""" - url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" - req_obj = urllib.request.Request(url, headers={"User-Agent": "JobHelperGuru/1.0"}) - with urllib.request.urlopen(req_obj, timeout=5) as response: - return json.loads(response.read().decode("utf-8")) + """Verify signature, issuer, expiry and this application's audience.""" + audience = load_config().google_client_id + if not audience: + raise ValueError("Google sign-in is not configured") + with requests.Session() as session: + session.trust_env = False + request = GoogleRequest(session=session) + def bounded_request(url, method="GET", body=None, headers=None, **kwargs): + return request(url, method=method, body=body, headers=headers, timeout=5) + return id_token.verify_oauth2_token(token, bounded_request, audience=audience) + + +def google_is_authoritative(claims: Dict[str, Any]) -> bool: + email = str(claims.get("email", "")).lower() + return claims.get("email_verified") is True and ( + email.endswith("@gmail.com") or bool(claims.get("hd")) + ) + + +def google_verification_pending(user: User, is_new: bool = False) -> AuthResponse: + if is_new: + token = secrets.token_urlsafe(32) + get_storage().create_auth_token( + user.id, hashlib.sha256(token.encode()).hexdigest(), "verify_email", + (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(), + ) + try: + email_service.send_verification(user.email, token, load_config().app_url) + except Exception: + # Tokens and transport credentials must never enter logs. + print("[WARN] Google account verification email delivery failed") + return AuthResponse(user=user, is_new_user=is_new, requires_verification=True, + message="Please verify your email before signing in. You can request a new link.") @router.post("/register", response_model=AuthResponse) @@ -199,10 +236,12 @@ def register(req: UserRegisterRequest, request: Request): expires_at=expires_at, ) + print(f"\n[AUTH] Generated verification link for {user.email}:\n{cfg.app_url}/verify-email?token={verify_token}\n") try: email_service.send_verification(recipient=user.email, token=verify_token, app_url=cfg.app_url) + print(f"[AUTH] Successfully dispatched verification email to {user.email}") except Exception as e: - print(f"[WARN] Failed to send verification email: {e}") + print(f"[AUTH ERROR] Failed to send verification email: {e}") if cfg.require_email_verification: return AuthResponse( @@ -367,10 +406,15 @@ def request_password_reset(req: PasswordResetRequest, request: Request): expires_at = (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat() storage.invalidate_prior_auth_tokens(raw_user["id"], "reset_password") storage.create_auth_token(raw_user["id"], token_hash, "reset_password", expires_at) + + print(f"\n[AUTH] Generated password reset link for {email}:\n{cfg.app_url}/reset-password?token={reset_token}\n") try: email_service.send_password_reset(recipient=email, token=reset_token, app_url=cfg.app_url) + print(f"[AUTH] Successfully dispatched password reset email to {email}") except Exception as e: - print(f"[WARN] Failed to send password reset email: {e}") + print(f"[AUTH ERROR] Failed to send password reset email: {e}") + else: + print(f"\n[AUTH] Password reset requested for '{email}', but no user account exists with this email address in the database.\n") # Generic response to prevent account enumeration return {"message": "If an account exists with that email, a password reset link has been sent."} @@ -399,25 +443,32 @@ def confirm_password_reset(req: PasswordResetConfirm): @router.post("/google", response_model=AuthResponse) -def google_auth(req: GoogleAuthRequest): +def google_auth(req: GoogleAuthRequest, request: Request): token = req.credential.strip() if not token: raise HTTPException(status_code=400, detail="Google credential token is missing.") + storage = get_storage() + cfg = load_config() + client_ip = get_client_ip(request, trust_proxy_headers=getattr(cfg, "trust_proxy_headers", False)) + limiter = RateLimiter(storage) + allowed, _, retry_after = limiter.check("google_login", client_ip, limit=10, window_seconds=60) + if not allowed: + raise HTTPException( + status_code=429, + detail="Too many Google login attempts. Please try again later.", + headers={"Retry-After": str(int(retry_after))}, + ) + try: token_data = verify_google_id_token(token) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Google token verification failed: {e}") + except Exception: + raise HTTPException(status_code=401, detail="Google token verification failed. Please sign in again.") cfg = load_config() - expected_aud = ( - cfg.google_client_id - or os.getenv("GOOGLE_CLIENT_ID") - or os.getenv("VITE_GOOGLE_CLIENT_ID") - or "999060759573-45b5m9cn9v7g6birnj9d8j65cqn72mfq.apps.googleusercontent.com" - ).strip() - token_aud = token_data.get("aud", "").strip() - if expected_aud and token_aud and token_aud != expected_aud: + expected_aud = (cfg.google_client_id or "").strip() + token_aud = token_data.get("aud") + if not expected_aud or token_aud != expected_aud: raise HTTPException( status_code=401, detail="Google token audience mismatch. Token was not issued for this application.", @@ -448,6 +499,8 @@ def google_auth(req: GoogleAuthRequest): existing_by_sub = storage.get_user_by_google_sub(sub) if existing_by_sub: user = storage.get_user_by_id(existing_by_sub["id"]) + if not user.email_verified: + return google_verification_pending(user) access_token = create_access_token(user.id, user.email, user.name, session_version=user.session_version) return AuthResponse(token=access_token, user=user, is_new_user=False) @@ -462,14 +515,18 @@ def google_auth(req: GoogleAuthRequest): detail="This email address is already bound to a different Google account.", ) + if not google_is_authoritative(token_data): + raise HTTPException(status_code=409, detail="Use password sign-in or password recovery for this account. Google cannot confirm current ownership of this email address.") + # Pre-account hijacking protection: if existing account was unverified, revoke password and sessions was_unverified = not bool(existing_by_email.get("email_verified")) - storage.link_google_identity( - user_id=existing_by_email["id"], - google_sub=sub, - clear_password=was_unverified, - verify_email=True, - ) + try: + storage.link_google_identity( + user_id=existing_by_email["id"], google_sub=sub, + clear_password=was_unverified, verify_email=True, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) user = storage.get_user_by_id(existing_by_email["id"]) access_token = create_access_token(user.id, user.email, user.name, session_version=user.session_version) return AuthResponse(token=access_token, user=user, is_new_user=False) @@ -481,10 +538,12 @@ def google_auth(req: GoogleAuthRequest): name=name, avatar_url=picture, provider="google", - email_verified=True, + email_verified=google_is_authoritative(token_data), session_version=1, google_sub=sub, ) + if not user.email_verified: + return google_verification_pending(user, is_new=True) access_token = create_access_token(user.id, user.email, user.name, session_version=user.session_version) return AuthResponse(token=access_token, user=user, is_new_user=True) @@ -497,12 +556,7 @@ def me(current_user: User = Depends(get_current_user)): @router.get("/config") def get_auth_config(): cfg = load_config() - client_id = ( - cfg.google_client_id - or os.getenv("GOOGLE_CLIENT_ID") - or os.getenv("VITE_GOOGLE_CLIENT_ID") - or "999060759573-45b5m9cn9v7g6birnj9d8j65cqn72mfq.apps.googleusercontent.com" - ).strip() + client_id = (cfg.google_client_id or "").strip() return { "google_client_id": client_id, "require_email_verification": cfg.require_email_verification, diff --git a/backend/services/ai_engine.py b/backend/services/ai_engine.py index 3d1c73d..06b9bfe 100644 --- a/backend/services/ai_engine.py +++ b/backend/services/ai_engine.py @@ -178,9 +178,13 @@ def _get_client(self) -> Optional[OpenAI]: local_ai_hosts=set(cfg.local_ai_hosts), is_ai_request=True, ) - validate_destination_url(self.api_base_url, policy) + validate_destination_url(self.api_base_url, policy, resolve_addresses=False) try: - return OpenAI(base_url=self.api_base_url, api_key=self.api_key, timeout=30.0) + import httpx2 + from backend.services.pinned_transport import PinnedTransport + http_client = httpx2.Client(transport=PinnedTransport(policy), follow_redirects=False, trust_env=False) + return OpenAI(base_url=self.api_base_url, api_key=self.api_key, timeout=30.0, + max_retries=0, http_client=http_client) except Exception as e: raise RuntimeError(f"Could not initialize AI Client for {self.model_name}: {str(e)}") from e diff --git a/backend/services/email_service.py b/backend/services/email_service.py index 832fd83..159dad0 100644 --- a/backend/services/email_service.py +++ b/backend/services/email_service.py @@ -77,7 +77,65 @@ def send(self, recipient: str, subject: str, text_body: str) -> None: server.send_message(msg) +class BrevoEmailTransport(EmailTransport): + """Brevo (formerly Sendinblue) v3 API email delivery transport.""" + + def __init__( + self, + api_key: str, + sender_email: str = "noreply@jobhelper.guru", + sender_name: str = "JobHelperGuru", + endpoint: str = "https://api.brevo.com/v3/smtp/email", + timeout: float = 10.0, + ): + self.api_key = api_key + self.sender_email = sender_email + self.sender_name = sender_name + self.endpoint = endpoint + self.timeout = timeout + + def send(self, recipient: str, subject: str, text_body: str) -> None: + import json + import urllib.request + import urllib.error + + if self.api_key.startswith("xsmtpsib-"): + raise ValueError( + "Configured BREVO_API_KEY starts with 'xsmtpsib-', which is an SMTP password, not a REST API key. " + "The Brevo REST API requires an API key starting with 'xkeysib-'. " + "In your Brevo dashboard, go to: Profile (top right) -> SMTP & API -> API Keys tab -> Generate a new API key." + ) + + payload = { + "sender": {"name": self.sender_name, "email": self.sender_email}, + "to": [{"email": recipient}], + "subject": subject, + "textContent": text_body, + } + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + self.endpoint, + data=data, + headers={ + "api-key": self.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + if resp.status not in (200, 201, 202): + raise RuntimeError(f"Brevo API returned unexpected status {resp.status}") + except urllib.error.HTTPError as e: + err_msg = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Brevo API error ({e.code}): {err_msg}") + except Exception as e: + raise RuntimeError(f"Failed to deliver email via Brevo: {e}") + + class EmailService: + """Service for dispatching transactional authentication emails.""" def __init__(self, transport: Optional[EmailTransport] = None): diff --git a/backend/services/object_storage.py b/backend/services/object_storage.py index fbc0a73..89e9cd6 100644 --- a/backend/services/object_storage.py +++ b/backend/services/object_storage.py @@ -5,7 +5,7 @@ from typing import Optional, Union from dotenv import load_dotenv -load_dotenv() +load_dotenv(override=True) try: import boto3 @@ -48,8 +48,11 @@ def __init__( self.is_configured = False self._upload_dir = Path(upload_dir or "data/uploads").resolve() + backend_choice = (os.getenv("STORAGE_BACKEND") or "").strip().lower() + if ( - BOTO3_AVAILABLE + backend_choice != "local" + and BOTO3_AVAILABLE and self.access_key_id and self.secret_access_key and self.endpoint_url @@ -203,11 +206,15 @@ def delete_file(self, object_key: str, user_id: Optional[str] = None) -> bool: return False safe_path = self._resolve_safe_path(object_key, user_id=user_id) - if safe_path and safe_path.is_file(): - try: - safe_path.unlink() + if safe_path: + if not safe_path.exists(): return True - except Exception: - return False + if safe_path.is_file(): + try: + safe_path.unlink() + return True + except Exception: + return False return False + diff --git a/backend/services/outbound_http.py b/backend/services/outbound_http.py index ee4338c..2c24acf 100644 --- a/backend/services/outbound_http.py +++ b/backend/services/outbound_http.py @@ -3,7 +3,11 @@ import urllib.parse from dataclasses import dataclass, field from typing import Optional, Set, Tuple, Any, Dict -import httpx +import httpx2 as httpx +import time +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import replace class SSRFBlockedError(ValueError): @@ -23,6 +27,7 @@ def is_safe_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: or ip.is_multicast or ip.is_reserved or ip.is_unspecified + or not ip.is_global ): return False @@ -53,7 +58,7 @@ class OutboundPolicy: max_body_bytes: int = 2 * 1024 * 1024 # 2 MiB -def validate_destination_url(url: str, policy: OutboundPolicy) -> Tuple[str, str]: +def validate_destination_url(url: str, policy: OutboundPolicy, *, resolve_addresses: bool = True) -> Tuple[str, str]: """ Enforces scheme, hostname, port, and resolved-address rules. Returns (clean_url, hostname). @@ -77,45 +82,39 @@ def validate_destination_url(url: str, policy: OutboundPolicy) -> Tuple[str, str port = parts.port or (443 if scheme == "https" else 80) - # 1. AI Request validation - if policy.is_ai_request: - if policy.mode == "local" and hostname in policy.local_ai_hosts: - # Local mode permits local AI endpoints on loopback - return clean, hostname - + local = policy.is_ai_request and policy.mode == "local" and hostname in policy.local_ai_hosts + if not local and port != (443 if scheme == "https" else 80): + raise SSRFBlockedError("Destination port is not permitted") + if policy.is_ai_request and not local: if scheme != "https": raise SSRFBlockedError("Cloud AI provider endpoints must use HTTPS.") - - if policy.allowed_provider_hosts and hostname not in policy.allowed_provider_hosts: + if not policy.allowed_provider_hosts or hostname not in policy.allowed_provider_hosts: raise SSRFBlockedError(f"Host '{hostname}' is not in allowed AI provider destinations.") - - return clean, hostname - - # 2. General Scraping / Web Request validation - # Check if hostname is direct IP literal try: - direct_ip = ipaddress.ip_address(hostname) - if not is_safe_ip(direct_ip): - raise SSRFBlockedError(f"Destination IP '{hostname}' is private or disallowed.") - return clean, hostname + literal = ipaddress.ip_address(hostname) except ValueError: - pass + literal = None + if literal is not None and not (literal.is_loopback if local else is_safe_ip(literal)): + raise SSRFBlockedError("Destination IP is private or disallowed") + if resolve_addresses: + from backend.services.pinned_transport import resolve_public_addresses + resolve_public_addresses(hostname, port, allow_loopback=local) + return clean, hostname - # Resolve DNS and check all returned IP addresses - try: - resolved = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) - for entry in resolved: - sockaddr = entry[4] - ip_str = sockaddr[0] - ip_obj = ipaddress.ip_address(ip_str) - if not is_safe_ip(ip_obj): - raise SSRFBlockedError(f"Host '{hostname}' resolves to private or disallowed address: {ip_str}") - except socket.gaierror as e: - raise SSRFBlockedError(f"Failed to resolve host '{hostname}': {e}") - return clean, hostname +_operation_deadline = ContextVar("outbound_operation_deadline", default=None) +@contextmanager +def outbound_budget(seconds=30): + existing = _operation_deadline.get() + deadline = min(existing, time.monotonic() + seconds) if existing else time.monotonic() + seconds + token = _operation_deadline.set(deadline) + try: + yield + finally: + _operation_deadline.reset(token) + class SafeHttpClient: """HTTP client enforcing outbound request policies, redirect bounds, and body limits.""" @@ -133,50 +132,28 @@ def request( headers: Optional[Dict[str, str]] = None, **kwargs, ) -> httpx.Response: + from backend.services.pinned_transport import PinnedTransport, remaining active_policy = policy or self.default_policy - limit_bytes = max_bytes or active_policy.max_body_bytes - + if max_bytes is not None: + active_policy = replace(active_policy, max_body_bytes=min(max_bytes, active_policy.max_body_bytes)) + deadline = _operation_deadline.get() or time.monotonic() + min(timeout, 30) current_url = url req_headers = dict(headers or {}) - hops = 0 - - with httpx.Client(follow_redirects=False, timeout=timeout) as client: - while hops <= active_policy.max_redirects: - clean_url, host = validate_destination_url(current_url, active_policy) - - resp = client.request( - method, - clean_url, - headers=req_headers, - **kwargs, - ) - - # Check for redirect status codes - if resp.is_redirect and "location" in resp.headers: - hops += 1 - location = resp.headers["location"].strip() - next_url = urllib.parse.urljoin(clean_url, location) - - # Strip Authorization if redirecting across different hostnames or downgrading to HTTP - next_parts = urllib.parse.urlsplit(next_url) - curr_parts = urllib.parse.urlsplit(clean_url) - - if next_parts.hostname != curr_parts.hostname or next_parts.scheme != curr_parts.scheme: - req_headers.pop("Authorization", None) - req_headers.pop("authorization", None) - - current_url = next_url - continue - - # Enforce response body size limit - content = resp.content - if len(content) > limit_bytes: - raise ValueError(f"Response body exceeded limit of {limit_bytes} bytes.") - - return resp - - raise SSRFBlockedError(f"Too many redirects (exceeded limit of {active_policy.max_redirects}).") - + with httpx.Client(transport=PinnedTransport(active_policy), follow_redirects=False, trust_env=False) as client: + for hops in range(active_policy.max_redirects + 1): + clean_url, _ = validate_destination_url(current_url, active_policy, resolve_addresses=False) + resp = client.request(method, clean_url, headers=req_headers, timeout=remaining(deadline), + extensions={"jhg_deadline": deadline}, **kwargs) + if not resp.is_redirect or "location" not in resp.headers: + return resp + if hops == active_policy.max_redirects: + raise SSRFBlockedError("Too many redirects") + next_url = urllib.parse.urljoin(clean_url, resp.headers["location"]) + previous = urllib.parse.urlsplit(clean_url) + following = urllib.parse.urlsplit(next_url) + if (previous.scheme, previous.netloc) != (following.scheme, following.netloc): + req_headers = {k: v for k, v in req_headers.items() if k.lower() not in {"authorization", "cookie"}} + current_url = next_url def get(self, url: str, **kwargs) -> httpx.Response: return self.request("GET", url, **kwargs) diff --git a/backend/services/pinned_transport.py b/backend/services/pinned_transport.py new file mode 100644 index 0000000..55a45da --- /dev/null +++ b/backend/services/pinned_transport.py @@ -0,0 +1,145 @@ +"""Connection enforcement for the pinned httpx2/httpcore2 transport versions. + +DNS results are checked at the dial boundary. TLS still sees the original host. +The pool adapter is version-pinned and covered by transport integration tests. +""" +import ipaddress +import socket +import time +import zlib +from concurrent.futures import ThreadPoolExecutor +from contextvars import ContextVar +from threading import BoundedSemaphore + +import httpcore2 +import httpx2 + +from backend.services.outbound_http import OutboundPolicy, SSRFBlockedError, is_safe_ip_string, validate_destination_url + +_deadline = ContextVar('outbound_deadline', default=None) +_dns_workers = ThreadPoolExecutor(max_workers=8, thread_name_prefix='outbound-dns') +_dns_slots = BoundedSemaphore(16) + + +def remaining(deadline, timeout=None): + budget = deadline - time.monotonic() + if budget <= 0: + raise TimeoutError('Outbound request deadline exceeded') + return min(budget, timeout) if timeout is not None else budget + + +def resolve_public_addresses(host, port, *, allow_loopback=False, deadline=None): + deadline = deadline or time.monotonic() + 5 + try: + addresses = (str(ipaddress.ip_address(host)),) + except ValueError: + if not _dns_slots.acquire(blocking=False): + raise TimeoutError('DNS resolver capacity exhausted') + future = _dns_workers.submit(socket.getaddrinfo, host, port, type=socket.SOCK_STREAM) + future.add_done_callback(lambda _: _dns_slots.release()) + rows = future.result(timeout=remaining(deadline, 5)) + addresses = tuple(dict.fromkeys(row[4][0] for row in rows)) + if not addresses: + raise SSRFBlockedError('DNS returned no destination addresses') + for address in addresses: + allowed = ipaddress.ip_address(address).is_loopback if allow_loopback else is_safe_ip_string(address) + if not allowed: + raise SSRFBlockedError('Destination resolves to a private or disallowed address') + return addresses + + +class DeadlineStream(httpcore2.NetworkStream): + def __init__(self, stream, deadline): + self.stream, self.deadline = stream, deadline + + def read(self, max_bytes, timeout=None): + data = self.stream.read(min(max_bytes, 65536), timeout=remaining(self.deadline, timeout)) + remaining(self.deadline) + return data + + def write(self, buffer, timeout=None): + self.stream.write(buffer, timeout=remaining(self.deadline, timeout)) + remaining(self.deadline) + + def start_tls(self, ssl_context, server_hostname=None, timeout=None): + stream = self.stream.start_tls(ssl_context=ssl_context, server_hostname=server_hostname, + timeout=remaining(self.deadline, timeout)) + return DeadlineStream(stream, self.deadline) + + def close(self): + self.stream.close() + + def get_extra_info(self, info): + return self.stream.get_extra_info(info) + + +class PinnedNetworkBackend(httpcore2.SyncBackend): + def __init__(self, policy): + self.policy = policy + + def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): + deadline = _deadline.get() or time.monotonic() + min(timeout or 30, 30) + local = self.policy.mode == 'local' and self.policy.is_ai_request and host in self.policy.local_ai_hosts + addresses = resolve_public_addresses(host, port, allow_loopback=local, deadline=deadline) + last_error = None + for address in addresses: + try: + stream = super().connect_tcp(host=address, port=port, timeout=remaining(deadline, timeout), + local_address=local_address, socket_options=socket_options) + return DeadlineStream(stream, deadline) + except (OSError, httpcore2.ConnectError) as exc: + last_error = exc + raise last_error + + +class PinnedTransport(httpx2.HTTPTransport): + def __init__(self, policy: OutboundPolicy): + # HTTPTransport.handle_request converts between HTTPX and HTTP Core models. + # Supply its pool with our connection backend; never create an ambient proxy. + self.policy = policy + self._pool = httpcore2.ConnectionPool( + network_backend=PinnedNetworkBackend(policy), max_connections=8, + max_keepalive_connections=0, retries=0, + ) + + def handle_request(self, request): + deadline = request.extensions.get('jhg_deadline', time.monotonic() + 30) + remaining(deadline) + validate_destination_url(str(request.url), self.policy, resolve_addresses=False) + request.headers['accept-encoding'] = 'identity' + token = _deadline.set(deadline) + try: + response = super().handle_request(request) + try: + if response.is_redirect: + if self.policy.is_ai_request: + raise SSRFBlockedError('AI provider redirects are not permitted') + return httpx2.Response(response.status_code, headers=response.headers, content=b'') + encoding = response.headers.get('content-encoding', 'identity').lower() + if encoding not in ('identity', 'gzip', 'deflate'): + raise ValueError('Unsupported response content encoding') + decoder = zlib.decompressobj(31 if encoding == 'gzip' else 15) if encoding != 'identity' else None + body = bytearray() + encoded = 0 + limit = self.policy.max_body_bytes + for chunk in response.iter_raw(): + remaining(deadline) + encoded += len(chunk) + if encoded > limit: + raise ValueError('Encoded response exceeds limit') + data = decoder.decompress(chunk, limit - len(body) + 1) if decoder else chunk + body.extend(data) + if len(body) > limit or (decoder and decoder.unconsumed_tail): + raise ValueError('Decoded response exceeds limit') + if decoder and decoder.unused_data: + raise ValueError('Multiple compressed response streams are not supported') + if decoder and not decoder.eof: + raise ValueError('Incomplete compressed response') + remaining(deadline) + headers = [(key, val) for key, val in response.headers.multi_items() + if key.lower() not in {'content-encoding', 'content-length', 'transfer-encoding'}] + return httpx2.Response(response.status_code, headers=headers, content=bytes(body)) + finally: + response.close() + finally: + _deadline.reset(token) diff --git a/backend/services/rate_limiter.py b/backend/services/rate_limiter.py index ba98a27..792d196 100644 --- a/backend/services/rate_limiter.py +++ b/backend/services/rate_limiter.py @@ -1,5 +1,6 @@ import hashlib import random +import threading import time from typing import Optional, Tuple from fastapi import Request @@ -8,6 +9,7 @@ class RateLimiter: """Database-backed atomic rate limiter with sliding window reset.""" + _lock = threading.Lock() def __init__(self, storage: StorageService): self.storage = storage @@ -34,50 +36,82 @@ def check( self.cleanup_expired(now) except Exception: pass - with self.storage._get_cursor() as cursor: - cursor.execute( - self.storage._format_sql( - "SELECT count, reset_at FROM rate_limits WHERE bucket = ? AND subject = ?" - ), - (bucket, subject), - ) - row = cursor.fetchone() - if not row: - reset_at = now + window_seconds - cursor.execute( - self.storage._format_sql( - "INSERT INTO rate_limits (bucket, subject, count, reset_at) VALUES (?, ?, ?, ?)" - ), - (bucket, subject, 1, reset_at), - ) - return True, max(0, limit - 1), 0.0 - - r = dict(row) - count = int(r["count"]) - reset_at = float(r["reset_at"]) - - if now >= reset_at: - new_reset_at = now + window_seconds - cursor.execute( - self.storage._format_sql( - "UPDATE rate_limits SET count = 1, reset_at = ? WHERE bucket = ? AND subject = ?" - ), - (new_reset_at, bucket, subject), - ) - return True, max(0, limit - 1), 0.0 - - if count < limit: - new_count = count + 1 - cursor.execute( - self.storage._format_sql( - "UPDATE rate_limits SET count = ?, reset_at = ? WHERE bucket = ? AND subject = ?" - ), - (new_count, reset_at, bucket, subject), - ) - return True, max(0, limit - new_count), 0.0 + + with self._lock: + if self.storage.is_postgres: + with self.storage._get_cursor() as cursor: + cursor.execute( + """ + INSERT INTO rate_limits (bucket, subject, count, reset_at) + VALUES (%s, %s, 0, %s) + ON CONFLICT (bucket, subject) DO NOTHING + """, + (bucket, subject, now + window_seconds), + ) + cursor.execute( + "SELECT count, reset_at FROM rate_limits WHERE bucket = %s AND subject = %s FOR UPDATE", + (bucket, subject), + ) + row = cursor.fetchone() + r = dict(row) + count = int(r["count"]) + reset_at = float(r["reset_at"]) + + if count == 0 or now >= reset_at: + new_reset_at = now + window_seconds + cursor.execute( + "UPDATE rate_limits SET count = 1, reset_at = %s WHERE bucket = %s AND subject = %s", + (new_reset_at, bucket, subject), + ) + return True, max(0, limit - 1), 0.0 + + if count < limit: + new_count = count + 1 + cursor.execute( + "UPDATE rate_limits SET count = %s WHERE bucket = %s AND subject = %s", + (new_count, bucket, subject), + ) + return True, max(0, limit - new_count), 0.0 + else: + retry_after = max(1.0, reset_at - now) + return False, 0, retry_after else: - retry_after = max(1.0, reset_at - now) - return False, 0, retry_after + with self.storage._get_immediate_cursor() as cursor: + cursor.execute( + "SELECT count, reset_at FROM rate_limits WHERE bucket = ? AND subject = ?", + (bucket, subject), + ) + row = cursor.fetchone() + if not row: + reset_at = now + window_seconds + cursor.execute( + "INSERT INTO rate_limits (bucket, subject, count, reset_at) VALUES (?, ?, 1, ?)", + (bucket, subject, reset_at), + ) + return True, max(0, limit - 1), 0.0 + + r = dict(row) + count = int(r["count"]) + reset_at = float(r["reset_at"]) + + if now >= reset_at: + new_reset_at = now + window_seconds + cursor.execute( + "UPDATE rate_limits SET count = 1, reset_at = ? WHERE bucket = ? AND subject = ?", + (new_reset_at, bucket, subject), + ) + return True, max(0, limit - 1), 0.0 + + if count < limit: + new_count = count + 1 + cursor.execute( + "UPDATE rate_limits SET count = ?, reset_at = ? WHERE bucket = ? AND subject = ?", + (new_count, reset_at, bucket, subject), + ) + return True, max(0, limit - new_count), 0.0 + else: + retry_after = max(1.0, reset_at - now) + return False, 0, retry_after def cleanup_expired(self, now_epoch: Optional[float] = None) -> int: """Removes expired rate limit records.""" diff --git a/backend/services/resource_leases.py b/backend/services/resource_leases.py new file mode 100644 index 0000000..9ff66a9 --- /dev/null +++ b/backend/services/resource_leases.py @@ -0,0 +1,90 @@ +import time +import uuid +from typing import Optional +from fastapi import HTTPException +from backend.storage import StorageService + + +class ResourceLeases: + """Manages expiring in-flight operation leases across workers to bound concurrency.""" + + def __init__(self, storage: StorageService): + self.storage = storage + + def acquire( + self, + user_id: str, + kind: str, + ttl_seconds: int = 30, + max_user_leases: int = 2, + max_global_leases: int = 8, + now_epoch: Optional[float] = None, + ) -> str: + """Acquires a resource lease. Raises HTTPException(429) if capacity is exhausted.""" + now = now_epoch if now_epoch is not None else time.time() + expires_at = now + ttl_seconds + + # Clean expired leases before checking + self.cleanup_expired(now) + + with self.storage._get_immediate_cursor() as cursor: + # Check user active leases + cursor.execute( + self.storage._format_sql( + "SELECT COUNT(*) as count FROM resource_leases WHERE user_id = ? AND expires_at > ?" + ), + (user_id, now), + ) + row = cursor.fetchone() + user_count = int(row["count"]) if row else 0 + if user_count >= max_user_leases: + raise HTTPException( + status_code=429, + detail=f"Too many concurrent {kind} operations for this user. Please wait for in-flight tasks to complete.", + headers={"Retry-After": "5"}, + ) + + # Check global active leases + cursor.execute( + self.storage._format_sql( + "SELECT COUNT(*) as count FROM resource_leases WHERE kind = ? AND expires_at > ?" + ), + (kind, now), + ) + row = cursor.fetchone() + global_count = int(row["count"]) if row else 0 + if global_count >= max_global_leases: + raise HTTPException( + status_code=429, + detail=f"Global concurrency capacity reached for {kind}. Please retry shortly.", + headers={"Retry-After": "5"}, + ) + + lease_id = str(uuid.uuid4()) + cursor.execute( + self.storage._format_sql( + "INSERT INTO resource_leases (lease_id, user_id, kind, expires_at) VALUES (?, ?, ?, ?)" + ), + (lease_id, user_id, kind, expires_at), + ) + return lease_id + + def release(self, lease_id: str) -> None: + """Releases an active lease.""" + if not lease_id: + return + with self.storage._get_immediate_cursor() as cursor: + cursor.execute( + self.storage._format_sql("DELETE FROM resource_leases WHERE lease_id = ?"), + (lease_id,), + ) + + def cleanup_expired(self, now_epoch: Optional[float] = None) -> int: + """Purges expired leases from storage.""" + now = now_epoch if now_epoch is not None else time.time() + with self.storage._get_immediate_cursor() as cursor: + cursor.execute( + self.storage._format_sql("DELETE FROM resource_leases WHERE expires_at <= ?"), + (now,), + ) + return cursor.rowcount if hasattr(cursor, "rowcount") else 0 diff --git a/backend/services/resume_files.py b/backend/services/resume_files.py new file mode 100644 index 0000000..8f0a87a --- /dev/null +++ b/backend/services/resume_files.py @@ -0,0 +1,94 @@ +import re +from typing import Optional +from urllib.parse import quote + +from backend.storage import StorageService +from backend.services.object_storage import ObjectStorageService +from backend.models import Resume + + +def format_content_disposition(filename: str) -> str: + """Builds RFC 6266 and RFC 5987 compliant Content-Disposition header with UTF-8 filename*.""" + clean_ascii = re.sub(r'[^\x20-\x7E]', '_', filename) + clean_ascii = re.sub(r'[\r\n"\\]', '_', clean_ascii).strip() or "resume.pdf" + encoded_utf8 = quote(filename.replace('\r', '_').replace('\n', '_'), safe="") + return f'attachment; filename="{clean_ascii}"; filename*=UTF-8\'\'{encoded_utf8}' + + +class ResumeFileManager: + def __init__(self, storage: StorageService, object_storage: ObjectStorageService): + self.storage = storage + self.object_storage = object_storage + + def reserve_and_upload( + self, + user_id: str, + filename: str, + content_type: Optional[str], + content_bytes: bytes, + resume_name: Optional[str], + text_content: str, + max_resumes: int = 10, + max_storage_bytes: int = 50 * 1024 * 1024, + ) -> Resume: + current_bytes = self.storage.get_user_upload_bytes(user_id) + if current_bytes + len(content_bytes) > max_storage_bytes: + raise ValueError("Maximum total storage limit of 50MB reached for resumes.") + + file_key = self.object_storage.upload_file( + content_bytes=content_bytes, + filename=filename, + content_type=content_type, + user_id=user_id, + ) + + storage_backend = "r2" if self.object_storage.is_configured else "local" + attachment = self.storage.create_attachment( + user_id=user_id, + storage_backend=storage_backend, + object_key=file_key, + original_filename=filename, + content_type=content_type, + size_bytes=len(content_bytes), + ) + + final_name = resume_name.strip() if (resume_name and resume_name.strip()) else filename + try: + resume = self.storage.add_resume( + name=final_name, + content=text_content, + file_key=file_key, + user_id=user_id, + attachment_id=attachment.id, + max_resumes=max_resumes, + ) + return resume + except Exception: + self.object_storage.delete_file(file_key, user_id=user_id) + self.storage.update_attachment_deletion_state(attachment.id, "failed", user_id=user_id) + raise + + def delete_resume(self, user_id: str, resume_id: str) -> bool: + resume = self.storage.get_resume(resume_id, user_id=user_id) + if not resume: + return False + + if resume.attachment_id or resume.file_key: + attachment = None + if resume.attachment_id: + attachment = self.storage.get_attachment(resume.attachment_id, user_id=user_id) + elif resume.file_key: + attachment = self.storage.get_attachment_by_key(resume.file_key, user_id=user_id) + + if attachment: + self.storage.update_attachment_deletion_state(attachment.id, "pending", user_id=user_id) + deleted = self.object_storage.delete_file(attachment.object_key, user_id=user_id) + if not deleted: + self.storage.update_attachment_deletion_state(attachment.id, "failed", user_id=user_id) + raise RuntimeError("Failed to delete associated storage file.") + self.storage.delete_attachment(attachment.id, user_id=user_id) + elif resume.file_key: + self.object_storage.delete_file(resume.file_key, user_id=user_id) + + self.storage.delete_resume(resume_id, user_id=user_id) + return True diff --git a/backend/services/scraper.py b/backend/services/scraper.py index 9126246..aaf80a6 100644 --- a/backend/services/scraper.py +++ b/backend/services/scraper.py @@ -50,6 +50,11 @@ def __init__(self): }) def scrape_url(self, url: str, timeout: int = 15) -> ScrapedJob: + from backend.services.outbound_http import outbound_budget + with outbound_budget(30): + return self._scrape_url(url, timeout) + + def _scrape_url(self, url: str, timeout: int = 15) -> ScrapedJob: clean_url = url.strip() if not clean_url.startswith("http://") and not clean_url.startswith("https://"): clean_url = "https://" + clean_url @@ -63,21 +68,6 @@ def scrape_url(self, url: str, timeout: int = 15) -> ScrapedJob: html = None - # 1. Try TLS browser impersonation via curl_cffi for anti-bot protected sites (Indeed, Glassdoor, Cloudflare) - if CURL_CFFI_AVAILABLE and is_safe_url(clean_url): - for profile in ("safari17_0", "safari15_5", "chrome124"): - try: - cffi_resp = cffi_requests.get(clean_url, impersonate=profile, timeout=timeout, allow_redirects=False) - if ( - cffi_resp.status_code == 200 - and "Authenticating..." not in cffi_resp.text - and "Security Check" not in cffi_resp.text - ): - html = cffi_resp.text - break - except Exception: - continue - # 2. Fallback to SafeHttpClient with redirect and body limits if not html: try: @@ -562,8 +552,8 @@ def _try_fetch_workday_cxs(self, url: str, timeout: int = 15) -> Optional[Scrape cxs_url = f"https://{host}/wday/cxs/{tenant}/{portal}/job/{job_slug}" try: - resp = self.session.get(cxs_url, headers={"Accept": "application/json"}, timeout=timeout) - if resp.ok: + resp = self.safe_client.get(cxs_url, headers={"Accept": "application/json"}, timeout=timeout) + if resp.is_success: data = resp.json() info = data.get("jobPostingInfo", {}) title = info.get("title", "") diff --git a/backend/storage.py b/backend/storage.py index d535f8b..8fe58a4 100644 --- a/backend/storage.py +++ b/backend/storage.py @@ -1,6 +1,7 @@ import json import os import sqlite3 +import threading import uuid from contextlib import contextmanager from datetime import datetime, timezone, timedelta @@ -8,7 +9,7 @@ from typing import List, Optional, Dict, Any from dotenv import load_dotenv -load_dotenv() +load_dotenv(override=True) try: import psycopg2 @@ -64,7 +65,10 @@ def canonical_posting_url(url: Optional[str]) -> Optional[str]: return clean.rstrip("/") + class StorageService: + _resume_quota_lock = threading.Lock() + def __init__(self, db_path: str = "data/tracker.db", force_sqlite: bool = False, database_url: Optional[str] = None): if force_sqlite: raw_url = None @@ -161,6 +165,30 @@ def _get_cursor(self): finally: conn.close() + @contextmanager + def _get_immediate_cursor(self): + """Yields a cursor inside an immediate transaction for SQLite or normal cursor for PostgreSQL.""" + if self.is_postgres: + with self._get_cursor() as cursor: + yield cursor + else: + conn = sqlite3.connect(self.db_path, timeout=30.0) + conn.row_factory = sqlite3.Row + conn.isolation_level = None + cursor = conn.cursor() + try: + cursor.execute("BEGIN IMMEDIATE") + yield cursor + cursor.execute("COMMIT") + except Exception: + try: + cursor.execute("ROLLBACK") + except Exception: + pass + raise + finally: + conn.close() + def close(self): """Closes all connections in the pool if active.""" if self.is_postgres and self.pool: @@ -335,6 +363,17 @@ def _init_db(self): PRIMARY KEY (bucket, subject) ) """) + # Resource leases table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS resource_leases ( + lease_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + expires_at REAL NOT NULL + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_resource_leases_user ON resource_leases(user_id, kind)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_resource_leases_expires ON resource_leases(expires_at)") # --- Users CRUD --- def create_user( @@ -445,19 +484,25 @@ def link_google_identity( clean_sub = google_sub.strip() with self._get_cursor() as cursor: if clear_password: + cursor.execute( + self._format_sql("UPDATE auth_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL"), + (now, user_id), + ) cursor.execute( self._format_sql( - "UPDATE users SET google_sub = ?, hashed_password = NULL, email_verified = ?, session_version = session_version + 1, updated_at = ? WHERE id = ?" + "UPDATE users SET google_sub = ?, hashed_password = CASE WHEN email_verified = FALSE THEN NULL ELSE hashed_password END, email_verified = ?, session_version = session_version + 1, updated_at = ? WHERE id = ? AND (google_sub IS NULL OR google_sub = ?)" ), - (clean_sub, verify_email, now, user_id), + (clean_sub, verify_email, now, user_id, clean_sub), ) else: cursor.execute( self._format_sql( - "UPDATE users SET google_sub = ?, email_verified = ?, updated_at = ? WHERE id = ?" + "UPDATE users SET google_sub = ?, email_verified = ?, updated_at = ? WHERE id = ? AND (google_sub IS NULL OR google_sub = ?)" ), - (clean_sub, verify_email, now, user_id), + (clean_sub, verify_email, now, user_id, clean_sub), ) + if cursor.rowcount != 1: + raise ValueError("Google identity changed during sign-in. Please try again.") def increment_user_session_version(self, user_id: str) -> int: now = datetime.now().isoformat() @@ -521,16 +566,30 @@ def add_resume( file_key: Optional[str] = None, user_id: Optional[str] = None, attachment_id: Optional[str] = None, + max_resumes: Optional[int] = None, ) -> Resume: now = datetime.now().isoformat() resume_id = str(uuid.uuid4()) - with self._get_cursor() as cursor: - cursor.execute( - self._format_sql( - "INSERT INTO resumes (id, user_id, name, content, file_key, attachment_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" - ), - (resume_id, user_id, name, content, file_key, attachment_id, now, now), - ) + with self._resume_quota_lock: + with self._get_immediate_cursor() as cursor: + if user_id and max_resumes is not None: + if self.is_postgres: + cursor.execute("SELECT id FROM users WHERE id = %s FOR UPDATE", (user_id,)) + cursor.execute( + self._format_sql("SELECT COUNT(*) as cnt FROM resumes WHERE user_id = ?"), + (user_id,), + ) + row = cursor.fetchone() + count = int(row["cnt"]) if isinstance(row, dict) else int(row[0]) + if count >= max_resumes: + raise ValueError(f"Maximum limit of {max_resumes} resumes reached per account.") + + cursor.execute( + self._format_sql( + "INSERT INTO resumes (id, user_id, name, content, file_key, attachment_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ), + (resume_id, user_id, name, content, file_key, attachment_id, now, now), + ) return Resume( id=resume_id, name=name, diff --git a/docs/PUBLIC_LAUNCH_RUNBOOK.md b/docs/PUBLIC_LAUNCH_RUNBOOK.md new file mode 100644 index 0000000..0bf7983 --- /dev/null +++ b/docs/PUBLIC_LAUNCH_RUNBOOK.md @@ -0,0 +1,113 @@ +# Public Launch Runbook + +This runbook defines the operational deployment, migration, and pre-flight verification procedures for releasing JobHelperGuru to production. + +--- + +## 1. Required Environment Variables + +Ensure the following environment variables are set in production before starting the service: + +### Core Configuration +- `PORT`: HTTP port to bind (default `8000` or assigned by host). +- `APP_BASE_URL`: Public canonical base URL (e.g. `https://jobhelper.guru`). +- `ENVIRONMENT`: Set to `production`. +- `JWT_SECRET`: High-entropy secret key for signing user authentication tokens. +- `ENCRYPTION_KEY`: Fernet encryption key for securing stored provider API keys (base64 url-safe 32-byte key). + +### Database Configuration +- `DATABASE_URL`: PostgreSQL connection string (e.g. `postgresql://user:pass@ep-xyz.neon.tech/neondb?sslmode=require`). +- `JOB_HELPER_DB`: Optional path to local SQLite database when running outside PostgreSQL. + +### Email Delivery (Brevo) +- `EMAIL_TRANSPORT`: Set to `brevo` for production delivery. +- `BREVO_API_KEY`: API key generated from Brevo account dashboard (SMTP & API -> API Keys). +- `BREVO_SENDER_EMAIL`: Verified sender email address registered with Brevo. +- `BREVO_SENDER_NAME`: Display name for outbound emails (e.g. `JobHelperGuru Support`). + +### Storage Backend (S3 / Cloudflare R2 / GCS) +- `STORAGE_BACKEND`: `s3` (or `local` in staging environments). +- `S3_BUCKET_NAME`: Name of the private object storage bucket. +- `S3_ENDPOINT_URL`: Endpoint URL for S3-compatible providers (leave empty for AWS S3). +- `AWS_ACCESS_KEY_ID`: IAM access key. +- `AWS_SECRET_ACCESS_KEY`: IAM secret key. +- `AWS_REGION`: Storage region (e.g. `auto` or `us-east-1`). + +### OAuth & Security +- `GOOGLE_CLIENT_ID`: Google OAuth client ID for SSO. +- `ALLOWED_ORIGINS`: Comma-separated list of allowed CORS origins (e.g. `https://jobhelper.guru`). + +--- + +## 2. Pre-Deployment Data Operations + +### Step A: Audit and Repair Inconsistent Data +Before performing migrations, run the automated repair utility to resolve any legacy schema inconsistencies or orphaned attachments: + +1. Dry run (read-only audit): + ```bash + python -m backend.repair_data + ``` +2. Apply repairs: + ```bash + python -m backend.repair_data --apply + ``` + +### Step B: Database Migration to Production +Migrate data from local/staging SQLite database to the target PostgreSQL / Neon instance: + +1. Verify record counts with dry-run: + ```bash + python -m backend.migrate --source data/tracker.db --dest "$DATABASE_URL" --dry-run + ``` +2. Execute migration: + ```bash + python -m backend.migrate --source data/tracker.db --dest "$DATABASE_URL" --apply + ``` +3. Verify migration summary logs: + - Check `Users`, `Applications`, `Resumes`, `Attachments`, `User Settings`, `Global Settings`, and `Provider Profiles`. + - Ensure `errors` list is empty. + +--- + +## 3. Pre-Flight Verification Checklist + +Run through the following verification scenarios prior to directing public traffic: + +### 1. User Registration & Email Delivery +- Register a new user account with a valid test email. +- Verify delivery of verification email via Brevo transactional API. +- Click the verification link and confirm the SPA deep link loads `/verify-email?token=...` correctly without 404. + +### 2. Account Recovery & Deep Link Navigation +- Request a password reset. +- Confirm reset email arrives with one-time token. +- Access `/reset-password?token=...` and verify form loads and completes password update. + +### 3. Resume Lifecycle & Quota Enforcement +- Upload a PDF/DOCX resume. +- Verify file metadata is recorded and storage object is created. +- Download the resume and verify Unicode filename preservation (`filename*` RFC 5987). +- Verify upload quota enforces maximum 10 active resumes per user. +- Delete the resume and confirm idempotent cleanup in database and object storage. + +### 4. Job Pipeline & Duplicate Detection +- Ingest a job URL into the Analyzer. +- Enter a duplicate URL already in the pipeline and verify inline notice displays: `Already tracked: [Company] ([Role]) [[Status]]`. +- Verify Kanban board horizontally scrolls smoothly with clear column widths on mobile and desktop viewports. + +### 5. Outbound Connection & SSRF Protection +- Ensure requests targeting internal or link-local IP addresses (`127.0.0.1`, `169.254.169.254`, `10.0.0.0/8`, `192.168.0.0/16`) are blocked with HTTP 400. + +### 6. Concurrency & Rate Limiting +- Confirm rate limiting returns `Retry-After` header when limit is exceeded. +- Confirm frontend automatically parses `Retry-After` and handles throttling transparently. + +--- + +## 4. Rollback Plan + +If unexpected failures occur during launch: +1. Revert DNS/routing to previous stable environment. +2. In the event of a database rollback, point `DATABASE_URL` back to the previous snapshot or restore from the pre-migration dump. +3. Review application logs via `journalctl` or hosting platform log stream. diff --git a/docs/STEP_BY_STEP_LAUNCH_GUIDE.md b/docs/STEP_BY_STEP_LAUNCH_GUIDE.md new file mode 100644 index 0000000..926912b --- /dev/null +++ b/docs/STEP_BY_STEP_LAUNCH_GUIDE.md @@ -0,0 +1,222 @@ +# Step-by-Step Production Launch Guide + +This guide walks you through every action required on your end to launch JobHelperGuru to production. + +--- + +## Overview of Tasks + +1. [Step 1: Set Up Brevo Transactional Email](#step-1-set-up-brevo-transactional-email) (5 minutes) +2. [Step 2: Generate Production Cryptographic Secrets](#step-2-generate-production-cryptographic-secrets) (1 minute) +3. [Step 3: Database & Object Storage Credentials](#step-3-database--object-storage-credentials) (5 minutes) +4. [Step 4: Run Database Repair & Migration](#step-4-run-database-repair--migration) (2 minutes) +5. [Step 5: Push Branch to GitHub & Deploy](#step-5-push-branch-to-github--deploy) (3 minutes) +6. [Step 6: Live Smoke Test Checklist](#step-6-live-smoke-test-checklist) (5 minutes) + +--- + +## Step 1: Set Up Brevo Transactional Email + +JobHelperGuru uses Brevo (formerly Sendinblue) to deliver account verification emails and password reset links. Brevo provides 300 free emails per day with high deliverability. + +### 1. Create / Log In to Brevo +1. Go to [https://www.brevo.com](https://www.brevo.com) and sign up or sign in. +2. Complete your organization details (you can use "JobHelperGuru"). + +### 2. Verify Your Sender Email Address +1. In the top-right corner, click your account name and select **Senders, Domains & Dedicated IPs** (or go directly to [https://app.brevo.com/senders](https://app.brevo.com/senders)). +2. Click **Add a Sender**. +3. Enter: + - **Sender Name**: `JobHelperGuru Support` + - **Sender Email**: Your email (e.g. `sourav.chandhok01@gmail.com` or your domain email `support@jobhelper.guru`). +4. Brevo will send a 6-digit verification code to that email address. Enter the code in Brevo to verify. + +### 3. Generate Your Brevo API Key +1. In the top-right account dropdown, click **SMTP & API** (or visit [https://app.brevo.com/settings/keys/api](https://app.brevo.com/settings/keys/api)). +2. Go to the **API Keys** tab. +3. Click **Generate a new API key**. +4. Name it `jobhelperguru-production`. +5. Click **Generate** and immediately copy the key (it starts with `xkeysib-...`). Save it securely. + +### Your Brevo Configuration: +```env +EMAIL_TRANSPORT=brevo +BREVO_API_KEY=xkeysib-your-copied-api-key +BREVO_SENDER_EMAIL=your-verified-sender-email@gmail.com +BREVO_SENDER_NAME=JobHelperGuru Support +``` + +--- + +## Step 2: Generate Production Cryptographic Secrets + +You need two random high-entropy secrets for production. Run these two quick one-liners directly in your terminal: + +### 1. Generate `JWT_SECRET` (For signing user auth tokens): +Run in PowerShell / Terminal: +```bash +python -c "import secrets; print('JWT_SECRET=' + secrets.token_hex(32))" +``` +*Copy the generated 64-character string.* + +### 2. Generate `ENCRYPTION_KEY` (For encrypting stored LLM provider keys): +Run in PowerShell / Terminal: +```bash +python -c "from cryptography.fernet import Fernet; print('ENCRYPTION_KEY=' + Fernet.generate_key().decode())" +``` +*Copy the generated 44-character base64 string.* + +--- + +## Step 3: Database & Object Storage Credentials + +### 1. PostgreSQL Database (Neon Serverless Postgres) +If you are using Neon (free tier Postgres): +1. Log in to [https://console.neon.tech](https://console.neon.tech). +2. Select your project (or click **New Project** -> name: `jobhelperguru-db`). +3. Under **Dashboard**, copy your connection string: + ``` + postgresql://user:password@ep-xyz.neon.tech/neondb?sslmode=require + ``` +4. Set this as `DATABASE_URL`. + +### 2. Cloud Storage for Resume Files (AWS S3 or Cloudflare R2) +If you want resume PDF uploads stored in the cloud: +- **Cloudflare R2** (Recommended: 10GB free, $0 egress fees): + - In Cloudflare Dashboard -> R2 -> Create bucket: `jobhelperguru-resumes`. + - Create API Token with Admin Read/Write permissions. + - Set: + ```env + STORAGE_BACKEND=s3 + S3_BUCKET_NAME=jobhelperguru-resumes + AWS_ACCESS_KEY_ID=your-r2-access-key-id + AWS_SECRET_ACCESS_KEY=your-r2-secret-access-key + AWS_REGION=auto + S3_ENDPOINT_URL=https://.r2.cloudflarestorage.com + ``` +- **Local Storage Alternative** (If running locally or with a persistent disk): + ```env + STORAGE_BACKEND=local + ``` + +--- + +## Step 4: Run Database Repair & Migration + +Before sending traffic to your production database, run the automated repair and migration scripts from your repository root. + +### 1. Clean Local Data Inconsistencies +Run this command to audit and fix any corrupted records in your local `data/tracker.db`: +```pwsh +python -m backend.repair_data --apply +``` +*Expected output: `Audit results: {'corrupt_applications': ..., 'corrupt_settings': ..., 'orphaned_attachments': ..., 'applied': True}`.* + +### 2. Migrate Data to PostgreSQL +Set your production `DATABASE_URL` in your terminal session and run the migration: + +**In PowerShell:** +```pwsh +$env:DATABASE_URL = "postgresql://user:password@ep-xyz.neon.tech/neondb?sslmode=require" + +# First run a dry-run check (read-only): +python -m backend.migrate --source data/tracker.db --dest $env:DATABASE_URL --dry-run + +# Then apply the migration to production: +python -m backend.migrate --source data/tracker.db --dest $env:DATABASE_URL --apply +``` + +**In Linux / macOS Bash:** +```bash +export DATABASE_URL="postgresql://user:password@ep-xyz.neon.tech/neondb?sslmode=require" + +# First run a dry-run check (read-only): +python -m backend.migrate --source data/tracker.db --dest "$DATABASE_URL" --dry-run + +# Then apply the migration to production: +python -m backend.migrate --source data/tracker.db --dest "$DATABASE_URL" --apply +``` + +Verify the output shows `Mode: APPLIED` and `Errors encountered:` is not present. + +--- + +## Step 5: Push Branch to GitHub & Deploy + +All fixes have been committed locally to branch `launch-readiness`. + +### 1. Push `launch-readiness` to GitHub +Run in PowerShell: +```pwsh +git push origin launch-readiness +``` + +### 2. Merge into `main` (When ready to release) +You can either open a Pull Request on GitHub and merge it, or merge locally: +```pwsh +git checkout main +git merge launch-readiness +git push origin main +``` + +### 3. Configure Production Environment Variables on Host +On your hosting dashboard (e.g. Render, Google Cloud Run, or Railway), enter the environment variables gathered from Steps 1, 2, and 3: + +```env +ENVIRONMENT=production +APP_BASE_URL=https://your-domain.com +JWT_SECRET= +ENCRYPTION_KEY= +DATABASE_URL= +EMAIL_TRANSPORT=brevo +BREVO_API_KEY= +BREVO_SENDER_EMAIL= +BREVO_SENDER_NAME=JobHelperGuru Support +STORAGE_BACKEND=s3 +S3_BUCKET_NAME=jobhelperguru-resumes +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=auto +GOOGLE_CLIENT_ID= +``` + +--- + +## Step 6: Live Smoke Test Checklist + +Once the production service is live, perform this 5-minute end-to-end verification: + +### Test 1: User Registration & Email Delivery +1. Open your production site in an incognito window. +2. Register a new account using your real email address. +3. Check your email inbox for the message: **"Verify your email - JobHelperGuru"**. +4. Click the verification button in the email. +5. Confirm it loads `https://your-domain.com/verify-email?token=...` and displays: + **"Email verified successfully! You can now log in."** (no 404 error). + +### Test 2: Password Recovery +1. On the login screen, click **Forgot password?**. +2. Enter your email and submit. +3. Check your inbox for the reset link: **"Reset your password - JobHelperGuru"**. +4. Click the link and enter a new password. +5. Confirm successful reset and log in with the new password. + +### Test 3: Resume Management & Unicode Download +1. Log in to your account. +2. Go to **Resumes** and upload a resume (PDF or DOCX). +3. Verify the resume appears in your Resume Library. +4. Click **Download** and verify the file downloads intact with the correct filename. +5. Delete the resume and confirm it is removed cleanly. + +### Test 4: Job Analyzer & Duplicate Detection +1. In the **Analyzer**, paste a job URL (e.g., from LinkedIn or Greenhouse) and click **Analyze Job**. +2. Click **Add to Pipeline**. +3. Now paste the exact same job URL into the input field again. +4. Verify the inline notice appears immediately: + `Already tracked: [Company] ([Role]) [[Status]]`. +5. Switch to the **Application Pipeline** tab and switch to **Kanban view**. +6. Resize your browser down to mobile width (or open on your phone) and verify the columns scroll smoothly horizontally without clipping. + +--- + +When all 6 steps are complete, JobHelperGuru is officially public-launch ready! diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 31615c0..e843197 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,7 @@ "jsdom": "25.0.1", "tailwindcss": "^4.3.3", "vite": "^6.4.3", - "vitest": "^3.2.7" + "vitest": "^4.1.11" } }, "node_modules/@adobe/css-tools": { @@ -1366,6 +1366,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1827,39 +1834,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1871,42 +1879,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1914,28 +1922,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2052,16 +2057,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2098,18 +2093,11 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2128,16 +2116,6 @@ "node": ">=8" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2245,16 +2223,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2363,9 +2331,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -3097,13 +3065,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3230,6 +3191,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3250,16 +3225,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3523,9 +3488,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -3542,26 +3507,6 @@ "node": ">=8" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3611,11 +3556,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -3634,30 +3582,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -3816,89 +3744,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -3909,6 +3828,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/frontend/package.json b/frontend/package.json index 16b64cf..5f58213 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,6 @@ "tailwindcss": "^4.3.3", "jsdom": "25.0.1", "vite": "^6.4.3", - "vitest": "^3.2.7" + "vitest": "^4.1.11" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 02fb9df..1b88786 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import SettingsModal from './components/SettingsModal'; import OfflineSwitchModal from './components/OfflineSwitchModal'; import AuthModal from './components/AuthModal'; import PrivacyModal from './components/PrivacyModal'; +import AuthLinkPage from './components/AuthLinkPage'; import UserNav from './components/UserNav'; import ThemeToggle from './components/ThemeToggle'; import { @@ -36,6 +37,14 @@ export default function App() { const [currentUser, setCurrentUser] = useState(getCurrentUser()); const [isAuthOpen, setIsAuthOpen] = useState(false); const [authMode, setAuthMode] = useState('login'); + const [currentPath, setCurrentPath] = useState(() => (typeof window !== 'undefined' ? window.location.pathname : '/')); + + useEffect(() => { + const onPopState = () => setCurrentPath(window.location.pathname); + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); + const [theme, setTheme] = useState(() => { return localStorage.getItem('jobhelperguru_theme') || 'light'; @@ -219,7 +228,33 @@ export default function App() { const offerCount = applications.filter((a) => a.status === 'Offered').length; const totalCount = applications.length; + if (currentPath === '/verify-email' || currentPath === '/reset-password') { + return ( + <> + setCurrentPath('/')} + onOpenAuth={(mode) => { + setCurrentPath('/'); + setAuthMode(mode || 'login'); + setIsAuthOpen(true); + }} + /> + setIsAuthOpen(false)} + onSuccess={(user) => { + setCurrentUser(user); + setIsAuthOpen(false); + }} + initialMode={authMode} + /> + + ); + } + return ( +
{/* Top Corporate Navbar */}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 32c3ed7..9c531e5 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -43,6 +43,13 @@ async function parseApiError(res, defaultMsg) { let errorMsg = defaultMsg; let canSwitchOffline = res.status === 502; let modelName = ''; + let retryAfter = null; + + const retryHeader = res.headers?.get?.('Retry-After'); + if (retryHeader) { + const parsed = parseInt(retryHeader, 10); + if (!isNaN(parsed)) retryAfter = parsed; + } try { const data = await res.json(); @@ -63,9 +70,23 @@ async function parseApiError(res, defaultMsg) { err.canSwitchOffline = canSwitchOffline; err.modelName = modelName; err.status = res.status; + if (retryAfter !== null) { + err.retryAfter = retryAfter; + } return err; } +export async function authFetchWithRetry(url, options = {}, maxRetries = 1) { + const res = await authFetch(url, options); + if (res.status === 429 && maxRetries > 0) { + const retryHeader = res.headers?.get?.('Retry-After'); + const delaySec = Math.min(Math.max(parseInt(retryHeader || '1', 10), 1), 10); + await new Promise((resolve) => setTimeout(resolve, delaySec * 1000)); + return authFetchWithRetry(url, options, maxRetries - 1); + } + return res; +} + // --- Auth APIs --- export async function registerUser({ email, password, name }) { @@ -106,6 +127,10 @@ export async function requestEmailVerification(email) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); + if (!res.ok) { + const data = await res.json().catch(() => ({ detail: 'Failed to request verification email' })); + throw new Error(data.detail || 'Failed to request verification email'); + } return res.json(); } @@ -128,6 +153,10 @@ export async function requestPasswordReset(email) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); + if (!res.ok) { + const data = await res.json().catch(() => ({ detail: 'Failed to request password reset' })); + throw new Error(data.detail || 'Failed to request password reset'); + } return res.json(); } @@ -144,6 +173,7 @@ export async function confirmPasswordReset(token, new_password) { return res.json(); } + export async function googleAuthUser(credential) { const res = await fetch(`${API_BASE}/auth/google`, { method: 'POST', @@ -155,7 +185,7 @@ export async function googleAuthUser(credential) { throw new Error(data.detail || 'Google sign-in failed'); } const data = await res.json(); - setAuth(data.token, data.user); + if (data.token) setAuth(data.token, data.user); return data; } diff --git a/frontend/src/components/AuthLinkPage.jsx b/frontend/src/components/AuthLinkPage.jsx new file mode 100644 index 0000000..607211b --- /dev/null +++ b/frontend/src/components/AuthLinkPage.jsx @@ -0,0 +1,239 @@ +import { useState, useEffect } from 'react'; +import { Mail, Lock, CheckCircle, AlertCircle, Loader2, ArrowLeft, Eye, EyeOff } from 'lucide-react'; +import { confirmEmailVerification, confirmPasswordReset } from '../api/client'; + +export default function AuthLinkPage({ path, onGoHome, onOpenAuth }) { + const isVerify = path === '/verify-email'; + const queryParams = new URLSearchParams(window.location.search); + const token = queryParams.get('token') || ''; + + // Verification state + const [verifyStatus, setVerifyStatus] = useState('loading'); // 'loading' | 'success' | 'error' + const [verifyError, setVerifyError] = useState(''); + + // Password reset state + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [resetLoading, setResetLoading] = useState(false); + const [resetError, setResetError] = useState(''); + const [resetSuccess, setResetSuccess] = useState(false); + + useEffect(() => { + if (isVerify) { + if (!token) { + setVerifyStatus('error'); + setVerifyError('Verification link is missing the token parameter.'); + return; + } + confirmEmailVerification(token) + .then(() => { + setVerifyStatus('success'); + }) + .catch((err) => { + setVerifyStatus('error'); + setVerifyError(err.message || 'Verification link is invalid or has expired.'); + }); + } + }, [isVerify, token]); + + const handleResetSubmit = async (e) => { + e.preventDefault(); + setResetError(''); + if (!token) { + setResetError('Reset token is missing.'); + return; + } + if (password.length < 8) { + setResetError('Password must be at least 8 characters long.'); + return; + } + if (password !== confirmPassword) { + setResetError('Passwords do not match.'); + return; + } + + setResetLoading(true); + try { + await confirmPasswordReset(token, password); + setResetSuccess(true); + } catch (err) { + setResetError(err.message || 'Failed to reset password. The link may have expired.'); + } finally { + setResetLoading(false); + } + }; + + const handleNavigateHome = () => { + window.history.pushState({}, '', '/'); + if (onGoHome) onGoHome(); + }; + + const handleOpenLogin = () => { + window.history.pushState({}, '', '/'); + if (onGoHome) onGoHome(); + if (onOpenAuth) onOpenAuth('login'); + }; + + return ( +
+
+
+ +
+ + {isVerify ? ( +
+ {verifyStatus === 'loading' && ( + <> +
+ +
+

Verifying your email...

+

+ Please hold on while we confirm your email address. +

+ + )} + + {verifyStatus === 'success' && ( + <> +
+
+ +
+
+

Email Verified!

+

+ Your email address has been verified successfully. You can now sign in to your account. +

+
+ +
+ + )} + + {verifyStatus === 'error' && ( + <> +
+
+ +
+
+

Verification Failed

+

+ {verifyError} +

+
+ +
+ + )} +
+ ) : ( +
+
+

Reset Your Password

+

+ Enter your new account password below. +

+
+ + {resetSuccess ? ( +
+
+
+ +
+
+

Password Updated!

+

+ Your password has been reset successfully. You can now sign in with your new credentials. +

+ +
+ ) : ( +
+ {resetError && ( +
+ + {resetError} +
+ )} + +
+ +
+ setPassword(e.target.value)} + required + placeholder="At least 8 characters" + className="w-full pl-10 pr-10 py-2.5 text-sm rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-primary-500" + /> + + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + placeholder="Re-enter your new password" + className="w-full pl-10 pr-10 py-2.5 text-sm rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-primary-500" + /> + +
+
+ + + +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/AuthModal.jsx b/frontend/src/components/AuthModal.jsx index 621ee3b..d873ba1 100644 --- a/frontend/src/components/AuthModal.jsx +++ b/frontend/src/components/AuthModal.jsx @@ -45,6 +45,11 @@ export default function AuthModal({ isOpen, onClose, onSuccess, initialMode = 'l setError(''); try { const data = await googleAuthUser(response.credential); + if (data.requires_verification) { + setEmail(data.user?.email || ''); + setMode('verify_pending'); + return; + } if (onSuccess) onSuccess(data.user, Boolean(data.is_new_user)); onClose(); } catch (err) { diff --git a/frontend/src/components/CoverLetterModal.jsx b/frontend/src/components/CoverLetterModal.jsx index 7c50930..6ba8673 100644 --- a/frontend/src/components/CoverLetterModal.jsx +++ b/frontend/src/components/CoverLetterModal.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Mail, Copy, Check, Sparkles, Loader2, MessageSquare, Send, FileText, Download } from 'lucide-react'; import { generateOutreach, exportCoverLetterDocx } from '../api/client'; +import { renderCoverLetter } from '../utils/printCoverLetter'; export default function CoverLetterModal({ isOpen, @@ -95,61 +96,16 @@ export default function CoverLetterModal({ alert('Please allow popups to download or print your PDF.'); return; } - const printCandidateName = candidateName; - const company = currentJob?.company || ''; - const role = currentJob?.title || ''; - const subject = data?.subject_line || ''; - const bodyHtml = data.cover_letter_pitch - .split('\n\n') - .map((p) => `

${p.replace(/\n/g, '
')}

`) - .join(''); - - printWindow.document.write(` - - - - Cover Letter - ${printCandidateName} - ${company} - - - -
-
${candidateName}
-
${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
-
- Hiring Team
- ${company ? company + '
' : ''} -
- ${subject ? `
Subject: ${subject}
` : ''} -
-
- ${bodyHtml} -
- - -`); - printWindow.document.close(); + printWindow.opener = null; + renderCoverLetter(printWindow.document, { + candidateName, + company: currentJob?.company || '', + subject: data.subject_line || '', + body: data.cover_letter_pitch, + dateLabel: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), + }); + printWindow.focus(); + printWindow.print(); }; return ( diff --git a/frontend/src/components/JobAnalyzer.jsx b/frontend/src/components/JobAnalyzer.jsx index f96b03f..5dd2a6b 100644 --- a/frontend/src/components/JobAnalyzer.jsx +++ b/frontend/src/components/JobAnalyzer.jsx @@ -56,6 +56,12 @@ export default function JobAnalyzer({ }) ); + const matchingAppByUrl = Boolean(urlInput.trim()) && (applications || []).find((app) => { + const cleanInput = urlInput.trim().replace(/\/+$/, '').toLowerCase(); + const appUrl = (app.url || '').trim().replace(/\/+$/, '').toLowerCase(); + return cleanInput && appUrl && cleanInput === appUrl; + }); + const handleAnalyze = async (e) => { e?.preventDefault(); setError(''); @@ -123,14 +129,24 @@ export default function JobAnalyzer({
- setUrlInput(e.target.value)} - disabled={loading} - className="input-corporate flex-1 text-sm text-[#000000] placeholder:text-[#666666] h-11" - /> +
+ setUrlInput(e.target.value)} + disabled={loading} + className="input-corporate w-full text-sm text-[#000000] placeholder:text-[#666666] h-11" + /> + {matchingAppByUrl && ( +
+ Already tracked: + {matchingAppByUrl.company} + ({matchingAppByUrl.role}) + [{matchingAppByUrl.status}] +
+ )} +