mirror of
https://github.com/daimyomizukagebay61/SteamPanel.git
synced 2026-07-25 11:44:28 +00:00
v 3.0.3
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
AccountCreate,
|
||||
AccountOut,
|
||||
AccountUpdate,
|
||||
BulkImportResult,
|
||||
MafileData,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[AccountOut])
|
||||
async def list_accounts():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM accounts ORDER BY id DESC")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=AccountOut)
|
||||
async def get_account(account_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.post("", response_model=AccountOut, status_code=201)
|
||||
async def create_account(account: AccountCreate):
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO accounts (login, password, steam_id, email, email_password, phone, proxy, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
account.login,
|
||||
account.password,
|
||||
account.steam_id,
|
||||
account.email,
|
||||
account.email_password,
|
||||
account.phone,
|
||||
account.proxy,
|
||||
account.notes,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
new_cursor = await db.execute(
|
||||
"SELECT * FROM accounts WHERE id = ?", (cursor.lastrowid,)
|
||||
)
|
||||
return dict(await new_cursor.fetchone())
|
||||
|
||||
|
||||
@router.put("/{account_id}", response_model=AccountOut)
|
||||
async def update_account(account_id: int, account: AccountUpdate):
|
||||
db = await get_db()
|
||||
fields = {k: v for k, v in account.model_dump().items() if v is not None}
|
||||
# Allow clearing fields by sending empty string → store as NULL
|
||||
for k, v in account.model_dump().items():
|
||||
if v == "" and k not in fields:
|
||||
fields[k] = None
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
fields["updated_at"] = "datetime('now')"
|
||||
set_clause = ", ".join(
|
||||
f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?"
|
||||
for k in fields
|
||||
)
|
||||
values = [v for k, v in fields.items() if k != "updated_at"]
|
||||
values.append(account_id)
|
||||
|
||||
await db.execute(
|
||||
f"UPDATE accounts SET {set_clause} WHERE id = ?",
|
||||
values,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=204)
|
||||
async def delete_account(account_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT mafile_path FROM accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
if row["mafile_path"]:
|
||||
mafile = Path(row["mafile_path"])
|
||||
if mafile.exists():
|
||||
mafile.unlink()
|
||||
await db.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
|
||||
await db.commit()
|
||||
|
||||
@router.post("/delete-bulk", status_code=200)
|
||||
async def delete_accounts_bulk(request: dict):
|
||||
"""Delete multiple accounts by IDs list, or all if ids is empty."""
|
||||
db = await get_db()
|
||||
ids = request.get("ids", [])
|
||||
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT id, mafile_path FROM accounts WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute("SELECT id, mafile_path FROM accounts")
|
||||
|
||||
rows = await cursor.fetchall()
|
||||
count = 0
|
||||
for row in rows:
|
||||
if row["mafile_path"]:
|
||||
mafile = Path(row["mafile_path"])
|
||||
if mafile.exists():
|
||||
mafile.unlink()
|
||||
count += 1
|
||||
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
await db.execute(f"DELETE FROM accounts WHERE id IN ({placeholders})", ids)
|
||||
else:
|
||||
await db.execute("DELETE FROM accounts")
|
||||
|
||||
await db.commit()
|
||||
return {"deleted": count}
|
||||
|
||||
|
||||
@router.post("/assign-proxies")
|
||||
async def assign_proxies_round_robin():
|
||||
"""Round-robin assign proxies to accounts that don't have one."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id")
|
||||
proxies = [dict(r) for r in await proxy_cursor.fetchall()]
|
||||
if not proxies:
|
||||
raise HTTPException(status_code=400, detail="No proxies available")
|
||||
|
||||
acc_cursor = await db.execute("SELECT id FROM accounts WHERE proxy IS NULL OR proxy = '' ORDER BY id")
|
||||
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
|
||||
if not account_ids:
|
||||
return {"assigned": 0, "proxies_used": 0}
|
||||
|
||||
for i, acc_id in enumerate(account_ids):
|
||||
proxy = proxies[i % len(proxies)]
|
||||
await db.execute(
|
||||
"UPDATE accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxy["address"], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
await proxy_manager.load()
|
||||
logger.info(f"Assigned {len(proxies)} proxies to {len(account_ids)} accounts (round-robin)")
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/reassign-proxies")
|
||||
async def reassign_proxies_round_robin():
|
||||
"""Round-robin reassign proxies to ALL accounts, overwriting existing."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id")
|
||||
proxies = [dict(r) for r in await proxy_cursor.fetchall()]
|
||||
if not proxies:
|
||||
raise HTTPException(status_code=400, detail="No proxies available")
|
||||
|
||||
acc_cursor = await db.execute("SELECT id FROM accounts ORDER BY id")
|
||||
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
|
||||
if not account_ids:
|
||||
return {"assigned": 0, "proxies_used": 0}
|
||||
|
||||
for i, acc_id in enumerate(account_ids):
|
||||
proxy = proxies[i % len(proxies)]
|
||||
await db.execute(
|
||||
"UPDATE accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxy["address"], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
await proxy_manager.load()
|
||||
logger.info(f"Reassigned {len(proxies)} proxies to {len(account_ids)} accounts (round-robin)")
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/clear-proxies")
|
||||
async def clear_all_proxies():
|
||||
"""Remove proxy assignment from all accounts."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute("UPDATE accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''")
|
||||
await db.commit()
|
||||
count = cursor.rowcount
|
||||
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
await proxy_manager.load()
|
||||
logger.info(f"Cleared proxies from {count} accounts")
|
||||
return {"cleared": count}
|
||||
|
||||
|
||||
@router.post("/import", response_model=BulkImportResult)
|
||||
async def import_accounts(file: UploadFile = File(...)):
|
||||
"""Import accounts.
|
||||
|
||||
Supported formats (: or | separator):
|
||||
login|password|{mafile_json}
|
||||
login|password|email|email_password|{mafile_json}
|
||||
login|password|email|email_password
|
||||
"""
|
||||
content = (await file.read()).decode("utf-8", errors="ignore")
|
||||
result = BulkImportResult()
|
||||
db = await get_db()
|
||||
|
||||
for raw_line in content.strip().splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Split off embedded mafile JSON (everything from first '{' onward)
|
||||
json_start = line.find("{")
|
||||
if json_start != -1:
|
||||
mafile_str = line[json_start:]
|
||||
prefix = line[:json_start].rstrip("|:")
|
||||
else:
|
||||
mafile_str = None
|
||||
prefix = line
|
||||
|
||||
sep = "|" if "|" in prefix else ":"
|
||||
parts = prefix.split(sep)
|
||||
|
||||
if mafile_str:
|
||||
# Accepted: 2 fields (login|pass) or 4 fields (login|pass|email|email_pass)
|
||||
if len(parts) == 2:
|
||||
login, password = parts
|
||||
email = email_password = None
|
||||
elif len(parts) == 4:
|
||||
login, password, email, email_password = parts
|
||||
else:
|
||||
result.errors.append(f"Invalid prefix (need 2 or 4 fields): {line[:50]}")
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
mafile_data = json.loads(mafile_str)
|
||||
except json.JSONDecodeError as exc:
|
||||
result.errors.append(f"Bad mafile JSON on line starting '{prefix[:30]}': {exc}")
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
# Fall back to mail fields embedded in the mafile JSON itself
|
||||
if not email:
|
||||
email = mafile_data.get("mail") or mafile_data.get("email") or None
|
||||
email_password = mafile_data.get("mail_password") or mafile_data.get("email_password") or None
|
||||
else:
|
||||
# No mafile — must be exactly 4 fields
|
||||
if len(parts) != 4:
|
||||
result.errors.append(f"Invalid line (need 4 fields): {line[:50]}")
|
||||
result.skipped += 1
|
||||
continue
|
||||
login, password, email, email_password = parts
|
||||
mafile_data = None
|
||||
|
||||
try:
|
||||
# Check if account with this login already exists
|
||||
existing = await db.execute(
|
||||
"SELECT id FROM accounts WHERE login = ?", (login,)
|
||||
)
|
||||
if await existing.fetchone():
|
||||
result.errors.append(f"{login}: уже существует")
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO accounts (login, password, email, email_password)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(login, password, email or None, email_password or None),
|
||||
)
|
||||
result.imported += 1
|
||||
except Exception as exc:
|
||||
result.errors.append(f"{login}: {exc}")
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
if mafile_data:
|
||||
account_name = mafile_data.get("account_name") or login
|
||||
shared_secret = mafile_data.get("shared_secret") or ""
|
||||
identity_secret = mafile_data.get("identity_secret") or ""
|
||||
steam_id = (mafile_data.get("Session") or {}).get("SteamID") or None
|
||||
|
||||
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = settings.mafiles_dir / f"{account_name}.mafile"
|
||||
dest.write_text(json.dumps(mafile_data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
await db.execute(
|
||||
"""UPDATE accounts
|
||||
SET mafile_path = ?, shared_secret = ?, identity_secret = ?, steam_id = ?
|
||||
WHERE login = ? AND steam_id IS NULL""",
|
||||
(str(dest), shared_secret, identity_secret, steam_id, login),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Imported {result.imported} accounts, skipped {result.skipped}")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{account_id}/mafile")
|
||||
async def upload_mafile(account_id: int, file: UploadFile = File(...)):
|
||||
"""Bind a .mafile to an account."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
content = (await file.read()).decode("utf-8")
|
||||
try:
|
||||
mafile = MafileData.model_validate_json(content)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid mafile format")
|
||||
|
||||
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
steam_id = mafile.Session.SteamID or row["steam_id"] or account_id
|
||||
mafile_path = settings.mafiles_dir / f"{steam_id}.mafile"
|
||||
mafile_path.write_text(content, encoding="utf-8")
|
||||
|
||||
await db.execute(
|
||||
"""UPDATE accounts SET mafile_path = ?, shared_secret = ?, identity_secret = ?,
|
||||
steam_id = COALESCE(steam_id, ?), updated_at = datetime('now') WHERE id = ?""",
|
||||
(
|
||||
str(mafile_path),
|
||||
mafile.shared_secret,
|
||||
mafile.identity_secret,
|
||||
str(mafile.Session.SteamID) if mafile.Session.SteamID else None,
|
||||
account_id,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "ok", "mafile_path": str(mafile_path)}
|
||||
@@ -0,0 +1,86 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ActionRequest
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/actions", tags=["actions"])
|
||||
|
||||
VALID_ACTIONS = {
|
||||
"change_password",
|
||||
"random_password",
|
||||
"change_email",
|
||||
"change_phone",
|
||||
"remove_guard",
|
||||
"generate_2fa",
|
||||
"validate",
|
||||
"add_friend",
|
||||
"accept_logins",
|
||||
"change_language",
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def execute_action(request: ActionRequest):
|
||||
if request.action not in VALID_ACTIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid action. Valid: {', '.join(sorted(VALID_ACTIONS))}",
|
||||
)
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in request.account_ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM accounts WHERE id IN ({placeholders})",
|
||||
request.account_ids,
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type=request.action,
|
||||
accounts=accounts,
|
||||
params=request.params or {},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Action '{request.action}' submitted for {len(accounts)} accounts → task {task_id}"
|
||||
)
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
class Generate2FARequest(BaseModel):
|
||||
shared_secret: str
|
||||
|
||||
|
||||
class Generate2FAByAccountRequest(BaseModel):
|
||||
account_id: int
|
||||
|
||||
|
||||
@router.post("/generate-2fa")
|
||||
async def generate_2fa_code(request: Generate2FARequest):
|
||||
"""Server-side 2FA code generation from shared_secret."""
|
||||
from app.services.steam_guard import generate_2fa_code as gen_code
|
||||
|
||||
code = gen_code(request.shared_secret)
|
||||
return {"code": code}
|
||||
|
||||
|
||||
@router.post("/generate-2fa-by-account")
|
||||
async def generate_2fa_by_account(request: Generate2FAByAccountRequest):
|
||||
"""Generate 2FA code for an account using its stored shared_secret."""
|
||||
from app.services.steam_guard import generate_2fa_code as gen_code
|
||||
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT shared_secret FROM accounts WHERE id = ?",
|
||||
(request.account_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row["shared_secret"]:
|
||||
raise HTTPException(status_code=404, detail="Account not found or has no shared_secret")
|
||||
return {"code": gen_code(row["shared_secret"])}
|
||||
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auto_accept import auto_accept_manager
|
||||
|
||||
router = APIRouter(prefix="/api/auto-accept", tags=["auto-accept"])
|
||||
|
||||
|
||||
class AutoAcceptRequest(BaseModel):
|
||||
account_ids: list[int]
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_auto_accept(body: AutoAcceptRequest):
|
||||
db = await get_db()
|
||||
started: list[int] = []
|
||||
for aid in body.account_ids:
|
||||
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (aid,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Account {aid} not found")
|
||||
account = dict(row)
|
||||
if not account.get("shared_secret"):
|
||||
raise HTTPException(400, f"Account {account['login']} has no shared_secret (mafile)")
|
||||
await auto_accept_manager.start(account)
|
||||
await db.execute("UPDATE accounts SET auto_accept = 1 WHERE id = ?", (aid,))
|
||||
started.append(aid)
|
||||
await db.commit()
|
||||
return {"started": started}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_auto_accept(body: AutoAcceptRequest):
|
||||
db = await get_db()
|
||||
stopped: list[int] = []
|
||||
for aid in body.account_ids:
|
||||
auto_accept_manager.stop(aid)
|
||||
await db.execute("UPDATE accounts SET auto_accept = 0 WHERE id = ?", (aid,))
|
||||
stopped.append(aid)
|
||||
await db.commit()
|
||||
return {"stopped": stopped}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auto_accept_status():
|
||||
running = auto_accept_manager.running_ids()
|
||||
errors = auto_accept_manager.pop_errors()
|
||||
return {"running": list(running), "errors": errors}
|
||||
@@ -0,0 +1,65 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import read_validation_settings
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["browser"])
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/browser")
|
||||
async def open_browser(account_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Account not found")
|
||||
|
||||
account = dict(row)
|
||||
|
||||
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
|
||||
from app.config import read_validation_settings
|
||||
|
||||
# If account isn't validated or has no cookies — auto-revalidate if enabled
|
||||
if account["status"] != "valid" or not account.get("session_cookies"):
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Account {account['login']} not valid/no cookies, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
if account["status"] != "valid":
|
||||
raise HTTPException(400, "Account is not validated")
|
||||
raise HTTPException(400, "No session cookies. Validate the account first.")
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
alive = await check_cookies_alive(account["session_cookies"], proxy)
|
||||
if not alive:
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
raise HTTPException(400, "Session cookies expired. Re-validate the account.")
|
||||
|
||||
from app.services.browser_login import open_browser_with_cookies
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await open_browser_with_cookies(account)
|
||||
except Exception as exc:
|
||||
logger.error(f"Browser open failed for {account['login']}: {exc}")
|
||||
|
||||
asyncio.create_task(_run())
|
||||
|
||||
return {"status": "ok", "message": "Browser opening..."}
|
||||
@@ -0,0 +1,356 @@
|
||||
"""CRUD and validate endpoints for log:pass accounts."""
|
||||
|
||||
import csv
|
||||
import ctypes
|
||||
import io
|
||||
import sys
|
||||
|
||||
csv.field_size_limit(min(sys.maxsize, ctypes.c_ulong(-1).value // 2))
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
LogpassAccountCreate,
|
||||
LogpassAccountOut,
|
||||
LogpassAccountUpdate,
|
||||
)
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/logpass", tags=["logpass"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[LogpassAccountOut])
|
||||
async def list_logpass():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM logpass_accounts ORDER BY id DESC")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=LogpassAccountOut)
|
||||
async def get_logpass(account_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM logpass_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.post("", response_model=LogpassAccountOut, status_code=201)
|
||||
async def create_logpass(account: LogpassAccountCreate):
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO logpass_accounts (login, password, steam_id, proxy, notes) VALUES (?, ?, ?, ?, ?)",
|
||||
(account.login, account.password, account.steam_id, account.proxy, account.notes),
|
||||
)
|
||||
await db.commit()
|
||||
new_cursor = await db.execute(
|
||||
"SELECT * FROM logpass_accounts WHERE id = ?", (cursor.lastrowid,)
|
||||
)
|
||||
return dict(await new_cursor.fetchone())
|
||||
|
||||
|
||||
@router.put("/{account_id}", response_model=LogpassAccountOut)
|
||||
async def update_logpass(account_id: int, account: LogpassAccountUpdate):
|
||||
db = await get_db()
|
||||
fields = {k: v for k, v in account.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
fields["updated_at"] = "datetime('now')"
|
||||
set_clause = ", ".join(
|
||||
f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?"
|
||||
for k in fields
|
||||
)
|
||||
values = [v for k, v in fields.items() if k != "updated_at"]
|
||||
values.append(account_id)
|
||||
await db.execute(
|
||||
f"UPDATE logpass_accounts SET {set_clause} WHERE id = ?", values
|
||||
)
|
||||
await db.commit()
|
||||
cursor = await db.execute("SELECT * FROM logpass_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=204)
|
||||
async def delete_logpass(account_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM logpass_accounts WHERE id = ?", (account_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/delete-bulk")
|
||||
async def delete_logpass_bulk(data: dict):
|
||||
ids: list[int] = data.get("ids", [])
|
||||
if not ids:
|
||||
return {"deleted": 0}
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
await db.execute(f"DELETE FROM logpass_accounts WHERE id IN ({placeholders})", ids)
|
||||
await db.commit()
|
||||
return {"deleted": len(ids)}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_logpass(data: dict):
|
||||
"""Bulk import from plain text (login:pass / login|pass) or CSV with headers."""
|
||||
lines: list[str] = data.get("lines", [])
|
||||
if not lines:
|
||||
return {"imported": 0, "skipped": 0, "errors": []}
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
db = await get_db()
|
||||
|
||||
async def upsert(login: str, fields: dict):
|
||||
"""Insert or update by login, works with or without UNIQUE index."""
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM logpass_accounts WHERE login = ?", (login,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
sets = ", ".join(f"{k} = ?" for k in fields)
|
||||
vals = list(fields.values()) + [existing["id"]]
|
||||
await db.execute(
|
||||
f"UPDATE logpass_accounts SET {sets}, updated_at = datetime('now') WHERE id = ?",
|
||||
vals,
|
||||
)
|
||||
else:
|
||||
fields["login"] = login
|
||||
cols = ", ".join(fields.keys())
|
||||
placeholders = ", ".join("?" for _ in fields)
|
||||
await db.execute(
|
||||
f"INSERT INTO logpass_accounts ({cols}) VALUES ({placeholders})",
|
||||
list(fields.values()),
|
||||
)
|
||||
|
||||
# Detect CSV: first line looks like a header row
|
||||
first = lines[0].strip().lower()
|
||||
is_csv = first.startswith("login,") or first.startswith("login;")
|
||||
|
||||
try:
|
||||
if is_csv:
|
||||
text = "\n".join(lines)
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
for row in reader:
|
||||
login = (row.get("login") or "").strip()
|
||||
password = (row.get("password") or "").strip()
|
||||
if not login or not password:
|
||||
skipped += 1
|
||||
continue
|
||||
fields = {"password": password}
|
||||
_NA = {"n/a", "na", "none", "null", ""}
|
||||
for csv_col, db_col in [
|
||||
("steam_id", "steam_id"), ("ban", "ban_status"),
|
||||
("prime", "prime"), ("trophy", "trophy"),
|
||||
("behavior", "behavior"), ("license", "license"),
|
||||
]:
|
||||
val = (row.get(csv_col) or "").strip()
|
||||
if val:
|
||||
if db_col in ("prime", "trophy", "behavior") and val.lower() in _NA:
|
||||
val = "\u2014"
|
||||
fields[db_col] = val
|
||||
try:
|
||||
await upsert(login, fields)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
logger.error(f"Logpass CSV import error for '{login}': {exc}")
|
||||
errors.append(f"{login}: {exc}")
|
||||
else:
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
skipped += 1
|
||||
continue
|
||||
# Skip JSON/mafile lines
|
||||
if line.startswith("{") or line.startswith("["):
|
||||
skipped += 1
|
||||
continue
|
||||
if "|" in line:
|
||||
parts = line.split("|", 1)
|
||||
elif ":" in line:
|
||||
parts = line.split(":", 1)
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
login, password = parts[0].strip(), parts[1].strip()
|
||||
if not login or not password:
|
||||
skipped += 1
|
||||
continue
|
||||
# Reject if password looks like JSON (mafile embedded) or is too long
|
||||
if password.startswith("{") or password.startswith("[") or len(password) > 128:
|
||||
skipped += 1
|
||||
logger.warning(f"Logpass import: skipped '{login}' — password looks like mafile or is too long ({len(password)} chars)")
|
||||
continue
|
||||
try:
|
||||
await upsert(login, {"password": password})
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
logger.error(f"Logpass import error for '{login}': {exc}")
|
||||
errors.append(f"{login}: {exc}")
|
||||
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.exception(f"Logpass import failed: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_logpass(data: dict):
|
||||
"""Submit validation task for selected log:pass accounts."""
|
||||
ids: list[int] = data.get("account_ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided")
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM logpass_accounts WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type="logpass_validate",
|
||||
accounts=accounts,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"logpass_validate submitted for {len(accounts)} accounts → task {task_id}")
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
@router.post("/full-parse")
|
||||
async def full_parse_logpass(data: dict):
|
||||
"""Submit full parse task (prime, trophy, behavior, licenses) for selected accounts."""
|
||||
ids: list[int] = data.get("account_ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided")
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM logpass_accounts WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type="logpass_full_parse",
|
||||
accounts=accounts,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"logpass_full_parse submitted for {len(accounts)} accounts → task {task_id}")
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
@router.post("/assign-proxies")
|
||||
async def logpass_assign_proxies():
|
||||
"""Round-robin assign proxies to logpass accounts that don't have one."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
|
||||
if not proxies:
|
||||
raise HTTPException(status_code=400, detail="No proxies available")
|
||||
|
||||
acc_cursor = await db.execute(
|
||||
"SELECT id FROM logpass_accounts WHERE proxy IS NULL OR proxy = '' ORDER BY id"
|
||||
)
|
||||
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
|
||||
if not account_ids:
|
||||
return {"assigned": 0, "proxies_used": 0}
|
||||
|
||||
for i, acc_id in enumerate(account_ids):
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/reassign-proxies")
|
||||
async def logpass_reassign_proxies():
|
||||
"""Round-robin reassign proxies to ALL logpass accounts."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
|
||||
if not proxies:
|
||||
raise HTTPException(status_code=400, detail="No proxies available")
|
||||
|
||||
acc_cursor = await db.execute("SELECT id FROM logpass_accounts ORDER BY id")
|
||||
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
|
||||
if not account_ids:
|
||||
return {"assigned": 0, "proxies_used": 0}
|
||||
|
||||
for i, acc_id in enumerate(account_ids):
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/clear-proxies")
|
||||
async def logpass_clear_proxies():
|
||||
"""Remove proxy from all logpass accounts."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"UPDATE logpass_accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''"
|
||||
)
|
||||
await db.commit()
|
||||
return {"cleared": cursor.rowcount}
|
||||
|
||||
|
||||
@router.post("/{account_id}/browser")
|
||||
async def open_logpass_browser(account_id: int):
|
||||
"""Open Chrome browser with saved session cookies for a logpass account."""
|
||||
import asyncio
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM logpass_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
account = dict(row)
|
||||
if not account.get("session_cookies"):
|
||||
raise HTTPException(status_code=400, detail="No session cookies. Validate the account first.")
|
||||
|
||||
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
|
||||
from app.config import read_validation_settings
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
alive = await check_cookies_alive(account["session_cookies"], proxy)
|
||||
if not alive:
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="logpass_validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
raise HTTPException(status_code=400, detail="Session cookies expired. Re-validate the account.")
|
||||
|
||||
from app.services.browser_login import open_browser_with_cookies
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await open_browser_with_cookies(account)
|
||||
except Exception as exc:
|
||||
logger.error(f"Browser open failed for {account['login']}: {exc}")
|
||||
|
||||
asyncio.create_task(_run())
|
||||
return {"status": "ok", "message": "Browser opening..."}
|
||||
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
_LOG_BUFFER: deque[dict[str, Any]] = deque(maxlen=500)
|
||||
_SUBSCRIBERS: list[asyncio.Queue] = []
|
||||
|
||||
LEVEL_ICONS = {
|
||||
"TRACE": "~",
|
||||
"DEBUG": ".",
|
||||
"INFO": "i",
|
||||
"SUCCESS": "+",
|
||||
"WARNING": "!",
|
||||
"ERROR": "e",
|
||||
"CRITICAL": "e",
|
||||
}
|
||||
|
||||
|
||||
def _log_sink(message: Any) -> None:
|
||||
record = message.record
|
||||
entry = {
|
||||
"ts": record["time"].strftime("%H:%M:%S"),
|
||||
"level": record["level"].name.lower(),
|
||||
"icon": LEVEL_ICONS.get(record["level"].name, "i"),
|
||||
"msg": record["message"],
|
||||
}
|
||||
_LOG_BUFFER.append(entry)
|
||||
for q in _SUBSCRIBERS[:]:
|
||||
try:
|
||||
q.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
def install_log_sink() -> None:
|
||||
logger.add(_log_sink, format="{message}", level="DEBUG", colorize=False)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_logs():
|
||||
return list(_LOG_BUFFER)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream_logs():
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=200)
|
||||
_SUBSCRIBERS.append(queue)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
entry = await queue.get()
|
||||
yield f"data: {json.dumps(entry, ensure_ascii=False)}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
if queue in _SUBSCRIBERS:
|
||||
_SUBSCRIBERS.remove(queue)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,275 @@
|
||||
import json
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import MafileData
|
||||
|
||||
router = APIRouter(prefix="/api/mafiles", tags=["mafile_tools"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_mafiles():
|
||||
"""List all stored mafiles."""
|
||||
mafiles_dir = settings.mafiles_dir
|
||||
if not mafiles_dir.exists():
|
||||
return []
|
||||
|
||||
result = []
|
||||
for f in sorted(mafiles_dir.glob("*.mafile")):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
result.append({
|
||||
"filename": f.name,
|
||||
"account_name": data.get("account_name", ""),
|
||||
"steam_id": data.get("Session", {}).get("SteamID", ""),
|
||||
"has_shared_secret": bool(data.get("shared_secret")),
|
||||
"has_identity_secret": bool(data.get("identity_secret")),
|
||||
"fully_enrolled": data.get("fully_enrolled", False),
|
||||
})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
result.append({"filename": f.name, "error": "Failed to parse"})
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_mafiles(files: list[UploadFile] = File(...)):
|
||||
"""Upload multiple .mafile files."""
|
||||
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
uploaded = 0
|
||||
errors: list[str] = []
|
||||
|
||||
db = await get_db()
|
||||
|
||||
for file in files:
|
||||
content = (await file.read()).decode("utf-8", errors="ignore")
|
||||
try:
|
||||
mafile = MafileData.model_validate_json(content)
|
||||
steam_id = mafile.Session.SteamID or mafile.account_name or file.filename
|
||||
dest = settings.mafiles_dir / f"{steam_id}.mafile"
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
uploaded += 1
|
||||
|
||||
if mafile.account_name:
|
||||
await db.execute(
|
||||
"""UPDATE accounts
|
||||
SET mafile_path = ?, shared_secret = ?, identity_secret = ?
|
||||
WHERE login = ?""",
|
||||
(
|
||||
str(dest),
|
||||
mafile.shared_secret or "",
|
||||
mafile.identity_secret or "",
|
||||
mafile.account_name,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
errors.append(f"{file.filename}: {exc}")
|
||||
|
||||
logger.info(f"Uploaded {uploaded} mafiles, {len(errors)} errors")
|
||||
return {"uploaded": uploaded, "errors": errors}
|
||||
|
||||
|
||||
@router.get("/{filename}")
|
||||
async def get_mafile(filename: str):
|
||||
"""Get parsed mafile data by filename."""
|
||||
safe_name = Path(filename).name
|
||||
mafile_path = settings.mafiles_dir / safe_name
|
||||
if not mafile_path.exists() or not mafile_path.suffix == ".mafile":
|
||||
raise HTTPException(status_code=404, detail="Mafile not found")
|
||||
|
||||
content = mafile_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=500, detail="Failed to parse mafile")
|
||||
|
||||
|
||||
@router.delete("/{filename}", status_code=204)
|
||||
async def delete_mafile(filename: str):
|
||||
safe_name = Path(filename).name
|
||||
mafile_path = settings.mafiles_dir / safe_name
|
||||
if not mafile_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Mafile not found")
|
||||
mafile_path.unlink()
|
||||
|
||||
|
||||
@router.get("/export/all")
|
||||
async def export_all_secrets():
|
||||
"""Export account_name:shared_secret:identity_secret for all mafiles."""
|
||||
mafiles_dir = settings.mafiles_dir
|
||||
if not mafiles_dir.exists():
|
||||
return []
|
||||
|
||||
result = []
|
||||
for f in sorted(mafiles_dir.glob("*.mafile")):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
result.append({
|
||||
"account_name": data.get("account_name", ""),
|
||||
"shared_secret": data.get("shared_secret", ""),
|
||||
"identity_secret": data.get("identity_secret", ""),
|
||||
"steam_id": data.get("Session", {}).get("SteamID", ""),
|
||||
})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
fields: list[str] = []
|
||||
session_fields: list[str] = []
|
||||
format: str = "flat_mafiles" # flat_mafiles | per_account_folder | single_file
|
||||
account_ids: list[int] = []
|
||||
# Naming variables: {username} and {steamid} are replaced at export time
|
||||
folder_name_template: str = "{username}"
|
||||
mafile_name_template: str = "{steamid}.mafile"
|
||||
txt_name_template: str = "{username}.txt"
|
||||
include_txt_per_folder: bool = False
|
||||
include_global_txt: bool = False
|
||||
skip_folders: bool = False
|
||||
# Format for .txt lines: login:password:email:email_password
|
||||
txt_format: str = "{login}:{password}:{email}:{email_password}"
|
||||
|
||||
|
||||
def _filter_mafile(data: dict, fields: list[str], session_fields: list[str]) -> dict:
|
||||
"""Keep only selected top-level + Session fields from mafile JSON."""
|
||||
if not fields and not session_fields:
|
||||
return data
|
||||
|
||||
filtered = {}
|
||||
for key in fields:
|
||||
if key in data and key != "Session":
|
||||
filtered[key] = data[key]
|
||||
|
||||
if session_fields and "Session" in data:
|
||||
filtered["Session"] = {
|
||||
k: v for k, v in data["Session"].items() if k in session_fields
|
||||
}
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
@router.post("/export/zip")
|
||||
async def export_mafiles_zip(body: ExportRequest):
|
||||
"""Build a .zip with filtered mafile data, optionally limited to account IDs."""
|
||||
if not body.account_ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided for export")
|
||||
|
||||
mafiles_dir = settings.mafiles_dir
|
||||
if not mafiles_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="No mafiles directory")
|
||||
|
||||
db = await get_db()
|
||||
|
||||
# Load account info for naming and .txt generation
|
||||
account_map: dict[str, dict] = {} # login -> account row
|
||||
if body.account_ids:
|
||||
placeholders = ",".join("?" * len(body.account_ids))
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM accounts WHERE id IN ({placeholders})",
|
||||
body.account_ids,
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute("SELECT * FROM accounts")
|
||||
for row in await cursor.fetchall():
|
||||
account_map[row["login"]] = dict(row)
|
||||
|
||||
account_logins = set(account_map.keys()) if body.account_ids else set()
|
||||
|
||||
def _resolve_template(template: str, username: str, steam_id: str) -> str:
|
||||
return template.replace("{username}", username).replace("{steamid}", steam_id)
|
||||
|
||||
def _resolve_txt_line(template: str, acc: dict, mafile_data: dict | None = None) -> str:
|
||||
has_mafile_var = "{mafile}" in template
|
||||
if has_mafile_var:
|
||||
before, _, after = template.partition("{mafile}")
|
||||
else:
|
||||
before = template
|
||||
|
||||
line = (
|
||||
before
|
||||
.replace("{login}", acc.get("login") or "")
|
||||
.replace("{password}", acc.get("password") or "")
|
||||
.replace("{email}", acc.get("email") or "")
|
||||
.replace("{email_password}", acc.get("email_password") or "")
|
||||
.replace("{steam_id}", acc.get("steam_id") or "")
|
||||
.replace("{proxy}", acc.get("proxy") or "")
|
||||
)
|
||||
line = line.rstrip(":")
|
||||
if has_mafile_var and mafile_data is not None:
|
||||
line += ":" + json.dumps(mafile_data, ensure_ascii=False, separators=(",", ":"))
|
||||
return line
|
||||
|
||||
buf = io.BytesIO()
|
||||
all_data: list[str] = []
|
||||
global_txt_lines: list[str] = []
|
||||
folder_txt_map: dict[str, list[str]] = {} # folder_name -> lines
|
||||
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in sorted(mafiles_dir.glob("*.mafile")):
|
||||
try:
|
||||
raw = json.loads(f.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
acct_name = raw.get("account_name", "")
|
||||
steam_id = str(raw.get("Session", {}).get("SteamID", ""))
|
||||
|
||||
if account_logins:
|
||||
if acct_name not in account_logins and steam_id not in {str(a) for a in body.account_ids}:
|
||||
continue
|
||||
|
||||
filtered = _filter_mafile(raw, body.fields, body.session_fields)
|
||||
|
||||
# Find matching account record for txt
|
||||
acc = account_map.get(acct_name, {})
|
||||
|
||||
if body.format == "single_file":
|
||||
if acc:
|
||||
all_data.append(_resolve_txt_line(body.txt_format, acc, filtered))
|
||||
else:
|
||||
all_data.append(json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
elif body.format == "per_account_folder":
|
||||
folder_name = _resolve_template(body.folder_name_template, acct_name, steam_id)
|
||||
mafile_name = _resolve_template(body.mafile_name_template, acct_name, steam_id)
|
||||
|
||||
if body.skip_folders:
|
||||
zf.writestr(mafile_name, json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
else:
|
||||
zf.writestr(f"{folder_name}/{mafile_name}", json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
|
||||
if body.include_txt_per_folder and acc:
|
||||
txt_name = _resolve_template(body.txt_name_template, acct_name, steam_id)
|
||||
line = _resolve_txt_line(body.txt_format, acc, filtered)
|
||||
if folder_name not in folder_txt_map:
|
||||
folder_txt_map[folder_name] = []
|
||||
folder_txt_map[folder_name].append(line)
|
||||
zf.writestr(f"{folder_name}/{txt_name}", line + "\n")
|
||||
else:
|
||||
mafile_name = _resolve_template(body.mafile_name_template, acct_name, steam_id)
|
||||
zf.writestr(mafile_name, json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
|
||||
|
||||
# Collect global txt line
|
||||
if body.include_global_txt and acc:
|
||||
global_txt_lines.append(_resolve_txt_line(body.txt_format, acc, filtered))
|
||||
|
||||
if body.format == "single_file" and all_data:
|
||||
zf.writestr("accounts.txt", "\n".join(all_data) + "\n")
|
||||
|
||||
if body.include_global_txt and global_txt_lines:
|
||||
zf.writestr("accounts.txt", "\n".join(global_txt_lines) + "\n")
|
||||
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": "attachment; filename=mafiles_export.zip"},
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ProxyCreate, ProxyOut
|
||||
|
||||
router = APIRouter(prefix="/api/proxies", tags=["proxies"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProxyOut])
|
||||
async def list_proxies():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM proxies ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=ProxyOut, status_code=201)
|
||||
async def add_proxy(proxy: ProxyCreate):
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO proxies (address, protocol) VALUES (?, ?)",
|
||||
(proxy.address, proxy.protocol),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail="Proxy already exists")
|
||||
|
||||
new_cursor = await db.execute(
|
||||
"SELECT * FROM proxies WHERE id = ?", (cursor.lastrowid,)
|
||||
)
|
||||
return dict(await new_cursor.fetchone())
|
||||
|
||||
|
||||
@router.delete("/all", status_code=200)
|
||||
async def delete_all_proxies():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT COUNT(*) as cnt FROM proxies")
|
||||
row = await cursor.fetchone()
|
||||
count = row["cnt"]
|
||||
await db.execute("DELETE FROM proxies")
|
||||
await db.commit()
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
await proxy_manager.load()
|
||||
logger.info(f"Deleted all {count} proxies")
|
||||
return {"deleted": count}
|
||||
|
||||
|
||||
@router.delete("/{proxy_id}", status_code=204)
|
||||
async def delete_proxy(proxy_id: int):
|
||||
db = await get_db()
|
||||
result = await db.execute("DELETE FROM proxies WHERE id = ?", (proxy_id,))
|
||||
await db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Proxy not found")
|
||||
|
||||
|
||||
@router.post("/bulk")
|
||||
async def bulk_add_proxies(proxies: list[ProxyCreate]):
|
||||
db = await get_db()
|
||||
added = 0
|
||||
for p in proxies:
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO proxies (address, protocol) VALUES (?, ?)",
|
||||
(p.address, p.protocol),
|
||||
)
|
||||
added += 1
|
||||
except Exception:
|
||||
pass
|
||||
await db.commit()
|
||||
logger.info(f"Bulk added {added} proxies")
|
||||
return {"added": added}
|
||||
|
||||
|
||||
async def _check_one_proxy(proxy_id: int, address: str, protocol: str) -> dict:
|
||||
"""Check proxy by making a real HTTP request through it."""
|
||||
import aiohttp
|
||||
from aiohttp_socks import ProxyConnector
|
||||
from app.core.proxy_manager import build_proxy_url
|
||||
|
||||
try:
|
||||
proxy_url = build_proxy_url(address, protocol)
|
||||
connector = ProxyConnector.from_url(proxy_url, ssl=False)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
async with session.get(
|
||||
"https://steamcommunity.com/robots.txt",
|
||||
timeout=aiohttp.ClientTimeout(total=3),
|
||||
) as resp:
|
||||
alive = resp.status == 200
|
||||
except Exception:
|
||||
alive = False
|
||||
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE proxies SET is_alive = ?, last_checked = datetime('now') WHERE id = ?",
|
||||
(int(alive), proxy_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"id": proxy_id, "alive": alive}
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
async def check_proxies():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
if not rows:
|
||||
return {"total": 0, "alive": 0, "dead": 0}
|
||||
|
||||
sem = asyncio.Semaphore(300)
|
||||
|
||||
async def limited(row):
|
||||
async with sem:
|
||||
return await _check_one_proxy(row["id"], row["address"], row["protocol"])
|
||||
|
||||
results = await asyncio.gather(*(limited(r) for r in rows))
|
||||
alive_count = sum(1 for r in results if r["alive"])
|
||||
return {"total": len(results), "alive": alive_count, "dead": len(results) - alive_count}
|
||||
@@ -0,0 +1,210 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
SETTINGS_FILE = settings.data_dir / "settings.json"
|
||||
|
||||
DEFAULTS = {
|
||||
"validation": {
|
||||
"fetch_profile": True,
|
||||
"check_ban": True,
|
||||
"max_threads": 5,
|
||||
"auto_revalidate_browser": True,
|
||||
},
|
||||
"display": {
|
||||
"hide_passwords": False,
|
||||
},
|
||||
"columns": {
|
||||
"browser": True,
|
||||
"profile": True,
|
||||
"steam_id": True,
|
||||
"login": True,
|
||||
"password": True,
|
||||
"login_pass": True,
|
||||
"email": True,
|
||||
"phone": True,
|
||||
"status": True,
|
||||
"ban": True,
|
||||
"twofa": True,
|
||||
"mafile": True,
|
||||
"proxy": True,
|
||||
"actions": True,
|
||||
},
|
||||
"logpass_columns": {
|
||||
"browser": True,
|
||||
"profile": True,
|
||||
"steam_id": True,
|
||||
"login": True,
|
||||
"password": True,
|
||||
"login_pass": True,
|
||||
"status": True,
|
||||
"ban": True,
|
||||
"prime": True,
|
||||
"trophy": True,
|
||||
"behavior": True,
|
||||
"license": True,
|
||||
"proxy": True,
|
||||
"notes": True,
|
||||
"actions": True,
|
||||
},
|
||||
"token_columns": {
|
||||
"browser": True,
|
||||
"profile": True,
|
||||
"last_online": True,
|
||||
"steam_id": True,
|
||||
"login": True,
|
||||
"token": True,
|
||||
"status": True,
|
||||
"proxy": True,
|
||||
"notes": True,
|
||||
"actions": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
if SETTINGS_FILE.exists():
|
||||
return json.loads(SETTINGS_FILE.read_text("utf-8"))
|
||||
return DEFAULTS.copy()
|
||||
|
||||
|
||||
def _write(data: dict) -> None:
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8")
|
||||
|
||||
|
||||
class ValidationSettings(BaseModel):
|
||||
fetch_profile: bool = True
|
||||
check_ban: bool = True
|
||||
max_threads: int = 5
|
||||
auto_revalidate_browser: bool = True
|
||||
|
||||
|
||||
class DisplaySettings(BaseModel):
|
||||
hide_passwords: bool = False
|
||||
|
||||
|
||||
class ColumnSettings(BaseModel):
|
||||
browser: bool = True
|
||||
profile: bool = True
|
||||
steam_id: bool = True
|
||||
login: bool = True
|
||||
password: bool = True
|
||||
login_pass: bool = True
|
||||
email: bool = True
|
||||
phone: bool = True
|
||||
status: bool = True
|
||||
ban: bool = True
|
||||
twofa: bool = True
|
||||
mafile: bool = True
|
||||
proxy: bool = True
|
||||
actions: bool = True
|
||||
last_online: bool = True
|
||||
|
||||
|
||||
@router.get("/validation", response_model=ValidationSettings)
|
||||
async def get_validation_settings():
|
||||
data = _read()
|
||||
return data.get("validation", DEFAULTS["validation"])
|
||||
|
||||
|
||||
@router.put("/validation", response_model=ValidationSettings)
|
||||
async def update_validation_settings(body: ValidationSettings):
|
||||
data = _read()
|
||||
data["validation"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["validation"]
|
||||
|
||||
|
||||
@router.get("/display", response_model=DisplaySettings)
|
||||
async def get_display_settings():
|
||||
data = _read()
|
||||
return data.get("display", DEFAULTS["display"])
|
||||
|
||||
|
||||
@router.put("/display", response_model=DisplaySettings)
|
||||
async def update_display_settings(body: DisplaySettings):
|
||||
data = _read()
|
||||
data["display"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["display"]
|
||||
|
||||
|
||||
@router.get("/columns", response_model=ColumnSettings)
|
||||
async def get_column_settings():
|
||||
data = _read()
|
||||
return data.get("columns", DEFAULTS["columns"])
|
||||
|
||||
|
||||
@router.put("/columns", response_model=ColumnSettings)
|
||||
async def update_column_settings(body: ColumnSettings):
|
||||
data = _read()
|
||||
data["columns"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["columns"]
|
||||
|
||||
|
||||
class LogpassColumnSettings(BaseModel):
|
||||
browser: bool = True
|
||||
profile: bool = True
|
||||
steam_id: bool = True
|
||||
login: bool = True
|
||||
password: bool = True
|
||||
login_pass: bool = True
|
||||
status: bool = True
|
||||
ban: bool = True
|
||||
prime: bool = True
|
||||
trophy: bool = True
|
||||
behavior: bool = True
|
||||
license: bool = True
|
||||
proxy: bool = True
|
||||
notes: bool = True
|
||||
actions: bool = True
|
||||
last_online: bool = True
|
||||
|
||||
|
||||
@router.get("/logpass-columns", response_model=LogpassColumnSettings)
|
||||
async def get_logpass_column_settings():
|
||||
data = _read()
|
||||
return data.get("logpass_columns", DEFAULTS["logpass_columns"])
|
||||
|
||||
|
||||
@router.put("/logpass-columns", response_model=LogpassColumnSettings)
|
||||
async def update_logpass_column_settings(body: LogpassColumnSettings):
|
||||
data = _read()
|
||||
data["logpass_columns"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["logpass_columns"]
|
||||
|
||||
|
||||
class TokenColumnSettings(BaseModel):
|
||||
browser: bool = True
|
||||
profile: bool = True
|
||||
last_online: bool = True
|
||||
steam_id: bool = True
|
||||
login: bool = True
|
||||
token: bool = True
|
||||
status: bool = True
|
||||
proxy: bool = True
|
||||
notes: bool = True
|
||||
actions: bool = True
|
||||
|
||||
|
||||
@router.get("/token-columns", response_model=TokenColumnSettings)
|
||||
async def get_token_column_settings():
|
||||
data = _read()
|
||||
return data.get("token_columns", DEFAULTS["token_columns"])
|
||||
|
||||
|
||||
@router.put("/token-columns", response_model=TokenColumnSettings)
|
||||
async def update_token_column_settings(body: TokenColumnSettings):
|
||||
data = _read()
|
||||
data["token_columns"] = body.model_dump()
|
||||
_write(data)
|
||||
return data["token_columns"]
|
||||
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import TaskOut
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tasks ORDER BY created_at DESC LIMIT 50")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskOut)
|
||||
async def get_task(task_id: str):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/{task_id}/stream")
|
||||
async def stream_task(task_id: str):
|
||||
"""SSE endpoint for real-time task progress."""
|
||||
|
||||
async def event_generator():
|
||||
last_data = None
|
||||
while True:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
yield f"data: {json.dumps({'error': 'Task not found'})}\n\n"
|
||||
break
|
||||
|
||||
task = dict(row)
|
||||
# Parse JSON string fields from DB into dicts for proper serialization
|
||||
for json_field in ("account_ids", "account_results"):
|
||||
if isinstance(task.get(json_field), str):
|
||||
try:
|
||||
task[json_field] = json.loads(task[json_field])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
prompt_info = task_manager.get_pending_prompt(task_id)
|
||||
if prompt_info:
|
||||
task["prompt"] = prompt_info["message"]
|
||||
task["prompt_login"] = prompt_info["login"]
|
||||
step_info = task_manager.get_step_info(task_id)
|
||||
if step_info:
|
||||
task["step"] = step_info["step"]
|
||||
task["total_steps"] = step_info["total_steps"]
|
||||
task["step_label"] = step_info["label"]
|
||||
active = task_manager.get_active_count(task_id)
|
||||
if active > 0:
|
||||
task["active_count"] = active
|
||||
acc_results = task_manager.get_account_results(task_id)
|
||||
if acc_results:
|
||||
task["account_results"] = {str(k): v for k, v in acc_results.items()}
|
||||
acc_steps = task_manager.get_account_steps(task_id)
|
||||
if acc_steps:
|
||||
task["account_steps"] = {str(k): v for k, v in acc_steps.items()}
|
||||
|
||||
data_str = json.dumps(task)
|
||||
if data_str != last_data or task["status"] in ("completed", "failed", "cancelled"):
|
||||
last_data = data_str
|
||||
yield f"data: {data_str}\n\n"
|
||||
|
||||
if task["status"] in ("completed", "failed", "cancelled"):
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/{task_id}/respond")
|
||||
async def respond_to_prompt(task_id: str, body: dict):
|
||||
"""Provide user input for a pending task prompt."""
|
||||
value = body.get("value", "")
|
||||
login = body.get("login", "")
|
||||
if not task_manager.respond(task_id, value, login):
|
||||
raise HTTPException(status_code=404, detail="No pending prompt for this task")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
async def cancel_task(task_id: str):
|
||||
cancelled = task_manager.cancel(task_id)
|
||||
if not cancelled:
|
||||
raise HTTPException(status_code=404, detail="Task not found or already finished")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""CRUD and validate endpoints for token accounts."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.task_manager import task_manager
|
||||
from app.models import TokenAccountCreate, TokenAccountOut, TokenAccountUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/token-accounts", tags=["token-accounts"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenAccountOut])
|
||||
async def list_tokens():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM token_accounts ORDER BY id DESC")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=TokenAccountOut)
|
||||
async def get_token(account_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.post("", response_model=TokenAccountOut, status_code=201)
|
||||
async def create_token(account: TokenAccountCreate):
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO token_accounts (login, token, steam_id, proxy, notes) VALUES (?, ?, ?, ?, ?)",
|
||||
(account.login, account.token, account.steam_id, account.proxy, account.notes),
|
||||
)
|
||||
await db.commit()
|
||||
new_cursor = await db.execute(
|
||||
"SELECT * FROM token_accounts WHERE id = ?", (cursor.lastrowid,)
|
||||
)
|
||||
return dict(await new_cursor.fetchone())
|
||||
|
||||
|
||||
@router.put("/{account_id}", response_model=TokenAccountOut)
|
||||
async def update_token(account_id: int, account: TokenAccountUpdate):
|
||||
db = await get_db()
|
||||
fields = {k: v for k, v in account.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
fields["updated_at"] = "datetime('now')"
|
||||
set_clause = ", ".join(
|
||||
f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?"
|
||||
for k in fields
|
||||
)
|
||||
values = [v for k, v in fields.items() if k != "updated_at"]
|
||||
values.append(account_id)
|
||||
await db.execute(f"UPDATE token_accounts SET {set_clause} WHERE id = ?", values)
|
||||
await db.commit()
|
||||
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=204)
|
||||
async def delete_token(account_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM token_accounts WHERE id = ?", (account_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/delete-bulk")
|
||||
async def delete_token_bulk(data: dict):
|
||||
ids: list[int] = data.get("ids", [])
|
||||
if not ids:
|
||||
return {"deleted": 0}
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
await db.execute(f"DELETE FROM token_accounts WHERE id IN ({placeholders})", ids)
|
||||
await db.commit()
|
||||
return {"deleted": len(ids)}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_tokens(data: dict):
|
||||
"""Bulk import: one token per line, optionally 'login:token'."""
|
||||
lines: list[str] = data.get("lines", [])
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
db = await get_db()
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
skipped += 1
|
||||
continue
|
||||
login = None
|
||||
token = line
|
||||
if ":" in line:
|
||||
parts = line.split(":", 1)
|
||||
login, token = parts[0].strip(), parts[1].strip()
|
||||
if not token:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO token_accounts (login, token) VALUES (?, ?)",
|
||||
(login, token),
|
||||
)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
errors.append(f"{token[:20]}: {exc}")
|
||||
await db.commit()
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_tokens(data: dict):
|
||||
"""Submit validation task for selected token accounts."""
|
||||
ids: list[int] = data.get("account_ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="No account IDs provided")
|
||||
|
||||
db = await get_db()
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM token_accounts WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
accounts = [dict(r) for r in await cursor.fetchall()]
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=404, detail="No accounts found")
|
||||
|
||||
task_id = await task_manager.submit(
|
||||
task_type="token_validate",
|
||||
accounts=accounts,
|
||||
params={},
|
||||
)
|
||||
logger.info(f"token_validate submitted for {len(accounts)} accounts → task {task_id}")
|
||||
return {"task_id": task_id, "accounts_count": len(accounts)}
|
||||
|
||||
|
||||
@router.post("/{account_id}/browser")
|
||||
async def open_token_browser(account_id: int):
|
||||
"""Open Chrome browser with saved session cookies for a token account."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
account = dict(row)
|
||||
if not account.get("session_cookies"):
|
||||
raise HTTPException(status_code=400, detail="No session cookies. Validate the account first.")
|
||||
|
||||
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
|
||||
from app.config import read_validation_settings
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
alive = await check_cookies_alive(account["session_cookies"], proxy)
|
||||
if not alive:
|
||||
val_settings = read_validation_settings()
|
||||
if val_settings.get("auto_revalidate_browser"):
|
||||
task_id = await task_manager.submit(
|
||||
task_type="token_validate",
|
||||
accounts=[account],
|
||||
params={},
|
||||
)
|
||||
logger.info(f"Cookies dead for token account {account_id}, auto-revalidating → task {task_id}")
|
||||
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
|
||||
raise HTTPException(status_code=400, detail="Session cookies expired. Re-validate the account.")
|
||||
|
||||
from app.services.browser_login import open_browser_with_cookies
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await open_browser_with_cookies(account)
|
||||
except Exception as exc:
|
||||
logger.error(f"Browser open failed for {account.get('login', account_id)}: {exc}")
|
||||
|
||||
asyncio.create_task(_run())
|
||||
return {"status": "ok", "message": "Browser opening..."}
|
||||
|
||||
|
||||
@router.post("/assign-proxies")
|
||||
async def token_assign_proxies():
|
||||
"""Round-robin assign proxies to token accounts that don't have one."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
|
||||
if not proxies:
|
||||
raise HTTPException(status_code=400, detail="No proxies available")
|
||||
|
||||
acc_cursor = await db.execute(
|
||||
"SELECT id FROM token_accounts WHERE proxy IS NULL OR proxy = '' ORDER BY id"
|
||||
)
|
||||
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
|
||||
if not account_ids:
|
||||
return {"assigned": 0, "proxies_used": 0}
|
||||
|
||||
for i, acc_id in enumerate(account_ids):
|
||||
await db.execute(
|
||||
"UPDATE token_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/reassign-proxies")
|
||||
async def token_reassign_proxies():
|
||||
"""Round-robin reassign proxies to ALL token accounts."""
|
||||
db = await get_db()
|
||||
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
|
||||
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
|
||||
if not proxies:
|
||||
raise HTTPException(status_code=400, detail="No proxies available")
|
||||
|
||||
acc_cursor = await db.execute("SELECT id FROM token_accounts ORDER BY id")
|
||||
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
|
||||
if not account_ids:
|
||||
return {"assigned": 0, "proxies_used": 0}
|
||||
|
||||
for i, acc_id in enumerate(account_ids):
|
||||
await db.execute(
|
||||
"UPDATE token_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(proxies[i % len(proxies)], acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
|
||||
|
||||
|
||||
@router.post("/clear-proxies")
|
||||
async def token_clear_proxies():
|
||||
"""Remove proxy from all token accounts."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"UPDATE token_accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''"
|
||||
)
|
||||
await db.commit()
|
||||
return {"cleared": cursor.rowcount}
|
||||
Reference in New Issue
Block a user