485 lines
13 KiB
Elixir
485 lines
13 KiB
Elixir
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
|