Compare commits

..

2 Commits

Author SHA1 Message Date
Aaron Kimbrell
8372202d8f WIP working state 2026-02-26 09:56:11 -06:00
Aaron Kimbrell
f1847d1f20 WIP: basic server, no features 2026-01-25 22:33:51 -06:00
247 changed files with 10856 additions and 1884 deletions

View File

@@ -1,14 +1,14 @@
name: Docker
name: CI
on:
push:
branches:
- main
- "main"
tags:
- "v*.*.*"
pull_request:
branches:
- main
- "main"
env:
REGISTRY: ghcr.io
@@ -20,21 +20,15 @@ jobs:
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to the Container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -42,32 +36,21 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=pr
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=raw,value=canary,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Sign Docker image
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@@ -3,8 +3,6 @@ name: CI
on:
push:
branches: [ main ]
tags:
- "v*.*.*"
pull_request:
branches: [ main ]
@@ -12,51 +10,38 @@ jobs:
build-and-test:
name: Build & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
continue-on-error: ${{ github.event_name == 'pull_request' }}
continue-on-error: true
strategy:
matrix:
include:
- os: windows-2025
artifact: windows
debug_preset: windows-msvc-relwithdebinfo
- os: ubuntu-24.04
artifact: linux
debug_preset: linux-gnu-relwithdebinfo
- os: macos-15-intel
artifact: macos
debug_preset: macos-relwithdebinfo
os: [ windows-2022, ubuntu-22.04, macos-13 ]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
submodules: true
- name: Add msbuild to PATH (Windows only)
if: ${{ matrix.os == 'windows-2025' }}
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3
if: ${{ matrix.os == 'windows-2022' }}
uses: microsoft/setup-msbuild@767f00a3f09872d96a0cb9fcd5e6a4ff33311330
with:
vs-version: '[17,18)'
msbuild-architecture: x64
- name: Get CMake 3.x
uses: lukka/get-cmake@591817e96fcad43505fb4eae36172462abb3a42e # v4.3.3
with:
cmakeVersion: "~3.25.0"
- name: cmake
uses: lukka/run-cmake@5d55ea7949e25f69f0ecb516d8d572297e03a956 # v10.9
with:
workflowPreset: "${{ matrix.debug_preset }}"
- name: Extract Linux debug symbols
if: matrix.os == 'ubuntu-24.04'
- name: Install libssl and switch to XCode 15.2 (Mac Only)
if: ${{ matrix.os == 'macos-13' }}
run: |
find build -type f -name '*Server' | while read bin; do
objcopy --only-keep-debug "$bin" "${bin}.debug"
objcopy --strip-debug --add-gnu-debuglink="${bin}.debug" "$bin"
done
- name: artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
brew install openssl@3
sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer
- name: Get CMake 3.x
uses: lukka/get-cmake@28983e0d3955dba2bb0a6810caae0c6cf268ec0c
with:
name: build-${{matrix.artifact}}
cmakeVersion: "~3.25.0" # <--= optional, use most recent 3.25.x version
- name: cmake
uses: lukka/run-cmake@67c73a83a46f86c4e0b96b741ac37ff495478c38
with:
workflowPreset: "ci-${{matrix.os}}"
- name: artifacts
uses: actions/upload-artifact@6027e3dd177782cd8ab9af838c04fd81a07f1d47
with:
name: build-${{matrix.os}}
path: |
build/*/*Server*
build/*/*.ini
@@ -67,30 +52,5 @@ jobs:
build/*/navmeshes/
build/*/migrations/
build/*/*.dcf
!build/*/*.pdb
!build/*/d*/
!build/*/*.dSYM/
!build/**/*.debug
- name: debug symbols (Windows)
if: matrix.os == 'windows-2025'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-${{matrix.artifact}}
path: |
build/*/*.pdb
build/*/d*/
retention-days: 30
- name: debug symbols (Linux)
if: matrix.os == 'ubuntu-24.04'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-${{matrix.artifact}}
path: build/**/*.debug
retention-days: 30
- name: debug symbols (macOS)
if: matrix.os == 'macos-15-intel'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-${{matrix.artifact}}
path: build/**/*.dSYM/
retention-days: 30

View File

@@ -1,93 +0,0 @@
name: Canary
on:
workflow_run:
workflows: ["CI"]
branches: [main]
types: [completed]
permissions:
contents: write
actions: read
jobs:
canary-release:
name: Publish Canary Release
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Get last release tag
id: last_tag
run: |
tag=$(git describe --tags --abbrev=0 --match "v*.*.*" 2>/dev/null || echo "none")
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Generate changelog since last release tag
uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4.8.0
id: cliff
with:
config: cliff.toml
args: --unreleased --strip header
env:
OUTPUT: CHANGES.md
GITHUB_REPO: ${{ github.repository }}
- name: Prepend header to changelog
run: |
last="${{ steps.last_tag.outputs.tag }}"
sha="${{ github.event.workflow_run.head_sha }}"
short="${sha:0:7}"
if [ "$last" != "none" ]; then
header="Changes since **$last** ([full diff](https://github.com/${{ github.repository }}/compare/${last}...${sha}))\n\n"
else
header="Changes up to \`${short}\`\n\n"
fi
printf "%b" "$header" | cat - CHANGES.md > CHANGES.tmp && mv CHANGES.tmp CHANGES.md
- name: Download artifacts from CI run
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
run_id: ${{ github.event.workflow_run.id }}
path: artifacts/
- name: Package artifacts
run: |
declare -A platform_map=(
["build-windows"]="darkflame-universe-windows"
["build-linux"]="darkflame-universe-linux"
["build-macos"]="darkflame-universe-macos"
)
cd artifacts
for dir in build-*/; do
name="${dir%/}"
out="${platform_map[$name]:-$name}"
zip -r "../${out}.zip" "$dir"
done
cd ..
ls -lh *.zip
- name: Delete existing canary release
run: gh release delete canary --yes --cleanup-tag 2>/dev/null || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create canary pre-release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
with:
tag: canary
name: "Canary ${{ steps.last_tag.outputs.tag }}+${{ github.event.workflow_run.head_sha }}"
bodyFile: CHANGES.md
artifacts: "*.zip"
artifactContentType: application/zip
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: true
draft: false
allowUpdates: true
removeArtifacts: true
commit: ${{ github.event.workflow_run.head_sha }}

View File

@@ -1,38 +0,0 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: write
statuses: write
jobs:
check-title:
name: Conventional Commit Title
runs-on: ubuntu-latest
steps:
- name: Check PR title follows Conventional Commits
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
refactor
docs
chore
test
ci
revert
requireScope: false
subjectPattern: ^.+$
subjectPatternError: |
The PR title "{title}" must have a description after the type/scope prefix.
Example: "feat: add new login flow" or "fix(auth): handle null token"
wip: true
validateSingleCommit: false

View File

@@ -1,70 +0,0 @@
name: Release
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: write
actions: read
jobs:
release:
name: Create Release
# Only run when CI completed successfully on a tag push (not a PR branch named like a version)
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Generate changelog
uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4.8.0
id: cliff
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGES.md
GITHUB_REPO: ${{ github.repository }}
- name: Download artifacts from CI run
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
run_id: ${{ github.event.workflow_run.id }}
path: artifacts/
- name: Package artifacts
run: |
declare -A platform_map=(
["build-windows"]="darkflame-universe-windows"
["build-linux"]="darkflame-universe-linux"
["build-macos"]="darkflame-universe-macos"
)
cd artifacts
for dir in build-*/; do
name="${dir%/}"
out="${platform_map[$name]:-$name}"
zip -r "../${out}.zip" "$dir"
done
cd ..
ls -lh *.zip
- name: Create GitHub Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
with:
tag: ${{ github.event.workflow_run.head_branch }}
name: ${{ github.event.workflow_run.head_branch }}
bodyFile: CHANGES.md
artifacts: "*.zip"
artifactContentType: application/zip
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: false
draft: false

2
.gitignore vendored
View File

@@ -126,5 +126,3 @@ docker-compose.override.yml
# CMake scripts
!cmake/*
!cmake/toolchains/*
.mcp.json
.claude/

View File

@@ -15,11 +15,6 @@ set(CMAKE_C_STANDARD 99)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(CMAKE_VERSION VERSION_GREATER_EQUAL "4.0")
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
endif()
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # Export the compile commands for debugging
set(CMAKE_POLICY_DEFAULT_CMP0063 NEW) # Set CMAKE visibility policy to NEW on project and subprojects
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) # Set C and C++ symbol visibility to hide inlined functions
@@ -72,11 +67,7 @@ set(RECASTNAVIGATION_EXAMPLES OFF CACHE BOOL "" FORCE)
# Disabled no-register
# Disabled unknown pragmas because Linux doesn't understand Windows pragmas.
if(UNIX)
if(APPLE)
add_link_options("-Wl,-rpath,@loader_path/")
else()
add_link_options("-Wl,-rpath,$ORIGIN/")
endif()
add_link_options("-Wl,-rpath,$ORIGIN/")
add_compile_options("-fPIC")
add_compile_definitions(_GLIBCXX_USE_CXX11_ABI=0 _GLIBCXX_USE_CXX17_ABI=0)
@@ -119,6 +110,8 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
find_package(MariaDB)
find_package(OpenSSL REQUIRED)
# Create a /resServer directory
make_directory(${CMAKE_BINARY_DIR}/resServer)
@@ -135,7 +128,7 @@ endif()
message(STATUS "Variable: DLU_CONFIG_DIR = ${DLU_CONFIG_DIR}")
# Copy resource files on first build
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf")
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "dashboardconfig.ini" "blocklist.dcf")
message(STATUS "Checking resource file integrity")
include(Utils)
@@ -331,6 +324,7 @@ endif()
add_subdirectory(dWorldServer)
add_subdirectory(dAuthServer)
add_subdirectory(dChatServer)
add_subdirectory(dDashboardServer)
add_subdirectory(dMasterServer) # Add MasterServer last so it can rely on the other binaries
target_precompile_headers(

View File

@@ -162,13 +162,6 @@
"rhs": "Darwin"
},
"binaryDir": "${sourceDir}/build/macos"
},
{
"name": "macos-relwithdebinfo",
"inherits": ["macos", "relwithdebinfo-config"],
"displayName": "[RelWithDebInfo] MacOS",
"description": "Create a RelWithDebInfo build for MacOS",
"binaryDir": "${sourceDir}/build/macos-relwithdebinfo"
}
],
"buildPresets": [
@@ -262,7 +255,7 @@
{
"name": "macos-relwithdebinfo",
"inherits": "default",
"configurePreset": "macos-relwithdebinfo",
"configurePreset": "macos",
"displayName": "[RelWithDebInfo] MacOS",
"description": "This preset is used to build in release mode with debug info on MacOS",
"configuration": "RelWithDebInfo"
@@ -381,7 +374,7 @@
{
"name": "macos-relwithdebinfo",
"inherits": "default",
"configurePreset": "macos-relwithdebinfo",
"configurePreset": "macos",
"displayName": "[RelWithDebInfo] MacOS",
"description": "Runs all tests on a MacOS configuration",
"configuration": "RelWithDebInfo"
@@ -610,7 +603,7 @@
"steps": [
{
"type": "configure",
"name": "macos-relwithdebinfo"
"name": "macos"
},
{
"type": "build",
@@ -623,7 +616,7 @@
]
},
{
"name": "ci-macos-15-intel",
"name": "ci-macos-13",
"displayName": "[Release] MacOS",
"description": "CI workflow preset for MacOS",
"steps": [

View File

@@ -16,9 +16,7 @@ RUN --mount=type=cache,target=/app/build,id=build-cache \
cd /app/build && \
cmake .. && \
make -j$(nproc --ignore 1) && \
cp -r /app/build/* /tmp/persisted-build/ && \
mkdir -p /tmp/persisted-build/mariadbcpp && \
cp /app/build/thirdparty/mariadb-connector-cpp/src/mariadb_connector_cpp-build/libmariadbcpp.so /tmp/persisted-build/mariadbcpp/
cp -r /app/build/* /tmp/persisted-build/
FROM debian:12 as runtime

View File

@@ -1,35 +0,0 @@
[changelog]
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/commit/{{ commit.id }}))\
{% if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){% endif %}
{% endfor %}
{% endfor %}
"""
footer = ""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^docs", group = "Documentation" },
{ message = "^chore", group = "Chores" },
{ message = "^test", group = "Testing" },
{ message = "^ci", group = "CI/CD" },
{ message = "^revert", group = "Reverts" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
skip_tags = "canary"
ignore_tags = ""
topo_order = false
sort_commits = "oldest"

View File

@@ -10,15 +10,14 @@ if(WIN32 AND NOT MARIADB_BUILD_SOURCE)
file(MAKE_DIRECTORY "${MARIADB_MSI_DIR}")
file(MAKE_DIRECTORY "${MARIADB_CONNECTOR_DIR}")
# These values track the published Windows MSI packages used by the prebuilt path.
# Keep the Connector/C++ package version aligned with the checked out submodule tag when possible.
# Go to https://mariadb.com/downloads/connectors/ to find the up-to-date URL parts.
set(MARIADB_CONNECTOR_C_VERSION "3.4.8")
set(MARIADB_CONNECTOR_C_BUCKET "4516894")
set(MARIADB_CONNECTOR_C_MD5 "50f6fc0c77b8d3bacbeac0126e179861")
set(MARIADB_CONNECTOR_CPP_VERSION "1.1.7")
set(MARIADB_CONNECTOR_CPP_BUCKET "4464908")
set(MARIADB_CONNECTOR_CPP_MD5 "08644a7ff084b5933325cadb904796e5")
# These values need to be updated whenever a new minor release replaces an old one
# Go to https://mariadb.com/downloads/connectors/ to find the up-to-date URL parts
set(MARIADB_CONNECTOR_C_VERSION "3.2.7")
set(MARIADB_CONNECTOR_C_BUCKET "2319651")
set(MARIADB_CONNECTOR_C_MD5 "f8636d733f1d093af9d4f22f3239f885")
set(MARIADB_CONNECTOR_CPP_VERSION "1.0.2")
set(MARIADB_CONNECTOR_CPP_BUCKET "2531525")
set(MARIADB_CONNECTOR_CPP_MD5 "3034bbd6ca00a0125345f9fd1a178401")
set(MARIADB_CONNECTOR_C_MSI "mariadb-connector-c-${MARIADB_CONNECTOR_C_VERSION}-win64.msi")
set(MARIADB_CONNECTOR_CPP_MSI "mariadb-connector-cpp-${MARIADB_CONNECTOR_CPP_VERSION}-win64.msi")
@@ -80,39 +79,23 @@ else() # Build from source
-DWITH_EXTERNAL_ZLIB=ON
-DOPENSSL_ROOT_DIR=${OPENSSL_ROOT_DIR}
-DCMAKE_C_FLAGS=-w # disable zlib warnings
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0\ -Wno-inconsistent-missing-override\ -include\ cstdint)
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0)
else()
set(MARIADB_EXTRA_CMAKE_ARGS
-DCMAKE_C_FLAGS=-w # disable zlib warnings
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0\ -include\ cstdint)
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0)
endif()
set(MARIADBCPP_BUILD_DIR "${PROJECT_BINARY_DIR}/thirdparty/mariadb-connector-cpp/src/mariadb_connector_cpp-build")
set(MARIADBCPP_INSTALL_DIR ${PROJECT_BINARY_DIR}/prefix)
set(MARIADBCPP_LIBRARY_DIR ${PROJECT_BINARY_DIR}/mariadbcpp)
set(MARIADBCPP_PLUGIN_DIR ${MARIADBCPP_LIBRARY_DIR}/plugin)
set(MARIADBCPP_SOURCE_DIR ${PROJECT_SOURCE_DIR}/thirdparty/mariadb-connector-cpp)
set(MARIADB_INCLUDE_DIR "${MARIADBCPP_SOURCE_DIR}/include")
if(WIN32)
set(MARIADBCPP_LIBRARY_DIR ${PROJECT_BINARY_DIR}/mariadbcpp)
set(MARIADBCPP_PLUGIN_DIR ${MARIADBCPP_LIBRARY_DIR}/plugin)
set(MARIADB_INSTALL_COMMAND)
else()
set(MARIADBCPP_LIBRARY_DIR ${MARIADBCPP_BUILD_DIR})
set(MARIADBCPP_PLUGIN_DIR ${MARIADBCPP_BUILD_DIR}/libmariadb)
set(MARIADB_INSTALL_COMMAND INSTALL_COMMAND ${CMAKE_COMMAND} -E true)
endif()
if(CMAKE_VERSION VERSION_GREATER_EQUAL "4.0")
set(MARIADB_POLICY_VERSION_ARG -DCMAKE_POLICY_VERSION_MINIMUM=3.5)
endif()
ExternalProject_Add(mariadb_connector_cpp
PREFIX "${PROJECT_BINARY_DIR}/thirdparty/mariadb-connector-cpp"
SOURCE_DIR ${MARIADBCPP_SOURCE_DIR}
BINARY_DIR ${MARIADBCPP_BUILD_DIR}
INSTALL_DIR ${MARIADBCPP_INSTALL_DIR}
CMAKE_ARGS -Wno-dev
${MARIADB_POLICY_VERSION_ARG}
-DWITH_UNIT_TESTS=OFF
-DMARIADB_LINK_DYNAMIC=OFF
-DCMAKE_BUILD_RPATH_USE_ORIGIN=${CMAKE_BUILD_RPATH_USE_ORIGIN}
@@ -120,7 +103,6 @@ else() # Build from source
-DINSTALL_LIBDIR=${MARIADBCPP_LIBRARY_DIR}
-DINSTALL_PLUGINDIR=${MARIADBCPP_PLUGIN_DIR}
${MARIADB_EXTRA_CMAKE_ARGS}
${MARIADB_INSTALL_COMMAND}
BUILD_ALWAYS true
)

View File

@@ -3,7 +3,6 @@
#include "MessageType/Chat.h"
#include "BitStreamUtils.h"
#include "Game.h"
#include "dConfig.h"
#include "Logger.h"
#include "eObjectBits.h"
@@ -73,8 +72,8 @@ void ChatIgnoreList::AddIgnore(Packet* packet) {
return;
}
const int32_t MAX_IGNORES = Game::config->GetValue("max_ignores", 32);
if (receiver.ignoredPlayers.size() >= MAX_IGNORES) {
constexpr int32_t MAX_IGNORES = 32;
if (receiver.ignoredPlayers.size() > MAX_IGNORES) {
LOG_DEBUG("Player %llu has too many ignores", playerId);
return;
}

View File

@@ -435,11 +435,6 @@ void ChatPacketHandler::HandleChatMessage(Packet* packet) {
inStream.IgnoreBytes(4);
inStream.Read(channel);
inStream.Read(size);
if (size > MAX_MESSAGE_LENGTH) {
LOG("Received a probably spoofed chat message, ignoring msg");
return;
}
inStream.IgnoreBytes(77);
LUWString message(size);
@@ -484,11 +479,6 @@ void ChatPacketHandler::HandlePrivateChatMessage(Packet* packet) {
if (channel != eChatChannel::PRIVATE_CHAT) LOG("WARNING: Received Private chat with the wrong channel!");
inStream.Read(size);
if (size > MAX_MESSAGE_LENGTH) {
LOG("Received a probably spoofed chat message, ignoring msg");
return;
}
inStream.IgnoreBytes(77);
inStream.Read(LUReceiverName);

View File

@@ -19,23 +19,24 @@
#include "eGameMasterLevel.h"
#include "dChatFilter.h"
#include "TeamContainer.h"
#include "HTTPContext.h"
using json = nlohmann::json;
void HandleHTTPPlayersRequest(HTTPReply& reply, std::string body) {
void HandleHTTPPlayersRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = Game::playerContainer;
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump();
}
void HandleHTTPTeamsRequest(HTTPReply& reply, std::string body) {
void HandleHTTPTeamsRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = TeamContainer::GetTeamContainer();
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump();
}
void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
auto data = GeneralUtils::TryParse<json>(body);
void HandleHTTPAnnounceRequest(HTTPReply& reply, const HTTPContext& context) {
auto data = GeneralUtils::TryParse<json>(context.body);
if (!data) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
@@ -96,18 +97,21 @@ namespace ChatWeb {
Game::web.RegisterHTTPRoute({
.path = v1_route + "players",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPPlayersRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "teams",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPTeamsRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "announce",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = HandleHTTPAnnounceRequest
});

View File

@@ -176,7 +176,6 @@ LWOOBJID PlayerContainer::GetId(const std::u16string& playerName) {
return toReturn;
}
// TODO Make this a pointer again or do something to make it so you cant edit the LWOOBJID_EMPTY entry? it should be ignored in all cases anyways though...
PlayerData& PlayerContainer::GetPlayerDataMutable(const LWOOBJID& playerID) {
return m_Players.contains(playerID) ? m_Players[playerID] : m_Players[LWOOBJID_EMPTY];
}

View File

@@ -3,7 +3,6 @@
#include <stdexcept>
#include "Amf3.h"
#include "StringifiedEnum.h"
/**
* AMF3 Reference document https://rtmp.veriskope.com/pdf/amf3-file-format-spec.pdf
@@ -54,7 +53,7 @@ std::unique_ptr<AMFBaseValue> AMFDeserialize::Read(RakNet::BitStream& inStream)
case eAmf::VectorObject:
[[fallthrough]];
case eAmf::Dictionary:
throw std::invalid_argument(StringifiedEnum::ToString(marker).data());
throw marker;
default:
throw std::invalid_argument("Invalid AMF3 marker" + std::to_string(static_cast<int32_t>(marker)));
}
@@ -89,11 +88,6 @@ const std::string AMFDeserialize::ReadString(RakNet::BitStream& inStream) {
// Right shift by 1 bit to get index if reference or size of next string if value
length = length >> 1;
if (isReference) {
constexpr int32_t maxStringSize = 1024 * 1024;
if (length > maxStringSize) {
LOG("1MB string attempted to be allocated in AMF deserialize, possible spoof, aborting deserialize.");
throw std::invalid_argument("1MB string attempted to be allocated in AMF deserialize, possible spoof, aborting deserialize.");
}
std::string value(length, 0);
inStream.Read(&value[0], length);
// Empty strings are never sent by reference
@@ -123,12 +117,6 @@ std::unique_ptr<AMFArrayValue> AMFDeserialize::ReadAmfArray(RakNet::BitStream& i
if (key.size() == 0) break;
arrayValue->Insert(key, Read(inStream));
}
constexpr int32_t maxArraySize = 10'000;
if (sizeOfDenseArray > maxArraySize) {
LOG("Someone sent 10,000 dense array entries, probably a bad packet.");
throw std::invalid_argument("Someone sent 10,000 dense array entries, probably a bad packet.");
}
// Finally read dense portion
for (uint32_t i = 0; i < sizeOfDenseArray; i++) {
arrayValue->Insert(i, Read(inStream));

View File

@@ -52,7 +52,8 @@ uint32_t BrickByBrickFix::TruncateBrokenBrickByBrickXml() {
if (actualUncompressedSize != -1) {
uint32_t previousSize = completeUncompressedModel.size();
completeUncompressedModel.append(reinterpret_cast<char*>(uncompressedChunk.get()), actualUncompressedSize);
completeUncompressedModel.append(reinterpret_cast<char*>(uncompressedChunk.get()));
completeUncompressedModel.resize(previousSize + actualUncompressedSize);
} else {
LOG("Failed to inflate chunk %i for model %llu. Error: %i", chunkCount, model.id, err);
break;

View File

@@ -308,9 +308,8 @@ std::vector<std::string> GeneralUtils::GetSqlFileNamesFromFolder(const std::stri
for (const auto& t : std::filesystem::directory_iterator(folder)) {
if (t.is_directory() || t.is_symlink()) continue;
auto filename = t.path().filename().string();
// Ensure the file has a name in the format of xxxxxxxx_anything_goes_here.sql
const auto migrationNumber = TryParse<uint32_t>(GeneralUtils::SplitString(filename, '_').at(0));
if (migrationNumber.has_value()) filenames.emplace(migrationNumber.value(), std::move(filename));
const auto index = std::stoi(GeneralUtils::SplitString(filename, '_').at(0));
filenames.emplace(index, std::move(filename));
}
// Now sort the map by the oldest migration.

View File

@@ -205,12 +205,6 @@ namespace GeneralUtils {
return isParsed ? static_cast<T>(result) : std::optional<T>{};
}
// A version of TryParse that will return `errorVal` if `str` failed to parse.
template <Numeric T>
[[nodiscard]] T TryParse(std::string_view str, const T errorVal) {
return TryParse<T>(str).value_or(errorVal);
}
template<typename T>
requires(!Numeric<T>)
[[nodiscard]] std::optional<T> TryParse(std::string_view str);
@@ -243,12 +237,6 @@ namespace GeneralUtils {
return std::nullopt;
}
// A version of TryParse that will return `errorVal` if `str` failed to parse.
template <std::floating_point T>
[[nodiscard]] T TryParse(std::string_view str, const T errorVal) {
return TryParse<T>(str).value_or(errorVal);
}
#endif
/**
@@ -270,11 +258,6 @@ namespace GeneralUtils {
return z ? std::make_optional<T>(x.value(), y.value(), z.value()) : std::nullopt;
}
// Alternative overload of TryParse with a default value
[[nodiscard]] inline NiPoint3 TryParse(const std::string_view strX, const std::string_view strY, const std::string_view strZ, const NiPoint3 errorVal) {
return TryParse<NiPoint3>(strX, strY, strZ).value_or(errorVal);
}
/**
* The TryParse overload for handling NiPoint3 by passing a span of three strings
* @param str The string vector representing the X, Y, and Z coordinates
@@ -285,11 +268,6 @@ namespace GeneralUtils {
return (str.size() == 3) ? TryParse<T>(str[0], str[1], str[2]) : std::nullopt;
}
// Alternative overload of TryParse with a default value
[[nodiscard]] inline NiPoint3 TryParse(const std::span<const std::string> str, const NiPoint3 errorVal) {
return TryParse<NiPoint3>(str).value_or(errorVal);
}
template <typename T>
std::u16string to_u16string(const T value) {
return GeneralUtils::ASCIIToUTF16(std::to_string(value));

View File

@@ -53,21 +53,16 @@ Lxfml::Result Lxfml::NormalizePositionOnlyFirstPart(const std::string_view data)
continue;
}
try {
auto x = GeneralUtils::TryParse<float>(split[9]).value();
auto y = GeneralUtils::TryParse<float>(split[10]).value();
auto z = GeneralUtils::TryParse<float>(split[11]).value();
if (x < lowest.x) lowest.x = x;
if (y < lowest.y) lowest.y = y;
if (z < lowest.z) lowest.z = z;
auto x = GeneralUtils::TryParse<float>(split[9]).value();
auto y = GeneralUtils::TryParse<float>(split[10]).value();
auto z = GeneralUtils::TryParse<float>(split[11]).value();
if (x < lowest.x) lowest.x = x;
if (y < lowest.y) lowest.y = y;
if (z < lowest.z) lowest.z = z;
if (highest.x < x) highest.x = x;
if (highest.y < y) highest.y = y;
if (highest.z < z) highest.z = z;
} catch (std::exception& e) {
LOG("Failed to parse a split value of either (%s), (%s), or (%s).", split[9].c_str(), split[10].c_str(), split[11].c_str());
return toReturn; // Early return since we failed to parse this lxfml.
}
if (highest.x < x) highest.x = x;
if (highest.y < y) highest.y = y;
if (highest.z < z) highest.z = z;
}
auto delta = (highest - lowest) / 2.0f;

View File

@@ -45,12 +45,6 @@ Sd0::Sd0(std::istream& buffer) {
uint32_t bufferSize = buffer.tellg();
buffer.seekg(0, std::ios::beg);
WriteSize(firstChunk, bufferSize);
// its expected that if we got here, we got an old sd0 buffer where we ignored the sd0 part
// that means this can be at most the compressed chunk limit.
if (bufferSize > MAX_UNCOMPRESSED_CHUNK_SIZE) {
LOG("Possible bad chunk size of %i specified, rejecting.", bufferSize);
return;
}
firstChunk.resize(firstChunk.size() + bufferSize);
auto* dataStart = reinterpret_cast<char*>(firstChunk.data() + GetDataOffset(true));
if (!buffer.read(dataStart, bufferSize)) {
@@ -77,12 +71,6 @@ Sd0::Sd0(std::istream& buffer) {
WriteSize(chunk, chunkSize);
// Assuming a good buffer that is large enough to take up 2 zlib buffers
// any buffer should be compressed enough to take up less size than its uncompressed counterpart
if (chunkSize > MAX_UNCOMPRESSED_CHUNK_SIZE) {
LOG("Possible bad chunk size of %i specified, rejecting.", chunkSize);
break;
}
chunk.resize(chunkSize + dataOffset);
auto* dataStart = reinterpret_cast<char*>(chunk.data() + dataOffset);
if (!buffer.read(dataStart, chunkSize)) {
@@ -107,11 +95,6 @@ void Sd0::FromData(const uint8_t* data, size_t bufferSize) {
startOffset, numToCopy,
compressedChunk.data(), compressedChunk.size());
if (compressedSize == -1) {
LOG("Failed to compress chunk, aborting");
break;
}
auto& chunk = m_Chunks.emplace_back();
bool firstBuffer = m_Chunks.size() == 1;
auto dataOffset = GetDataOffset(firstBuffer);
@@ -136,12 +119,6 @@ std::string Sd0::GetAsStringUncompressed() const {
auto dataOffset = GetDataOffset(first);
first = false;
const auto chunkSize = chunk.size();
if (chunkSize <= static_cast<size_t>(dataOffset)) {
LOG("Bad chunkSize for data, aborting");
toReturn = "";
totalSize = 0;
break;
}
auto oldSize = toReturn.size();
toReturn.resize(oldSize + MAX_UNCOMPRESSED_CHUNK_SIZE);
@@ -151,13 +128,6 @@ std::string Sd0::GetAsStringUncompressed() const {
reinterpret_cast<uint8_t*>(toReturn.data()) + oldSize, MAX_UNCOMPRESSED_CHUNK_SIZE,
error);
if (uncompressedSize == -1) {
LOG("Failed to decompress chunk, aborting");
toReturn = "";
totalSize = 0;
break;
}
totalSize += uncompressedSize;
}

View File

@@ -3,12 +3,12 @@
#include "zlib.h"
namespace ZCompression {
uint32_t GetMaxCompressedLength(uint32_t nLenSrc) {
uint32_t n16kBlocks = (nLenSrc + 16383) / 16384; // round up any fraction of a block
int32_t GetMaxCompressedLength(int32_t nLenSrc) {
int32_t n16kBlocks = (nLenSrc + 16383) / 16384; // round up any fraction of a block
return (nLenSrc + 6 + (n16kBlocks * 5));
}
int32_t Compress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst) {
int32_t Compress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst) {
z_stream zInfo = { 0 };
zInfo.total_in = zInfo.avail_in = nLenSrc;
zInfo.total_out = zInfo.avail_out = nLenDst;
@@ -27,7 +27,7 @@ namespace ZCompression {
return(nRet);
}
int32_t Decompress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst, int32_t& nErr) {
int32_t Decompress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst, int32_t& nErr) {
// Get the size of the decompressed data
z_stream zInfo = { 0 };
zInfo.total_in = zInfo.avail_in = nLenSrc;

View File

@@ -3,10 +3,10 @@
#include <cstdint>
namespace ZCompression {
uint32_t GetMaxCompressedLength(uint32_t nLenSrc);
int32_t GetMaxCompressedLength(int32_t nLenSrc);
int32_t Compress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst);
int32_t Compress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst);
int32_t Decompress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst, int32_t& nErr);
int32_t Decompress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst, int32_t& nErr);
}

View File

@@ -7,7 +7,7 @@
#include "zlib.h"
constexpr uint32_t CRC32_INIT = 0xFFFFFFFF;
constexpr auto NULL_TERMINATOR = std::string_view{ "\0\0\0", 4 };
constexpr auto NULL_TERMINATOR = std::string_view{"\0\0\0", 4};
AssetManager::AssetManager(const std::filesystem::path& path) {
if (!std::filesystem::is_directory(path)) {
@@ -25,7 +25,7 @@ AssetManager::AssetManager(const std::filesystem::path& path) {
if (!std::filesystem::exists(m_Path / ".." / "versions")) {
throw std::runtime_error("No \"versions\" directory found in the parent directories of \"res\" - packed asset bundle cannot be loaded.");
}
m_AssetBundleType = eAssetBundleType::Packed;
m_RootPath = (m_Path / "..");
@@ -34,7 +34,7 @@ AssetManager::AssetManager(const std::filesystem::path& path) {
if (!std::filesystem::exists(m_Path / ".." / ".." / "versions")) {
throw std::runtime_error("No \"versions\" directory found in the parent directories of \"res\" - packed asset bundle cannot be loaded.");
}
m_AssetBundleType = eAssetBundleType::Packed;
m_RootPath = (m_Path / ".." / "..");
@@ -54,15 +54,15 @@ AssetManager::AssetManager(const std::filesystem::path& path) {
}
switch (m_AssetBundleType) {
case eAssetBundleType::Packed: {
this->LoadPackIndex();
break;
}
case eAssetBundleType::None:
[[fallthrough]];
case eAssetBundleType::Unpacked: {
break;
}
case eAssetBundleType::Packed: {
this->LoadPackIndex();
break;
}
case eAssetBundleType::None:
[[fallthrough]];
case eAssetBundleType::Unpacked: {
break;
}
}
}
@@ -79,7 +79,7 @@ bool AssetManager::HasFile(std::string fixedName) const {
std::replace(fixedName.begin(), fixedName.end(), '\\', '/');
if (std::filesystem::exists(m_ResPath / fixedName)) return true;
if (this->m_AssetBundleType == eAssetBundleType::Unpacked || !m_PackIndex) return false;
if (this->m_AssetBundleType == eAssetBundleType::Unpacked) return false;
std::replace(fixedName.begin(), fixedName.end(), '/', '\\');
if (fixedName.rfind("client\\res\\", 0) != 0) fixedName = "client\\res\\" + fixedName;
@@ -145,12 +145,8 @@ bool AssetManager::GetFile(std::string fixedName, char** data, uint32_t* len) co
}
const auto& pack = this->m_PackIndex->GetPacks().at(packIndex);
bool success = false;
try {
success = pack.ReadFileFromPack(crc, data, len);
} catch (std::exception& e) {
LOG("Failed to read file %s from pack file", fixedName.c_str());
}
const bool success = pack.ReadFileFromPack(crc, data, len);
return success;
}

View File

@@ -46,7 +46,6 @@ bool Pack::HasFile(const uint32_t crc) const {
}
bool Pack::ReadFileFromPack(const uint32_t crc, char** data, uint32_t* len) const {
const auto pathStr = m_FilePath.string();
// Time for some wacky C file reading for speed reasons
PackRecord pkRecord{};
@@ -66,21 +65,16 @@ bool Pack::ReadFileFromPack(const uint32_t crc, char** data, uint32_t* len) cons
bool isCompressed = (pkRecord.m_IsCompressed & 0xff) > 0;
auto inPackSize = isCompressed ? pkRecord.m_CompressedSize : pkRecord.m_UncompressedSize;
FILE* file = nullptr;
FILE* file;
#ifdef _WIN32
fopen_s(&file, pathStr.c_str(), "rb");
fopen_s(&file, m_FilePath.string().c_str(), "rb");
#elif __APPLE__
// macOS has 64bit file IO by default
file = fopen(pathStr.c_str(), "rb");
file = fopen(m_FilePath.string().c_str(), "rb");
#else
file = fopen64(pathStr.c_str(), "rb");
file = fopen64(m_FilePath.string().c_str(), "rb");
#endif
if (!file) {
LOG("No file found for path %s", pathStr.c_str());
throw std::runtime_error("Could not find file " + pathStr);
}
fseek(file, pos, SEEK_SET);
if (!isCompressed) {
@@ -108,18 +102,14 @@ bool Pack::ReadFileFromPack(const uint32_t crc, char** data, uint32_t* len) cons
int32_t readInData = fread(&size, sizeof(uint32_t), 1, file);
pos += 4; // Move pointer position 4 to the right
std::unique_ptr<char[]> chunk(new char[size]);
int32_t readInData2 = fread(chunk.get(), sizeof(int8_t), size, file);
char* chunk = static_cast<char*>(malloc(size));
int32_t readInData2 = fread(chunk, sizeof(int8_t), size, file);
pos += size; // Move pointer position the amount of bytes read to the right
int32_t err;
const auto countToRead = ZCompression::Decompress(reinterpret_cast<uint8_t*>(chunk.get()), size, reinterpret_cast<uint8_t*>(decompressedData + currentReadPos), Sd0::MAX_UNCOMPRESSED_CHUNK_SIZE, err);
if (countToRead == -1) {
LOG("Error decompressing zlib data from file %s", pathStr.c_str());
throw std::runtime_error("Error decompressing zlib data from file " + pathStr);
}
currentReadPos += countToRead;
currentReadPos += ZCompression::Decompress(reinterpret_cast<uint8_t*>(chunk), size, reinterpret_cast<uint8_t*>(decompressedData + currentReadPos), Sd0::MAX_UNCOMPRESSED_CHUNK_SIZE, err);
free(chunk);
}
*data = decompressedData;

View File

@@ -84,7 +84,3 @@ void dConfig::ProcessLine(const std::string& line) {
this->m_ConfigValues.insert(std::make_pair(key, value));
}
std::string dConfig::GetValue(const std::string& key, const char* emptyValue) {
return GetValue(key, std::string(emptyValue));
};

View File

@@ -5,8 +5,6 @@
#include <map>
#include <string>
#include "GeneralUtils.h"
class dConfig {
public:
dConfig(const std::string& filepath);
@@ -24,14 +22,6 @@ public:
*/
const std::string& GetValue(std::string key);
// Gets a value from the config and returns the parsed value, or the default value should parsing have failed.
template<typename T>
T GetValue(const std::string& key, const T emptyValue = T()) {
return GeneralUtils::TryParse<T>(GetValue(key)).value_or(emptyValue);
}
std::string GetValue(const std::string& key, const char* emptyValue);
/**
* Loads the config from a file
*/
@@ -53,9 +43,3 @@ private:
std::vector<std::function<void()>> m_ConfigHandlers;
std::string m_ConfigFilePath;
};
template<>
inline std::string dConfig::GetValue(const std::string& key, const std::string emptyValue) {
const auto& value = GetValue(key);
return value.empty() ? emptyValue : value;
};

View File

@@ -27,6 +27,8 @@ namespace MessageType {
AFFIRM_TRANSFER_REQUEST,
AFFIRM_TRANSFER_RESPONSE,
NEW_SESSION_ALERT
NEW_SESSION_ALERT,
REQUEST_SERVER_LIST
};
}

View File

@@ -5,7 +5,8 @@ enum class ServiceType : uint16_t {
COMMON = 0,
AUTH,
CHAT,
WORLD = 4,
DASHBOARD,
WORLD,
CLIENT,
MASTER,
UNKNOWN

View File

@@ -16,8 +16,8 @@
// These are the same define, but they mean two different things in different contexts
// so a different define to distinguish what calculation is happening will help clarity.
#define FRAMES_TO_MS(x) (1000 / (x))
#define MS_TO_FRAMES(x) (1000 / (x))
#define FRAMES_TO_MS(x) 1000 / x
#define MS_TO_FRAMES(x) 1000 / x
//=========== FRAME TIMINGS ===========
constexpr uint32_t highFramerate = 60;
@@ -58,7 +58,6 @@ constexpr LWOCLONEID LWOCLONEID_INVALID = -1; //!< Invalid LWOCLONEID
constexpr LWOINSTANCEID LWOINSTANCEID_INVALID = -1; //!< Invalid LWOINSTANCEID
constexpr LWOMAPID LWOMAPID_INVALID = -1; //!< Invalid LWOMAPID
constexpr uint64_t LWOZONEID_INVALID = 0; //!< Invalid LWOZONEID
constexpr uint32_t MAX_MESSAGE_LENGTH = 0x500000; //!< Prevent exceptionally large msgs from being processed. Should always be used to check user provided inputs.
constexpr float PI = 3.14159f;

View File

@@ -20,8 +20,7 @@ enum class eCharacterVersion : uint32_t {
NJ_JAYMISSIONS,
NEXUS_FORCE_EXPLORER, // Fixes pet ids in player inventories
PET_IDS, // Fixes pet ids in player inventories
INVENTORY_PERSISTENT_IDS, // Fixes racing meta missions
UP_TO_DATE, // will become RACING_META_MISSIONS
UP_TO_DATE, // will become INVENTORY_PERSISTENT_IDS
};
#endif //!__ECHARACTERVERSION__H__

View File

@@ -0,0 +1,58 @@
set(DDASHBOARDSERVER_SOURCES
"DashboardServer.cpp"
)
add_subdirectory(routes)
add_subdirectory(auth)
add_executable(DashboardServer ${DDASHBOARDSERVER_SOURCES})
target_include_directories(DashboardServer PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
)
target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto DashboardRoutes DashboardAuth)
# Copy static files and templates to build directory (always copy)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Removing old static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/static
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Copying DashboardServer static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Removing old templates"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/templates
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Copying DashboardServer templates"
)

View File

@@ -0,0 +1,203 @@
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <thread>
#include <csignal>
#include <memory>
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "Database.h"
#include "dConfig.h"
#include "Logger.h"
#include "dServer.h"
#include "AssetManager.h"
#include "BinaryPathFinder.h"
#include "ServiceType.h"
#include "MessageType/Master.h"
#include "Game.h"
#include "BitStreamUtils.h"
#include "Diagnostics.h"
#include "Web.h"
#include "Server.h"
#include "ServerState.h"
#include "APIRoutes.h"
#include "StaticRoutes.h"
#include "DashboardRoutes.h"
#include "WSRoutes.h"
#include "AuthRoutes.h"
#include "AuthMiddleware.h"
namespace Game {
Logger* logger = nullptr;
dServer* server = nullptr;
dConfig* config = nullptr;
Game::signal_t lastSignal = 0;
std::mt19937 randomEngine;
}
// Define global server state
namespace ServerState {
ServerStatus g_AuthStatus{};
ServerStatus g_ChatStatus{};
std::vector<WorldInstanceInfo> g_WorldInstances{};
std::mutex g_StatusMutex{};
}
namespace {
dServer* g_Server = nullptr;
bool g_RequestedServerList = false;
}
int main(int argc, char** argv) {
Diagnostics::SetProduceMemoryDump(true);
std::signal(SIGINT, Game::OnSignal);
std::signal(SIGTERM, Game::OnSignal);
uint32_t maxClients = 999;
uint32_t ourPort = 2006;
std::string ourIP = "127.0.0.1";
// Read config
Game::config = new dConfig("dashboardconfig.ini");
// Setup logger
Server::SetupLogger("DashboardServer");
if (!Game::logger) return EXIT_FAILURE;
Game::config->LogSettings();
LOG("Starting Dashboard Server");
// Load settings
if (Game::config->GetValue("max_clients") != "")
maxClients = std::stoi(Game::config->GetValue("max_clients"));
if (Game::config->GetValue("port") != "")
ourPort = std::atoi(Game::config->GetValue("port").c_str());
if (Game::config->GetValue("listen_ip") != "")
ourIP = Game::config->GetValue("listen_ip");
// Connect to CDClient database
try {
const std::string cdclientPath = BinaryPathFinder::GetBinaryDir() / "resServer/CDServer.sqlite";
CDClientDatabase::Connect(cdclientPath);
} catch (std::exception& ex) {
LOG("Failed to connect to CDClient database: %s", ex.what());
return EXIT_FAILURE;
}
// Connect to the database
try {
Database::Connect();
} catch (std::exception& ex) {
LOG("Failed to connect to the database: %s", ex.what());
return EXIT_FAILURE;
}
// Get master info from database
std::string masterIP = "localhost";
uint32_t masterPort = 1000;
std::string masterPassword;
auto masterInfo = Database::Get()->GetMasterInfo();
if (masterInfo) {
masterIP = masterInfo->ip;
masterPort = masterInfo->port;
masterPassword = masterInfo->password;
}
// Setup network server for communicating with Master
g_Server = new dServer(
masterIP,
ourPort,
0,
maxClients,
false,
false,
Game::logger,
masterIP,
masterPort,
ServiceType::DASHBOARD, // Connect as dashboard to master
Game::config,
&Game::lastSignal,
masterPassword
);
// Initialize web server
if (!Game::web.Startup(ourIP, ourPort)) {
LOG("Failed to start web server on %s:%d", ourIP.c_str(), ourPort);
return EXIT_FAILURE;
}
// Register global middleware
Game::web.AddGlobalMiddleware(std::make_shared<AuthMiddleware>());
// Register routes in order: API, Static, Auth, WebSocket, Dashboard (dashboard MUST be last)
RegisterAPIRoutes();
RegisterStaticRoutes();
RegisterAuthRoutes();
RegisterWSRoutes();
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes
LOG("Dashboard Server started successfully on %s:%d", ourIP.c_str(), ourPort);
LOG("Connected to Master Server at %s:%d", masterIP.c_str(), masterPort);
// Main loop
auto lastTime = std::chrono::high_resolution_clock::now();
auto lastBroadcast = lastTime;
auto currentTime = lastTime;
constexpr float deltaTime = 1.0f / 60.0f; // 60 FPS
constexpr float broadcastInterval = 2000.0f; // Broadcast every 2 seconds
while (!Game::ShouldShutdown()) {
currentTime = std::chrono::high_resolution_clock::now();
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastTime).count();
const auto elapsedSinceBroadcast = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastBroadcast).count();
if (elapsed >= 1000.0f / 60.0f) {
// // Handle master server packets
// Packet* packet = g_Server->ReceiveFromMaster();
// if (packet) {
// RakNet::BitStream bitStream(packet->data, packet->length, false);
// PacketHandler::HandlePacket(bitStream, packet->systemAddress);
// g_Server->DeallocateMasterPacket(packet);
// }
// // Handle RakNet protocol packets from connected servers
// packet = g_Server->Receive();
// while (packet) {
// RakNet::BitStream bitStream(packet->data, packet->length, false);
// PacketHandler::HandlePacket(bitStream, packet->systemAddress);
// g_Server->DeallocatePacket(packet);
// packet = g_Server->Receive();
// }
// Handle web requests
Game::web.ReceiveRequests();
// Broadcast dashboard updates periodically
if (elapsedSinceBroadcast >= broadcastInterval) {
BroadcastDashboardUpdate();
lastBroadcast = currentTime;
}
lastTime = currentTime;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Cleanup
Database::Destroy("DashboardServer");
delete g_Server;
g_Server = nullptr;
delete Game::logger;
Game::logger = nullptr;
delete Game::config;
Game::config = nullptr;
return EXIT_SUCCESS;
}

View File

@@ -0,0 +1,132 @@
#include "AuthMiddleware.h"
#include "DashboardAuthService.h"
#include "Game.h"
#include "Logger.h"
#include <string>
#include <cctype>
// Helper to extract cookie value from header
static std::string ExtractCookieValue(const std::string& cookieHeader, const std::string& cookieName) {
std::string searchStr = cookieName + "=";
size_t pos = cookieHeader.find(searchStr);
if (pos == std::string::npos) {
return "";
}
size_t valueStart = pos + searchStr.length();
size_t valueEnd = cookieHeader.find(";", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = cookieHeader.length();
}
std::string value = cookieHeader.substr(valueStart, valueEnd - valueStart);
// URL decode the value
std::string decoded;
for (size_t i = 0; i < value.length(); ++i) {
if (value[i] == '%' && i + 2 < value.length()) {
std::string hex = value.substr(i + 1, 2);
char* endptr;
int charCode = static_cast<int>(std::strtol(hex.c_str(), &endptr, 16));
if (endptr - hex.c_str() == 2) {
decoded += static_cast<char>(charCode);
i += 2;
continue;
}
}
decoded += value[i];
}
return decoded;
}
std::string AuthMiddleware::ExtractTokenFromQueryString(const std::string& queryString) {
if (queryString.empty()) {
return "";
}
// Parse query string to find token parameter
// Expected format: "?token=eyJhbGc..."
std::string tokenPrefix = "token=";
size_t tokenPos = queryString.find(tokenPrefix);
if (tokenPos == std::string::npos) {
return "";
}
// Extract token value (from "token=" to next "&" or end of string)
size_t valueStart = tokenPos + tokenPrefix.length();
size_t valueEnd = queryString.find("&", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = queryString.length();
}
return queryString.substr(valueStart, valueEnd - valueStart);
}
std::string AuthMiddleware::ExtractTokenFromCookies(const std::string& cookieHeader) {
if (cookieHeader.empty()) {
return "";
}
// Extract dashboardToken cookie value
return ExtractCookieValue(cookieHeader, "dashboardToken");
}
std::string AuthMiddleware::ExtractTokenFromAuthHeader(const std::string& authHeader) {
if (authHeader.empty()) {
return "";
}
// Check for "Bearer <token>" format
if (authHeader.substr(0, 7) == "Bearer ") {
return authHeader.substr(7);
}
// Check for "Token <token>" format
if (authHeader.substr(0, 6) == "Token ") {
return authHeader.substr(6);
}
// If no prefix, assume raw token
return authHeader;
}
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
// Try to extract token from various sources (in priority order)
std::string token = ExtractTokenFromQueryString(context.queryString);
if (token.empty()) {
const std::string& cookieHeader = context.GetHeader("Cookie");
token = ExtractTokenFromCookies(cookieHeader);
}
if (token.empty()) {
const std::string& authHeader = context.GetHeader("Authorization");
token = ExtractTokenFromAuthHeader(authHeader);
}
// If we found a token, try to verify it
if (!token.empty()) {
std::string username;
uint8_t gmLevel{};
if (DashboardAuthService::VerifyToken(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
LOG_DEBUG("User %s authenticated via API token (GM level %d)", username.c_str(), gmLevel);
return true;
} else {
LOG_DEBUG("Invalid authentication token provided");
return true; // Continue - let routes decide if auth is required
}
}
// No token found - continue without authentication
// Routes can use RequireAuthMiddleware to enforce authentication
return true;
}

View File

@@ -0,0 +1,34 @@
#ifndef __AUTHMIDDLEWARE_H__
#define __AUTHMIDDLEWARE_H__
#include <string>
#include <memory>
#include "IHTTPMiddleware.h"
/**
* AuthMiddleware: Extracts and verifies authentication tokens
*
* Token extraction sources (in priority order):
* 1. Query parameter: ?token=eyJhbGc...
* 2. Cookie: dashboardToken=...
* 3. Authorization header: Bearer <token> or Token <token>
*
* Sets HTTPContext.isAuthenticated, HTTPContext.authenticatedUser,
* and HTTPContext.gmLevel if token is valid.
*/
class AuthMiddleware final : public IHTTPMiddleware {
public:
AuthMiddleware() = default;
~AuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "AuthMiddleware"; }
private:
// Extract token from various sources
static std::string ExtractTokenFromQueryString(const std::string& queryString);
static std::string ExtractTokenFromCookies(const std::string& cookieHeader);
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
};
#endif // !__AUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,28 @@
set(DASHBOARDAUTH_SOURCES
"JWTUtils.cpp"
"DashboardAuthService.cpp"
"AuthMiddleware.cpp"
"RequireAuthMiddleware.cpp"
)
add_library(DashboardAuth STATIC ${DASHBOARDAUTH_SOURCES})
target_include_directories(DashboardAuth PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
)
target_link_libraries(DashboardAuth PRIVATE ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto)

View File

@@ -0,0 +1,144 @@
#include "DashboardAuthService.h"
#include "JWTUtils.h"
#include "Database.h"
#include "Logger.h"
#include "Game.h"
#include "dConfig.h"
#include "GeneralUtils.h"
#include <bcrypt/bcrypt.h>
#include <ctime>
namespace {
constexpr int64_t LOCKOUT_DURATION = 15 * 60; // 15 minutes in seconds
}
DashboardAuthService::LoginResult DashboardAuthService::Login(
const std::string& username,
const std::string& password,
bool rememberMe) {
LoginResult result;
if (username.empty() || password.empty()) {
result.message = "Username and password are required";
return result;
}
if (password.length() > 40) {
result.message = "Password exceeds maximum length (40 characters)";
return result;
}
try {
// Get account info
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo) {
result.message = "Invalid username or password";
LOG_DEBUG("Login attempt for non-existent user: %s", username.c_str());
return result;
}
uint32_t accountId = accountInfo->id;
// Check if account is locked
bool isLockedOut = Database::Get()->IsLockedOut(accountId);
if (isLockedOut) {
// Record failed attempt even without checking password
Database::Get()->RecordFailedAttempt(accountId);
uint8_t failedAttempts = Database::Get()->GetFailedAttempts(accountId);
result.message = "Account is locked due to too many failed attempts";
result.accountLocked = true;
LOG("Login attempt on locked account: %s (failed attempts: %d)", username.c_str(), failedAttempts);
return result;
}
// Check password
if (::bcrypt_checkpw(password.c_str(), accountInfo->bcryptPassword.c_str()) != 0) {
// Record failed attempt
Database::Get()->RecordFailedAttempt(accountId);
uint8_t newFailedAttempts = Database::Get()->GetFailedAttempts(accountId);
// Lock account after 3 failed attempts
if (newFailedAttempts >= 3) {
int64_t lockoutUntil = std::time(nullptr) + LOCKOUT_DURATION;
Database::Get()->SetLockout(accountId, lockoutUntil);
result.message = "Account locked due to too many failed attempts";
result.accountLocked = true;
LOG("Account locked after failed attempts: %s", username.c_str());
} else {
result.message = "Invalid username or password";
LOG_DEBUG("Failed login attempt for user: %s (attempt %d/3)",
username.c_str(), newFailedAttempts);
}
return result;
}
// Check GM level
if (!HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
result.message = "Access denied: insufficient permissions";
LOG("Access denied for non-admin user: %s", username.c_str());
return result;
}
// Successful login
Database::Get()->ClearFailedAttempts(accountId);
result.success = true;
result.gmLevel = static_cast<uint8_t>(accountInfo->maxGmLevel);
result.token = JWTUtils::GenerateToken(username, result.gmLevel, rememberMe);
result.message = "Login successful";
LOG("Successful login: %s (GM Level: %d)", username.c_str(), result.gmLevel);
return result;
} catch (const std::exception& ex) {
result.message = "An error occurred during login";
LOG("Error during login process: %s", ex.what());
return result;
}
}
bool DashboardAuthService::VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel) {
JWTUtils::JWTPayload payload;
if (!JWTUtils::ValidateToken(token, payload)) {
LOG_DEBUG("Token validation failed: invalid or expired JWT");
return false;
}
username = payload.username;
gmLevel = payload.gmLevel;
// Optionally verify user still exists and has access
try {
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo || !HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
LOG_DEBUG("Token verification failed: user no longer has access");
return false;
}
} catch (const std::exception& ex) {
LOG_DEBUG("Error verifying user during token validation: %s", ex.what());
return false;
}
LOG_DEBUG("Token verified successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel);
return true;
}
bool DashboardAuthService::HasDashboardAccess(uint8_t gmLevel) {
// Get minimum GM level from config (default 0 = any user)
uint8_t minGmLevel = 0;
if (Game::config) {
const std::string& minGmLevelStr = Game::config->GetValue("min_dashboard_gm_level");
if (!minGmLevelStr.empty()) {
const auto parsed = GeneralUtils::TryParse<uint8_t>(minGmLevelStr);
if (parsed) {
minGmLevel = parsed.value();
}
}
}
return gmLevel >= minGmLevel;
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <string>
#include <cstdint>
/**
* Dashboard authentication service
* Handles user login, password verification, and account lockout
*/
class DashboardAuthService {
public:
/**
* Login result structure
*/
struct LoginResult {
bool success{false};
std::string message{};
std::string token{}; // JWT token if successful
uint8_t gmLevel{0}; // GM level if successful
bool accountLocked{false}; // Account is locked out
};
/**
* Attempt to log in with username and password
* @param username The username
* @param password The plaintext password (max 40 characters)
* @param rememberMe If true, extends token expiration to 30 days
* @return LoginResult with success status and JWT token if successful
*/
static LoginResult Login(const std::string& username, const std::string& password, bool rememberMe = false);
/**
* Verify that a token is valid and get the username
* @param token The JWT token
* @param username Output parameter for the username
* @param gmLevel Output parameter for the GM level
* @return true if token is valid
*/
static bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel);
/**
* Check if user has required GM level for dashboard access
* @param gmLevel The user's GM level
* @return true if user can access dashboard (GM level > 0)
*/
static bool HasDashboardAccess(uint8_t gmLevel);
};

View File

@@ -0,0 +1,186 @@
#include "JWTUtils.h"
#include "GeneralUtils.h"
#include "Logger.h"
#include "json.hpp"
#include <ctime>
#include <cstring>
#include <openssl/hmac.h>
#include <openssl/sha.h>
namespace {
std::string g_Secret = "default-secret-change-me";
// Simple base64 encoding
std::string Base64Encode(const std::string& input) {
static const char* base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string ret;
int i = 0;
unsigned char char_array_3[3];
unsigned char char_array_4[4];
for (size_t n = 0; n < input.length(); n++) {
char_array_3[i++] = input[n];
if (i == 3) {
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for (i = 0; i < 4; i++) ret += base64_chars[char_array_4[i]];
i = 0;
}
}
if (i) {
for (int j = i; j < 3; j++) char_array_3[j] = '\0';
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
for (int j = 0; j <= i; j++) ret += base64_chars[char_array_4[j]];
while (i++ < 3) ret += '=';
}
return ret;
}
// Simple base64 decoding
std::string Base64Decode(const std::string& encoded_string) {
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int in_len = encoded_string.size();
int i = 0, j = 0, in_ = 0;
unsigned char char_array_4[4], char_array_3[3];
std::string ret;
while (in_len-- && (encoded_string[in_] != '=') &&
(isalnum(encoded_string[in_]) || encoded_string[in_] == '+' || encoded_string[in_] == '/')) {
char_array_4[i++] = encoded_string[in_]; in_++;
if (i == 4) {
for (i = 0; i < 4; i++) char_array_4[i] = base64_chars.find(char_array_4[i]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; i < 3; i++) ret += char_array_3[i];
i = 0;
}
}
if (i) {
for (j = i; j < 4; j++) char_array_4[j] = 0;
for (j = 0; j < 4; j++) char_array_4[j] = base64_chars.find(char_array_4[j]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
for (j = 0; j < i - 1; j++) ret += char_array_3[j];
}
return ret;
}
// HMAC-SHA256
std::string HmacSha256(const std::string& key, const std::string& message) {
unsigned char* digest = HMAC(EVP_sha256(),
reinterpret_cast<const unsigned char*>(key.c_str()), key.length(),
reinterpret_cast<const unsigned char*>(message.c_str()), message.length(),
nullptr, nullptr);
std::string result(reinterpret_cast<char*>(digest), SHA256_DIGEST_LENGTH);
return result;
}
// Create signature for JWT
std::string CreateSignature(const std::string& header, const std::string& payload, const std::string& secret) {
std::string message = header + "." + payload;
std::string signature = HmacSha256(secret, message);
return Base64Encode(signature);
}
// Verify JWT signature
bool VerifySignature(const std::string& header, const std::string& payload, const std::string& signature, const std::string& secret) {
std::string expected = CreateSignature(header, payload, secret);
return signature == expected;
}
}
namespace JWTUtils {
void SetSecretKey(const std::string& secret) {
if (secret.empty()) {
LOG("Warning: JWT secret key is empty, using default");
return;
}
g_Secret = secret;
}
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe) {
// Header
std::string header = R"({"alg":"HS256","typ":"JWT"})";
std::string encodedHeader = Base64Encode(header);
// Payload
int64_t now = std::time(nullptr);
int64_t expiresAt = now + (rememberMe ? 30 * 24 * 60 * 60 : 24 * 60 * 60); // 30 days or 24 hours
std::string payload = R"({"username":")" + username + R"(","gmLevel":)" + std::to_string(gmLevel) +
R"(,"rememberMe":)" + (rememberMe ? "true" : "false") +
R"(,"iat":)" + std::to_string(now) +
R"(,"exp":)" + std::to_string(expiresAt) + "}";
std::string encodedPayload = Base64Encode(payload);
// Signature
std::string signature = CreateSignature(encodedHeader, encodedPayload, g_Secret);
return encodedHeader + "." + encodedPayload + "." + signature;
}
bool ValidateToken(const std::string& token, JWTPayload& payload) {
// Split token into parts
size_t firstDot = token.find('.');
size_t secondDot = token.find('.', firstDot + 1);
if (firstDot == std::string::npos || secondDot == std::string::npos) {
LOG_DEBUG("Invalid JWT format");
return false;
}
std::string header = token.substr(0, firstDot);
std::string encodedPayload = token.substr(firstDot + 1, secondDot - firstDot - 1);
std::string signature = token.substr(secondDot + 1);
// Verify signature
if (!VerifySignature(header, encodedPayload, signature, g_Secret)) {
LOG_DEBUG("Invalid JWT signature");
return false;
}
// Decode and parse payload
std::string decodedPayload = Base64Decode(encodedPayload);
try {
auto json = nlohmann::json::parse(decodedPayload);
payload.username = json.value("username", "");
payload.gmLevel = json.value("gmLevel", 0);
payload.rememberMe = json.value("rememberMe", false);
payload.issuedAt = json.value("iat", 0);
payload.expiresAt = json.value("exp", 0);
if (payload.username.empty()) {
LOG_DEBUG("JWT missing username");
return false;
}
// Check expiration
if (IsTokenExpired(payload.expiresAt)) {
LOG_DEBUG("JWT token expired");
return false;
}
return true;
} catch (const std::exception& ex) {
LOG_DEBUG("Error parsing JWT payload: %s", ex.what());
return false;
}
}
bool IsTokenExpired(int64_t expiresAt) {
return std::time(nullptr) > expiresAt;
}
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <string>
#include <ctime>
#include "json_fwd.hpp"
/**
* JWT Token utilities for dashboard authentication
* Provides secure token generation, validation, and parsing
*/
namespace JWTUtils {
/**
* JWT payload structure
*/
struct JWTPayload {
std::string username{};
uint8_t gmLevel{0};
bool rememberMe{false};
int64_t issuedAt{0};
int64_t expiresAt{0};
};
/**
* Generate a new JWT token
* @param username The username to encode in the token
* @param gmLevel The GM level of the user
* @param rememberMe If true, extends token expiration to 30 days; otherwise 24 hours
* @return Signed JWT token string
*/
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe = false);
/**
* Validate and decode a JWT token
* @param token The JWT token to validate
* @param payload Output parameter for the decoded payload
* @return true if token is valid and not expired, false otherwise
*/
bool ValidateToken(const std::string& token, JWTPayload& payload);
/**
* Check if a token is expired
* @param expiresAt Expiration timestamp
* @return true if token is expired
*/
bool IsTokenExpired(int64_t expiresAt);
/**
* Set the JWT secret key (must be called once at startup)
* @param secret The secret key for signing tokens
*/
void SetSecretKey(const std::string& secret);
}

View File

@@ -0,0 +1,35 @@
#include "RequireAuthMiddleware.h"
#include "HTTPContext.h"
#include "Web.h"
#include "Game.h"
#include "Logger.h"
RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : minGmLevel(minGmLevel) {}
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
// Check if user is authenticated
if (!context.isAuthenticated) {
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FOUND;
reply.message = "";
reply.location = "/login";
reply.contentType = eContentType::TEXT_HTML;
return false; // Stop middleware chain and send reply
}
// Check if user has required GM level
if (context.gmLevel < minGmLevel) {
LOG_DEBUG("Forbidden access attempt by user %s (GM level %d < %d required) to %s from %s",
context.authenticatedUser.c_str(), context.gmLevel, minGmLevel,
context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Insufficient permissions\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return false; // Stop middleware chain and send reply
}
// Authentication passed
LOG_DEBUG("User %s authenticated with GM level %d accessing %s",
context.authenticatedUser.c_str(), context.gmLevel, context.path.c_str());
return true; // Continue to next middleware or route handler
}

View File

@@ -0,0 +1,30 @@
#ifndef __REQUIREAUTHMIDDLEWARE_H__
#define __REQUIREAUTHMIDDLEWARE_H__
#include <memory>
#include <cstdint>
#include "IHTTPMiddleware.h"
/**
* RequireAuthMiddleware: Enforces authentication on protected routes
*
* Returns 401 Unauthorized if user is not authenticated
* Returns 403 Forbidden if user's GM level is below minimum required
*/
class RequireAuthMiddleware final : public IHTTPMiddleware {
public:
/**
* @param minGmLevel Minimum GM level required to access this route
* 0 = any authenticated user, higher numbers = GM-only
*/
explicit RequireAuthMiddleware(uint8_t minGmLevel = 0);
~RequireAuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "RequireAuthMiddleware"; }
private:
uint8_t minGmLevel;
};
#endif // !__REQUIREAUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,443 @@
#include "APIRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "HTTPContext.h"
#include "RequireAuthMiddleware.h"
#include <memory>
void RegisterAPIRoutes() {
// GET /api/status - Get overall server status
Game::web.RegisterHTTPRoute({
.path = "/api/status",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = ServerState::GetServerStateJson();
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/players - Get list of online players
Game::web.RegisterHTTPRoute({
.path = "/api/players",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = {
{"players", nlohmann::json::array()},
{"count", 0}
};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/accounts/count - Get total account count
Game::web.RegisterHTTPRoute({
.path = "/api/accounts/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
const uint32_t count = Database::Get()->GetAccountCount();
nlohmann::json response = {{"count", count}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/accounts/count: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /api/characters/count - Get total character count
Game::web.RegisterHTTPRoute({
.path = "/api/characters/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
const uint32_t count = Database::Get()->GetCharacterCount();
nlohmann::json response = {{"count", count}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/characters/count: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/accounts - Get accounts table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/accounts",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
// Extract DataTables parameters
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
// Extract search - it can be a string or an object with a "value" property
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
// Extract order parameters
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
// Get the accounts table data
nlohmann::json response = Database::Get()->GetAccountsTable(start, length, search, orderColumn, orderAsc);
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/accounts: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/accounts: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/characters - Get characters table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/characters",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetCharactersTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/characters: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/characters: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/play_keys - Get play keys table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/play_keys",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try { // Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetPlayKeysTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/play_keys: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/play_keys: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/properties - Get properties table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/properties",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetPropertiesTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/properties: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/properties: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/bug_reports - Get bug reports table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/bug_reports",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try { // Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetBugReportsTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/bug_reports: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/bug_reports: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /api/accounts/:id - Get single account by ID
Game::web.RegisterHTTPRoute({
.path = "/api/accounts/:id",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Extract account ID from URL path
const std::string path = context.path;
size_t lastSlash = path.rfind('/');
if (lastSlash == std::string::npos) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid account ID\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
std::string idStr = path.substr(lastSlash + 1);
uint32_t accountId = 0;
try {
accountId = std::stoul(idStr);
} catch (...) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid account ID\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
// Permission check: GM 0 can only view own account, GM > 0 can view any account
if (context.gmLevel == 0) {
// Regular user - get their own account ID
auto currentUserInfo = Database::Get()->GetAccountInfo(context.authenticatedUser);
if (!currentUserInfo.has_value() || currentUserInfo->id != accountId) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - You do not have permission to view this account\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
}
// Get account data
nlohmann::json response = Database::Get()->GetAccountById(accountId);
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/accounts/:id: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/accounts/:id: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterAPIRoutes();

View File

@@ -0,0 +1,102 @@
#include "AuthRoutes.h"
#include "DashboardAuthService.h"
#include "json.hpp"
#include "Logger.h"
#include "GeneralUtils.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "HTTPContext.h"
void RegisterAuthRoutes() {
// POST /api/auth/login
// Request body: { "username": "string", "password": "string", "rememberMe": boolean }
// Response: { "success": boolean, "message": "string", "token": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/login",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
auto json = nlohmann::json::parse(context.body);
std::string username = json.value("username", "");
std::string password = json.value("password", "");
bool rememberMe = json.value("rememberMe", false);
// Validate input
if (username.empty() || password.empty()) {
reply.message = R"({"success":false,"message":"Username and password are required"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
if (password.length() > 40) {
reply.message = R"({"success":false,"message":"Password exceeds maximum length"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
// Attempt login
auto result = DashboardAuthService::Login(username, password, rememberMe);
nlohmann::json response;
response["success"] = result.success;
response["message"] = result.message;
if (result.success) {
response["token"] = result.token;
response["gmLevel"] = result.gmLevel;
}
reply.message = response.dump();
reply.status = result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing login request: %s", ex.what());
reply.message = R"({"success":false,"message":"Internal server error"})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/auth/verify
// Request body: { "token": "string" }
// Response: { "valid": boolean, "username": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/verify",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
auto json = nlohmann::json::parse(context.body);
std::string token = json.value("token", "");
if (token.empty()) {
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
std::string username;
uint8_t gmLevel{};
bool valid = DashboardAuthService::VerifyToken(token, username, gmLevel);
nlohmann::json response;
response["valid"] = valid;
if (valid) {
response["username"] = username;
response["gmLevel"] = gmLevel;
}
reply.message = response.dump();
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing verify request: %s", ex.what());
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Web.h"
/**
* Register authentication routes
* /api/auth/login - POST login endpoint
* /api/auth/verify - POST verify token endpoint
*/
void RegisterAuthRoutes();

View File

@@ -0,0 +1,30 @@
set(DASHBOARDROUTES_SOURCES
"APIRoutes.cpp"
"StaticRoutes.cpp"
"DashboardRoutes.cpp"
"WSRoutes.cpp"
"AuthRoutes.cpp"
)
add_library(DashboardRoutes STATIC ${DASHBOARDROUTES_SOURCES})
target_include_directories(DashboardRoutes PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
)
target_link_libraries(DashboardRoutes PRIVATE ${COMMON_LIBRARIES} dWeb dServer)

View File

@@ -0,0 +1,291 @@
#include "DashboardRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "inja.hpp"
#include "AuthMiddleware.h"
#include "RequireAuthMiddleware.h"
void RegisterDashboardRoutes() {
// GET / - Main dashboard page (requires authentication)
Game::web.RegisterHTTPRoute({
.path = "/",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
// Server status - merge with server state
nlohmann::json serverState = ServerState::GetServerStateJson();
data.merge_patch(serverState);
// Statistics
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
data["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();
// Render template
const std::string html = env.render_file("index.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render template\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /login - Login page (no authentication required)
Game::web.RegisterHTTPRoute({
.path = "/login",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Render template with empty user data (not authenticated)
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("login.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering login template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render login page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /accounts/:id - View single account
Game::web.RegisterHTTPRoute({
.path = "/accounts/:id",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Extract account ID from URL path
const std::string path = context.path;
size_t lastSlash = path.rfind('/');
if (lastSlash == std::string::npos) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Account not found</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
std::string idStr = path.substr(lastSlash + 1);
uint32_t accountId = 0;
try {
accountId = std::stoul(idStr);
} catch (...) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Invalid account ID</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
// Permission check: GM 0 can only view own account, GM > 0 can view any account
if (context.gmLevel == 0) {
LOG("Regular user '%s' (GM level 0) is trying to access account ID %u", context.authenticatedUser.c_str(), accountId);
// Regular user - get their own account ID
auto currentUserInfo = Database::Get()->GetAccountInfo(context.authenticatedUser);
if (!currentUserInfo.has_value() || currentUserInfo->id != accountId) {
LOG("Permission denied: user '%s' cannot access account ID %u", context.authenticatedUser.c_str(), accountId);
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "<h1>403 - Forbidden</h1><p>You do not have permission to view this account.</p>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
}
// Get account data from API
nlohmann::json account = Database::Get()->GetAccountById(accountId);
// Check if account was found
if (account.contains("error")) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Account not found</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
data["account"] = account;
// Render template
const std::string html = env.render_file("account-view.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering account view template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "<h1>500 - Server Error</h1>";
reply.contentType = eContentType::TEXT_HTML;
}
}
});
// GET /accounts - Accounts management page
Game::web.RegisterHTTPRoute({
.path = "/accounts",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
// Render template
const std::string html = env.render_file("accounts.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering accounts template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render accounts page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /characters - Characters management page
Game::web.RegisterHTTPRoute({
.path = "/characters",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("characters.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering characters template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render characters page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /play_keys - Play keys management page
Game::web.RegisterHTTPRoute({
.path = "/play_keys",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("play_keys.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering play_keys template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render play_keys page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /properties - Properties management page
Game::web.RegisterHTTPRoute({
.path = "/properties",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("properties.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering properties template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render properties page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /bug_reports - Bug reports management page
Game::web.RegisterHTTPRoute({
.path = "/bug_reports",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("bug_reports.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering bug_reports template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render bug_reports page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include "json.hpp"
class HTTPContext;
void RegisterDashboardRoutes();

View File

@@ -0,0 +1,52 @@
#pragma once
#include <chrono>
#include <vector>
#include <string>
#include <cstdint>
#include "json.hpp"
struct ServerStatus {
bool online{false};
uint32_t players{0};
std::string version{};
std::chrono::steady_clock::time_point lastSeen{};
};
struct WorldInstanceInfo {
uint32_t mapID{0};
uint32_t instanceID{0};
uint32_t cloneID{0};
uint32_t players{0};
std::string ip{};
uint32_t port{0};
bool isPrivate{false};
};
namespace ServerState {
extern ServerStatus g_AuthStatus;
extern ServerStatus g_ChatStatus;
extern std::vector<WorldInstanceInfo> g_WorldInstances;
// Helper function to get all server state as JSON
inline nlohmann::json GetServerStateJson() {
nlohmann::json data;
data["auth"]["online"] = g_AuthStatus.online;
data["auth"]["players"] = g_AuthStatus.players;
data["chat"]["online"] = g_ChatStatus.online;
data["chat"]["players"] = g_ChatStatus.players;
data["worlds"] = nlohmann::json::array();
for (const auto& world : g_WorldInstances) {
data["worlds"].push_back({
{"mapID", world.mapID},
{"instanceID", world.instanceID},
{"cloneID", world.cloneID},
{"players", world.players},
{"isPrivate", world.isPrivate}
});
}
return data;
}
}

View File

@@ -0,0 +1,68 @@
#include "StaticRoutes.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "Game.h"
#include "Logger.h"
#include <fstream>
#include <sstream>
namespace {
std::string ReadFileToString(const std::string& filePath) {
std::ifstream file(filePath);
if (!file.is_open()) {
LOG("Failed to open file: %s", filePath.c_str());
return "";
}
std::stringstream buffer{};
buffer << file.rdbuf();
return buffer.str();
}
eContentType GetContentType(const std::string& filePath) {
if (filePath.ends_with(".css")) {
return eContentType::TEXT_CSS;
} else if (filePath.ends_with(".js")) {
return eContentType::TEXT_JAVASCRIPT;
} else if (filePath.ends_with(".html")) {
return eContentType::TEXT_HTML;
} else if (filePath.ends_with(".png")) {
return eContentType::IMAGE_PNG;
} else if (filePath.ends_with(".jpg") || filePath.ends_with(".jpeg")) {
return eContentType::IMAGE_JPEG;
} else if (filePath.ends_with(".json")) {
return eContentType::APPLICATION_JSON;
}
return eContentType::TEXT_PLAIN;
}
void ServeStaticFile(const std::string& urlPath, const std::string& filePath) {
Game::web.RegisterHTTPRoute({
.path = urlPath,
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [filePath](HTTPReply& reply, const HTTPContext& context) {
const std::string content = ReadFileToString(filePath);
if (content.empty()) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "{\"error\":\"File not found\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} else {
reply.status = eHTTPStatusCode::OK;
reply.message = content;
reply.contentType = GetContentType(filePath);
}
}
});
}
}
void RegisterStaticRoutes() {
// Serve CSS files
ServeStaticFile("/css/dashboard.css", "dDashboardServer/static/css/dashboard.css");
ServeStaticFile("/css/login.css", "dDashboardServer/static/css/login.css");
// Serve JavaScript files
ServeStaticFile("/js/dashboard.js", "dDashboardServer/static/js/dashboard.js");
ServeStaticFile("/js/login.js", "dDashboardServer/static/js/login.js");
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterStaticRoutes();

View File

@@ -0,0 +1,35 @@
#include "WSRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
void RegisterWSRoutes() {
// Register WebSocket subscriptions for real-time updates
Game::web.RegisterWSSubscription("dashboard_update");
Game::web.RegisterWSSubscription("server_status");
Game::web.RegisterWSSubscription("player_joined");
Game::web.RegisterWSSubscription("player_left");
// dashboard_update: Broadcasts complete dashboard data every 2 seconds
// Other subscriptions can be triggered by events from the master server
}
void BroadcastDashboardUpdate() {
// Get server state data (auth, chat, worlds) - mutex is acquired internally
nlohmann::json data = ServerState::GetServerStateJson();
// Add statistics
try {
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
data["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();
} catch (const std::exception& ex) {
LOG_DEBUG("Error getting stats: %s", ex.what());
}
// Broadcast to all connected WebSocket clients subscribed to "dashboard_update"
Game::web.SendWSMessage("dashboard_update", data);
}

View File

@@ -0,0 +1,4 @@
#pragma once
void RegisterWSRoutes();
void BroadcastDashboardUpdate();

View File

@@ -0,0 +1,495 @@
/* Minimal custom styling - mostly Bootstrap5 utilities */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 0;
}
body.d-flex.bg-dark.text-white {
background-color: #0d0d0d;
color: #fff;
}
/* Sidebar adjustments */
.navbar.flex-column {
box-shadow: 0.125rem 0 0.25rem rgba(0, 0, 0, 0.075);
}
.navbar.flex-column .navbar-nav {
width: 100%;
}
.navbar.flex-column .nav-link {
padding: 0.75rem 1.25rem;
border-left: 3px solid transparent;
transition: all 0.3s ease;
}
.navbar.flex-column .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
border-left-color: #667eea;
padding-left: 1.5rem;
}
.navbar.flex-column .nav-link.active {
background-color: rgba(255, 255, 255, 0.1);
border-left-color: #667eea;
}
main {
display: flex;
flex-direction: column;
padding: 0;
min-height: 100vh;
}
/* Responsive design */
@media (max-width: 991.98px) {
body {
display: block !important;
}
main {
margin-left: 0 !important;
}
.navbar.flex-column {
width: 100% !important;
height: auto !important;
position: relative !important;
top: auto !important;
start: auto !important;
}
}
.navbar {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.username {
font-weight: 600;
color: #667eea;
font-size: 1.1em;
}
.logout-btn {
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
transition: opacity 0.3s;
}
.logout-btn:hover {
opacity: 0.9;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.stat:last-child {
border-bottom: none;
}
.stat-label {
color: #666;
font-weight: 500;
}
.stat-value {
color: #333;
font-weight: bold;
font-size: 1.2em;
}
.status {
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
font-weight: bold;
}
.status.online {
background: #4caf50;
color: white;
}
.status.offline {
background: #f44336;
color: white;
}
.world-list {
max-height: 300px;
overflow-y: auto;
}
.world-item {
padding: 15px;
background: #f5f5f5;
border-radius: 5px;
margin-bottom: 10px;
}
.world-item h3 {
color: #333;
margin-bottom: 8px;
}
.world-detail {
color: #666;
font-size: 0.9em;
margin: 3px 0;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
/* Dark theme for data tables and containers */
/* Container margin for sidebar layout */
.account-view-container,
.accounts-container,
.characters-container,
.play-keys-container,
.properties-container,
.bug-reports-container {
margin-left: 280px;
padding: 20px;
}
/* Table card styling */
.table-card {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.table-header {
background-color: #222;
padding: 1.5rem;
border-bottom: 1px solid #333;
border-radius: 0.5rem 0.5rem 0 0;
}
.table-header h2 {
margin: 0;
color: #fff;
}
.table-body {
padding: 1.5rem;
}
/* Bootstrap card overrides for dark theme */
.card {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
margin-bottom: 1.5rem;
}
.card-header {
background-color: #222;
padding: 1.5rem;
border-bottom: 1px solid #333;
border-radius: 0.5rem 0.5rem 0 0;
}
.card-header h2 {
margin: 0;
color: #fff;
}
.card-body {
padding: 1.5rem;
}
/* Table styling */
.table-dark {
color: #fff;
}
.table-dark thead {
background-color: #2a2a2a;
}
.table-dark thead th {
border-bottom: 2px solid #444;
color: #aaa;
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
padding: 1rem;
}
.table-dark tbody td {
padding: 0.875rem 1rem;
border-bottom: 1px solid #333;
vertical-align: middle;
}
.table-dark tbody tr:hover {
background-color: #252525;
}
/* DataTables customization */
.dataTables_wrapper {
color: #fff;
}
.dataTables_wrapper .dataTables_filter input {
background-color: #2a2a2a;
color: #fff;
border: 1px solid #444;
border-radius: 0.25rem;
padding: 0.4rem 0.6rem;
}
.dataTables_wrapper .dataTables_filter input::placeholder {
color: #888;
}
.dataTables_wrapper .dataTables_info {
color: #aaa;
padding: 1rem;
}
.dataTables_wrapper .dataTables_paginate .paginate_button {
background: #2a2a2a;
color: #fff;
border: 1px solid #444;
margin: 0 2px;
padding: 0.4rem 0.8rem;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s;
}
.dataTables_wrapper .dataTables_paginate .paginate_button:hover {
background: #3a3a3a;
border: 1px solid #555;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current {
background: #0d6efd;
border: 1px solid #0d6efd;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dataTables_wrapper .dataTables_length select {
background-color: #2a2a2a;
color: #fff;
border: 1px solid #444;
padding: 0.4rem 0.6rem;
border-radius: 0.25rem;
}
/* Detail grid layout */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.detail-item {
background-color: #0a0a0a;
padding: 1rem;
border-radius: 0.25rem;
border-left: 3px solid #0d6efd;
}
.detail-label {
color: #999;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.detail-value {
color: #fff;
font-size: 1rem;
font-weight: 500;
}
/* Badge styling */
.badge {
display: inline-block;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
font-weight: 500;
border-radius: 0.25rem;
}
.badge-active {
background-color: #28a745;
color: #fff;
}
.badge-inactive {
background-color: #6c757d;
color: #fff;
}
.badge-banned {
background-color: #dc3545;
color: #fff;
}
.badge-locked {
background-color: #ffc107;
color: #000;
}
.badge-gm {
background-color: #17a2b8;
color: #fff;
}
.badge-approved {
background-color: #28a745;
color: #fff;
}
.badge-pending {
background-color: #ffc107;
color: #000;
}
/* Button styling */
.button-group {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-primary {
background-color: #0d6efd;
color: #fff;
}
.btn-primary:hover {
background-color: #0b5ed7;
}
.btn-secondary {
background-color: #6c757d;
color: #fff;
}
.btn-secondary:hover {
background-color: #5c636a;
}
.btn-danger {
background-color: #dc3545;
color: #fff;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-info {
background-color: #17a2b8;
color: #fff;
}
.btn-info:hover {
background-color: #138496;
}
.btn-warning {
background-color: #ffc107;
color: #000;
}
.btn-warning:hover {
background-color: #e0a800;
}
/* Action buttons */
.account-actions {
display: flex;
gap: 0.5rem;
}
/* Utility classes */
.text-muted {
color: #999 !important;
}
.back-link {
color: #0d6efd;
text-decoration: none;
margin-bottom: 1rem;
display: inline-block;
}
.back-link:hover {
text-decoration: underline;
}
.report-preview {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-section {
margin-bottom: 1.5rem;
}
.search-input {
background-color: #2a2a2a;
border: 1px solid #444;
color: #fff;
padding: 0.5rem;
border-radius: 0.25rem;
}
.search-input::placeholder {
color: #888;
}

View File

@@ -0,0 +1,30 @@
/* Custom styling for login page on top of Bootstrap5 */
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.card {
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2) !important;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #5568d3 0%, #6a3f93 100%);
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
h1 {
color: #333;
font-weight: 600;
}

View File

@@ -0,0 +1,240 @@
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
// Helper function to get cookie value
function getCookie(name) {
const nameEQ = name + '=';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(cookie.substring(nameEQ.length));
}
}
return null;
}
// Helper function to delete cookie
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict`;
}
// Check authentication on page load
function checkAuthentication() {
// Check localStorage first (most secure)
let token = localStorage.getItem('dashboardToken');
// Fallback to cookie if localStorage empty
if (!token) {
token = getCookie('dashboardToken');
}
if (!token) {
// Redirect to login if no token
window.location.href = '/login';
return false;
}
// Verify token is valid (asynchronous)
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => {
if (!res.ok) {
console.error('Verify endpoint returned:', res.status);
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then(data => {
console.log('Token verification response:', data);
if (!data.valid) {
// Token is invalid/expired, delete cookies and redirect to login
console.log('Token verification failed, redirecting to login');
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
} else {
// Update UI with username
console.log('Token verified, user:', data.username);
const usernameElement = document.querySelector('.username');
if (usernameElement) {
usernameElement.textContent = data.username || 'User';
} else {
console.warn('Username element not found in DOM');
}
// Now that verification is complete, connect to WebSocket
setTimeout(() => {
console.log('Starting WebSocket connection');
connectWebSocket();
}, 100);
}
})
.catch(err => {
console.error('Token verification error:', err);
// Network error - log but don't redirect immediately
// This prevents redirect loops on network issues
});
return true;
}
// Get token from localStorage or cookie
function getAuthToken() {
let token = localStorage.getItem('dashboardToken');
if (!token) {
token = getCookie('dashboardToken');
}
console.log('getAuthToken called, token available:', !!token);
return token;
}
// Logout function
function logout() {
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getAuthToken();
if (!token) {
console.error('No token available for WebSocket connection');
window.location.href = '/login';
return;
}
console.log(`WebSocket connection attempt ${reconnectAttempts + 1}/${maxReconnectAttempts}`);
// Connect to WebSocket without token in URL (token is in cookies)
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts = 0;
// Subscribe to dashboard updates
ws.send(JSON.stringify({
event: 'subscribe',
subscription: 'dashboard_update'
}));
document.getElementById('connection-status')?.remove();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle subscription confirmation
if (data.subscribed) {
console.log('Subscribed to:', data.subscribed);
return;
}
// Handle dashboard updates
if (data.event === 'dashboard_update') {
updateDashboard(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
ws = null;
// Show connection status
showConnectionStatus('Disconnected - Attempting to reconnect...');
// Attempt to reconnect with exponential backoff
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const backoffDelay = reconnectDelay * Math.pow(2, reconnectAttempts - 1);
console.log(`Reconnecting in ${backoffDelay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(connectWebSocket, backoffDelay);
} else {
console.error('Max reconnection attempts reached');
showConnectionStatus('Connection lost - Reload page to reconnect');
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
showConnectionStatus('Failed to connect - Reload page to retry');
}
}
function showConnectionStatus(message) {
let statusEl = document.getElementById('connection-status');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.id = 'connection-status';
statusEl.style.cssText = 'position: fixed; top: 10px; right: 10px; background: #f44336; color: white; padding: 10px 20px; border-radius: 4px; z-index: 1000;';
document.body.appendChild(statusEl);
}
statusEl.textContent = message;
}
function updateDashboard(data) {
// Update server status
if (data.auth) {
document.getElementById('auth-status').textContent = data.auth.online ? 'Online' : 'Offline';
document.getElementById('auth-status').className = 'status ' + (data.auth.online ? 'online' : 'offline');
}
if (data.chat) {
document.getElementById('chat-status').textContent = data.chat.online ? 'Online' : 'Offline';
document.getElementById('chat-status').className = 'status ' + (data.chat.online ? 'online' : 'offline');
}
// Update world instances
if (data.worlds) {
document.getElementById('world-count').textContent = data.worlds.length;
const worldList = document.getElementById('world-list');
if (data.worlds.length === 0) {
worldList.innerHTML = '<div class="loading">No active world instances</div>';
} else {
worldList.innerHTML = data.worlds.map(world => `
<div class="world-item">
<h3>Zone ${world.mapID} - Instance ${world.instanceID}</h3>
<div class="world-detail">Clone ID: ${world.cloneID}</div>
<div class="world-detail">Players: ${world.players}</div>
<div class="world-detail">Type: ${world.isPrivate ? 'Private' : 'Public'}</div>
</div>
`).join('');
}
}
// Update statistics
if (data.stats) {
if (data.stats.onlinePlayers !== undefined) {
document.getElementById('online-players').textContent = data.stats.onlinePlayers;
}
if (data.stats.totalAccounts !== undefined) {
document.getElementById('total-accounts').textContent = data.stats.totalAccounts;
}
if (data.stats.totalCharacters !== undefined) {
document.getElementById('total-characters').textContent = data.stats.totalCharacters;
}
}
}
// Connect on page load
connectWebSocket();

View File

@@ -0,0 +1,99 @@
// Check if user is already logged in
function checkExistingToken() {
const token = localStorage.getItem('dashboardToken');
if (token) {
verifyTokenAndRedirect(token);
}
}
function verifyTokenAndRedirect(token) {
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => res.json())
.then(data => {
if (data.valid) {
window.location.href = '/';
}
})
.catch(err => console.error('Token verification failed:', err));
}
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = 'alert';
if (type === 'error') {
alert.classList.add('alert-danger');
} else if (type === 'success') {
alert.classList.add('alert-success');
}
alert.style.display = 'block';
}
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('loginForm');
if (!loginForm) {
console.error('Login form not found');
return;
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// Validate input
if (!username || !password) {
showAlert('Username and password are required', 'error');
return;
}
if (password.length > 40) {
showAlert('Password exceeds maximum length (40 characters)', 'error');
return;
}
// Show loading state
document.getElementById('loading').style.display = 'inline-block';
document.getElementById('loginBtn').disabled = true;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
if (data.success) {
// Store token in localStorage (also set as cookie for API calls)
localStorage.setItem('dashboardToken', data.token);
document.cookie = `dashboardToken=${data.token}; path=/; SameSite=Strict`;
showAlert('Login successful! Redirecting...', 'success');
// Redirect after a short delay (no token in URL)
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
showAlert(data.message || 'Login failed', 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
} catch (error) {
showAlert('Network error: ' + error.message, 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
});
// Check existing token on page load
checkExistingToken();
});

View File

@@ -0,0 +1,137 @@
{% extends "base.jinja2" %}
{% block title %}Account - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="account-view-container">
<div class="container-fluid">
<a href="/accounts" class="back-link">← Back to Accounts</a>
<div class="card">
<div class="card-header">
<h2>Account #{{ account.id }} - {{ account.name }}</h2>
<p class="text-muted">View account details and manage settings</p>
</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Account ID</div>
<div class="detail-value">{{ account.id }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Username</div>
<div class="detail-value">{{ account.name }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Created</div>
<div class="detail-value">{{ account.created_at }}</div>
</div>
<div class="detail-item">
<div class="detail-label">GM Level</div>
<div class="detail-value">
{% if account.gm_level > 0 %}
<span class="badge badge-gm">GM {{ account.gm_level }}</span>
{% else %}
<span class="badge badge-inactive">User</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Ban Status</div>
<div class="detail-value">
{% if account.banned %}
<span class="badge badge-banned">BANNED</span>
{% else %}
<span class="badge badge-active">Active</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Lock Status</div>
<div class="detail-value">
{% if account.locked %}
<span class="badge badge-locked">LOCKED</span>
{% else %}
<span class="badge badge-active">Unlocked</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Mute Expires</div>
<div class="detail-value">
{% if account.mute_expire > 0 %}
<span>{{ account.mute_expire }}</span>
{% else %}
<span class="text-muted">Not muted</span>
{% endif %}
</div>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="EditAccount()">Edit Account</button>
{% if not account.banned %}
<button class="btn btn-danger" onclick="BanAccount()">Ban Account</button>
{% else %}
<button class="btn btn-secondary" onclick="UnbanAccount()">Unban Account</button>
{% endif %}
{% if not account.locked %}
<button class="btn btn-danger" onclick="LockAccount()">Lock Account</button>
{% else %}
<button class="btn btn-secondary" onclick="UnlockAccount()">Unlock Account</button>
{% endif %}
</div>
</div>
</div>
<!-- TODO: Add modals for edit, ban, lock operations -->
<!-- TODO: Add character list for this account -->
<!-- TODO: Add login history -->
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function EditAccount() {
alert("Edit functionality coming soon");
// TODO: Open edit modal
}
function BanAccount() {
if (confirm("Are you sure you want to ban this account?")) {
alert("Ban functionality coming soon");
// TODO: Call ban API endpoint
}
}
function UnbanAccount() {
if (confirm("Are you sure you want to unban this account?")) {
alert("Unban functionality coming soon");
// TODO: Call unban API endpoint
}
}
function LockAccount() {
if (confirm("Are you sure you want to lock this account?")) {
alert("Lock functionality coming soon");
// TODO: Call lock API endpoint
}
}
function UnlockAccount() {
if (confirm("Are you sure you want to unlock this account?")) {
alert("Unlock functionality coming soon");
// TODO: Call unlock API endpoint
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,133 @@
{% extends "base.jinja2" %}
{% block title %}Accounts - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="accounts-container">
<div class="container-fluid">
<div class="table-card">
<div class="table-header">
<h2 class="mb-0">Accounts</h2>
<p class="text-muted">View and manage user accounts</p>
</div>
<div class="table-body">
<div class="table-responsive">
<table id="accountsTable" class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Banned</th>
<th>Locked</th>
<th>GM Level</th>
<th>Mute Expires</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
$('#accountsTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/accounts',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
},
error: function(xhr, error, thrown) {
console.error('Error loading accounts:', error);
if (xhr.status === 401) {
window.location.href = '/login';
}
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{
data: 'banned',
render: function(data) {
return data ? '<span class="badge badge-banned">Banned</span>' : '<span class="badge bg-success">Active</span>';
}
},
{
data: 'locked',
render: function(data) {
return data ? '<span class="badge badge-locked">Locked</span>' : '<span class="badge bg-secondary">Unlocked</span>';
}
},
{
data: 'gm_level',
render: function(data) {
if (data === 0) return '-';
return '<span class="badge badge-gm">Level ' + data + '</span>';
}
},
{
data: 'mute_expire',
render: function(data) {
if (data === 0) return 'Not Muted';
const now = Math.floor(Date.now() / 1000);
const isMuted = data > now;
const date = new Date(data * 1000);
const dateStr = date.toLocaleString();
if (isMuted) {
return '<span class="badge bg-danger">Muted until ' + dateStr + '</span>';
} else {
return '<span class="badge bg-success">Expired ' + dateStr + '</span>';
}
}
},
{
data: 'created_at',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewAccount(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editAccount(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
});
function viewAccount(id) {
window.location.href = '/accounts/' + id;
}
function editAccount(id) {
alert('Edit account: ' + id);
// TODO: Implement account edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DarkflameServer{% endblock %}</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link href="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.3.7/b-3.2.6/fh-4.0.6/sc-2.4.3/datatables.min.css" rel="stylesheet" integrity="sha384-XMNDGLb5fN9IqhIrVXOAtGKcz4KCr+JSHXGZ1TDXQPDukbEAfmLPjHdCXhgK93fv" crossorigin="anonymous">
<link rel="stylesheet" href="/css/dashboard.css">
{% block css %}{% endblock %}
</head>
<body class="d-flex bg-dark text-white">
{% if username and username != "" %}
{% include "header.jinja2" %}
{% endif %}
<div class="container-fluid py-3">
{% block content_before %}{% endblock %}
{% block content %}{% endblock %}
{% block content_after %}{% endblock %}
</div>
<footer class="mt-5 pt-5 border-top border-secondary text-center pb-3">
{% block footer %}
<p class="text-muted small">DarkflameServer Dashboard &copy; 2024</p>
{% endblock %}
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.3.7/b-3.2.6/fh-4.0.6/sc-2.4.3/datatables.min.js" integrity="sha384-BPUXtS4tH3onFfu5m+dPbFfpLOXQwSWGwrsNWxOAAwqqJx6tJHhFkGF6uitrmEui" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,97 @@
{% extends "base.jinja2" %}
{% block title %}Bug Reports - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="bug-reports-container">
<div class="table-card">
<div class="table-header">
<h2 class="mb-0">Bug Reports</h2>
<p class="text-muted">View and manage bug reports from players</p>
</div>
<div class="table-body">
<table id="bugReportsTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Player</th>
<th>Client Version</th>
<th>Submitted</th>
<th>Report Preview</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
$('#bugReportsTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/bug_reports',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'other_player_id' },
{ data: 'client_version' },
{
data: 'submitted',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'body',
render: function(data) {
return '<span class="report-preview" title="' + (data || '') + '">' + (data || '-') + '</span>';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewReport(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteReport(' + data + ')" title="Delete">🗑️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'desc']],
stateSave: false
});
});
function viewReport(id) {
alert('View report: ' + id);
// TODO: Implement report view modal
}
function deleteReport(id) {
if (confirm('Are you sure you want to delete this report?')) {
alert('Delete report: ' + id);
// TODO: Implement report deletion
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends "base.jinja2" %}
{% block title %}Characters - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="characters-container">
<div class="table-card">
<div class="table-header">
<h2 class="mb-0">Characters</h2>
<p class="text-muted">View and manage player characters</p>
</div>
<div class="table-body">
<table id="charactersTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Account</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
$('#charactersTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/characters',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'account_name' },
{
data: 'last_login',
render: function(data) {
if (data === 0) return 'Never';
const date = new Date(data * 1000);
return date.toLocaleString();
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewCharacter(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editCharacter(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
});
function viewCharacter(id) {
alert('View character: ' + id);
// TODO: Implement character view modal
}
function editCharacter(id) {
alert('Edit character: ' + id);
// TODO: Implement character edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{# Navigation #}
<nav class="navbar navbar-dark bg-dark flex-column" style="width: 280px; height: 100vh; position: fixed; left: 0; top: 0; overflow-y: auto;">
<div class="p-3">
<a class="navbar-brand fw-bold" href="/">🎮 DarkflameServer</a>
</div>
<ul class="navbar-nav flex-column w-100 flex-grow-1 p-3">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/characters">Characters</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/play_keys">Play Keys</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/properties">Properties</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/bug_reports">Bug Reports</a>
</li>
<li class="nav-item mt-auto">
<a class="nav-link" href="#" id="logoutBtn">Logout</a>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,35 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard - DarkflameServer{% endblock %}
{% block content %}
<!-- Main Content -->
<main style="margin-left: 280px;">
<div class="container-fluid p-3 p-md-4">
<div class="row g-3">
{% include "server_status.jinja2" %}
{% include "statistics.jinja2" %}
</div>
{% include "world_instances.jinja2" %}
</div>
</main>
{% endblock %}
{% block scripts %}
<script src="/js/dashboard.js"></script>
<script>
// Check authentication and initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
// checkAuthentication is now async and calls connectWebSocket when ready
checkAuthentication();
// Setup logout button
document.getElementById('logoutBtn').addEventListener('click', (e) => {
e.preventDefault();
logout();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard Login - DarkflameServer{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/css/login.css">
{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
<h1 class="text-center mb-4">🎮 DarkflameServer</h1>
<div id="alert" class="alert" role="alert" style="display: none;"></div>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required maxlength="40">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe" name="rememberMe">
<label class="form-check-label" for="rememberMe">
Remember me for 30 days
</label>
</div>
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
<span id="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" style="display: none;"></span>
<span>Login</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/login.js"></script>
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "base.jinja2" %}
{% block title %}Play Keys - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="play-keys-container">
<div class="table-card">
<div class="table-header">
<h2 class="mb-0">Play Keys</h2>
<p class="text-muted">View and manage play keys</p>
</div>
<div class="table-body">
<table id="playKeysTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Key String</th>
<th>Uses Remaining</th>
<th>Created</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
$('#playKeysTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/play_keys',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'key_string' },
{ data: 'key_uses' },
{
data: 'created_at',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'active',
render: function(data) {
return data ? '<span class="badge badge-active">Active</span>' : '<span class="badge badge-inactive">Inactive</span>';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewKey(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editKey(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
});
function viewKey(id) {
alert('View key: ' + id);
// TODO: Implement key view modal
}
function editKey(id) {
alert('Edit key: ' + id);
// TODO: Implement key edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends "base.jinja2" %}
{% block title %}Properties - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="properties-container">
<div class="table-card">
<div class="table-header">
<h2 class="mb-0">Properties</h2>
<p class="text-muted">View and manage player properties</p>
</div>
<div class="table-body">
<table id="propertiesTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Owner ID</th>
<th>Moderation Status</th>
<th>Reputation</th>
<th>Zone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
$('#propertiesTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/properties',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'owner_id' },
{
data: 'mod_approved',
render: function(data) {
return data ? '<span class="badge badge-approved">Approved</span>' : '<span class="badge badge-pending">Pending</span>';
}
},
{ data: 'reputation' },
{ data: 'zone_id' },
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewProperty(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editProperty(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
});
function viewProperty(id) {
alert('View property: ' + id);
// TODO: Implement property view modal
}
function editProperty(id) {
alert('Edit property: ' + id);
// TODO: Implement property edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,29 @@
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-light">
<h5 class="mb-0">Server Status</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Auth Server</span>
{% if auth.online %}
<span class="badge bg-success" id="auth-status">Online</span>
{% else %}
<span class="badge bg-danger" id="auth-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Chat Server</span>
{% if chat.online %}
<span class="badge bg-success" id="chat-status">Online</span>
{% else %}
<span class="badge bg-danger" id="chat-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Active Worlds</span>
<span class="badge bg-primary" id="world-count">{{ length(worlds) }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-light">
<h5 class="mb-0">Statistics</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Online Players</span>
<span class="badge bg-info" id="online-players">{{ stats.onlinePlayers }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Total Accounts</span>
<span class="badge bg-info" id="total-accounts">{{ stats.totalAccounts }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Total Characters</span>
<span class="badge bg-info" id="total-characters">{{ stats.totalCharacters }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div class="card border-0 shadow-sm mt-4">
<div class="card-header bg-light">
<h5 class="mb-0">Active World Instances</h5>
</div>
<div class="card-body">
<div id="world-list">
{% if length(worlds) == 0 %}
<p class="text-muted text-center mb-0">No active world instances</p>
{% else %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Zone</th>
<th>Instance</th>
<th>Clone</th>
<th>Players</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for world in worlds %}
<tr>
<td>{{ world.mapID }}</td>
<td>{{ world.instanceID }}</td>
<td>{{ world.cloneID }}</td>
<td><span class="badge bg-secondary">{{ world.players }}</span></td>
<td>{% if world.isPrivate %}<span class="badge bg-warning">Private</span>{% else %}<span class="badge bg-primary">Public</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -154,8 +154,8 @@ std::map<LOT, uint32_t> CDItemComponentTable::ParseCraftingCurrencies(const CDIt
// Checking for 2 here, not sure what to do when there's more stuff than expected
if (amountSplit.size() == 2) {
currencies.insert({
GeneralUtils::TryParse<LOT>(amountSplit[0], LOT_NULL),
GeneralUtils::TryParse<uint32_t>(amountSplit[1], 0)
std::stoull(amountSplit[0]),
std::stoi(amountSplit[1])
});
}
}

View File

@@ -93,14 +93,13 @@ std::vector<CDMissions> CDMissionsTable::Query(std::function<bool(CDMissions)> p
}
const CDMissions* CDMissionsTable::GetPtrByMissionID(uint32_t missionID) const {
const CDMissions* toReturn = &Default;
for (const auto& entry : GetEntries()) {
if (entry.id == missionID) {
toReturn = &entry;
return const_cast<CDMissions*>(&entry);
}
}
return toReturn;
return &Default;
}
const CDMissions& CDMissionsTable::GetByMissionID(uint32_t missionID, bool& found) const {

View File

@@ -66,7 +66,6 @@ public:
// Queries the table with a custom "where" clause
std::vector<CDMissions> Query(std::function<bool(CDMissions)> predicate);
// Cannot be null.
const CDMissions* GetPtrByMissionID(uint32_t missionID) const;
const CDMissions& GetByMissionID(uint32_t missionID, bool& found) const;

View File

@@ -69,7 +69,7 @@ const CDObjects& CDObjectsTable::GetByID(const uint32_t lot) {
entry.name = tableData.getStringField("name", "");
UNUSED(entry.placeable = tableData.getIntField("placeable", -1));
entry.type = tableData.getStringField("type", "");
UNUSED(entry.description = tableData.getStringField(4, ""));
UNUSED(ntry.description = tableData.getStringField(4, ""));
UNUSED(entry.localize = tableData.getIntField("localize", -1));
UNUSED(entry.npcTemplateID = tableData.getIntField("npcTemplateID", -1));
UNUSED(entry.displayName = tableData.getStringField("displayName", ""));

View File

@@ -56,7 +56,7 @@ CDRailActivatorComponent CDRailActivatorComponentTable::GetEntryByID(int32_t id)
std::pair<uint32_t, std::u16string> CDRailActivatorComponentTable::EffectPairFromString(std::string& str) {
const auto split = GeneralUtils::SplitString(str, ':');
if (split.size() == 2) {
return { GeneralUtils::TryParse(split.at(0), 0), GeneralUtils::ASCIIToUTF16(split.at(1)) };
return { std::stoi(split.at(0)), GeneralUtils::ASCIIToUTF16(split.at(1)) };
}
return {};

View File

@@ -5,6 +5,7 @@
#include <optional>
#include <string>
#include <string_view>
#include "json.hpp"
enum class eGameMasterLevel : uint8_t;
@@ -39,6 +40,30 @@ public:
virtual void UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) = 0;
virtual uint32_t GetAccountCount() = 0;
// Login attempt tracking methods
// Record a failed login attempt
virtual void RecordFailedAttempt(const uint32_t accountId) = 0;
// Clear failed login attempts and update last login time
virtual void ClearFailedAttempts(const uint32_t accountId) = 0;
// Set account lockout
virtual void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) = 0;
// Check if account is locked out
virtual bool IsLockedOut(const uint32_t accountId) = 0;
// Get failed attempt count
virtual uint8_t GetFailedAttempts(const uint32_t accountId) = 0;
// Get paginated list of accounts with optional search/filtering for DataTables
// Returns a JSON object with the account data and metadata
virtual nlohmann::json GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
// Get a single account by ID
// Returns a JSON object with the account details
virtual nlohmann::json GetAccountById(uint32_t accountId) = 0;
};
#endif //!__IACCOUNTS__H__

View File

@@ -2,6 +2,7 @@
#define __IBUGREPORTS__H__
#include <cstdint>
#include <string>
#include <string_view>
class IBugReports {
@@ -16,5 +17,9 @@ public:
// Add a new bug report to the database.
virtual void InsertNewBugReport(const Info& info) = 0;
// Get paginated list of bug reports with optional search/filtering for DataTables
// Returns a JSON-formatted string with the bug report data and metadata
virtual std::string GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__IBUGREPORTS__H__

View File

@@ -33,6 +33,9 @@ public:
// Get the character ids for the given account.
virtual std::vector<LWOOBJID> GetAccountCharacterIds(const LWOOBJID accountId) = 0;
// Get the total number of characters in the database.
virtual uint32_t GetCharacterCount() = 0;
// Insert a new character into the database.
virtual void InsertNewCharacter(const ICharInfo::Info info) = 0;

View File

@@ -15,6 +15,10 @@ public:
// Insert the character xml for the given character id.
virtual void InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) = 0;
// Get paginated list of characters with optional search/filtering for DataTables
// Returns a JSON-formatted string with the character data and metadata
virtual std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__ICHARXML__H__

View File

@@ -3,6 +3,8 @@
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
class IPlayKeys {
public:
@@ -10,6 +12,10 @@ public:
// Optional of bool may seem pointless, however the optional indicates if the playkey exists
// and the bool indicates if the playkey is active.
virtual std::optional<bool> IsPlaykeyActive(const int32_t playkeyId) = 0;
// Get paginated list of play keys with optional search/filtering for DataTables
// Returns a JSON-formatted string with the play key data and metadata
virtual std::string GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__IPLAYKEYS__H__

View File

@@ -34,7 +34,6 @@ public:
};
struct PropertyEntranceResult {
// This is the number of entries that are in the query IF it were ran without a limit.
int32_t totalEntriesMatchingQuery{};
// The entries that match the query. This should only contain up to 12 entries.
std::vector<IProperty::Info> entries;
@@ -49,7 +48,7 @@ public:
// Get the properties for the given property lookup params.
// This is expected to return a result set of up to 12 properties
// so as not to transfer too much data at once.
virtual IProperty::PropertyEntranceResult GetProperties(const PropertyLookup& params) = 0;
virtual std::optional<IProperty::PropertyEntranceResult> GetProperties(const PropertyLookup& params) = 0;
// Update the property moderation info for the given property id.
virtual void UpdatePropertyModerationInfo(const IProperty::Info& info) = 0;
@@ -65,5 +64,9 @@ public:
// Insert a new property into the database.
virtual void InsertNewProperty(const IProperty::Info& info, const uint32_t templateId, const LWOZONEID& zoneId) = 0;
// Get paginated list of properties with optional search/filtering for DataTables
// Returns a JSON-formatted string with the property data and metadata
virtual std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__IPROPERTY__H__

View File

@@ -9,20 +9,6 @@
typedef std::unique_ptr<sql::PreparedStatement>& UniquePreppedStmtRef;
typedef std::unique_ptr<sql::ResultSet> UniqueResultSet;
// This struct is used to keep the PreparedStatement alive alongside the ResultSet, since the ResultSet will be invalidated if the PreparedStatement is destroyed.
// Declaring the members in reverse order of usage to ensure the PreparedStatement is destroyed after the ResultSet. This is guaranteed by the C++ standard.
struct PreparedStmtResultSet {
std::unique_ptr<sql::PreparedStatement> m_stmt;
std::unique_ptr<sql::ResultSet> m_resultSet;
PreparedStmtResultSet(sql::PreparedStatement* stmt = nullptr, sql::ResultSet* resultSet = nullptr)
: m_stmt(stmt), m_resultSet(resultSet) {}
sql::ResultSet* operator->() const {
return m_resultSet.get();
}
};
// Purposefully no definition for this to provide linker errors in the case someone tries to
// bind a parameter to a type that isn't defined.
template<typename ParamType>
@@ -74,6 +60,7 @@ public:
std::optional<IAccounts::Info> GetAccountInfo(const std::string_view username) override;
void InsertNewCharacter(const ICharInfo::Info info) override;
void InsertCharacterXml(const LWOOBJID accountId, const std::string_view lxfml) override;
std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<LWOOBJID> GetAccountCharacterIds(LWOOBJID accountId) override;
void DeleteCharacter(const LWOOBJID characterId) override;
void SetCharacterName(const LWOOBJID characterId, const std::string_view name) override;
@@ -93,6 +80,7 @@ public:
void RemoveModel(const LWOOBJID& modelId) override;
void UpdatePerformanceCost(const LWOZONEID& zoneId, const float performanceCost) override;
void InsertNewBugReport(const IBugReports::Info& info) override;
std::string GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
void InsertCheatDetection(const IPlayerCheatDetections::Info& info) override;
void InsertNewMail(const MailInfo& mail) override;
void InsertNewUgcModel(
@@ -117,6 +105,7 @@ public:
void InsertDefaultPersistentId() override;
std::optional<uint32_t> GetDonationTotal(const uint32_t activityId) override;
std::optional<bool> IsPlaykeyActive(const int32_t playkeyId) override;
std::string GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<IUgc::Model> GetUgcModels(const LWOOBJID& propertyId) override;
void AddIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
void RemoveIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
@@ -127,7 +116,7 @@ public:
std::string GetBehavior(const LWOOBJID behaviorId) override;
void RemoveBehavior(const LWOOBJID characterId) override;
void UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) override;
IProperty::PropertyEntranceResult GetProperties(const IProperty::PropertyLookup& params) override;
std::optional<IProperty::PropertyEntranceResult> GetProperties(const IProperty::PropertyLookup& params) override;
std::vector<ILeaderboard::Entry> GetDescendingLeaderboard(const uint32_t activityId) override;
std::vector<ILeaderboard::Entry> GetAscendingLeaderboard(const uint32_t activityId) override;
std::vector<ILeaderboard::Entry> GetNsLeaderboard(const uint32_t activityId) override;
@@ -140,25 +129,31 @@ public:
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
void DeleteUgcBuild(const LWOOBJID bigId) override;
uint32_t GetAccountCount() override;
uint32_t GetCharacterCount() override;
void RecordFailedAttempt(const uint32_t accountId) override;
void ClearFailedAttempts(const uint32_t accountId) override;
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
bool IsLockedOut(const uint32_t accountId) override;
uint8_t GetFailedAttempts(const uint32_t accountId) override;
nlohmann::json GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
nlohmann::json GetAccountById(uint32_t accountId) override;
bool IsNameInUse(const std::string_view name) override;
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;
std::optional<IProperty::Info> GetPropertyInfo(const LWOOBJID id) override;
std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
sql::PreparedStatement* CreatePreppedStmt(const std::string& query);
private:
// Generic query functions that can be used for any query.
// Return type may be different depending on the query, so it is up to the caller to check the return type.
// The first argument is the query string, and the rest are the parameters to bind to the query.
// The return type is a PreparedStmtResultSet which keeps the PreparedStatement alive alongside the ResultSet.
// The return type is a unique_ptr to the result set, which is deleted automatically when it goes out of scope
template<typename... Args>
inline PreparedStmtResultSet ExecuteSelect(const std::string& query, Args&&... args) {
PreparedStmtResultSet toReturn;
toReturn.m_stmt.reset(CreatePreppedStmt(query));
SetParams(toReturn.m_stmt, std::forward<Args>(args)...);
DLU_SQL_TRY_CATCH_RETHROW(toReturn.m_resultSet.reset(toReturn.m_stmt->executeQuery()));
// Return the PreparedStmtResultSet, which now owns both the PreparedStatement and ResultSet via unique_ptr and will ensure they are properly cleaned up.
return toReturn;
inline std::unique_ptr<sql::ResultSet> ExecuteSelect(const std::string& query, Args&&... args) {
std::unique_ptr<sql::PreparedStatement> preppedStmt(CreatePreppedStmt(query));
SetParams(preppedStmt, std::forward<Args>(args)...);
DLU_SQL_TRY_CATCH_RETHROW(return std::unique_ptr<sql::ResultSet>(preppedStmt->executeQuery()));
}
template<typename... Args>

View File

@@ -1,6 +1,7 @@
#include "MySQLDatabase.h"
#include "eGameMasterLevel.h"
#include "json.hpp"
std::optional<IAccounts::Info> MySQLDatabase::GetAccountInfo(const std::string_view username) {
auto result = ExecuteSelect("SELECT id, password, banned, locked, play_key_id, gm_level, mute_expire FROM accounts WHERE name = ? LIMIT 1;", username);
@@ -45,3 +46,142 @@ uint32_t MySQLDatabase::GetAccountCount() {
auto res = ExecuteSelect("SELECT COUNT(*) as count FROM accounts;");
return res->next() ? res->getUInt("count") : 0;
}
void MySQLDatabase::RecordFailedAttempt(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
}
void MySQLDatabase::ClearFailedAttempts(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = NOW() WHERE id = ?;", accountId);
}
void MySQLDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
ExecuteUpdate("UPDATE accounts SET lockout_time = FROM_UNIXTIME(?) WHERE id = ?;", lockoutUntil, accountId);
}
bool MySQLDatabase::IsLockedOut(const uint32_t accountId) {
auto result = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
if (!result->next()) {
return false;
}
// If lockout_time is set and in the future, account is locked
const char* lockoutTime = result->getString("lockout_time").c_str();
if (lockoutTime == nullptr || strlen(lockoutTime) == 0) {
return false;
}
// Simplified check - if lockout_time exists and is not null, it's locked
return true;
}
uint8_t MySQLDatabase::GetFailedAttempts(const uint32_t accountId) {
auto result = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
if (!result->next()) {
return 0;
}
return result->getUInt("failed_attempts");
}
nlohmann::json MySQLDatabase::GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at FROM accounts";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "banned"; break;
case 3: orderColumnName = "locked"; break;
case 4: orderColumnName = "gm_level"; break;
case 5: orderColumnName = "mute_expire"; break;
case 6: orderColumnName = "created_at"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM accounts;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM accounts WHERE name LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json accountsArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json account = {
{"id", result->getUInt("id")},
{"name", result->getString("name")},
{"banned", result->getBoolean("banned")},
{"locked", result->getBoolean("locked")},
{"gm_level", result->getInt("gm_level")},
{"mute_expire", result->getUInt64("mute_expire")},
{"created_at", result->getString("created_at")}
};
accountsArray.push_back(account);
}
nlohmann::json response = {
{"draw", 1},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", accountsArray}
};
return response;
}
nlohmann::json MySQLDatabase::GetAccountById(uint32_t accountId) {
try {
const std::string query = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at FROM accounts WHERE id = ?;";
auto result = ExecuteSelect(query, accountId);
if (!result->next()) {
return nlohmann::json{{"error", "Account not found"}};
}
nlohmann::json account = {
{"id", result->getUInt("id")},
{"name", result->getString("name")},
{"banned", result->getBoolean("banned")},
{"locked", result->getBoolean("locked")},
{"gm_level", result->getInt("gm_level")},
{"mute_expire", result->getUInt64("mute_expire")},
{"created_at", result->getString("created_at")}
};
return account;
} catch (const sql::SQLException& e) {
LOG_DEBUG("SQL Error: %s", e.what());
return nlohmann::json{{"error", "Database error"}};
}
}

View File

@@ -4,3 +4,77 @@ void MySQLDatabase::InsertNewBugReport(const IBugReports::Info& info) {
ExecuteInsert("INSERT INTO `bug_reports`(body, client_version, other_player_id, selection, reporter_id) VALUES (?, ?, ?, ?, ?)",
info.body, info.clientVersion, info.otherPlayer, info.selection, info.characterId);
}
#include "json.hpp"
std::string MySQLDatabase::GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, body, client_version, other_player_id, selection, submitted FROM bug_reports";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE body LIKE CONCAT('%', ?, '%') OR other_player_id LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "other_player_id"; break;
case 2: orderColumnName = "client_version"; break;
case 3: orderColumnName = "submitted"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM bug_reports;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM bug_reports WHERE body LIKE CONCAT('%', ?, '%') OR other_player_id LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json reportsArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json report = {
{"id", result->getUInt("id")},
{"other_player_id", result->getString("other_player_id")},
{"client_version", result->getString("client_version")},
{"selection", result->getString("selection")},
{"submitted", result->getString("submitted")},
{"body", result->getString("body")}
};
reportsArray.push_back(report);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", reportsArray}
};
return response.dump();
}

View File

@@ -12,7 +12,7 @@ std::vector<std::string> MySQLDatabase::GetApprovedCharacterNames() {
return toReturn;
}
std::optional<ICharInfo::Info> CharInfoFromQueryResult(PreparedStmtResultSet& stmt) {
std::optional<ICharInfo::Info> CharInfoFromQueryResult(std::unique_ptr<sql::ResultSet> stmt) {
if (!stmt->next()) {
return std::nullopt;
}
@@ -31,13 +31,15 @@ std::optional<ICharInfo::Info> CharInfoFromQueryResult(PreparedStmtResultSet& st
}
std::optional<ICharInfo::Info> MySQLDatabase::GetCharacterInfo(const LWOOBJID charId) {
auto result = ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE id = ? LIMIT 1;", charId);
return CharInfoFromQueryResult(result);
return CharInfoFromQueryResult(
ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE id = ? LIMIT 1;", charId)
);
}
std::optional<ICharInfo::Info> MySQLDatabase::GetCharacterInfo(const std::string_view name) {
auto result = ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE name = ? LIMIT 1;", name);
return CharInfoFromQueryResult(result);
return CharInfoFromQueryResult(
ExecuteSelect("SELECT name, pending_name, needs_rename, prop_clone_id, permission_map, id, account_id FROM charinfo WHERE name = ? LIMIT 1;", name)
);
}
std::vector<LWOOBJID> MySQLDatabase::GetAccountCharacterIds(const LWOOBJID accountId) {
@@ -52,6 +54,11 @@ std::vector<LWOOBJID> MySQLDatabase::GetAccountCharacterIds(const LWOOBJID accou
return toReturn;
}
uint32_t MySQLDatabase::GetCharacterCount() {
auto res = ExecuteSelect("SELECT COUNT(*) as count FROM charinfo;");
return res->next() ? res->getUInt("count") : 0;
}
void MySQLDatabase::InsertNewCharacter(const ICharInfo::Info info) {
ExecuteInsert(
"INSERT INTO `charinfo`(`id`, `account_id`, `name`, `pending_name`, `needs_rename`, `last_login`) VALUES (?,?,?,?,?,?)",

View File

@@ -17,3 +17,75 @@ void MySQLDatabase::UpdateCharacterXml(const LWOOBJID charId, const std::string_
void MySQLDatabase::InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) {
ExecuteInsert("INSERT INTO `charxml` (`id`, `xml_data`) VALUES (?,?)", characterId, lxfml);
}
#include "json.hpp"
std::string MySQLDatabase::GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT c.id, c.name, c.account_id, c.last_login, a.name as account_name FROM charinfo c JOIN accounts a ON c.account_id = a.id";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE c.name LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "c.id";
switch (orderColumn) {
case 0: orderColumnName = "c.id"; break;
case 1: orderColumnName = "c.name"; break;
case 2: orderColumnName = "a.name"; break;
case 3: orderColumnName = "c.last_login"; break;
default: orderColumnName = "c.id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM charinfo;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM charinfo WHERE name LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json charactersArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json character = {
{"id", result->getUInt64("id")},
{"name", result->getString("name")},
{"account_name", result->getString("account_name")},
{"last_login", result->getUInt64("last_login")}
};
charactersArray.push_back(character);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", charactersArray}
};
return response.dump();
}

View File

@@ -14,7 +14,7 @@ std::optional<uint32_t> MySQLDatabase::GetDonationTotal(const uint32_t activityI
return donation_total->getUInt("donation_total");
}
std::vector<ILeaderboard::Entry> ProcessQuery(PreparedStmtResultSet& rows) {
std::vector<ILeaderboard::Entry> ProcessQuery(UniqueResultSet& rows) {
std::vector<ILeaderboard::Entry> entries;
entries.reserve(rows->rowsCount());

View File

@@ -48,7 +48,7 @@ std::vector<MailInfo> MySQLDatabase::GetMailForPlayer(const LWOOBJID characterId
}
std::optional<MailInfo> MySQLDatabase::GetMail(const uint64_t mailId) {
auto res = ExecuteSelect("SELECT attachment_lot, attachment_count, receiver_id FROM mail WHERE id=? LIMIT 1;", mailId);
auto res = ExecuteSelect("SELECT attachment_lot, attachment_count FROM mail WHERE id=? LIMIT 1;", mailId);
if (!res->next()) {
return std::nullopt;
@@ -57,7 +57,6 @@ std::optional<MailInfo> MySQLDatabase::GetMail(const uint64_t mailId) {
MailInfo toReturn;
toReturn.itemLOT = res->getInt("attachment_lot");
toReturn.itemCount = res->getInt("attachment_count");
toReturn.receiverId = res->getUInt64("receiver_id");
return toReturn;
}

View File

@@ -9,3 +9,77 @@ std::optional<bool> MySQLDatabase::IsPlaykeyActive(const int32_t playkeyId) {
return keyCheckRes->getBoolean("active");
}
#include "json.hpp"
std::string MySQLDatabase::GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, key_string, key_uses, created_at, active FROM play_keys";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE key_string LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "key_string"; break;
case 2: orderColumnName = "key_uses"; break;
case 3: orderColumnName = "created_at"; break;
case 4: orderColumnName = "active"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM play_keys;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM play_keys WHERE key_string LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json keysArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json key = {
{"id", result->getUInt("id")},
{"key_string", result->getString("key_string")},
{"key_uses", result->getUInt("key_uses")},
{"created_at", result->getString("created_at")},
{"active", result->getBoolean("active")}
};
keysArray.push_back(key);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", keysArray}
};
return response.dump();
}

View File

@@ -1,7 +1,7 @@
#include "MySQLDatabase.h"
#include "ePropertySortType.h"
IProperty::Info ReadPropertyInfo(PreparedStmtResultSet& result) {
IProperty::Info ReadPropertyInfo(UniqueResultSet& result) {
IProperty::Info info;
info.id = result->getUInt64("id");
info.ownerId = result->getInt64("owner_id");
@@ -18,10 +18,10 @@ IProperty::Info ReadPropertyInfo(PreparedStmtResultSet& result) {
return info;
}
IProperty::PropertyEntranceResult MySQLDatabase::GetProperties(const IProperty::PropertyLookup& params) {
IProperty::PropertyEntranceResult result;
std::optional<IProperty::PropertyEntranceResult> MySQLDatabase::GetProperties(const IProperty::PropertyLookup& params) {
std::optional<IProperty::PropertyEntranceResult> result;
std::string query;
PreparedStmtResultSet properties;
std::unique_ptr<sql::ResultSet> properties;
if (params.sortChoice == SORT_TYPE_FEATURED || params.sortChoice == SORT_TYPE_FRIENDS) {
query = R"QUERY(
@@ -73,7 +73,8 @@ IProperty::PropertyEntranceResult MySQLDatabase::GetProperties(const IProperty::
params.playerId
);
if (count->next()) {
result.totalEntriesMatchingQuery = count->getUInt("count");
if (!result) result = IProperty::PropertyEntranceResult();
result->totalEntriesMatchingQuery = count->getUInt("count");
}
} else {
if (params.sortChoice == SORT_TYPE_REPUTATION) {
@@ -126,12 +127,14 @@ IProperty::PropertyEntranceResult MySQLDatabase::GetProperties(const IProperty::
params.playerSort
);
if (count->next()) {
result.totalEntriesMatchingQuery = count->getUInt("count");
if (!result) result = IProperty::PropertyEntranceResult();
result->totalEntriesMatchingQuery = count->getUInt("count");
}
}
while (properties->next()) {
result.entries.push_back(ReadPropertyInfo(properties));
if (!result) result = IProperty::PropertyEntranceResult();
result->entries.push_back(ReadPropertyInfo(properties));
}
return result;
@@ -195,3 +198,79 @@ std::optional<IProperty::Info> MySQLDatabase::GetPropertyInfo(const LWOOBJID id)
return ReadPropertyInfo(propertyEntry);
}
#include "json.hpp"
std::string MySQLDatabase::GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, owner_id, name, mod_approved, reputation, zone_id FROM properties";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "owner_id"; break;
case 3: orderColumnName = "mod_approved"; break;
case 4: orderColumnName = "reputation"; break;
case 5: orderColumnName = "zone_id"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM properties;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM properties WHERE name LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json propertiesArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json property = {
{"id", result->getUInt64("id")},
{"owner_id", result->getUInt64("owner_id")},
{"name", result->getString("name")},
{"mod_approved", result->getBoolean("mod_approved")},
{"reputation", result->getUInt64("reputation")},
{"zone_id", result->getUInt("zone_id")}
};
propertiesArray.push_back(property);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", propertiesArray}
};
return response.dump();
}

View File

@@ -1,6 +1,6 @@
#include "MySQLDatabase.h"
IUgc::Model ReadModel(PreparedStmtResultSet& result) {
IUgc::Model ReadModel(UniqueResultSet& result) {
IUgc::Model model;
// blob is owned by the query, so we need to do a deep copy :/

View File

@@ -58,6 +58,7 @@ public:
std::optional<IAccounts::Info> GetAccountInfo(const std::string_view username) override;
void InsertNewCharacter(const ICharInfo::Info info) override;
void InsertCharacterXml(const LWOOBJID accountId, const std::string_view lxfml) override;
std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<LWOOBJID> GetAccountCharacterIds(LWOOBJID accountId) override;
void DeleteCharacter(const LWOOBJID characterId) override;
void SetCharacterName(const LWOOBJID characterId, const std::string_view name) override;
@@ -77,6 +78,7 @@ public:
void RemoveModel(const LWOOBJID& modelId) override;
void UpdatePerformanceCost(const LWOZONEID& zoneId, const float performanceCost) override;
void InsertNewBugReport(const IBugReports::Info& info) override;
std::string GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
void InsertCheatDetection(const IPlayerCheatDetections::Info& info) override;
void InsertNewMail(const MailInfo& mail) override;
void InsertNewUgcModel(
@@ -101,6 +103,7 @@ public:
void InsertDefaultPersistentId() override;
std::optional<uint32_t> GetDonationTotal(const uint32_t activityId) override;
std::optional<bool> IsPlaykeyActive(const int32_t playkeyId) override;
std::string GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<IUgc::Model> GetUgcModels(const LWOOBJID& propertyId) override;
void AddIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
void RemoveIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
@@ -111,7 +114,7 @@ public:
std::string GetBehavior(const LWOOBJID behaviorId) override;
void RemoveBehavior(const LWOOBJID characterId) override;
void UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) override;
IProperty::PropertyEntranceResult GetProperties(const IProperty::PropertyLookup& params) override;
std::optional<IProperty::PropertyEntranceResult> GetProperties(const IProperty::PropertyLookup& params) override;
std::vector<ILeaderboard::Entry> GetDescendingLeaderboard(const uint32_t activityId) override;
std::vector<ILeaderboard::Entry> GetAscendingLeaderboard(const uint32_t activityId) override;
std::vector<ILeaderboard::Entry> GetNsLeaderboard(const uint32_t activityId) override;
@@ -124,10 +127,19 @@ public:
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
void DeleteUgcBuild(const LWOOBJID bigId) override;
uint32_t GetAccountCount() override;
uint32_t GetCharacterCount() override;
void RecordFailedAttempt(const uint32_t accountId) override;
void ClearFailedAttempts(const uint32_t accountId) override;
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
bool IsLockedOut(const uint32_t accountId) override;
uint8_t GetFailedAttempts(const uint32_t accountId) override;
nlohmann::json GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
nlohmann::json GetAccountById(uint32_t accountId) override;
bool IsNameInUse(const std::string_view name) override;
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;
std::optional<IProperty::Info> GetPropertyInfo(const LWOOBJID id) override;
std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
private:
CppSQLite3Statement CreatePreppedStmt(const std::string& query);
@@ -170,91 +182,91 @@ private:
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const std::string_view param) {
LOG_DEBUG("%s", param.data());
LOG("%s", param.data());
stmt.bind(index, param.data());
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const char* param) {
LOG_DEBUG("%s", param);
LOG("%s", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const std::string param) {
LOG_DEBUG("%s", param.c_str());
LOG("%s", param.c_str());
stmt.bind(index, param.c_str());
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const int8_t param) {
LOG_DEBUG("%u", param);
LOG("%u", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const uint8_t param) {
LOG_DEBUG("%d", param);
LOG("%d", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const int16_t param) {
LOG_DEBUG("%u", param);
LOG("%u", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const uint16_t param) {
LOG_DEBUG("%d", param);
LOG("%d", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const uint32_t param) {
LOG_DEBUG("%u", param);
LOG("%u", param);
stmt.bind(index, static_cast<int32_t>(param));
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const int32_t param) {
LOG_DEBUG("%d", param);
LOG("%d", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const int64_t param) {
LOG_DEBUG("%llu", param);
LOG("%llu", param);
stmt.bind(index, static_cast<sqlite_int64>(param));
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const uint64_t param) {
LOG_DEBUG("%llu", param);
LOG("%llu", param);
stmt.bind(index, static_cast<sqlite_int64>(param));
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const float param) {
LOG_DEBUG("%f", param);
LOG("%f", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const double param) {
LOG_DEBUG("%f", param);
LOG("%f", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const bool param) {
LOG_DEBUG("%d", param);
LOG("%d", param);
stmt.bind(index, param);
}
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const std::istream* param) {
LOG_DEBUG("Blob");
LOG("Blob");
// This is the one time you will ever see me use const_cast.
std::stringstream stream;
stream << param->rdbuf();
@@ -264,10 +276,10 @@ inline void SetParam(PreppedStmtRef stmt, const int index, const std::istream* p
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const std::optional<uint32_t> param) {
if (param) {
LOG_DEBUG("%d", param.value());
LOG("%d", param.value());
stmt.bind(index, static_cast<int>(param.value()));
} else {
LOG_DEBUG("Null");
LOG("Null");
stmt.bindNull(index);
}
}
@@ -275,10 +287,10 @@ inline void SetParam(PreppedStmtRef stmt, const int index, const std::optional<u
template<>
inline void SetParam(PreppedStmtRef stmt, const int index, const std::optional<LWOOBJID> param) {
if (param) {
LOG_DEBUG("%d", param.value());
LOG("%d", param.value());
stmt.bind(index, static_cast<sqlite_int64>(param.value()));
} else {
LOG_DEBUG("Null");
LOG("Null");
stmt.bindNull(index);
}
}

View File

@@ -2,6 +2,7 @@
#include "eGameMasterLevel.h"
#include "Database.h"
#include "json.hpp"
std::optional<IAccounts::Info> SQLiteDatabase::GetAccountInfo(const std::string_view username) {
auto [_, result] = ExecuteSelect("SELECT * FROM accounts WHERE name = ? LIMIT 1", username);
@@ -48,3 +49,137 @@ uint32_t SQLiteDatabase::GetAccountCount() {
return res.getIntField("count");
}
void SQLiteDatabase::RecordFailedAttempt(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
}
void SQLiteDatabase::ClearFailedAttempts(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = CURRENT_TIMESTAMP WHERE id = ?;", accountId);
}
void SQLiteDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
ExecuteUpdate("UPDATE accounts SET lockout_time = datetime(?, 'unixepoch') WHERE id = ?;", lockoutUntil, accountId);
}
bool SQLiteDatabase::IsLockedOut(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
if (result.eof()) {
return false;
}
const char* lockoutTime = result.getStringField("lockout_time");
if (lockoutTime == nullptr || strlen(lockoutTime) == 0 || strcmp(lockoutTime, "0") == 0) {
return false;
}
// If lockout_time is set and in the future, account is locked
// For now, simplified check - if lockout_time exists, it's locked
return true;
}
uint8_t SQLiteDatabase::GetFailedAttempts(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
if (result.eof()) {
return 0;
}
return result.getIntField("failed_attempts");
}
nlohmann::json SQLiteDatabase::GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at FROM accounts";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "banned"; break;
case 3: orderColumnName = "locked"; break;
case 4: orderColumnName = "gm_level"; break;
case 5: orderColumnName = "mute_expire"; break;
case 6: orderColumnName = "created_at"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM accounts;";
auto [_, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM accounts WHERE name LIKE '%' || ? || '%';";
auto [__, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json accountsArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json account = {
{"id", result.getIntField("id")},
{"name", result.getStringField("name")},
{"banned", result.getIntField("banned")},
{"locked", result.getIntField("locked")},
{"gm_level", result.getIntField("gm_level")},
{"mute_expire", result.getInt64Field("mute_expire")},
{"created_at", result.getStringField("created_at")}
};
accountsArray.push_back(account);
result.nextRow();
}
nlohmann::json response = {
{"draw", 1},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", accountsArray}
};
return response;
}
nlohmann::json SQLiteDatabase::GetAccountById(uint32_t accountId) {
try {
auto [_, result] = ExecuteSelect("SELECT * FROM accounts WHERE id = ? LIMIT 1;", accountId);
if (result.eof()) {
return nlohmann::json{{"error", "Account not found"}};
}
nlohmann::json account = {
{"id", result.getIntField("id")},
{"name", result.getStringField("name")},
{"banned", result.getIntField("banned")},
{"locked", result.getIntField("locked")},
{"gm_level", result.getIntField("gm_level")},
{"mute_expire", result.getInt64Field("mute_expire")},
{"created_at", result.getStringField("created_at")}
};
return account;
} catch (const CppSQLite3Exception& e) {
LOG_DEBUG("SQLite Error: %s", e.errorMessage());
return nlohmann::json{{"error", "Database error"}};
}
}

View File

@@ -4,3 +4,75 @@ void SQLiteDatabase::InsertNewBugReport(const IBugReports::Info& info) {
ExecuteInsert("INSERT INTO `bug_reports`(body, client_version, other_player_id, selection, reporter_id) VALUES (?, ?, ?, ?, ?)",
info.body, info.clientVersion, info.otherPlayer, info.selection, info.characterId);
}
#include "json.hpp"
std::string SQLiteDatabase::GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, body, client_version, other_player_id, selection, submitted FROM bug_reports";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE body LIKE '%' || ? || '%' OR other_player_id LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "other_player_id"; break;
case 2: orderColumnName = "client_version"; break;
case 3: orderColumnName = "submitted"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM bug_reports;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM bug_reports WHERE body LIKE '%' || ? || '%' OR other_player_id LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json reportsArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json report = {
{"id", result.getIntField("id")},
{"other_player_id", result.getStringField("other_player_id")},
{"client_version", result.getStringField("client_version")},
{"selection", result.getStringField("selection")},
{"submitted", result.getStringField("submitted")},
{"body", result.getStringField("body")}
};
reportsArray.push_back(report);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", reportsArray}
};
return response.dump();
}

View File

@@ -55,6 +55,13 @@ std::vector<LWOOBJID> SQLiteDatabase::GetAccountCharacterIds(const LWOOBJID acco
return toReturn;
}
uint32_t SQLiteDatabase::GetCharacterCount() {
auto [_, res] = ExecuteSelect("SELECT COUNT(*) as count FROM charinfo;");
if (res.eof()) return 0;
return res.getIntField("count");
}
void SQLiteDatabase::InsertNewCharacter(const ICharInfo::Info info) {
ExecuteInsert(
"INSERT INTO `charinfo`(`id`, `account_id`, `name`, `pending_name`, `needs_rename`, `last_login`, `prop_clone_id`) VALUES (?,?,?,?,?,?,(SELECT IFNULL(MAX(`prop_clone_id`), 0) + 1 FROM `charinfo`))",

View File

@@ -17,3 +17,73 @@ void SQLiteDatabase::UpdateCharacterXml(const LWOOBJID charId, const std::string
void SQLiteDatabase::InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) {
ExecuteInsert("INSERT INTO `charxml` (`id`, `xml_data`) VALUES (?,?)", characterId, lxfml);
}
#include "json.hpp"
std::string SQLiteDatabase::GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT c.id, c.name, c.account_id, c.last_login, a.name as account_name FROM charinfo c JOIN accounts a ON c.account_id = a.id";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE c.name LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "c.id";
switch (orderColumn) {
case 0: orderColumnName = "c.id"; break;
case 1: orderColumnName = "c.name"; break;
case 2: orderColumnName = "a.name"; break;
case 3: orderColumnName = "c.last_login"; break;
default: orderColumnName = "c.id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM charinfo;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM charinfo WHERE name LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json charactersArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json character = {
{"id", result.getInt64Field("id")},
{"name", result.getStringField("name")},
{"account_name", result.getStringField("account_name")},
{"last_login", result.getInt64Field("last_login")}
};
charactersArray.push_back(character);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", charactersArray}
};
return response.dump();
}

View File

@@ -47,7 +47,7 @@ std::vector<MailInfo> SQLiteDatabase::GetMailForPlayer(const LWOOBJID characterI
}
std::optional<MailInfo> SQLiteDatabase::GetMail(const uint64_t mailId) {
auto [_, res] = ExecuteSelect("SELECT attachment_lot, attachment_count, receiver_id FROM mail WHERE id=? LIMIT 1;", mailId);
auto [_, res] = ExecuteSelect("SELECT attachment_lot, attachment_count FROM mail WHERE id=? LIMIT 1;", mailId);
if (res.eof()) {
return std::nullopt;
@@ -56,7 +56,6 @@ std::optional<MailInfo> SQLiteDatabase::GetMail(const uint64_t mailId) {
MailInfo toReturn;
toReturn.itemLOT = res.getIntField("attachment_lot");
toReturn.itemCount = res.getIntField("attachment_count");
toReturn.receiverId = res.getInt64Field("receiver_id");
return toReturn;
}

View File

@@ -9,3 +9,75 @@ std::optional<bool> SQLiteDatabase::IsPlaykeyActive(const int32_t playkeyId) {
return keyCheckRes.getIntField("active");
}
#include "json.hpp"
std::string SQLiteDatabase::GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, key_string, key_uses, created_at, active FROM play_keys";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE key_string LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "key_string"; break;
case 2: orderColumnName = "key_uses"; break;
case 3: orderColumnName = "created_at"; break;
case 4: orderColumnName = "active"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM play_keys;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM play_keys WHERE key_string LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json keysArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json key = {
{"id", result.getIntField("id")},
{"key_string", result.getStringField("key_string")},
{"key_uses", result.getIntField("key_uses")},
{"created_at", result.getStringField("created_at")},
{"active", result.getIntField("active")}
};
keysArray.push_back(key);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", keysArray}
};
return response.dump();
}

View File

@@ -18,8 +18,8 @@ IProperty::Info ReadPropertyInfo(CppSQLite3Query& propertyEntry) {
return toReturn;
}
IProperty::PropertyEntranceResult SQLiteDatabase::GetProperties(const IProperty::PropertyLookup& params) {
IProperty::PropertyEntranceResult result;
std::optional<IProperty::PropertyEntranceResult> SQLiteDatabase::GetProperties(const IProperty::PropertyLookup& params) {
std::optional<IProperty::PropertyEntranceResult> result;
std::string query;
std::pair<CppSQLite3Statement, CppSQLite3Query> propertiesRes;
@@ -73,7 +73,8 @@ IProperty::PropertyEntranceResult SQLiteDatabase::GetProperties(const IProperty:
params.playerId
);
if (!count.eof()) {
result.totalEntriesMatchingQuery = count.getIntField("count");
result = IProperty::PropertyEntranceResult();
result->totalEntriesMatchingQuery = count.getIntField("count");
}
} else {
if (params.sortChoice == SORT_TYPE_REPUTATION) {
@@ -126,13 +127,15 @@ IProperty::PropertyEntranceResult SQLiteDatabase::GetProperties(const IProperty:
params.playerSort
);
if (!count.eof()) {
result.totalEntriesMatchingQuery = count.getIntField("count");
result = IProperty::PropertyEntranceResult();
result->totalEntriesMatchingQuery = count.getIntField("count");
}
}
auto& [_, properties] = propertiesRes;
if (!properties.eof() && !result.has_value()) result = IProperty::PropertyEntranceResult();
while (!properties.eof()) {
result.entries.push_back(ReadPropertyInfo(properties));
result->entries.push_back(ReadPropertyInfo(properties));
properties.nextRow();
}
@@ -197,3 +200,77 @@ std::optional<IProperty::Info> SQLiteDatabase::GetPropertyInfo(const LWOOBJID id
return ReadPropertyInfo(propertyEntry);
}
#include "json.hpp"
std::string SQLiteDatabase::GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, owner_id, name, mod_approved, reputation, zone_id FROM properties";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "owner_id"; break;
case 3: orderColumnName = "mod_approved"; break;
case 4: orderColumnName = "reputation"; break;
case 5: orderColumnName = "zone_id"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM properties;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM properties WHERE name LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json propertiesArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json property = {
{"id", result.getInt64Field("id")},
{"owner_id", result.getInt64Field("owner_id")},
{"name", result.getStringField("name")},
{"mod_approved", result.getIntField("mod_approved")},
{"reputation", result.getInt64Field("reputation")},
{"zone_id", result.getIntField("zone_id")}
};
propertiesArray.push_back(property);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", propertiesArray}
};
return response.dump();
}

Some files were not shown because too many files have changed in this diff Show More