extends Node signal profile_changed signal request_failed(message: String) signal preferences_saved signal password_changed signal account_deleted signal archive_saved(path: String) signal external_link_started(provider: String) signal external_linked(provider: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const AUTH_URL := "https://us3q.ranaze.com/api/Auth" const PROFILE_URL := "https://us3q.ranaze.com/api/profile" var account: Dictionary = {} var preferences: Dictionary = _default_preferences() var auth_request: HTTPRequest var profile_request: HTTPRequest var external_request: HTTPRequest var external_poll_timer: Timer var auth_operation := "" var profile_operation := "" var auth_token := "" var profile_token := "" var preferences_pending := false var external_operation := "" var external_provider := "" var external_provider_name := "" var external_transaction_id := "" var external_poll_deadline_msec := 0 func _ready() -> void: auth_request = HTTPRequest.new() auth_request.timeout = 25.0 auth_request.request_completed.connect(_on_auth_completed) add_child(auth_request) profile_request = HTTPRequest.new() profile_request.timeout = 30.0 profile_request.request_completed.connect(_on_profile_completed) add_child(profile_request) external_request = HTTPRequest.new() external_request.timeout = 25.0 external_request.request_completed.connect(_on_external_completed) add_child(external_request) external_poll_timer = Timer.new() external_poll_timer.wait_time = 2.0 external_poll_timer.timeout.connect(_poll_external_link) add_child(external_poll_timer) AuthState.auth_changed.connect(_on_auth_changed) func refresh() -> void: if not AuthState.can_send_authenticated_request(): return _load_cache() _request_auth("identity", HTTPClient.METHOD_GET, "/me") if preferences_pending: _request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences) else: _request_profile("preferences", HTTPClient.METHOD_GET, "/me/preferences") func update_account(display_name: String, email: String) -> void: _request_auth("update_account", HTTPClient.METHOD_PATCH, "/me", { "displayName": display_name.strip_edges(), "email": email.strip_edges() }) func update_preferences(target_exam_date: String, preparation_stage: String, weekly_goal_hours: int, preferred_mode: String) -> void: var target_date: Variant = null if not target_exam_date.strip_edges().is_empty(): target_date = "%sT00:00:00Z" % target_exam_date.strip_edges() preferences = { "targetExamDate": target_date, "preparationStage": preparation_stage, "weeklyGoalHours": weekly_goal_hours, "preferredStudyMode": preferred_mode } preferences_pending = true _save_cache(true) profile_changed.emit() _request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences) func change_password(current_password: String, new_password: String) -> void: _request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", { "currentPassword": current_password, "newPassword": new_password }) func export_archive() -> void: _request_profile("export", HTTPClient.METHOD_GET, "/me/export") func delete_account(password: String, confirmation: String) -> void: _request_auth("delete_account", HTTPClient.METHOD_DELETE, "/me", { "password": password, "confirmation": confirmation }) func connect_provider(provider: String, provider_name: String) -> void: if not AuthState.can_send_authenticated_request(): request_failed.emit("Sign in again before connecting an account.") return if external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: request_failed.emit("Another connection request is still in progress.") return external_provider = provider external_provider_name = provider_name var storage_key := _external_storage_key() if OS.has_feature("web"): var popup_result: Variant = JavaScriptBridge.eval( "window.localStorage.removeItem(%s); var bridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeProfileExternalWindow = window.open(bridge, 'ranaze_profile_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); window.ranazeProfileExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)], true) if not bool(popup_result): request_failed.emit("Allow popups for this site, then try again.") _reset_external_link() return external_operation = "start" var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token, "Content-Type: application/json"]) var error := external_request.request("%s/external/%s/start" % [_auth_base(), provider], headers, HTTPClient.METHOD_POST, JSON.stringify({"purpose": "link"})) if error != OK: request_failed.emit("The account provider could not be reached.") _reset_external_link() func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void: if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: request_failed.emit("Another account request is still in progress.") return auth_operation = operation auth_token = AuthState.access_token var headers := PackedStringArray(["Authorization: Bearer %s" % auth_token, "Content-Type: application/json"]) var url := "%s%s" % [EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/"), path] var error := auth_request.request(url, headers, method, "" if payload == null else JSON.stringify(payload)) if error != OK: auth_operation = "" request_failed.emit("The account service could not be reached.") func _request_profile(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void: if profile_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: request_failed.emit("Another profile request is still in progress.") return profile_operation = operation profile_token = AuthState.access_token var headers := PackedStringArray(["Authorization: Bearer %s" % profile_token, "Content-Type: application/json"]) var base := EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/profile_api_url", PROFILE_URL)), "/api/profile") var error := profile_request.request("%s%s" % [base.rstrip("/"), path], headers, method, "" if payload == null else JSON.stringify(payload)) if error != OK: profile_operation = "" request_failed.emit("The profile service could not be reached.") func _on_auth_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: var operation := auth_operation auth_operation = "" if response_code == 401: AuthState.handle_unauthorized(auth_token) return if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: request_failed.emit(_error_message(body, "The account request failed.")) return if operation in ["identity", "update_account"]: var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: request_failed.emit("The account service returned invalid data.") return account = parsed profile_changed.emit() elif operation == "change_password": password_changed.emit() AuthState.clear_session() elif operation == "delete_account": _clear_cache() account_deleted.emit() AuthState.clear_session() func _on_profile_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: var operation := profile_operation profile_operation = "" if response_code == 401: AuthState.handle_unauthorized(profile_token) return if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: request_failed.emit(_error_message(body, "The profile request failed.")) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: request_failed.emit("The profile service returned invalid data.") return if operation in ["preferences", "update_preferences"]: preferences = parsed preferences_pending = false _save_cache(false) profile_changed.emit() if operation == "update_preferences": preferences_saved.emit() elif operation == "export": var archive: Dictionary = parsed archive["account"] = account.duplicate(true) _save_archive(JSON.stringify(archive, " ")) func _on_external_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: var operation := external_operation external_operation = "" if response_code == 401: AuthState.handle_unauthorized(AuthState.access_token) _reset_external_link() return if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: request_failed.emit(_error_message(body, "The external account request failed.")) _reset_external_link() return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: request_failed.emit("The external account service returned invalid data.") _reset_external_link() return var data: Dictionary = parsed if operation == "start": external_transaction_id = str(data.get("transactionId", "")) var auth_url := str(data.get("authUrl", "")) if external_transaction_id.is_empty() or auth_url.is_empty(): request_failed.emit("The provider returned an invalid response.") _reset_external_link() return if OS.has_feature("web"): JavaScriptBridge.eval("var url=%s; window.localStorage.setItem(%s,url); if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.location.replace(url);window.ranazeProfileExternalWindow.focus();}" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())], true) else: OS.shell_open(auth_url) external_poll_deadline_msec = Time.get_ticks_msec() + 300000 external_poll_timer.start() external_link_started.emit(external_provider) elif operation == "status": match str(data.get("status", "pending")): "linked": var linked_provider := external_provider _reset_external_link() external_linked.emit(linked_provider) refresh() "failed": request_failed.emit(str(data.get("message", "The provider could not connect this account."))) _reset_external_link() func _poll_external_link() -> void: if external_transaction_id.is_empty() or external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: return if Time.get_ticks_msec() >= external_poll_deadline_msec: request_failed.emit("The account connection timed out.") _reset_external_link() return external_operation = "status" var error := external_request.request("%s/external/%s/status/%s" % [_auth_base(), external_provider, external_transaction_id]) if error != OK: external_operation = "" func _reset_external_link() -> void: if external_poll_timer != null: external_poll_timer.stop() if OS.has_feature("web") and not external_provider.is_empty(): JavaScriptBridge.eval("window.localStorage.removeItem(%s);if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.close();}window.ranazeProfileExternalWindow=null;" % JSON.stringify(_external_storage_key()), true) external_operation = "" external_provider = "" external_provider_name = "" external_transaction_id = "" external_poll_deadline_msec = 0 func _external_storage_key() -> String: return "ranaze_profile_external_auth_url_%s" % external_provider func _auth_base() -> String: return EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/") func _save_archive(content: String) -> void: var filename := "ranaze-account-export-%s.json" % Time.get_date_string_from_system() if OS.has_feature("web"): var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer()) JavaScriptBridge.eval("(function(){const a=document.createElement('a');a.href='data:application/json;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [encoded, JSON.stringify(filename)], true) archive_saved.emit(filename) return var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS) var path := directory.path_join(filename) var file := FileAccess.open(path, FileAccess.WRITE) if file == null: request_failed.emit("The archive could not be saved.") return file.store_string(content) archive_saved.emit(path) func _on_auth_changed() -> void: if AuthState.is_logged_in: _load_cache() else: account = {} preferences = _default_preferences() func _cache_path() -> String: return "user://profile-%s.json" % AuthState.username.sha256_text().left(16) func _load_cache() -> void: if AuthState.username.is_empty() or not FileAccess.file_exists(_cache_path()): return var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(_cache_path())) if typeof(parsed) == TYPE_DICTIONARY: preferences = parsed.get("preferences", _default_preferences()) preferences_pending = bool(parsed.get("pending", false)) func _save_cache(pending: bool) -> void: if AuthState.username.is_empty(): return var file := FileAccess.open(_cache_path(), FileAccess.WRITE) if file != null: preferences_pending = pending file.store_string(JSON.stringify({"preferences": preferences, "pending": pending})) func _clear_cache() -> void: if not AuthState.username.is_empty() and FileAccess.file_exists(_cache_path()): DirAccess.remove_absolute(ProjectSettings.globalize_path(_cache_path())) func _error_message(body: PackedByteArray, fallback: String) -> String: var raw := body.get_string_from_utf8().strip_edges() var parsed: Variant = JSON.parse_string(raw) if typeof(parsed) == TYPE_DICTIONARY: var detail := str(parsed.get("detail", parsed.get("title", ""))).strip_edges() if not detail.is_empty(): return detail var text := raw.trim_prefix("\"").trim_suffix("\"") return fallback if text.is_empty() else text static func _default_preferences() -> Dictionary: return {"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed"}