Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 144 additions & 43 deletions apps/server/lib/prodigy/server/service/logoff.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2022, Phillip Heller
# Copyright 2022-2025, Phillip Heller
#
# This file is part of Prodigy Reloaded.
#
Expand All @@ -14,75 +14,176 @@
# see <https://www.gnu.org/licenses/>.

defmodule Prodigy.Server.Service.Logoff do
@behaviour Prodigy.Server.Service
@moduledoc """
Handle Logoff requests
"""

@behaviour Prodigy.Server.Service

require Logger
require Ecto.Query
use EnumType

import Ecto.Changeset
import Ecto.Query, only: [from: 2]

alias Prodigy.Core.Data.{Repo, User}
alias Prodigy.Server.Protocol.Dia.Packet, as: DiaPacket
alias Prodigy.Server.Protocol.Dia.Packet
alias Prodigy.Server.Protocol.Dia.Packet.Fm0
alias Prodigy.Server.Context
alias Prodigy.Server.SessionManager

defp clear_id_in_use(user_id, reason \\ "normal") do
user =
User
|> Ecto.Query.where([u], u.id == ^user_id)
|> Ecto.Query.first()
|> Repo.one()
@normal_logoff_dest 0x00D202
@disconnect_logoff_dest 0x00D201

defenum Status do
@moduledoc "An enumeration of Logoff service responses"

value SUCCESS, 0x0
end

@doc """
Handles incoming logoff requests
"""
def handle(%Fm0{} = request, %Context{user: nil}) do
# No user in context - just send empty response
response = build_empty_response(request)
{:ok, %Context{}, response}
end

def handle(%Fm0{} = request, %Context{user: user} = context) do
request
|> parse_request(user)
|> and_then(&update_last_logon_time/1)
|> and_then(&end_session/1)
|> and_then(&determine_result/1)
|> and_then(&build_response/1)
|> finalize_response(context)
end

@doc """
Handles abnormal disconnection (e.g., connection drop)
"""
def handle_abnormal(nil), do: :ok

def handle_abnormal(user) do
{:ok, %{user: user, reason: :abnormal, request: nil}}
|> update_last_logon_time()
|> and_then(&end_session/1)

:ok
end

# Railway-oriented combinator - handles any :ok tuple
defp and_then({:error, _} = error, _func), do: error
defp and_then(success_tuple, func) when elem(success_tuple, 0) == :ok, do: func.(success_tuple)

# Pipeline functions

defp parse_request(request, user) do
reason = case request.dest do
@disconnect_logoff_dest -> :disconnect
@normal_logoff_dest -> :normal
_ -> :normal
end

{:ok, %{user: user, reason: reason, request: request}}
end

defp update_last_logon_time({:ok, %{user: user} = state}) do
# Because some pages specifically say "Eastern"
now = Timex.now() |> Timex.Timezone.convert("US/Eastern")

user
|> change(%{
prf_last_logon_date: Timex.format!(now, "{0M}/{0D}/{YYYY}"),
prf_last_logon_time: Timex.format!(now, "{h24}.{m}")
})
|> Repo.update()

# Close the connection
status = case reason do
"normal" -> :normal
"abnormal" -> :abnormal
_ -> :abnormal
last_logon_date = Timex.format!(now, "{0M}/{0D}/{YYYY}")
last_logon_time = Timex.format!(now, "{h24}.{m}")

result = Repo.transaction(fn ->
fetch_user_for_update(user.id)
|> change(%{
prf_last_logon_date: last_logon_date,
prf_last_logon_time: last_logon_time
})
|> Repo.update!()
end)

case result do
{:ok, _updated_user} ->
{:ok, state}
{:error, _reason} ->
# Log but don't fail the logoff
Logger.warning("Failed to update last logon time for user #{user.id}")
{:ok, state}
end
end

SessionManager.close_session(user_id, status)
Logger.info("User #{user_id} logged off (#{reason})")
:ok
defp end_session({:ok, %{user: user, reason: reason} = state}) do
session_status = case reason do
:normal -> :normal
:disconnect -> :normal
:abnormal -> :abnormal
end

SessionManager.close_session(user.id, session_status)

log_reason = case reason do
:disconnect -> "Disconnect"
:normal -> "Normal"
:abnormal -> "Abnormal"
end

Logger.info("User #{user.id} logged off (#{log_reason})")

{:ok, state}
end

def handle(%Fm0{} = request, %Context{user: nil}) do
{:ok, %Context{}, DiaPacket.encode(Fm0.make_response(<<>>, request))}
defp determine_result({:ok, %{reason: reason} = state}) do
result = case reason do
:disconnect -> :disconnect
_ -> :ok
end

{:ok, Map.put(state, :result, result)}
end

defp build_response({:ok, %{request: nil} = state}) do
# Abnormal termination - no response needed
{:ok, state}
end

def handle(%Fm0{dest: dest} = request, %Context{user: user}) do
{:ok, result} =
Repo.transaction(fn ->
clear_id_in_use(user.id)
defp build_response({:ok, %{request: request} = state}) do
# TODO: Determine what this timestamp should actually be
response_payload = <<Status.SUCCESS.value(), "xxxxxxxx01011988124510">>

case dest do
0x00D201 -> :disconnect
0x00D202 -> :ok
end
end)
response = response_payload
|> Fm0.make_response(request)
|> Packet.encode()

{result, %Context{auth_timeout: Context.set_auth_timer()},
DiaPacket.encode(Fm0.make_response(<<0, "xxxxxxxx01011988124510">>, request))}
{:ok, Map.put(state, :response, response)}
end

def handle_abnormal(nil) do
:ok
defp finalize_response({:ok, %{result: result, response: response}}, _context) do
new_context = %Context{auth_timeout: Context.set_auth_timer()}
{result, new_context, response}
end

def handle_abnormal(user) do
clear_id_in_use(user.id, "abnormal")
:ok
defp finalize_response({:ok, _state}, context) do
# Abnormal termination path - no response
{:ok, context, nil}
end

# Helper functions

defp fetch_user_for_update(user_id) do
from(u in User,
where: u.id == ^user_id,
lock: "FOR UPDATE"
)
|> Repo.one!()
end

defp build_empty_response(request) do
<<>>
|> Fm0.make_response(request)
|> Packet.encode()
end
end
end
Loading