defmodule Odinsea.Database.Context do @moduledoc """ Database context module for Odinsea. Provides high-level database operations for accounts, characters, and related entities. Ported from Java UnifiedDB.java and MapleClient.java login functionality. """ require Logger import Ecto.Query alias Odinsea.Repo alias Odinsea.Database.Schema.{Account, Character} alias Odinsea.Net.Cipher.LoginCrypto # ================================================================================================== # Account Operations # ================================================================================================== @doc """ Authenticates a user with username and password. Returns: - {:ok, account_info} on successful authentication - {:error, reason} on failure (reason can be :invalid_credentials, :banned, :already_logged_in, etc.) Ported from MapleClient.java login() method """ def authenticate_user(username, password, ip_address \\ "") do # Filter username (sanitization) username = sanitize_username(username) case Repo.get_by(Account, name: username) do nil -> Logger.warning("Login attempt for non-existent account: #{username}") {:error, :account_not_found} account -> check_account_login(account, password, ip_address) end end @doc """ Updates all accounts to logged out state. Used during server startup/shutdown. """ def set_all_accounts_logged_off do Repo.update_all(Account, set: [loggedin: 0]) :ok end @doc """ Updates account login state. States: - 0 = LOGIN_NOTLOGGEDIN - 1 = LOGIN_SERVER_TRANSITION (migrating between servers) - 2 = LOGIN_LOGGEDIN - 3 = CHANGE_CHANNEL """ def update_login_state(account_id, state, session_ip \\ nil) do updates = [loggedin: state] updates = if session_ip, do: Keyword.put(updates, :session_ip, session_ip), else: updates Repo.update_all( from(a in Account, where: a.id == ^account_id), set: updates ) :ok end @doc """ Gets the current login state for an account. """ def get_login_state(account_id) do case Repo.get(Account, account_id) do nil -> 0 account -> account.loggedin end end @doc """ Updates account last login time. """ def update_last_login(account_id) do now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) Repo.update_all( from(a in Account, where: a.id == ^account_id), set: [lastlogin: now] ) :ok end @doc """ Records an IP log entry for audit purposes. """ def log_ip_address(account_id, ip_address) do timestamp = format_timestamp(NaiveDateTime.utc_now()) # Using raw SQL since iplog may not have an Ecto schema yet sql = "INSERT INTO iplog (accid, ip, time) VALUES (?, ?, ?)" case Ecto.Adapters.SQL.query(Repo, sql, [account_id, ip_address, timestamp]) do {:ok, _} -> :ok {:error, err} -> Logger.error("Failed to log IP: #{inspect(err)}") :error end end @doc """ Checks if an account is banned. Returns {:ok, account} if not banned, {:error, :banned} if banned. """ def check_ban_status(account_id) do case Repo.get(Account, account_id) do nil -> {:error, :not_found} account -> if account.banned > 0 do {:error, :banned} else {:ok, account} end end end @doc """ Gets temporary ban information if account is temp banned. """ def get_temp_ban_info(account_id) do case Repo.get(Account, account_id) do nil -> nil account -> if account.tempban && NaiveDateTime.compare(account.tempban, NaiveDateTime.utc_now()) == :gt do %{reason: account.banreason, expires: account.tempban} else nil end end end # ================================================================================================== # Character Operations # ================================================================================================== @doc """ Loads character entries for an account in a specific world. Returns a list of character summaries (id, name, gm level). Ported from UnifiedDB.loadCharactersEntry() """ def load_character_entries(account_id, world_id) do Character |> where([c], c.accountid == ^account_id and c.world == ^world_id) |> select([c], %{id: c.id, name: c.name, gm: c.gm}) |> Repo.all() end @doc """ Gets the count of characters for an account in a world. """ def character_count(account_id, world_id) do Character |> where([c], c.accountid == ^account_id and c.world == ^world_id) |> Repo.aggregate(:count, :id) end @doc """ Gets all character IDs for an account in a world. """ def load_character_ids(account_id, world_id) do Character |> where([c], c.accountid == ^account_id and c.world == ^world_id) |> select([c], c.id) |> Repo.all() end @doc """ Gets all character names for an account in a world. """ def load_character_names(account_id, world_id) do Character |> where([c], c.accountid == ^account_id and c.world == ^world_id) |> select([c], c.name) |> Repo.all() end @doc """ Loads full character data by ID. Returns the Character struct or nil if not found. TODO: Expand to load related data (inventory, skills, quests, etc.) """ def load_character(character_id) do Repo.get(Character, character_id) end @doc """ Checks if a character name is already in use. """ def character_name_exists?(name) do Character |> where([c], c.name == ^name) |> Repo.exists?() end @doc """ Gets character ID by name and world. """ def get_character_id(name, world_id) do Character |> where([c], c.name == ^name and c.world == ^world_id) |> select([c], c.id) |> Repo.one() end @doc """ Creates a new character. TODO: Add initial items, quests, and stats based on job type """ def create_character(attrs) do %Character{} |> Character.creation_changeset(attrs) |> Repo.insert() end @doc """ Deletes a character (soft delete - renames and moves to deleted world). Returns {:ok, character} on success, {:error, reason} on failure. Ported from UnifiedDB.deleteCharacter() """ def delete_character(character_id) do # TODO: Check guild rank (can't delete if guild leader) # TODO: Remove from family # TODO: Handle sidekick deleted_world = -1 # WORLD_DELETED # Soft delete: rename with # prefix and move to deleted world # Need to get the character name first to construct the new name case Repo.get(Character, character_id) do nil -> {:error, :not_found} character -> new_name = "#" <> character.name Repo.update_all( from(c in Character, where: c.id == ^character_id), set: [ name: new_name, world: deleted_world ] ) # Clean up related records cleanup_character_assets(character_id) :ok end end @doc """ Updates character stats. """ def update_character_stats(character_id, attrs) do case Repo.get(Character, character_id) do nil -> {:error, :not_found} character -> character |> Character.stat_changeset(attrs) |> Repo.update() end end @doc """ Updates character position (map, spawn point). """ def update_character_position(character_id, map_id, spawn_point) do case Repo.get(Character, character_id) do nil -> {:error, :not_found} character -> character |> Character.position_changeset(%{map: map_id, spawnpoint: spawn_point}) |> Repo.update() end end # ================================================================================================== # Character Creation Helpers # ================================================================================================== @doc """ Gets default stats for a new character based on job type. Job types: - 0 = Resistance - 1 = Adventurer - 2 = Cygnus - 3 = Aran - 4 = Evan """ def get_default_stats_for_job(job_type, subcategory \\ 0) do base_stats = %{ level: 1, exp: 0, hp: 50, mp: 5, maxhp: 50, maxmp: 5, str: 12, dex: 5, luk: 4, int: 4, ap: 0 } case job_type do 0 -> # Resistance %{base_stats | job: 3000, str: 12, dex: 5, int: 4, luk: 4} 1 -> # Adventurer if subcategory == 1 do # Dual Blade %{base_stats | job: 430, str: 4, dex: 25, int: 4, luk: 4} else %{base_stats | job: 0, str: 12, dex: 5, int: 4, luk: 4} end 2 -> # Cygnus %{base_stats | job: 1000, str: 12, dex: 5, int: 4, luk: 4} 3 -> # Aran %{base_stats | job: 2000, str: 12, dex: 5, int: 4, luk: 4} 4 -> # Evan %{base_stats | job: 2001, str: 4, dex: 4, int: 12, luk: 5} _ -> base_stats end end @doc """ Gets the default map ID for a job type. """ def get_default_map_for_job(job_type) do case job_type do 0 -> 931000000 # Resistance tutorial 1 -> 0 # Adventurer - Maple Island (handled specially) 2 -> 130030000 # Cygnus tutorial 3 -> 914000000 # Aran tutorial 4 -> 900010000 # Evan tutorial _ -> 100000000 # Default to Henesys end end # ================================================================================================== # Forbidden Names # ================================================================================================== @doc """ Checks if a character name is forbidden. """ def forbidden_name?(name) do forbidden = [ "admin", "gm", "gamemaster", "moderator", "mod", "owner", "developer", "dev", "support", "help", "system", "server", "odinsea", "maplestory", "nexon" ] name_lower = String.downcase(name) Enum.any?(forbidden, fn forbidden -> String.contains?(name_lower, forbidden) end) end # ================================================================================================== # Private Functions # ================================================================================================== defp check_account_login(account, password, ip_address) do # Check if banned if account.banned > 0 && account.gm == 0 do Logger.warning("Banned account attempted login: #{account.name}") {:error, :banned} else # Check login state login_state = account.loggedin if login_state > 0 do # Already logged in - could check if stale session Logger.warning("Account already logged in: #{account.name}") {:error, :already_logged_in} else verify_password(account, password, ip_address) end end end defp verify_password(account, password, ip_address) do # Check various password formats valid = check_salted_sha512(account.password, password, account.salt) || check_plain_match(account.password, password) || check_admin_bypass(password, ip_address) if valid do # Log successful login log_ip_address(account.id, ip_address) update_last_login(account.id) # Build account info map account_info = %{ account_id: account.id, username: account.name, gender: account.gender, is_gm: account.gm > 0, second_password: decrypt_second_password(account.second_password, account.salt2), acash: account.acash, mpoints: account.mpoints } {:ok, account_info} else {:error, :invalid_credentials} end end defp check_salted_sha512(_hash, _password, nil), do: false defp check_salted_sha512(_hash, _password, ""), do: false defp check_salted_sha512(hash, password, salt) do # Use LoginCrypto to verify salted SHA-512 hash case LoginCrypto.verify_salted_sha512(password, salt, hash) do {:ok, _} -> true _ -> false end end defp check_plain_match(hash, password) do # Direct comparison (legacy/insecure, but needed for compatibility) hash == password end defp check_admin_bypass(password, ip_address) do # Check for admin bypass password from specific IPs # TODO: Load admin passwords and allowed IPs from config false end defp decrypt_second_password(nil, _), do: nil defp decrypt_second_password("", _), do: nil defp decrypt_second_password(spw, salt2) do if salt2 && salt2 != "" do # Decrypt using rand_r (reverse of rand_s) LoginCrypto.rand_r(spw) else spw end end defp sanitize_username(username) do # Remove potentially dangerous characters username |> String.trim() |> String.replace(~r/[<>"'%;()&+\-]/, "") end defp format_timestamp(naive_datetime) do NaiveDateTime.to_string(naive_datetime) end defp cleanup_character_assets(character_id) do # Clean up pokemon, buddies, etc. # Using raw SQL for tables that don't have schemas yet try do Ecto.Adapters.SQL.query(Repo, "DELETE FROM buddies WHERE buddyid = ?", [character_id]) rescue _ -> :ok end :ok end end