286 lines
11 KiB
GDScript
286 lines
11 KiB
GDScript
@tool
|
|
extends Node
|
|
class_name CghAuth
|
|
## Web-broker login (BlenderKit-style), mirroring the Blender addon:
|
|
## 1. open system browser -> cgheven.com/addon-login
|
|
## 2. run a loopback server, catch the handoff ?code=
|
|
## 3. exchange the code with the backend for a session token (JWT)
|
|
## Godot has NO HTTP server, only raw TCP, so we hand-parse the request line
|
|
## and hand-write the HTTP response. Also supports license-key + machine_hash.
|
|
|
|
signal logged_in(plan: String)
|
|
signal login_failed(message: String)
|
|
signal plan_refreshed(plan: String)
|
|
|
|
var session_token := ""
|
|
var plan := "Free"
|
|
var email := ""
|
|
var expires_at := "" # ISO date the plan/session expires (shown in the Account popup)
|
|
|
|
var _server: TCPServer
|
|
var _port := 0
|
|
var _state := ""
|
|
var _peers: Array = [] # [{peer, req, frames}] — ALL live loopback connections
|
|
var _wait_frames := 0 # global poll ticks since login started (overall timeout)
|
|
var _poll_timer: Timer = null # polls the loopback server (Timers fire reliably in the editor; _process may not)
|
|
var _exchange_http: HTTPRequest
|
|
var _heartbeat_http: HTTPRequest
|
|
|
|
func _ready() -> void:
|
|
_exchange_http = HTTPRequest.new(); add_child(_exchange_http)
|
|
_exchange_http.request_completed.connect(_on_exchange_done)
|
|
_heartbeat_http = HTTPRequest.new(); add_child(_heartbeat_http)
|
|
_heartbeat_http.request_completed.connect(_on_heartbeat_done)
|
|
_load_session()
|
|
|
|
# -------- persisted session (EditorSettings, per-user, cross-project) --------
|
|
func _load_session() -> void:
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es.has_setting("cgheven/session_token"):
|
|
session_token = es.get_setting("cgheven/session_token")
|
|
plan = es.get_setting("cgheven/plan") if es.has_setting("cgheven/plan") else "Free"
|
|
email = es.get_setting("cgheven/email") if es.has_setting("cgheven/email") else ""
|
|
expires_at = es.get_setting("cgheven/expires_at") if es.has_setting("cgheven/expires_at") else ""
|
|
|
|
func _save_session() -> void:
|
|
var es := EditorInterface.get_editor_settings()
|
|
es.set_setting("cgheven/session_token", session_token)
|
|
es.set_setting("cgheven/plan", plan)
|
|
es.set_setting("cgheven/email", email)
|
|
es.set_setting("cgheven/expires_at", expires_at)
|
|
|
|
func is_logged_in() -> bool:
|
|
return session_token != ""
|
|
|
|
func is_premium() -> bool:
|
|
return plan == "Pro" or plan == "Studio" or plan == "Bundle"
|
|
|
|
func logout() -> void:
|
|
session_token = ""
|
|
plan = "Free"
|
|
email = ""
|
|
expires_at = ""
|
|
_save_session()
|
|
|
|
# -------------------------- web-broker login --------------------------
|
|
func start_login(register := false) -> void:
|
|
_server = TCPServer.new()
|
|
_port = 0
|
|
for p in CghConfig.LOOPBACK_PORTS:
|
|
if _server.listen(p, "127.0.0.1") == OK:
|
|
_port = p
|
|
break
|
|
if _port == 0:
|
|
login_failed.emit("Could not open a local login port. Close other addons and retry.")
|
|
return
|
|
# Build the SAME broker URL the Blender/AE addons use, so the website handles it
|
|
# identically: /addon-login?redirect=<loopback>&state=&addon=godot&machine=&label=
|
|
_state = "%08x%08x" % [randi(), randi()]
|
|
var redirect := CghConfig.loopback_redirect(_port)
|
|
var label := _device_label()
|
|
var url := "%s?redirect=%s&state=%s&addon=%s&machine=%s&label=%s" % [
|
|
CghConfig.login_base_url(),
|
|
redirect.uri_encode(), _state, CghConfig.ADDON_SLUG,
|
|
machine_hash().uri_encode(), label.uri_encode()]
|
|
if register:
|
|
url += "&screen=register"
|
|
OS.shell_open(url)
|
|
_start_poll() # poll the loopback server on a Timer (reliable in the editor)
|
|
|
|
func _start_poll() -> void:
|
|
if _poll_timer == null:
|
|
_poll_timer = Timer.new()
|
|
_poll_timer.wait_time = 0.05 # tick fast so we answer the browser's GET promptly
|
|
_poll_timer.one_shot = false
|
|
add_child(_poll_timer)
|
|
_poll_timer.timeout.connect(_poll_login)
|
|
_peers = []
|
|
_wait_frames = 0
|
|
_poll_timer.start()
|
|
|
|
func _stop_poll() -> void:
|
|
if _poll_timer != null:
|
|
_poll_timer.stop()
|
|
|
|
func _device_label() -> String:
|
|
var host := OS.get_environment("COMPUTERNAME") # Windows
|
|
if host == "":
|
|
host = OS.get_environment("HOSTNAME") # macOS / Linux
|
|
if host == "":
|
|
host = "Godot"
|
|
return "%s (%s)" % [host, OS.get_name()]
|
|
|
|
func _poll_login() -> void:
|
|
if _server == null or not _server.is_listening():
|
|
_stop_poll()
|
|
return
|
|
_wait_frames += 1
|
|
# Accept EVERY pending connection this tick, not just one. Chrome/Edge open a speculative
|
|
# "preconnect" socket alongside the real request; the old code latched onto ONE socket, so
|
|
# if it grabbed the empty preconnect it blocked there while the real GET (carrying ?code=)
|
|
# sat unanswered in the backlog — the browser then just spun on the localhost URL until a
|
|
# 30s timeout. We now track them all and answer whichever one actually delivers the code.
|
|
while _server.is_connection_available():
|
|
var np := _server.take_connection()
|
|
if np != null:
|
|
_peers.append({"peer": np, "req": "", "frames": 0})
|
|
var i := _peers.size() - 1
|
|
while i >= 0:
|
|
var slot: Dictionary = _peers[i]
|
|
var peer: StreamPeerTCP = slot["peer"]
|
|
peer.poll()
|
|
var st := peer.get_status()
|
|
if st == StreamPeerTCP.STATUS_CONNECTING:
|
|
slot["frames"] = int(slot["frames"]) + 1
|
|
i -= 1
|
|
continue
|
|
if st != StreamPeerTCP.STATUS_CONNECTED:
|
|
_peers.remove_at(i) # closed/errored (usually the idle preconnect) — drop it
|
|
i -= 1
|
|
continue
|
|
var avail := peer.get_available_bytes()
|
|
if avail > 0:
|
|
slot["req"] = str(slot["req"]) + peer.get_utf8_string(avail)
|
|
slot["frames"] = int(slot["frames"]) + 1
|
|
var req: String = str(slot["req"])
|
|
if req.find("\r\n") != -1:
|
|
var code := _extract_query_param(req, "code")
|
|
if code != "":
|
|
_answer_and_close(peer, code) # 302 -> branded cgheven.com/addon-login/done page
|
|
_peers.remove_at(i)
|
|
_finish_login(code)
|
|
return
|
|
# Complete request line but no ?code= (favicon, bare hit) — send it to the error
|
|
# page and drop it; another peer may still carry the real code.
|
|
_answer_and_close(peer, "")
|
|
_peers.remove_at(i)
|
|
i -= 1
|
|
continue
|
|
# Idle socket that never sends a request line — retire it after ~4s. This NEVER fails
|
|
# the login; only the global timeout below can.
|
|
if int(slot["frames"]) > 80:
|
|
peer.disconnect_from_host()
|
|
_peers.remove_at(i)
|
|
i -= 1
|
|
if _wait_frames > 1200: # ~60s overall safety timeout at 0.05s/tick
|
|
_finish_login("")
|
|
|
|
# Write the browser's 302 redirect to the branded cgheven.com/addon-login/done page — exactly
|
|
# like the Blender/AE/Premiere addons — so the user lands on our site, NOT a localhost page.
|
|
# The loopback is only the secure handoff; it's never the landing.
|
|
func _answer_and_close(peer: StreamPeerTCP, code: String) -> void:
|
|
var status := "ok" if code != "" else "error"
|
|
var dest := "%s/addon-login/done?addon=%s&status=%s" % [
|
|
CghConfig.web_base(), CghConfig.ADDON_SLUG, status]
|
|
var resp := "HTTP/1.1 302 Found\r\nLocation: %s\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" % dest
|
|
peer.put_data(resp.to_utf8_buffer())
|
|
peer.poll()
|
|
peer.disconnect_from_host()
|
|
|
|
# Tear down the loopback (close any stragglers + stop the server) and, if we caught a code,
|
|
# redeem it for the session token.
|
|
func _finish_login(code: String) -> void:
|
|
for slot in _peers:
|
|
var p: StreamPeerTCP = slot["peer"]
|
|
if p != null:
|
|
p.disconnect_from_host()
|
|
_peers = []
|
|
if _server != null:
|
|
_server.stop()
|
|
_stop_poll()
|
|
if code == "":
|
|
login_failed.emit("No login code received. Please try logging in again.")
|
|
return
|
|
_exchange_code(code)
|
|
|
|
func _extract_query_param(req: String, key: String) -> String:
|
|
# req first line looks like: GET /callback?code=ABC&... HTTP/1.1
|
|
var line := req.split("\r\n")[0]
|
|
var qpos := line.find("?")
|
|
if qpos == -1:
|
|
return ""
|
|
var path := line.substr(qpos + 1)
|
|
path = path.split(" ")[0] # strip trailing " HTTP/1.1"
|
|
for pair in path.split("&"):
|
|
# Split on the FIRST '=' only — the value is a JWT/base64 handoff code that itself
|
|
# contains '=' padding, so a plain split("=") would break into 3+ parts and drop it.
|
|
var eq := pair.find("=")
|
|
if eq == -1:
|
|
continue
|
|
if pair.substr(0, eq) == key:
|
|
return pair.substr(eq + 1).uri_decode()
|
|
return ""
|
|
|
|
func _exchange_code(code: String) -> void:
|
|
# Redeem the handoff code for the session payload (same as Blender/AE).
|
|
var body := JSON.stringify({"code": code})
|
|
_exchange_http.request(CghConfig.addon_exchange_url(),
|
|
PackedStringArray(["Content-Type: application/json"]),
|
|
HTTPClient.METHOD_POST, body)
|
|
|
|
func _on_exchange_done(_r: int, code_status: int, _h: PackedStringArray, b: PackedByteArray) -> void:
|
|
if code_status < 200 or code_status >= 300:
|
|
login_failed.emit("Login exchange failed (HTTP %d)" % code_status)
|
|
return
|
|
var d = JSON.parse_string(b.get_string_from_utf8())
|
|
if typeof(d) != TYPE_DICTIONARY or not d.has("session_token"):
|
|
login_failed.emit("Login exchange returned no token.")
|
|
return
|
|
session_token = d["session_token"]
|
|
plan = _plan_name_from(d)
|
|
email = str(d.get("email", ""))
|
|
expires_at = str(d.get("expires_at", ""))
|
|
_save_session()
|
|
logged_in.emit(plan)
|
|
|
|
# The broker's `plan` field is the BILLING CYCLE ("Yearly"/"Monthly"), not the plan
|
|
# name — the real name is in `plan_label` ("Studio (Yearly)"). Derive a clean name
|
|
# so is_premium()/gating/analytics recognise paid users.
|
|
func _plan_name_from(d: Dictionary) -> String:
|
|
var hay := (str(d.get("plan_label", "")) + " " + str(d.get("plan", ""))).to_lower()
|
|
if hay.contains("studio"):
|
|
return "Studio"
|
|
if hay.contains("pro"):
|
|
return "Pro"
|
|
if hay.contains("bundle"):
|
|
return "Bundle"
|
|
if bool(d.get("is_premium", false)):
|
|
return "Pro"
|
|
return "Free"
|
|
|
|
# -------------------------- license-key activation --------------------------
|
|
func activate_key(key: String) -> void:
|
|
var body := JSON.stringify({
|
|
"license_key": key,
|
|
"machine_hash": machine_hash(),
|
|
"source": "godot",
|
|
})
|
|
# reuse the exchange HTTPRequest; same _on_exchange_done handler shape
|
|
_exchange_http.request(CghConfig.addon_activate_url(),
|
|
PackedStringArray(["Content-Type: application/json"]),
|
|
HTTPClient.METHOD_POST, body)
|
|
|
|
# -------------------------- heartbeat (live plan refresh) --------------------------
|
|
func heartbeat() -> void:
|
|
if session_token == "":
|
|
return
|
|
_heartbeat_http.request(CghConfig.heartbeat_url(),
|
|
PackedStringArray(["Authorization: Bearer " + session_token]),
|
|
HTTPClient.METHOD_GET)
|
|
|
|
func _on_heartbeat_done(_r: int, code_status: int, _h: PackedStringArray, b: PackedByteArray) -> void:
|
|
if code_status < 200 or code_status >= 300:
|
|
return
|
|
var d = JSON.parse_string(b.get_string_from_utf8())
|
|
if typeof(d) == TYPE_DICTIONARY and d.has("plan"):
|
|
var new_plan := _plan_name_from(d)
|
|
if new_plan != plan:
|
|
plan = new_plan
|
|
_save_session()
|
|
plan_refreshed.emit(plan)
|
|
|
|
# -------------------------- machine fingerprint (license-key path) --------------------------
|
|
static func machine_hash() -> String:
|
|
# NOTE (risk): must match the Blender addon fingerprint or existing keys won't validate.
|
|
return (OS.get_unique_id() + "|" + OS.get_name()).sha256_text()
|