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
+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}