import json from pathlib import Path from fastapi import APIRouter, HTTPException, UploadFile, File from loguru import logger from app.database import get_db from app.models import ( AccountCreate, AccountOut, AccountUpdate, BulkImportResult, MafileData, ) from app.config import settings router = APIRouter(prefix="/api/accounts", tags=["accounts"]) @router.get("", response_model=list[AccountOut]) async def list_accounts(): db = await get_db() cursor = await db.execute("SELECT * FROM accounts ORDER BY id DESC") rows = await cursor.fetchall() return [dict(r) for r in rows] @router.get("/{account_id}", response_model=AccountOut) async def get_account(account_id: int): db = await get_db() cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)) row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Account not found") return dict(row) @router.post("", response_model=AccountOut, status_code=201) async def create_account(account: AccountCreate): db = await get_db() cursor = await db.execute( """INSERT INTO accounts (login, password, steam_id, email, email_password, phone, proxy, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", ( account.login, account.password, account.steam_id, account.email, account.email_password, account.phone, account.proxy, account.notes, ), ) await db.commit() new_cursor = await db.execute( "SELECT * FROM accounts WHERE id = ?", (cursor.lastrowid,) ) return dict(await new_cursor.fetchone()) @router.put("/{account_id}", response_model=AccountOut) async def update_account(account_id: int, account: AccountUpdate): db = await get_db() fields = {k: v for k, v in account.model_dump().items() if v is not None} # Allow clearing fields by sending empty string → store as NULL for k, v in account.model_dump().items(): if v == "" and k not in fields: fields[k] = None if not fields: raise HTTPException(status_code=400, detail="No fields to update") fields["updated_at"] = "datetime('now')" set_clause = ", ".join( f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?" for k in fields ) values = [v for k, v in fields.items() if k != "updated_at"] values.append(account_id) await db.execute( f"UPDATE accounts SET {set_clause} WHERE id = ?", values, ) await db.commit() cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)) row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Account not found") return dict(row) @router.delete("/{account_id}", status_code=204) async def delete_account(account_id: int): db = await get_db() cursor = await db.execute("SELECT mafile_path FROM accounts WHERE id = ?", (account_id,)) row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Account not found") if row["mafile_path"]: mafile = Path(row["mafile_path"]) if mafile.exists(): mafile.unlink() await db.execute("DELETE FROM accounts WHERE id = ?", (account_id,)) await db.commit() @router.post("/delete-bulk", status_code=200) async def delete_accounts_bulk(request: dict): """Delete multiple accounts by IDs list, or all if ids is empty.""" db = await get_db() ids = request.get("ids", []) if ids: placeholders = ",".join("?" for _ in ids) cursor = await db.execute( f"SELECT id, mafile_path FROM accounts WHERE id IN ({placeholders})", ids ) else: cursor = await db.execute("SELECT id, mafile_path FROM accounts") rows = await cursor.fetchall() count = 0 for row in rows: if row["mafile_path"]: mafile = Path(row["mafile_path"]) if mafile.exists(): mafile.unlink() count += 1 if ids: placeholders = ",".join("?" for _ in ids) await db.execute(f"DELETE FROM accounts WHERE id IN ({placeholders})", ids) else: await db.execute("DELETE FROM accounts") await db.commit() return {"deleted": count} @router.post("/assign-proxies") async def assign_proxies_round_robin(): """Round-robin assign proxies to accounts that don't have one.""" db = await get_db() proxy_cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id") proxies = [dict(r) for r in await proxy_cursor.fetchall()] if not proxies: raise HTTPException(status_code=400, detail="No proxies available") acc_cursor = await db.execute("SELECT id FROM accounts WHERE proxy IS NULL OR proxy = '' ORDER BY id") account_ids = [r["id"] for r in await acc_cursor.fetchall()] if not account_ids: return {"assigned": 0, "proxies_used": 0} for i, acc_id in enumerate(account_ids): proxy = proxies[i % len(proxies)] await db.execute( "UPDATE accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?", (proxy["address"], acc_id), ) await db.commit() from app.core.proxy_manager import proxy_manager await proxy_manager.load() logger.info(f"Assigned {len(proxies)} proxies to {len(account_ids)} accounts (round-robin)") return {"assigned": len(account_ids), "proxies_used": len(proxies)} @router.post("/reassign-proxies") async def reassign_proxies_round_robin(): """Round-robin reassign proxies to ALL accounts, overwriting existing.""" db = await get_db() proxy_cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id") proxies = [dict(r) for r in await proxy_cursor.fetchall()] if not proxies: raise HTTPException(status_code=400, detail="No proxies available") acc_cursor = await db.execute("SELECT id FROM accounts ORDER BY id") account_ids = [r["id"] for r in await acc_cursor.fetchall()] if not account_ids: return {"assigned": 0, "proxies_used": 0} for i, acc_id in enumerate(account_ids): proxy = proxies[i % len(proxies)] await db.execute( "UPDATE accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?", (proxy["address"], acc_id), ) await db.commit() from app.core.proxy_manager import proxy_manager await proxy_manager.load() logger.info(f"Reassigned {len(proxies)} proxies to {len(account_ids)} accounts (round-robin)") return {"assigned": len(account_ids), "proxies_used": len(proxies)} @router.post("/clear-proxies") async def clear_all_proxies(): """Remove proxy assignment from all accounts.""" db = await get_db() cursor = await db.execute("UPDATE accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''") await db.commit() count = cursor.rowcount from app.core.proxy_manager import proxy_manager await proxy_manager.load() logger.info(f"Cleared proxies from {count} accounts") return {"cleared": count} @router.post("/import", response_model=BulkImportResult) async def import_accounts(file: UploadFile = File(...)): """Import accounts. Supported formats (: or | separator): login|password|{mafile_json} login|password|email|email_password|{mafile_json} login|password|email|email_password|{mafile_json}|notes login|password|email|email_password login|password|email|email_password|notes (no mafile, with notes) """ content = (await file.read()).decode("utf-8", errors="ignore") result = BulkImportResult() db = await get_db() for raw_line in content.strip().splitlines(): line = raw_line.strip() if not line: continue notes: str | None = None # Split off embedded mafile JSON — find matching braces json_start = line.find("{") if json_start != -1: depth = 0 json_end = json_start for i in range(json_start, len(line)): if line[i] == "{": depth += 1 elif line[i] == "}": depth -= 1 if depth == 0: json_end = i break mafile_str = line[json_start : json_end + 1] prefix = line[:json_start].rstrip("|:") suffix = line[json_end + 1 :].lstrip("|:") if suffix: notes = suffix else: mafile_str = None prefix = line sep = "|" if "|" in prefix else ":" parts = prefix.split(sep) if mafile_str: # Accepted: 2 fields (login|pass) or 4 fields (login|pass|email|email_pass) if len(parts) == 2: login, password = parts email = email_password = None elif len(parts) == 4: login, password, email, email_password = parts else: result.errors.append(f"Invalid prefix (need 2 or 4 fields): {line[:50]}") result.skipped += 1 continue try: mafile_data = json.loads(mafile_str) except json.JSONDecodeError as exc: result.errors.append(f"Bad mafile JSON on line starting '{prefix[:30]}': {exc}") result.skipped += 1 continue # Fall back to mail fields embedded in the mafile JSON itself if not email: email = mafile_data.get("mail") or mafile_data.get("email") or None email_password = mafile_data.get("mail_password") or mafile_data.get("email_password") or None else: # No mafile — 4 fields, or 5 fields with notes if len(parts) == 5: login, password, email, email_password, notes = parts elif len(parts) == 4: login, password, email, email_password = parts else: result.errors.append(f"Invalid line (need 4-5 fields): {line[:50]}") result.skipped += 1 continue mafile_data = None try: # Check if account with this login already exists existing = await db.execute( "SELECT id FROM accounts WHERE login = ?", (login,) ) if await existing.fetchone(): result.errors.append(f"{login}: уже существует") result.skipped += 1 continue await db.execute( """INSERT INTO accounts (login, password, email, email_password, notes) VALUES (?, ?, ?, ?, ?)""", (login, password, email or None, email_password or None, notes or None), ) result.imported += 1 except Exception as exc: result.errors.append(f"{login}: {exc}") result.skipped += 1 continue if mafile_data: account_name = mafile_data.get("account_name") or login shared_secret = mafile_data.get("shared_secret") or "" identity_secret = mafile_data.get("identity_secret") or "" steam_id = (mafile_data.get("Session") or {}).get("SteamID") or None settings.mafiles_dir.mkdir(parents=True, exist_ok=True) safe_name = Path(account_name).name dest = settings.mafiles_dir / f"{safe_name}.mafile" dest.write_text(json.dumps(mafile_data, ensure_ascii=False), encoding="utf-8") await db.execute( """UPDATE accounts SET mafile_path = ?, shared_secret = ?, identity_secret = ?, steam_id = ? WHERE login = ? AND steam_id IS NULL""", (str(dest), shared_secret, identity_secret, steam_id, login), ) await db.commit() logger.info(f"Imported {result.imported} accounts, skipped {result.skipped}") return result @router.post("/{account_id}/mafile") async def upload_mafile(account_id: int, file: UploadFile = File(...)): """Bind a .mafile to an account.""" db = await get_db() cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)) row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Account not found") content = (await file.read()).decode("utf-8") try: mafile = MafileData.model_validate_json(content) except Exception as exc: raise HTTPException(status_code=400, detail=f"Invalid mafile format: {exc}") settings.mafiles_dir.mkdir(parents=True, exist_ok=True) steam_id = mafile.Session.SteamID or row["steam_id"] or account_id safe_name = Path(str(steam_id)).name mafile_path = settings.mafiles_dir / f"{safe_name}.mafile" mafile_path.write_text(content, encoding="utf-8") await db.execute( """UPDATE accounts SET mafile_path = ?, shared_secret = ?, identity_secret = ?, steam_id = COALESCE(steam_id, ?), updated_at = datetime('now') WHERE id = ?""", ( str(mafile_path), mafile.shared_secret, mafile.identity_secret, str(mafile.Session.SteamID) if mafile.Session.SteamID else None, account_id, ), ) await db.commit() return {"status": "ok", "mafile_path": str(mafile_path)}