from __future__ import annotations import json import secrets import shutil import string import uuid from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any from .paths import app_data_dir _EXT_ID = "signup-autofill@proxygod" _EXT_SRC = Path(__file__).resolve().parent / "signup_extension" @dataclass(frozen=True) class SignupSitePreset: key: str label: str url: str hosts: tuple[str, ...] extra_selectors: dict[str, list[str]] = field(default_factory=dict) SIGNUP_PRESETS: dict[str, SignupSitePreset] = { "google": SignupSitePreset( key="google", label="Google", url="https://accounts.google.com/signup/v2/createaccount?flowName=GlifWebSignIn&flowEntry=SignUp", hosts=("accounts.google.com", "google.com"), extra_selectors={ "first_name": ['input[name="firstName"]'], "last_name": ['input[name="lastName"]'], "email": ['input[name="Username"]', 'input[type="email"]'], "password": ['input[name="Passwd"]'], "password_confirm": ['input[name="PasswdAgain"]'], }, ), "protonmail": SignupSitePreset( key="protonmail", label="Proton Mail", url="https://account.proton.me/signup", hosts=("account.proton.me", "proton.me"), extra_selectors={ "email": ['input[id="email"]', 'input[name="email"]', 'input[type="email"]'], "password": ['input[id="password"]', 'input[name="password"]'], "password_confirm": ['input[id="password-confirm"]', 'input[name="passwordConfirmation"]'], }, ), "hydraproxy": SignupSitePreset( key="hydraproxy", label="HydraProxy", url="https://dashboard.hydraproxy.com/register", hosts=("dashboard.hydraproxy.com", "hydraproxy.com"), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]', 'input[id="email"]'], "password": ['input[name="password"]', 'input[type="password"]'], "password_confirm": ['input[name="password_confirmation"]', 'input[name="confirmPassword"]'], "username": ['input[name="username"]', 'input[name="name"]'], }, ), "outlook": SignupSitePreset( key="outlook", label="Outlook / Microsoft", url="https://signup.live.com/signup", hosts=("signup.live.com", "login.live.com", "account.microsoft.com"), extra_selectors={ "email": ['input[name="MemberName"]', 'input[type="email"]'], "password": ['input[name="Password"]', 'input[name="PasswordInput"]'], "first_name": ['input[name="FirstName"]'], "last_name": ['input[name="LastName"]'], }, ), "github": SignupSitePreset( key="github", label="GitHub", url="https://github.com/signup", hosts=("github.com",), extra_selectors={ "email": ['input[name="user[email]"]', 'input[autocomplete="email"]'], "password": ['input[name="user[password]"]', 'input[autocomplete="new-password"]'], "username": ['input[name="user[login]"]'], }, ), "reddit": SignupSitePreset( key="reddit", label="Reddit", url="https://www.reddit.com/register", hosts=("www.reddit.com", "reddit.com"), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]'], "username": ['input[name="username"]'], "password": ['input[name="password"]'], }, ), "discord": SignupSitePreset( key="discord", label="Discord", url="https://discord.com/register", hosts=("discord.com",), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]'], "username": ['input[name="username"]'], "password": ['input[name="password"]'], }, ), "x_twitter": SignupSitePreset( key="x_twitter", label="X / Twitter", url="https://x.com/i/flow/signup", hosts=("x.com", "twitter.com"), extra_selectors={ "email": ['input[name="email"]', 'input[type="email"]'], "username": ['input[name="username"]'], "password": ['input[name="password"]'], "first_name": ['input[name="name"]'], }, ), "telegram": SignupSitePreset( key="telegram", label="Telegram Web", url="https://web.telegram.org/", hosts=("web.telegram.org", "telegram.org"), ), "tutanota": SignupSitePreset( key="tutanota", label="Tuta Mail", url="https://app.tuta.com/signup", hosts=("app.tuta.com", "tutanota.com", "tuta.com"), extra_selectors={ "email": ['input[id="mailAddress"]', 'input[name="mailAddress"]'], "password": ['input[id="password1"]', 'input[type="password"]'], "password_confirm": ['input[id="password2"]'], }, ), "mailcom": SignupSitePreset( key="mailcom", label="mail.com", url="https://signup.mail.com/", hosts=("signup.mail.com", "mail.com"), extra_selectors={ "username": ['input[name="email_user"]', 'input[name="username"]'], "password": ['input[name="password"]'], "first_name": ['input[name="firstName"]'], "last_name": ['input[name="lastName"]'], }, ), } # Public site-key list in display order. Useful for building dropdowns. SIGNUP_PRESET_KEYS: tuple[str, ...] = ( "google", "protonmail", "outlook", "tutanota", "mailcom", "github", "reddit", "discord", "x_twitter", "telegram", "hydraproxy", ) _FIRST_NAMES: tuple[str, ...] = ( "Alex", "Jordan", "Casey", "Morgan", "Taylor", "Riley", "Cameron", "Sam", "Jamie", "Quinn", "Avery", "Drew", "Reese", "Skyler", "Logan", "Hayden", "Parker", "Rowan", "Sage", "Emerson", "Finley", "Harper", "Kai", "Micah", "Nico", "Phoenix", "River", "Shawn", "Sutton", "Wren", ) _LAST_NAMES: tuple[str, ...] = ( "Reed", "Hayes", "Brooks", "Bennett", "Carter", "Foster", "Gray", "Hughes", "Jensen", "Knight", "Lambert", "Mason", "Nash", "Owens", "Porter", "Quincy", "Reyes", "Sutton", "Tate", "Underwood", "Vance", "Walsh", "Yates", "Zimmerman", "Abbott", "Barker", "Cohen", "Dixon", "Ellis", "Fitch", ) def random_first_name() -> str: return secrets.choice(_FIRST_NAMES) def random_last_name() -> str: return secrets.choice(_LAST_NAMES) def random_full_name() -> tuple[str, str]: return random_first_name(), random_last_name() def random_username(first: str = "", last: str = "") -> str: """Generate a plausible-looking username. If a name is provided we mix it with digits; otherwise we use two short name fragments. Always lowercase, always 8-18 chars. """ f = (first or random_first_name()).lower() l = (last or random_last_name()).lower() seps = ("", ".", "_", "") sep = secrets.choice(seps) digits = "".join(secrets.choice(string.digits) for _ in range(secrets.choice([2, 3, 4]))) style = secrets.randbelow(4) if style == 0: base = f + sep + l + digits elif style == 1: base = f + digits elif style == 2: base = l + sep + f[0] + digits else: base = f[0] + sep + l + digits base = base.strip(".-_") return base[:18] @dataclass class SignupDraft: site_key: str = "google" custom_url: str = "" email: str = "" password: str = "" username: str = "" first_name: str = "" last_name: str = "" notes: str = "" @dataclass class AccountRecord: id: str site: str url: str email: str password: str username: str = "" first_name: str = "" last_name: str = "" exit_ip: str = "" exit_hop: str = "" notes: str = "" status: str = "created" # pending | created created_at: str = "" def accounts_path() -> Path: return app_data_dir() / "signup_accounts.json" def draft_path() -> Path: return app_data_dir() / "signup_draft.json" def generate_password(length: int = 18) -> str: n = max(12, min(64, int(length))) alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+" while True: pw = "".join(secrets.choice(alphabet) for _ in range(n)) if ( any(c.islower() for c in pw) and any(c.isupper() for c in pw) and any(c.isdigit() for c in pw) ): return pw def preset_for(key: str) -> SignupSitePreset | None: return SIGNUP_PRESETS.get(key) def resolve_signup_url(draft: SignupDraft) -> str: if draft.site_key == "custom": return (draft.custom_url or "").strip() p = preset_for(draft.site_key) return p.url if p else "" def load_draft() -> SignupDraft: p = draft_path() if not p.is_file(): return SignupDraft() try: raw = json.loads(p.read_text(encoding="utf-8")) return SignupDraft(**{k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__}) except (OSError, json.JSONDecodeError, TypeError): return SignupDraft() def save_draft(draft: SignupDraft) -> None: draft_path().write_text(json.dumps(asdict(draft), indent=2), encoding="utf-8") def load_accounts() -> list[AccountRecord]: p = accounts_path() if not p.is_file(): return [] try: raw = json.loads(p.read_text(encoding="utf-8")) if not isinstance(raw, list): return [] out: list[AccountRecord] = [] for item in raw: if not isinstance(item, dict): continue fields = AccountRecord.__dataclass_fields__ kwargs = {k: item.get(k, "") for k in fields} if not kwargs.get("id"): kwargs["id"] = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f") out.append(AccountRecord(**kwargs)) return out except (OSError, json.JSONDecodeError, TypeError): return [] def save_accounts(rows: list[AccountRecord]) -> None: accounts_path().write_text( json.dumps([asdict(r) for r in rows], indent=2), encoding="utf-8", ) def append_account(record: AccountRecord) -> None: rows = load_accounts() rows.insert(0, record) save_accounts(rows) def build_autofill_config(draft: SignupDraft) -> dict[str, Any]: preset = preset_for(draft.site_key) hosts = list(preset.hosts) if preset else [] selectors: dict[str, list[str]] = {} if preset: selectors.update(preset.extra_selectors) fields: dict[str, str] = {} if draft.first_name: fields["first_name"] = draft.first_name if draft.last_name: fields["last_name"] = draft.last_name if draft.username: fields["username"] = draft.username if draft.email: fields["email"] = draft.email if draft.password: fields["password"] = draft.password fields["password_confirm"] = draft.password out_fields: dict[str, Any] = {} for key, val in fields.items(): out_fields[key] = val sel_key = f"{key}_selectors" if key in selectors: out_fields[sel_key] = selectors[key] return { "site": draft.site_key, "hosts": hosts, "fields": out_fields, "updated_at": datetime.now(timezone.utc).isoformat(), } def _extensions_json_entry(ext_dir_name: str) -> dict[str, Any]: now = int(datetime.now(timezone.utc).timestamp()) * 1000 return { "id": _EXT_ID, "syncGUID": str(uuid.uuid4()), "version": "1.0.0", "type": "extension", "location": { "appProfile": { "root": "app-profile", "path": f"extensions/{ext_dir_name}", } }, "internalName": None, "updateURL": None, "webExtensionConverted": False, "webExtensionId": _EXT_ID, "active": True, "userDisabled": False, "appDisabled": False, "installDate": now, "scope": 1, } def install_signup_extension(profile_dir: Path, draft: SignupDraft) -> None: """Copy autofill WebExtension into the Firefox profile and write current field values.""" profile_dir.mkdir(parents=True, exist_ok=True) ext_name = _EXT_ID dest = profile_dir / "extensions" / ext_name if dest.exists(): shutil.rmtree(dest, ignore_errors=True) shutil.copytree(_EXT_SRC, dest) cfg = build_autofill_config(draft) (dest / "autofill_config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8") ext_json_path = profile_dir / "extensions.json" entry = _extensions_json_entry(ext_name) data: dict[str, Any] if ext_json_path.is_file(): try: data = json.loads(ext_json_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): data = {"schemaVersion": 35, "addons": []} else: data = {"schemaVersion": 35, "addons": []} addons = data.get("addons") if not isinstance(addons, list): addons = [] addons = [a for a in addons if isinstance(a, dict) and a.get("id") != _EXT_ID] addons.append(entry) data["addons"] = addons ext_json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")