349 lines
14 KiB
GDScript
349 lines
14 KiB
GDScript
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"}
|