forked from FOSS/Steam-Panel
v 3.0.3
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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
|
||||
"""
|
||||
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
|
||||
|
||||
# Split off embedded mafile JSON (everything from first '{' onward)
|
||||
json_start = line.find("{")
|
||||
if json_start != -1:
|
||||
mafile_str = line[json_start:]
|
||||
prefix = line[:json_start].rstrip("|:")
|
||||
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 — must be exactly 4 fields
|
||||
if len(parts) != 4:
|
||||
result.errors.append(f"Invalid line (need 4 fields): {line[:50]}")
|
||||
result.skipped += 1
|
||||
continue
|
||||
login, password, email, email_password = parts
|
||||
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)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(login, password, email or None, email_password 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)
|
||||
dest = settings.mafiles_dir / f"{account_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:
|
||||
raise HTTPException(status_code=400, detail="Invalid mafile format")
|
||||
|
||||
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
steam_id = mafile.Session.SteamID or row["steam_id"] or account_id
|
||||
mafile_path = settings.mafiles_dir / f"{steam_id}.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)}
|
||||
@@ -0,0 +1,86 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ActionRequest
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/actions", tags=["actions"])
|
||||
|
||||
VALID_ACTIONS = {
|
||||
"change_password",
|
||||
"random_password",
|
||||
"change_email",
|
||||
"change_phone",
|
||||
"remove_guard",
|
||||
"generate_2fa",
|
||||
"validate",
|
||||
"add_friend",
|
||||
"accept_logins",
|
||||
"change_language",
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def execute_action(request: ActionRequest):
|
||||
if request.action not in VALID_ACTIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid action. Valid: {', '.join(sorted(VALID_ACTIONS))}",
|
||||
)
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in request.account_ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM accounts WHERE id IN ({placeholders})",
|
||||
request.account_ids,
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type=request.action,
|
||||
accounts=accounts,
|
||||
params=request.params or {},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Action '{request.action}' submitted for {len(accounts)} accounts → task {task_id}"
|
||||
)
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
class Generate2FARequest(BaseModel):
|
||||
shared_secret: str
|
||||
|
||||
|
||||
class Generate2FAByAccountRequest(BaseModel):
|
||||
account_id: int
|
||||
|
||||
|
||||
@router.post("/generate-2fa")
|
||||
async def generate_2fa_code(request: Generate2FARequest):
|
||||
"""Server-side 2FA code generation from shared_secret."""
|
||||
from app.services.steam_guard import generate_2fa_code as gen_code
|
||||
|
||||
code = gen_code(request.shared_secret)
|
||||
return {"code": code}
|
||||
|
||||
|
||||
@router.post("/generate-2fa-by-account")
|
||||
async def generate_2fa_by_account(request: Generate2FAByAccountRequest):
|
||||
"""Generate 2FA code for an account using its stored shared_secret."""
|
||||
from app.services.steam_guard import generate_2fa_code as gen_code
|
||||
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT shared_secret FROM accounts WHERE id = ?",
|
||||
(request.account_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row["shared_secret"]:
|
||||
raise HTTPException(status_code=404, detail="Account not found or has no shared_secret")
|
||||
return {"code": gen_code(row["shared_secret"])}
|
||||
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auto_accept import auto_accept_manager
|
||||
|
||||
router = APIRouter(prefix="/api/auto-accept", tags=["auto-accept"])
|
||||
|
||||
|
||||
class AutoAcceptRequest(BaseModel):
|
||||
account_ids: list[int]
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_auto_accept(body: AutoAcceptRequest):
|
||||
db = await get_db()
|
||||
started: list[int] = []
|
||||
for aid in body.account_ids:
|
||||
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (aid,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Account {aid} not found")
|
||||
account = dict(row)
|
||||
if not account.get("shared_secret"):
|
||||
raise HTTPException(400, f"Account {account['login']} has no shared_secret (mafile)")
|
||||
await auto_accept_manager.start(account)
|
||||
await db.execute("UPDATE accounts SET auto_accept = 1 WHERE id = ?", (aid,))
|
||||
started.append(aid)
|
||||
await db.commit()
|
||||
return {"started": started}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_auto_accept(body: AutoAcceptRequest):
|
||||
db = await get_db()
|
||||
stopped: list[int] = []
|
||||
for aid in body.account_ids:
|
||||
auto_accept_manager.stop(aid)
|
||||
await db.execute("UPDATE accounts SET auto_accept = 0 WHERE id = ?", (aid,))
|
||||
stopped.append(aid)
|
||||
await db.commit()
|
||||
return {"stopped": stopped}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auto_accept_status():
|
||||
running = auto_accept_manager.running_ids()
|
||||
errors = auto_accept_manager.pop_errors()
|
||||
return {"running": list(running), "errors": errors}
|
||||
@@ -0,0 +1,65 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import read_validation_settings
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["browser"])
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/browser")
|
||||
async def open_browser(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(404, "Account not found")
|
||||
|
||||
account = dict(row)
|
||||
|
||||
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
|
||||
from app.config import read_validation_settings
|
||||
|
||||
# If account isn't validated or has no cookies — auto-revalidate if enabled
|
||||
if account["status"] != "valid" or not account.get("session_cookies"):
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Account {account['login']} not valid/no cookies, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
if account["status"] != "valid":
|
||||
raise HTTPException(400, "Account is not validated")
|
||||
raise HTTPException(400, "No session cookies. Validate the account first.")
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
alive = await check_cookies_alive(account["session_cookies"], proxy)
|
||||
if not alive:
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
raise HTTPException(400, "Session cookies expired. Re-validate the account.")
|
||||
|
||||
from app.services.browser_login import open_browser_with_cookies
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await open_browser_with_cookies(account)
|
||||
except Exception as exc:
|
||||
logger.error(f"Browser open failed for {account['login']}: {exc}")
|
||||
|
||||
asyncio.create_task(_run())
|
||||
|
||||
return {"status": "ok", "message": "Browser opening..."}
|
||||
@@ -0,0 +1,356 @@
|
||||
"""CRUD and validate endpoints for log:pass accounts."""
|
||||
|
||||
import csv
|
||||
import ctypes
|
||||
import io
|
||||
import sys
|
||||
|
||||
csv.field_size_limit(min(sys.maxsize, ctypes.c_ulong(-1).value // 2))
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
LogpassAccountCreate,
|
||||
LogpassAccountOut,
|
||||
LogpassAccountUpdate,
|
||||
)
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/logpass", tags=["logpass"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[LogpassAccountOut])
|
||||
async def list_logpass():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM logpass_accounts ORDER BY id DESC")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=LogpassAccountOut)
|
||||
async def get_logpass(account_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM logpass_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=LogpassAccountOut, status_code=201)
|
||||
async def create_logpass(account: LogpassAccountCreate):
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO logpass_accounts (login, password, steam_id, proxy, notes) VALUES (?, ?, ?, ?, ?)",
|
||||
(account.login, account.password, account.steam_id, account.proxy, account.notes),
|
||||
)
|
||||
await db.commit()
|
||||
new_cursor = await db.execute(
|
||||
"SELECT * FROM logpass_accounts WHERE id = ?", (cursor.lastrowid,)
|
||||
)
|
||||
return dict(await new_cursor.fetchone())
|
||||
|
||||
|
||||
@router.put("/{account_id}", response_model=LogpassAccountOut)
|
||||
async def update_logpass(account_id: int, account: LogpassAccountUpdate):
|
||||
db = await get_db()
|
||||
fields = {k: v for k, v in account.model_dump().items() if v is not 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 logpass_accounts SET {set_clause} WHERE id = ?", values
|
||||
)
|
||||
await db.commit()
|
||||
cursor = await db.execute("SELECT * FROM logpass_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_logpass(account_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM logpass_accounts WHERE id = ?", (account_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/delete-bulk")
|
||||
async def delete_logpass_bulk(data: dict):
|
||||
ids: list[int] = data.get("ids", [])
|
||||
if not ids:
|
||||
return {"deleted": 0}
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
await db.execute(f"DELETE FROM logpass_accounts WHERE id IN ({placeholders})", ids)
|
||||
await db.commit()
|
||||
return {"deleted": len(ids)}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_logpass(data: dict):
|
||||
"""Bulk import from plain text (login:pass / login|pass) or CSV with headers."""
|
||||
lines: list[str] = data.get("lines", [])
|
||||
if not lines:
|
||||
return {"imported": 0, "skipped": 0, "errors": []}
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
db = await get_db()
|
||||
|
||||
async def upsert(login: str, fields: dict):
|
||||
"""Insert or update by login, works with or without UNIQUE index."""
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM logpass_accounts WHERE login = ?", (login,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
sets = ", ".join(f"{k} = ?" for k in fields)
|
||||
vals = list(fields.values()) + [existing["id"]]
|
||||
await db.execute(
|
||||
f"UPDATE logpass_accounts SET {sets}, updated_at = datetime('now') WHERE id = ?",
|
||||
vals,
|
||||
)
|
||||
else:
|
||||
fields["login"] = login
|
||||
cols = ", ".join(fields.keys())
|
||||
placeholders = ", ".join("?" for _ in fields)
|
||||
await db.execute(
|
||||
f"INSERT INTO logpass_accounts ({cols}) VALUES ({placeholders})",
|
||||
list(fields.values()),
|
||||
)
|
||||
|
||||
# Detect CSV: first line looks like a header row
|
||||
first = lines[0].strip().lower()
|
||||
is_csv = first.startswith("login,") or first.startswith("login;")
|
||||
|
||||
try:
|
||||
if is_csv:
|
||||
text = "\n".join(lines)
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
for row in reader:
|
||||
login = (row.get("login") or "").strip()
|
||||
password = (row.get("password") or "").strip()
|
||||
if not login or not password:
|
||||
skipped += 1
|
||||
continue
|
||||
fields = {"password": password}
|
||||
_NA = {"n/a", "na", "none", "null", ""}
|
||||
for csv_col, db_col in [
|
||||
("steam_id", "steam_id"), ("ban", "ban_status"),
|
||||
("prime", "prime"), ("trophy", "trophy"),
|
||||
("behavior", "behavior"), ("license", "license"),
|
||||
]:
|
||||
val = (row.get(csv_col) or "").strip()
|
||||
if val:
|
||||
if db_col in ("prime", "trophy", "behavior") and val.lower() in _NA:
|
||||
val = "\u2014"
|
||||
fields[db_col] = val
|
||||
try:
|
||||
await upsert(login, fields)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
logger.error(f"Logpass CSV import error for '{login}': {exc}")
|
||||
errors.append(f"{login}: {exc}")
|
||||
else:
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
skipped += 1
|
||||
continue
|
||||
# Skip JSON/mafile lines
|
||||
if line.startswith("{") or line.startswith("["):
|
||||
skipped += 1
|
||||
continue
|
||||
if "|" in line:
|
||||
parts = line.split("|", 1)
|
||||
elif ":" in line:
|
||||
parts = line.split(":", 1)
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
login, password = parts[0].strip(), parts[1].strip()
|
||||
if not login or not password:
|
||||
skipped += 1
|
||||
continue
|
||||
# Reject if password looks like JSON (mafile embedded) or is too long
|
||||
if password.startswith("{") or password.startswith("[") or len(password) > 128:
|
||||
skipped += 1
|
||||
logger.warning(f"Logpass import: skipped '{login}' — password looks like mafile or is too long ({len(password)} chars)")
|
||||
continue
|
||||
try:
|
||||
await upsert(login, {"password": password})
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
logger.error(f"Logpass import error for '{login}': {exc}")
|
||||
errors.append(f"{login}: {exc}")
|
||||
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.exception(f"Logpass import failed: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_logpass(data: dict):
|
||||
"""Submit validation task for selected log:pass accounts."""
|
||||
ids: list[int] = data.get("account_ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided")
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM logpass_accounts WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type="logpass_validate",
|
||||
accounts=accounts,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"logpass_validate submitted for {len(accounts)} accounts → task {task_id}")
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
@router.post("/full-parse")
|
||||
async def full_parse_logpass(data: dict):
|
||||
"""Submit full parse task (prime, trophy, behavior, licenses) for selected accounts."""
|
||||
ids: list[int] = data.get("account_ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided")
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM logpass_accounts WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type="logpass_full_parse",
|
||||
accounts=accounts,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"logpass_full_parse submitted for {len(accounts)} accounts → task {task_id}")
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
@router.post("/assign-proxies")
|
||||
async def logpass_assign_proxies():
|
||||
"""Round-robin assign proxies to logpass accounts that don't have one."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] 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 logpass_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):
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/reassign-proxies")
|
||||
async def logpass_reassign_proxies():
|
||||
"""Round-robin reassign proxies to ALL logpass accounts."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] 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 logpass_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):
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/clear-proxies")
|
||||
async def logpass_clear_proxies():
|
||||
"""Remove proxy from all logpass accounts."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"UPDATE logpass_accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''"
|
||||
)
|
||||
await db.commit()
|
||||
return {"cleared": cursor.rowcount}
|
||||
|
||||
|
||||
@router.post("/{account_id}/browser")
|
||||
async def open_logpass_browser(account_id: int):
|
||||
"""Open Chrome browser with saved session cookies for a logpass account."""
|
||||
import asyncio
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM logpass_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
account = dict(row)
|
||||
if not account.get("session_cookies"):
|
||||
raise HTTPException(status_code=400, detail="No session cookies. Validate the account first.")
|
||||
|
||||
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
|
||||
from app.config import read_validation_settings
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
alive = await check_cookies_alive(account["session_cookies"], proxy)
|
||||
if not alive:
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="logpass_validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
raise HTTPException(status_code=400, detail="Session cookies expired. Re-validate the account.")
|
||||
|
||||
from app.services.browser_login import open_browser_with_cookies
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await open_browser_with_cookies(account)
|
||||
except Exception as exc:
|
||||
logger.error(f"Browser open failed for {account['login']}: {exc}")
|
||||
|
||||
asyncio.create_task(_run())
|
||||
return {"status": "ok", "message": "Browser opening..."}
|
||||
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
_LOG_BUFFER: deque[dict[str, Any]] = deque(maxlen=500)
|
||||
_SUBSCRIBERS: list[asyncio.Queue] = []
|
||||
|
||||
LEVEL_ICONS = {
|
||||
"TRACE": "~",
|
||||
"DEBUG": ".",
|
||||
"INFO": "i",
|
||||
"SUCCESS": "+",
|
||||
"WARNING": "!",
|
||||
"ERROR": "e",
|
||||
"CRITICAL": "e",
|
||||
}
|
||||
|
||||
|
||||
def _log_sink(message: Any) -> None:
|
||||
record = message.record
|
||||
entry = {
|
||||
"ts": record["time"].strftime("%H:%M:%S"),
|
||||
"level": record["level"].name.lower(),
|
||||
"icon": LEVEL_ICONS.get(record["level"].name, "i"),
|
||||
"msg": record["message"],
|
||||
}
|
||||
_LOG_BUFFER.append(entry)
|
||||
for q in _SUBSCRIBERS[:]:
|
||||
try:
|
||||
q.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
def install_log_sink() -> None:
|
||||
logger.add(_log_sink, format="{message}", level="DEBUG", colorize=False)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_logs():
|
||||
return list(_LOG_BUFFER)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream_logs():
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=200)
|
||||
_SUBSCRIBERS.append(queue)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
entry = await queue.get()
|
||||
yield f"data: {json.dumps(entry, ensure_ascii=False)}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
if queue in _SUBSCRIBERS:
|
||||
_SUBSCRIBERS.remove(queue)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,275 @@
|
||||
import json
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import MafileData
|
||||
|
||||
router = APIRouter(prefix="/api/mafiles", tags=["mafile_tools"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_mafiles():
|
||||
"""List all stored mafiles."""
|
||||
mafiles_dir = settings.mafiles_dir
|
||||
if not mafiles_dir.exists():
|
||||
return []
|
||||
|
||||
result = []
|
||||
for f in sorted(mafiles_dir.glob("*.mafile")):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
result.append({
|
||||
"filename": f.name,
|
||||
"account_name": data.get("account_name", ""),
|
||||
"steam_id": data.get("Session", {}).get("SteamID", ""),
|
||||
"has_shared_secret": bool(data.get("shared_secret")),
|
||||
"has_identity_secret": bool(data.get("identity_secret")),
|
||||
"fully_enrolled": data.get("fully_enrolled", False),
|
||||
})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
result.append({"filename": f.name, "error": "Failed to parse"})
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_mafiles(files: list[UploadFile] = File(...)):
|
||||
"""Upload multiple .mafile files."""
|
||||
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
uploaded = 0
|
||||
errors: list[str] = []
|
||||
|
||||
db = await get_db()
|
||||
|
||||
for file in files:
|
||||
content = (await file.read()).decode("utf-8", errors="ignore")
|
||||
try:
|
||||
mafile = MafileData.model_validate_json(content)
|
||||
steam_id = mafile.Session.SteamID or mafile.account_name or file.filename
|
||||
dest = settings.mafiles_dir / f"{steam_id}.mafile"
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
uploaded += 1
|
||||
|
||||
if mafile.account_name:
|
||||
await db.execute(
|
||||
"""UPDATE accounts
|
||||
SET mafile_path = ?, shared_secret = ?, identity_secret = ?
|
||||
WHERE login = ?""",
|
||||
(
|
||||
str(dest),
|
||||
mafile.shared_secret or "",
|
||||
mafile.identity_secret or "",
|
||||
mafile.account_name,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
errors.append(f"{file.filename}: {exc}")
|
||||
|
||||
logger.info(f"Uploaded {uploaded} mafiles, {len(errors)} errors")
|
||||
return {"uploaded": uploaded, "errors": errors}
|
||||
|
||||
|
||||
@router.get("/{filename}")
|
||||
async def get_mafile(filename: str):
|
||||
"""Get parsed mafile data by filename."""
|
||||
safe_name = Path(filename).name
|
||||
mafile_path = settings.mafiles_dir / safe_name
|
||||
if not mafile_path.exists() or not mafile_path.suffix == ".mafile":
|
||||
raise HTTPException(status_code=404, detail="Mafile not found")
|
||||
|
||||
content = mafile_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=500, detail="Failed to parse mafile")
|
||||
|
||||
|
||||
@router.delete("/{filename}", status_code=204)
|
||||
async def delete_mafile(filename: str):
|
||||
safe_name = Path(filename).name
|
||||
mafile_path = settings.mafiles_dir / safe_name
|
||||
if not mafile_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Mafile not found")
|
||||
mafile_path.unlink()
|
||||
|
||||
|
||||
@router.get("/export/all")
|
||||
async def export_all_secrets():
|
||||
"""Export account_name:shared_secret:identity_secret for all mafiles."""
|
||||
mafiles_dir = settings.mafiles_dir
|
||||
if not mafiles_dir.exists():
|
||||
return []
|
||||
|
||||
result = []
|
||||
for f in sorted(mafiles_dir.glob("*.mafile")):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
result.append({
|
||||
"account_name": data.get("account_name", ""),
|
||||
"shared_secret": data.get("shared_secret", ""),
|
||||
"identity_secret": data.get("identity_secret", ""),
|
||||
"steam_id": data.get("Session", {}).get("SteamID", ""),
|
||||
})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
fields: list[str] = []
|
||||
session_fields: list[str] = []
|
||||
format: str = "flat_mafiles" # flat_mafiles | per_account_folder | single_file
|
||||
account_ids: list[int] = []
|
||||
# Naming variables: {username} and {steamid} are replaced at export time
|
||||
folder_name_template: str = "{username}"
|
||||
mafile_name_template: str = "{steamid}.mafile"
|
||||
txt_name_template: str = "{username}.txt"
|
||||
include_txt_per_folder: bool = False
|
||||
include_global_txt: bool = False
|
||||
skip_folders: bool = False
|
||||
# Format for .txt lines: login:password:email:email_password
|
||||
txt_format: str = "{login}:{password}:{email}:{email_password}"
|
||||
|
||||
|
||||
def _filter_mafile(data: dict, fields: list[str], session_fields: list[str]) -> dict:
|
||||
"""Keep only selected top-level + Session fields from mafile JSON."""
|
||||
if not fields and not session_fields:
|
||||
return data
|
||||
|
||||
filtered = {}
|
||||
for key in fields:
|
||||
if key in data and key != "Session":
|
||||
filtered[key] = data[key]
|
||||
|
||||
if session_fields and "Session" in data:
|
||||
filtered["Session"] = {
|
||||
k: v for k, v in data["Session"].items() if k in session_fields
|
||||
}
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
@router.post("/export/zip")
|
||||
async def export_mafiles_zip(body: ExportRequest):
|
||||
"""Build a .zip with filtered mafile data, optionally limited to account IDs."""
|
||||
if not body.account_ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided for export")
|
||||
|
||||
mafiles_dir = settings.mafiles_dir
|
||||
if not mafiles_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="No mafiles directory")
|
||||
|
||||
db = await get_db()
|
||||
|
||||
# Load account info for naming and .txt generation
|
||||
account_map: dict[str, dict] = {} # login -> account row
|
||||
if body.account_ids:
|
||||
placeholders = ",".join("?" * len(body.account_ids))
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM accounts WHERE id IN ({placeholders})",
|
||||
body.account_ids,
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute("SELECT * FROM accounts")
|
||||
for row in await cursor.fetchall():
|
||||
account_map[row["login"]] = dict(row)
|
||||
|
||||
account_logins = set(account_map.keys()) if body.account_ids else set()
|
||||
|
||||
def _resolve_template(template: str, username: str, steam_id: str) -> str:
|
||||
return template.replace("{username}", username).replace("{steamid}", steam_id)
|
||||
|
||||
def _resolve_txt_line(template: str, acc: dict, mafile_data: dict | None = None) -> str:
|
||||
has_mafile_var = "{mafile}" in template
|
||||
if has_mafile_var:
|
||||
before, _, after = template.partition("{mafile}")
|
||||
else:
|
||||
before = template
|
||||
|
||||
line = (
|
||||
before
|
||||
.replace("{login}", acc.get("login") or "")
|
||||
.replace("{password}", acc.get("password") or "")
|
||||
.replace("{email}", acc.get("email") or "")
|
||||
.replace("{email_password}", acc.get("email_password") or "")
|
||||
.replace("{steam_id}", acc.get("steam_id") or "")
|
||||
.replace("{proxy}", acc.get("proxy") or "")
|
||||
)
|
||||
line = line.rstrip(":")
|
||||
if has_mafile_var and mafile_data is not None:
|
||||
line += ":" + json.dumps(mafile_data, ensure_ascii=False, separators=(",", ":"))
|
||||
return line
|
||||
|
||||
buf = io.BytesIO()
|
||||
all_data: list[str] = []
|
||||
global_txt_lines: list[str] = []
|
||||
folder_txt_map: dict[str, list[str]] = {} # folder_name -> lines
|
||||
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in sorted(mafiles_dir.glob("*.mafile")):
|
||||
try:
|
||||
raw = json.loads(f.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
acct_name = raw.get("account_name", "")
|
||||
steam_id = str(raw.get("Session", {}).get("SteamID", ""))
|
||||
|
||||
if account_logins:
|
||||
if acct_name not in account_logins and steam_id not in {str(a) for a in body.account_ids}:
|
||||
continue
|
||||
|
||||
filtered = _filter_mafile(raw, body.fields, body.session_fields)
|
||||
|
||||
# Find matching account record for txt
|
||||
acc = account_map.get(acct_name, {})
|
||||
|
||||
if body.format == "single_file":
|
||||
if acc:
|
||||
all_data.append(_resolve_txt_line(body.txt_format, acc, filtered))
|
||||
else:
|
||||
all_data.append(json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
elif body.format == "per_account_folder":
|
||||
folder_name = _resolve_template(body.folder_name_template, acct_name, steam_id)
|
||||
mafile_name = _resolve_template(body.mafile_name_template, acct_name, steam_id)
|
||||
|
||||
if body.skip_folders:
|
||||
zf.writestr(mafile_name, json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
else:
|
||||
zf.writestr(f"{folder_name}/{mafile_name}", json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
|
||||
if body.include_txt_per_folder and acc:
|
||||
txt_name = _resolve_template(body.txt_name_template, acct_name, steam_id)
|
||||
line = _resolve_txt_line(body.txt_format, acc, filtered)
|
||||
if folder_name not in folder_txt_map:
|
||||
folder_txt_map[folder_name] = []
|
||||
folder_txt_map[folder_name].append(line)
|
||||
zf.writestr(f"{folder_name}/{txt_name}", line + "\n")
|
||||
else:
|
||||
mafile_name = _resolve_template(body.mafile_name_template, acct_name, steam_id)
|
||||
zf.writestr(mafile_name, json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
|
||||
# Collect global txt line
|
||||
if body.include_global_txt and acc:
|
||||
global_txt_lines.append(_resolve_txt_line(body.txt_format, acc, filtered))
|
||||
|
||||
if body.format == "single_file" and all_data:
|
||||
zf.writestr("accounts.txt", "\n".join(all_data) + "\n")
|
||||
|
||||
if body.include_global_txt and global_txt_lines:
|
||||
zf.writestr("accounts.txt", "\n".join(global_txt_lines) + "\n")
|
||||
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": "attachment; filename=mafiles_export.zip"},
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ProxyCreate, ProxyOut
|
||||
|
||||
router = APIRouter(prefix="/api/proxies", tags=["proxies"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProxyOut])
|
||||
async def list_proxies():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM proxies ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=ProxyOut, status_code=201)
|
||||
async def add_proxy(proxy: ProxyCreate):
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO proxies (address, protocol) VALUES (?, ?)",
|
||||
(proxy.address, proxy.protocol),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail="Proxy already exists")
|
||||
|
||||
new_cursor = await db.execute(
|
||||
"SELECT * FROM proxies WHERE id = ?", (cursor.lastrowid,)
|
||||
)
|
||||
return dict(await new_cursor.fetchone())
|
||||
|
||||
|
||||
@router.delete("/all", status_code=200)
|
||||
async def delete_all_proxies():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT COUNT(*) as cnt FROM proxies")
|
||||
row = await cursor.fetchone()
|
||||
count = row["cnt"]
|
||||
await db.execute("DELETE FROM proxies")
|
||||
await db.commit()
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
await proxy_manager.load()
|
||||
logger.info(f"Deleted all {count} proxies")
|
||||
return {"deleted": count}
|
||||
|
||||
|
||||
@router.delete("/{proxy_id}", status_code=204)
|
||||
async def delete_proxy(proxy_id: int):
|
||||
db = await get_db()
|
||||
result = await db.execute("DELETE FROM proxies WHERE id = ?", (proxy_id,))
|
||||
await db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Proxy not found")
|
||||
|
||||
|
||||
@router.post("/bulk")
|
||||
async def bulk_add_proxies(proxies: list[ProxyCreate]):
|
||||
db = await get_db()
|
||||
added = 0
|
||||
for p in proxies:
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO proxies (address, protocol) VALUES (?, ?)",
|
||||
(p.address, p.protocol),
|
||||
)
|
||||
added += 1
|
||||
except Exception:
|
||||
pass
|
||||
await db.commit()
|
||||
logger.info(f"Bulk added {added} proxies")
|
||||
return {"added": added}
|
||||
|
||||
|
||||
async def _check_one_proxy(proxy_id: int, address: str, protocol: str) -> dict:
|
||||
"""Check proxy by making a real HTTP request through it."""
|
||||
import aiohttp
|
||||
from aiohttp_socks import ProxyConnector
|
||||
from app.core.proxy_manager import build_proxy_url
|
||||
|
||||
try:
|
||||
proxy_url = build_proxy_url(address, protocol)
|
||||
connector = ProxyConnector.from_url(proxy_url, ssl=False)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
async with session.get(
|
||||
"https://steamcommunity.com/robots.txt",
|
||||
timeout=aiohttp.ClientTimeout(total=3),
|
||||
) as resp:
|
||||
alive = resp.status == 200
|
||||
except Exception:
|
||||
alive = False
|
||||
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE proxies SET is_alive = ?, last_checked = datetime('now') WHERE id = ?",
|
||||
(int(alive), proxy_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"id": proxy_id, "alive": alive}
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
async def check_proxies():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
if not rows:
|
||||
return {"total": 0, "alive": 0, "dead": 0}
|
||||
|
||||
sem = asyncio.Semaphore(300)
|
||||
|
||||
async def limited(row):
|
||||
async with sem:
|
||||
return await _check_one_proxy(row["id"], row["address"], row["protocol"])
|
||||
|
||||
results = await asyncio.gather(*(limited(r) for r in rows))
|
||||
alive_count = sum(1 for r in results if r["alive"])
|
||||
return {"total": len(results), "alive": alive_count, "dead": len(results) - alive_count}
|
||||
@@ -0,0 +1,210 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
SETTINGS_FILE = settings.data_dir / "settings.json"
|
||||
|
||||
DEFAULTS = {
|
||||
"validation": {
|
||||
"fetch_profile": True,
|
||||
"check_ban": True,
|
||||
"max_threads": 5,
|
||||
"auto_revalidate_browser": True,
|
||||
},
|
||||
"display": {
|
||||
"hide_passwords": False,
|
||||
},
|
||||
"columns": {
|
||||
"browser": True,
|
||||
"profile": True,
|
||||
"steam_id": True,
|
||||
"login": True,
|
||||
"password": True,
|
||||
"login_pass": True,
|
||||
"email": True,
|
||||
"phone": True,
|
||||
"status": True,
|
||||
"ban": True,
|
||||
"twofa": True,
|
||||
"mafile": True,
|
||||
"proxy": True,
|
||||
"actions": True,
|
||||
},
|
||||
"logpass_columns": {
|
||||
"browser": True,
|
||||
"profile": True,
|
||||
"steam_id": True,
|
||||
"login": True,
|
||||
"password": True,
|
||||
"login_pass": True,
|
||||
"status": True,
|
||||
"ban": True,
|
||||
"prime": True,
|
||||
"trophy": True,
|
||||
"behavior": True,
|
||||
"license": True,
|
||||
"proxy": True,
|
||||
"notes": True,
|
||||
"actions": True,
|
||||
},
|
||||
"token_columns": {
|
||||
"browser": True,
|
||||
"profile": True,
|
||||
"last_online": True,
|
||||
"steam_id": True,
|
||||
"login": True,
|
||||
"token": True,
|
||||
"status": True,
|
||||
"proxy": True,
|
||||
"notes": True,
|
||||
"actions": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
if SETTINGS_FILE.exists():
|
||||
return json.loads(SETTINGS_FILE.read_text("utf-8"))
|
||||
return DEFAULTS.copy()
|
||||
|
||||
|
||||
def _write(data: dict) -> None:
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8")
|
||||
|
||||
|
||||
class ValidationSettings(BaseModel):
|
||||
fetch_profile: bool = True
|
||||
check_ban: bool = True
|
||||
max_threads: int = 5
|
||||
auto_revalidate_browser: bool = True
|
||||
|
||||
|
||||
class DisplaySettings(BaseModel):
|
||||
hide_passwords: bool = False
|
||||
|
||||
|
||||
class ColumnSettings(BaseModel):
|
||||
browser: bool = True
|
||||
profile: bool = True
|
||||
steam_id: bool = True
|
||||
login: bool = True
|
||||
password: bool = True
|
||||
login_pass: bool = True
|
||||
email: bool = True
|
||||
phone: bool = True
|
||||
status: bool = True
|
||||
ban: bool = True
|
||||
twofa: bool = True
|
||||
mafile: bool = True
|
||||
proxy: bool = True
|
||||
actions: bool = True
|
||||
last_online: bool = True
|
||||
|
||||
|
||||
@router.get("/validation", response_model=ValidationSettings)
|
||||
async def get_validation_settings():
|
||||
data = _read()
|
||||
return data.get("validation", DEFAULTS["validation"])
|
||||
|
||||
|
||||
@router.put("/validation", response_model=ValidationSettings)
|
||||
async def update_validation_settings(body: ValidationSettings):
|
||||
data = _read()
|
||||
data["validation"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["validation"]
|
||||
|
||||
|
||||
@router.get("/display", response_model=DisplaySettings)
|
||||
async def get_display_settings():
|
||||
data = _read()
|
||||
return data.get("display", DEFAULTS["display"])
|
||||
|
||||
|
||||
@router.put("/display", response_model=DisplaySettings)
|
||||
async def update_display_settings(body: DisplaySettings):
|
||||
data = _read()
|
||||
data["display"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["display"]
|
||||
|
||||
|
||||
@router.get("/columns", response_model=ColumnSettings)
|
||||
async def get_column_settings():
|
||||
data = _read()
|
||||
return data.get("columns", DEFAULTS["columns"])
|
||||
|
||||
|
||||
@router.put("/columns", response_model=ColumnSettings)
|
||||
async def update_column_settings(body: ColumnSettings):
|
||||
data = _read()
|
||||
data["columns"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["columns"]
|
||||
|
||||
|
||||
class LogpassColumnSettings(BaseModel):
|
||||
browser: bool = True
|
||||
profile: bool = True
|
||||
steam_id: bool = True
|
||||
login: bool = True
|
||||
password: bool = True
|
||||
login_pass: bool = True
|
||||
status: bool = True
|
||||
ban: bool = True
|
||||
prime: bool = True
|
||||
trophy: bool = True
|
||||
behavior: bool = True
|
||||
license: bool = True
|
||||
proxy: bool = True
|
||||
notes: bool = True
|
||||
actions: bool = True
|
||||
last_online: bool = True
|
||||
|
||||
|
||||
@router.get("/logpass-columns", response_model=LogpassColumnSettings)
|
||||
async def get_logpass_column_settings():
|
||||
data = _read()
|
||||
return data.get("logpass_columns", DEFAULTS["logpass_columns"])
|
||||
|
||||
|
||||
@router.put("/logpass-columns", response_model=LogpassColumnSettings)
|
||||
async def update_logpass_column_settings(body: LogpassColumnSettings):
|
||||
data = _read()
|
||||
data["logpass_columns"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["logpass_columns"]
|
||||
|
||||
|
||||
class TokenColumnSettings(BaseModel):
|
||||
browser: bool = True
|
||||
profile: bool = True
|
||||
last_online: bool = True
|
||||
steam_id: bool = True
|
||||
login: bool = True
|
||||
token: bool = True
|
||||
status: bool = True
|
||||
proxy: bool = True
|
||||
notes: bool = True
|
||||
actions: bool = True
|
||||
|
||||
|
||||
@router.get("/token-columns", response_model=TokenColumnSettings)
|
||||
async def get_token_column_settings():
|
||||
data = _read()
|
||||
return data.get("token_columns", DEFAULTS["token_columns"])
|
||||
|
||||
|
||||
@router.put("/token-columns", response_model=TokenColumnSettings)
|
||||
async def update_token_column_settings(body: TokenColumnSettings):
|
||||
data = _read()
|
||||
data["token_columns"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["token_columns"]
|
||||
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import TaskOut
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tasks ORDER BY created_at DESC LIMIT 50")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskOut)
|
||||
async def get_task(task_id: str):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/{task_id}/stream")
|
||||
async def stream_task(task_id: str):
|
||||
"""SSE endpoint for real-time task progress."""
|
||||
|
||||
async def event_generator():
|
||||
last_data = None
|
||||
while True:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
yield f"data: {json.dumps({'error': 'Task not found'})}\n\n"
|
||||
break
|
||||
|
||||
task = dict(row)
|
||||
# Parse JSON string fields from DB into dicts for proper serialization
|
||||
for json_field in ("account_ids", "account_results"):
|
||||
if isinstance(task.get(json_field), str):
|
||||
try:
|
||||
task[json_field] = json.loads(task[json_field])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
prompt_info = task_manager.get_pending_prompt(task_id)
|
||||
if prompt_info:
|
||||
task["prompt"] = prompt_info["message"]
|
||||
task["prompt_login"] = prompt_info["login"]
|
||||
step_info = task_manager.get_step_info(task_id)
|
||||
if step_info:
|
||||
task["step"] = step_info["step"]
|
||||
task["total_steps"] = step_info["total_steps"]
|
||||
task["step_label"] = step_info["label"]
|
||||
active = task_manager.get_active_count(task_id)
|
||||
if active > 0:
|
||||
task["active_count"] = active
|
||||
acc_results = task_manager.get_account_results(task_id)
|
||||
if acc_results:
|
||||
task["account_results"] = {str(k): v for k, v in acc_results.items()}
|
||||
acc_steps = task_manager.get_account_steps(task_id)
|
||||
if acc_steps:
|
||||
task["account_steps"] = {str(k): v for k, v in acc_steps.items()}
|
||||
|
||||
data_str = json.dumps(task)
|
||||
if data_str != last_data or task["status"] in ("completed", "failed", "cancelled"):
|
||||
last_data = data_str
|
||||
yield f"data: {data_str}\n\n"
|
||||
|
||||
if task["status"] in ("completed", "failed", "cancelled"):
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/{task_id}/respond")
|
||||
async def respond_to_prompt(task_id: str, body: dict):
|
||||
"""Provide user input for a pending task prompt."""
|
||||
value = body.get("value", "")
|
||||
login = body.get("login", "")
|
||||
if not task_manager.respond(task_id, value, login):
|
||||
raise HTTPException(status_code=404, detail="No pending prompt for this task")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
async def cancel_task(task_id: str):
|
||||
cancelled = task_manager.cancel(task_id)
|
||||
if not cancelled:
|
||||
raise HTTPException(status_code=404, detail="Task not found or already finished")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""CRUD and validate endpoints for token accounts."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.task_manager import task_manager
|
||||
from app.models import TokenAccountCreate, TokenAccountOut, TokenAccountUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/token-accounts", tags=["token-accounts"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenAccountOut])
|
||||
async def list_tokens():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM token_accounts ORDER BY id DESC")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=TokenAccountOut)
|
||||
async def get_token(account_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM token_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=TokenAccountOut, status_code=201)
|
||||
async def create_token(account: TokenAccountCreate):
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO token_accounts (login, token, steam_id, proxy, notes) VALUES (?, ?, ?, ?, ?)",
|
||||
(account.login, account.token, account.steam_id, account.proxy, account.notes),
|
||||
)
|
||||
await db.commit()
|
||||
new_cursor = await db.execute(
|
||||
"SELECT * FROM token_accounts WHERE id = ?", (cursor.lastrowid,)
|
||||
)
|
||||
return dict(await new_cursor.fetchone())
|
||||
|
||||
|
||||
@router.put("/{account_id}", response_model=TokenAccountOut)
|
||||
async def update_token(account_id: int, account: TokenAccountUpdate):
|
||||
db = await get_db()
|
||||
fields = {k: v for k, v in account.model_dump().items() if v is not 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 token_accounts SET {set_clause} WHERE id = ?", values)
|
||||
await db.commit()
|
||||
cursor = await db.execute("SELECT * FROM token_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_token(account_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM token_accounts WHERE id = ?", (account_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/delete-bulk")
|
||||
async def delete_token_bulk(data: dict):
|
||||
ids: list[int] = data.get("ids", [])
|
||||
if not ids:
|
||||
return {"deleted": 0}
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
await db.execute(f"DELETE FROM token_accounts WHERE id IN ({placeholders})", ids)
|
||||
await db.commit()
|
||||
return {"deleted": len(ids)}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_tokens(data: dict):
|
||||
"""Bulk import: one token per line, optionally 'login:token'."""
|
||||
lines: list[str] = data.get("lines", [])
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
db = await get_db()
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
skipped += 1
|
||||
continue
|
||||
login = None
|
||||
token = line
|
||||
if ":" in line:
|
||||
parts = line.split(":", 1)
|
||||
login, token = parts[0].strip(), parts[1].strip()
|
||||
if not token:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO token_accounts (login, token) VALUES (?, ?)",
|
||||
(login, token),
|
||||
)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
errors.append(f"{token[:20]}: {exc}")
|
||||
await db.commit()
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_tokens(data: dict):
|
||||
"""Submit validation task for selected token accounts."""
|
||||
ids: list[int] = data.get("account_ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided")
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM token_accounts WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type="token_validate",
|
||||
accounts=accounts,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"token_validate submitted for {len(accounts)} accounts → task {task_id}")
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
@router.post("/{account_id}/browser")
|
||||
async def open_token_browser(account_id: int):
|
||||
"""Open Chrome browser with saved session cookies for a token account."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
account = dict(row)
|
||||
if not account.get("session_cookies"):
|
||||
raise HTTPException(status_code=400, detail="No session cookies. Validate the account first.")
|
||||
|
||||
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
|
||||
from app.config import read_validation_settings
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
alive = await check_cookies_alive(account["session_cookies"], proxy)
|
||||
if not alive:
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="token_validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Cookies dead for token account {account_id}, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
raise HTTPException(status_code=400, detail="Session cookies expired. Re-validate the account.")
|
||||
|
||||
from app.services.browser_login import open_browser_with_cookies
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await open_browser_with_cookies(account)
|
||||
except Exception as exc:
|
||||
logger.error(f"Browser open failed for {account.get('login', account_id)}: {exc}")
|
||||
|
||||
asyncio.create_task(_run())
|
||||
return {"status": "ok", "message": "Browser opening..."}
|
||||
|
||||
|
||||
@router.post("/assign-proxies")
|
||||
async def token_assign_proxies():
|
||||
"""Round-robin assign proxies to token accounts that don't have one."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] 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 token_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):
|
||||
await db.execute(
|
||||
"UPDATE token_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/reassign-proxies")
|
||||
async def token_reassign_proxies():
|
||||
"""Round-robin reassign proxies to ALL token accounts."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] 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 token_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):
|
||||
await db.execute(
|
||||
"UPDATE token_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/clear-proxies")
|
||||
async def token_clear_proxies():
|
||||
"""Remove proxy from all token accounts."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"UPDATE token_accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''"
|
||||
)
|
||||
await db.commit()
|
||||
return {"cleared": cursor.rowcount}
|
||||
@@ -0,0 +1,50 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "SteamPanel"
|
||||
debug: bool = False
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8000
|
||||
|
||||
base_dir: Path = Path(__file__).resolve().parent.parent
|
||||
data_dir: Path = base_dir / "data"
|
||||
db_path: Path = data_dir / "accounts.db"
|
||||
mafiles_dir: Path = data_dir / "mafiles"
|
||||
logs_dir: Path = data_dir / "logs"
|
||||
proxies_path: Path = data_dir / "proxies.json"
|
||||
|
||||
max_concurrent_tasks: int = 5
|
||||
request_timeout: int = 30
|
||||
proxy_rotation_interval: int = 60
|
||||
|
||||
model_config = {"env_prefix": "STEAM_PANEL_"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
# Defaults for data/settings.json → "validation" section
|
||||
_VALIDATION_DEFAULTS: dict = {
|
||||
"fetch_profile": True,
|
||||
"check_ban": True,
|
||||
"max_threads": 10,
|
||||
"account_timeout": 90,
|
||||
"auto_revalidate_browser": True,
|
||||
}
|
||||
|
||||
|
||||
def read_validation_settings() -> dict:
|
||||
"""Read the 'validation' section from data/settings.json (sync, for small file)."""
|
||||
import json as _json
|
||||
|
||||
settings_path = settings.data_dir / "settings.json"
|
||||
result = _VALIDATION_DEFAULTS.copy()
|
||||
if settings_path.exists():
|
||||
try:
|
||||
data = _json.loads(settings_path.read_text("utf-8"))
|
||||
result.update(data.get("validation", {}))
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@@ -0,0 +1,120 @@
|
||||
"""AutoAcceptManager — manages per-account background loops for auto-accepting Steam logins."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auto_accept import mobile_login, get_pending_sessions, confirm_session
|
||||
from app.database import get_db
|
||||
|
||||
CHECK_INTERVAL = 15 # seconds
|
||||
|
||||
|
||||
class AutoAcceptManager:
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[int, asyncio.Task] = {}
|
||||
self._tokens: dict[int, str] = {}
|
||||
self._errors: dict[int, str] = {}
|
||||
|
||||
async def start(self, account: dict) -> None:
|
||||
account_id = account["id"]
|
||||
if account_id in self._tasks and not self._tasks[account_id].done():
|
||||
return
|
||||
|
||||
self._tasks[account_id] = asyncio.create_task(self._loop(account))
|
||||
logger.info(f"[auto-accept] Started monitoring for {account['login']} (id={account_id})")
|
||||
|
||||
def stop(self, account_id: int) -> None:
|
||||
task = self._tasks.pop(account_id, None)
|
||||
self._tokens.pop(account_id, None)
|
||||
self._errors.pop(account_id, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
logger.info(f"[auto-accept] Stopped monitoring for id={account_id}")
|
||||
|
||||
def is_running(self, account_id: int) -> bool:
|
||||
task = self._tasks.get(account_id)
|
||||
return task is not None and not task.done()
|
||||
|
||||
def running_ids(self) -> set[int]:
|
||||
# Cleanup dead tasks
|
||||
dead = [aid for aid, t in self._tasks.items() if t.done()]
|
||||
for aid in dead:
|
||||
self._tasks.pop(aid, None)
|
||||
self._tokens.pop(aid, None)
|
||||
return {aid for aid, t in self._tasks.items() if not t.done()}
|
||||
|
||||
def get_errors(self) -> dict[int, str]:
|
||||
"""Return error messages for accounts that stopped unexpectedly."""
|
||||
return dict(self._errors)
|
||||
|
||||
def pop_errors(self) -> dict[int, str]:
|
||||
"""Return and clear error messages."""
|
||||
errors = dict(self._errors)
|
||||
self._errors.clear()
|
||||
return errors
|
||||
|
||||
async def _loop(self, account: dict) -> None:
|
||||
account_id = account["id"]
|
||||
login = account["login"]
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Initial mobile login
|
||||
token = await mobile_login(session, account)
|
||||
if not token:
|
||||
logger.error(f"[auto-accept] Initial login failed for {login}, stopping")
|
||||
self._errors[account_id] = "Initial login failed"
|
||||
await self._disable_in_db(account_id)
|
||||
return
|
||||
self._tokens[account_id] = token
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
client_ids = await get_pending_sessions(session, self._tokens[account_id])
|
||||
|
||||
# Token expired — re-login
|
||||
if client_ids is None:
|
||||
logger.warning(f"[auto-accept] Token expired for {login}, re-logging...")
|
||||
new_token = await mobile_login(session, account)
|
||||
if new_token:
|
||||
self._tokens[account_id] = new_token
|
||||
client_ids = await get_pending_sessions(session, new_token)
|
||||
else:
|
||||
logger.error(f"[auto-accept] Re-login failed for {login}, stopping")
|
||||
self._errors[account_id] = "Re-login failed (token expired)"
|
||||
await self._disable_in_db(account_id)
|
||||
return
|
||||
|
||||
if not client_ids:
|
||||
continue
|
||||
|
||||
for cid in client_ids:
|
||||
await confirm_session(session, self._tokens[account_id], account, cid)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"[auto-accept] Loop cancelled for {login}")
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto-accept] Loop error for {login}: {exc}")
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
for aid in list(self._tasks):
|
||||
self.stop(aid)
|
||||
|
||||
async def _disable_in_db(self, account_id: int) -> None:
|
||||
"""Reset auto_accept flag in DB when task fails."""
|
||||
try:
|
||||
db = await get_db()
|
||||
await db.execute("UPDATE accounts SET auto_accept = 0 WHERE id = ?", (account_id,))
|
||||
await db.commit()
|
||||
logger.info(f"[auto-accept] Disabled auto_accept in DB for id={account_id}")
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto-accept] Failed to update DB for id={account_id}: {exc}")
|
||||
|
||||
|
||||
auto_accept_manager = AutoAcceptManager()
|
||||
@@ -0,0 +1,20 @@
|
||||
import base64
|
||||
|
||||
import rsa
|
||||
|
||||
|
||||
def get_rsa_key(response_data: dict) -> tuple[str, str, int]:
|
||||
"""Extract RSA key components from Steam's getrsakey response."""
|
||||
mod = response_data["publickey_mod"]
|
||||
exp = response_data["publickey_exp"]
|
||||
timestamp = int(response_data["timestamp"])
|
||||
return mod, exp, timestamp
|
||||
|
||||
|
||||
def encrypt_password(password: str, mod_hex: str, exp_hex: str) -> str:
|
||||
"""RSA-encrypt a password using Steam's public key, return base64."""
|
||||
mod = int(mod_hex, 16)
|
||||
exp = int(exp_hex, 16)
|
||||
public_key = rsa.PublicKey(mod, exp)
|
||||
encrypted = rsa.encrypt(password.encode("ascii"), public_key)
|
||||
return base64.b64encode(encrypted).decode("ascii")
|
||||
@@ -0,0 +1,35 @@
|
||||
class SteamPanelError(Exception):
|
||||
def __init__(self, message: str, code: str = "unknown_error") -> None:
|
||||
self.message = message
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class SteamAuthError(SteamPanelError):
|
||||
def __init__(self, message: str = "Authentication failed") -> None:
|
||||
super().__init__(message, code="auth_error")
|
||||
|
||||
|
||||
class SteamGuardError(SteamPanelError):
|
||||
def __init__(self, message: str = "Steam Guard operation failed") -> None:
|
||||
super().__init__(message, code="guard_error")
|
||||
|
||||
|
||||
class ProxyError(SteamPanelError):
|
||||
def __init__(self, message: str = "Proxy error") -> None:
|
||||
super().__init__(message, code="proxy_error")
|
||||
|
||||
|
||||
class MafileError(SteamPanelError):
|
||||
def __init__(self, message: str = "Invalid mafile") -> None:
|
||||
super().__init__(message, code="mafile_error")
|
||||
|
||||
|
||||
class TaskError(SteamPanelError):
|
||||
def __init__(self, message: str = "Task execution failed") -> None:
|
||||
super().__init__(message, code="task_error")
|
||||
|
||||
|
||||
class EmailError(SteamPanelError):
|
||||
def __init__(self, message: str = "Email operation failed") -> None:
|
||||
super().__init__(message, code="email_error")
|
||||
@@ -0,0 +1,138 @@
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
LEVEL_ICONS = {
|
||||
"TRACE": "[~]",
|
||||
"DEBUG": "[.]",
|
||||
"INFO": "[i]",
|
||||
"SUCCESS": "[+]",
|
||||
"WARNING": "[!]",
|
||||
"ERROR": "[e]",
|
||||
"CRITICAL":"[e]",
|
||||
}
|
||||
|
||||
LEVEL_COLORS = {
|
||||
"TRACE": "\033[35m",
|
||||
"DEBUG": "\033[37m",
|
||||
"INFO": "\033[34m",
|
||||
"SUCCESS": "\033[32m",
|
||||
"WARNING": "\033[33m",
|
||||
"ERROR": "\033[31m",
|
||||
"CRITICAL":"\033[31m",
|
||||
}
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def _loguru_fmt(record: dict) -> str:
|
||||
level = record["level"].name
|
||||
icon = LEVEL_ICONS.get(level, "[i]")
|
||||
color = LEVEL_COLORS.get(level, "")
|
||||
ts = record["time"].strftime("%H:%M:%S")
|
||||
msg = record["message"].replace("<", "\\<").replace("{", "{{").replace("}", "}}")
|
||||
return f"{RESET}[{ts}] {color}{icon}{RESET} {msg}\n"
|
||||
|
||||
|
||||
class _UvicornInterceptHandler(logging.Handler):
|
||||
"""Route all standard logging records (uvicorn, fastapi) into loguru."""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
frame, depth = sys._getframe(6), 6
|
||||
while frame and frame.f_code.co_filename == logging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
_logging_configured = False
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
global _logging_configured
|
||||
if _logging_configured:
|
||||
return
|
||||
_logging_configured = True
|
||||
|
||||
logger.remove()
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
format=_loguru_fmt,
|
||||
level="DEBUG" if settings.debug else "INFO",
|
||||
colorize=False,
|
||||
)
|
||||
|
||||
settings.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _file_fmt(record: dict) -> str:
|
||||
level = record["level"].name
|
||||
icon = LEVEL_ICONS.get(level, "[i]")
|
||||
ts = record["time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
msg = record["message"].replace("<", "\\<").replace("{", "{{").replace("}", "}}")
|
||||
return f"[{ts}] {icon} {msg}\n"
|
||||
|
||||
logger.add(
|
||||
settings.logs_dir / "steampanel_{time:YYYY-MM-DD}.log",
|
||||
format=_file_fmt,
|
||||
level="DEBUG",
|
||||
rotation="1 day",
|
||||
retention="7 days",
|
||||
compression="zip",
|
||||
)
|
||||
|
||||
_install_intercept()
|
||||
|
||||
|
||||
class _AioSQLiteFilter(logging.Filter):
|
||||
"""Reformats raw aiosqlite debug messages into readable DB operation lines."""
|
||||
|
||||
_OP_RE = re.compile(r"method (\w+) of sqlite3\.(\w+)")
|
||||
_SQL_RE = re.compile(r',\s*"(.*?)"(?:,\s*(\(.*?\)))?\s*\)$', re.DOTALL)
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
# Drop the redundant "operation X completed" lines
|
||||
if " completed" in msg:
|
||||
return False
|
||||
if msg.startswith("executing"):
|
||||
op_m = self._OP_RE.search(msg)
|
||||
method = op_m.group(1).upper() if op_m else "OP"
|
||||
sql_m = self._SQL_RE.search(msg)
|
||||
if sql_m:
|
||||
sql = sql_m.group(1).strip()
|
||||
params = sql_m.group(2) or ""
|
||||
record.msg = f"[DB] {sql}" + (f" {params}" if params else "")
|
||||
else:
|
||||
record.msg = f"[DB] {method}"
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def _install_intercept() -> None:
|
||||
intercept = _UvicornInterceptHandler()
|
||||
for name in ("", "uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
|
||||
log = logging.getLogger(name)
|
||||
log.handlers = [intercept]
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.propagate = False
|
||||
|
||||
# Format aiosqlite SQL logs instead of silencing them
|
||||
aiosqlite_log = logging.getLogger("aiosqlite")
|
||||
aiosqlite_log.setLevel(logging.DEBUG)
|
||||
aiosqlite_log.addFilter(_AioSQLiteFilter())
|
||||
|
||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import random
|
||||
|
||||
import aiohttp
|
||||
from aiohttp_socks import ProxyConnector
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
|
||||
def build_proxy_url(address: str, protocol: str) -> str:
|
||||
"""Convert stored address to standard proxy URL (proto://login:pass@ip:port).
|
||||
|
||||
The frontend always normalizes addresses to login:pass@ip:port (or plain ip:port),
|
||||
so we just prepend the scheme.
|
||||
"""
|
||||
if "://" in address:
|
||||
return address
|
||||
scheme = protocol if protocol in ("socks5", "socks4") else "http"
|
||||
return f"{scheme}://{address}"
|
||||
|
||||
|
||||
class ProxyManager:
|
||||
def __init__(self) -> None:
|
||||
self._proxies: list[dict] = []
|
||||
self._index: int = 0
|
||||
|
||||
async def load(self) -> None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM proxies WHERE is_alive = 1 ORDER BY fail_count ASC"
|
||||
)
|
||||
self._proxies = [dict(r) for r in await cursor.fetchall()]
|
||||
logger.info(f"Loaded {len(self._proxies)} alive proxies")
|
||||
|
||||
def get_next(self) -> dict | None:
|
||||
if not self._proxies:
|
||||
return None
|
||||
proxy = self._proxies[self._index % len(self._proxies)]
|
||||
self._index += 1
|
||||
return proxy
|
||||
|
||||
def get_random(self) -> dict | None:
|
||||
if not self._proxies:
|
||||
return None
|
||||
return random.choice(self._proxies)
|
||||
|
||||
def get_connector(self, proxy: dict | None = None) -> aiohttp.TCPConnector | ProxyConnector:
|
||||
if proxy is None:
|
||||
return aiohttp.TCPConnector()
|
||||
url = build_proxy_url(proxy["address"], proxy.get("protocol", "http"))
|
||||
return ProxyConnector.from_url(url, ssl=False)
|
||||
|
||||
async def mark_failed(self, proxy_id: int) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE proxies SET fail_count = fail_count + 1, last_checked = datetime('now') WHERE id = ?",
|
||||
(proxy_id,),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE proxies SET is_alive = 0 WHERE id = ? AND fail_count >= 5",
|
||||
(proxy_id,),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def mark_alive(self, proxy_id: int) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE proxies SET fail_count = 0, is_alive = 1, last_checked = datetime('now') WHERE id = ?",
|
||||
(proxy_id,),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._proxies)
|
||||
|
||||
|
||||
proxy_manager = ProxyManager()
|
||||
@@ -0,0 +1,249 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
|
||||
DEFAULT_MAX_PARALLEL = 10
|
||||
DEFAULT_ACCOUNT_TIMEOUT = 90 # fallback for actions not in the map below
|
||||
|
||||
# Per-action timeouts (seconds). Fast read-only actions get 60s;
|
||||
# write actions that involve SMS/email prompts get 120s.
|
||||
TASK_TIMEOUTS: dict[str, int] = {
|
||||
"validate": 60,
|
||||
"remove_guard": 60,
|
||||
"change_password": 60,
|
||||
"random_password": 60,
|
||||
"change_phone": 120,
|
||||
"change_email": 120,
|
||||
}
|
||||
|
||||
|
||||
def _read_max_threads() -> int:
|
||||
from app.config import read_validation_settings
|
||||
return int(read_validation_settings().get("max_threads", DEFAULT_MAX_PARALLEL))
|
||||
|
||||
|
||||
def _read_account_timeout() -> int:
|
||||
from app.config import read_validation_settings
|
||||
return int(read_validation_settings().get("account_timeout", DEFAULT_ACCOUNT_TIMEOUT))
|
||||
|
||||
|
||||
class TaskHandler(Protocol):
|
||||
async def __call__(self, account: dict, params: dict, *, task_id: str) -> None: ...
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self) -> None:
|
||||
self._running: dict[str, asyncio.Task] = {}
|
||||
self._handlers: dict[str, TaskHandler] = {}
|
||||
self._prompts: dict[str, dict[str, Any]] = {}
|
||||
self._steps: dict[str, dict[str, Any]] = {}
|
||||
self._active_counts: dict[str, int] = {}
|
||||
self._account_results: dict[str, dict[int, dict[str, str]]] = {}
|
||||
self._account_steps: dict[str, dict[int, dict[str, int]]] = {}
|
||||
|
||||
def register_handler(self, action: str, handler: TaskHandler) -> None:
|
||||
self._handlers[action] = handler
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
task_type: str,
|
||||
accounts: list[dict],
|
||||
params: dict,
|
||||
) -> str:
|
||||
task_id = uuid.uuid4().hex[:12]
|
||||
account_ids = [acc.get("id", 0) for acc in accounts]
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"INSERT INTO tasks (id, type, status, total, account_ids) VALUES (?, ?, 'running', ?, ?)",
|
||||
(task_id, task_type, len(accounts), json.dumps(account_ids)),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async_task = asyncio.create_task(
|
||||
self._execute(task_id, task_type, accounts, params)
|
||||
)
|
||||
self._running[task_id] = async_task
|
||||
return task_id
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
task_id: str,
|
||||
task_type: str,
|
||||
accounts: list[dict],
|
||||
params: dict,
|
||||
) -> None:
|
||||
from app.database import get_db
|
||||
|
||||
handler = self._handlers.get(task_type)
|
||||
if not handler:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?",
|
||||
(f"No handler for action: {task_type}", task_id),
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
completed = 0
|
||||
errors: list[str] = []
|
||||
lock = asyncio.Lock()
|
||||
max_parallel = _read_max_threads()
|
||||
account_timeout = TASK_TIMEOUTS.get(task_type, _read_account_timeout())
|
||||
sem = asyncio.Semaphore(max_parallel)
|
||||
self._active_counts[task_id] = 0
|
||||
self._account_results[task_id] = {}
|
||||
total = len(accounts)
|
||||
|
||||
async def process_one(account: dict, idx: int) -> None:
|
||||
nonlocal completed
|
||||
login = account.get("login", "?")
|
||||
acc_id = account.get("id", 0)
|
||||
logger.debug(f"[{task_type}] ({idx}/{total}) processing {login}...")
|
||||
|
||||
async with sem:
|
||||
async with lock:
|
||||
self._active_counts[task_id] = self._active_counts.get(task_id, 0) + 1
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
handler(account, params, task_id=task_id),
|
||||
timeout=account_timeout,
|
||||
)
|
||||
logger.success(f"[{task_type}] ({idx}/{total}) {login} — done")
|
||||
async with lock:
|
||||
self._account_results[task_id][acc_id] = {"status": "ok"}
|
||||
except asyncio.TimeoutError:
|
||||
async with lock:
|
||||
errors.append(f"{login}: timed out after {account_timeout}s")
|
||||
self._account_results[task_id][acc_id] = {"status": "error", "error": f"Timed out after {account_timeout}s"}
|
||||
logger.error(f"[{task_type}] ({idx}/{total}) {login} — timed out after {account_timeout}s")
|
||||
except Exception as exc:
|
||||
err_str = str(exc)
|
||||
async with lock:
|
||||
errors.append(f"{login}: {exc}")
|
||||
self._account_results[task_id][acc_id] = {"status": "error", "error": err_str}
|
||||
logger.error(f"[{task_type}] ({idx}/{total}) {login} — error: {exc}")
|
||||
finally:
|
||||
async with lock:
|
||||
self._active_counts[task_id] = max(0, self._active_counts.get(task_id, 0) - 1)
|
||||
|
||||
async with lock:
|
||||
completed += 1
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE tasks SET progress = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(completed, task_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
tasks = [process_one(acc, i) for i, acc in enumerate(accounts, 1)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
db = await get_db()
|
||||
status = "completed"
|
||||
result = f"Done: {completed - len(errors)} success, {len(errors)} errors"
|
||||
error_text = "; ".join(errors[:10]) if errors else None
|
||||
results_json = json.dumps(
|
||||
{str(k): v for k, v in self._account_results.get(task_id, {}).items()}
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE tasks SET status = ?, result = ?, error = ?, account_results = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(status, result, error_text, results_json, task_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
db = await get_db()
|
||||
results_json = json.dumps(
|
||||
{str(k): v for k, v in self._account_results.get(task_id, {}).items()}
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE tasks SET status = 'cancelled', account_results = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(results_json, task_id),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.error(f"Task {task_id} fatal error: {exc}")
|
||||
db = await get_db()
|
||||
results_json = json.dumps(
|
||||
{str(k): v for k, v in self._account_results.get(task_id, {}).items()}
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE tasks SET status = 'failed', error = ?, account_results = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(str(exc)[:500], results_json, task_id),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
self._running.pop(task_id, None)
|
||||
self._steps.pop(task_id, None)
|
||||
self._active_counts.pop(task_id, None)
|
||||
self._account_results.pop(task_id, None)
|
||||
self._account_steps.pop(task_id, None)
|
||||
|
||||
def get_active_count(self, task_id: str) -> int:
|
||||
return self._active_counts.get(task_id, 0)
|
||||
|
||||
def get_account_results(self, task_id: str) -> dict[int, dict[str, str]]:
|
||||
return self._account_results.get(task_id, {})
|
||||
|
||||
def clear_account_results(self, task_id: str) -> None:
|
||||
self._account_results.pop(task_id, None)
|
||||
|
||||
async def set_step(self, task_id: str, step: int, total_steps: int, label: str = "", acc_id: int = 0) -> None:
|
||||
self._steps[task_id] = {"step": step, "total_steps": total_steps, "label": label}
|
||||
if acc_id:
|
||||
self._account_steps.setdefault(task_id, {})[acc_id] = {"step": step, "total": total_steps}
|
||||
|
||||
def get_step_info(self, task_id: str) -> dict[str, Any] | None:
|
||||
return self._steps.get(task_id)
|
||||
|
||||
def get_account_steps(self, task_id: str) -> dict[int, dict[str, int]]:
|
||||
return self._account_steps.get(task_id, {})
|
||||
|
||||
async def prompt_user(self, task_id: str, message: str, login: str = "") -> str:
|
||||
"""Pause task execution and ask the user for input via SSE prompt."""
|
||||
key = f"{task_id}:{login}" if login else task_id
|
||||
event = asyncio.Event()
|
||||
self._prompts[key] = {"message": message, "login": login, "event": event, "response": ""}
|
||||
await event.wait()
|
||||
response = self._prompts.pop(key, {}).get("response", "")
|
||||
return response
|
||||
|
||||
def respond(self, task_id: str, value: str, login: str = "") -> bool:
|
||||
key = f"{task_id}:{login}" if login else task_id
|
||||
prompt = self._prompts.get(key)
|
||||
if not prompt:
|
||||
return False
|
||||
prompt["response"] = value
|
||||
prompt["event"].set()
|
||||
return True
|
||||
|
||||
def get_pending_prompt(self, task_id: str) -> dict[str, str] | None:
|
||||
"""Return first pending prompt for this task (with login info)."""
|
||||
prefix = f"{task_id}:"
|
||||
for key, prompt in self._prompts.items():
|
||||
if key == task_id or key.startswith(prefix):
|
||||
return {"message": prompt["message"], "login": prompt.get("login", "")}
|
||||
return None
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
task = self._running.get(task_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
return True
|
||||
return False
|
||||
|
||||
def active_count(self) -> int:
|
||||
return len(self._running)
|
||||
|
||||
|
||||
task_manager = TaskManager()
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import aiosqlite
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
|
||||
import asyncio
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
_db_lock = asyncio.Lock()
|
||||
|
||||
SQL_CREATE_ACCOUNTS = """
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
login TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
steam_id TEXT UNIQUE,
|
||||
email TEXT,
|
||||
email_password TEXT,
|
||||
phone TEXT,
|
||||
mafile_path TEXT,
|
||||
shared_secret TEXT,
|
||||
identity_secret TEXT,
|
||||
proxy TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
notes TEXT,
|
||||
nickname TEXT,
|
||||
avatar_url TEXT,
|
||||
steam_level INTEGER,
|
||||
auto_accept INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
SQL_CREATE_PROXIES = """
|
||||
CREATE TABLE IF NOT EXISTS proxies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL UNIQUE,
|
||||
protocol TEXT NOT NULL DEFAULT 'http',
|
||||
is_alive INTEGER NOT NULL DEFAULT 1,
|
||||
last_checked TEXT,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
SQL_CREATE_TASKS = """
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
SQL_CREATE_LOGPASS_ACCOUNTS = """
|
||||
CREATE TABLE IF NOT EXISTS logpass_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
steam_id TEXT,
|
||||
proxy TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
ban_status TEXT,
|
||||
nickname TEXT,
|
||||
steam_level INTEGER,
|
||||
prime TEXT,
|
||||
trophy TEXT,
|
||||
behavior TEXT,
|
||||
license TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
SQL_CREATE_TOKEN_ACCOUNTS = """
|
||||
CREATE TABLE IF NOT EXISTS token_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
login TEXT,
|
||||
token TEXT NOT NULL,
|
||||
steam_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
global _db
|
||||
async with _db_lock:
|
||||
if _db is None:
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
_db = await aiosqlite.connect(str(settings.db_path))
|
||||
_db.row_factory = aiosqlite.Row
|
||||
await _db.execute("PRAGMA journal_mode=WAL")
|
||||
await _db.execute("PRAGMA foreign_keys=ON")
|
||||
await _init_tables(_db)
|
||||
logger.info(f"Database connected: {settings.db_path}")
|
||||
return _db
|
||||
|
||||
|
||||
async def _init_tables(db: aiosqlite.Connection) -> None:
|
||||
await db.execute(SQL_CREATE_ACCOUNTS)
|
||||
await db.execute(SQL_CREATE_PROXIES)
|
||||
await db.execute(SQL_CREATE_TASKS)
|
||||
await db.execute(SQL_CREATE_LOGPASS_ACCOUNTS)
|
||||
await db.execute(SQL_CREATE_TOKEN_ACCOUNTS)
|
||||
await _migrate(db)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _migrate(db: aiosqlite.Connection) -> None:
|
||||
"""Add columns that may not exist in older DBs."""
|
||||
cursor = await db.execute("PRAGMA table_info(accounts)")
|
||||
cols = {row[1] for row in await cursor.fetchall()}
|
||||
migrations = [
|
||||
("nickname", "ALTER TABLE accounts ADD COLUMN nickname TEXT"),
|
||||
("avatar_url", "ALTER TABLE accounts ADD COLUMN avatar_url TEXT"),
|
||||
("steam_level", "ALTER TABLE accounts ADD COLUMN steam_level INTEGER"),
|
||||
("auto_accept", "ALTER TABLE accounts ADD COLUMN auto_accept INTEGER NOT NULL DEFAULT 0"),
|
||||
("ban_status", "ALTER TABLE accounts ADD COLUMN ban_status TEXT"),
|
||||
("session_cookies", "ALTER TABLE accounts ADD COLUMN session_cookies TEXT"),
|
||||
("last_online", "ALTER TABLE accounts ADD COLUMN last_online TEXT"),
|
||||
]
|
||||
for col, sql in migrations:
|
||||
if col not in cols:
|
||||
await db.execute(sql)
|
||||
logger.info(f"Migration: added column '{col}' to accounts")
|
||||
|
||||
cursor = await db.execute("PRAGMA table_info(tasks)")
|
||||
task_cols = {row[1] for row in await cursor.fetchall()}
|
||||
task_migrations = [
|
||||
("account_ids", "ALTER TABLE tasks ADD COLUMN account_ids TEXT"),
|
||||
("account_results", "ALTER TABLE tasks ADD COLUMN account_results TEXT"),
|
||||
]
|
||||
for col, sql in task_migrations:
|
||||
if col not in task_cols:
|
||||
await db.execute(sql)
|
||||
logger.info(f"Migration: added column '{col}' to tasks")
|
||||
|
||||
# Logpass accounts migrations
|
||||
cursor = await db.execute("PRAGMA table_info(logpass_accounts)")
|
||||
lp_cols = {row[1] for row in await cursor.fetchall()}
|
||||
lp_migrations = [
|
||||
("prime", "ALTER TABLE logpass_accounts ADD COLUMN prime TEXT"),
|
||||
("trophy", "ALTER TABLE logpass_accounts ADD COLUMN trophy TEXT"),
|
||||
("behavior", "ALTER TABLE logpass_accounts ADD COLUMN behavior TEXT"),
|
||||
("license", "ALTER TABLE logpass_accounts ADD COLUMN license TEXT"),
|
||||
("session_cookies", "ALTER TABLE logpass_accounts ADD COLUMN session_cookies TEXT"),
|
||||
("steam_level", "ALTER TABLE logpass_accounts ADD COLUMN steam_level INTEGER"),
|
||||
("avatar_url", "ALTER TABLE logpass_accounts ADD COLUMN avatar_url TEXT"),
|
||||
("last_online", "ALTER TABLE logpass_accounts ADD COLUMN last_online TEXT"),
|
||||
]
|
||||
for col, sql in lp_migrations:
|
||||
if col not in lp_cols:
|
||||
await db.execute(sql)
|
||||
logger.info(f"Migration: added column '{col}' to logpass_accounts")
|
||||
|
||||
# Token accounts migrations
|
||||
cursor = await db.execute("PRAGMA table_info(token_accounts)")
|
||||
tk_cols = {row[1] for row in await cursor.fetchall()}
|
||||
tk_migrations = [
|
||||
("proxy", "ALTER TABLE token_accounts ADD COLUMN proxy TEXT"),
|
||||
("session_cookies", "ALTER TABLE token_accounts ADD COLUMN session_cookies TEXT"),
|
||||
("ban_status", "ALTER TABLE token_accounts ADD COLUMN ban_status TEXT"),
|
||||
("nickname", "ALTER TABLE token_accounts ADD COLUMN nickname TEXT"),
|
||||
("steam_level", "ALTER TABLE token_accounts ADD COLUMN steam_level INTEGER"),
|
||||
("avatar_url", "ALTER TABLE token_accounts ADD COLUMN avatar_url TEXT"),
|
||||
("last_online", "ALTER TABLE token_accounts ADD COLUMN last_online TEXT"),
|
||||
]
|
||||
for col, sql in tk_migrations:
|
||||
if col not in tk_cols:
|
||||
await db.execute(sql)
|
||||
logger.info(f"Migration: added column '{col}' to token_accounts")
|
||||
|
||||
# Add UNIQUE index on login if missing
|
||||
cursor = await db.execute("PRAGMA index_list(logpass_accounts)")
|
||||
idx_rows = await cursor.fetchall()
|
||||
idx_names = {row[1] for row in idx_rows}
|
||||
if "uq_logpass_login" not in idx_names:
|
||||
try:
|
||||
await db.execute("CREATE UNIQUE INDEX uq_logpass_login ON logpass_accounts(login)")
|
||||
logger.info("Migration: added unique index on logpass_accounts.login")
|
||||
except Exception:
|
||||
logger.warning("Could not create unique index on logpass_accounts.login (duplicates may exist)")
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
global _db
|
||||
if _db is not None:
|
||||
await _db.close()
|
||||
_db = None
|
||||
logger.info("Database connection closed")
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class AccountCreate(BaseModel):
|
||||
login: str
|
||||
password: str
|
||||
steam_id: str | None = None
|
||||
email: str | None = None
|
||||
email_password: str | None = None
|
||||
phone: str | None = None
|
||||
proxy: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
login: str | None = None
|
||||
password: str | None = None
|
||||
steam_id: str | None = None
|
||||
email: str | None = None
|
||||
email_password: str | None = None
|
||||
phone: str | None = None
|
||||
proxy: str | None = None
|
||||
status: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class AccountOut(BaseModel):
|
||||
id: int
|
||||
login: str
|
||||
password: str
|
||||
steam_id: str | None = None
|
||||
email: str | None = None
|
||||
email_password: str | None = None
|
||||
phone: str | None = None
|
||||
mafile_path: str | None = None
|
||||
shared_secret: str | None = None
|
||||
identity_secret: str | None = None
|
||||
proxy: str | None = None
|
||||
status: str
|
||||
notes: str | None = None
|
||||
nickname: str | None = None
|
||||
avatar_url: str | None = None
|
||||
steam_level: int | None = None
|
||||
last_online: str | None = None
|
||||
auto_accept: int = 0
|
||||
ban_status: str | None = None
|
||||
has_cookies: bool = False
|
||||
has_revocation_code: bool = False
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@staticmethod
|
||||
def _check_revocation_code(mafile_path: str | None) -> bool:
|
||||
if not mafile_path:
|
||||
return False
|
||||
try:
|
||||
p = Path(mafile_path)
|
||||
if not p.exists():
|
||||
return False
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return bool(data.get("revocation_code", ""))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _compute_flags(cls, data):
|
||||
if isinstance(data, dict):
|
||||
data["has_cookies"] = bool(data.get("session_cookies"))
|
||||
data["has_revocation_code"] = AccountOut._check_revocation_code(data.get("mafile_path"))
|
||||
return data
|
||||
|
||||
|
||||
class ProxyCreate(BaseModel):
|
||||
address: str
|
||||
protocol: str = "http"
|
||||
|
||||
|
||||
class ProxyOut(BaseModel):
|
||||
id: int
|
||||
address: str
|
||||
protocol: str
|
||||
is_alive: bool
|
||||
last_checked: str | None = None
|
||||
fail_count: int
|
||||
|
||||
|
||||
class MafileSession(BaseModel):
|
||||
SessionID: str = ""
|
||||
AccessToken: str = ""
|
||||
RefreshToken: str = ""
|
||||
SteamID: int = 0
|
||||
SteamLoginSecure: str = ""
|
||||
|
||||
|
||||
class MafileData(BaseModel):
|
||||
shared_secret: str
|
||||
serial_number: str = ""
|
||||
revocation_code: str = ""
|
||||
uri: str = ""
|
||||
account_name: str = ""
|
||||
token_gid: str = ""
|
||||
identity_secret: str = ""
|
||||
secret_1: str = ""
|
||||
device_id: str = ""
|
||||
server_time: str = ""
|
||||
fully_enrolled: bool = False
|
||||
Session: MafileSession = Field(default_factory=MafileSession)
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
status: str
|
||||
progress: int
|
||||
total: int
|
||||
result: str | None = None
|
||||
error: str | None = None
|
||||
account_ids: str | None = None
|
||||
account_results: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
account_ids: list[int]
|
||||
action: str
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
class BulkImportResult(BaseModel):
|
||||
imported: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log:pass accounts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogpassAccountCreate(BaseModel):
|
||||
login: str
|
||||
password: str
|
||||
steam_id: str | None = None
|
||||
proxy: str | None = None
|
||||
ban_status: str | None = None
|
||||
prime: str | None = None
|
||||
trophy: str | None = None
|
||||
behavior: str | None = None
|
||||
license: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class LogpassAccountUpdate(BaseModel):
|
||||
login: str | None = None
|
||||
password: str | None = None
|
||||
steam_id: str | None = None
|
||||
proxy: str | None = None
|
||||
status: str | None = None
|
||||
ban_status: str | None = None
|
||||
nickname: str | None = None
|
||||
steam_level: int | None = None
|
||||
prime: str | None = None
|
||||
trophy: str | None = None
|
||||
behavior: str | None = None
|
||||
license: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class LogpassAccountOut(BaseModel):
|
||||
id: int
|
||||
login: str
|
||||
password: str
|
||||
steam_id: str | None = None
|
||||
proxy: str | None = None
|
||||
status: str
|
||||
ban_status: str | None = None
|
||||
nickname: str | None = None
|
||||
steam_level: int | None = None
|
||||
prime: str | None = None
|
||||
trophy: str | None = None
|
||||
behavior: str | None = None
|
||||
license: str | None = None
|
||||
notes: str | None = None
|
||||
avatar_url: str | None = None
|
||||
last_online: str | None = None
|
||||
has_cookies: bool = False
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _compute_has_cookies(cls, data):
|
||||
if isinstance(data, dict):
|
||||
data["has_cookies"] = bool(data.get("session_cookies"))
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token accounts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TokenAccountCreate(BaseModel):
|
||||
login: str | None = None
|
||||
token: str
|
||||
steam_id: str | None = None
|
||||
proxy: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class TokenAccountUpdate(BaseModel):
|
||||
login: str | None = None
|
||||
token: str | None = None
|
||||
steam_id: str | None = None
|
||||
proxy: str | None = None
|
||||
status: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class TokenAccountOut(BaseModel):
|
||||
id: int
|
||||
login: str | None = None
|
||||
token: str
|
||||
steam_id: str | None = None
|
||||
proxy: str | None = None
|
||||
status: str
|
||||
ban_status: str | None = None
|
||||
nickname: str | None = None
|
||||
steam_level: int | None = None
|
||||
avatar_url: str | None = None
|
||||
last_online: str | None = None
|
||||
has_cookies: bool = False
|
||||
notes: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _compute_has_cookies(cls, data):
|
||||
if isinstance(data, dict):
|
||||
data["has_cookies"] = bool(data.get("session_cookies"))
|
||||
return data
|
||||
@@ -0,0 +1,96 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_active_browsers: list = []
|
||||
|
||||
STEAM_DOMAINS = [
|
||||
".steamcommunity.com",
|
||||
".steampowered.com",
|
||||
".store.steampowered.com",
|
||||
".help.steampowered.com",
|
||||
".login.steampowered.com",
|
||||
]
|
||||
|
||||
|
||||
async def open_browser_with_cookies(account: dict) -> None:
|
||||
"""Open a Chrome browser with Steam session cookies using nodriver."""
|
||||
import nodriver as uc
|
||||
|
||||
cookies_json = account.get("session_cookies")
|
||||
if not cookies_json:
|
||||
raise ValueError("No session cookies saved. Validate the account first.")
|
||||
|
||||
cookies = json.loads(cookies_json)
|
||||
|
||||
browser = await uc.start()
|
||||
_active_browsers.append(browser)
|
||||
|
||||
tab = await browser.get("about:blank")
|
||||
|
||||
for c in cookies:
|
||||
domain = c.get("domain") or ".steamcommunity.com"
|
||||
domains_to_set = [domain]
|
||||
if "steam" in domain:
|
||||
domains_to_set = list(set([domain] + STEAM_DOMAINS))
|
||||
|
||||
for d in domains_to_set:
|
||||
try:
|
||||
await tab.send(uc.cdp.network.set_cookie(
|
||||
name=c["name"],
|
||||
value=c["value"],
|
||||
domain=d,
|
||||
path=c.get("path") or "/",
|
||||
secure=c.get("secure", True),
|
||||
http_only=c.get("httpOnly", False),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
steam_id = account.get("steam_id") or ""
|
||||
if steam_id:
|
||||
start_url = f"https://steamcommunity.com/profiles/{steam_id}/"
|
||||
else:
|
||||
start_url = "https://steamcommunity.com/"
|
||||
await browser.get(start_url)
|
||||
logger.info(f"Browser opened for {account['login']}")
|
||||
|
||||
# Wait until browser is closed by user, suppress connection errors
|
||||
try:
|
||||
while browser:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
if not browser.connection or browser.connection.closed:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if browser in _active_browsers:
|
||||
_active_browsers.remove(browser)
|
||||
|
||||
# Temporarily suppress nodriver's internal task errors during cleanup
|
||||
# (Browser.update_targets() raises ConnectionRefusedError after close)
|
||||
loop = asyncio.get_running_loop()
|
||||
_original_handler = loop.get_exception_handler()
|
||||
|
||||
def _suppress_nodriver_close_errors(loop, context):
|
||||
exc = context.get("exception")
|
||||
if isinstance(exc, (ConnectionRefusedError, ConnectionResetError, OSError)):
|
||||
return
|
||||
if _original_handler:
|
||||
_original_handler(loop, context)
|
||||
else:
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
loop.set_exception_handler(_suppress_nodriver_close_errors)
|
||||
try:
|
||||
browser.stop()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.5)
|
||||
loop.set_exception_handler(_original_handler)
|
||||
|
||||
logger.debug(f"Browser closed for {account['login']}")
|
||||
@@ -0,0 +1,20 @@
|
||||
from app.core.task_manager import task_manager
|
||||
from app.services.steam_guard import remove_guard, generate_2fa
|
||||
from app.services.steam_password import change_password, random_password
|
||||
from app.services.steam_email import change_email, validate_account
|
||||
from app.services.steam_phone import change_phone
|
||||
from app.services.steam_checker import check_logpass_account, full_parse_logpass_account
|
||||
from app.services.steam_token_checker import check_token_account
|
||||
|
||||
|
||||
def register_all_handlers() -> None:
|
||||
task_manager.register_handler("change_password", change_password)
|
||||
task_manager.register_handler("random_password", random_password)
|
||||
task_manager.register_handler("change_email", change_email)
|
||||
task_manager.register_handler("change_phone", change_phone)
|
||||
task_manager.register_handler("remove_guard", remove_guard)
|
||||
task_manager.register_handler("generate_2fa", generate_2fa)
|
||||
task_manager.register_handler("validate", validate_account)
|
||||
task_manager.register_handler("logpass_validate", check_logpass_account)
|
||||
task_manager.register_handler("logpass_full_parse", full_parse_logpass_account)
|
||||
task_manager.register_handler("token_validate", check_token_account)
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from loguru import logger
|
||||
|
||||
from app.core.exceptions import SteamAuthError
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
from pysteamauth.base.request import BaseRequestStrategy, DEFAULT_REQUEST_TIMEOUT
|
||||
|
||||
|
||||
class ProxyRequestStrategy(BaseRequestStrategy):
|
||||
"""Request strategy that routes traffic through a proxy connector."""
|
||||
|
||||
def __init__(self, connector: aiohttp.BaseConnector):
|
||||
super().__init__()
|
||||
self._proxy_connector = connector
|
||||
|
||||
def _create_session(self) -> ClientSession:
|
||||
return ClientSession(
|
||||
connector=self._proxy_connector,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def read_mafile_data(account: dict) -> dict | None:
|
||||
"""Read and parse the account's mafile JSON from disk."""
|
||||
mafile_path = account.get("mafile_path")
|
||||
if not mafile_path:
|
||||
return None
|
||||
try:
|
||||
return json.loads(Path(mafile_path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_proxy(account: dict) -> dict | None:
|
||||
"""Determine proxy dict {address, protocol} for the account."""
|
||||
proxy_address = account.get("proxy")
|
||||
if proxy_address:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT protocol FROM proxies WHERE address = ?", (proxy_address,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
protocol = row["protocol"] if row else "http"
|
||||
return {"address": proxy_address, "protocol": protocol}
|
||||
if proxy_manager.count > 0:
|
||||
return proxy_manager.get_next()
|
||||
return None
|
||||
|
||||
|
||||
async def create_steam_session(account: dict):
|
||||
"""Create an authenticated Steam session for an account."""
|
||||
try:
|
||||
from pysteamauth.auth import Steam
|
||||
except ImportError:
|
||||
raise SteamAuthError("pysteamauth not installed: pip install pysteamauth")
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
request_strategy = None
|
||||
if proxy:
|
||||
connector = proxy_manager.get_connector(proxy)
|
||||
request_strategy = ProxyRequestStrategy(connector)
|
||||
logger.debug(f"Using proxy {proxy['protocol']}://{proxy['address']} for {account['login']}")
|
||||
|
||||
mafile = read_mafile_data(account)
|
||||
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
identity_secret = account.get("identity_secret", "")
|
||||
device_id = None
|
||||
steamid = None
|
||||
|
||||
if mafile:
|
||||
shared_secret = mafile.get("shared_secret", shared_secret)
|
||||
identity_secret = mafile.get("identity_secret", identity_secret)
|
||||
device_id = mafile.get("device_id")
|
||||
steamid = int(mafile.get("Session", {}).get("SteamID", 0)) or None
|
||||
|
||||
steam = Steam(
|
||||
login=account["login"],
|
||||
password=account["password"],
|
||||
shared_secret=shared_secret,
|
||||
identity_secret=identity_secret,
|
||||
device_id=device_id,
|
||||
steamid=steamid,
|
||||
request_strategy=request_strategy,
|
||||
)
|
||||
|
||||
try:
|
||||
await steam.login_to_steam()
|
||||
except Exception as exc:
|
||||
await close_steam(steam)
|
||||
exc_str = str(exc)
|
||||
# Pydantic ValidationError from pysteamauth = Steam rejected login
|
||||
if "validation error" in exc_str.lower() and "FinalizeLoginStatus" in exc_str:
|
||||
# Extract the raw response dict from the error
|
||||
m = __import__("re").search(r"input_value=(\{[^}]+\})", exc_str)
|
||||
raw = m.group(1) if m else ""
|
||||
logger.error(f"Auth failed for {account['login']}: Steam rejected login: {raw}")
|
||||
raise SteamAuthError(f"Login failed: Steam rejected credentials ({raw})")
|
||||
logger.error(f"Auth failed for {account['login']}: {exc}")
|
||||
raise SteamAuthError(f"Login failed: {exc}")
|
||||
|
||||
return steam
|
||||
|
||||
|
||||
async def close_steam(steam) -> None:
|
||||
"""Close the underlying aiohttp session and proxy connector of a pysteamauth Steam object."""
|
||||
requests = getattr(steam, "_requests", None)
|
||||
if requests is None:
|
||||
return
|
||||
session = getattr(requests, "_session", None)
|
||||
if session is not None and not getattr(session, "closed", True):
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
requests._session = None
|
||||
|
||||
connector = getattr(requests, "_proxy_connector", None)
|
||||
if connector is not None and not getattr(connector, "closed", True):
|
||||
try:
|
||||
await connector.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def extract_session_cookies(steam) -> str | None:
|
||||
"""Extract session cookies from a pysteamauth Steam object as JSON string.
|
||||
|
||||
Returns JSON string of cookies list, or None if extraction fails.
|
||||
"""
|
||||
try:
|
||||
_req = getattr(steam, "_requests", None)
|
||||
_sess = getattr(_req, "_session", None) if _req else None
|
||||
if not _sess or not hasattr(_sess, "cookie_jar"):
|
||||
return None
|
||||
all_cookies = []
|
||||
for c in _sess.cookie_jar:
|
||||
all_cookies.append({
|
||||
"name": c.key,
|
||||
"value": c.value,
|
||||
"domain": c.get("domain", ""),
|
||||
"path": c.get("path", "/"),
|
||||
"secure": str(c.get("secure", "")).lower() == "true" or c.get("secure") is True,
|
||||
"httpOnly": str(c.get("httponly", "")).lower() == "true" or c.get("httponly") is True,
|
||||
})
|
||||
return json.dumps(all_cookies)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def check_cookies_alive(cookies_json: str, proxy: dict | None = None) -> bool:
|
||||
"""Check if Steam session cookies are still valid.
|
||||
|
||||
Makes a lightweight request to Steam's clientjstoken endpoint.
|
||||
Returns True if logged in, False otherwise.
|
||||
"""
|
||||
try:
|
||||
cookies = json.loads(cookies_json)
|
||||
jar = aiohttp.CookieJar(unsafe=True)
|
||||
for c in cookies:
|
||||
jar.update_cookies(
|
||||
{c["name"]: c["value"]},
|
||||
response_url=__import__("yarl").URL(f"https://{c.get('domain', '.steamcommunity.com').lstrip('.')}"),
|
||||
)
|
||||
connector = proxy_manager.get_connector(proxy) if proxy else None
|
||||
async with aiohttp.ClientSession(cookie_jar=jar, connector=connector) as session:
|
||||
async with session.get(
|
||||
"https://steamcommunity.com/chat/clientjstoken",
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return False
|
||||
data = await resp.json(content_type=None)
|
||||
return bool(data.get("logged_in"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def raw_request(steam, url: str, method: str = "GET", **kwargs) -> aiohttp.ClientResponse:
|
||||
"""Low-level request returning raw aiohttp.ClientResponse (for redirects, etc.)."""
|
||||
from yarl import URL
|
||||
|
||||
host = URL(url).host or ""
|
||||
cookies = await steam.cookies(host)
|
||||
return await steam._requests.request(url=url, method=method, cookies=cookies, **kwargs)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Steam auto-accept login confirmations — ported from legacy/steam_auto_accept_logins.py."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
|
||||
import aiohttp
|
||||
import rsa
|
||||
from loguru import logger
|
||||
|
||||
from pysteamauth.pb2.steammessages_auth.steamclient_pb2 import (
|
||||
CAuthentication_GetPasswordRSAPublicKey_Request,
|
||||
CAuthentication_GetPasswordRSAPublicKey_Response,
|
||||
CAuthentication_BeginAuthSessionViaCredentials_Request,
|
||||
CAuthentication_BeginAuthSessionViaCredentials_Response,
|
||||
CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request,
|
||||
CAuthentication_PollAuthSessionStatus_Request,
|
||||
CAuthentication_PollAuthSessionStatus_Response,
|
||||
CAuthentication_GetAuthSessionsForAccount_Response,
|
||||
CAuthentication_GetAuthSessionInfo_Request,
|
||||
CAuthentication_GetAuthSessionInfo_Response,
|
||||
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
|
||||
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response,
|
||||
EAuthTokenPlatformType,
|
||||
EAuthSessionGuardType,
|
||||
)
|
||||
|
||||
BASE_URL = "https://api.steampowered.com"
|
||||
HEADERS = {"User-Agent": "okhttp/4.9.2", "Cookie": "Steam_Language=english"}
|
||||
|
||||
|
||||
def _generate_2fa_code(shared_secret: str, server_time: int | None = None) -> str:
|
||||
"""Generate a Steam Guard TOTP code from shared_secret."""
|
||||
import time as _time
|
||||
|
||||
if server_time is None:
|
||||
server_time = int(_time.time())
|
||||
key = base64.b64decode(shared_secret)
|
||||
msg = struct.pack(">Q", server_time // 30)
|
||||
mac = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
offset = mac[-1] & 0x0F
|
||||
code_int = struct.unpack(">I", mac[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
chars = "23456789BCDFGHJKMNPQRTVWXY"
|
||||
code = ""
|
||||
for _ in range(5):
|
||||
code += chars[code_int % len(chars)]
|
||||
code_int //= len(chars)
|
||||
return code
|
||||
|
||||
|
||||
def _confirmation_signature(shared_secret: str, client_id: int, steamid: int) -> bytes:
|
||||
"""HMAC-SHA256 signature for login confirmation (version=1 + client_id + steamid)."""
|
||||
key = base64.b64decode(shared_secret)
|
||||
data = struct.pack("<HQQ", 1, client_id, steamid)
|
||||
return hmac.new(key, data, hashlib.sha256).digest()
|
||||
|
||||
|
||||
async def mobile_login(session: aiohttp.ClientSession, account: dict) -> str | None:
|
||||
"""Perform mobile login flow, return access_token or None."""
|
||||
login = account["login"]
|
||||
password = account["password"]
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
|
||||
try:
|
||||
# GetPasswordRSAPublicKey
|
||||
req = CAuthentication_GetPasswordRSAPublicKey_Request()
|
||||
req.account_name = login
|
||||
encoded = base64.b64encode(req.SerializeToString()).decode()
|
||||
|
||||
async with session.get(
|
||||
f"{BASE_URL}/IAuthenticationService/GetPasswordRSAPublicKey/v1",
|
||||
params={"origin": "SteamMobile", "input_protobuf_encoded": encoded},
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] RSA key failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
rsa_resp = CAuthentication_GetPasswordRSAPublicKey_Response.FromString(await resp.read())
|
||||
|
||||
pub_key = rsa.PublicKey(int(rsa_resp.publickey_mod, 16), int(rsa_resp.publickey_exp, 16))
|
||||
encrypted_pw = base64.b64encode(rsa.encrypt(password.encode(), pub_key)).decode()
|
||||
|
||||
# BeginAuthSession
|
||||
begin = CAuthentication_BeginAuthSessionViaCredentials_Request()
|
||||
begin.account_name = login
|
||||
begin.encrypted_password = encrypted_pw
|
||||
begin.encryption_timestamp = rsa_resp.timestamp
|
||||
begin.platform_type = EAuthTokenPlatformType.k_EAuthTokenPlatformType_MobileApp
|
||||
begin.device_friendly_name = "Android Device"
|
||||
begin.persistence = 1
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(begin.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/BeginAuthSessionViaCredentials/v1",
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] BeginAuth failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
begin_resp = CAuthentication_BeginAuthSessionViaCredentials_Response.FromString(await resp.read())
|
||||
|
||||
# Send 2FA code
|
||||
if shared_secret:
|
||||
code = _generate_2fa_code(shared_secret)
|
||||
upd = CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request()
|
||||
upd.client_id = begin_resp.client_id
|
||||
upd.steamid = begin_resp.steamid
|
||||
upd.code = code
|
||||
upd.code_type = EAuthSessionGuardType.k_EAuthSessionGuardType_DeviceCode
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(upd.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/UpdateAuthSessionWithSteamGuardCode/v1",
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] 2FA submit failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
|
||||
# PollAuthSessionStatus
|
||||
poll = CAuthentication_PollAuthSessionStatus_Request()
|
||||
poll.client_id = begin_resp.client_id
|
||||
poll.request_id = begin_resp.request_id
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(poll.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/PollAuthSessionStatus/v1",
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] Poll failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
poll_resp = CAuthentication_PollAuthSessionStatus_Response.FromString(await resp.read())
|
||||
|
||||
if not poll_resp.access_token:
|
||||
logger.error(f"[auto-accept] No access_token for {login}")
|
||||
return None
|
||||
|
||||
logger.success(f"[auto-accept] Mobile login OK for {login}")
|
||||
return poll_resp.access_token
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto-accept] Login error for {login}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_pending_sessions(session: aiohttp.ClientSession, access_token: str) -> list[int] | None:
|
||||
"""Get pending auth session client_ids. Returns None on 401 (token expired)."""
|
||||
try:
|
||||
async with session.get(
|
||||
f"{BASE_URL}/IAuthenticationService/GetAuthSessionsForAccount/v1",
|
||||
params={
|
||||
"access_token": access_token,
|
||||
"origin": "SteamMobile",
|
||||
"input_protobuf_encoded": "",
|
||||
},
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
return None
|
||||
if resp.status != 200:
|
||||
return []
|
||||
data = CAuthentication_GetAuthSessionsForAccount_Response.FromString(await resp.read())
|
||||
return list(data.client_ids)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[auto-accept] get_pending_sessions error: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
async def confirm_session(
|
||||
session: aiohttp.ClientSession,
|
||||
access_token: str,
|
||||
account: dict,
|
||||
client_id: int,
|
||||
) -> bool:
|
||||
"""Confirm a pending login session."""
|
||||
steam_id = int(account.get("steam_id", 0))
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
if not steam_id or not shared_secret:
|
||||
return False
|
||||
|
||||
try:
|
||||
signature = _confirmation_signature(shared_secret, client_id, steam_id)
|
||||
|
||||
msg = CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request()
|
||||
msg.version = 1
|
||||
msg.client_id = client_id
|
||||
msg.steamid = steam_id
|
||||
msg.signature = signature
|
||||
msg.confirm = True
|
||||
msg.persistence = 1
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(msg.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/UpdateAuthSessionWithMobileConfirmation/v1",
|
||||
params={"access_token": access_token},
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
logger.success(f"[auto-accept] Confirmed login for {account['login']} (client_id={client_id})")
|
||||
return True
|
||||
logger.warning(f"[auto-accept] Confirm failed for {account['login']}: HTTP {resp.status}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto-accept] Confirm error for {account['login']}: {exc}")
|
||||
return False
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Steam account ban/alert checking via supportmessages page."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
async def check_ban(steam) -> str:
|
||||
"""Check if account has active bans/alerts.
|
||||
|
||||
Returns: 'BANNED', 'NO BAN', or '' on error.
|
||||
"""
|
||||
try:
|
||||
response_html = await steam.request(
|
||||
"https://store.steampowered.com/supportmessages/",
|
||||
method="GET",
|
||||
)
|
||||
|
||||
if isinstance(response_html, bytes):
|
||||
response_html = response_html.decode("utf-8", errors="replace")
|
||||
|
||||
if "support_message_page" in response_html or "This account has been locked" in response_html:
|
||||
return "BANNED"
|
||||
elif "It doesn't appear that you have any active account alerts" in response_html:
|
||||
return "NO BAN"
|
||||
else:
|
||||
return "NO BAN"
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Ban check failed: {exc}")
|
||||
return ""
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Validation service for log:pass accounts (no mafile)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import _resolve_proxy, ProxyRequestStrategy, close_steam, extract_session_cookies
|
||||
from app.services.steam_ban import check_ban
|
||||
from app.services.steam_profile import fetch_profile
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
|
||||
async def check_logpass_account(account: dict, params: dict, *, task_id: str) -> None:
|
||||
"""Login with login:pass only, get steamid/nickname, check ban, update DB row."""
|
||||
try:
|
||||
from pysteamauth.auth import Steam
|
||||
except ImportError:
|
||||
raise RuntimeError("pysteamauth not installed")
|
||||
|
||||
acc_id = account["id"]
|
||||
proxy = await _resolve_proxy(account)
|
||||
request_strategy = None
|
||||
if proxy:
|
||||
connector = proxy_manager.get_connector(proxy)
|
||||
request_strategy = ProxyRequestStrategy(connector)
|
||||
logger.debug(f"[checker] Using proxy for {account['login']}")
|
||||
|
||||
steam = Steam(
|
||||
login=account["login"],
|
||||
password=account["password"],
|
||||
request_strategy=request_strategy,
|
||||
)
|
||||
|
||||
try:
|
||||
await task_manager.set_step(task_id, 1, 3, "Авторизация", acc_id)
|
||||
|
||||
# Pre-validate password before attempting RSA encryption (Steam RSA-2048 max 245 bytes)
|
||||
password = account.get("password") or ""
|
||||
if len(password.encode("utf-8")) > 245:
|
||||
raise ValueError(
|
||||
f"Password is too long ({len(password)} chars) — possible mafile data imported as password. "
|
||||
"Re-import the account in login:password format."
|
||||
)
|
||||
|
||||
await steam.login_to_steam()
|
||||
|
||||
# Save session cookies right after login
|
||||
try:
|
||||
session_cookies_json = extract_session_cookies(steam)
|
||||
if session_cookies_json:
|
||||
from app.database import get_db as _get_db
|
||||
_db = await _get_db()
|
||||
await _db.execute(
|
||||
"UPDATE logpass_accounts SET session_cookies = ? WHERE id = ?",
|
||||
(session_cookies_json, acc_id),
|
||||
)
|
||||
await _db.commit()
|
||||
logger.debug(f"[checker] {account['login']}: saved session cookies")
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"[checker] {account['login']}: failed to save cookies — {cookie_err}")
|
||||
|
||||
from app.config import read_validation_settings
|
||||
val_settings = read_validation_settings()
|
||||
|
||||
steam_id = str(steam.steamid)
|
||||
ban_status = ""
|
||||
if val_settings.get("check_ban"):
|
||||
await task_manager.set_step(task_id, 2, 3, "Проверка бана", acc_id)
|
||||
ban_status = await check_ban(steam)
|
||||
|
||||
nickname = None
|
||||
steam_level = None
|
||||
avatar_url = None
|
||||
last_online = None
|
||||
if steam_id and steam_id != "0" and val_settings.get("fetch_profile"):
|
||||
await task_manager.set_step(task_id, 3, 3, "Получение профиля", acc_id)
|
||||
profile = await fetch_profile(steam_id, steam=steam)
|
||||
if profile:
|
||||
nickname = profile.get("nickname")
|
||||
steam_level = profile.get("steam_level")
|
||||
avatar_url = profile.get("avatar_url")
|
||||
last_online = profile.get("last_online")
|
||||
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""UPDATE logpass_accounts
|
||||
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
|
||||
ban_status = ?, status = 'valid', updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(steam_id, nickname, steam_level, avatar_url, last_online, ban_status, acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.success(f"[checker] {account['login']} → valid, ban={ban_status}")
|
||||
|
||||
except Exception as exc:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
|
||||
(acc_id,),
|
||||
)
|
||||
await db.commit()
|
||||
logger.error(f"[checker] {account['login']} → {exc}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def _check_cs2_prime(steam, steam_id: str) -> str:
|
||||
"""Check CS2 Prime status."""
|
||||
try:
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/730/?tab=primeaccount"
|
||||
resp = await steam.request(url, method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
if "no_personal_data_stored_message" in html:
|
||||
return "Disabled"
|
||||
if "personaldata_elements_container" in html and "Enabled" in html:
|
||||
return "Enabled"
|
||||
return "Disabled"
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] prime check error: {e}")
|
||||
return "Disabled"
|
||||
|
||||
|
||||
async def _check_dota_trophy(steam, steam_id: str) -> str | None:
|
||||
"""Check Dota 2 Trophy Score."""
|
||||
try:
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/570/?category=Stats&tab=Trophy"
|
||||
resp = await steam.request(url, method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
if "no_personal_data_stored_message" in html:
|
||||
return None
|
||||
if "personaldata_elements_container" in html:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for row in soup.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
if len(cells) >= 3:
|
||||
try:
|
||||
int(cells[0].get_text(strip=True))
|
||||
score = cells[1].get_text(strip=True)
|
||||
if score.isdigit():
|
||||
return score
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] trophy check error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _check_dota_behavior(steam, steam_id: str) -> str | None:
|
||||
"""Check Dota 2 Behavior Score."""
|
||||
try:
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/570/?category=Account&tab=MatchPlayerReportIncoming"
|
||||
resp = await steam.request(url, method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
if "no_personal_data_stored_message" in html:
|
||||
return None
|
||||
if "personaldata_elements_container" in html:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for row in soup.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
if cells:
|
||||
last_cell = cells[-1].get_text(strip=True)
|
||||
if last_cell.isdigit():
|
||||
return last_cell
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] behavior check error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _check_licenses(steam) -> str:
|
||||
"""Get list of licenses (games) from Steam account."""
|
||||
try:
|
||||
resp = await steam.request("https://store.steampowered.com/account/licenses/", method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
table = soup.find("table", class_="account_table")
|
||||
if not table:
|
||||
return ""
|
||||
licenses = []
|
||||
rows = table.find_all("tr")
|
||||
for row in rows[1:]:
|
||||
cells = row.find_all("td")
|
||||
if len(cells) >= 2:
|
||||
game_cell = cells[1]
|
||||
remove_div = game_cell.find("div", class_="free_license_remove_link")
|
||||
if remove_div:
|
||||
remove_div.decompose()
|
||||
name = game_cell.get_text(strip=True)
|
||||
if name:
|
||||
licenses.append(name)
|
||||
return ", ".join(licenses)
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] licenses check error: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
async def full_parse_logpass_account(account: dict, params: dict, *, task_id: str) -> None:
|
||||
"""Full parse: login, ban, profile, prime, trophy, behavior, licenses."""
|
||||
try:
|
||||
from pysteamauth.auth import Steam
|
||||
except ImportError:
|
||||
raise RuntimeError("pysteamauth not installed")
|
||||
|
||||
acc_id = account["id"]
|
||||
proxy = await _resolve_proxy(account)
|
||||
request_strategy = None
|
||||
if proxy:
|
||||
connector = proxy_manager.get_connector(proxy)
|
||||
request_strategy = ProxyRequestStrategy(connector)
|
||||
|
||||
steam = Steam(
|
||||
login=account["login"],
|
||||
password=account["password"],
|
||||
request_strategy=request_strategy,
|
||||
)
|
||||
|
||||
try:
|
||||
await task_manager.set_step(task_id, 1, 6, "Авторизация", acc_id)
|
||||
await steam.login_to_steam()
|
||||
|
||||
# Save session cookies
|
||||
try:
|
||||
session_cookies_json = extract_session_cookies(steam)
|
||||
if session_cookies_json:
|
||||
from app.database import get_db as _get_db
|
||||
_db = await _get_db()
|
||||
await _db.execute(
|
||||
"UPDATE logpass_accounts SET session_cookies = ? WHERE id = ?",
|
||||
(session_cookies_json, acc_id),
|
||||
)
|
||||
await _db.commit()
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"[full_parse] {account['login']}: failed to save cookies — {cookie_err}")
|
||||
|
||||
steam_id = str(steam.steamid)
|
||||
|
||||
await task_manager.set_step(task_id, 2, 6, "Проверка бана", acc_id)
|
||||
ban_status = await check_ban(steam)
|
||||
|
||||
nickname = avatar_url = last_online = None
|
||||
steam_level = None
|
||||
if steam_id and steam_id != "0":
|
||||
await task_manager.set_step(task_id, 3, 6, "Профиль", acc_id)
|
||||
profile = await fetch_profile(steam_id, steam=steam)
|
||||
if profile:
|
||||
nickname = profile.get("nickname")
|
||||
steam_level = profile.get("steam_level")
|
||||
avatar_url = profile.get("avatar_url")
|
||||
last_online = profile.get("last_online")
|
||||
|
||||
prime = "Disabled"
|
||||
trophy = None
|
||||
behavior = None
|
||||
if steam_id and steam_id != "0":
|
||||
await task_manager.set_step(task_id, 4, 6, "Prime / Trophy", acc_id)
|
||||
prime = await _check_cs2_prime(steam, steam_id)
|
||||
trophy = await _check_dota_trophy(steam, steam_id)
|
||||
behavior = await _check_dota_behavior(steam, steam_id)
|
||||
|
||||
await task_manager.set_step(task_id, 5, 6, "Лицензии", acc_id)
|
||||
license_str = await _check_licenses(steam)
|
||||
|
||||
await task_manager.set_step(task_id, 6, 6, "Сохранение", acc_id)
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""UPDATE logpass_accounts
|
||||
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
|
||||
ban_status = ?, prime = ?, trophy = ?, behavior = ?, license = ?,
|
||||
status = 'valid', updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(steam_id, nickname, steam_level, avatar_url, last_online,
|
||||
ban_status, prime, trophy, behavior, license_str, acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.success(f"[full_parse] {account['login']} → valid, ban={ban_status}, prime={prime}, trophy={trophy}, behavior={behavior}, licenses={len(license_str.split(', ')) if license_str else 0}")
|
||||
|
||||
except Exception as exc:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
|
||||
(acc_id,),
|
||||
)
|
||||
await db.commit()
|
||||
logger.error(f"[full_parse] {account['login']} → {exc}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
@@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import create_steam_session, close_steam, extract_session_cookies
|
||||
from app.services.steam_wizard import AJAX_HEADERS, run_common_wizard
|
||||
|
||||
|
||||
def _get_fresh_sessionid(steam) -> str:
|
||||
"""Get sessionid from the live aiohttp cookie jar."""
|
||||
return steam._requests.cookies("help.steampowered.com").get("sessionid", "")
|
||||
|
||||
|
||||
async def change_email(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change account email via Steam recovery wizard + interactive prompts."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
login = account["login"]
|
||||
new_email = params.get("new_email", "")
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[change_email] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
try:
|
||||
password = account["password"]
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[change_email] {login}: auth ok, starting email change wizard...")
|
||||
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/wizard/HelpChangeEmail?redir=store/account/",
|
||||
login=login,
|
||||
password=password,
|
||||
)
|
||||
logger.info(f"[change_email] {login}: wizard done")
|
||||
|
||||
if not new_email:
|
||||
new_email = await task_manager.prompt_user(task_id, f"Введите новый email для {login}", login=login)
|
||||
if not new_email:
|
||||
raise ValueError("new_email is required")
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
|
||||
logger.info(f"[change_email] {login}: requesting email change -> {new_email}...")
|
||||
|
||||
for attempt in range(2):
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangeEmail/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"email": new_email,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[change_email] {login}: change request attempt {attempt+1} response: {data}")
|
||||
if data.get("errorMsg"):
|
||||
error_code = data.get("success", 0)
|
||||
if error_code == 24:
|
||||
raise PermissionError(f"Insufficient privileges for email change: {data['errorMsg']}")
|
||||
raise RuntimeError(f"Email change request failed: {data['errorMsg']}")
|
||||
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(3)
|
||||
|
||||
await task_manager.set_step(task_id, 4, 5, "Код подтверждения", acc_id=account["id"])
|
||||
email_code = await task_manager.prompt_user(task_id, f"Введите код подтверждения с email ({new_email})", login=login)
|
||||
if not email_code:
|
||||
raise ValueError("email_code is required")
|
||||
email_code = email_code.strip()
|
||||
|
||||
logger.info(f"[change_email] {login}: confirming email change...")
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
confirm_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"email": new_email,
|
||||
"email_change_code": email_code,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
|
||||
logger.debug(f"[change_email] {login}: confirm response: success={confirm_data.get('success')}")
|
||||
|
||||
error_code = confirm_data.get("success", 0)
|
||||
if confirm_data.get("errorMsg") and error_code != 29:
|
||||
raise RuntimeError(f"Email confirmation failed: {confirm_data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET email = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_email, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[change_email] {login}: email changed successfully -> {new_email}")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def validate_account(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Validate account credentials by attempting login. Fetches profile and ban status on success."""
|
||||
from app.database import get_db
|
||||
from app.services.steam_auth import close_steam
|
||||
from app.services.steam_profile import fetch_profile
|
||||
from app.services.steam_ban import check_ban
|
||||
from app.core.task_manager import task_manager
|
||||
from app.config import read_validation_settings
|
||||
|
||||
steam = None
|
||||
error_msg = None
|
||||
ban_status = ""
|
||||
|
||||
val_settings = read_validation_settings()
|
||||
|
||||
login = account["login"]
|
||||
profile = None
|
||||
try:
|
||||
await task_manager.set_step(task_id, 1, 4, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[validate] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
status = "valid"
|
||||
logger.info(f"[validate] {login}: auth ok")
|
||||
|
||||
if status == "valid" and steam is not None:
|
||||
if val_settings.get("check_ban"):
|
||||
await task_manager.set_step(task_id, 2, 4, "Проверка бана", acc_id=account["id"])
|
||||
logger.info(f"[validate] {login}: checking ban status...")
|
||||
ban_status = await check_ban(steam)
|
||||
logger.info(f"[validate] {login}: ban = {ban_status or 'none'}")
|
||||
if account.get("steam_id") and val_settings.get("fetch_profile"):
|
||||
await task_manager.set_step(task_id, 3, 4, "Профиль", acc_id=account["id"])
|
||||
logger.info(f"[validate] {login}: fetching profile (id={account['steam_id']})...")
|
||||
profile = await fetch_profile(account["steam_id"], steam=steam)
|
||||
if profile:
|
||||
logger.info(f"[validate] {login}: profile fetched — {profile.get('nickname')}, lvl={profile.get('steam_level')}")
|
||||
else:
|
||||
logger.warning(f"[validate] {login}: failed to fetch profile")
|
||||
|
||||
await task_manager.set_step(task_id, 4, 4, "Сохранение", acc_id=account["id"])
|
||||
if steam is not None and status == "valid":
|
||||
try:
|
||||
session_cookies = extract_session_cookies(steam)
|
||||
if session_cookies:
|
||||
_db = await get_db()
|
||||
await _db.execute(
|
||||
"UPDATE accounts SET session_cookies = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(session_cookies, account["id"]),
|
||||
)
|
||||
await _db.commit()
|
||||
logger.debug(f"[validate] {login}: saved session cookies")
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"[validate] {login}: failed to save cookies — {cookie_err}")
|
||||
|
||||
except Exception as exc:
|
||||
status = "invalid"
|
||||
error_msg = str(exc)
|
||||
logger.warning(f"[validate] {login}: error — {error_msg}")
|
||||
finally:
|
||||
if steam is not None:
|
||||
await close_steam(steam)
|
||||
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET status = ?, ban_status = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(status, ban_status or None, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if profile:
|
||||
sets = []
|
||||
vals = []
|
||||
for col, key in [("nickname", "nickname"), ("avatar_url", "avatar_url"), ("steam_level", "steam_level"), ("last_online", "last_online")]:
|
||||
if profile.get(key) is not None:
|
||||
sets.append(f"{col} = ?")
|
||||
vals.append(profile[key])
|
||||
if sets:
|
||||
sets.append("updated_at = datetime('now')")
|
||||
vals.append(account["id"])
|
||||
await db.execute(f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?", vals)
|
||||
await db.commit()
|
||||
logger.info(f"[validate] {login}: profile saved — {profile.get('nickname')}")
|
||||
|
||||
if error_msg:
|
||||
raise Exception(f"{error_msg}")
|
||||
else:
|
||||
ban_info = f" | ban={ban_status}" if ban_status else ""
|
||||
logger.info(f"[validate] {login}: result — {status}{ban_info}")
|
||||
@@ -0,0 +1,83 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def generate_2fa_code(shared_secret: str) -> str:
|
||||
"""Generate a Steam Guard 2FA code from shared_secret."""
|
||||
timestamp = int(time.time()) // 30
|
||||
msg = struct.pack(">Q", timestamp)
|
||||
key = base64.b64decode(shared_secret)
|
||||
auth = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
|
||||
offset = auth[19] & 0xF
|
||||
code = struct.unpack(">I", auth[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
|
||||
chars = "23456789BCDFGHJKMNPQRTVWXY"
|
||||
result = []
|
||||
for _ in range(5):
|
||||
result.append(chars[code % len(chars)])
|
||||
code //= len(chars)
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
async def remove_guard(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Remove Steam Guard 2FA via revocation code from mafile."""
|
||||
from app.services.steam_auth import create_steam_session, close_steam, read_mafile_data
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
mafile = read_mafile_data(account)
|
||||
revocation_code = (mafile or {}).get("revocation_code", "") if mafile else ""
|
||||
if not revocation_code:
|
||||
raise ValueError("revocation_code not found in mafile")
|
||||
|
||||
login = account["login"]
|
||||
await task_manager.set_step(task_id, 1, 3, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[remove_guard] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
logger.info(f"[remove_guard] {login}: auth ok")
|
||||
try:
|
||||
await task_manager.set_step(task_id, 2, 3, "Удаление Guard", acc_id=account["id"])
|
||||
logger.info(f"[remove_guard] {login}: sending Guard removal request...")
|
||||
sessionid = await steam.sessionid("store.steampowered.com")
|
||||
|
||||
await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/remove",
|
||||
method="GET",
|
||||
params={"step": "promptdevice"},
|
||||
)
|
||||
await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/remove",
|
||||
method="GET",
|
||||
params={"step": "promptrcode"},
|
||||
)
|
||||
await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/manage_action",
|
||||
method="POST",
|
||||
data={"action": "removercode", "sessionid": sessionid},
|
||||
)
|
||||
response = await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/manage_remove_revocation_code",
|
||||
method="POST",
|
||||
data={"revocation_code": revocation_code, "sessionid": sessionid},
|
||||
)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 3, "Готово", acc_id=account["id"])
|
||||
logger.success(f"[remove_guard] {login}: Guard removed")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def generate_2fa(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Generate and log a 2FA code for an account."""
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
if not shared_secret:
|
||||
raise ValueError("No shared_secret for this account")
|
||||
|
||||
code = generate_2fa_code(shared_secret)
|
||||
logger.debug(f"[generate_2fa] {account['login']}: code = {code}")
|
||||
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.core.crypto import encrypt_password
|
||||
from app.services.steam_auth import create_steam_session, close_steam, read_mafile_data
|
||||
from app.services.steam_wizard import (
|
||||
AJAX_HEADERS,
|
||||
get_rsa_key,
|
||||
run_common_wizard,
|
||||
)
|
||||
|
||||
|
||||
async def change_password(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change account password via Steam recovery wizard."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
new_password = params.get("new_password", "")
|
||||
if not new_password:
|
||||
raise ValueError("new_password is required")
|
||||
|
||||
login = account["login"]
|
||||
|
||||
mafile_data = read_mafile_data(account)
|
||||
if not mafile_data:
|
||||
raise ValueError(f"No mafile found for {login} — cannot change password without 2FA")
|
||||
|
||||
identity_secret = mafile_data.get("identity_secret", "")
|
||||
device_id = mafile_data.get("device_id")
|
||||
|
||||
if not identity_secret:
|
||||
raise ValueError(f"No identity_secret in mafile for {login} — mobile confirmation impossible")
|
||||
if not device_id:
|
||||
raise ValueError(f"No device_id in mafile for {login} — mobile confirmation impossible")
|
||||
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
logger.info(f"[change_password] {login}: auth ok")
|
||||
|
||||
try:
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: starting password change wizard...")
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/wizard/HelpChangePassword?redir=store/account/",
|
||||
login=login,
|
||||
password=account["password"],
|
||||
)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "RSA ключ", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: wizard done, getting RSA key...")
|
||||
mod, exp, timestamp = await get_rsa_key(steam, login)
|
||||
|
||||
logger.info(f"[change_password] {login}: checking new password availability...")
|
||||
sessionid = await steam.sessionid("help.steampowered.com")
|
||||
check_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxCheckPasswordAvailable/",
|
||||
method="POST",
|
||||
data={
|
||||
"sessionid": sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"password": new_password,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
check_data = json.loads(check_text) if isinstance(check_text, str) else check_text
|
||||
if not check_data.get("available"):
|
||||
raise ValueError("New password is not available (too weak or reused)")
|
||||
|
||||
encrypted_new = encrypt_password(new_password, mod, exp)
|
||||
await task_manager.set_step(task_id, 4, 5, "Отправка запроса", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: password available, sending change request...")
|
||||
result_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePassword/",
|
||||
method="POST",
|
||||
data={
|
||||
"sessionid": sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"password": encrypted_new,
|
||||
"rsatimestamp": timestamp,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
|
||||
result_data = json.loads(result_text) if isinstance(result_text, str) else result_text
|
||||
logger.debug(f"[change_password] {login}: change response: success={result_data.get('success')}, hasHash={bool(result_data.get('hash'))}")
|
||||
|
||||
if result_data.get("errorMsg"):
|
||||
raise RuntimeError(f"Password change rejected by Steam: {result_data['errorMsg']}")
|
||||
if not result_data.get("hash"):
|
||||
raise RuntimeError(f"Password change failed — no confirmation hash from Steam: {result_data}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET password = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_password, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[change_password] {login}: password changed")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
def _generate_random_password(length: int = 24) -> str:
|
||||
alphabet = string.ascii_letters + string.digits + "!"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
async def random_password(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change account password to a randomly generated one."""
|
||||
new_password = _generate_random_password()
|
||||
params["new_password"] = new_password
|
||||
await change_password(account, params)
|
||||
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import create_steam_session, close_steam
|
||||
from app.services.steam_wizard import AJAX_HEADERS, run_common_wizard
|
||||
|
||||
_PHONE_RE = re.compile(r"^\+\d{7,15}$")
|
||||
|
||||
|
||||
def _get_fresh_sessionid(steam) -> str:
|
||||
"""Get sessionid from the live aiohttp cookie jar (not from stale storage)."""
|
||||
return steam._requests.cookies("help.steampowered.com").get("sessionid", "")
|
||||
|
||||
|
||||
def _validate_phone(phone: str) -> str:
|
||||
"""Strip spaces/dashes and verify E.164-like format."""
|
||||
cleaned = re.sub(r"[\s\-()]", "", phone)
|
||||
if cleaned and not cleaned.startswith("+"):
|
||||
cleaned = "+" + cleaned
|
||||
if not _PHONE_RE.match(cleaned):
|
||||
raise ValueError(
|
||||
f"Неверный формат номера: '{phone}'. "
|
||||
"Ожидается международный формат, например +79123456789"
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
async def change_phone(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change phone number on a Steam account via recovery wizard + interactive prompts."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
login = account["login"]
|
||||
new_phone = params.get("new_phone", "")
|
||||
if new_phone:
|
||||
new_phone = _validate_phone(new_phone)
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[change_phone] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
try:
|
||||
password = account["password"]
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[change_phone] {login}: auth ok, starting phone change wizard...")
|
||||
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/en/wizard/HelpRemovePhoneNumber?redir=store/account",
|
||||
login=login,
|
||||
password=password,
|
||||
)
|
||||
logger.info(f"[change_phone] {login}: wizard done")
|
||||
|
||||
if not new_phone:
|
||||
new_phone = await task_manager.prompt_user(task_id, f"Введите новый номер телефона для {login}", login=login)
|
||||
if not new_phone:
|
||||
raise ValueError("new_phone is required")
|
||||
new_phone = _validate_phone(new_phone)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
|
||||
logger.info(f"[change_phone] {login}: requesting phone change -> {new_phone}...")
|
||||
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.info(f"[change_phone] {login}: change request response: {data}")
|
||||
if data.get("errorMsg"):
|
||||
err = data["errorMsg"]
|
||||
hint = ""
|
||||
if "112" in err:
|
||||
hint = " (номер уже привязан к другому аккаунту, виртуальный/VoIP, или не поддерживается Steam)"
|
||||
raise RuntimeError(f"Phone change request failed: {err}{hint}")
|
||||
|
||||
await task_manager.set_step(task_id, 4, 5, "SMS код", acc_id=account["id"])
|
||||
sms_code = await task_manager.prompt_user(task_id, f"Введите SMS код с номера {new_phone}", login=login)
|
||||
if not sms_code:
|
||||
raise ValueError("sms_code is required")
|
||||
sms_code = sms_code.strip()
|
||||
|
||||
logger.info(f"[change_phone] {login}: confirming with SMS code...")
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
confirm_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
"phone_change_code": sms_code,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
|
||||
logger.info(f"[change_phone] {login}: confirm response: {confirm_data}")
|
||||
if confirm_data.get("errorMsg"):
|
||||
raise RuntimeError(f"Phone confirmation failed: {confirm_data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET phone = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_phone, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[change_phone] {login}: phone changed -> {new_phone}")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def remove_phone(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Remove phone by changing it to a disposable number + SMS confirmation."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
login = account["login"]
|
||||
new_phone = params.get("new_phone", "")
|
||||
if new_phone:
|
||||
new_phone = _validate_phone(new_phone)
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[remove_phone] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
try:
|
||||
password = account["password"]
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[remove_phone] {login}: starting phone removal wizard...")
|
||||
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/en/wizard/HelpRemovePhoneNumber?redir=store/account",
|
||||
login=login,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if not new_phone:
|
||||
new_phone = await task_manager.prompt_user(task_id, "Введите новый номер телефона (на который переставится старый)", login=login)
|
||||
if not new_phone:
|
||||
raise ValueError("new_phone is required for phone removal")
|
||||
new_phone = _validate_phone(new_phone)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
|
||||
logger.info(f"[remove_phone] {login}: requesting phone change -> {new_phone}...")
|
||||
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.info(f"[remove_phone] {login}: change request response: {data}")
|
||||
if data.get("errorMsg"):
|
||||
raise RuntimeError(f"Phone change request failed: {data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 4, 5, "SMS код", acc_id=account["id"])
|
||||
sms_code = await task_manager.prompt_user(task_id, f"Введите SMS код с номера {new_phone}", login=login)
|
||||
if not sms_code:
|
||||
raise ValueError("sms_code is required")
|
||||
sms_code = sms_code.strip()
|
||||
|
||||
logger.info(f"[remove_phone] {login}: confirming with SMS code...")
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
confirm_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
"phone_change_code": sms_code,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
|
||||
logger.info(f"[remove_phone] {login}: confirm response: {confirm_data}")
|
||||
if confirm_data.get("errorMsg"):
|
||||
raise RuntimeError(f"Phone confirmation failed: {confirm_data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET phone = NULL, updated_at = datetime('now') WHERE id = ?",
|
||||
(account["id"],),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[remove_phone] {login}: phone removed (changed to {new_phone})")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
@@ -0,0 +1,90 @@
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
|
||||
async def fetch_profile(steam_id: str, steam=None) -> dict | None:
|
||||
"""Fetch persona name and avatar URL from Steam community profile page.
|
||||
|
||||
If *steam* session is provided, uses authenticated cookies.
|
||||
Returns {"nickname": str, "avatar_url": str, "steam_level": int|None} or None on failure.
|
||||
"""
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/"
|
||||
try:
|
||||
if steam is not None:
|
||||
from app.services.steam_auth import raw_request
|
||||
resp = await raw_request(steam, url, method="GET", allow_redirects=True)
|
||||
if resp.status != 200:
|
||||
logger.warning(f"Profile fetch for {steam_id}: HTTP {resp.status}")
|
||||
return None
|
||||
html = await resp.text()
|
||||
else:
|
||||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||||
async with aiohttp.ClientSession(headers=headers) as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(f"Profile fetch for {steam_id}: HTTP {resp.status}")
|
||||
return None
|
||||
html = await resp.text()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Profile fetch error for {steam_id}: {exc}")
|
||||
return None
|
||||
|
||||
nickname = _parse_persona_name(html)
|
||||
avatar_url = _parse_avatar(html)
|
||||
steam_level = _parse_level(html)
|
||||
last_online = _parse_last_online(html)
|
||||
|
||||
if not nickname and not avatar_url:
|
||||
return None
|
||||
|
||||
return {"nickname": nickname, "avatar_url": avatar_url, "steam_level": steam_level, "last_online": last_online}
|
||||
|
||||
|
||||
def _parse_persona_name(html: str) -> str | None:
|
||||
# g_rgProfileData = {"personaname":"jeffreyoelze1999",...}
|
||||
m = re.search(r'"personaname"\s*:\s*"([^"]*)"', html)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# Fallback: <span class="actual_persona_name">...</span>
|
||||
m = re.search(r'<span class="actual_persona_name">([^<]+)</span>', html)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _parse_avatar(html: str) -> str | None:
|
||||
# 1) g_rgProfileData JSON: {"avatarfull":"https://avatars.fastly.steamstatic.com/..."}
|
||||
m = re.search(r'"avatarfull"\s*:\s*"(https://[^"]+)"', html)
|
||||
if m:
|
||||
return m.group(1).replace("_full.jpg", "_medium.jpg")
|
||||
# 2) <meta property="og:image" content="https://avatars...">
|
||||
m = re.search(r'<meta\s+property="og:image"\s+content="(https://avatars[^"]+)"', html)
|
||||
if m:
|
||||
return m.group(1).replace("_full.jpg", "_medium.jpg")
|
||||
# 3) playerAvatarAutoSizeInner img
|
||||
m = re.search(r'playerAvatarAutoSizeInner[\s\S]{0,200}?<img[^>]+srcset="(https://[^"]+)"', html)
|
||||
if m:
|
||||
return m.group(1).replace("_full.jpg", "_medium.jpg")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_level(html: str) -> int | None:
|
||||
# <span class="friendPlayerLevelNum">100</span>
|
||||
m = re.search(r'<span\s+class="friendPlayerLevelNum">(\d+)</span>', html)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _parse_last_online(html: str) -> str | None:
|
||||
# "Last Online X days ago" → "Xd", "X hrs ago" → "Xh", "X min ago" → "Xm"
|
||||
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+day', html, re.IGNORECASE)
|
||||
if m:
|
||||
return f"{m.group(1)}d"
|
||||
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+hr', html, re.IGNORECASE)
|
||||
if m:
|
||||
return f"{m.group(1)}h"
|
||||
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+min', html, re.IGNORECASE)
|
||||
if m:
|
||||
return f"{m.group(1)}m"
|
||||
if re.search(r'Currently\s+(In-Game|Online)', html, re.IGNORECASE):
|
||||
return "online"
|
||||
return None
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Validation service for token accounts (refresh token → cookies)."""
|
||||
|
||||
import base64
|
||||
import json as _json
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import FormData
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import _resolve_proxy
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
from app.core.task_manager import task_manager
|
||||
from pysteamauth.auth.schemas import FinalizeLoginStatus
|
||||
|
||||
|
||||
def _decode_jwt_payload(token: str) -> dict:
|
||||
"""Decode the payload section of a JWT without verifying the signature."""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError("Token must have 3 parts (header.payload.signature)")
|
||||
payload_b64 = parts[1]
|
||||
# Pad base64 if needed
|
||||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||
return _json.loads(payload_bytes)
|
||||
|
||||
|
||||
async def check_token_account(account: dict, params: dict, *, task_id: str) -> None:
|
||||
"""Convert refresh_token → web cookies, fetch profile via cookies, update DB."""
|
||||
acc_id = account["id"]
|
||||
token = account["token"]
|
||||
|
||||
# Step 1: Decode JWT to get steam_id
|
||||
await task_manager.set_step(task_id, 1, 4, "Декодирование токена", acc_id)
|
||||
try:
|
||||
jwt_payload = _decode_jwt_payload(token)
|
||||
except Exception as exc:
|
||||
logger.error(f"[token_checker] {account.get('login', acc_id)}: bad JWT — {exc}")
|
||||
await _mark_invalid(acc_id)
|
||||
raise
|
||||
|
||||
steam_id = jwt_payload.get("sub")
|
||||
|
||||
# Step 2: GET steamcommunity.com for sessionid cookie
|
||||
await task_manager.set_step(task_id, 2, 4, "Получение sessionid", acc_id)
|
||||
proxy = await _resolve_proxy(account)
|
||||
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
|
||||
jar = aiohttp.CookieJar(unsafe=True)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Origin": "https://steamcommunity.com",
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
connector=connector, cookie_jar=jar, headers=headers, timeout=timeout,
|
||||
) as session:
|
||||
async with session.get("https://steamcommunity.com") as resp:
|
||||
await resp.read()
|
||||
|
||||
sessionid = None
|
||||
for cookie in jar:
|
||||
if cookie.key == "sessionid":
|
||||
sessionid = cookie.value
|
||||
break
|
||||
if not sessionid:
|
||||
raise RuntimeError("Failed to acquire sessionid cookie")
|
||||
|
||||
# Step 3: POST /jwt/finalizelogin with refresh_token as nonce
|
||||
await task_manager.set_step(task_id, 3, 4, "Получение куки", acc_id)
|
||||
form = FormData(fields=[
|
||||
("nonce", token),
|
||||
("sessionid", sessionid),
|
||||
("redir", "https://steamcommunity.com/login/home/?goto="),
|
||||
])
|
||||
async with session.post(
|
||||
"https://login.steampowered.com/jwt/finalizelogin",
|
||||
data=form,
|
||||
) as resp:
|
||||
resp_text = await resp.text()
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"finalizelogin HTTP {resp.status}: {resp_text[:200]}")
|
||||
|
||||
resp_json = _json.loads(resp_text)
|
||||
if not resp_json.get("steamID"):
|
||||
error_msg = resp_json.get("message", resp_text[:200])
|
||||
raise RuntimeError(f"finalizelogin failed: {error_msg}")
|
||||
|
||||
finalize = FinalizeLoginStatus.model_validate(resp_json)
|
||||
steam_id = finalize.steamID or steam_id
|
||||
|
||||
# Post to each transfer_info URL to set domain cookies
|
||||
for ti in finalize.transfer_info:
|
||||
form = FormData(fields=[
|
||||
("nonce", ti.params.nonce),
|
||||
("auth", ti.params.auth),
|
||||
("steamID", str(steam_id)),
|
||||
])
|
||||
try:
|
||||
async with session.post(ti.url, data=form) as _resp:
|
||||
await _resp.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Visit extra domains to collect all cookies
|
||||
for url in ("https://store.steampowered.com", "https://help.steampowered.com"):
|
||||
try:
|
||||
async with session.get(url) as _resp:
|
||||
await _resp.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Collect cookies from jar
|
||||
all_cookies = []
|
||||
for c in jar:
|
||||
all_cookies.append({
|
||||
"name": c.key,
|
||||
"value": c.value,
|
||||
"domain": c.get("domain", ""),
|
||||
"path": c.get("path", "/"),
|
||||
"secure": "secure" in str(c).lower(),
|
||||
"httpOnly": "httponly" in str(c).lower(),
|
||||
})
|
||||
session_cookies_json = _json.dumps(all_cookies)
|
||||
logger.debug(f"[token_checker] {account.get('login', acc_id)}: got {len(all_cookies)} cookies")
|
||||
|
||||
# Step 4: Fetch profile using authenticated session (cookies set)
|
||||
await task_manager.set_step(task_id, 4, 4, "Получение профиля", acc_id)
|
||||
nickname = None
|
||||
steam_level = None
|
||||
avatar_url = None
|
||||
last_online = None
|
||||
if steam_id and steam_id != "0":
|
||||
profile_url = f"https://steamcommunity.com/profiles/{steam_id}/"
|
||||
try:
|
||||
async with session.get(profile_url) as resp:
|
||||
if resp.status == 200:
|
||||
html = await resp.text()
|
||||
from app.services.steam_profile import (
|
||||
_parse_persona_name, _parse_avatar, _parse_level, _parse_last_online,
|
||||
)
|
||||
nickname = _parse_persona_name(html)
|
||||
avatar_url = _parse_avatar(html)
|
||||
steam_level = _parse_level(html)
|
||||
last_online = _parse_last_online(html)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[token_checker] profile fetch error: {exc}")
|
||||
|
||||
# Update DB
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""UPDATE token_accounts
|
||||
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
|
||||
session_cookies = ?, status = 'valid', updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(steam_id, nickname, steam_level, avatar_url, last_online, session_cookies_json, acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.success(f"[token_checker] {account.get('login', acc_id)} → valid, steam_id={steam_id}")
|
||||
|
||||
except Exception as exc:
|
||||
await _mark_invalid(acc_id)
|
||||
logger.error(f"[token_checker] {account.get('login', acc_id)} → {exc}")
|
||||
raise
|
||||
|
||||
|
||||
async def _mark_invalid(acc_id: int) -> None:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE token_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
|
||||
(acc_id,),
|
||||
)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Shared Steam account recovery wizard flow.
|
||||
|
||||
Steps 1-9 are identical across password change, email change, and phone removal.
|
||||
This module extracts that common logic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.core.crypto import encrypt_password
|
||||
from app.services.steam_auth import raw_request
|
||||
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/"
|
||||
"537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36"
|
||||
)
|
||||
|
||||
AJAX_HEADERS = {
|
||||
"Accept": "*/*",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Origin": "https://help.steampowered.com",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WizardParams:
|
||||
s: int
|
||||
account: int
|
||||
reset: int
|
||||
issueid: int
|
||||
lost: int = 0
|
||||
sessionid: str = ""
|
||||
|
||||
|
||||
async def parse_wizard_redirect(steam, entry_url: str) -> WizardParams:
|
||||
"""Step 1-2: Navigate to wizard entry URL, follow redirects, parse params from final URL."""
|
||||
logger.debug(f"[Wizard] Step 1-2: GET {entry_url}")
|
||||
response = await raw_request(
|
||||
steam,
|
||||
url=entry_url,
|
||||
method="GET",
|
||||
headers={"User-Agent": BROWSER_UA},
|
||||
allow_redirects=True,
|
||||
)
|
||||
final_url = str(response.url)
|
||||
logger.debug(f"[Wizard] Redirect → {final_url}")
|
||||
query = parse_qs(urlparse(final_url).query)
|
||||
|
||||
sessionid = await steam.sessionid("help.steampowered.com")
|
||||
|
||||
params = WizardParams(
|
||||
s=int(query.get("s", [0])[0]),
|
||||
account=int(query.get("account", [0])[0]),
|
||||
reset=int(query.get("reset", [0])[0]),
|
||||
issueid=int(query.get("issueid", [0])[0]),
|
||||
lost=int(query.get("lost", [0])[0]),
|
||||
sessionid=sessionid,
|
||||
)
|
||||
logger.debug(f"[Wizard] Parsed: s={params.s}, account={params.account}, reset={params.reset}, issueid={params.issueid}")
|
||||
return params
|
||||
|
||||
|
||||
async def login_info_enter_code(steam, params: WizardParams) -> dict:
|
||||
"""Step 3: Enter code mode."""
|
||||
logger.debug("[Wizard] Step 3: HelpWithLoginInfoEnterCode")
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/HelpWithLoginInfoEnterCode",
|
||||
method="GET",
|
||||
params={
|
||||
"s": params.s,
|
||||
"account": params.account,
|
||||
"reset": params.reset,
|
||||
"lost": params.lost,
|
||||
"issueid": params.issueid,
|
||||
"sessionid": params.sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
result = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[Wizard] Step 3 response: {result}")
|
||||
return result
|
||||
|
||||
|
||||
async def send_recovery_code(steam, params: WizardParams) -> dict:
|
||||
"""Step 4: Send account recovery code (method=8 = mobile app)."""
|
||||
logger.debug("[Wizard] Step 4: AjaxSendAccountRecoveryCode (method=8, mobile)")
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxSendAccountRecoveryCode",
|
||||
method="POST",
|
||||
data={
|
||||
"s": params.s,
|
||||
"account": params.account,
|
||||
"reset": params.reset,
|
||||
"lost": params.lost,
|
||||
"issueid": params.issueid,
|
||||
"sessionid": params.sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"method": 8,
|
||||
"link": "",
|
||||
"n": 1,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
result = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[Wizard] Step 4 response: {result}")
|
||||
if result.get("errorMsg"):
|
||||
raise RuntimeError(f"SendRecoveryCode failed: {result['errorMsg']}")
|
||||
return result
|
||||
|
||||
|
||||
async def mobile_confirm(steam, params: WizardParams, retries: int = 5) -> None:
|
||||
"""Step 5: Confirm via Steam mobile confirmations API (getlist → find by creator_id → ajaxop allow)."""
|
||||
logger.debug("[Wizard] Step 5: Mobile confirmation")
|
||||
|
||||
try:
|
||||
sid = steam.steamid
|
||||
except (ValueError, AttributeError):
|
||||
sid = None
|
||||
if not sid:
|
||||
raise RuntimeError("No steamid on Steam object — cannot perform mobile confirmation")
|
||||
|
||||
try:
|
||||
did = steam.device_id
|
||||
except (ValueError, AttributeError):
|
||||
did = None
|
||||
if not did:
|
||||
raise RuntimeError("No device_id on Steam object — cannot perform mobile confirmation")
|
||||
|
||||
try:
|
||||
_ = steam.identity_secret
|
||||
except (ValueError, AttributeError):
|
||||
raise RuntimeError("No identity_secret on Steam object — cannot perform mobile confirmation")
|
||||
|
||||
logger.debug(f"[MobileConfirm] steamid={sid}, device_id={did[:20]}..., creator_id(s)={params.s}")
|
||||
|
||||
# Steam needs time to create the confirmation after send_recovery_code
|
||||
logger.debug("[MobileConfirm] Waiting 3s for Steam to create confirmation...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
server_time = await steam.get_server_time()
|
||||
conf_hash = steam.get_confirmation_hash(server_time=server_time)
|
||||
logger.debug(f"[MobileConfirm] Attempt {attempt + 1}/{retries}: getlist (server_time={server_time})")
|
||||
|
||||
conf_text = await steam.request(
|
||||
url="https://steamcommunity.com/mobileconf/getlist",
|
||||
method="GET",
|
||||
cookies={
|
||||
"mobileClient": "ios",
|
||||
"mobileClientVersion": "2.0.20",
|
||||
"steamid": str(sid),
|
||||
"Steam_Language": "english",
|
||||
},
|
||||
params={
|
||||
"p": did,
|
||||
"a": str(sid),
|
||||
"k": conf_hash,
|
||||
"t": server_time,
|
||||
"m": "react",
|
||||
"tag": "conf",
|
||||
},
|
||||
)
|
||||
conf_data = json.loads(conf_text) if isinstance(conf_text, str) else conf_text
|
||||
|
||||
confs = conf_data.get("conf", [])
|
||||
logger.debug(f"[MobileConfirm] getlist response: success={conf_data.get('success')}, {len(confs)} confirmation(s)")
|
||||
for i, c in enumerate(confs):
|
||||
logger.debug(
|
||||
f"[MobileConfirm] [{i}] id={c.get('id')}, type={c.get('type')}, "
|
||||
f"type_name={c.get('type_name', '?')}, creator_id={c.get('creator_id')}, "
|
||||
f"headline={c.get('headline', '')}"
|
||||
)
|
||||
|
||||
if not conf_data.get("success"):
|
||||
logger.warning(f"[MobileConfirm] getlist failed: {conf_data}")
|
||||
raise RuntimeError(f"getlist failed: {conf_data}")
|
||||
|
||||
target = None
|
||||
creator = str(params.s)
|
||||
for c in confs:
|
||||
if str(c.get("creator_id", "")) == creator:
|
||||
target = c
|
||||
break
|
||||
|
||||
if not target:
|
||||
ids = [str(c.get("creator_id", "")) for c in confs]
|
||||
logger.warning(f"[MobileConfirm] No match for creator_id={creator}, available: {ids}")
|
||||
raise RuntimeError(f"No confirmation found for creator_id={creator}")
|
||||
|
||||
logger.debug(
|
||||
f"[MobileConfirm] Found match! id={target['id']}, nonce={target.get('nonce')}, "
|
||||
f"creator_id={target.get('creator_id')}"
|
||||
)
|
||||
|
||||
allow_time = await steam.get_server_time()
|
||||
allow_hash = steam.get_confirmation_hash(server_time=allow_time, tag="allow")
|
||||
logger.debug(f"[MobileConfirm] Calling ajaxop allow (server_time={allow_time})")
|
||||
|
||||
allow_text = await steam.request(
|
||||
url="https://steamcommunity.com/mobileconf/ajaxop",
|
||||
method="GET",
|
||||
cookies={
|
||||
"mobileClient": "ios",
|
||||
"mobileClientVersion": "2.0.20",
|
||||
},
|
||||
params={
|
||||
"op": "allow",
|
||||
"p": did,
|
||||
"a": str(sid),
|
||||
"k": allow_hash,
|
||||
"t": allow_time,
|
||||
"m": "react",
|
||||
"tag": "allow",
|
||||
"cid": target["id"],
|
||||
"ck": target["nonce"],
|
||||
},
|
||||
)
|
||||
allow_data = json.loads(allow_text) if isinstance(allow_text, str) else allow_text
|
||||
logger.debug(f"[MobileConfirm] ajaxop response: {allow_data}")
|
||||
|
||||
if allow_data.get("success"):
|
||||
logger.debug(f"[MobileConfirm] ✓ Confirmation accepted (attempt {attempt + 1})")
|
||||
return
|
||||
raise RuntimeError(f"ajaxop returned success=false: {allow_data}")
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"[MobileConfirm] Attempt {attempt + 1}/{retries} failed: {exc}")
|
||||
if attempt < retries - 1:
|
||||
logger.debug(f"[MobileConfirm] Retrying in 3s...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
raise RuntimeError(f"Mobile confirmation failed after {retries} retries")
|
||||
|
||||
|
||||
async def poll_recovery_confirmation(steam, params: WizardParams) -> dict:
|
||||
"""Step 6: Poll account recovery confirmation (single call, matches old chemail.py)."""
|
||||
logger.debug("[Wizard] Step 6: AjaxPollAccountRecoveryConfirmation")
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxPollAccountRecoveryConfirmation",
|
||||
method="POST",
|
||||
data={
|
||||
"s": params.s,
|
||||
"reset": params.reset,
|
||||
"lost": params.lost,
|
||||
"issueid": params.issueid,
|
||||
"sessionid": params.sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"method": 8,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[Wizard] Step 6 response: {data}")
|
||||
if data.get("errorMsg"):
|
||||
raise RuntimeError(f"Poll error from Steam: {data['errorMsg']}")
|
||||
return data
|
||||
|
||||
|
||||
async def verify_recovery_code(steam, params: WizardParams) -> dict:
|
||||
"""Step 7: Verify recovery code (empty for mobile method)."""
|
||||
logger.debug("[Wizard] Step 7: AjaxVerifyAccountRecoveryCode (code=empty, method=8)")
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxVerifyAccountRecoveryCode",
|
||||
method="GET",
|
||||
params={
|
||||
"code": "",
|
||||
"s": params.s,
|
||||
"reset": params.reset,
|
||||
"lost": params.lost,
|
||||
"method": 8,
|
||||
"issueid": params.issueid,
|
||||
"sessionid": params.sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
result = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[Wizard] Step 7 response: {result}")
|
||||
if result.get("errorMsg"):
|
||||
raise RuntimeError(f"Step 7 (VerifyRecoveryCode) failed: {result['errorMsg']}")
|
||||
return result
|
||||
|
||||
|
||||
async def get_next_step(steam, params: WizardParams) -> dict:
|
||||
"""Step 8: Get next wizard step (lost=2 hardcoded)."""
|
||||
logger.debug("[Wizard] Step 8: AjaxAccountRecoveryGetNextStep (lost=2)")
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryGetNextStep",
|
||||
method="POST",
|
||||
data={
|
||||
"s": params.s,
|
||||
"account": params.account,
|
||||
"reset": params.reset,
|
||||
"lost": 2,
|
||||
"issueid": params.issueid,
|
||||
"sessionid": params.sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
result = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[Wizard] Step 8 response: {result}")
|
||||
if result.get("errorMsg"):
|
||||
raise RuntimeError(f"Step 8 (GetNextStep) failed: {result['errorMsg']}")
|
||||
return result
|
||||
|
||||
|
||||
async def get_rsa_key(steam, login: str) -> tuple[str, str, int]:
|
||||
"""Step 9a: Get RSA public key from Steam."""
|
||||
logger.debug(f"[Wizard] Getting RSA key for {login}")
|
||||
sessionid = await steam.sessionid("help.steampowered.com")
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/login/getrsakey/",
|
||||
method="POST",
|
||||
data={
|
||||
"sessionid": sessionid,
|
||||
"username": login,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[Wizard] RSA key received: success={data.get('success')}, timestamp={data.get('timestamp')}")
|
||||
return data["publickey_mod"], data["publickey_exp"], int(data["timestamp"])
|
||||
|
||||
|
||||
async def verify_password(steam, params: WizardParams, login: str, password: str) -> dict:
|
||||
"""Step 9: Verify current password via RSA encryption."""
|
||||
logger.debug(f"[Wizard] Step 9: Verifying current password for {login}")
|
||||
mod, exp, timestamp = await get_rsa_key(steam, login)
|
||||
encrypted = encrypt_password(password, mod, exp)
|
||||
sessionid = await steam.sessionid("help.steampowered.com")
|
||||
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryVerifyPassword/",
|
||||
method="POST",
|
||||
data={
|
||||
"sessionid": sessionid,
|
||||
"s": params.s,
|
||||
"lost": 2,
|
||||
"reset": 1,
|
||||
"password": encrypted,
|
||||
"rsatimestamp": timestamp,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
result = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[Wizard] Step 9 response: {result}")
|
||||
if result.get("errorMsg"):
|
||||
raise RuntimeError(f"Step 9 (VerifyPassword) failed: {result['errorMsg']}")
|
||||
return result
|
||||
|
||||
|
||||
async def run_common_wizard(steam, entry_url: str, login: str, password: str) -> WizardParams:
|
||||
"""Execute the full shared wizard flow (steps 1-9).
|
||||
|
||||
Returns WizardParams ready for the service-specific final steps.
|
||||
"""
|
||||
logger.debug(f"[Wizard] ===== Starting wizard for {login} =====")
|
||||
logger.debug(f"[Wizard] Entry URL: {entry_url}")
|
||||
|
||||
params = await parse_wizard_redirect(steam, entry_url)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await login_info_enter_code(steam, params)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await send_recovery_code(steam, params)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await mobile_confirm(steam, params)
|
||||
|
||||
await poll_recovery_confirmation(steam, params)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await verify_recovery_code(steam, params)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await get_next_step(steam, params)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await verify_password(steam, params, login, password)
|
||||
|
||||
logger.debug(f"[Wizard] ===== Wizard complete for {login} =====")
|
||||
return params
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24">
|
||||
<title>Steam</title>
|
||||
<path d="M11.979 0C5.678 0 0.511 4.86 0.022 11.037l6.432 2.658c0.545 -0.371 1.203 -0.59 1.912 -0.59 0.063 0 0.125 0.004 0.188 0.006l2.861 -4.142V8.91c0 -2.495 2.028 -4.524 4.524 -4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525 -4.524 4.525h-0.105l-4.076 2.911c0 0.052 0.004 0.105 0.004 0.159 0 1.875 -1.515 3.396 -3.39 3.396 -1.635 0 -3.016 -1.173 -3.331 -2.727L0.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999 -5.373 11.999 -12S18.605 0 11.979 0zM7.54 18.21l-1.473 -0.61c0.262 0.543 0.714 0.999 1.314 1.25 1.297 0.539 2.793 -0.076 3.332 -1.375 0.263 -0.63 0.264 -1.319 0.005 -1.949s-0.75 -1.121 -1.377 -1.383c-0.624 -0.26 -1.29 -0.249 -1.878 -0.03l1.523 0.63c0.956 0.4 1.409 1.5 1.009 2.455 -0.397 0.957 -1.497 1.41 -2.454 1.012H7.54zm11.415 -9.303c0 -1.662 -1.353 -3.015 -3.015 -3.015 -1.665 0 -3.015 1.353 -3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015 -1.35 3.015 -3.015zm-5.273 -0.005c0 -1.252 1.013 -2.266 2.265 -2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251 -1.017 2.265 -2.266 2.265 -1.253 0 -2.265 -1.014 -2.265 -2.265z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SteamPanel</title>
|
||||
<script type="module" crossorigin src="/assets/index-Bxw--tTv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cm89176E.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user