Files
step3/scripts/autoload/account_profile_service.gd
T

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"}