extends Node signal profile_changed signal request_failed(message: String) signal preferences_saved signal password_changed signal account_deleted signal archive_saved(path: 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 auth_operation := "" var profile_operation := "" var auth_token := "" var profile_token := "" var preferences_pending := false 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) 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 _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 _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"}