# Harden Wix test routes behind admin+env gating
This commit is contained in:
@@ -18,7 +18,7 @@ CORE_ROUTER_REGISTRY = [
|
||||
{"name": "step3_research", "module": "api.onboarding_utils.step3_routes", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "step4_assets", "module": "api.onboarding_utils.step4_asset_routes", "attr": "router", "features": {"all", "core", "podcast"}},
|
||||
{"name": "step4_persona", "module": "api.onboarding_utils.step4_persona_routes_optimized", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "gsc_auth", "module": "routers.gsc_auth", "attr": "router", "features": {"all", "core", "seo"}},
|
||||
{"name": "gsc_auth", "module": "routers.gsc_auth", "attr": "router", "features": {"all", "core", "seo", "blog_writer"}},
|
||||
{"name": "wordpress_oauth", "module": "routers.wordpress_oauth", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "bing_oauth", "module": "routers.bing_oauth", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "bing_analytics", "module": "routers.bing_analytics", "attr": "router", "features": {"all", "core"}},
|
||||
@@ -45,6 +45,7 @@ OPTIONAL_ROUTER_REGISTRY = [
|
||||
{"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog_writer"}},
|
||||
{"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story_writer"}},
|
||||
{"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all"}},
|
||||
{"name": "wix_test", "module": "api.wix_routes", "attr": "qa_router", "features": {"all"}},
|
||||
{"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog_writer"}},
|
||||
{"name": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}},
|
||||
{"name": "video_studio", "module": "api.video_studio.router", "attr": "router", "features": {"all", "video_studio"}},
|
||||
@@ -159,6 +160,12 @@ class RouterManager:
|
||||
logger.info(f"Including {group_name} routers with features: {enabled_features}...")
|
||||
|
||||
for entry in registry:
|
||||
if entry["name"] == "wix_test" and not self._should_include_wix_test_router():
|
||||
reason = "wix test routes disabled or running in production environment"
|
||||
self.skipped_routers.append({"name": entry["name"], "reason": reason})
|
||||
if verbose:
|
||||
logger.info(f"⏭️ Skipping {entry['name']}: {reason}")
|
||||
continue
|
||||
if not self._should_include_router(entry, enabled_features):
|
||||
reason = f"features {enabled_features} not matching {entry.get('features', set())}"
|
||||
self.skipped_routers.append({"name": entry["name"], "reason": reason})
|
||||
@@ -178,6 +185,13 @@ class RouterManager:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error including {group_name} routers: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _should_include_wix_test_router() -> bool:
|
||||
environment = (os.getenv("ENVIRONMENT") or os.getenv("APP_ENV") or "development").strip().lower()
|
||||
is_production = environment in {"prod", "production"}
|
||||
wix_test_enabled = os.getenv("WIX_TEST_ROUTES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
return wix_test_enabled and not is_production
|
||||
|
||||
def include_core_routers(self) -> bool:
|
||||
"""Include core application routers."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse
|
||||
from typing import Dict, Any, Optional
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
import uuid
|
||||
|
||||
from services.wix_service import WixService
|
||||
from services.integrations.wix_oauth import WixOAuthService
|
||||
@@ -18,6 +19,7 @@ import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"])
|
||||
qa_router = APIRouter(prefix="/api/wix/test", tags=["Wix Integration QA"])
|
||||
|
||||
|
||||
def _sanitize_error_message(error: Exception) -> str:
|
||||
@@ -122,8 +124,41 @@ class WixConnectionStatus(BaseModel):
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def _is_wix_test_mode_enabled() -> bool:
|
||||
return os.getenv("WIX_TEST_ROUTES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _is_admin_user(current_user: Dict[str, Any]) -> bool:
|
||||
email = (current_user.get("email") or "").lower()
|
||||
role = current_user.get("role")
|
||||
public_metadata = current_user.get("public_metadata")
|
||||
if isinstance(public_metadata, dict):
|
||||
role = public_metadata.get("role") or role
|
||||
|
||||
admin_emails = {
|
||||
e.strip().lower()
|
||||
for e in os.getenv("ADMIN_EMAILS", "").split(",")
|
||||
if e.strip()
|
||||
}
|
||||
admin_domain = (os.getenv("ADMIN_EMAIL_DOMAIN") or "").lower().strip()
|
||||
|
||||
return bool(
|
||||
role == "admin"
|
||||
or (email and email in admin_emails)
|
||||
or (email and admin_domain and email.endswith(f"@{admin_domain}"))
|
||||
)
|
||||
|
||||
|
||||
def _require_wix_test_access(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
if not _is_wix_test_mode_enabled():
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if not _is_admin_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/auth/url")
|
||||
async def get_authorization_url(state: Optional[str] = None) -> Dict[str, str]:
|
||||
async def get_authorization_url(state: Optional[str] = None, current_user: dict = Depends(get_current_user)) -> Dict[str, str]:
|
||||
"""
|
||||
Get Wix OAuth authorization URL
|
||||
|
||||
@@ -134,8 +169,21 @@ async def get_authorization_url(state: Optional[str] = None) -> Dict[str, str]:
|
||||
Authorization URL
|
||||
"""
|
||||
try:
|
||||
url = wix_service.get_authorization_url(state)
|
||||
return {"authorization_url": url}
|
||||
user_id = current_user.get('id') if current_user else None
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
oauth_state = state or str(uuid.uuid4())
|
||||
oauth_payload = wix_service.get_authorization_url(oauth_state)
|
||||
saved = wix_oauth_service.store_pkce_verifier(
|
||||
user_id=user_id,
|
||||
state=oauth_state,
|
||||
code_verifier=oauth_payload["code_verifier"],
|
||||
ttl_seconds=600
|
||||
)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=500, detail="Failed to persist OAuth verifier state")
|
||||
return {"authorization_url": oauth_payload["authorization_url"], "state": oauth_state}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate authorization URL: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -158,8 +206,16 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found")
|
||||
|
||||
if not request.state:
|
||||
raise HTTPException(status_code=400, detail="Missing OAuth state")
|
||||
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=request.state)
|
||||
if not code_verifier:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid or expired OAuth state. Please restart Wix connection."
|
||||
)
|
||||
# Exchange code for tokens
|
||||
tokens = wix_service.exchange_code_for_tokens(request.code)
|
||||
tokens = wix_service.exchange_code_for_tokens(request.code, code_verifier=code_verifier)
|
||||
|
||||
# Get site information to extract site_id and member_id
|
||||
site_info = wix_service.get_site_info(tokens['access_token'])
|
||||
@@ -212,32 +268,38 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
|
||||
async def handle_oauth_callback_get(code: str, state: Optional[str] = None, request: Request = None, current_user: dict = Depends(get_current_user)):
|
||||
"""HTML callback page for Wix OAuth that exchanges code and notifies opener via postMessage."""
|
||||
try:
|
||||
tokens = wix_service.exchange_code_for_tokens(code)
|
||||
user_id = current_user.get('id') if current_user else None
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if not state:
|
||||
raise HTTPException(status_code=400, detail="Missing OAuth state")
|
||||
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=state)
|
||||
if not code_verifier:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state. Please reconnect Wix.")
|
||||
tokens = wix_service.exchange_code_for_tokens(code, code_verifier=code_verifier)
|
||||
site_info = wix_service.get_site_info(tokens['access_token'])
|
||||
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
||||
|
||||
# Store tokens in database if we have user_id
|
||||
user_id = current_user.get('id') if current_user else None
|
||||
if user_id:
|
||||
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||
member_id = None
|
||||
try:
|
||||
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stored = wix_oauth_service.store_tokens(
|
||||
user_id=user_id,
|
||||
access_token=tokens['access_token'],
|
||||
refresh_token=tokens.get('refresh_token'),
|
||||
expires_in=tokens.get('expires_in'),
|
||||
token_type=tokens.get('token_type', 'Bearer'),
|
||||
scope=tokens.get('scope'),
|
||||
site_id=site_id,
|
||||
member_id=member_id
|
||||
)
|
||||
if not stored:
|
||||
logger.warning(f"Failed to store Wix tokens for user {user_id} in GET callback")
|
||||
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||
member_id = None
|
||||
try:
|
||||
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stored = wix_oauth_service.store_tokens(
|
||||
user_id=user_id,
|
||||
access_token=tokens['access_token'],
|
||||
refresh_token=tokens.get('refresh_token'),
|
||||
expires_in=tokens.get('expires_in'),
|
||||
token_type=tokens.get('token_type', 'Bearer'),
|
||||
scope=tokens.get('scope'),
|
||||
site_id=site_id,
|
||||
member_id=member_id
|
||||
)
|
||||
if not stored:
|
||||
logger.warning(f"Failed to store Wix tokens for user {user_id} in GET callback")
|
||||
|
||||
# Build success payload for postMessage
|
||||
payload = {
|
||||
@@ -493,8 +555,8 @@ async def disconnect_wix(current_user: dict = Depends(get_current_user)) -> Dict
|
||||
# TEST ENDPOINTS - No authentication required for testing
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/test/connection/status")
|
||||
async def get_test_connection_status() -> WixConnectionStatus:
|
||||
@qa_router.get("/connection/status")
|
||||
async def get_test_connection_status(_: Dict[str, Any] = Depends(_require_wix_test_access)) -> WixConnectionStatus:
|
||||
"""
|
||||
TEST ENDPOINT: Check Wix connection status without authentication
|
||||
|
||||
@@ -519,8 +581,8 @@ async def get_test_connection_status() -> WixConnectionStatus:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/test/auth/url")
|
||||
async def get_test_authorization_url(state: Optional[str] = None) -> Dict[str, str]:
|
||||
@qa_router.get("/auth/url")
|
||||
async def get_test_authorization_url(state: Optional[str] = None, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, str]:
|
||||
"""
|
||||
TEST ENDPOINT: Get Wix OAuth authorization URL without authentication
|
||||
|
||||
@@ -557,8 +619,8 @@ async def get_test_authorization_url(state: Optional[str] = None) -> Dict[str, s
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/test/publish")
|
||||
async def test_publish_to_wix(request: WixPublishRequest) -> Dict[str, Any]:
|
||||
@qa_router.post("/publish")
|
||||
async def test_publish_to_wix(request: WixPublishRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
||||
"""
|
||||
TEST ENDPOINT: Simulate publishing a blog post to Wix without authentication.
|
||||
|
||||
@@ -610,8 +672,8 @@ async def refresh_wix_token(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to refresh token: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/test/publish/real")
|
||||
async def test_publish_real(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@qa_router.post("/publish/real")
|
||||
async def test_publish_real(payload: Dict[str, Any], _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
||||
"""
|
||||
TEST ENDPOINT: Perform a real publish to Wix using a provided access token.
|
||||
|
||||
@@ -688,8 +750,8 @@ async def test_publish_real(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/test/category")
|
||||
async def test_create_category(request: WixCreateCategoryRequest) -> Dict[str, Any]:
|
||||
@qa_router.post("/category")
|
||||
async def test_create_category(request: WixCreateCategoryRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
||||
try:
|
||||
result = wix_service.create_category(
|
||||
access_token=request.access_token,
|
||||
@@ -703,8 +765,8 @@ async def test_create_category(request: WixCreateCategoryRequest) -> Dict[str, A
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/test/tag")
|
||||
async def test_create_tag(request: WixCreateTagRequest) -> Dict[str, Any]:
|
||||
@qa_router.post("/tag")
|
||||
async def test_create_tag(request: WixCreateTagRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
||||
try:
|
||||
result = wix_service.create_tag(
|
||||
access_token=request.access_token,
|
||||
|
||||
@@ -48,7 +48,94 @@ class WixOAuthService:
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS wix_oauth_pkce_states (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL UNIQUE,
|
||||
code_verifier TEXT NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_wix_oauth_pkce_user_state
|
||||
ON wix_oauth_pkce_states (user_id, state)
|
||||
''')
|
||||
conn.commit()
|
||||
|
||||
def cleanup_expired_pkce_states(self, user_id: str) -> int:
|
||||
"""Delete expired or already-used PKCE state records."""
|
||||
try:
|
||||
self._init_db(user_id)
|
||||
db_path = self._get_db_path(user_id)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'''
|
||||
DELETE FROM wix_oauth_pkce_states
|
||||
WHERE used_at IS NOT NULL OR expires_at <= datetime('now')
|
||||
'''
|
||||
)
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
return deleted if deleted is not None else 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup expired Wix PKCE states for user {user_id}: {e}")
|
||||
return 0
|
||||
|
||||
def store_pkce_verifier(self, user_id: str, state: str, code_verifier: str, ttl_seconds: int = 600) -> bool:
|
||||
"""Store PKCE code verifier by OAuth state with short TTL."""
|
||||
try:
|
||||
self._init_db(user_id)
|
||||
self.cleanup_expired_pkce_states(user_id)
|
||||
db_path = self._get_db_path(user_id)
|
||||
expires_at = datetime.now() + timedelta(seconds=ttl_seconds)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'''
|
||||
INSERT OR REPLACE INTO wix_oauth_pkce_states (user_id, state, code_verifier, expires_at, created_at, used_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL)
|
||||
''',
|
||||
(user_id, state, code_verifier, expires_at)
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed storing Wix PKCE verifier for user {user_id}, state {state}: {e}")
|
||||
return False
|
||||
|
||||
def consume_pkce_verifier(self, user_id: str, state: str) -> Optional[str]:
|
||||
"""Get and invalidate one-time PKCE verifier for a state if valid and unexpired."""
|
||||
try:
|
||||
self._init_db(user_id)
|
||||
self.cleanup_expired_pkce_states(user_id)
|
||||
db_path = self._get_db_path(user_id)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'''
|
||||
SELECT id, code_verifier
|
||||
FROM wix_oauth_pkce_states
|
||||
WHERE user_id = ? AND state = ? AND used_at IS NULL AND expires_at > datetime('now')
|
||||
LIMIT 1
|
||||
''',
|
||||
(user_id, state)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
cursor.execute(
|
||||
"UPDATE wix_oauth_pkce_states SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(row[0],)
|
||||
)
|
||||
conn.commit()
|
||||
return row[1]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed consuming Wix PKCE verifier for user {user_id}, state {state}: {e}")
|
||||
return None
|
||||
|
||||
def store_tokens(
|
||||
self,
|
||||
@@ -302,4 +389,3 @@ class WixOAuthService:
|
||||
except Exception as e:
|
||||
logger.error(f"Error revoking Wix token: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class WixService:
|
||||
if not self.client_id:
|
||||
logger.warning("Wix client ID not configured. Set WIX_CLIENT_ID environment variable.")
|
||||
|
||||
def get_authorization_url(self, state: str = None) -> str:
|
||||
def get_authorization_url(self, state: str = None) -> Dict[str, str]:
|
||||
"""
|
||||
Generate Wix OAuth authorization URL for "on behalf of user" authentication
|
||||
|
||||
@@ -54,8 +54,7 @@ class WixService:
|
||||
Authorization URL for user to visit
|
||||
"""
|
||||
url, code_verifier = self.auth_service.generate_authorization_url(state)
|
||||
self._code_verifier = code_verifier
|
||||
return url
|
||||
return {"authorization_url": url, "state": state, "code_verifier": code_verifier}
|
||||
|
||||
def _create_redirect_session_for_auth(self, redirect_uri: str, client_id: str, code_challenge: str, state: str) -> str:
|
||||
"""
|
||||
@@ -97,13 +96,13 @@ class WixService:
|
||||
logger.error(f"Failed to create redirect session for auth: {e}")
|
||||
raise
|
||||
|
||||
def exchange_code_for_tokens(self, code: str, code_verifier: str = None) -> Dict[str, Any]:
|
||||
def exchange_code_for_tokens(self, code: str, code_verifier: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Exchange authorization code for access and refresh tokens using PKCE
|
||||
|
||||
Args:
|
||||
code: Authorization code from Wix
|
||||
code_verifier: PKCE code verifier (uses stored one if not provided)
|
||||
code_verifier: PKCE code verifier
|
||||
|
||||
Returns:
|
||||
Token response with access_token, refresh_token, etc.
|
||||
@@ -111,9 +110,7 @@ class WixService:
|
||||
if not self.client_id:
|
||||
raise ValueError("Wix client ID not configured")
|
||||
if not code_verifier:
|
||||
code_verifier = getattr(self, '_code_verifier', None)
|
||||
if not code_verifier:
|
||||
raise ValueError("Code verifier not found. Please provide code_verifier parameter.")
|
||||
raise ValueError("Code verifier is required.")
|
||||
try:
|
||||
return self.auth_service.exchange_code_for_tokens(code, code_verifier)
|
||||
except requests.RequestException as e:
|
||||
|
||||
Reference in New Issue
Block a user