262 lines
12 KiB
Python
262 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
plane.so FULL COMBO - autoreg + workspace + API token gateway, end-to-end automated
|
|
====================================================================================
|
|
One account in ~20s, zero captcha. Pipeline per account:
|
|
1. POST /auth/email-check/ -> confirm email free (MAGIC_CODE flow)
|
|
2. POST /auth/magic-generate/ -> 6-digit code sent to mailbox
|
|
3. IMAP poll -> read code from SUBJECT "Plane login code is NNNNNN"
|
|
4. GET /auth/get-csrf-token/ -> csrftoken (mandatory, else CSRF failure page)
|
|
5. POST /auth/magic-sign-up/ -> 302 + session-id cookie (account created, email auto-verified)
|
|
6. GET /api/users/me/ -> verify session
|
|
7. POST /api/workspaces/ -> create workspace (name+slug)
|
|
8. POST /api/users/api-tokens/ -> issue API token (plane_api_*, 60/min, shown ONCE)
|
|
9. GET /api/v1/users/me/ (X-API-Key) -> verify token live
|
|
10. append full record to accounts.jsonl
|
|
|
|
Backend: api.plane.so (app.plane.so = SPA only, 405 on /auth).
|
|
Throttle: auth 10/min per IP -> bulk sleeps 7s.
|
|
Reverse source: github.com/makeplane/plane (preview branch):
|
|
apps/api/plane/authentication/urls.py, views/app/magic.py, app/views/api.py
|
|
|
|
CONFIG: fill IMAP_* + EMAIL_* below, or pass --emails file (email:imap_host:imap_user:imap_pass).
|
|
|
|
Usage:
|
|
python plane_full_combo.py <alias> # one account via +alias
|
|
python plane_full_combo.py --bulk N [out.jsonl] # mass run
|
|
python plane_full_combo.py --emails list.txt [out.jsonl]
|
|
Token use afterwards:
|
|
curl https://api.plane.so/api/v1/users/me/ -H "X-API-Key: [REDACTED]"
|
|
workspace-scoped: https://api.plane.so/api/v1/workspaces/<slug>/projects/ etc.
|
|
"""
|
|
import imaplib, email as emaillib, email.utils, re, time, json, sys, random, ssl
|
|
import urllib.request, urllib.error, http.cookiejar
|
|
from urllib.request import Request, urlopen
|
|
from urllib.parse import urlencode
|
|
|
|
# ============ CONFIG - FILL YOUR OWN ============
|
|
IMAP_HOST = "" # imap.gmail.com / outlook.office365.com / ...
|
|
IMAP_USER = ""
|
|
IMAP_PASS = "" # app password recommended
|
|
EMAIL_DOMAIN = "" # gmail.com
|
|
EMAIL_LOCAL = "" # yourname -> yourname+prNNNNNN@gmail.com
|
|
WORKSPACE_NAME = "farm lab"
|
|
WORKSPACE_SLUG_PREFIX = "farmlab"
|
|
# ================================================
|
|
|
|
API = "https://api.plane.so"
|
|
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
|
"Origin": "https://app.plane.so", "Referer": "https://app.plane.so/sign-up/"}
|
|
|
|
|
|
def post_json(path, payload, cookie=None, csrf=None, extra=None):
|
|
hdrs = {"Content-Type": "application/json", **UA}
|
|
if cookie: hdrs["Cookie"] = cookie
|
|
if csrf: hdrs["X-CSRFToken"] = csrf
|
|
if extra: hdrs.update(extra)
|
|
req = Request(API + path, data=json.dumps(payload).encode(), headers=hdrs)
|
|
try:
|
|
with urlopen(req, timeout=30) as r:
|
|
return r.status, json.loads(r.read().decode() or "{}")
|
|
except urllib.error.HTTPError as e:
|
|
try: body = json.loads(e.read().decode() or "{}")
|
|
except Exception: body = {}
|
|
return e.code, body
|
|
|
|
|
|
def get_csrf():
|
|
cj = http.cookiejar.CookieJar()
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
|
req = urllib.request.Request(API + "/auth/get-csrf-token/", headers=UA)
|
|
with opener.open(req, timeout=30) as r:
|
|
r.read()
|
|
token = [REDACTED] for c in cj if c.name == "csrftoken"), None)
|
|
return token, "; ".join(f"{c.name}={c.value}" for c in cj)
|
|
|
|
|
|
def post_form(path, data, cookies=None, csrf=None):
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, *a, **k): return None
|
|
opener = urllib.request.build_opener(NoRedirect)
|
|
hdrs = {"Content-Type": "application/x-www-form-urlencoded", **UA}
|
|
if cookies: hdrs["Cookie"] = cookies
|
|
if csrf: hdrs["X-CSRFToken"] = csrf
|
|
req = urllib.request.Request(API + path, data=urlencode(data).encode(), headers=hdrs)
|
|
try:
|
|
r = opener.open(req, timeout=30)
|
|
return r.status, r.read().decode(errors="ignore"), r.headers
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read().decode(errors="ignore"), e.headers
|
|
|
|
|
|
def get_json(path, cookie=None, api_key=None):
|
|
hdrs = {"User-Agent": UA["User-Agent"]}
|
|
if cookie: hdrs["Cookie"] = cookie
|
|
if api_key: hdrs["X-API-Key"] = api_key
|
|
req = Request(API + path, headers=hdrs)
|
|
try:
|
|
with urlopen(req, timeout=30) as r:
|
|
return r.status, json.loads(r.read().decode() or "{}")
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, {}
|
|
|
|
|
|
def read_otp(match_suffix, host, user, passwd, tries=24, wait=5, since_ts=None):
|
|
for _ in range(tries):
|
|
try:
|
|
m = imaplib.IMAP4_SSL(host, ssl_context=ssl.create_default_context())
|
|
m.login(user, passwd); m.select("INBOX")
|
|
_, data = m.search(None, 'SUBJECT "Plane login code"')
|
|
best = None
|
|
for num in data[0].split()[-10:]:
|
|
_, msg = m.fetch(num, "(RFC822)")
|
|
raw = msg[0][1]
|
|
try:
|
|
mime = emaillib.message_from_bytes(raw)
|
|
full = ""
|
|
for part in mime.walk():
|
|
if part.get_content_type() in ("text/plain", "text/html"):
|
|
try: full += part.get_payload(decode=True).decode(errors="ignore")
|
|
except Exception: pass
|
|
full += " " + str(mime.get("Subject", "")) + " " + str(mime.get("To", ""))
|
|
except Exception:
|
|
full = raw.decode(errors="ignore")
|
|
if match_suffix.lower() not in full.lower():
|
|
continue
|
|
mm = re.search(r"login code is (\d{6})", full)
|
|
dm = re.search(r"^Date: (.+)$", raw.decode(errors="ignore"), re.M)
|
|
ts = 0
|
|
if dm:
|
|
try: ts = email.utils.mktime_tz(email.utils.parsedate_tz(dm.group(1)))
|
|
except Exception: ts = 0
|
|
if mm and (best is None or ts >= best[0]):
|
|
best = (ts, mm.group(1))
|
|
m.logout()
|
|
if best and (since_ts is None or best[0] >= since_ts - 180):
|
|
return best[1]
|
|
except Exception as e:
|
|
print("imap err:", e, file=sys.stderr)
|
|
time.sleep(wait)
|
|
return None
|
|
|
|
|
|
def full_combo(email_addr, match_suffix, imap_host, imap_user, imap_pass, slug=None):
|
|
rec = {"email": email_addr, "ts": int(time.time())}
|
|
# 1-2. email check + magic generate
|
|
st, js = post_json("/auth/email-check/", {"email": email_addr})
|
|
if js.get("existing"):
|
|
rec["result"] = "EXISTS"; return rec
|
|
t0 = time.time()
|
|
st, js = post_json("/auth/magic-generate/", {"email": email_addr})
|
|
if st != 200:
|
|
rec["result"] = f"GEN_FAIL_{st}"; return rec
|
|
# 3. OTP
|
|
code = read_otp(match_suffix, imap_host, imap_user, imap_pass, since_ts=t0)
|
|
if not code:
|
|
rec["result"] = "NO_OTP"; return rec
|
|
# 4-5. csrf + sign-up
|
|
csrf_tok, csrf_cookie = get_csrf()
|
|
st, body, hdrs = post_form("/auth/magic-sign-up/", {"email": email_addr, "code": code},
|
|
cookies=csrf_cookie, csrf=csrf_tok)
|
|
loc = hdrs.get("Location", "")
|
|
if "error_code" in loc:
|
|
rec["result"] = "SIGNUP_ERR:" + loc[:150]; return rec
|
|
cookies = hdrs.get_all("Set-Cookie") or []
|
|
sess = "; ".join(c.split(";")[0] for c in cookies)
|
|
cookie = ((sess + "; ") if sess else "") + csrf_cookie
|
|
csrf_val = next((c.split(";")[0].split("=", 1)[1] for c in cookies if c.startswith("csrftoken=")), csrf_tok)
|
|
# 6. verify session
|
|
st, me = get_json("/api/users/me/", cookie=cookie)
|
|
if st != 200 or not me.get("email"):
|
|
rec["result"] = f"SESSION_FAIL_{st}"; return rec
|
|
rec["user_id"] = me["id"]
|
|
# 7. create workspace
|
|
slug = slug or (WORKSPACE_SLUG_PREFIX + str(random.randint(10000, 99999)))
|
|
st, ws = post_json("/api/workspaces/", {"name": WORKSPACE_NAME, "slug": slug},
|
|
cookie=cookie, csrf=csrf_val,
|
|
extra={"Referer": "https://app.plane.so/"})
|
|
if st not in (200, 201):
|
|
rec["result"] = f"WS_FAIL_{st}:{str(ws)[:100]}"; rec["session_cookie"] = cookie; return rec
|
|
rec["workspace"] = {"slug": ws.get("slug"), "id": ws.get("id")}
|
|
# 8. API token
|
|
st, tk = post_json("/api/users/api-tokens/", {"label": "combo-" + slug, "description": "farm"},
|
|
cookie=cookie, csrf=csrf_val,
|
|
extra={"Referer": "https://app.plane.so/"})
|
|
if st not in (200, 201) or not tk.get("token"):
|
|
rec["result"] = f"TOKEN_FAIL_{st}:{str(tk)[:100]}"; rec["session_cookie"] = cookie; return rec
|
|
rec["api_token"] = tk["token"]
|
|
rec["rate_limit"] = tk.get("allowed_rate_limit")
|
|
# 9. verify token on public v1
|
|
st, v1 = get_json("/api/v1/users/me/", api_key=tk["token"])
|
|
rec["token_live"] = (st == 200)
|
|
rec["session_cookie"] = cookie
|
|
rec["result"] = "FULL_OK" if st == 200 else "TOKEN_UNVERIFIED"
|
|
return rec
|
|
|
|
|
|
def gen_alias_email():
|
|
alias = "pr" + str(random.randint(100000, 999999))
|
|
return f"{EMAIL_LOCAL}+{alias}@{EMAIL_DOMAIN}", alias
|
|
|
|
|
|
def run_one(target, imap_host=None, imap_user=None, imap_pass=None):
|
|
h = imap_host or IMAP_HOST; u = imap_user or IMAP_USER; p = imap_pass or IMAP_PASS
|
|
if "@" in target:
|
|
addr, suffix = target, target
|
|
else:
|
|
if not (EMAIL_LOCAL and EMAIL_DOMAIN):
|
|
print("ERROR: fill EMAIL_LOCAL/EMAIL_DOMAIN or pass full email"); sys.exit(1)
|
|
addr = f"{EMAIL_LOCAL}+{target}@{EMAIL_DOMAIN}"; suffix = target
|
|
rec = full_combo(addr, suffix, h, u, p)
|
|
print(json.dumps(rec, indent=2, default=str))
|
|
return rec
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
if not args:
|
|
print(__doc__); sys.exit(1)
|
|
if args[0] == "--bulk":
|
|
n = int(args[1]) if len(args) > 1 else 10
|
|
outfile = args[2] if len(args) > 2 else "plane_accounts.jsonl"
|
|
ok = 0
|
|
with open(outfile, "a", encoding="utf-8") as f:
|
|
for i in range(n):
|
|
addr, alias = gen_alias_email()
|
|
try:
|
|
rec = full_combo(addr, alias, IMAP_HOST, IMAP_USER, IMAP_PASS)
|
|
except Exception as e:
|
|
rec = {"email": addr, "result": "EXC:" + str(e)}
|
|
f.write(json.dumps(rec, default=str) + "\n"); f.flush()
|
|
if rec.get("result") == "FULL_OK": ok += 1
|
|
print(f"[{i+1}/{n}] {addr} -> {rec.get('result')} token={rec.get('api_token','-')[:20]} (full_ok={ok})")
|
|
time.sleep(7)
|
|
print(f"DONE {ok}/{n} -> {outfile}")
|
|
elif args[0] == "--emails":
|
|
outfile = args[2] if len(args) > 2 else "plane_accounts.jsonl"
|
|
ok = 0
|
|
with open(args[1], encoding="utf-8") as f, open(outfile, "a", encoding="utf-8") as o:
|
|
for i, line in enumerate(f):
|
|
line = line.strip()
|
|
if not line: continue
|
|
parts = line.split(":")
|
|
if len(parts) == 4:
|
|
addr, h, u, p = parts
|
|
else:
|
|
addr, h, u, p = line, IMAP_HOST, IMAP_USER, IMAP_PASS
|
|
try:
|
|
rec = full_combo(addr, addr, h, u, p)
|
|
except Exception as e:
|
|
rec = {"email": addr, "result": "EXC:" + str(e)}
|
|
o.write(json.dumps(rec, default=str) + "\n"); o.flush()
|
|
if rec.get("result") == "FULL_OK": ok += 1
|
|
print(f"[{i+1}] {addr} -> {rec.get('result')} (full_ok={ok})")
|
|
time.sleep(7)
|
|
print(f"DONE {ok} -> {outfile}")
|
|
else:
|
|
run_one(args[0])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|