This commit is contained in:
Manchik
2026-04-25 04:17:13 +03:00
parent c82866b58a
commit 9b7001407f
23 changed files with 1225 additions and 258 deletions
+36 -13
View File
@@ -215,7 +215,9 @@ async def import_accounts(file: UploadFile = File(...)):
Supported formats (: or | separator):
login|password|{mafile_json}
login|password|email|email_password|{mafile_json}
login|password|email|email_password|{mafile_json}|notes
login|password|email|email_password
login|password|email|email_password|notes (no mafile, with notes)
"""
content = (await file.read()).decode("utf-8", errors="ignore")
result = BulkImportResult()
@@ -226,11 +228,27 @@ async def import_accounts(file: UploadFile = File(...)):
if not line:
continue
# Split off embedded mafile JSON (everything from first '{' onward)
notes: str | None = None
# Split off embedded mafile JSON — find matching braces
json_start = line.find("{")
if json_start != -1:
mafile_str = line[json_start:]
depth = 0
json_end = json_start
for i in range(json_start, len(line)):
if line[i] == "{":
depth += 1
elif line[i] == "}":
depth -= 1
if depth == 0:
json_end = i
break
mafile_str = line[json_start : json_end + 1]
prefix = line[:json_start].rstrip("|:")
suffix = line[json_end + 1 :].lstrip("|:")
if suffix:
notes = suffix
else:
mafile_str = None
prefix = line
@@ -262,12 +280,15 @@ async def import_accounts(file: UploadFile = File(...)):
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]}")
# No mafile — 4 fields, or 5 fields with notes
if len(parts) == 5:
login, password, email, email_password, notes = parts
elif len(parts) == 4:
login, password, email, email_password = parts
else:
result.errors.append(f"Invalid line (need 4-5 fields): {line[:50]}")
result.skipped += 1
continue
login, password, email, email_password = parts
mafile_data = None
try:
@@ -281,9 +302,9 @@ async def import_accounts(file: UploadFile = File(...)):
continue
await db.execute(
"""INSERT INTO accounts (login, password, email, email_password)
VALUES (?, ?, ?, ?)""",
(login, password, email or None, email_password or None),
"""INSERT INTO accounts (login, password, email, email_password, notes)
VALUES (?, ?, ?, ?, ?)""",
(login, password, email or None, email_password or None, notes or None),
)
result.imported += 1
except Exception as exc:
@@ -298,7 +319,8 @@ async def import_accounts(file: UploadFile = File(...)):
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"
safe_name = Path(account_name).name
dest = settings.mafiles_dir / f"{safe_name}.mafile"
dest.write_text(json.dumps(mafile_data, ensure_ascii=False), encoding="utf-8")
await db.execute(
@@ -325,12 +347,13 @@ async def upload_mafile(account_id: int, file: UploadFile = File(...)):
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")
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid mafile format: {exc}")
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
steam_id = mafile.Session.SteamID or row["steam_id"] or account_id
mafile_path = settings.mafiles_dir / f"{steam_id}.mafile"
safe_name = Path(str(steam_id)).name
mafile_path = settings.mafiles_dir / f"{safe_name}.mafile"
mafile_path.write_text(content, encoding="utf-8")
await db.execute(
+51
View File
@@ -84,3 +84,54 @@ async def generate_2fa_by_account(request: Generate2FAByAccountRequest):
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"])}
class ConfirmationRespondRequest(BaseModel):
ids: list[str]
nonces: list[str]
accept: bool
@router.get("/confirmations/{account_id}")
async def get_confirmations(account_id: int):
"""Fetch pending Steam confirmations for an account."""
from app.services.steam_confirmations import fetch_confirmations
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
account = await cursor.fetchone()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
try:
confirmations = await fetch_confirmations(dict(account))
return {"success": True, "confirmations": confirmations}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"[confirmations] Error fetching for account {account_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/confirmations/{account_id}/respond")
async def respond_confirmations(account_id: int, request: ConfirmationRespondRequest):
"""Accept or deny confirmations for an account."""
from app.services.steam_confirmations import respond_to_confirmation
if len(request.ids) != len(request.nonces):
raise HTTPException(status_code=400, detail="ids and nonces must have same length")
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
account = await cursor.fetchone()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
try:
success = await respond_to_confirmation(dict(account), request.ids, request.nonces, request.accept)
return {"success": success}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"[confirmations] Error responding for account {account_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
+6 -2
View File
@@ -53,19 +53,23 @@ async def upload_mafiles(files: list[UploadFile] = File(...)):
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"
safe_name = Path(str(steam_id)).name
dest = settings.mafiles_dir / f"{safe_name}.mafile"
dest.write_text(content, encoding="utf-8")
uploaded += 1
if mafile.account_name:
mafile_steam_id = str(mafile.Session.SteamID) if mafile.Session.SteamID else None
await db.execute(
"""UPDATE accounts
SET mafile_path = ?, shared_secret = ?, identity_secret = ?
SET mafile_path = ?, shared_secret = ?, identity_secret = ?,
steam_id = COALESCE(steam_id, ?)
WHERE login = ?""",
(
str(dest),
mafile.shared_secret or "",
mafile.identity_secret or "",
mafile_steam_id,
mafile.account_name,
),
)
+9 -5
View File
@@ -2,7 +2,7 @@ import json
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
class AccountCreate(BaseModel):
@@ -90,14 +90,18 @@ class ProxyOut(BaseModel):
class MafileSession(BaseModel):
SessionID: str = ""
model_config = ConfigDict(extra="ignore")
SessionID: str | None = ""
AccessToken: str = ""
RefreshToken: str = ""
SteamID: int = 0
SteamLoginSecure: str = ""
SteamID: int | None = 0
SteamLoginSecure: str | None = ""
class MafileData(BaseModel):
model_config = ConfigDict(extra="ignore")
shared_secret: str
serial_number: str = ""
revocation_code: str = ""
@@ -107,7 +111,7 @@ class MafileData(BaseModel):
identity_secret: str = ""
secret_1: str = ""
device_id: str = ""
server_time: str = ""
server_time: str | int = ""
fully_enrolled: bool = False
Session: MafileSession = Field(default_factory=MafileSession)
+59 -2
View File
@@ -34,7 +34,8 @@ def read_mafile_data(account: dict) -> dict | None:
return None
try:
return json.loads(Path(mafile_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
except (OSError, json.JSONDecodeError, ValueError) as exc:
logger.warning(f"Failed to read mafile for {account.get('login', '?')}: {exc}")
return None
@@ -80,7 +81,8 @@ async def create_steam_session(account: dict):
shared_secret = mafile.get("shared_secret", shared_secret)
identity_secret = mafile.get("identity_secret", identity_secret)
device_id = mafile.get("device_id")
steamid = int(mafile.get("Session", {}).get("SteamID", 0)) or None
steamid_raw = (mafile.get("Session") or {}).get("SteamID") or 0
steamid = int(steamid_raw) or None
steam = Steam(
login=account["login"],
@@ -155,6 +157,61 @@ def extract_session_cookies(steam) -> str | None:
except Exception:
return None
def update_mafile_tokens(steam, account: dict) -> bool:
"""Extract fresh tokens from a logged-in steam session and update the .mafile on disk."""
mafile_path = account.get("mafile_path")
if not mafile_path:
return False
try:
mafile = json.loads(Path(mafile_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
_req = getattr(steam, "_requests", None)
_sess = getattr(_req, "_session", None) if _req else None
if not _sess or not hasattr(_sess, "cookie_jar"):
return False
access_token = None
session_id = None
steam_id = str(getattr(steam, "_steamid", "") or "")
for c in _sess.cookie_jar:
if c.key == "steamLoginSecure" and c.value:
raw = __import__("urllib.parse", fromlist=["unquote"]).unquote(c.value)
if "||" in raw:
parts = raw.split("||", 1)
if not steam_id:
steam_id = parts[0]
access_token = parts[1]
elif c.key == "sessionid" and c.value:
session_id = c.value
if not access_token:
return False
session = mafile.setdefault("Session", {})
session["AccessToken"] = access_token
if session_id:
session["SessionID"] = session_id
if steam_id:
session["SteamID"] = steam_id
session["SteamLoginSecure"] = f"{steam_id}%7C%7C{access_token}"
refresh_token = getattr(steam, "_refresh_token", None)
if refresh_token:
session["RefreshToken"] = refresh_token
try:
Path(mafile_path).write_text(json.dumps(mafile, indent=2, ensure_ascii=False), encoding="utf-8")
logger.debug(f"[auth] Updated mafile tokens: {mafile_path}")
return True
except OSError as e:
logger.warning(f"[auth] Failed to update mafile: {e}")
return False
async def check_cookies_alive(cookies_json: str, proxy: dict | None = None) -> bool:
"""Check if Steam session cookies are still valid.
+390
View File
@@ -0,0 +1,390 @@
"""Steam mobile confirmations — fetch, accept, deny trade/market confirmations."""
import base64
import hashlib
import hmac
import json
import struct
import time
from pathlib import Path
import aiohttp
from yarl import URL
from loguru import logger
COMMUNITY_BASE = "https://steamcommunity.com"
MOBILE_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 9; Valve Steam App Version/3)"
CONF_TYPES = {
0: "Invalid",
1: "Test",
2: "Trade",
3: "MarketListing",
4: "FeatureOptOut",
5: "PhoneNumberChange",
6: "AccountRecovery",
}
def _decode_jwt_payload(token: str) -> dict:
parts = token.split(".")
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64))
def _is_token_expired(token: str, margin: int = 60) -> bool:
"""Check if a JWT access token is expired (with safety margin in seconds)."""
try:
payload = _decode_jwt_payload(token)
return payload.get("exp", 0) < int(time.time()) + margin
except Exception:
return True
async def _resolve_proxy(account: dict):
"""Resolve proxy for an account (same logic as steam_auth)."""
from app.core.proxy_manager import proxy_manager
proxy_address = account.get("proxy")
if proxy_address:
from app.database import get_db
db = await get_db()
cursor = await db.execute(
"SELECT protocol FROM proxies WHERE address = ?", (proxy_address,)
)
row = await cursor.fetchone()
protocol = row["protocol"] if row else "http"
return {"address": proxy_address, "protocol": protocol}
if proxy_manager.count > 0:
return proxy_manager.get_next()
return None
async def _refresh_access_token(
refresh_token: str, steam_id: str, connector: aiohttp.BaseConnector | None = None,
) -> str | None:
"""Use RefreshToken to get a fresh AccessToken via Steam protobuf API."""
try:
from pysteamauth.pb2.steammessages_auth.steamclient_pb2 import (
CAuthentication_AccessToken_GenerateForApp_Request,
CAuthentication_AccessToken_GenerateForApp_Response,
)
except ImportError:
logger.warning("[confirmations] protobuf classes not available for token refresh")
return None
msg = CAuthentication_AccessToken_GenerateForApp_Request()
msg.refresh_token = refresh_token
msg.steamid = int(steam_id)
encoded = base64.b64encode(msg.SerializeToString()).decode()
kw: dict = {"headers": {"User-Agent": MOBILE_USER_AGENT}, "timeout": aiohttp.ClientTimeout(total=15)}
if connector:
kw["connector"] = connector
try:
async with aiohttp.ClientSession(**kw) as http:
async with http.post(
"https://api.steampowered.com/IAuthenticationService/GenerateAccessTokenForApp/v1/",
data={"input_protobuf_encoded": encoded},
) as resp:
eresult = resp.headers.get("x-eresult", "?")
raw = await resp.read()
if raw:
resp_msg = CAuthentication_AccessToken_GenerateForApp_Response()
resp_msg.ParseFromString(raw)
if resp_msg.access_token:
logger.info(f"[confirmations] Refreshed AccessToken for SteamID {steam_id}")
return resp_msg.access_token
logger.warning(f"[confirmations] Token refresh failed for SteamID {steam_id}: eresult={eresult}")
except Exception as e:
logger.error(f"[confirmations] Token refresh error for SteamID {steam_id}: {e}")
return None
def _has_mobile_audience(token: str) -> bool:
"""Check if JWT token has 'mobile' in its audience claim."""
try:
payload = _decode_jwt_payload(token)
aud = payload.get("aud", [])
return "mobile" in aud
except Exception:
return False
async def _ensure_valid_access_token(account: dict, mafile: dict) -> str:
"""Return a valid AccessToken, refreshing if expired. Updates mafile on disk."""
session = mafile.get("Session", {})
access_token = session.get("AccessToken", "")
refresh_token = session.get("RefreshToken", "")
steam_id = str(session.get("SteamID", ""))
needs_refresh = (
not access_token
or _is_token_expired(access_token)
or not _has_mobile_audience(access_token)
)
if not needs_refresh:
return access_token
if not refresh_token:
raise ValueError("AccessToken expired and no RefreshToken available")
if _is_token_expired(refresh_token, margin=0):
raise ValueError("Both AccessToken and RefreshToken are expired — re-authentication required")
from app.core.proxy_manager import proxy_manager
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else None
new_token = await _refresh_access_token(refresh_token, steam_id, connector)
if not new_token:
raise ValueError(
"Failed to refresh AccessToken — session may be revoked or proxy IP mismatch"
)
# Update mafile on disk
mafile["Session"]["AccessToken"] = new_token
mafile["Session"]["SteamLoginSecure"] = f"{steam_id}%7C%7C{new_token}"
mafile_path = account.get("mafile_path")
if mafile_path:
try:
Path(mafile_path).write_text(json.dumps(mafile, indent=2), encoding="utf-8")
logger.debug(f"[confirmations] Updated mafile on disk: {mafile_path}")
except OSError as e:
logger.warning(f"[confirmations] Could not update mafile: {e}")
return new_token
async def _get_steam_server_time() -> int:
"""Query Steam server time for HMAC generation."""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.steampowered.com/ITwoFactorService/QueryTime/v0001",
data={"steamid": "0"},
headers={"User-Agent": MOBILE_USER_AGENT},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
data = await resp.json()
return int(data.get("response", {}).get("server_time", time.time()))
except Exception:
pass
return int(time.time())
def _generate_confirmation_hash(identity_secret: str, steam_time: int, tag: str) -> str:
"""HMAC-SHA1(identity_secret, time_bytes + tag_bytes) → Base64."""
key = base64.b64decode(identity_secret)
msg = struct.pack(">Q", steam_time)
tag_bytes = tag.encode("ascii")
msg += tag_bytes[:32]
mac = hmac.new(key, msg, hashlib.sha1).digest()
return base64.b64encode(mac).decode()
def _build_query_params(
device_id: str, steam_id: str, identity_secret: str, steam_time: int, tag: str
) -> dict:
"""Build confirmation query parameters."""
return {
"p": device_id,
"a": steam_id,
"k": _generate_confirmation_hash(identity_secret, steam_time, tag),
"t": str(steam_time),
"m": "react",
"tag": tag,
}
def _build_cookies(steam_id: str, access_token: str, session_id: str) -> dict:
"""Build mobile cookies for confirmation requests."""
steam_login_secure = f"{steam_id}%7C%7C{access_token}"
return {
"steamLoginSecure": steam_login_secure,
"sessionid": session_id,
"mobileClient": "android",
"mobileClientVersion": "777777 3.7.2",
"Steam_Language": "english",
}
async def fetch_confirmations(account: dict) -> list[dict]:
"""Fetch pending confirmations for an account.
Returns list of confirmation dicts with id, nonce, type, headline, summary etc.
"""
from app.services.steam_auth import read_mafile_data
from app.core.proxy_manager import proxy_manager
mafile = read_mafile_data(account)
if not mafile:
raise ValueError("No mafile data for this account")
identity_secret = mafile.get("identity_secret", "")
device_id = mafile.get("device_id", "")
session = mafile.get("Session", {})
steam_id = str(session.get("SteamID", ""))
session_id = session.get("SessionID", "")
if not identity_secret or not steam_id:
raise ValueError("Missing identity_secret or SteamID in mafile")
access_token = await _ensure_valid_access_token(account, mafile)
if not device_id:
device_id = f"android:{steam_id}"
if not session_id:
import secrets
session_id = secrets.token_hex(12)
steam_time = await _get_steam_server_time()
params = _build_query_params(device_id, steam_id, identity_secret, steam_time, "conf")
cookies = _build_cookies(steam_id, access_token, session_id)
url = f"{COMMUNITY_BASE}/mobileconf/getlist"
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
async with aiohttp.ClientSession(
headers={"User-Agent": MOBILE_USER_AGENT},
cookie_jar=aiohttp.CookieJar(unsafe=True),
connector=connector,
) as http:
for domain in ["steamcommunity.com", "store.steampowered.com"]:
for k, v in cookies.items():
http.cookie_jar.update_cookies({k: v}, URL(f"https://{domain}"))
async with http.get(url, params=params) as resp:
if resp.status != 200:
raise ValueError(f"HTTP {resp.status} from Steam confirmations API")
data = await resp.json()
if not data.get("success"):
if data.get("needsauth"):
raise ValueError("Session expired — needs re-authentication")
detail = data.get("detail", "")
message = data.get("message", "")
login = account.get("login", "?")
logger.warning(f"[confirmations] {login}: {message or 'unknown error'}")
if "authenticator" in message.lower() or "authenticator" in detail.lower():
raise ValueError(
"Invalid authenticator — mafile secrets may be outdated. "
"Re-link Steam Guard or re-import a fresh mafile."
)
if detail:
raise ValueError(f"Steam: {detail}")
if message:
raise ValueError(f"Steam: {message}")
raise ValueError("Steam rejected the request — likely expired session or invalid mafile tokens")
confirmations = []
for conf in data.get("conf", []):
confirmations.append({
"id": str(conf.get("id", "")),
"nonce": str(conf.get("nonce", "")),
"type": conf.get("type", 0),
"type_name": CONF_TYPES.get(conf.get("type", 0), "Unknown"),
"creator_id": str(conf.get("creator_id", "")),
"headline": conf.get("headline", ""),
"summary": [conf.get("summary", "")] if isinstance(conf.get("summary"), str) else conf.get("summary", []),
"accept": conf.get("accept", "Accept"),
"cancel": conf.get("cancel", "Cancel"),
"icon": conf.get("icon", ""),
})
logger.info(f"[confirmations] {account.get('login', '?')}: fetched {len(confirmations)} confirmations")
return confirmations
async def respond_to_confirmation(
account: dict, confirmation_ids: list[str], confirmation_nonces: list[str], accept: bool
) -> bool:
"""Accept or deny one or more confirmations.
Uses multiajaxop for batch operations, ajaxop for single.
"""
from app.services.steam_auth import read_mafile_data
from app.core.proxy_manager import proxy_manager
mafile = read_mafile_data(account)
if not mafile:
raise ValueError("No mafile data")
identity_secret = mafile.get("identity_secret", "")
device_id = mafile.get("device_id", "")
session = mafile.get("Session", {})
steam_id = str(session.get("SteamID", ""))
session_id = session.get("SessionID", "")
if not identity_secret or not steam_id:
raise ValueError("Missing secrets in mafile")
access_token = await _ensure_valid_access_token(account, mafile)
if not device_id:
device_id = f"android:{steam_id}"
if not session_id:
import secrets
session_id = secrets.token_hex(12)
op = "allow" if accept else "cancel"
tag = "accept" if accept else "reject"
steam_time = await _get_steam_server_time()
params = _build_query_params(device_id, steam_id, identity_secret, steam_time, tag)
cookies = _build_cookies(steam_id, access_token, session_id)
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
async with aiohttp.ClientSession(
headers={"User-Agent": MOBILE_USER_AGENT},
cookie_jar=aiohttp.CookieJar(unsafe=True),
connector=connector,
) as http:
for domain in ["steamcommunity.com", "store.steampowered.com"]:
for k, v in cookies.items():
http.cookie_jar.update_cookies({k: v}, URL(f"https://{domain}"))
if len(confirmation_ids) == 1:
url = f"{COMMUNITY_BASE}/mobileconf/ajaxop"
query = {
"op": op,
**params,
"cid": confirmation_ids[0],
"ck": confirmation_nonces[0],
}
async with http.get(url, params=query) as resp:
data = await resp.json()
else:
url = f"{COMMUNITY_BASE}/mobileconf/multiajaxop"
form_data = {"op": op}
form_data.update(params)
for cid in confirmation_ids:
form_data[f"cid[]"] = cid
for ck in confirmation_nonces:
form_data[f"ck[]"] = ck
payload = aiohttp.FormData()
payload.add_field("op", op)
for k, v in params.items():
payload.add_field(k, str(v))
for cid in confirmation_ids:
payload.add_field("cid[]", cid)
for ck in confirmation_nonces:
payload.add_field("ck[]", ck)
async with http.post(url, data=payload) as resp:
data = await resp.json()
success = data.get("success", False)
action_word = "accepted" if accept else "denied"
logger.info(f"[confirmations] {account.get('login', '?')}: {action_word} {len(confirmation_ids)} confirmations — success={success}")
return success
+4
View File
@@ -165,6 +165,10 @@ async def validate_account(account: dict, params: dict, task_id: str = "") -> No
except Exception as cookie_err:
logger.warning(f"[validate] {login}: failed to save cookies — {cookie_err}")
from app.services.steam_auth import update_mafile_tokens
if update_mafile_tokens(steam, account):
logger.info(f"[validate] {login}: updated mafile tokens")
except Exception as exc:
status = "invalid"
error_msg = str(exc)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SteamPanel</title>
<script type="module" crossorigin src="/assets/index-Bxw--tTv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cm89176E.css">
<script type="module" crossorigin src="/assets/index-D5Q9Ekc6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aSRiX3qP.css">
</head>
<body>
<div id="root"></div>