Start repo, claude & kimi still vibing tho
This commit is contained in:
484
lib/odinsea/database/context.ex
Normal file
484
lib/odinsea/database/context.ex
Normal file
@@ -0,0 +1,484 @@
|
||||
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
|
||||
Reference in New Issue
Block a user