diff --git a/admin-frontend/package-lock.json b/admin-frontend/package-lock.json index 15e47bf..3296084 100644 --- a/admin-frontend/package-lock.json +++ b/admin-frontend/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", + "playwright": "^1.60.0", "vite": "^6.0.7" } }, @@ -1706,6 +1707,53 @@ } } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", diff --git a/admin-frontend/package.json b/admin-frontend/package.json index 842e279..0ed32db 100644 --- a/admin-frontend/package.json +++ b/admin-frontend/package.json @@ -9,16 +9,17 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.5.13", - "vue-router": "^4.5.0", - "element-plus": "^2.9.1", "@element-plus/icons-vue": "^2.3.1", "axios": "^1.7.9", + "dayjs": "^1.11.13", + "element-plus": "^2.9.1", "pinia": "^2.3.0", - "dayjs": "^1.11.13" + "vue": "^3.5.13", + "vue-router": "^4.5.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", + "playwright": "^1.60.0", "vite": "^6.0.7" } } diff --git a/backend/app/ai/providers/alibaba.py b/backend/app/ai/providers/alibaba.py index e72ed8c..b039d31 100644 --- a/backend/app/ai/providers/alibaba.py +++ b/backend/app/ai/providers/alibaba.py @@ -1,6 +1,6 @@ from typing import Dict, Any, Optional from aliyunsdkcore.client import AcsClient -from aliyunsdkcore.auth.credentials import StsTokenCredential +from aliyunsdkcore.auth.credentials import AccessKeyCredential from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest from app.services.translation_quota import TranslationQuotaService from app.database import AsyncSessionLocal diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 1b931ca..50048e7 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -41,11 +41,11 @@ async def list_users( return await service.list_users(page, size, role) +from app.core.utils import validate_uuid + + def _validate_uuid(user_id: str): - try: - uuid.UUID(user_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid user ID format") + validate_uuid(user_id) @router.patch("/users/{target_user_id}/tier") diff --git a/backend/app/api/v1/admin_search.py b/backend/app/api/v1/admin_search.py index 1477b44..a717d38 100644 --- a/backend/app/api/v1/admin_search.py +++ b/backend/app/api/v1/admin_search.py @@ -181,9 +181,8 @@ async def test_provider( return {"success": False, "error": str(e)} +from app.core.utils import validate_uuid + + def _validate_uuid(uuid_str: str): - import uuid - try: - uuid.UUID(uuid_str) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid UUID") + validate_uuid(uuid_str) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index fa3c9f7..95b024c 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -8,7 +8,7 @@ from app.database import get_db from app.models.user import User from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token from app.core.csrf import require_csrf_token -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator from datetime import datetime, timedelta from app.services.admin import AdminService from app.models.subscription import Subscription @@ -40,6 +40,13 @@ class LoginRequest(BaseModel): phone: str = "" password: str + @field_validator('password') + @classmethod + def validate_password(cls, v: str) -> str: + if len(v) < 6: + raise ValueError('Password must be at least 6 characters') + return v + class RefreshRequest(BaseModel): refresh_token: str @@ -146,6 +153,37 @@ async def login( @router.post("/login/guest") async def guest_login(request: Request, db: AsyncSession = Depends(get_db)): + # Rate limiting: max 5 guest logins per IP per 15 minutes + from app.core.redis import get_redis + import time + + client_ip = request.client.host if request.client else "unknown" + cache_key = f"guest_login:{client_ip}" + + try: + redis_client = await get_redis() + now = int(time.time()) + window = 900 # 15 minutes + limit = 5 + + # Get count of logins in current window + count = await redis_client.get(cache_key) + if count and int(count) >= limit: + raise HTTPException( + status_code=429, + detail="Too many guest login attempts. Please try again later or register an account." + ) + + # Increment counter + pipe = redis_client.pipeline() + pipe.incr(cache_key) + pipe.expire(cache_key, window) + await pipe.execute() + + except Exception: + # If Redis is down, proceed without rate limiting + pass + guest_id = str(uuid.uuid4()) access_token = create_access_token( {"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True}, @@ -153,8 +191,7 @@ async def guest_login(request: Request, db: AsyncSession = Depends(get_db)): ) refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True}) - client_ip = request.client.host if request.client else None - await AdminService(db).log_usage(guest_id, "user.login_guest", {}, ip=client_ip) + await AdminService(db).log_usage(guest_id, "user.login_guest", {}) return LoginResponse( access_token=access_token, diff --git a/backend/app/api/v1/customer.py b/backend/app/api/v1/customer.py index 7d440dd..4642a5a 100644 --- a/backend/app/api/v1/customer.py +++ b/backend/app/api/v1/customer.py @@ -136,6 +136,8 @@ async def delete_customer( return {"message": "Customer deleted"} +MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB + @router.post("/import") async def import_customers( file: UploadFile = File(...), @@ -144,8 +146,17 @@ async def import_customers( ): from app.workers.tasks import process_customer_import - content = await file.read() - filename = file.filename or "" + filename = file.filename or "unknown" + file_size = 0 + content = b"" + while True: + chunk = await file.read(8192) + if not chunk: + break + file_size += len(chunk) + if file_size > MAX_UPLOAD_SIZE: + raise HTTPException(status_code=413, detail=f"File too large. Max {MAX_UPLOAD_SIZE // (1024*1024)}MB") + content += chunk if filename.endswith(".xlsx"): records, parse_errors = import_service.parse_xlsx(content) @@ -155,7 +166,7 @@ async def import_customers( raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv") if parse_errors and not records: - raise HTTPException(status_code=400, detail=f"Parse failed: {'; '.join(parse_errors)}") + raise HTTPException(status_code=400, detail="Parse failed. Check file format.") valid, validation_errors = import_service.validate_records(records) all_errors = parse_errors + validation_errors @@ -167,7 +178,7 @@ async def import_customers( await svc.create_customer(user_id, record) imported_count += 1 except Exception as e: - all_errors.append(f"Import failed for {record.get('name', 'unknown')}: {str(e)}") + all_errors.append(f"Import failed for row: {str(e)}") return { "imported": imported_count, diff --git a/backend/app/api/v1/discovery.py b/backend/app/api/v1/discovery.py index 9dc13ab..ec4e3b3 100644 --- a/backend/app/api/v1/discovery.py +++ b/backend/app/api/v1/discovery.py @@ -32,7 +32,8 @@ async def search_leads(req: SearchRequest, db: AsyncSession = Depends(get_db)): result = await svc.search(req.product_description, req.target_market) return {"success": True, "data": result} except Exception as e: - raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}") + logger.error(f"Search failed: {e}") + raise HTTPException(status_code=500, detail="搜索失败,请稍后重试") @router.post("/analyze") @@ -46,7 +47,8 @@ async def analyze_company(req: AnalyzeRequest): result = await svc.analyze(req.company_url, req.product_description) return {"success": True, "data": result} except Exception as e: - raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}") + logger.error(f"Analysis failed: {e}") + raise HTTPException(status_code=500, detail="分析失败,请稍后重试") @router.post("/outreach") @@ -57,7 +59,9 @@ async def generate_outreach(req: OutreachRequest): raise HTTPException(status_code=400, detail="请填写产品名称") svc = DiscoveryService() try: - result = await svc.outreach(req.company, req.product) + result = await svc.generate_outreach(req.company, req.product) return {"success": True, "data": result} except Exception as e: - raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") + logger.error(f"Outreach generation failed: {e}") + raise HTTPException(status_code=500, detail="生成失败,请稍后重试") + diff --git a/backend/app/api/v1/marketing.py b/backend/app/api/v1/marketing.py index aab54b6..953c14d 100644 --- a/backend/app/api/v1/marketing.py +++ b/backend/app/api/v1/marketing.py @@ -67,10 +67,10 @@ async def generate_marketing( @router.post("/keywords") -async def generate_keywords(data: KeywordsRequest, authorization: str = Header(None)): - if not authorization: - raise HTTPException(status_code=401, detail="Missing token") - +async def generate_keywords( + data: KeywordsRequest, + user_id: str = Depends(get_current_user_id), +): service = MarketingService() product_info = { "name": data.product_name, @@ -83,10 +83,10 @@ async def generate_keywords(data: KeywordsRequest, authorization: str = Header(N @router.post("/competitor-analysis") -async def competitor_analysis(data: CompetitorRequest, authorization: str = Header(None)): - if not authorization: - raise HTTPException(status_code=401, detail="Missing token") - +async def competitor_analysis( + data: CompetitorRequest, + user_id: str = Depends(get_current_user_id), +): service = MarketingService() product_info = { "name": data.product_name, diff --git a/backend/app/api/v1/product.py b/backend/app/api/v1/product.py index efec608..06011c8 100644 --- a/backend/app/api/v1/product.py +++ b/backend/app/api/v1/product.py @@ -100,9 +100,24 @@ async def import_products( user_id: str = Depends(get_current_user_id), db: AsyncSession = Depends(get_db), ): + from app.services.product import ProductService + + MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB + + filename = file.filename or "unknown" + file_size = 0 + content = b"" + while True: + chunk = await file.read(8192) + if not chunk: + break + file_size += len(chunk) + if file_size > MAX_UPLOAD_SIZE: + raise HTTPException(status_code=413, detail=f"File too large. Max {MAX_UPLOAD_SIZE // (1024*1024)}MB") + content += chunk + service = ProductService(db) - content = await file.read() - filename = file.filename.lower() + filename_lower = filename.lower() if filename.endswith(".xlsx"): if not HAS_OPENPYXL: diff --git a/backend/app/api/v1/whatsapp.py b/backend/app/api/v1/whatsapp.py index e86d1a6..e059157 100644 --- a/backend/app/api/v1/whatsapp.py +++ b/backend/app/api/v1/whatsapp.py @@ -38,6 +38,8 @@ async def handle_webhook( if x_hub_signature_256: if not svc.verify_signature(body, x_hub_signature_256): raise HTTPException(status_code=403, detail="Invalid signature") + else: + raise HTTPException(status_code=403, detail="Missing signature") import json body_json = json.loads(body) diff --git a/backend/app/core/utils.py b/backend/app/core/utils.py new file mode 100644 index 0000000..3208c3e --- /dev/null +++ b/backend/app/core/utils.py @@ -0,0 +1,27 @@ +"""Shared utility functions""" +import uuid +from typing import Any + + +def validate_uuid(value: str) -> str: + """Validate UUID format and return the value""" + try: + uuid.UUID(value) + return value + except ValueError: + raise ValueError(f"Invalid UUID format: {value}") + + +def truncate_string(value: str, max_length: int = 100) -> str: + """Truncate string to specified length""" + if len(value) <= max_length: + return value + return value[:max_length] + + +def sanitize_for_logging(value: str) -> str: + """Sanitize string for logging (remove sensitive info)""" + # Remove common sensitive patterns + import re + value = re.sub(r'[^a-zA-Z0-9\s\-_.,:;!?\'"]', '', value) + return value[:200] # Limit length for log safety \ No newline at end of file diff --git a/backend/app/services/import_service.py b/backend/app/services/import_service.py index f0f4050..849940d 100644 --- a/backend/app/services/import_service.py +++ b/backend/app/services/import_service.py @@ -22,18 +22,27 @@ OPTIONAL_COLUMNS = { class ImportService: + MAX_ROWS = 10000 + @staticmethod def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]: if not HAS_OPENPYXL: return [], ["openpyxl not installed"] try: - wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True) + # Validate magic bytes for XLSX + if len(file_bytes) < 4 or file_bytes[:4] != b'PK\x03\x04': + return [], ["Invalid XLSX file format"] + + wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True) ws = wb.active rows = list(ws.iter_rows(values_only=True)) if not rows: return [], ["Empty file"] + if len(rows) > ImportService.MAX_ROWS + 1: + return [], [f"File too large. Max {ImportService.MAX_ROWS} data rows"] + headers = [str(h).strip().lower() if h else "" for h in rows[0]] missing = REQUIRED_COLUMNS - set(headers) if missing: diff --git a/backend/app/services/search_web.py b/backend/app/services/search_web.py index 717736a..d350261 100644 --- a/backend/app/services/search_web.py +++ b/backend/app/services/search_web.py @@ -56,6 +56,31 @@ async def _google_cse(query: str, max_results: int, api_key: str, cse_id: str) - async def fetch_page_text(url: str) -> Optional[str]: + # Validate URL to prevent SSRF + from urllib.parse import urlparse + import ipaddress + + try: + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https'): + logger.warning(f"Invalid URL scheme: {url}") + return None + + # Check if hostname is an IP address and block private/reserved ranges + hostname = parsed.hostname + if hostname: + try: + ip = ipaddress.ip_address(hostname) + if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local: + logger.warning(f"Blocked private/reserved IP: {url}") + return None + except ValueError: + # Not an IP address, it's a hostname - proceed normally + pass + except Exception as e: + logger.warning(f"URL validation failed for {url}: {e}") + return None + try: async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) diff --git a/backend/app/services/translation.py b/backend/app/services/translation.py index 339f678..539a669 100644 --- a/backend/app/services/translation.py +++ b/backend/app/services/translation.py @@ -50,9 +50,6 @@ class TranslationService: preference_context: Optional[str] = None, ) -> List[Dict[str, Any]]: similar = await self.corpus.find_similar(inquiry, "reply") - if similar and count > 1: - pass - results = [] tones = self._get_tones(tone, count) diff --git a/user-frontend/package-lock.json b/user-frontend/package-lock.json index 15e47bf..129618a 100644 --- a/user-frontend/package-lock.json +++ b/user-frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "trademate-admin", + "name": "trademate-user", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "trademate-admin", + "name": "trademate-user", "version": "1.0.0", "dependencies": { "@element-plus/icons-vue": "^2.3.1", @@ -18,6 +18,7 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", + "playwright": "^1.60.0", "vite": "^6.0.7" } }, @@ -1706,6 +1707,53 @@ } } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", diff --git a/user-frontend/package.json b/user-frontend/package.json index c4c9020..5bd60a1 100644 --- a/user-frontend/package.json +++ b/user-frontend/package.json @@ -9,16 +9,17 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.5.13", - "vue-router": "^4.5.0", - "element-plus": "^2.9.1", "@element-plus/icons-vue": "^2.3.1", "axios": "^1.7.9", + "dayjs": "^1.11.13", + "element-plus": "^2.9.1", "pinia": "^2.3.0", - "dayjs": "^1.11.13" + "vue": "^3.5.13", + "vue-router": "^4.5.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", + "playwright": "^1.60.0", "vite": "^6.0.7" } } diff --git a/user-frontend/src/views/Workspace.vue b/user-frontend/src/views/Workspace.vue index 79ffb55..cd61196 100644 --- a/user-frontend/src/views/Workspace.vue +++ b/user-frontend/src/views/Workspace.vue @@ -129,13 +129,13 @@ - + - + - +