Initial commit
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
@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()
|
||||
Reference in New Issue
Block a user