230 lines
8.6 KiB
GDScript
230 lines
8.6 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)
|
|
|
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
|
const AUTH_URL := "https://s3q.ranaze.com/api/Auth"
|
|
const PROFILE_URL := "https://s3q.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"}
|