This commit is contained in:
Manchik
2026-04-17 22:22:40 +03:00
commit c82866b58a
135 changed files with 18493 additions and 0 deletions
View File
View File
+348
View File
@@ -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)}
+86
View File
@@ -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"])}
+49
View File
@@ -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}
+65
View File
@@ -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..."}
+356
View File
@@ -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..."}
+67
View File
@@ -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")
+275
View File
@@ -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"},
)
+123
View File
@@ -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}
+210
View File
@@ -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"]
+101
View File
@@ -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")
+245
View File
@@ -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}
+50
View File
@@ -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
View File
+120
View File
@@ -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()
+20
View File
@@ -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")
+35
View File
@@ -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")
+138
View File
@@ -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)
+78
View File
@@ -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()
+249
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
+96
View File
@@ -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']}")
+20
View File
@@ -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)
+192
View File
@@ -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)
+220
View File
@@ -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
+29
View File
@@ -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 ""
+297
View File
@@ -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)
+201
View File
@@ -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}")
+83
View File
@@ -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}")
+122
View File
@@ -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)
+216
View File
@@ -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)
+90
View File
@@ -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
+176
View File
@@ -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()
+397
View File
@@ -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
BIN
View File
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
+4
View File
@@ -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

+14
View File
@@ -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>