Compare commits

..

54 Commits

Author SHA1 Message Date
Aaron Kimbrell
dd24e20165 fix: improve skill management in InventoryComponent to ensure correct client updates 2026-06-09 16:03:15 -05:00
Aaron Kimbrell
54dc3a0b80 refactor: update behavior slot determination to use equipLocation instead of itemType 2026-06-08 22:27:42 -05:00
David Markowitz
a156a8fcba fix: security vulnerabilities (#1980)
* fix: security vulnerabilities

Tested that all functions related to the touched files work

will test sqlite on a CI build

* fix failing test

* ai feedback

* add buffer size checking

* use c_str

* dont log session key

* Try this for a mac definition

* be quiet apple
2026-06-07 20:59:11 -07:00
Aaron Kimbrell
f6c9a27a2b fix: update permissions for PR title check workflow (#1981) 2026-06-07 02:07:17 -07:00
Aaron Kimbrell
8e09ffd6e8 feat: enhance CI/CD workflows with Docker support and artifact management (#1977)
* feat: enhance CI/CD workflows with Docker support and artifact management

* chore: update GitHub Actions workflows with version pinning and permission adjustments

* feat: update CI workflows to support new OS versions and improve artifact handling

* chore: remove macOS-specific installation of libssl and XCode switching

* fix: update artifact zipping logic to target build directories in canary and release workflows

* chore: update actions/checkout to version 6.0.2 in CI workflow

* fix: update continue-on-error condition in CI workflow and add macOS RelWithDebInfo build preset

* fix: rename step to accurately reflect Docker image signing process

* don't ignore pdb's
2026-06-06 01:30:12 -05:00
David Markowitz
0c1808686c fix: tac arc absolute value bug (#1979)
Tested that tac arc correctly checks the full range
2026-06-06 01:17:00 -05:00
Johntor
4d6a624da2 docs: Refine command documentation (#1978)
* Refine command documentation

Updated command descriptions From dGame/dUtilities/SlashCommandHandler.cpp. Added aliases and improved formatting for better readability.

* fix: grammatical feedback

---------

Co-authored-by: David Markowitz <39972741+EmosewaMC@users.noreply.github.com>
2026-06-02 00:00:13 -07:00
David Markowitz
4ab09cf1aa Remove non-functioning saving of gm invis, re-add gm invis as a feature (#1976)
Does not save, only works for this world.  Fixed an issue where the incorrect comparison was used to make players invisible again (the same check that makes then INvisible needs to make them visible.)
2026-05-27 04:35:00 -05:00
David Markowitz
4ef9f43266 feat: make gm registration simpler and safer (#1932)
* gm registration re-work

* fix errors

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove duplicate message

* Remove duplicate function

* add null check

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-19 13:42:56 -05:00
David Markowitz
f3a5add038 fix(tac arc): incorrect check causing any enemy lower than you to not attack you (#1975)
tested that close range enemies above and below you now correctly attack you
2026-05-18 03:15:03 -07:00
David Markowitz
f5d33a773a fix: security fixes (#1974)
* fix: security fixes

dont print passwords for worlds
bound strings from clients
actually enable encryption between rakpeers
dont allow underflow when reading a string

Tested that packets are encrypted
tested that models can still be built
tested that combat still works

* add check

* use c++ nullptr instead of NULL

* initialize to 0

* globalize constant (should be in a namespace at least in the future)

* Update GameMessages.cpp

* check bounds
2026-05-17 14:21:22 -05:00
David Markowitz
67bbe4c1f0 fix(Missions): mission progression undefined behavior (#1973)
* fix: mission progression undefined behavior

defer the sub calls until after the loop has finished, that way no ub happens.  tested that mission progression all the way up until joining a faction still works and meta missions still function.

* default initialize

* Update MissionComponent.h
2026-05-17 14:21:08 -05:00
David Markowitz
482ff82656 fix: adding custom behaviors (#1969) 2026-04-14 01:04:26 -07:00
David Markowitz
8061f512aa feat: fix go outside and play mission (#1967)
* feat: fix go outside and play mission

* Update dWorldServer/WorldServer.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dGame/dComponents/MissionComponent.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* const

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 00:55:57 -05:00
David Markowitz
247576e101 fix: use copy ellision (#1963)
* use copy ellision

tested that the server still starts

* Update dDatabase/GameDatabase/MySQL/MySQLDatabase.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 15:35:28 -07:00
David Markowitz
8dfdca7fbd feat: add mission progression for behaviors (#1962)
* feat: add mission progression for behaviors

* Add const to ptr

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 13:02:23 -07:00
Aaron Kimbrell
8283d1fa95 fix: mariadb on newer gcc and newer cmake version (#1961)
* Fix newer gcc issues in mariadb

* fix error with newer cmake versions

* update mariadb to latest

* fix macos and docker

* fix: update Windows MSI package comments and align Connector/C++ version

* Update cmake/FindMariaDB.cmake

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: only pass CMAKE_POLICY_VERSION_MINIMUM to ExternalProject when CMake >= 4.0

Agent-Logs-Url: https://github.com/DarkflameUniverse/DarkflameServer/sessions/a247f729-a0b1-4fb6-825e-d23045b1ee55

Co-authored-by: aronwk-aaron <26027722+aronwk-aaron@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-03-29 13:59:09 -05:00
David Markowitz
434c9b6315 fix: imaginite on racing minigames and add null checks (#1958) 2026-02-23 01:16:36 -08:00
David Markowitz
3c64b26c39 fix: macos ci (#1955) 2026-02-11 19:49:51 -08:00
David Markowitz
347b1d17d4 fix: not checking pending names on rename (#1954) 2026-02-11 19:49:39 -08:00
David Markowitz
c723ce2588 fix: donations requiring new high score vs adding to previous one (#1951)
tested that jawbox works as intended now for donation counting on the leaderboards
2026-01-13 22:48:29 -08:00
Terrev
66b7d3606e fix: flower activity (#1950) 2025-12-26 13:58:10 -08:00
David Markowitz
40fef36530 fix: coins dropping on killer (#1948)
tested that when a player dies the coins spawn on their body instead
2025-12-13 22:00:58 -08:00
David Markowitz
bf020baa17 fix: temp fixes for ghosting so I can continue being on break (#1947)
* fix: temp fixes for ghosting so I can continue being on break

disables the ghost feature for now so i can continue my break

* Update GhostComponent.cpp
2025-12-08 20:39:33 -08:00
David Markowitz
a713216540 fix: saving gm invis for non gms (#1940) 2025-11-18 22:04:07 -06:00
David Markowitz
ea86a708e4 fix: uninitialized memory (#1937) 2025-11-18 19:06:03 -08:00
David Markowitz
ca7424cbeb fix: chest loot not working (#1933)
* fix bons and dragon loot

* fix chest server loot
2025-11-16 16:17:49 -06:00
David Markowitz
991e55f305 feat: dont drop loot for dead players if configured in the zone activity settings (#1935)
* feat: dont drop loot for dead players if configured in the zone activity settings

* fix errors

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dGame/dComponents/ActivityComponent.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dGame/dUtilities/Loot.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 16:17:26 -06:00
David Markowitz
5410acffaa fix: chat server crash (#1931)
* fix: chat server crash

* Update dChatServer/ChatServer.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 13:48:57 -08:00
David Markowitz
86f8601bbd feat: various debug command improvements (#1934)
* feat: various debug command improvements

* add missing utility function

* Update dGame/dUtilities/SlashCommands/DEVGMCommands.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 13:48:16 -08:00
David Markowitz
4658318a3a fix: deactivate bubble buff from server too (#1936) 2025-11-16 13:46:59 -08:00
Aaron Kimbrell
11d44ffb98 feat: proper gminvs with ghosting (#1920)
* feat: proper gminvis ghosting

* address feedback

---------

Co-authored-by: David Markowitz <39972741+EmosewaMC@users.noreply.github.com>
2025-11-15 16:43:33 -08:00
David Markowitz
2fb16420f3 fix: ape anchor not respawning (#1927)
* fix: ape anchor not respawning

* add return

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 13:30:02 -08:00
David Markowitz
96089a8d9a fix: fb race activityid (#1929) 2025-11-15 13:29:11 -08:00
David Markowitz
eac50acfcc fix: correct mission tracking (#1930)
checked that live captures did not track achievements in this count
2025-11-15 13:29:03 -08:00
David Markowitz
ca60787055 fix: ffa -> shared loot for activities (#1925) 2025-10-26 01:01:21 -07:00
David Markowitz
396dcb0465 feat: add logger feature to log on function entry and exit (#1924)
* feat: add logger feature to log on function entry and exit

* i didnt save the file
2025-10-25 14:53:49 -05:00
David Markowitz
6e545eb1b9 Update Loot.cpp (#1923) 2025-10-24 21:53:00 -07:00
David Markowitz
46aac016fd fix: unintended stopping (#1922) 2025-10-23 23:41:16 -05:00
David Markowitz
83823fa64f fix: resurrect not available for non-gms (#1919) 2025-10-20 23:05:22 -07:00
David Markowitz
0dd504c803 feat: behavior states (#1918) 2025-10-20 01:16:36 -05:00
David Markowitz
a70c365c23 feat banana (#1917) 2025-10-19 14:00:14 -05:00
David Markowitz
281d9762ef fix: tac arc sorting and target acquisition (#1916) 2025-10-19 07:23:54 -05:00
David Markowitz
002aa896d8 feat: debug information (#1915) 2025-10-19 07:22:45 -05:00
David Markowitz
f3a5f60d81 feat: more destroyable debug info (#1912)
* feat: more destroyable info

* Change type and remove duplicate value
2025-10-16 14:15:02 -05:00
David Markowitz
4c9c773ec5 fix: powerup drops and hardcore loot drops (#1914)
tested the following are now functional
ag buff station
tiki torch
ve rocket part boxes
ns statue
property behavior
extra items from full inventory
hardcore drops (items and coins)
2025-10-16 14:13:38 -05:00
David Markowitz
ec6253c80c fix: coin dupe on same team (#1911)
* feat: Loot rework

* Allow dupe powerup pickups

* change default team loot to shared

* fix: coin dupe on team
2025-10-15 22:36:45 -05:00
Aaron Kimbrell
c2dba31f70 fix: bbb splitting dupe issue (#1908)
* fix bbb group splitting issues

* address feedback
2025-10-15 16:45:09 -07:00
David Markowitz
74630b56c8 feat: Loot rework (#1909)
* feat: Loot rework

* Allow dupe powerup pickups

* change default team loot to shared
2025-10-15 00:53:39 -05:00
David Markowitz
fd6029ae10 feat: read from server macros folder as well (#1906) 2025-10-11 15:33:38 -07:00
David Markowitz
ff645a6662 feat: model debug (#1907) 2025-10-11 15:33:28 -07:00
David Markowitz
e051229fb6 feat: InventoryComponent debug info (#1902) 2025-10-11 00:58:52 -05:00
Aaron Kimbrell
ce28834dce feat: lxfml splitting for bbb (#1877)
* LXFML SPLITTING
Included test file

* move base to global namespace

* wip need to test

* update last fixes

* update world sending bbb to be more efficient

* Address feedback form Emo in doscord

* Make LXFML class for robust and add more tests to edge cases and malformed data

* get rid of the string copy and make the deep clone have a recursive limit

* cleanup tests

* fix test file locations

* fix file path

* KISS

* add cmakelists

* fix typos

* NL @ EOF

* tabs and split out to func

* naming standard
2025-10-10 23:07:16 -05:00
David Markowitz
cbdd5d9bc6 fix: dying while dead (#1905) 2025-10-10 01:15:21 -05:00
319 changed files with 5137 additions and 14028 deletions

29
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,29 @@
# GitHub Copilot Instructions
* c++20 standard, please use the latest features except NO modules.
* use `.contains` for searching in associative containers
* use const as much as possible. If it can be const, it should be made const
* DO NOT USE const_cast EVER.
* use `cstdint` bitwidth types ALWAYS for integral types.
* NEVER use std::wstring. If wide strings are necessary, use std::u16string with conversion utilties in GeneralUtils.h.
* Functions are ALWAYS PascalCase.
* local variables are camelCase
* NEVER use snake case
* indentation is TABS, not SPACES.
* TABS are 4 spaces by default
* Use trailing braces ALWAYS
* global variables are prefixed with `g_`
* if global variables or functions are needed, they should be located in an anonymous namespace
* Use `GeneralUtils::TryParse` for ANY parsing of strings to integrals.
* Use brace initialization when possible.
* ALWAYS default initialize variables.
* Pointers should be avoided unless necessary. Use references when the pointer has been checked and should not be null
* headers should be as compact as possible. Do NOT include extra data that isnt needed.
* Remember to include logs (LOG macro uses printf style logging) while putting verbose logs under LOG_DEBUG.
* NEVER USE `RakNet::BitStream::ReadBit`
* NEVER assume pointers are good, always check if they are null. Once a pointer is checked and is known to be non-null, further accesses no longer need checking
* Be wary of TOCTOU. Prevent all possible issues relating to TOCTOU.
* new memory allocations should never be used unless absolutely necessary.
* new for reconstruction of objects is allowed
* Prefer following the format of the file over correct formatting. Consistency over correctness.
* When using auto, ALWAYS put a * for pointers.

View File

@@ -1,14 +1,14 @@
name: CI
name: Docker
on:
push:
branches:
- "main"
- main
tags:
- "v*.*.*"
pull_request:
branches:
- "main"
- main
env:
REGISTRY: ghcr.io
@@ -20,15 +20,21 @@ jobs:
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to the Container registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -36,21 +42,32 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
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
uses: docker/build-push-action@v5
id: push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
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,6 +3,8 @@ name: CI
on:
push:
branches: [ main ]
tags:
- "v*.*.*"
pull_request:
branches: [ main ]
@@ -10,38 +12,51 @@ jobs:
build-and-test:
name: Build & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
continue-on-error: true
continue-on-error: ${{ github.event_name == 'pull_request' }}
strategy:
matrix:
os: [ windows-2022, ubuntu-22.04, macos-13 ]
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
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true
- name: Add msbuild to PATH (Windows only)
if: ${{ matrix.os == 'windows-2022' }}
uses: microsoft/setup-msbuild@767f00a3f09872d96a0cb9fcd5e6a4ff33311330
if: ${{ matrix.os == 'windows-2025' }}
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3
with:
vs-version: '[17,18)'
msbuild-architecture: x64
- name: Install libssl and switch to XCode 15.2 (Mac Only)
if: ${{ matrix.os == 'macos-13' }}
run: |
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
uses: lukka/get-cmake@591817e96fcad43505fb4eae36172462abb3a42e # v4.3.3
with:
cmakeVersion: "~3.25.0" # <--= optional, use most recent 3.25.x version
cmakeVersion: "~3.25.0"
- name: cmake
uses: lukka/run-cmake@67c73a83a46f86c4e0b96b741ac37ff495478c38
uses: lukka/run-cmake@5d55ea7949e25f69f0ecb516d8d572297e03a956 # v10.9
with:
workflowPreset: "ci-${{matrix.os}}"
workflowPreset: "${{ matrix.debug_preset }}"
- name: Extract Linux debug symbols
if: matrix.os == 'ubuntu-24.04'
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@6027e3dd177782cd8ab9af838c04fd81a07f1d47
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build-${{matrix.os}}
name: build-${{matrix.artifact}}
path: |
build/*/*Server*
build/*/*.ini
@@ -52,5 +67,30 @@ 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

93
.github/workflows/canary.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
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 }}

38
.github/workflows/pr-title-check.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
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

70
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
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,3 +126,5 @@ docker-compose.override.yml
# CMake scripts
!cmake/*
!cmake/toolchains/*
.mcp.json
.claude/

View File

@@ -15,6 +15,11 @@ 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
@@ -67,7 +72,11 @@ set(RECASTNAVIGATION_EXAMPLES OFF CACHE BOOL "" FORCE)
# Disabled no-register
# Disabled unknown pragmas because Linux doesn't understand Windows pragmas.
if(UNIX)
add_link_options("-Wl,-rpath,$ORIGIN/")
if(APPLE)
add_link_options("-Wl,-rpath,@loader_path/")
else()
add_link_options("-Wl,-rpath,$ORIGIN/")
endif()
add_compile_options("-fPIC")
add_compile_definitions(_GLIBCXX_USE_CXX11_ABI=0 _GLIBCXX_USE_CXX17_ABI=0)
@@ -89,7 +98,6 @@ elseif(MSVC)
add_compile_options("/wd4267" "/utf-8" "/volatile:iso" "/Zc:inline")
elseif(WIN32)
add_compile_definitions(_CRT_SECURE_NO_WARNINGS)
add_compile_definitions(NOMINMAX)
endif()
# Our output dir
@@ -127,7 +135,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" "dashboardconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf")
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf")
message(STATUS "Checking resource file integrity")
include(Utils)
@@ -254,7 +262,6 @@ include_directories(
"thirdparty/MD5"
"thirdparty/nlohmann"
"thirdparty/mongoose"
"thirdparty/inja"
)
# Add system specfic includes for Apple, Windows and Other Unix OS' (including Linux)
@@ -324,7 +331,6 @@ 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,6 +162,13 @@
"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": [
@@ -255,7 +262,7 @@
{
"name": "macos-relwithdebinfo",
"inherits": "default",
"configurePreset": "macos",
"configurePreset": "macos-relwithdebinfo",
"displayName": "[RelWithDebInfo] MacOS",
"description": "This preset is used to build in release mode with debug info on MacOS",
"configuration": "RelWithDebInfo"
@@ -374,7 +381,7 @@
{
"name": "macos-relwithdebinfo",
"inherits": "default",
"configurePreset": "macos",
"configurePreset": "macos-relwithdebinfo",
"displayName": "[RelWithDebInfo] MacOS",
"description": "Runs all tests on a MacOS configuration",
"configuration": "RelWithDebInfo"
@@ -603,7 +610,7 @@
"steps": [
{
"type": "configure",
"name": "macos"
"name": "macos-relwithdebinfo"
},
{
"type": "build",
@@ -616,7 +623,7 @@
]
},
{
"name": "ci-macos-13",
"name": "ci-macos-15-intel",
"displayName": "[Release] MacOS",
"description": "CI workflow preset for MacOS",
"steps": [

View File

@@ -16,7 +16,9 @@ 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/
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/
FROM debian:12 as runtime

35
cliff.toml Normal file
View File

@@ -0,0 +1,35 @@
[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,14 +10,15 @@ if(WIN32 AND NOT MARIADB_BUILD_SOURCE)
file(MAKE_DIRECTORY "${MARIADB_MSI_DIR}")
file(MAKE_DIRECTORY "${MARIADB_CONNECTOR_DIR}")
# 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")
# 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")
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")
@@ -79,23 +80,39 @@ 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)
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0\ -Wno-inconsistent-missing-override\ -include\ cstdint)
else()
set(MARIADB_EXTRA_CMAKE_ARGS
-DCMAKE_C_FLAGS=-w # disable zlib warnings
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0)
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0\ -include\ cstdint)
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}
@@ -103,6 +120,7 @@ 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,6 +3,7 @@
#include "MessageType/Chat.h"
#include "BitStreamUtils.h"
#include "Game.h"
#include "dConfig.h"
#include "Logger.h"
#include "eObjectBits.h"
@@ -72,8 +73,8 @@ void ChatIgnoreList::AddIgnore(Packet* packet) {
return;
}
constexpr int32_t MAX_IGNORES = 32;
if (receiver.ignoredPlayers.size() > MAX_IGNORES) {
const int32_t MAX_IGNORES = Game::config->GetValue("max_ignores", 32);
if (receiver.ignoredPlayers.size() >= MAX_IGNORES) {
LOG_DEBUG("Player %llu has too many ignores", playerId);
return;
}

View File

@@ -435,6 +435,11 @@ 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);
@@ -479,6 +484,11 @@ 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

@@ -202,8 +202,11 @@ int main(int argc, char** argv) {
//Delete our objects here:
Database::Destroy("ChatServer");
delete Game::server;
Game::server = nullptr;
delete Game::logger;
Game::logger = nullptr;
delete Game::config;
Game::config = nullptr;
return EXIT_SUCCESS;
}

View File

@@ -26,14 +26,12 @@ void HandleHTTPPlayersRequest(HTTPReply& reply, std::string body) {
const json data = Game::playerContainer;
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump();
reply.contentType = ContentType::JSON;
}
void HandleHTTPTeamsRequest(HTTPReply& reply, std::string body) {
const json data = TeamContainer::GetTeamContainer();
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump();
reply.contentType = ContentType::JSON;
}
void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
@@ -41,7 +39,6 @@ void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
if (!data) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = ContentType::JSON;
return;
}
@@ -50,7 +47,6 @@ void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
if (!check.empty()) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = check;
reply.contentType = ContentType::JSON;
} else {
ChatPackets::Announcement announcement;
@@ -60,7 +56,6 @@ void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
reply.status = eHTTPStatusCode::OK;
reply.message = "{\"status\":\"Announcement Sent\"}";
reply.contentType = ContentType::JSON;
}
}

View File

@@ -176,6 +176,7 @@ 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

@@ -477,7 +477,7 @@ TeamData* TeamContainer::CreateLocalTeam(std::vector<LWOOBJID> members) {
}
}
newTeam->lootFlag = 1;
newTeam->lootFlag = 0;
TeamStatusUpdate(newTeam);

View File

@@ -3,6 +3,7 @@
#include <stdexcept>
#include "Amf3.h"
#include "StringifiedEnum.h"
/**
* AMF3 Reference document https://rtmp.veriskope.com/pdf/amf3-file-format-spec.pdf
@@ -53,7 +54,7 @@ std::unique_ptr<AMFBaseValue> AMFDeserialize::Read(RakNet::BitStream& inStream)
case eAmf::VectorObject:
[[fallthrough]];
case eAmf::Dictionary:
throw marker;
throw std::invalid_argument(StringifiedEnum::ToString(marker).data());
default:
throw std::invalid_argument("Invalid AMF3 marker" + std::to_string(static_cast<int32_t>(marker)));
}
@@ -88,6 +89,11 @@ 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
@@ -117,6 +123,12 @@ 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

@@ -374,6 +374,21 @@ public:
return value->Insert<AmfType>("value", std::make_unique<AmfType>());
}
AMFArrayValue& PushDebug(const NiPoint3& point) {
PushDebug<AMFDoubleValue>("X") = point.x;
PushDebug<AMFDoubleValue>("Y") = point.y;
PushDebug<AMFDoubleValue>("Z") = point.z;
return *this;
}
AMFArrayValue& PushDebug(const NiQuaternion& rot) {
PushDebug<AMFDoubleValue>("W") = rot.w;
PushDebug<AMFDoubleValue>("X") = rot.x;
PushDebug<AMFDoubleValue>("Y") = rot.y;
PushDebug<AMFDoubleValue>("Z") = rot.z;
return *this;
}
private:
/**
* The associative portion. These values are key'd with strings to an AMFValue.

View File

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

View File

@@ -308,8 +308,9 @@ 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();
const auto index = std::stoi(GeneralUtils::SplitString(filename, '_').at(0));
filenames.emplace(index, std::move(filename));
// 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));
}
// Now sort the map by the oldest migration.

View File

@@ -205,6 +205,12 @@ 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);
@@ -237,6 +243,12 @@ 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
/**
@@ -258,6 +270,11 @@ 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
@@ -268,6 +285,11 @@ 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

@@ -96,3 +96,17 @@ bool Logger::GetLogToConsole() const {
}
return toReturn;
}
FuncEntry::FuncEntry(const char* funcName, const char* fileName, const uint32_t line) {
m_FuncName = funcName;
if (!m_FuncName) m_FuncName = "Unknown";
m_Line = line;
m_FileName = fileName;
LOG("--> %s::%s:%i", m_FileName, m_FuncName, m_Line);
}
FuncEntry::~FuncEntry() {
if (!m_FuncName || !m_FileName) return;
LOG("<-- %s::%s:%i", m_FileName, m_FuncName, m_Line);
}

View File

@@ -32,6 +32,19 @@ constexpr const char* GetFileNameFromAbsolutePath(const char* path) {
#define LOG(message, ...) do { auto str_ = FILENAME_AND_LINE; Game::logger->Log(str_, message, ##__VA_ARGS__); } while(0)
#define LOG_DEBUG(message, ...) do { auto str_ = FILENAME_AND_LINE; Game::logger->LogDebug(str_, message, ##__VA_ARGS__); } while(0)
// Place this right at the start of a function. Will log a message when called and then once you leave the function.
#define LOG_ENTRY auto str_ = GetFileNameFromAbsolutePath(__FILE__); FuncEntry funcEntry_(__FUNCTION__, str_, __LINE__)
class FuncEntry {
public:
FuncEntry(const char* funcName, const char* fileName, const uint32_t line);
~FuncEntry();
private:
const char* m_FuncName = nullptr;
const char* m_FileName = nullptr;
uint32_t m_Line = 0;
};
// Writer class for writing data to files.
class Writer {
public:

View File

@@ -5,13 +5,43 @@
#include "TinyXmlUtils.h"
#include <ranges>
#include <unordered_map>
#include <unordered_set>
#include <functional>
#include <sstream>
namespace {
// The base LXFML xml file to use when creating new models.
std::string g_base = R"(<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<LXFML versionMajor="5" versionMinor="0">
<Meta>
<Application name="LEGO Universe" versionMajor="0" versionMinor="0"/>
<Brand name="LEGOUniverse"/>
<BrickSet version="457"/>
</Meta>
<Bricks>
</Bricks>
<RigidSystems>
</RigidSystems>
<GroupSystems>
<GroupSystem>
</GroupSystem>
</GroupSystems>
</LXFML>)";
}
Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoint3& curPosition) {
Result toReturn;
// Handle empty or invalid input
if (data.empty()) {
return toReturn;
}
tinyxml2::XMLDocument doc;
const auto err = doc.Parse(data.data());
// Use length-based parsing to avoid expensive string copy
const auto err = doc.Parse(data.data(), data.size());
if (err != tinyxml2::XML_SUCCESS) {
LOG("Failed to parse xml %s.", StringifiedEnum::ToString(err).data());
return toReturn;
}
@@ -20,7 +50,6 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
auto lxfml = reader["LXFML"];
if (!lxfml) {
LOG("Failed to find LXFML element.");
return toReturn;
}
@@ -49,16 +78,19 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
// Calculate the lowest and highest points on the entire model
for (const auto& transformation : transformations | std::views::values) {
auto split = GeneralUtils::SplitString(transformation, ',');
if (split.size() < 12) {
LOG("Not enough in the split?");
continue;
}
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 (split.size() < 12) continue;
auto xOpt = GeneralUtils::TryParse<float>(split[9]);
auto yOpt = GeneralUtils::TryParse<float>(split[10]);
auto zOpt = GeneralUtils::TryParse<float>(split[11]);
if (!xOpt.has_value() || !yOpt.has_value() || !zOpt.has_value()) continue;
auto x = xOpt.value();
auto y = yOpt.value();
auto z = zOpt.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;
@@ -87,13 +119,19 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
for (auto& transformation : transformations | std::views::values) {
auto split = GeneralUtils::SplitString(transformation, ',');
if (split.size() < 12) {
LOG("Not enough in the split?");
continue;
}
auto x = GeneralUtils::TryParse<float>(split[9]).value() - newRootPos.x + curPosition.x;
auto y = GeneralUtils::TryParse<float>(split[10]).value() - newRootPos.y + curPosition.y;
auto z = GeneralUtils::TryParse<float>(split[11]).value() - newRootPos.z + curPosition.z;
auto xOpt = GeneralUtils::TryParse<float>(split[9]);
auto yOpt = GeneralUtils::TryParse<float>(split[10]);
auto zOpt = GeneralUtils::TryParse<float>(split[11]);
if (!xOpt.has_value() || !yOpt.has_value() || !zOpt.has_value()) {
continue;
}
auto x = xOpt.value() - newRootPos.x + curPosition.x;
auto y = yOpt.value() - newRootPos.y + curPosition.y;
auto z = zOpt.value() - newRootPos.z + curPosition.z;
std::stringstream stream;
for (int i = 0; i < 9; i++) {
stream << split[i];
@@ -128,3 +166,345 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
toReturn.center = newRootPos;
return toReturn;
}
// Deep-clone an XMLElement (attributes, text, and child elements) into a target document
// with maximum depth protection to prevent infinite loops
static tinyxml2::XMLElement* CloneElementDeep(const tinyxml2::XMLElement* src, tinyxml2::XMLDocument& dstDoc, int maxDepth = 100) {
if (!src || maxDepth <= 0) return nullptr;
auto* dst = dstDoc.NewElement(src->Name());
// copy attributes
for (const tinyxml2::XMLAttribute* attr = src->FirstAttribute(); attr; attr = attr->Next()) {
dst->SetAttribute(attr->Name(), attr->Value());
}
// copy children (elements and text)
for (const tinyxml2::XMLNode* child = src->FirstChild(); child; child = child->NextSibling()) {
if (const tinyxml2::XMLElement* childElem = child->ToElement()) {
// Recursively clone child elements with decremented depth
auto* clonedChild = CloneElementDeep(childElem, dstDoc, maxDepth - 1);
if (clonedChild) dst->InsertEndChild(clonedChild);
} else if (const tinyxml2::XMLText* txt = child->ToText()) {
auto* n = dstDoc.NewText(txt->Value());
dst->InsertEndChild(n);
} else if (const tinyxml2::XMLComment* c = child->ToComment()) {
auto* n = dstDoc.NewComment(c->Value());
dst->InsertEndChild(n);
}
}
return dst;
}
std::vector<Lxfml::Result> Lxfml::Split(const std::string_view data, const NiPoint3& curPosition) {
std::vector<Result> results;
// Handle empty or invalid input
if (data.empty()) {
return results;
}
// Prevent processing extremely large inputs that could cause hangs
if (data.size() > 10000000) { // 10MB limit
return results;
}
tinyxml2::XMLDocument doc;
// Use length-based parsing to avoid expensive string copy
const auto err = doc.Parse(data.data(), data.size());
if (err != tinyxml2::XML_SUCCESS) {
return results;
}
auto* lxfml = doc.FirstChildElement("LXFML");
if (!lxfml) {
return results;
}
// Build maps: partRef -> Part element, partRef -> Brick element, boneRef -> partRef, brickRef -> Brick element
std::unordered_map<std::string, tinyxml2::XMLElement*> partRefToPart;
std::unordered_map<std::string, tinyxml2::XMLElement*> partRefToBrick;
std::unordered_map<std::string, std::string> boneRefToPartRef;
std::unordered_map<std::string, tinyxml2::XMLElement*> brickByRef;
auto* bricksParent = lxfml->FirstChildElement("Bricks");
if (bricksParent) {
for (auto* brick = bricksParent->FirstChildElement("Brick"); brick; brick = brick->NextSiblingElement("Brick")) {
const char* brickRef = brick->Attribute("refID");
if (brickRef) brickByRef.emplace(std::string(brickRef), brick);
for (auto* part = brick->FirstChildElement("Part"); part; part = part->NextSiblingElement("Part")) {
const char* partRef = part->Attribute("refID");
if (partRef) {
partRefToPart.emplace(std::string(partRef), part);
partRefToBrick.emplace(std::string(partRef), brick);
}
auto* bone = part->FirstChildElement("Bone");
if (bone) {
const char* boneRef = bone->Attribute("refID");
if (boneRef) boneRefToPartRef.emplace(std::string(boneRef), partRef ? std::string(partRef) : std::string());
}
}
}
}
// Collect RigidSystem elements
std::vector<tinyxml2::XMLElement*> rigidSystems;
auto* rigidSystemsParent = lxfml->FirstChildElement("RigidSystems");
if (rigidSystemsParent) {
for (auto* rs = rigidSystemsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) {
rigidSystems.push_back(rs);
}
}
// Collect top-level groups (immediate children of GroupSystem)
std::vector<tinyxml2::XMLElement*> groupRoots;
auto* groupSystemsParent = lxfml->FirstChildElement("GroupSystems");
if (groupSystemsParent) {
for (auto* gs = groupSystemsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) {
for (auto* group = gs->FirstChildElement("Group"); group; group = group->NextSiblingElement("Group")) {
groupRoots.push_back(group);
}
}
}
// Track used bricks and rigidsystems
std::unordered_set<std::string> usedBrickRefs;
std::unordered_set<tinyxml2::XMLElement*> usedRigidSystems;
// Track used groups to avoid processing them twice
std::unordered_set<tinyxml2::XMLElement*> usedGroups;
// Helper to create output document from sets of brick refs and rigidsystem pointers
auto makeOutput = [&](const std::unordered_set<std::string>& bricksToInclude, const std::vector<tinyxml2::XMLElement*>& rigidSystemsToInclude, const std::vector<tinyxml2::XMLElement*>& groupsToInclude = {}) {
tinyxml2::XMLDocument outDoc;
outDoc.Parse(g_base.c_str());
auto* outRoot = outDoc.FirstChildElement("LXFML");
auto* outBricks = outRoot->FirstChildElement("Bricks");
auto* outRigidSystems = outRoot->FirstChildElement("RigidSystems");
auto* outGroupSystems = outRoot->FirstChildElement("GroupSystems");
// clone and insert bricks
for (const auto& bref : bricksToInclude) {
auto it = brickByRef.find(bref);
if (it == brickByRef.end()) continue;
tinyxml2::XMLElement* cloned = CloneElementDeep(it->second, outDoc);
if (cloned) outBricks->InsertEndChild(cloned);
}
// clone and insert rigidsystems
for (auto* rsPtr : rigidSystemsToInclude) {
tinyxml2::XMLElement* cloned = CloneElementDeep(rsPtr, outDoc);
if (cloned) outRigidSystems->InsertEndChild(cloned);
}
// clone and insert group(s) if requested
if (outGroupSystems && !groupsToInclude.empty()) {
// clear default children
while (outGroupSystems->FirstChild()) outGroupSystems->DeleteChild(outGroupSystems->FirstChild());
// create a GroupSystem element and append requested groups
auto* newGS = outDoc.NewElement("GroupSystem");
for (auto* gptr : groupsToInclude) {
tinyxml2::XMLElement* clonedG = CloneElementDeep(gptr, outDoc);
if (clonedG) newGS->InsertEndChild(clonedG);
}
outGroupSystems->InsertEndChild(newGS);
}
// Print to string
tinyxml2::XMLPrinter printer;
outDoc.Print(&printer);
// Normalize position and compute center using existing helper
std::string xmlString = printer.CStr();
if (xmlString.size() > 5000000) { // 5MB limit for normalization
Result emptyResult;
emptyResult.lxfml = xmlString;
return emptyResult;
}
auto normalized = NormalizePosition(xmlString, curPosition);
return normalized;
};
// 1) Process groups (each top-level Group becomes one output; nested groups are included)
for (auto* groupRoot : groupRoots) {
// Skip if this group was already processed as part of another group
if (usedGroups.find(groupRoot) != usedGroups.end()) continue;
// Helper to collect all partRefs in a group's subtree
std::function<void(const tinyxml2::XMLElement*, std::unordered_set<std::string>&)> collectParts = [&](const tinyxml2::XMLElement* g, std::unordered_set<std::string>& partRefs) {
if (!g) return;
const char* partAttr = g->Attribute("partRefs");
if (partAttr) {
for (auto& tok : GeneralUtils::SplitString(partAttr, ',')) partRefs.insert(tok);
}
for (auto* child = g->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectParts(child, partRefs);
};
// Collect all groups that need to be merged into this output
std::vector<tinyxml2::XMLElement*> groupsToInclude{ groupRoot };
usedGroups.insert(groupRoot);
// Build initial sets of bricks and boneRefs from the starting group
std::unordered_set<std::string> partRefs;
collectParts(groupRoot, partRefs);
std::unordered_set<std::string> bricksIncluded;
std::unordered_set<std::string> boneRefsIncluded;
for (const auto& pref : partRefs) {
auto pit = partRefToBrick.find(pref);
if (pit != partRefToBrick.end()) {
const char* bref = pit->second->Attribute("refID");
if (bref) bricksIncluded.insert(std::string(bref));
}
auto partIt = partRefToPart.find(pref);
if (partIt != partRefToPart.end()) {
auto* bone = partIt->second->FirstChildElement("Bone");
if (bone) {
const char* bref = bone->Attribute("refID");
if (bref) boneRefsIncluded.insert(std::string(bref));
}
}
}
// Iteratively include any RigidSystems that reference any boneRefsIncluded
// and check if those rigid systems' bricks span other groups
bool changed = true;
std::vector<tinyxml2::XMLElement*> rigidSystemsToInclude;
int maxIterations = 1000; // Safety limit to prevent infinite loops
int iteration = 0;
while (changed && iteration < maxIterations) {
changed = false;
iteration++;
// First, expand rigid systems based on current boneRefsIncluded
for (auto* rs : rigidSystems) {
if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue;
// parse boneRefs of this rigid system (from its <Rigid> children)
bool intersects = false;
std::vector<std::string> rsBoneRefs;
for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) {
const char* battr = rigid->Attribute("boneRefs");
if (!battr) continue;
for (auto& tok : GeneralUtils::SplitString(battr, ',')) {
rsBoneRefs.push_back(tok);
if (boneRefsIncluded.find(tok) != boneRefsIncluded.end()) intersects = true;
}
}
if (!intersects) continue;
// include this rigid system and all boneRefs it references
usedRigidSystems.insert(rs);
rigidSystemsToInclude.push_back(rs);
for (const auto& br : rsBoneRefs) {
boneRefsIncluded.insert(br);
auto bpIt = boneRefToPartRef.find(br);
if (bpIt != boneRefToPartRef.end()) {
auto partRef = bpIt->second;
auto pbIt = partRefToBrick.find(partRef);
if (pbIt != partRefToBrick.end()) {
const char* bref = pbIt->second->Attribute("refID");
if (bref && bricksIncluded.insert(std::string(bref)).second) changed = true;
}
}
}
}
// Second, check if the newly included bricks span any other groups
// If so, merge those groups into the current output
for (auto* otherGroup : groupRoots) {
if (usedGroups.find(otherGroup) != usedGroups.end()) continue;
// Collect partRefs from this other group
std::unordered_set<std::string> otherPartRefs;
collectParts(otherGroup, otherPartRefs);
// Check if any of these partRefs correspond to bricks we've already included
bool spansOtherGroup = false;
for (const auto& pref : otherPartRefs) {
auto pit = partRefToBrick.find(pref);
if (pit != partRefToBrick.end()) {
const char* bref = pit->second->Attribute("refID");
if (bref && bricksIncluded.find(std::string(bref)) != bricksIncluded.end()) {
spansOtherGroup = true;
break;
}
}
}
if (spansOtherGroup) {
// Merge this group into the current output
usedGroups.insert(otherGroup);
groupsToInclude.push_back(otherGroup);
changed = true;
// Add all partRefs, boneRefs, and bricks from this group
for (const auto& pref : otherPartRefs) {
auto pit = partRefToBrick.find(pref);
if (pit != partRefToBrick.end()) {
const char* bref = pit->second->Attribute("refID");
if (bref) bricksIncluded.insert(std::string(bref));
}
auto partIt = partRefToPart.find(pref);
if (partIt != partRefToPart.end()) {
auto* bone = partIt->second->FirstChildElement("Bone");
if (bone) {
const char* bref = bone->Attribute("refID");
if (bref) boneRefsIncluded.insert(std::string(bref));
}
}
}
}
}
}
if (iteration >= maxIterations) {
// Iteration limit reached, stop processing to prevent infinite loops
// The file is likely malformed, so just skip further processing
return results;
}
// include bricks from bricksIncluded into used set
for (const auto& b : bricksIncluded) usedBrickRefs.insert(b);
// make output doc and push result (include all merged groups' XML)
auto normalized = makeOutput(bricksIncluded, rigidSystemsToInclude, groupsToInclude);
results.push_back(normalized);
}
// 2) Process remaining RigidSystems (each becomes its own file)
for (auto* rs : rigidSystems) {
if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue;
std::unordered_set<std::string> bricksIncluded;
// collect boneRefs referenced by this rigid system
for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) {
const char* battr = rigid->Attribute("boneRefs");
if (!battr) continue;
for (auto& tok : GeneralUtils::SplitString(battr, ',')) {
auto bpIt = boneRefToPartRef.find(tok);
if (bpIt != boneRefToPartRef.end()) {
auto partRef = bpIt->second;
auto pbIt = partRefToBrick.find(partRef);
if (pbIt != partRefToBrick.end()) {
const char* bref = pbIt->second->Attribute("refID");
if (bref) bricksIncluded.insert(std::string(bref));
}
}
}
}
// mark used
for (const auto& b : bricksIncluded) usedBrickRefs.insert(b);
usedRigidSystems.insert(rs);
std::vector<tinyxml2::XMLElement*> rsVec{ rs };
auto normalized = makeOutput(bricksIncluded, rsVec);
results.push_back(normalized);
}
// 3) Any remaining bricks not included become their own files
for (const auto& [bref, brickPtr] : brickByRef) {
if (usedBrickRefs.find(bref) != usedBrickRefs.end()) continue;
std::unordered_set<std::string> bricksIncluded{ bref };
auto normalized = makeOutput(bricksIncluded, {});
results.push_back(normalized);
usedBrickRefs.insert(bref);
}
return results;
}

View File

@@ -6,6 +6,7 @@
#include <string>
#include <string_view>
#include <vector>
#include "NiPoint3.h"
@@ -18,6 +19,7 @@ namespace Lxfml {
// Normalizes a LXFML model to be positioned relative to its local 0, 0, 0 rather than a game worlds 0, 0, 0.
// Returns a struct of its new center and the updated LXFML containing these edits.
[[nodiscard]] Result NormalizePosition(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO);
[[nodiscard]] std::vector<Result> Split(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO);
// these are only for the migrations due to a bug in one of the implementations.
[[nodiscard]] Result NormalizePositionOnlyFirstPart(const std::string_view data);

View File

@@ -53,16 +53,21 @@ Lxfml::Result Lxfml::NormalizePositionOnlyFirstPart(const std::string_view data)
continue;
}
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;
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;
if (highest.x < x) highest.x = x;
if (highest.y < y) highest.y = y;
if (highest.z < z) highest.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.
}
}
auto delta = (highest - lowest) / 2.0f;

View File

@@ -45,6 +45,12 @@ 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)) {
@@ -71,6 +77,12 @@ 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)) {
@@ -95,6 +107,11 @@ 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);
@@ -119,6 +136,12 @@ 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);
@@ -128,6 +151,13 @@ 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 {
int32_t GetMaxCompressedLength(int32_t nLenSrc) {
int32_t n16kBlocks = (nLenSrc + 16383) / 16384; // round up any fraction of a block
uint32_t GetMaxCompressedLength(uint32_t nLenSrc) {
uint32_t n16kBlocks = (nLenSrc + 16383) / 16384; // round up any fraction of a block
return (nLenSrc + 6 + (n16kBlocks * 5));
}
int32_t Compress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst) {
int32_t Compress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_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, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst, int32_t& nErr) {
int32_t Decompress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_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 {
int32_t GetMaxCompressedLength(int32_t nLenSrc);
uint32_t GetMaxCompressedLength(uint32_t nLenSrc);
int32_t Compress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst);
int32_t Compress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst);
int32_t Decompress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst, int32_t& nErr);
int32_t Decompress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_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) return false;
if (this->m_AssetBundleType == eAssetBundleType::Unpacked || !m_PackIndex) return false;
std::replace(fixedName.begin(), fixedName.end(), '/', '\\');
if (fixedName.rfind("client\\res\\", 0) != 0) fixedName = "client\\res\\" + fixedName;
@@ -145,8 +145,12 @@ bool AssetManager::GetFile(std::string fixedName, char** data, uint32_t* len) co
}
const auto& pack = this->m_PackIndex->GetPacks().at(packIndex);
const bool success = pack.ReadFileFromPack(crc, data, len);
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());
}
return success;
}

View File

@@ -81,6 +81,9 @@ public:
[[nodiscard]]
AssetStream GetFile(const char* name) const;
[[nodiscard]]
AssetStream GetFile(const std::string& name) const { return GetFile(name.c_str()); };
private:
void LoadPackIndex();

View File

@@ -46,6 +46,7 @@ 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{};
@@ -65,16 +66,21 @@ 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;
FILE* file = nullptr;
#ifdef _WIN32
fopen_s(&file, m_FilePath.string().c_str(), "rb");
fopen_s(&file, pathStr.c_str(), "rb");
#elif __APPLE__
// macOS has 64bit file IO by default
file = fopen(m_FilePath.string().c_str(), "rb");
file = fopen(pathStr.c_str(), "rb");
#else
file = fopen64(m_FilePath.string().c_str(), "rb");
file = fopen64(pathStr.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) {
@@ -102,14 +108,18 @@ 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
char* chunk = static_cast<char*>(malloc(size));
int32_t readInData2 = fread(chunk, sizeof(int8_t), size, file);
std::unique_ptr<char[]> chunk(new char[size]);
int32_t readInData2 = fread(chunk.get(), sizeof(int8_t), size, file);
pos += size; // Move pointer position the amount of bytes read to the right
int32_t err;
currentReadPos += ZCompression::Decompress(reinterpret_cast<uint8_t*>(chunk), size, reinterpret_cast<uint8_t*>(decompressedData + currentReadPos), Sd0::MAX_UNCOMPRESSED_CHUNK_SIZE, 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;
free(chunk);
}
*data = decompressedData;

View File

@@ -84,3 +84,7 @@ 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,6 +5,8 @@
#include <map>
#include <string>
#include "GeneralUtils.h"
class dConfig {
public:
dConfig(const std::string& filepath);
@@ -22,6 +24,14 @@ 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
*/
@@ -43,3 +53,9 @@ 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

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

View File

@@ -1,55 +0,0 @@
add_subdirectory(blueprints)
set(DDASHBOARDSERVER_SOURCES
"DashboardWeb.cpp"
# Explicitly include blueprint sources to ensure they are compiled into the library
"blueprints/AuthBlueprint.cpp"
"blueprints/ApiBlueprint.cpp"
"blueprints/PageBlueprint.cpp"
"blueprints/PlayKeysBlueprint.cpp"
"blueprints/CharactersBlueprint.cpp"
"blueprints/MailBlueprint.cpp"
"blueprints/BugReportsBlueprint.cpp"
"blueprints/ModerationBlueprint.cpp"
)
# Create dDashboardServer library
add_library(dDashboardServer ${DDASHBOARDSERVER_SOURCES})
target_include_directories(dDashboardServer PRIVATE ${PROJECT_SOURCE_DIR}/dServer)
find_package(CURL)
if (CURL_FOUND)
target_link_libraries(dDashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt CURL::libcurl)
else()
message(WARNING "libcurl not found; building dDashboardServer without CURL::libcurl. Some features may be disabled.")
target_link_libraries(dDashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt)
endif()
add_executable(DashboardServer "DashboardServer.cpp")
if (CURL_FOUND)
target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt CURL::libcurl dDashboardServer)
else()
target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt dDashboardServer)
endif()
target_include_directories(DashboardServer PRIVATE ${PROJECT_SOURCE_DIR}/dServer)
add_compile_definitions(DashboardServer PRIVATE PROJECT_VERSION="\"${PROJECT_VERSION}\"")
# Define Windows version for ASIO/Crow compatibility (Windows 10)
if(WIN32)
target_compile_definitions(DashboardServer PRIVATE _WIN32_WINNT=0x0A00)
target_compile_definitions(dDashboardServer PRIVATE _WIN32_WINNT=0x0A00)
endif()
# Copy static files and templates to build directory
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/static
$<TARGET_FILE_DIR:DashboardServer>/static
COMMENT "Copying static files to build directory"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/templates
$<TARGET_FILE_DIR:DashboardServer>/templates
COMMENT "Copying templates to build directory"
)

View File

@@ -1,33 +0,0 @@
#include "DashboardHelpers.h"
namespace DashboardHelpers {
DataTablesParams ParseDataTablesParams(const crow::request& req) {
DataTablesParams p;
try {
if (req.url_params.get("draw")) p.draw = std::stoi(req.url_params.get("draw"));
if (req.url_params.get("start")) p.start = std::stoi(req.url_params.get("start"));
if (req.url_params.get("length")) p.length = std::stoi(req.url_params.get("length"));
if (req.url_params.get("order[0][column]")) p.orderColumn = std::stoi(req.url_params.get("order[0][column]"));
if (req.url_params.get("order[0][dir]")) p.orderDir = req.url_params.get("order[0][dir]");
} catch (...) {
// ignore parse errors, return defaults
}
return p;
}
crow::json::wvalue CreateDataTablesResponse(int draw, uint32_t recordsTotal, uint32_t recordsFiltered, const crow::json::wvalue::list& data) {
crow::json::wvalue resp;
resp["draw"] = draw;
resp["recordsTotal"] = recordsTotal;
resp["recordsFiltered"] = recordsFiltered;
resp["data"] = data;
return resp;
}
bool RescueCharacter(const uint64_t characterId, const uint32_t zoneId) {
// Minimal stub: not implemented here. Return false to indicate no-op.
return false;
}
} // namespace DashboardHelpers

View File

@@ -1,24 +0,0 @@
#pragma once
#include <crow.h>
#include <string>
namespace DashboardHelpers {
struct DataTablesParams {
int draw{0};
int start{0};
int length{10};
int orderColumn{-1};
std::string orderDir{"asc"};
};
// Parse common DataTables GET params from the request
DataTablesParams ParseDataTablesParams(const crow::request& req);
// Create a DataTables response object
crow::json::wvalue CreateDataTablesResponse(int draw, uint32_t recordsTotal, uint32_t recordsFiltered, const crow::json::wvalue::list& data);
// Rescue character stub (real logic may be project-specific)
bool RescueCharacter(const uint64_t characterId, const uint32_t zoneId);
}

View File

@@ -1,248 +0,0 @@
#ifndef PROJECT_VERSION
#define PROJECT_VERSION "dev"
#endif
#include <iostream>
#include <string>
#include <chrono>
#include <thread>
//DLU Includes:
#include "dCommonVars.h"
#include "dServer.h"
#include "Logger.h"
#include "Database.h"
#include "dConfig.h"
#include "Diagnostics.h"
#include "AssetManager.h"
#include "BinaryPathFinder.h"
#include "ServiceType.h"
#include "StringifiedEnum.h"
#include "Game.h"
#include "Server.h"
//RakNet includes:
#include "RakNetDefines.h"
#include "MessageIdentifiers.h"
#include "MessageType/Server.h"
#include "DashboardWeb.h"
#include "DashboardShared.h"
namespace Game {
Logger* logger = nullptr;
dServer* server = nullptr;
dConfig* config = nullptr;
AssetManager* assetManager = nullptr;
Game::signal_t lastSignal = 0;
std::mt19937 randomEngine;
}
// Forward declaration
void HandlePacket(Packet* packet);
int main(int argc, char** argv) {
constexpr uint32_t dashboardFramerate = mediumFramerate;
constexpr uint32_t dashboardFrameDelta = mediumFrameDelta;
Diagnostics::SetProcessName("Dashboard");
Diagnostics::SetProcessFileName(argv[0]);
Diagnostics::Initialize();
std::signal(SIGINT, Game::OnSignal);
std::signal(SIGTERM, Game::OnSignal);
Game::config = new dConfig("dashboardconfig.ini");
//Create all the objects we need to run our service:
Server::SetupLogger("DashboardServer");
if (!Game::logger) return EXIT_FAILURE;
Game::config->LogSettings();
//Read our config:
LOG("Starting Dashboard server...");
LOG("Version: %s", PROJECT_VERSION);
LOG("Compiled on: %s", __TIMESTAMP__);
try {
std::string clientPathStr = Game::config->GetValue("client_location");
if (clientPathStr.empty()) clientPathStr = "./res";
std::filesystem::path clientPath = std::filesystem::path(clientPathStr);
if (clientPath.is_relative()) {
clientPath = BinaryPathFinder::GetBinaryDir() / clientPath;
}
Game::assetManager = new AssetManager(clientPath);
} catch (std::runtime_error& ex) {
LOG("Got an error while setting up assets: %s", ex.what());
delete Game::logger;
delete Game::config;
return EXIT_FAILURE;
}
//Connect to the Database
try {
Database::Connect();
} catch (std::exception& ex) {
LOG("Got an error while connecting to the database: %s", ex.what());
Database::Destroy("DashboardServer");
delete Game::logger;
delete Game::config;
return EXIT_FAILURE;
}
// Setup and start the Crow web server (runs in its own thread)
const uint32_t web_server_port = GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("web_server_port")).value_or(8080);
DashboardWeb::Initialize(web_server_port);
//Find out the master's IP:
std::string masterIP;
uint32_t masterPort = 1000;
std::string masterPassword;
auto masterInfo = Database::Get()->GetMasterInfo();
if (masterInfo) {
masterIP = masterInfo->ip;
masterPort = masterInfo->port;
masterPassword = masterInfo->password;
}
//It's safe to pass 'localhost' here, as the IP is only used as the external IP.
std::string ourIP = "localhost";
const uint32_t maxClients = GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("max_clients")).value_or(999);
const uint32_t ourPort = GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("dashboard_server_port")).value_or(2006);
const auto externalIPString = Game::config->GetValue("external_ip");
if (!externalIPString.empty()) ourIP = externalIPString;
Game::server = new dServer(ourIP, ourPort, 0, maxClients, false, true, Game::logger, masterIP, masterPort, ServiceType::COMMON, Game::config, &Game::lastSignal, masterPassword);
// Update shared state with master server info
DashboardShared::g_Stats.SetMasterInfo(masterIP, masterPort);
Game::randomEngine = std::mt19937(time(0));
//Run it until server gets a kill message from Master:
auto t = std::chrono::high_resolution_clock::now();
Packet* packet = nullptr;
constexpr uint32_t logFlushTime = 30 * dashboardFramerate; // 30 seconds in frames
constexpr uint32_t sqlPingTime = 10 * 60 * dashboardFramerate; // 10 minutes in frames
uint32_t framesSinceLastFlush = 0;
uint32_t framesSinceMasterDisconnect = 0;
uint32_t framesSinceLastSQLPing = 0;
auto lastTime = std::chrono::high_resolution_clock::now();
auto startTime = lastTime; // Track server start time for uptime
Game::logger->Flush(); // once immediately before main loop
while (!Game::ShouldShutdown()) {
// Check if we're still connected to master:
if (!Game::server->GetIsConnectedToMaster()) {
framesSinceMasterDisconnect++;
if (framesSinceMasterDisconnect >= dashboardFramerate)
break; //Exit our loop, shut down.
DashboardShared::SetMasterConnected(false);
} else {
framesSinceMasterDisconnect = 0;
DashboardShared::SetMasterConnected(true);
}
const auto currentTime = std::chrono::high_resolution_clock::now();
const float deltaTime = std::chrono::duration<float>(currentTime - lastTime).count();
lastTime = currentTime;
// Check for packets from master:
Game::server->ReceiveFromMaster();
// Process queued packet sends from Crow threads
if (DashboardShared::g_PacketQueue.HasPending()) {
auto pendingPackets = DashboardShared::g_PacketQueue.DequeueAll();
for (const auto& request : pendingPackets) {
// Create BitStream from queued data
RakNet::BitStream bitStream(const_cast<unsigned char*>(request.data.data()), request.data.size(), false);
// Send via RakNet (safe - we're in the RakNet thread)
Game::server->Send(bitStream, request.target, request.broadcast);
DashboardShared::OnPacketSent();
LOG("Sent queued packet from web request (%zu bytes)", request.data.size());
}
}
// Check for RakNet packets:
packet = Game::server->Receive();
if (packet) {
HandlePacket(packet);
DashboardShared::OnPacketReceived(); // Update shared stats
Game::server->DeallocatePacket(packet);
packet = nullptr;
}
//Push our log every 30s:
if (framesSinceLastFlush >= logFlushTime) {
Game::logger->Flush();
framesSinceLastFlush = 0;
} else framesSinceLastFlush++;
//Every 10 min we ping our sql server to keep it alive hopefully:
if (framesSinceLastSQLPing >= sqlPingTime) {
//Find out the master's IP for absolutely no reason:
std::string masterIP;
uint32_t masterPort;
auto masterInfo = Database::Get()->GetMasterInfo();
if (masterInfo) {
masterIP = masterInfo->ip;
masterPort = masterInfo->port;
}
framesSinceLastSQLPing = 0;
} else framesSinceLastSQLPing++;
//Sleep our thread since dashboard can afford to.
t += std::chrono::milliseconds(dashboardFrameDelta);
std::this_thread::sleep_until(t);
}
// Stop the Crow web server
DashboardWeb::Stop();
//Delete our objects here:
Database::Destroy("DashboardServer");
delete Game::server;
delete Game::logger;
delete Game::config;
return EXIT_SUCCESS;
}
void HandlePacket(Packet* packet) {
if (packet->length < 4) return;
if (packet->data[0] == ID_DISCONNECTION_NOTIFICATION || packet->data[0] == ID_CONNECTION_LOST) {
LOG("A client has disconnected");
DashboardShared::OnClientDisconnected();
return;
}
if (packet->data[0] == ID_NEW_INCOMING_CONNECTION) {
LOG("New incoming connection from %s", packet->systemAddress.ToString());
DashboardShared::OnClientConnected();
return;
}
if (packet->data[0] != ID_USER_PACKET_ENUM) return;
// Handle server packets
if (static_cast<ServiceType>(packet->data[1]) == ServiceType::COMMON) {
if (static_cast<MessageType::Server>(packet->data[3]) == MessageType::Server::VERSION_CONFIRM) {
LOG("Version confirmation received from client");
DashboardShared::OnPacketReceived("VERSION_CONFIRM");
}
}
// Add more packet handling as needed
// This is where you would handle custom dashboard-specific packets
// All packet handling can safely update DashboardShared state
}

View File

@@ -1,187 +0,0 @@
#ifndef __DASHBOARDSHARED_H__
#define __DASHBOARDSHARED_H__
#include <atomic>
#include <mutex>
#include <string>
#include <vector>
#include <queue>
#include <functional>
#include <set>
#include <map>
#include <ctime>
#include <random>
#include <optional>
#include "dCommonVars.h"
#include "RakNetTypes.h"
#include "GameDatabase.h"
#include "crow.h"
// Forward declaration
class GameDatabase;
namespace RakNet {
class BitStream;
};
/**
* Shared state between the Crow web server (runs in background threads)
* and the RakNet game loop (runs in main thread).
*
* All members use thread-safe types (atomic, mutex-protected)
*
* IMPORTANT: RakNet is NOT thread-safe!
* - Crow threads can READ state and QUEUE packet send requests
* - Only the RakNet thread (main loop) can actually send packets
*/
namespace DashboardShared {
// ===== Atomic Counters (lock-free, safe for simple reads/writes) =====
inline std::atomic<uint32_t> g_ConnectedClients{0};
inline std::atomic<bool> g_ConnectedToMaster{false};
inline std::atomic<uint64_t> g_PacketsReceived{0};
inline std::atomic<uint64_t> g_PacketsSent{0};
// ===== Mutex-Protected Data (for complex structures) =====
struct ServerStats {
std::mutex mutex;
uint64_t uptime_seconds = 0;
std::string last_packet_type;
uint32_t raknet_port = 0;
std::string master_ip;
// Thread-safe getters
uint64_t GetUptime() {
std::lock_guard<std::mutex> lock(mutex);
return uptime_seconds;
}
std::string GetLastPacketType() {
std::lock_guard<std::mutex> lock(mutex);
return last_packet_type;
}
void SetLastPacketType(const std::string& type) {
std::lock_guard<std::mutex> lock(mutex);
last_packet_type = type;
}
void SetMasterInfo(const std::string& ip, uint32_t port) {
std::lock_guard<std::mutex> lock(mutex);
master_ip = ip;
raknet_port = port;
}
};
inline ServerStats g_Stats;
// ===== Packet Send Queue (for Crow -> RakNet communication) =====
/**
* Represents a packet send request from Crow to RakNet.
* Crow threads add to the queue, RakNet thread processes them.
*/
struct PacketSendRequest {
std::vector<uint8_t> data; // Packet data (owns the memory)
SystemAddress target; // Target address (or UNASSIGNED for broadcast)
bool broadcast; // Whether to broadcast
PacketSendRequest(const std::vector<uint8_t>& packetData,
const SystemAddress& addr,
bool isBroadcast)
: data(packetData), target(addr), broadcast(isBroadcast) {}
};
// Thread-safe queue of packet send requests
struct PacketQueue {
std::mutex mutex;
std::queue<PacketSendRequest> queue;
// Called from Crow threads to queue a packet for sending
void Enqueue(const std::vector<uint8_t>& data, const SystemAddress& addr, bool broadcast) {
std::lock_guard<std::mutex> lock(mutex);
queue.emplace(data, addr, broadcast);
}
// Called from RakNet thread to get all pending packets
std::vector<PacketSendRequest> DequeueAll() {
std::lock_guard<std::mutex> lock(mutex);
std::vector<PacketSendRequest> result;
while (!queue.empty()) {
result.push_back(std::move(queue.front()));
queue.pop();
}
return result;
}
// Check if queue has pending packets
bool HasPending() {
std::lock_guard<std::mutex> lock(mutex);
return !queue.empty();
}
};
inline PacketQueue g_PacketQueue;
// ===== Helper Functions =====
// Called from RakNet thread when a client connects
inline void OnClientConnected() {
g_ConnectedClients++;
}
// Called from RakNet thread when a client disconnects
inline void OnClientDisconnected() {
if (g_ConnectedClients > 0) {
g_ConnectedClients--;
}
}
// Called from RakNet thread when master connection status changes
inline void SetMasterConnected(bool connected) {
g_ConnectedToMaster = connected;
}
// Called from RakNet thread when a packet is processed
inline void OnPacketReceived(const std::string& packetType = "") {
g_PacketsReceived++;
if (!packetType.empty()) {
g_Stats.SetLastPacketType(packetType);
}
}
// Called from RakNet thread when a packet is sent
inline void OnPacketSent() {
g_PacketsSent++;
}
// ===== Crow -> RakNet Communication =====
/**
* Queue a RakNet packet to be sent (called from Crow threads).
* The packet will be sent on the next RakNet thread update.
*
* @param data Packet data to send
* @param target Target system address (use UNASSIGNED_SYSTEM_ADDRESS for broadcast)
* @param broadcast Whether to broadcast to all connected clients
*/
inline void QueuePacketSend(const std::vector<uint8_t>& data,
const SystemAddress& target = UNASSIGNED_SYSTEM_ADDRESS,
bool broadcast = false) {
g_PacketQueue.Enqueue(data, target, broadcast);
}
/**
* Helper to queue a BitStream for sending (called from Crow threads).
* Converts BitStream to raw data and queues it.
*/
inline void QueueBitStreamSend(RakNet::BitStream& bitStream,
const SystemAddress& target = UNASSIGNED_SYSTEM_ADDRESS,
bool broadcast = false) {
std::vector<uint8_t> data(bitStream.GetData(),
bitStream.GetData() + bitStream.GetNumberOfBytesUsed());
QueuePacketSend(data, target, broadcast);
}
}
#endif // __DASHBOARDSHARED_H__

View File

@@ -1,153 +0,0 @@
#include "DashboardWeb.h"
#include "DashboardShared.h"
// Blueprint includes
#include "blueprints/AuthBlueprint.h"
#include "blueprints/ApiBlueprint.h"
#include "blueprints/PageBlueprint.h"
#include "blueprints/PlayKeysBlueprint.h"
#include "blueprints/CharactersBlueprint.h"
#include "blueprints/MailBlueprint.h"
#include "blueprints/BugReportsBlueprint.h"
#include "blueprints/ModerationBlueprint.h"
// Crow headers - must come before ASIO to avoid conflicts
#include "crow.h"
#include "crow/middlewares/session.h"
// thanks bill gates
#ifdef _WIN32
#undef min
#undef max
#endif
#include <memory>
#include <thread>
#include <chrono>
#include <iostream>
namespace DashboardWeb {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
static crow::App<crow::CookieParser, Session> g_App {
Session{
// cookie config: use "session" cookie name, 24h max_age
crow::CookieParser::Cookie("session").max_age(24 * 60 * 60).path("/"),
// session id length
32,
// storage backend (InMemoryStore)
crow::InMemoryStore{}
}
};
static std::future<void> g_ServerFuture;
static bool g_Running = false;
static bool g_Initialized = false;
void SetupRoutes() {
static bool setupCalled = false;
if (setupCalled) {
std::cerr << "WARNING: SetupRoutes() called multiple times!" << std::endl;
return;
}
setupCalled = true;
std::cerr << "Setting up dashboard routes..." << std::endl;
// Set mustache template base directory
crow::mustache::set_base("./templates");
// Setup all blueprint routes
try {
std::cerr << " - Setting up AuthBlueprint..." << std::endl;
AuthBlueprint::Setup(g_App);
std::cerr << " - Setting up ApiBlueprint..." << std::endl;
ApiBlueprint::Setup(g_App);
std::cerr << " - Setting up PageBlueprint..." << std::endl;
PageBlueprint::Setup(g_App);
std::cerr << " - Setting up PlayKeysBlueprint..." << std::endl;
PlayKeysBlueprint::Setup(g_App);
std::cerr << " - Setting up CharactersBlueprint..." << std::endl;
CharactersBlueprint::Setup(g_App);
std::cerr << " - Setting up MailBlueprint..." << std::endl;
MailBlueprint::Setup(g_App);
std::cerr << " - Setting up BugReportsBlueprint..." << std::endl;
BugReportsBlueprint::Setup(g_App);
std::cerr << " - Setting up ModerationBlueprint..." << std::endl;
ModerationBlueprint::Setup(g_App);
std::cerr << "All routes set up successfully!" << std::endl;
} catch (const std::exception& e) {
// Print to stderr since LOG might not be available
std::cerr << "Error setting up routes: " << e.what() << std::endl;
throw;
}
}
void Initialize(uint32_t port) {
// Only allow initialization once per process lifetime
// Crow apps cannot be restarted once stopped
if (g_Initialized) {
std::cerr << "Dashboard web server already initialized. Cannot reinitialize." << std::endl;
return;
}
try {
// Setup routes (only happens once)
SetupRoutes();
// Configure Crow app
g_App.loglevel(crow::LogLevel::Info); // Changed to Info to see startup messages
// Start the server in a separate thread
g_ServerFuture = std::async(std::launch::async, [port]() {
try {
g_App.port(port).multithreaded().run();
} catch (const std::exception& e) {
std::cerr << "Error running Crow server: " << e.what() << std::endl;
}
});
g_Running = true;
g_Initialized = true;
// Give the server a moment to start
std::this_thread::sleep_for(std::chrono::milliseconds(500));
} catch (const std::exception& e) {
std::cerr << "Error initializing dashboard web server: " << e.what() << std::endl;
throw;
}
}
void Update() {
// Crow runs in its own thread, nothing to update here
}
void Stop() {
if (!g_Running) {
return;
}
g_App.stop();
// Wait for the server thread to finish (with timeout)
if (g_ServerFuture.valid()) {
auto status = g_ServerFuture.wait_for(std::chrono::seconds(5));
if (status == std::future_status::timeout) {
std::cerr << "Warning: Dashboard web server did not stop gracefully" << std::endl;
}
}
g_Running = false;
}
} // namespace DashboardWeb

View File

@@ -1,19 +0,0 @@
#ifndef __DASHBOARDWEB_H__
#define __DASHBOARDWEB_H__
#include <cstdint>
#include <string>
namespace DashboardWeb {
// Initialize the web server and configure routes using blueprints
void Initialize(uint32_t port);
// Process pending web requests (call each frame/tick)
void Update();
// Stop the web server
void Stop();
};
#endif // __DASHBOARDWEB_H__

View File

@@ -1,143 +0,0 @@
<!doctype html>
<html lang='en'>
<head>
<!-- Title -->
<title>{{#title}}{{title}}{{/title}}{{^title}}Dashboard{{/title}} - {{config.APP_NAME}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{{! CSS }}
<style>
.required:after {
content:" *";
color: red;
}
.error {
color: red;
}
</style>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<!-- Custom CSS consolidated -->
<link rel="stylesheet" href="/static/css/dashboard.css">
</head>
<body class="bg-dark text-white">
{{> header}}
<!-- Content -->
<div class="container py-0">
<!-- Text -->
<div class="text-center">
<span class="h3 mb-0"><br/>{{content_before}}<br/><br/></span>
</div>
<!-- Flashed messages: expect `messages` to be an array of {category, message} -->
{! TODO: make this dynamic toasts !!}
{{#messages}}
<div class="alert alert-{{category}}" role="alert">
{{message}}
</div>
{{/messages}}
</div>
<div class='container mt-4'>
{{content}}
</div>
<div class='container mt-4'>
{{content_after}}
</div>
<footer>
{{#footer}}
<hr class="my-5"/>
{{/footer}}
</footer>
{{! JS assets }}
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery (optional fallback for older scripts) -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<!-- DataTables JS -->
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<!-- Shared helper: wait for jQuery/DataTables (keeps pages resilient to CDN timing) -->
<script src="/static/js/wait-for-jq-dt.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Custom JS -->
<script src="/static/js/api.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/login.js"></script>
<script>
// set the active nav-link item (use vanilla JS, fallback to jQuery)
(function(){
var endpoint = '{{request_endpoint}}' || '';
try{
var target_nav = '#' + endpoint.replace(/\./g, '-');
var el = document.querySelector(target_nav);
if(el) el.classList.add('active');
else if(window.jQuery) $(target_nav).addClass('active');
}catch(e){}
})();
// initialize Bootstrap 5 tooltips (no jQuery required)
(function(){
try{
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
});
}catch(e){
// fallback for legacy attribute name if still used
// legacy jQuery tooltip fallback (only runs if bootstrap init failed and jQuery tooltip is present)
if(window.jQuery && window.jQuery.fn && window.jQuery.fn.tooltip) $(function(){ $('[data-toggle="tooltip"]').tooltip(); });
}
})();
function setInnerHTML(elm, html) {
elm.innerHTML = html;
// re-init Bootstrap tooltips inside newly injected content
try{
var tooltipTriggerList = [].slice.call(elm.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
});
}catch(e){
if(window.jQuery && window.jQuery.fn && window.jQuery.fn.tooltip) $("body").tooltip({ selector: '[data-toggle=tooltip]' });
}
Array.from(elm.querySelectorAll("script")).forEach(function(oldScriptEl) {
var newScriptEl = document.createElement("script");
Array.from(oldScriptEl.attributes).forEach(function(attr) {
newScriptEl.setAttribute(attr.name, attr.value);
});
var scriptText = document.createTextNode(oldScriptEl.innerHTML || '');
newScriptEl.appendChild(scriptText);
oldScriptEl.parentNode.replaceChild(newScriptEl, oldScriptEl);
});
}
</script>
</body>
</html>

View File

@@ -1,113 +0,0 @@
{{! Navigation brand, nav toggle bar }}
<nav class='navbar navbar-expand-sm navbar-dark bg-primary flex-row pb-3'>
<div class='container md-0 flex-nowrap'>
{{! Logo and App Name }}
<nav class="navbar">
<a class="navbar-brand" href="{{url.main_index}}">
<img src="{{static.logo}}" width="30" height="30" class="d-inline-block align-top" alt="">
{{config.APP_NAME}}
</a>
</nav>
{{! Navigation brand, nav toggle bar }}
<nav class='navbar navbar-expand-sm navbar-dark bg-primary flex-row pb-3'>
<div class='container md-0 flex-nowrap'>
{{! Logo and App Name }}
<nav class="navbar">
<a class="navbar-brand" href="{{url.main_index}}">
<img src="{{static.logo}}" width="30" height="30" class="d-inline-block align-top" alt="">
{{config.APP_NAME}}
</a>
</nav>
{{! Visible only on large devices }}
<nav class='navbar-nav'>
<div class='collapse navbar-collapse'>
{{#current_user_authenticated}}
{{#USER_ENABLE_INVITE_USER}}
<a class='btn-nav-dashboard me-2' href='{{url.user_invite_user}}'>Invite</a>
{{/USER_ENABLE_INVITE_USER}}
<a class='btn-nav-dashboard' href='{{url.user_logout}}'><i class='fas fa-sign-out-alt me-1'></i>Logout</a>
{{/current_user_authenticated}}
</div>
</nav>
<button class='navbar-toggler' type='button' data-bs-toggle='collapse' data-bs-target='#navbarSupportedContent' aria-controls='navbarSupportedContent' aria-expanded='false' aria-label='Toggle navigation'>
<span class='navbar-toggler-icon'></span>
</button>
</div>
</nav>
{{! Navigation menu / links bar }}
<nav class='navbar navbar-expand-sm navbar-dark bg-primary p-sm-0 py-0 {{#navbar_shadow}}shadow-sm{{/navbar_shadow}}'>
<div class='container mt-0 pt-0'>
<div class='collapse navbar-collapse' id='navbarSupportedContent' style='margin-top: -16px;'>
<nav class='navbar-nav me-auto'>
<a id='main-index' class='nav-link' href='{{url.main_index}}'>Home</a>
{{#gm_ge_3}}
{{! General Moderation Links }}
<a id='accounts-index' class='nav-link' href='{{url.accounts_index}}'>Accounts</a>
<a id='character-index' class='nav-link' href='{{url.characters_index}}'>Characters</a>
<a id='property-index' class='nav-link' href='{{url.properties_index}}'>Properties</a>
{{/gm_ge_3}}
{{#gm_ge_5_require_play_key}}
{{! Play Keys }}
<a id='play_keys-index' class='nav-link' href='{{url.play_keys_index}}'>Play Keys</a>
{{/gm_ge_5_require_play_key}}
{{#gm_ge_2}}
<a id='report-index' class='nav-link' href='{{url.reports_index}}'>Reports</a>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">Tools</a>
<div class="dropdown-menu">
<a class="dropdown-item text-center" href='{{url.mail_send}}'>Send Mail</a>
<hr/>
<h3 class="text-center">Moderation</h3>
<a class="dropdown-item text-center" href='{{url.moderation_unapproved}}'>Unapproved Items</a>
<a class="dropdown-item text-center" href='{{url.moderation_approved}}'>Approved Items</a>
<a class="dropdown-item text-center" href='{{url.moderation_all}}'>All Items</a>
<hr/>
<h3 class="text-center">Bug Reports</h3>
<a class="dropdown-item text-center" href='{{url.bug_reports_unresolved}}'>Unresolved Reports</a>
<a class="dropdown-item text-center" href='{{url.bug_reports_resolved}}'>Resolved Reports</a>
<a class="dropdown-item text-center" href='{{url.bug_reports_all}}'>All Reports</a>
{{#gm_ge_8}}
<hr/>
<h3 class="text-center">Logs</h3>
<a class="dropdown-item text-center" href='{{url.log_command}}'>Command Log</a>
<a class="dropdown-item text-center" href='{{url.log_activity}}'>Activity Log</a>
<a class="dropdown-item text-center" href='{{url.log_audit}}'>Audit Log</a>
<a class="dropdown-item text-center" href='{{url.log_system}}'>System Log</a>
{{/gm_ge_8}}
</div>
</li>
{{/gm_ge_2}}
{{#gm_eq_0}}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">Bug Reports</a>
<div class="dropdown-menu">
<a class="dropdown-item text-center" href='{{url.bug_reports_unresolved}}'>Unresolved Reports</a>
<a class="dropdown-item text-center" href='{{url.bug_reports_resolved}}'>Resolved Reports</a>
<a class="dropdown-item text-center" href='{{url.bug_reports_all}}'>All Reports</a>
</div>
</li>
{{/gm_eq_0}}
{{#current_user_authenticated}}
<a id='main-about' class='nav-link' href='{{url.main_about}}'>About</a>
{{/current_user_authenticated}}
{{#current_user_authenticated}}
<a class='nav-link d-sm-none' href='{{url.user_logout}}'><i class='fas fa-sign-out-alt me-1'></i>Logout</a>
{{/current_user_authenticated}}
</nav>
</div>
</div>
</nav>

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
#pragma once
#include "crow.h"
#include "crow/middlewares/session.h"
namespace ApiBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup API routes
* Registers all API endpoints for stats, accounts, and moderation
*/
void Setup(DashboardApp& app);
} // namespace ApiBlueprint

View File

@@ -1,129 +0,0 @@
#include "AuthBlueprint.h"
#include "Database.h"
#include <bcrypt/BCrypt.hpp>
namespace AuthBlueprint {
void Setup(DashboardApp& app) {
// Login route
CROW_ROUTE(app, "/api/login")
.methods("POST"_method)
([&](crow::request& req, crow::response& res) {
auto body = crow::json::load(req.body);
if (!body) {
res.code = 400;
res.set_header("Content-Type", "application/json");
res.write("{\"error\": \"Invalid JSON\"}");
res.end();
return;
}
std::string username = body["username"].s();
std::string password = body["password"].s();
if (username.empty() || password.empty()) {
res.code = 400;
res.set_header("Content-Type", "application/json");
res.write("{\"error\": \"Username and password required\"}");
res.end();
return;
}
// Get account info from database
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo) {
res.code = 401;
res.set_header("Content-Type", "application/json");
res.write("{\"error\": \"Invalid credentials\"}");
res.end();
return;
}
// Verify password using bcrypt
if (!BCrypt::validatePassword(password, accountInfo->bcryptPassword)) {
res.code = 401;
res.set_header("Content-Type", "application/json");
res.write("{\"error\": \"Invalid credentials\"}");
res.end();
return;
}
// Check if account is banned or locked
if (accountInfo->banned) {
res.code = 403;
res.set_header("Content-Type", "application/json");
res.write("{\"error\": \"Account is banned\"}");
res.end();
return;
}
if (accountInfo->locked) {
res.code = 403;
res.set_header("Content-Type", "application/json");
res.write("{\"error\": \"Account is locked\"}");
res.end();
return;
}
// Create session
auto& session = app.get_context<Session>(req);
session.set("username", username);
session.set("account_id", static_cast<int>(accountInfo->id));
session.set("gm_level", static_cast<int>(accountInfo->maxGmLevel));
// Return success with user info
crow::json::wvalue response;
response["success"] = true;
response["username"] = username;
response["account_id"] = accountInfo->id;
response["gm_level"] = static_cast<uint8_t>(accountInfo->maxGmLevel);
res.set_header("Content-Type", "application/json");
res.write(response.dump());
res.end();
});
// Logout route
CROW_ROUTE(app, "/api/logout")
.methods("POST"_method)
([&](crow::request& req, crow::response& res) {
auto& session = app.get_context<Session>(req);
// Clear session
session.remove("username");
session.remove("account_id");
session.remove("gm_level");
crow::json::wvalue response;
response["success"] = true;
res.set_header("Content-Type", "application/json");
res.write(response.dump());
res.end();
});
// Auth status route
CROW_ROUTE(app, "/api/auth/status")
([&](const crow::request& req) {
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
crow::json::wvalue response;
if (!username.empty()) {
int account_id = session.template get<int>("account_id", -1);
int gm_level = session.template get<int>("gm_level", -1);
response["authenticated"] = true;
response["username"] = username;
response["account_id"] = account_id;
response["gm_level"] = gm_level;
} else {
response["authenticated"] = false;
}
return crow::response(response);
});
}
} // namespace AuthBlueprint

View File

@@ -1,17 +0,0 @@
#pragma once
#include "crow.h"
#include "crow/middlewares/session.h"
namespace AuthBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup authentication routes
* Registers login, logout, and auth status endpoints
*/
void Setup(DashboardApp& app);
} // namespace AuthBlueprint

View File

@@ -1,234 +0,0 @@
#include "BugReportsBlueprint.h"
#include "Database.h"
#include "eGameMasterLevel.h"
#include "Logger.h"
#include <ctime>
namespace BugReportsBlueprint {
// Helper function to get current user's account info from session
std::optional<IAccounts::Info> GetCurrentUser(const crow::request& req, DashboardApp& app) {
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
if (username.empty()) {
return std::nullopt;
}
return Database::Get()->GetAccountInfo(username);
}
// Helper function to get user's GM level
eGameMasterLevel GetUserGMLevel(const crow::request& req, DashboardApp& app) {
auto user = GetCurrentUser(req, app);
if (!user) {
return eGameMasterLevel::CIVILIAN;
}
return user->maxGmLevel;
}
// Helper function to check if user has minimum GM level
bool HasMinimumGMLevel(const crow::request& req, DashboardApp& app, eGameMasterLevel required) {
auto level = GetUserGMLevel(req, app);
return static_cast<uint8_t>(level) >= static_cast<uint8_t>(required);
}
void Setup(DashboardApp& app) {
// Get all bug reports (filtered by status)
CROW_ROUTE(app, "/api/bugreports")
.methods("GET"_method)
([&](const crow::request& req) {
// Anyone authenticated can view their own bug reports
// GMs can view all
auto user = GetCurrentUser(req, app);
if (!user) {
return crow::response(401, "{\"error\": \"Not authenticated\"}");
}
crow::json::wvalue response;
crow::json::wvalue::list data;
try {
auto statusParam = req.url_params.get("status");
std::string status = statusParam ? statusParam : "all";
std::vector<IBugReports::DetailedInfo> reports;
if (status == "resolved") {
reports = Database::Get()->GetResolvedBugReports();
} else if (status == "unresolved") {
reports = Database::Get()->GetUnresolvedBugReports();
} else {
reports = Database::Get()->GetAllBugReports();
}
bool isGM = static_cast<uint8_t>(user->maxGmLevel) >= static_cast<uint8_t>(eGameMasterLevel::MODERATOR);
for (const auto& report : reports) {
// If not a GM, only show reports from user's own characters
if (!isGM) {
auto charInfo = Database::Get()->GetCharacterInfo(report.characterId);
if (!charInfo || charInfo->accountId != user->id) {
continue;
}
}
crow::json::wvalue item;
item["id"] = report.id;
item["body"] = report.body;
item["client_version"] = report.clientVersion;
item["other_player"] = report.otherPlayer;
item["selection"] = report.selection;
item["character_id"] = static_cast<uint64_t>(report.characterId);
item["submitted"] = report.submitted;
item["resolved_time"] = report.resolved_time;
item["resolved_by_id"] = report.resolved_by_id;
item["resolution"] = report.resolution;
// Get character name
auto charInfo = Database::Get()->GetCharacterInfo(report.characterId);
if (charInfo) {
item["character_name"] = charInfo->name;
} else {
item["character_name"] = "Unknown";
}
data.push_back(std::move(item));
}
response["data"] = std::move(data);
} catch (std::exception& ex) {
response["error"] = ex.what();
return crow::response(500, response);
}
return crow::response(response);
});
// Get a single bug report by ID
CROW_ROUTE(app, "/api/bugreports/<uint>")
.methods("GET"_method)
([&](const crow::request& req, uint64_t report_id) {
auto user = GetCurrentUser(req, app);
if (!user) {
return crow::response(401, "{\"error\": \"Not authenticated\"}");
}
crow::json::wvalue response;
try {
auto report = Database::Get()->GetBugReportById(report_id);
if (!report) {
response["success"] = false;
response["error"] = "Bug report not found";
return crow::response(404, response);
}
// Check access rights
bool canAccess = false;
if (static_cast<uint8_t>(user->maxGmLevel) >= static_cast<uint8_t>(eGameMasterLevel::MODERATOR)) {
canAccess = true;
} else {
auto charInfo = Database::Get()->GetCharacterInfo(report->characterId);
if (charInfo && charInfo->accountId == user->id) {
canAccess = true;
}
}
if (!canAccess) {
response["success"] = false;
response["error"] = "Access denied";
return crow::response(403, response);
}
response["success"] = true;
response["id"] = report->id;
response["body"] = report->body;
response["client_version"] = report->clientVersion;
response["other_player"] = report->otherPlayer;
response["selection"] = report->selection;
response["character_id"] = static_cast<uint64_t>(report->characterId);
response["submitted"] = report->submitted;
response["resolved_time"] = report->resolved_time;
response["resolved_by_id"] = report->resolved_by_id;
response["resolution"] = report->resolution;
// Get character name
auto charInfo = Database::Get()->GetCharacterInfo(report->characterId);
if (charInfo) {
response["character_name"] = charInfo->name;
}
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Resolve a bug report
CROW_ROUTE(app, "/api/bugreports/<uint>/resolve")
.methods("POST"_method)
([&](const crow::request& req, uint64_t report_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
auto body = crow::json::load(req.body);
if (!body) {
return crow::response(400, "{\"error\": \"Invalid JSON\"}");
}
crow::json::wvalue response;
try {
auto user = GetCurrentUser(req, app);
if (!user) {
response["success"] = false;
response["error"] = "Not authenticated";
return crow::response(401, response);
}
std::string resolution;
if (body.has("resolution"))
resolution = std::string(body["resolution"].s());
else
resolution = "";
if (resolution.empty()) {
response["success"] = false;
response["error"] = "Resolution message is required";
return crow::response(response);
}
// Check if report exists and is not already resolved
auto report = Database::Get()->GetBugReportById(report_id);
if (!report) {
response["success"] = false;
response["error"] = "Bug report not found";
return crow::response(404, response);
}
if (report->resolved_time > 0) {
response["success"] = false;
response["error"] = "Bug report already resolved";
return crow::response(response);
}
Database::Get()->ResolveBugReport(report_id, user->id, resolution);
response["success"] = true;
response["message"] = "Bug report resolved successfully";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
}
} // namespace BugReportsBlueprint

View File

@@ -1,20 +0,0 @@
#ifndef __BUGREPORTSBLUEPRINT_H__
#define __BUGREPORTSBLUEPRINT_H__
#include "crow.h"
#include "crow/middlewares/session.h"
namespace BugReportsBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup bug reports management routes
* Registers routes for viewing and resolving bug reports
*/
void Setup(DashboardApp& app);
} // namespace BugReportsBlueprint
#endif // __BUGREPORTSBLUEPRINT_H__

View File

@@ -1,14 +0,0 @@
set(DDASHBOARDSERVER_BLUEPRINTS
"AuthBlueprint.cpp"
"ApiBlueprint.cpp"
"PageBlueprint.cpp"
"PlayKeysBlueprint.cpp"
"CharactersBlueprint.cpp"
"MailBlueprint.cpp"
"BugReportsBlueprint.cpp"
"ModerationBlueprint.cpp"
)
foreach(file ${DDASHBOARDSERVER_BLUEPRINTS})
set(DDASHBOARDSERVER_BLUEPRINTS_SOURCES ${DDASHBOARDSERVER_BLUEPRINTS_SOURCES} "blueprints/${file}" PARENT_SCOPE)
endforeach()

View File

@@ -1,263 +0,0 @@
#include "CharactersBlueprint.h"
#include "Database.h"
#include "eGameMasterLevel.h"
#include "ePermissionMap.h"
#include "Logger.h"
namespace CharactersBlueprint {
// Helper function to get current user's account info from session
std::optional<IAccounts::Info> GetCurrentUser(const crow::request& req, DashboardApp& app) {
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
if (username.empty()) {
return std::nullopt;
}
return Database::Get()->GetAccountInfo(username);
}
// Helper function to get user's GM level
eGameMasterLevel GetUserGMLevel(const crow::request& req, DashboardApp& app) {
auto user = GetCurrentUser(req, app);
if (!user) {
return eGameMasterLevel::CIVILIAN;
}
return user->maxGmLevel;
}
// Helper function to check if user has minimum GM level
bool HasMinimumGMLevel(const crow::request& req, DashboardApp& app, eGameMasterLevel required) {
auto level = GetUserGMLevel(req, app);
return static_cast<uint8_t>(level) >= static_cast<uint8_t>(required);
}
// Helper to check if user can access a character (owns it or is GM 3+)
bool CanAccessCharacter(const crow::request& req, DashboardApp& app, LWOOBJID characterId) {
auto user = GetCurrentUser(req, app);
if (!user) return false;
// GMs can access any character
if (static_cast<uint8_t>(user->maxGmLevel) >= static_cast<uint8_t>(eGameMasterLevel::MODERATOR)) {
return true;
}
// Check if user owns this character
auto charInfo = Database::Get()->GetCharacterInfo(characterId);
if (charInfo && charInfo->accountId == user->id) {
return true;
}
return false;
}
void Setup(DashboardApp& app) {
// Get character by ID
CROW_ROUTE(app, "/api/characters/<uint>")
.methods("GET"_method)
([&](const crow::request& req, uint64_t character_id) {
if (!CanAccessCharacter(req, app, character_id)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
auto charInfo = Database::Get()->GetCharacterInfo(character_id);
if (!charInfo) {
response["success"] = false;
response["error"] = "Character not found";
return crow::response(404, response);
}
response["success"] = true;
response["id"] = static_cast<uint64_t>(charInfo->id);
response["name"] = charInfo->name;
response["pending_name"] = charInfo->pendingName;
response["account_id"] = charInfo->accountId;
response["needs_rename"] = charInfo->needsRename;
response["clone_id"] = static_cast<uint64_t>(charInfo->cloneId);
response["permission_map"] = static_cast<uint64_t>(charInfo->permissionMap);
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Get character XML
CROW_ROUTE(app, "/api/characters/<uint>/xml")
.methods("GET"_method)
([&](const crow::request& req, uint64_t character_id) {
if (!CanAccessCharacter(req, app, character_id)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
try {
auto xml = Database::Get()->GetCharacterXml(character_id);
auto res = crow::response(xml);
res.set_header("Content-Type", "application/xml");
res.set_header("Content-Disposition", "attachment; filename=\"character_" + std::to_string(character_id) + ".xml\"");
return res;
} catch (std::exception& ex) {
crow::json::wvalue response;
response["success"] = false;
response["error"] = ex.what();
return crow::response(500, response);
}
});
// Rescue character (teleport to safe zone)
CROW_ROUTE(app, "/api/characters/<uint>/rescue")
.methods("POST"_method)
([&](const crow::request& req, uint64_t character_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
auto body = crow::json::load(req.body);
if (!body) {
return crow::response(400, "{\"error\": \"Invalid JSON\"}");
}
uint32_t zoneId = 1200; // Default to Avant Gardens
if (body.has("zone_id")) {
zoneId = body["zone_id"].i();
}
// RescueCharacter logic removed; this server does not perform live rescues.
// Return not-implemented to indicate the operation must be performed via the chat server.
response["success"] = false;
response["error"] = "Rescue character not implemented on this server. Use chat server tools.";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Toggle character restrictions (trade, mail, chat)
CROW_ROUTE(app, "/api/characters/<uint>/restrict/<int>")
.methods("POST"_method)
([&](const crow::request& req, uint64_t character_id, int restriction_bit) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
auto charInfo = Database::Get()->GetCharacterInfo(character_id);
if (!charInfo) {
response["success"] = false;
response["error"] = "Character not found";
return crow::response(404, response);
}
// Toggle the restriction bit
uint64_t currentPerms = static_cast<uint64_t>(charInfo->permissionMap);
uint64_t newPerms = currentPerms ^ (1ULL << restriction_bit);
Database::Get()->UpdateCharacterPermissions(character_id, static_cast<ePermissionMap>(newPerms));
response["success"] = true;
response["permission_map"] = newPerms;
response["message"] = "Character restrictions updated";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Force character rename
CROW_ROUTE(app, "/api/characters/<uint>/force-rename")
.methods("POST"_method)
([&](const crow::request& req, uint64_t character_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
auto charInfo = Database::Get()->GetCharacterInfo(character_id);
if (!charInfo) {
response["success"] = false;
response["error"] = "Character not found";
return crow::response(404, response);
}
Database::Get()->SetCharacterNeedsRename(character_id, true);
response["success"] = true;
response["message"] = "Character will be forced to rename on next login";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Set character name (admin override)
CROW_ROUTE(app, "/api/characters/<uint>/set-name")
.methods("POST"_method)
([&](const crow::request& req, uint64_t character_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::DEVELOPER)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
auto body = crow::json::load(req.body);
if (!body) {
return crow::response(400, "{\"error\": \"Invalid JSON\"}");
}
crow::json::wvalue response;
try {
std::string newName = body["name"].s();
if (newName.empty() || newName.length() > 33) {
response["success"] = false;
response["error"] = "Invalid name length (must be 1-33 characters)";
return crow::response(response);
}
// Check if name is already in use
if (Database::Get()->IsNameInUse(newName)) {
response["success"] = false;
response["error"] = "Name is already in use";
return crow::response(response);
}
Database::Get()->SetCharacterName(character_id, newName);
Database::Get()->SetPendingCharacterName(character_id, "");
Database::Get()->SetCharacterNeedsRename(character_id, false);
response["success"] = true;
response["message"] = "Character name updated successfully";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
}
} // namespace CharactersBlueprint

View File

@@ -1,20 +0,0 @@
#ifndef __CHARACTERSBLUEPRINT_H__
#define __CHARACTERSBLUEPRINT_H__
#include "crow.h"
#include "crow/middlewares/session.h"
namespace CharactersBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup character management routes
* Registers routes for viewing, editing, and managing characters
*/
void Setup(DashboardApp& app);
} // namespace CharactersBlueprint
#endif // __CHARACTERSBLUEPRINT_H__

View File

@@ -1,207 +0,0 @@
#include "MailBlueprint.h"
#include "Database.h"
#include "eGameMasterLevel.h"
#include "MailInfo.h"
#include "Logger.h"
#include <ctime>
namespace MailBlueprint {
// Helper function to get current user's account info from session
std::optional<IAccounts::Info> GetCurrentUser(const crow::request& req, DashboardApp& app) {
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
if (username.empty()) {
return std::nullopt;
}
return Database::Get()->GetAccountInfo(username);
}
// Helper function to get user's GM level
eGameMasterLevel GetUserGMLevel(const crow::request& req, DashboardApp& app) {
auto user = GetCurrentUser(req, app);
if (!user) {
return eGameMasterLevel::CIVILIAN;
}
return user->maxGmLevel;
}
// Helper function to check if user has minimum GM level
bool HasMinimumGMLevel(const crow::request& req, DashboardApp& app, eGameMasterLevel required) {
auto level = GetUserGMLevel(req, app);
return static_cast<uint8_t>(level) >= static_cast<uint8_t>(required);
}
void Setup(DashboardApp& app) {
// Send mail to a character or all characters
CROW_ROUTE(app, "/api/mail/send")
.methods("POST"_method)
([&](const crow::request& req) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
auto body = crow::json::load(req.body);
if (!body) {
return crow::response(400, "{\"error\": \"Invalid JSON\"}");
}
crow::json::wvalue response;
try {
auto user = GetCurrentUser(req, app);
if (!user) {
response["success"] = false;
response["error"] = "Not authenticated";
return crow::response(401, response);
}
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
// Get mail parameters
std::string subject;
if (body.has("subject"))
subject = std::string(body["subject"].s());
else
subject = "";
std::string message;
if (body.has("body"))
message = std::string(body["body"].s());
else
message = "";
int64_t recipientId = body.has("recipient_id") ? body["recipient_id"].i() : 0;
bool sendToAll = body.has("send_to_all") ? body["send_to_all"].b() : false;
// Item attachment (optional)
int32_t itemLot = body.has("attachment_lot") ? body["attachment_lot"].i() : 0;
int32_t itemCount = body.has("attachment_count") ? body["attachment_count"].i() : 0;
if (subject.empty() || message.empty()) {
response["success"] = false;
response["error"] = "Subject and body are required";
return crow::response(response);
}
// Prefix sender name with [GM]
std::string senderName = "[GM] " + username;
std::vector<LWOOBJID> recipients;
if (sendToAll) {
// Get all accounts and their characters
auto allAccounts = Database::Get()->GetAllAccounts();
for (const auto& acct : allAccounts) {
auto chars = Database::Get()->GetAccountCharacterIds(acct.id);
for (const auto& charId : chars) {
recipients.push_back(charId);
}
}
} else if (recipientId > 0) {
recipients.push_back(recipientId);
} else {
response["success"] = false;
response["error"] = "No recipients specified";
return crow::response(response);
}
// Send mail to all recipients
uint64_t currentTime = static_cast<uint64_t>(std::time(nullptr));
int mailSent = 0;
for (const auto& recipId : recipients) {
// Get recipient character name
auto charInfo = Database::Get()->GetCharacterInfo(recipId);
if (!charInfo) continue;
MailInfo mail;
mail.senderUsername = senderName;
mail.recipient = charInfo->name;
mail.receiverId = recipId;
mail.subject = subject;
mail.body = message;
mail.itemID = itemLot > 0 ? 1 : 0; // If there's an item, set ID to 1
mail.itemLOT = itemLot;
mail.itemCount = itemCount > 0 ? itemCount : 1;
mail.timeSent = currentTime;
mail.wasRead = false;
Database::Get()->InsertNewMail(mail);
mailSent++;
}
response["success"] = true;
response["message"] = "Mail sent successfully";
response["recipients"] = mailSent;
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Get mail by ID (for viewing)
CROW_ROUTE(app, "/api/mail/<uint>")
.methods("GET"_method)
([&](const crow::request& req, uint64_t mail_id) {
// Any authenticated user can view mail
auto user = GetCurrentUser(req, app);
if (!user) {
return crow::response(401, "{\"error\": \"Not authenticated\"}");
}
crow::json::wvalue response;
try {
auto mail = Database::Get()->GetMail(mail_id);
if (!mail) {
response["success"] = false;
response["error"] = "Mail not found";
return crow::response(404, response);
}
// Check if user can access this mail (owns the character or is GM)
auto charInfo = Database::Get()->GetCharacterInfo(mail->receiverId);
bool canAccess = false;
if (charInfo && charInfo->accountId == user->id) {
canAccess = true;
}
if (static_cast<uint8_t>(user->maxGmLevel) >= static_cast<uint8_t>(eGameMasterLevel::MODERATOR)) {
canAccess = true;
}
if (!canAccess) {
response["success"] = false;
response["error"] = "Access denied";
return crow::response(403, response);
}
response["success"] = true;
response["id"] = mail->id;
response["sender_name"] = mail->senderUsername;
response["receiver_name"] = mail->recipient;
response["receiver_id"] = static_cast<uint64_t>(mail->receiverId);
response["subject"] = mail->subject;
response["body"] = mail->body;
response["attachment_lot"] = mail->itemLOT;
response["attachment_count"] = mail->itemCount;
response["time_sent"] = mail->timeSent;
response["was_read"] = mail->wasRead;
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
}
} // namespace MailBlueprint

View File

@@ -1,20 +0,0 @@
#ifndef __MAILBLUEPRINT_H__
#define __MAILBLUEPRINT_H__
#include "crow.h"
#include "crow/middlewares/session.h"
namespace MailBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup mail management routes
* Registers routes for sending and viewing mail
*/
void Setup(DashboardApp& app);
} // namespace MailBlueprint
#endif // __MAILBLUEPRINT_H__

View File

@@ -1,279 +0,0 @@
#include "ModerationBlueprint.h"
#include "Database.h"
#include "eGameMasterLevel.h"
#include "Logger.h"
namespace ModerationBlueprint {
// Helper function to get current user's account info from session
std::optional<IAccounts::Info> GetCurrentUser(const crow::request& req, DashboardApp& app) {
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
if (username.empty()) {
return std::nullopt;
}
return Database::Get()->GetAccountInfo(username);
}
// Helper function to check if user has minimum GM level
bool HasMinimumGMLevel(const crow::request& req, DashboardApp& app, eGameMasterLevel required) {
auto user = GetCurrentUser(req, app);
if (!user) {
return false;
}
return static_cast<uint8_t>(user->maxGmLevel) >= static_cast<uint8_t>(required);
}
void Setup(DashboardApp& app) {
// Get pet names by status
CROW_ROUTE(app, "/api/moderation/pets")
.methods("GET"_method)
([&](const crow::request& req) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
crow::json::wvalue::list data;
try {
auto statusParam = req.url_params.get("status");
std::string status = statusParam ? statusParam : "all";
std::vector<IPetNames::DetailedInfo> pets;
if (status == "approved") {
pets = Database::Get()->GetPetNamesByStatus(2);
} else if (status == "unapproved") {
pets = Database::Get()->GetPetNamesByStatus(1);
} else {
pets = Database::Get()->GetAllPetNames();
}
for (const auto& pet : pets) {
crow::json::wvalue item;
item["id"] = static_cast<uint64_t>(pet.id);
item["pet_name"] = pet.petName;
item["approval_status"] = pet.approvalStatus;
item["owner_id"] = static_cast<uint64_t>(pet.ownerId);
// Get owner character name
if (pet.ownerId > 0) {
auto charInfo = Database::Get()->GetCharacterInfo(pet.ownerId);
if (charInfo) {
item["owner_name"] = charInfo->name;
} else {
item["owner_name"] = "Unknown";
}
} else {
item["owner_name"] = "None";
}
data.push_back(std::move(item));
}
response["data"] = std::move(data);
} catch (std::exception& ex) {
response["error"] = ex.what();
return crow::response(500, response);
}
return crow::response(response);
});
// Approve a pet name
CROW_ROUTE(app, "/api/moderation/pets/<uint>/approve")
.methods("POST"_method)
([&](const crow::request& req, uint64_t pet_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
Database::Get()->SetPetApprovalStatus(pet_id, 2); // 2 = approved
response["success"] = true;
response["message"] = "Pet name approved";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Reject a pet name
CROW_ROUTE(app, "/api/moderation/pets/<uint>/reject")
.methods("POST"_method)
([&](const crow::request& req, uint64_t pet_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
Database::Get()->SetPetApprovalStatus(pet_id, 0); // 0 = rejected
response["success"] = true;
response["message"] = "Pet name rejected";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Get properties by approval status
CROW_ROUTE(app, "/api/moderation/properties")
.methods("GET"_method)
([&](const crow::request& req) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
crow::json::wvalue::list data;
try {
auto statusParam = req.url_params.get("status");
std::string status = statusParam ? statusParam : "all";
std::vector<IProperty::Info> properties;
if (status == "approved") {
properties = Database::Get()->GetPropertiesByApprovalStatus(1);
} else if (status == "unapproved") {
properties = Database::Get()->GetPropertiesByApprovalStatus(0);
} else {
properties = Database::Get()->GetAllProperties();
}
for (const auto& prop : properties) {
crow::json::wvalue item;
item["id"] = static_cast<uint64_t>(prop.id);
item["name"] = prop.name;
item["description"] = prop.description;
item["owner_id"] = static_cast<uint64_t>(prop.ownerId);
item["clone_id"] = static_cast<uint64_t>(prop.cloneId);
item["privacy_option"] = prop.privacyOption;
item["mod_approved"] = prop.modApproved;
item["last_updated"] = prop.lastUpdatedTime;
item["claimed_time"] = prop.claimedTime;
item["reputation"] = prop.reputation;
item["performance_cost"] = prop.performanceCost;
item["rejection_reason"] = prop.rejectionReason;
// Get owner character name
auto charInfo = Database::Get()->GetCharacterInfo(prop.ownerId);
if (charInfo) {
item["owner_name"] = charInfo->name;
} else {
item["owner_name"] = "Unknown";
}
data.push_back(std::move(item));
}
response["data"] = std::move(data);
} catch (std::exception& ex) {
response["error"] = ex.what();
return crow::response(500, response);
}
return crow::response(response);
});
// Approve/unapprove a property
CROW_ROUTE(app, "/api/moderation/properties/<uint>/approve")
.methods("POST"_method)
([&](const crow::request& req, uint64_t property_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
auto prop = Database::Get()->GetPropertyInfo(property_id);
if (!prop) {
response["success"] = false;
response["error"] = "Property not found";
return crow::response(404, response);
}
// Toggle approval
IProperty::Info updatedInfo = *prop;
updatedInfo.modApproved = prop->modApproved ? 0 : 1;
updatedInfo.rejectionReason = "";
Database::Get()->UpdatePropertyModerationInfo(updatedInfo);
response["success"] = true;
response["approved"] = updatedInfo.modApproved;
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Reject a property with reason
CROW_ROUTE(app, "/api/moderation/properties/<uint>/reject")
.methods("POST"_method)
([&](const crow::request& req, uint64_t property_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
auto body = crow::json::load(req.body);
if (!body) {
return crow::response(400, "{\"error\": \"Invalid JSON\"}");
}
crow::json::wvalue response;
try {
auto prop = Database::Get()->GetPropertyInfo(property_id);
if (!prop) {
response["success"] = false;
response["error"] = "Property not found";
return crow::response(404, response);
}
std::string reason;
if (body.has("reason"))
reason = std::string(body["reason"].s());
else
reason = "No reason provided";
IProperty::Info updatedInfo = *prop;
updatedInfo.modApproved = 0;
updatedInfo.rejectionReason = reason;
Database::Get()->UpdatePropertyModerationInfo(updatedInfo);
response["success"] = true;
response["message"] = "Property rejected";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
}
} // namespace ModerationBlueprint

View File

@@ -1,20 +0,0 @@
#ifndef __MODERATIONBLUEPRINT_H__
#define __MODERATIONBLUEPRINT_H__
#include "crow.h"
#include "crow/middlewares/session.h"
namespace ModerationBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup moderation routes
* Registers routes for pet name moderation and property approval
*/
void Setup(DashboardApp& app);
} // namespace ModerationBlueprint
#endif // __MODERATIONBLUEPRINT_H__

View File

@@ -1,380 +0,0 @@
#include "PageBlueprint.h"
#include "Logger.h"
#include "Database.h"
#include "eGameMasterLevel.h"
namespace PageBlueprint {
// Helper to get GM level name
std::string GetGMLevelName(eGameMasterLevel level) {
switch (level) {
case eGameMasterLevel::CIVILIAN: return "Civilian";
case eGameMasterLevel::FORUM_MODERATOR: return "Forum Moderator";
case eGameMasterLevel::JUNIOR_MODERATOR: return "Junior Moderator";
case eGameMasterLevel::MODERATOR: return "Moderator";
case eGameMasterLevel::SENIOR_MODERATOR: return "Senior Moderator";
case eGameMasterLevel::LEAD_MODERATOR: return "Lead Moderator";
case eGameMasterLevel::JUNIOR_DEVELOPER: return "Junior Developer";
case eGameMasterLevel::INACTIVE_DEVELOPER: return "Inactive Developer";
case eGameMasterLevel::DEVELOPER: return "Developer";
case eGameMasterLevel::OPERATOR: return "Operator";
default: return "Unknown";
}
}
// Helper to get current user's account info from session
std::optional<IAccounts::Info> GetCurrentUser(const crow::request& req, DashboardApp& app) {
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
if (username.empty()) {
return std::nullopt;
}
return Database::Get()->GetAccountInfo(username);
}
// Helper to get user's GM level
eGameMasterLevel GetUserGMLevel(const crow::request& req, DashboardApp& app) {
auto user = GetCurrentUser(req, app);
if (!user) {
return eGameMasterLevel::CIVILIAN;
}
return user->maxGmLevel;
}
// Helper to check if user has minimum GM level
bool HasMinimumGMLevel(const crow::request& req, DashboardApp& app, eGameMasterLevel required) {
auto level = GetUserGMLevel(req, app);
return static_cast<uint8_t>(level) >= static_cast<uint8_t>(required);
}
// Helper to create base context for all templates
crow::mustache::context GetBaseContext(const crow::request& req, DashboardApp& app) {
crow::mustache::context ctx;
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
int account_id = session.template get<int>("account_id", -1);
int gm_level = session.template get<int>("gm_level", -1);
if (!username.empty() && account_id != -1) {
LOG("User '%s' (Account ID: %d) is authenticated with GM level %d", username.c_str(), account_id, gm_level);
ctx["is_authenticated"] = true;
ctx["show_navbar"] = true;
ctx["username"] = username;
ctx["account_id"] = account_id;
ctx["gm_level"] = gm_level;
ctx["gm_level_name"] = GetGMLevelName(static_cast<eGameMasterLevel>(gm_level));
// Set permission flags
ctx["is_gm_3_plus"] = (gm_level >= 3);
ctx["is_gm_5_plus"] = (gm_level >= 5);
ctx["is_gm_8_plus"] = (gm_level >= 8);
ctx["is_gm_9_plus"] = (gm_level >= 9);
} else {
LOG("User is not authenticated");
ctx["is_authenticated"] = false;
ctx["show_navbar"] = false;
}
return ctx;
}
// Helper to render a page with layout
std::string RenderPage(const crow::request& req, DashboardApp& app, const std::string& template_name, const std::string& page_title, crow::mustache::context& page_ctx) {
auto base_ctx = GetBaseContext(req, app);
// Merge base context with page-specific context
for (const auto& key : page_ctx.keys()) {
base_ctx[key] = crow::json::wvalue(page_ctx[key]);
}
// Load the content template and render to string
auto content_page = crow::mustache::load(template_name);
std::string content_html = content_page.render_string(base_ctx);
// Set content and page title in base context
base_ctx["content"] = crow::json::wvalue(content_html);
base_ctx["page_title"] = crow::json::wvalue(page_title);
// Render with layout
auto layout = crow::mustache::load("layouts/base.html");
return layout.render_string(base_ctx);
}
void Setup(DashboardApp& app) {
// Home/Dashboard page
CROW_ROUTE(app, "/")
([&](const crow::request& req) {
crow::mustache::context ctx;
ctx["nav_home"] = true;
std::string html = RenderPage(req, app, "index.html", "Dashboard", ctx);
return crow::response(html);
});
// Login page
CROW_ROUTE(app, "/login")
([&](const crow::request& req) {
crow::mustache::context ctx;
std::string html = RenderPage(req, app, "login.html", "Login", ctx);
return crow::response(html);
});
// Accounts page
CROW_ROUTE(app, "/accounts")
([&](const crow::request& req) {
// Check GM level
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_accounts"] = true;
std::string html = RenderPage(req, app, "accounts/index.html", "Accounts", ctx);
return crow::response(html);
});
// Activity Logs page
CROW_ROUTE(app, "/logs/activities")
([&](const crow::request& req) {
// Check GM level - Developers and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::DEVELOPER)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
// Set nav active state if needed
std::string html = RenderPage(req, app, "logs/activities.html", "Activity Logs", ctx);
return crow::response(html);
});
// Characters page
CROW_ROUTE(app, "/characters")
([&](const crow::request& req) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_characters"] = true;
std::string html = RenderPage(req, app, "characters/index.html", "Characters", ctx);
return crow::response(html);
});
// Play Keys page
CROW_ROUTE(app, "/playkeys")
([&](const crow::request& req) {
// Check GM level - Lead Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::LEAD_MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_playkeys"] = true;
std::string html = RenderPage(req, app, "playkeys/index.html", "Play Keys", ctx);
return crow::response(html);
});
// Registration page - public
CROW_ROUTE(app, "/register")
([&](const crow::request& req) {
crow::mustache::context ctx;
std::string html = RenderPage(req, app, "register.html", "Register", ctx);
return crow::response(html);
});
// Mail page
CROW_ROUTE(app, "/mail/send")
([&](const crow::request& req) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_mail"] = true;
std::string html = RenderPage(req, app, "mail/send.html", "Send Mail", ctx);
return crow::response(html);
});
// Bug Reports page
CROW_ROUTE(app, "/bugreports")
([&](const crow::request& req) {
// Anyone authenticated can view their own bug reports
// GMs can view all
auto user = GetCurrentUser(req, app);
if (!user) {
return crow::response(403, "Forbidden - Login required");
}
crow::mustache::context ctx;
ctx["nav_bugreports"] = true;
std::string html = RenderPage(req, app, "bugreports/index.html", "Bug Reports", ctx);
return crow::response(html);
});
// Moderation page - Pet Names
CROW_ROUTE(app, "/moderation/pets")
([&](const crow::request& req) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_moderation"] = true;
std::string html = RenderPage(req, app, "moderation/pets.html", "Pet Name Moderation", ctx);
return crow::response(html);
});
// Moderation page - Properties
CROW_ROUTE(app, "/moderation/properties")
([&](const crow::request& req) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_moderation"] = true;
std::string html = RenderPage(req, app, "moderation/properties.html", "Property Moderation", ctx);
return crow::response(html);
});
// Account view page
CROW_ROUTE(app, "/accounts/view/<int>")
([&](const crow::request& req, int account_id) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_accounts"] = true;
ctx["account_id"] = account_id;
std::string html = RenderPage(req, app, "accounts/view.html", "View Account", ctx);
return crow::response(html);
});
// Character view page
CROW_ROUTE(app, "/characters/view/<int>")
([&](const crow::request& req, int character_id) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_characters"] = true;
ctx["character_id"] = character_id;
std::string html = RenderPage(req, app, "characters/view.html", "View Character", ctx);
return crow::response(html);
});
// Logs - Command Logs page
CROW_ROUTE(app, "/logs/commands")
([&](const crow::request& req) {
// Check GM level - Developers and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::DEVELOPER)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
// Set nav active state if needed
std::string html = RenderPage(req, app, "logs/commands.html", "Command Logs", ctx);
return crow::response(html);
});
// Logs - Audit Logs page
CROW_ROUTE(app, "/logs/audits")
([&](const crow::request& req) {
// Check GM level - Developers and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::DEVELOPER)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
// Set nav active state if needed
std::string html = RenderPage(req, app, "logs/audits.html", "Audit Logs", ctx);
return crow::response(html);
});
// About page
CROW_ROUTE(app, "/about")
([&](const crow::request& req) {
auto user = GetCurrentUser(req, app);
if (!user) {
return crow::response(403, "Forbidden - Login required");
}
crow::mustache::context ctx;
std::string html = RenderPage(req, app, "about.html", "About", ctx);
return crow::response(html);
});
// Bug Reports page (fix routing)
CROW_ROUTE(app, "/bugs")
([&](const crow::request& req) {
// Anyone authenticated can view their own bug reports
// GMs can view all
auto user = GetCurrentUser(req, app);
if (!user) {
return crow::response(403, "Forbidden - Login required");
}
crow::mustache::context ctx;
ctx["nav_bugs"] = true;
std::string html = RenderPage(req, app, "bugreports/index.html", "Bug Reports", ctx);
return crow::response(html);
});
// Moderation page - Pending Pets
CROW_ROUTE(app, "/moderation/pending")
([&](const crow::request& req) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_moderation"] = true;
std::string html = RenderPage(req, app, "moderation/pets.html", "Pending Pet Names", ctx);
return crow::response(html);
});
// Properties page
CROW_ROUTE(app, "/properties")
([&](const crow::request& req) {
// Check GM level - Moderators and above
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::MODERATOR)) {
return crow::response(403, "Forbidden - Insufficient GM level");
}
crow::mustache::context ctx;
ctx["nav_moderation"] = true;
std::string html = RenderPage(req, app, "moderation/properties.html", "Property Moderation", ctx);
return crow::response(html);
});
}
} // namespace PageBlueprint

View File

@@ -1,17 +0,0 @@
#pragma once
#include "crow.h"
#include "crow/middlewares/session.h"
namespace PageBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup page rendering routes
* Registers routes that render HTML pages (dashboard, login, accounts, etc.)
*/
void Setup(DashboardApp& app);
} // namespace PageBlueprint

View File

@@ -1,288 +0,0 @@
#include "PlayKeysBlueprint.h"
#include "Database.h"
#include "eGameMasterLevel.h"
#include "Logger.h"
#include <random>
#include <sstream>
#include <iomanip>
namespace PlayKeysBlueprint {
// Helper to generate a random play key string (format: XXXX-XXXX-XXXX-XXXX)
std::string GeneratePlayKeyString() {
static const char charset[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excluding ambiguous chars
static std::random_device rd;
static std::mt19937 gen(rd());
static std::uniform_int_distribution<> dis(0, sizeof(charset) - 2);
std::stringstream ss;
for (int i = 0; i < 16; i++) {
if (i > 0 && i % 4 == 0) ss << '-';
ss << charset[dis(gen)];
}
return ss.str();
}
// Helper function to get current user's account info from session
std::optional<IAccounts::Info> GetCurrentUser(const crow::request& req, DashboardApp& app) {
auto& session = app.get_context<Session>(const_cast<crow::request&>(req));
std::string username = session.template get<std::string>("username");
if (username.empty()) {
return std::nullopt;
}
return Database::Get()->GetAccountInfo(username);
}
// Helper function to get user's GM level
eGameMasterLevel GetUserGMLevel(const crow::request& req, DashboardApp& app) {
auto user = GetCurrentUser(req, app);
if (!user) {
return eGameMasterLevel::CIVILIAN;
}
return user->maxGmLevel;
}
// Helper function to check if user has minimum GM level
bool HasMinimumGMLevel(const crow::request& req, DashboardApp& app, eGameMasterLevel required) {
auto level = GetUserGMLevel(req, app);
return static_cast<uint8_t>(level) >= static_cast<uint8_t>(required);
}
void Setup(DashboardApp& app) {
// Get all play keys (DataTables endpoint)
CROW_ROUTE(app, "/api/playkeys")
.methods("GET"_method)
([&](const crow::request& req) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::LEAD_MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
crow::json::wvalue::list data;
try {
auto keys = Database::Get()->GetAllPlayKeys();
for (const auto& key : keys) {
crow::json::wvalue item;
item["id"] = key.id;
item["key_string"] = key.key_string;
item["key_uses"] = key.key_uses;
item["times_used"] = key.times_used;
item["active"] = key.active;
item["notes"] = key.notes;
item["created_at"] = static_cast<uint64_t>(key.created_at);
data.push_back(std::move(item));
}
} catch (std::exception& ex) {
// return empty list on failure
}
response["data"] = std::move(data);
return crow::response(response);
});
// Create a new play key
CROW_ROUTE(app, "/api/playkeys/create")
.methods("POST"_method)
([&](const crow::request& req) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::LEAD_MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
auto body = crow::json::load(req.body);
if (!body) {
return crow::response(400, "{\"error\": \"Invalid JSON\"}");
}
crow::json::wvalue response;
try {
uint32_t count = body.has("count") ? body["count"].i() : 1;
uint32_t uses = body.has("uses") ? body["uses"].i() : 1;
std::string notes;
if (body.has("notes"))
notes = std::string(body["notes"].s());
else
notes = "";
// Limit to prevent abuse
if (count > 100) {
response["success"] = false;
response["error"] = "Cannot create more than 100 keys at once";
return crow::response(response);
}
crow::json::wvalue::list keys;
for (uint32_t i = 0; i < count; i++) {
std::string keyString = GeneratePlayKeyString();
Database::Get()->CreatePlayKey(keyString, uses, notes);
keys.push_back(keyString);
}
response["success"] = true;
response["keys"] = std::move(keys);
response["count"] = count;
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Get single play key by ID
CROW_ROUTE(app, "/api/playkeys/<int>")
.methods("GET"_method)
([&](const crow::request& req, int key_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::LEAD_MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
auto key = Database::Get()->GetPlayKeyById(key_id);
if (!key) {
response["success"] = false;
response["error"] = "Play key not found";
return crow::response(404, response);
}
response["success"] = true;
response["id"] = key->id;
response["key_string"] = key->key_string;
response["key_uses"] = key->key_uses;
response["times_used"] = key->times_used;
response["active"] = key->active;
response["notes"] = key->notes;
response["created_at"] = static_cast<uint64_t>(key->created_at);
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Update a play key
CROW_ROUTE(app, "/api/playkeys/<int>")
.methods("PUT"_method, "POST"_method)
([&](const crow::request& req, int key_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::LEAD_MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
auto body = crow::json::load(req.body);
if (!body) {
return crow::response(400, "{\"error\": \"Invalid JSON\"}");
}
crow::json::wvalue response;
try {
// Get current key info
auto key = Database::Get()->GetPlayKeyById(key_id);
if (!key) {
response["success"] = false;
response["error"] = "Play key not found";
return crow::response(404, response);
}
uint32_t uses = body.has("uses") ? body["uses"].i() : key->key_uses;
bool active = body.has("active") ? body["active"].b() : key->active;
std::string notes;
if (body.has("notes"))
notes = std::string(body["notes"].s());
else
notes = key->notes;
Database::Get()->UpdatePlayKey(key_id, uses, active, notes);
response["success"] = true;
response["message"] = "Play key updated successfully";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Delete a play key
CROW_ROUTE(app, "/api/playkeys/<int>")
.methods("DELETE"_method)
([&](const crow::request& req, int key_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::LEAD_MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
try {
// Check if key exists
auto key = Database::Get()->GetPlayKeyById(key_id);
if (!key) {
response["success"] = false;
response["error"] = "Play key not found";
return crow::response(404, response);
}
Database::Get()->DeletePlayKey(key_id);
response["success"] = true;
response["message"] = "Play key deleted successfully";
} catch (std::exception& ex) {
response["success"] = false;
response["error"] = ex.what();
}
return crow::response(response);
});
// Get accounts associated with a play key
CROW_ROUTE(app, "/api/playkeys/<int>/accounts")
.methods("GET"_method)
([&](const crow::request& req, int key_id) {
if (!HasMinimumGMLevel(req, app, eGameMasterLevel::LEAD_MODERATOR)) {
return crow::response(403, "{\"error\": \"Forbidden\"}");
}
crow::json::wvalue response;
crow::json::wvalue::list accounts;
try {
// Get all accounts and filter by play_key_id
auto allAccounts = Database::Get()->GetAllAccounts();
for (const auto& acct : allAccounts) {
if (acct.play_key_id == static_cast<uint32_t>(key_id)) {
crow::json::wvalue item;
item["id"] = acct.id;
item["name"] = acct.name;
item["gm_level"] = static_cast<int>(acct.gm_level);
item["banned"] = acct.banned;
item["locked"] = acct.locked;
accounts.push_back(std::move(item));
}
}
response["data"] = std::move(accounts);
} catch (std::exception& ex) {
response["error"] = ex.what();
return crow::response(500, response);
}
return crow::response(response);
});
}
} // namespace PlayKeysBlueprint

View File

@@ -1,20 +0,0 @@
#ifndef __PLAYKEYSBLUEPRINT_H__
#define __PLAYKEYSBLUEPRINT_H__
#include "crow.h"
#include "crow/middlewares/session.h"
namespace PlayKeysBlueprint {
using Session = crow::SessionMiddleware<crow::InMemoryStore>;
using DashboardApp = crow::App<crow::CookieParser, Session>;
/**
* Setup play keys management routes
* Registers routes for creating, viewing, editing, and deleting play keys
*/
void Setup(DashboardApp& app);
} // namespace PlayKeysBlueprint
#endif // __PLAYKEYSBLUEPRINT_H__

View File

@@ -1,144 +0,0 @@
/*
* Consolidated NexusDashboard CSS
* Combined from nexus-theme.css and dashboard.css to provide a single
* consistent stylesheet for the DarkflameServer dashboard.
*/
/* ------------------------ Nexus theme (dark) variables ------------------------ */
:root {
--nexus-dark-bg: #212529;
--nexus-darker-bg: #1a1d20;
--nexus-card-bg: #2c3034;
--nexus-border: #404448;
--nexus-text: #f8f9fa;
--nexus-text-muted: #adb5bd;
--nexus-primary: #0d6efd;
--nexus-success: #198754;
--nexus-warning: #ffc107;
--nexus-danger: #dc3545;
--nexus-info: #0dcaf0;
/* legacy dashboard variables */
--primary-color: #0d6efd;
--success-color: #198754;
--warning-color: #ffc107;
--danger-color: #dc3545;
--dark-bg: #1a1a1a;
--light-bg: #f8f9fa;
}
/* ------------------------ Base layout, navbar, cards ------------------------ */
body {
background-color: var(--nexus-dark-bg);
color: var(--nexus-text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
main { flex: 1; padding-bottom: 60px; }
.footer { margin-top: auto; border-top: 1px solid var(--nexus-border); background-color: var(--nexus-dark-bg); }
/* Ensure footer text is visible on dark background */
.footer, .footer .text-muted { color: var(--nexus-text-muted) !important; }
.navbar { box-shadow: 0 2px 4px rgba(0,0,0,.1); }
.navbar-brand { font-weight: bold; font-size: 1.25rem; }
.nav-link { transition: all 0.3s ease; }
.nav-link:hover { background-color: rgba(255,255,255,0.05); border-radius: 4px; }
.nav-link.active { background-color: rgba(255,255,255,0.08); border-radius: 4px; }
.card { background-color: var(--nexus-card-bg); border-color: var(--nexus-border); color: var(--nexus-text); border: none; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin-bottom: 1.5rem; transition: transform 0.2s ease, box-shadow 0.2s ease; }
.card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.card-header { background-color: var(--nexus-darker-bg); border-bottom-color: var(--nexus-border); color: var(--nexus-text); font-weight: 600; }
/* ------------------------ Tables and DataTables ------------------------ */
.table { color: var(--nexus-text); background-color: #1e1e1e; }
.table thead th { background-color: #242526; color: var(--nexus-text); border-bottom: 1px solid var(--nexus-border); font-weight: 600; }
.table tbody td { color: var(--nexus-text); }
.table-striped > tbody > tr:nth-of-type(odd) > * { background-color: rgba(255,255,255,0.02); }
.table-hover > tbody > tr:hover > * { background-color: rgba(255,255,255,0.035); }
/* DataTables adds `odd`/`even` classes and sometimes doesn't use `.table-striped`.
Normalize striping across Bootstrap tables and DataTables instances so every
other row has a visible background in dark mode. Use slightly stronger contrast
and cover different DOM shapes that DataTables can produce (cells or `*`). */
.dataTable tbody tr.odd > *,
.dataTable tbody tr.odd td,
.table.table-striped tbody tr.odd > *,
.table.table-striped tbody tr.odd td {
background-color: rgba(255,255,255,0.03);
}
.dataTable tbody tr.even > *,
.dataTable tbody tr.even td,
.table.table-striped tbody tr.even > *,
.table.table-striped tbody tr.even td {
background-color: transparent;
}
/* Some DataTables setups use nested wrappers (.dataTables_scrollBody) so ensure
striping still applies inside scroll bodies. */
.dataTables_scrollBody table tbody tr.odd > *,
.dataTables_scrollBody table tbody tr.odd td {
background-color: rgba(255,255,255,0.03);
}
.dataTables_scrollBody table tbody tr.even > *,
.dataTables_scrollBody table tbody tr.even td {
background-color: transparent;
}
/* Keep hover state clear above striping */
.dataTable tbody tr:hover > *,
.table tbody tr:hover > * {
background-color: rgba(255,255,255,0.05);
}
.table > :not(caption) > * > * { border-bottom-color: var(--nexus-border); }
/* Light-theme overrides (explicit) */
@media (prefers-color-scheme: light) {
body { background-color: var(--light-bg); color: #212529; }
.card { background-color: #fff; color: #212529; }
.card-header { background-color: #fff; border-bottom: 2px solid var(--primary-color); color: #212529; }
.table { background-color: white; color: #212529; }
.table thead th { background-color: #f8f9fa; color: #212529; border-bottom: 2px solid #dee2e6; font-weight: 600; text-transform: uppercase; font-size: 0.85rem; letter-spacing: 0.5px; }
.dataTables_wrapper select, .dataTables_wrapper input { background-color: #fff; border-color: #ced4da; color: #212529; }
}
/* Dark mode explicit styling (prefers-color-scheme: dark) */
@media (prefers-color-scheme: dark) {
body { background-color: var(--nexus-dark-bg); color: var(--nexus-text); }
.card { background-color: var(--nexus-card-bg); color: var(--nexus-text); }
.card-header { background-color: var(--nexus-darker-bg); color: var(--nexus-text); }
.table { background-color: #1e1e1e; color: var(--nexus-text); }
.table thead th { background-color: #252525; border-bottom-color: #3a3a3a; color: var(--nexus-text); }
.dataTables_wrapper select, .dataTables_wrapper input { background-color: var(--nexus-darker-bg); border-color: var(--nexus-border); color: var(--nexus-text); }
}
/* DataTables specific visual rules */
.dataTables_wrapper { padding: 0; }
.dataTables_filter input { margin-left: 0.5rem; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem; }
.dataTables_length select { padding: 0.375rem 2rem 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem; margin: 0 0.5rem; }
.dataTables_wrapper .dataTables_paginate .paginate_button { color: var(--nexus-text) !important; }
.dataTables_wrapper .dataTables_paginate .paginate_button.current { background: var(--nexus-primary); border-color: var(--nexus-primary); color: white !important; }
/* Forms, badges, buttons, utilities */
.form-control, .form-select { background-color: var(--nexus-darker-bg); border-color: var(--nexus-border); color: var(--nexus-text); }
.form-control::placeholder { color: var(--nexus-text-muted); }
.form-label { color: var(--nexus-text); }
.badge { padding: 0.35em 0.65em; font-weight: 500; }
.btn { transition: all 0.2s ease; }
.btn:hover { transform: translateY(-1px); box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
/* Utilities and accessibility */
.loading { position: relative; pointer-events: none; opacity: 0.6; }
.loading::after { content: ""; position: absolute; top: 50%; left: 50%; width: 2rem; height: 2rem; margin: -1rem 0 0 -1rem; border: 0.25rem solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spinner 0.75s linear infinite; }
@keyframes spinner { to { transform: rotate(360deg); } }
/* Responsive tweaks */
@media (max-width: 768px) { .navbar-brand { font-size: 1rem; } .card { margin-bottom: 1rem; } .alerts-container { left: 10px; right: 10px; max-width: none; } .btn-group { flex-wrap: wrap; } }
/* Extra helpers */
.cursor-pointer { cursor: pointer; }
.text-truncate-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.ws-nowrap { white-space: nowrap; }
/* End of consolidated stylesheet */

View File

@@ -1,144 +0,0 @@
/**
* API Client for DarkflameServer Dashboard
* Provides a simple interface for making API calls with error handling
*/
const API = {
/**
* Base URL for API endpoints
*/
baseURL: '',
/**
* Make a GET request
* @param {string} endpoint - The API endpoint
* @param {object} params - Query parameters
* @returns {Promise<any>} Response data
*/
async get(endpoint, params = {}) {
const url = new URL(this.baseURL + endpoint, window.location.origin);
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
const response = await fetch(url, {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
return this.handleResponse(response);
},
/**
* Make a POST request
* @param {string} endpoint - The API endpoint
* @param {object} data - Request body data
* @returns {Promise<any>} Response data
*/
async post(endpoint, data = {}) {
const response = await fetch(this.baseURL + endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
return this.handleResponse(response);
},
/**
* Make a PUT request
* @param {string} endpoint - The API endpoint
* @param {object} data - Request body data
* @returns {Promise<any>} Response data
*/
async put(endpoint, data = {}) {
const response = await fetch(this.baseURL + endpoint, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
return this.handleResponse(response);
},
/**
* Make a DELETE request
* @param {string} endpoint - The API endpoint
* @returns {Promise<any>} Response data
*/
async delete(endpoint) {
const response = await fetch(this.baseURL + endpoint, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
return this.handleResponse(response);
},
/**
* Handle fetch response
* @param {Response} response - Fetch response object
* @returns {Promise<any>} Parsed response data
*/
async handleResponse(response) {
const contentType = response.headers.get('content-type');
// Try to parse as JSON first (even if content-type is missing)
try {
const text = await response.text();
// Try to parse as JSON
if (text) {
try {
const data = JSON.parse(text);
if (!response.ok) {
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
return data;
} catch (jsonError) {
// Not JSON, return as text
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return text;
}
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return text;
} catch (error) {
throw error;
}
}
};
/**
* Logout function
*/
async function logout() {
try {
await API.post('/api/logout');
window.location.href = '/login';
} catch (error) {
console.error('Logout error:', error);
// Force redirect even on error
window.location.href = '/login';
}
}

View File

@@ -1,188 +0,0 @@
/**
* Main Dashboard JavaScript
* Common utilities and functions for all pages
*/
/**
* Show an alert message
* @param {string} type - Alert type (success, danger, warning, info)
* @param {string} message - Alert message
* @param {number} duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
*/
function showAlert(type, message, duration = 5000) {
const alertsContainer = document.getElementById('alerts-container') || createAlertsContainer();
const alertId = 'alert-' + Date.now();
const alertHTML = `
<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
alertsContainer.insertAdjacentHTML('beforeend', alertHTML);
if (duration > 0) {
setTimeout(() => {
const alert = document.getElementById(alertId);
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, duration);
}
}
/**
* Create alerts container if it doesn't exist
*/
function createAlertsContainer() {
const main = document.querySelector('main');
const container = document.createElement('div');
container.id = 'alerts-container';
container.className = 'alerts-container';
main.insertBefore(container, main.firstChild);
return container;
}
/**
* Format timestamp to localized date/time
* @param {number} timestamp - Unix timestamp
* @returns {string} Formatted date/time
*/
function formatTimestamp(timestamp) {
if (!timestamp || timestamp === 0) return '-';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
/**
* Format GM level to human-readable name
* @param {number} level - GM level number
* @returns {string} GM level name
*/
function formatGMLevel(level) {
const levels = {
0: 'Civilian',
1: 'Forum Moderator',
2: 'Junior Moderator',
3: 'Moderator',
4: 'Senior Moderator',
5: 'Lead Moderator',
6: 'Junior Developer',
7: 'Inactive Developer',
8: 'Developer',
9: 'Operator'
};
return levels[level] || 'Unknown';
}
/**
* Confirm action with modal
* @param {string} title - Modal title
* @param {string} message - Modal message
* @param {function} callback - Callback function if confirmed
*/
function confirmAction(title, message, callback) {
if (confirm(message)) {
callback();
}
}
/**
* Copy text to clipboard
* @param {string} text - Text to copy
*/
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
showAlert('success', 'Copied to clipboard!', 2000);
} catch (err) {
showAlert('danger', 'Failed to copy to clipboard');
}
}
/**
* Debounce function calls
* @param {function} func - Function to debounce
* @param {number} wait - Wait time in ms
* @returns {function} Debounced function
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Initialize DataTables default settings
*/
$.extend(true, $.fn.dataTable.defaults, {
responsive: true,
lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]],
pageLength: 25,
language: {
search: "_INPUT_",
searchPlaceholder: "Search...",
lengthMenu: "Show _MENU_ entries",
info: "Showing _START_ to _END_ of _TOTAL_ entries",
infoEmpty: "No entries found",
infoFiltered: "(filtered from _MAX_ total entries)",
zeroRecords: "No matching records found",
emptyTable: "No data available in table"
}
});
/**
* Handle form submission with API
* @param {string} formId - Form element ID
* @param {string} endpoint - API endpoint
* @param {function} onSuccess - Success callback
*/
function handleFormSubmit(formId, endpoint, onSuccess) {
const form = document.getElementById(formId);
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData);
try {
const result = await API.post(endpoint, data);
if (result.success) {
showAlert('success', result.message || 'Operation successful');
if (onSuccess) onSuccess(result);
} else {
showAlert('danger', result.error || 'Operation failed');
}
} catch (error) {
showAlert('danger', error.message);
}
});
}
/**
* Initialize tooltips
*/
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function(tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Initialize Bootstrap popovers
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTriggerList.map(function(popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
});

View File

@@ -1,46 +0,0 @@
/**
* Login page functionality
*/
// Function to initialize login form
function initLoginForm() {
const form = document.getElementById('login-form');
if (!form) return; // Not on login page
form.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const messageDiv = document.getElementById('login-message');
try {
const response = await API.post('/api/login', { username, password });
if (response && response.success) {
messageDiv.className = 'alert alert-success';
messageDiv.textContent = 'Login successful! Redirecting...';
messageDiv.style.display = 'block';
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
messageDiv.className = 'alert alert-danger';
messageDiv.textContent = response.error || 'Login failed';
messageDiv.style.display = 'block';
}
} catch (error) {
messageDiv.className = 'alert alert-danger';
messageDiv.textContent = error.message || 'An error occurred during login';
messageDiv.style.display = 'block';
}
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initLoginForm);
} else {
initLoginForm();
}

View File

@@ -1,43 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('register-form');
const alertBox = document.getElementById('register-alert');
form.addEventListener('submit', async (e) => {
e.preventDefault();
alertBox.style.display = 'none';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const play_key = document.getElementById('play_key').value.trim();
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, play_key })
});
const data = await res.json();
if (!res.ok) {
alertBox.className = 'alert alert-danger';
alertBox.textContent = data.error || 'Registration failed';
alertBox.style.display = 'block';
return;
}
if (data.success) {
alertBox.className = 'alert alert-success';
alertBox.textContent = 'Account created successfully. You can now log in.';
alertBox.style.display = 'block';
form.reset();
} else {
alertBox.className = 'alert alert-danger';
alertBox.textContent = data.error || 'Registration failed';
alertBox.style.display = 'block';
}
} catch (err) {
alertBox.className = 'alert alert-danger';
alertBox.textContent = err.message || 'Registration failed';
alertBox.style.display = 'block';
}
});
});

View File

@@ -1,75 +0,0 @@
// Helper to wait for jQuery and DataTables (and optionally API) to be available
// Usage:
// safeInit(callback, { timeout: 5000, interval: 100, requireApi: false })
// The callback receives `window.jQuery` as its first argument.
(function(window) {
'use strict';
function waitFor(conditionFn, timeoutMs, intervalMs) {
return new Promise((resolve, reject) => {
const start = Date.now();
const iv = setInterval(() => {
try {
if (conditionFn()) {
clearInterval(iv);
resolve();
return;
}
} catch (e) {
// ignore
}
if (Date.now() - start > timeoutMs) {
clearInterval(iv);
reject(new Error('waitFor: timed out'));
}
}, intervalMs);
});
}
async function safeInit(cb, opts) {
opts = opts || {};
const timeout = typeof opts.timeout === 'number' ? opts.timeout : 5000;
const interval = typeof opts.interval === 'number' ? opts.interval : 100;
const requireApi = !!opts.requireApi;
// Wait for DOM ready first so scripts included at end of body have run
if (document.readyState === 'loading') {
await new Promise(r => document.addEventListener('DOMContentLoaded', r, { once: true }));
}
try {
await waitFor(() => window.jQuery && window.jQuery.fn && window.jQuery.fn.DataTable, timeout, interval);
if (requireApi) {
await waitFor(() => window.API, timeout, interval);
}
// call callback with jQuery
try { cb(window.jQuery); } catch (e) { console.error('safeInit callback error', e); }
} catch (err) {
console.error('safeInit: required libraries failed to load', err);
// If callback provided an onError handler, call it
if (opts.onError && typeof opts.onError === 'function') {
try { opts.onError(err); } catch (e) { console.error(e); }
} else {
// default fallback: show a banner if possible
const tableEls = document.querySelectorAll('table');
if (tableEls && tableEls.length) {
tableEls.forEach(el => {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = 'Required JavaScript libraries failed to load (jQuery/DataTables). Please check your network or CDN allowlist.';
el.replaceWith(wrapper);
});
} else {
console.warn('safeInit: libraries missing');
}
}
}
}
// Expose globally
window.safeInit = safeInit;
window.waitForLibraries = function(timeoutMs, intervalMs) {
return waitFor(() => window.jQuery && window.jQuery.fn && window.jQuery.fn.DataTable, timeoutMs || 5000, intervalMs || 100);
};
})(window);

View File

@@ -1,102 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-info-circle"></i>
About DarkflameServer Dashboard
</h1>
</div>
</div>
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Dashboard Information</h5>
</div>
<div class="card-body">
<h4 class="mb-3">DarkflameServer Web Dashboard</h4>
<p class="lead">
A modern C++ web interface for managing your Darkflame Universe server.
</p>
<hr class="my-4">
<h5>Features</h5>
<ul>
<li><strong>Account Management:</strong> Create, modify, ban, lock, and mute player accounts</li>
<li><strong>Character Management:</strong> View, rescue, and manage player characters</li>
<li><strong>Moderation Tools:</strong> Approve pet names, manage properties, and review bug reports</li>
<li><strong>Mail System:</strong> Send in-game mail to players with item attachments</li>
<li><strong>Play Keys:</strong> Manage registration keys for new accounts</li>
<li><strong>Activity Logs:</strong> Monitor player activity and track logins/logouts</li>
<li><strong>Audit Trail:</strong> Track all administrative actions for accountability</li>
</ul>
<hr class="my-4">
<h5>Technology Stack</h5>
<ul>
<li><strong>Backend:</strong> C++ with Crow web framework</li>
<li><strong>Frontend:</strong> Bootstrap 5, jQuery, DataTables</li>
<li><strong>Templates:</strong> Mustache templating engine</li>
<li><strong>Database:</strong> MySQL/MariaDB or SQLite</li>
</ul>
<hr class="my-4">
<h5>GM Levels</h5>
<dl class="row">
<dt class="col-sm-3">Level 0</dt>
<dd class="col-sm-9"><span class="badge bg-secondary">Civilian</span> - Regular player</dd>
<dt class="col-sm-3">Level 1</dt>
<dd class="col-sm-9"><span class="badge bg-info">Forum Moderator</span> - Forum moderation only</dd>
<dt class="col-sm-3">Level 2</dt>
<dd class="col-sm-9"><span class="badge bg-primary">Junior Moderator</span> - Basic moderation tools</dd>
<dt class="col-sm-3">Level 3</dt>
<dd class="col-sm-9"><span class="badge bg-success">Moderator</span> - Full moderation access</dd>
<dt class="col-sm-3">Level 4</dt>
<dd class="col-sm-9"><span class="badge bg-success">Senior Moderator</span> - Advanced moderation</dd>
<dt class="col-sm-3">Level 5</dt>
<dd class="col-sm-9"><span class="badge bg-warning">Lead Moderator</span> - Moderation leadership</dd>
<dt class="col-sm-3">Level 6</dt>
<dd class="col-sm-9"><span class="badge bg-warning">Junior Developer</span> - Development access</dd>
<dt class="col-sm-3">Level 7</dt>
<dd class="col-sm-9"><span class="badge bg-warning">Inactive Developer</span> - Limited dev access</dd>
<dt class="col-sm-3">Level 8</dt>
<dd class="col-sm-9"><span class="badge bg-danger">Developer</span> - Full development access</dd>
<dt class="col-sm-3">Level 9</dt>
<dd class="col-sm-9"><span class="badge bg-danger">Operator</span> - Full system access</dd>
</dl>
<hr class="my-4">
<h5>About Darkflame Universe</h5>
<p>
DarkflameServer is an open-source server emulator for LEGO Universe,
a massively multiplayer online game that was officially discontinued in 2012.
The Darkflame Universe project aims to preserve and revive this beloved game
for fans to continue enjoying.
</p>
<div class="d-grid gap-2 d-md-flex justify-content-md-start mt-4">
<a href="https://github.com/DarkflameUniverse/DarkflameServer" target="_blank" class="btn btn-primary">
<i class="bi bi-github"></i> GitHub Repository
</a>
<a href="https://github.com/DarkflameUniverse/DarkflameServer/tree/main/docs" target="_blank" class="btn btn-secondary">
<i class="bi bi-book"></i> Documentation
</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,162 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-people"></i>
Account Management
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">All Accounts</h5>
</div>
<div class="card-body">
<table id="accounts-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>GM Level</th>
<th>Banned</th>
<th>Locked</th>
<th>Muted Until</th>
<th>Play Key ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Wait for jQuery + DataTables to be available without copying libraries locally.
// Poll for a limited time and show a helpful error if they fail to load.
function showLibraryError(message) {
const el = document.getElementById('accounts-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
// Use the same pattern as Recent Activity: wait for DOMContentLoaded, check auth, then fetch data
function loadAccounts() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 3) {
showLibraryError('You do not have permission to view accounts. Please log in with sufficient GM level.');
return;
}
API.get('/api/accounts').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#accounts-table')) {
const table = $('#accounts-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
const table = $('#accounts-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'name', render: function(d, t, row) { return `<a href="/accounts/view/${row.id}">${d}</a>`; } },
{ data: 'gm_level', render: function(d) { const badges={0:'secondary',1:'info',2:'primary',3:'success',4:'success',5:'warning',6:'warning',7:'warning',8:'danger',9:'danger'}; return `<span class="badge bg-${badges[d]||'secondary'}">${d}</span>`; } },
{ data: 'banned', render: d => d ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>' },
{ data: 'locked', render: d => d ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>' },
{ data: 'mute_expire', render: function(d) { if (!d || d === 0) return '-'; return new Date(d * 1000).toLocaleString(); } },
{ data: 'play_key_id' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<div class="btn-group btn-group-sm" role="group">
<a href="/accounts/view/${row.id}" class="btn btn-info" title="View"><i class="bi bi-eye"></i></a>
<button data-account-id="${row.id}" class="btn btn-warning js-toggle-lock" title="Lock/Unlock"><i class="bi bi-lock"></i></button>
<button data-account-id="${row.id}" class="btn btn-danger js-toggle-ban" title="Ban/Unban"><i class="bi bi-slash-circle"></i></button>
<button data-account-id="${row.id}" class="btn btn-secondary js-mute-account" title="Mute"><i class="bi bi-mic-mute"></i></button>
</div>`;
} }
],
pageLength: 25,
order: [[0, 'asc']],
processing: true
});
// Delegated event handlers
$('#accounts-table').on('click', '.js-toggle-lock', function() { const id = $(this).data('account-id'); toggleLock(id, table); });
$('#accounts-table').on('click', '.js-toggle-ban', function() { const id = $(this).data('account-id'); toggleBan(id, table); });
$('#accounts-table').on('click', '.js-mute-account', function() { const id = $(this).data('account-id'); muteAccount(id, table); });
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load accounts';
showLibraryError(`Error loading accounts: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadAccounts();
}, { requireApi: true, timeout: 8000 });
async function toggleLock(accountId, table) {
if (!confirm('Are you sure you want to toggle the lock status for this account?')) return;
try {
const result = await API.post(`/api/accounts/${accountId}/lock`);
if (result.success) {
if (table && table.ajax) table.ajax.reload();
showAlert('success', 'Account lock status updated');
} else {
showAlert('danger', result.error || 'Failed to update account');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
async function toggleBan(accountId, table) {
if (!confirm('Are you sure you want to toggle the ban status for this account?')) return;
try {
const result = await API.post(`/api/accounts/${accountId}/ban`);
if (result.success) {
if (table && table.ajax) table.ajax.reload();
showAlert('success', 'Account ban status updated');
} else {
showAlert('danger', result.error || 'Failed to update account');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
async function muteAccount(accountId, table) {
const days = prompt('Enter number of days to mute (0 to unmute):');
if (days === null) return;
try {
const result = await API.post(`/api/accounts/${accountId}/mute`, { days: parseInt(days) });
if (result.success) {
if (table && table.ajax) table.ajax.reload();
showAlert('success', 'Account mute status updated');
} else {
showAlert('danger', result.error || 'Failed to update account');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
</script>

View File

@@ -1,214 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-person-circle"></i> Account Details</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">Account Info</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">ID</dt><dd class="col-sm-8" id="acct-id">-</dd>
<dt class="col-sm-4">Username</dt><dd class="col-sm-8" id="acct-name">-</dd>
<dt class="col-sm-4">Email</dt><dd class="col-sm-8" id="acct-email">-</dd>
<dt class="col-sm-4">GM Level</dt><dd class="col-sm-8" id="acct-gm">-</dd>
<dt class="col-sm-4">Banned</dt><dd class="col-sm-8" id="acct-banned">-</dd>
<dt class="col-sm-4">Locked</dt><dd class="col-sm-8" id="acct-locked">-</dd>
<dt class="col-sm-4">Mute Expire</dt><dd class="col-sm-8" id="acct-mute">-</dd>
<dt class="col-sm-4">Play Key ID</dt><dd class="col-sm-8" id="acct-playkey">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Administrative Actions</div>
<div class="card-body">
<button class="btn btn-danger mb-2" id="delete-account">Delete Account</button>
<hr>
<div class="mb-3">
<label class="form-label">Set GM Level</label>
<input type="number" id="gm-level-input" class="form-control" min="0" max="9">
<button class="btn btn-primary mt-2" id="set-gm">Update GM Level</button>
</div>
<div class="mb-3">
<label class="form-label">Update Email</label>
<input type="email" id="email-input" class="form-control">
<button class="btn btn-primary mt-2" id="set-email">Update Email</button>
</div>
<div class="mb-3">
<label class="form-label">Reset Password</label>
<input type="password" id="password-input" class="form-control">
<button class="btn btn-primary mt-2" id="reset-password">Reset Password</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Characters</div>
<div class="card-body">
<table id="characters-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Level</th>
<th>Map</th>
<th>Last Login</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Sessions</div>
<div class="card-body">
<table id="sessions-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>IP Address</th>
<th>Login Time</th>
<th>Logout Time</th>
<th>Active</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const accountId = (window.location.pathname.split('/').pop() || '').trim();
async function loadAccount() {
try {
const res = await API.get(`/api/accounts/${accountId}`);
if (res && res.success) {
$('#acct-id').text(res.id);
$('#acct-name').text(res.name);
$('#acct-email').text(res.email || '-');
$('#acct-gm').text(res.gm_level);
$('#acct-banned').html(res.banned ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#acct-locked').html(res.locked ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#acct-mute').text(res.mute_expire && res.mute_expire>0 ? new Date(res.mute_expire*1000).toLocaleString() : '-');
$('#acct-playkey').text(res.play_key_id || '-');
$('#gm-level-input').val(res.gm_level);
$('#email-input').val(res.email || '');
// Load related data
loadCharacters();
loadSessions();
} else {
alert(res.error || 'Failed to load account');
}
} catch (err) { alert(err.message); }
}
async function loadCharacters() {
try {
const res = await API.get(`/api/accounts/${accountId}/characters`);
const data = (res && Array.isArray(res.data)) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#characters-table')) {
const table = $('#characters-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#characters-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'name', render: function(d, t, row) { return `<a href="/characters/view/${row.id}">${d}</a>`; } },
{ data: 'level' },
{ data: 'map_id' },
{ data: 'last_login', render: d => d ? new Date(d * 1000).toLocaleString() : '-' }
],
order: [[0, 'desc']],
pageLength: 10
});
}
} catch (err) {
console.error('Failed to load characters', err);
}
}
async function loadSessions() {
try {
const res = await API.get(`/api/accounts/${accountId}/sessions`);
const data = (res && Array.isArray(res.data)) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#sessions-table')) {
const table = $('#sessions-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#sessions-table').DataTable({
data: data,
columns: [
{ data: 'session_id' },
{ data: 'ip_address' },
{ data: 'login_time', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'logout_time', render: d => d && d>0 ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'active', render: d => d ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>' }
],
order: [[2, 'desc']],
pageLength: 10
});
}
} catch (err) {
console.error('Failed to load sessions', err);
}
}
// Initialize when libraries are ready (API used, jQuery optional)
safeInit(function($) {
loadAccount();
document.getElementById('delete-account').addEventListener('click', async function() {
if (!confirm('Delete this account? This action is irreversible.')) return;
try {
const res = await API.post(`/api/accounts/${accountId}/delete`, {});
if (res && res.success) {
alert('Account deleted');
window.location.href = '/accounts';
} else {
alert(res.error || 'Failed to delete');
}
} catch (err) { alert(err.message); }
});
document.getElementById('set-gm').addEventListener('click', async function() {
const lvl = parseInt(document.getElementById('gm-level-input').value);
try {
const res = await API.post(`/api/accounts/${accountId}/gm-level`, { gm_level: lvl });
if (res && res.success) { alert('GM level updated'); loadAccount(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('set-email').addEventListener('click', async function() {
const email = document.getElementById('email-input').value.trim();
try {
const res = await API.post(`/api/accounts/${accountId}/email`, { email: email });
if (res && res.success) { alert('Email updated'); loadAccount(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('reset-password').addEventListener('click', async function() {
const pw = document.getElementById('password-input').value;
if (!pw || pw.length < 8) { alert('Password must be at least 8 characters'); return; }
try {
const res = await API.post(`/api/accounts/${accountId}/password-reset`, { password: pw });
if (res && res.success) { alert('Password reset'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
}, { requireApi: true, timeout: 8000 });
</script>

View File

@@ -1,151 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-bug"></i> Bug Reports</h1>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<div class="list-group" id="report-filter">
<button class="list-group-item list-group-item-action active" data-status="all">All</button>
<button class="list-group-item list-group-item-action" data-status="unresolved">Unresolved</button>
<button class="list-group-item list-group-item-action" data-status="resolved">Resolved</button>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">Reports</div>
<div class="card-body">
<table id="bugreports-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Character</th>
<th>Submitted</th>
<th>Resolved</th>
<th>Summary</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal for resolving -->
<div class="modal" tabindex="-1" id="resolveModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Resolve Bug Report</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Resolution Message</label>
<textarea id="resolution-text" class="form-control" rows="4"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="resolve-confirm">Resolve</button>
</div>
</div>
</div>
</div>
<script>
let currentStatus = 'all';
let currentResolveId = 0;
function loadTable() {
API.get('/api/bugreports', { status: currentStatus }).then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#bugreports-table')) {
const table = $('#bugreports-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#bugreports-table').DataTable({
data: data,
destroy: true,
columns: [
{ data: 'id' },
{ data: 'character_name' },
{ data: 'submitted', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'resolved_time', render: d => d && d>0 ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'body', render: d => d ? d.substring(0,120) : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
let actions = '';
if (!row.resolved_time || row.resolved_time == 0) {
actions += `<button class="btn btn-sm btn-success" onclick="openResolve(${row.id})">Resolve</button>`;
}
actions += ` <button class="btn btn-sm btn-info" onclick="viewReport(${row.id})">View</button>`;
return actions;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
}
}).catch(err => {
alert(err && err.message ? err.message : 'Failed to load bug reports');
});
}
// Initialize when libraries are ready
safeInit(function($) {
loadTable();
// Filter clicks
$('#report-filter button').on('click', function() {
$('#report-filter button').removeClass('active');
$(this).addClass('active');
currentStatus = $(this).data('status');
loadTable();
});
// Resolve confirm
$('#resolve-confirm').on('click', async function() {
const resolution = $('#resolution-text').val().trim();
if (!resolution) { alert('Resolution message required'); return; }
try {
const res = await API.post(`/api/bugreports/${currentResolveId}/resolve`, { resolution: resolution });
if (res && res.success) {
$('#resolveModal').modal('hide');
loadTable();
alert('Bug report resolved');
} else {
alert(res.error || 'Failed to resolve');
}
} catch (err) {
alert(err.message);
}
});
}, { requireApi: true, timeout: 8000 });
function openResolve(id) {
currentResolveId = id;
$('#resolution-text').val('');
var modal = new bootstrap.Modal(document.getElementById('resolveModal'));
modal.show();
}
async function viewReport(id) {
try {
const res = await API.get(`/api/bugreports/${id}`);
if (res && res.success) {
const text = `ID: ${res.id}\nCharacter: ${res.character_name}\nSubmitted: ${res.submitted?new Date(res.submitted*1000).toLocaleString():''}\n\n${res.body}`;
alert(text);
} else {
alert(res.error || 'Failed to get report');
}
} catch (err) {
alert(err.message);
}
}
</script>

View File

@@ -1,163 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-person-badge"></i>
Character Management
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">All Characters</h5>
</div>
<div class="card-body">
<table id="characters-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Account</th>
<th>Name</th>
<th>Pending Name</th>
<th>Needs Rename</th>
<th>Last Login</th>
<th>Permission Map</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Wait for jQuery + DataTables to be available
function showLibraryError(message) {
const el = document.getElementById('characters-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
function loadCharacters() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 3) {
showLibraryError('You do not have permission to view characters. Please log in with sufficient GM level.');
return;
}
API.get('/api/characters').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#characters-table')) {
const table = $('#characters-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
const table = $('#characters-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'account_name', render: function(d, t, row) {
return row.account_id ? `<a href="/accounts/view/${row.account_id}">${d || row.account_id}</a>` : (d || '-');
}},
{ data: 'name', render: function(d, t, row) {
return `<a href="/characters/view/${row.id}">${d}</a>`;
}},
{ data: 'pending_name', render: d => d || '-' },
{ data: 'needs_rename', render: d => d ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>' },
{ data: 'last_login', render: function(d) {
if (!d || d === 0) return 'Never';
return new Date(d * 1000).toLocaleString();
}},
{ data: 'permission_map', render: d => d || '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<div class="btn-group btn-group-sm" role="group">
<a href="/characters/view/${row.id}" class="btn btn-info" title="View"><i class="bi bi-eye"></i></a>
<button data-char-id="${row.id}" class="btn btn-warning js-rescue-char" title="Rescue Character"><i class="bi bi-life-preserver"></i></button>
<button data-char-id="${row.id}" class="btn btn-danger js-delete-char" title="Delete Character"><i class="bi bi-trash"></i></button>
</div>`;
}}
],
pageLength: 25,
order: [[0, 'desc']],
processing: true
});
// Delegated event handlers
$('#characters-table').on('click', '.js-rescue-char', function() {
const id = $(this).data('char-id');
rescueCharacter(id, table);
});
$('#characters-table').on('click', '.js-delete-char', function() {
const id = $(this).data('char-id');
deleteCharacter(id, table);
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load characters';
showLibraryError(`Error loading characters: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadCharacters();
}, { requireApi: true, timeout: 8000 });
async function rescueCharacter(charId, table) {
if (!confirm('Are you sure you want to rescue this character? This will move them to a safe location.')) return;
try {
const result = await API.post(`/api/characters/${charId}/rescue`);
if (result.success) {
showAlert('success', 'Character rescued successfully');
if (table && table.ajax) table.ajax.reload();
} else {
showAlert('danger', result.error || 'Failed to rescue character');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
async function deleteCharacter(charId, table) {
const confirmMsg = 'Are you sure you want to DELETE this character? This action is irreversible!';
if (!confirm(confirmMsg)) return;
const doubleConfirm = prompt('Type "DELETE" to confirm:');
if (doubleConfirm !== 'DELETE') {
showAlert('info', 'Deletion cancelled');
return;
}
try {
const result = await API.post(`/api/characters/${charId}/delete`);
if (result.success) {
showAlert('success', 'Character deleted');
if (table && table.ajax) table.ajax.reload();
} else {
showAlert('danger', result.error || 'Failed to delete character');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
</script>

View File

@@ -1,314 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-person-badge"></i> Character Details</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">Character Info</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-5">ID</dt><dd class="col-sm-7" id="char-id">-</dd>
<dt class="col-sm-5">Name</dt><dd class="col-sm-7" id="char-name">-</dd>
<dt class="col-sm-5">Pending Name</dt><dd class="col-sm-7" id="char-pending-name">-</dd>
<dt class="col-sm-5">Account</dt><dd class="col-sm-7" id="char-account">-</dd>
<dt class="col-sm-5">Level</dt><dd class="col-sm-7" id="char-level">-</dd>
<dt class="col-sm-5">Universe Score</dt><dd class="col-sm-7" id="char-uscore">-</dd>
<dt class="col-sm-5">Current Zone</dt><dd class="col-sm-7" id="char-zone">-</dd>
<dt class="col-sm-5">Last Login</dt><dd class="col-sm-7" id="char-last-login">-</dd>
<dt class="col-sm-5">Created</dt><dd class="col-sm-7" id="char-created">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Restrictions</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-5">Mail Restricted</dt><dd class="col-sm-7" id="char-mail-restricted">-</dd>
<dt class="col-sm-5">Trade Restricted</dt><dd class="col-sm-7" id="char-trade-restricted">-</dd>
<dt class="col-sm-5">Chat Restricted</dt><dd class="col-sm-7" id="char-chat-restricted">-</dd>
<dt class="col-sm-5">Needs Rename</dt><dd class="col-sm-7" id="char-needs-rename">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Administrative Actions</div>
<div class="card-body">
<button class="btn btn-warning mb-2 w-100" id="rescue-char">
<i class="bi bi-life-preserver"></i> Rescue Character
</button>
<button class="btn btn-primary mb-2 w-100" id="approve-name">
<i class="bi bi-check-circle"></i> Approve Pending Name
</button>
<button class="btn btn-danger mb-2 w-100" id="delete-char">
<i class="bi bi-trash"></i> Delete Character
</button>
<hr>
<div class="mb-3">
<label class="form-label">Toggle Mail Restriction</label>
<button class="btn btn-secondary w-100" id="toggle-mail">Toggle Mail</button>
</div>
<div class="mb-3">
<label class="form-label">Toggle Trade Restriction</label>
<button class="btn btn-secondary w-100" id="toggle-trade">Toggle Trade</button>
</div>
<div class="mb-3">
<label class="form-label">Toggle Chat Restriction</label>
<button class="btn btn-secondary w-100" id="toggle-chat">Toggle Chat</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="stats-tab" data-bs-toggle="tab" href="#stats" role="tab">Stats</a>
</li>
<li class="nav-item">
<a class="nav-link" id="inventory-tab" data-bs-toggle="tab" href="#inventory" role="tab">Inventory</a>
</li>
<li class="nav-item">
<a class="nav-link" id="activity-tab" data-bs-toggle="tab" href="#activity" role="tab">Activity</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane fade show active" id="stats" role="tabpanel">
<h6>Character Statistics</h6>
<div id="char-stats-content">
<dl class="row">
<dt class="col-sm-6">Total Currency Collected</dt><dd class="col-sm-6" id="stat-currency">-</dd>
<dt class="col-sm-6">Total Bricks Collected</dt><dd class="col-sm-6" id="stat-bricks">-</dd>
<dt class="col-sm-6">Total Smashables</dt><dd class="col-sm-6" id="stat-smashables">-</dd>
<dt class="col-sm-6">Total Quick Builds</dt><dd class="col-sm-6" id="stat-quickbuilds">-</dd>
<dt class="col-sm-6">Total Enemies Smashed</dt><dd class="col-sm-6" id="stat-enemies">-</dd>
<dt class="col-sm-6">Total Rockets Used</dt><dd class="col-sm-6" id="stat-rockets">-</dd>
<dt class="col-sm-6">Total Missions Completed</dt><dd class="col-sm-6" id="stat-missions">-</dd>
<dt class="col-sm-6">Total Pets Tamed</dt><dd class="col-sm-6" id="stat-pets">-</dd>
</dl>
</div>
</div>
<div class="tab-pane fade" id="inventory" role="tabpanel">
<h6>Inventory Items</h6>
<div id="char-inventory-content">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Item ID</th>
<th>Count</th>
<th>Slot</th>
</tr>
</thead>
<tbody id="inventory-tbody">
<tr><td colspan="3" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="activity" role="tabpanel">
<h6>Recent Activity</h6>
<div id="char-activity-content">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Timestamp</th>
<th>Activity</th>
<th>Map</th>
</tr>
</thead>
<tbody id="activity-tbody">
<tr><td colspan="3" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const characterId = (window.location.pathname.split('/').pop() || '').trim();
async function loadCharacter() {
try {
const res = await API.get(`/api/characters/${characterId}`);
if (res && res.success) {
$('#char-id').text(res.id);
$('#char-name').text(res.name);
$('#char-pending-name').text(res.pending_name || '-');
if (res.account_id) {
$('#char-account').html(`<a href="/accounts/view/${res.account_id}">${res.account_name || res.account_id}</a>`);
} else {
$('#char-account').text('-');
}
$('#char-level').text(res.level || 0);
$('#char-uscore').text(res.uscore || 0);
$('#char-zone').text(res.zone_id || '-');
$('#char-last-login').text(res.last_login && res.last_login > 0 ? new Date(res.last_login * 1000).toLocaleString() : 'Never');
$('#char-created').text(res.created_on ? new Date(res.created_on * 1000).toLocaleString() : '-');
// Restrictions
$('#char-mail-restricted').html(res.mail_restricted ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#char-trade-restricted').html(res.trade_restricted ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#char-chat-restricted').html(res.chat_restricted ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#char-needs-rename').html(res.needs_rename ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>');
// Load related data
loadCharacterStats();
loadCharacterActivity();
} else {
alert(res.error || 'Failed to load character');
}
} catch (err) {
alert(err.message || 'Error loading character');
}
}
async function loadCharacterStats() {
try {
const res = await API.get(`/api/characters/${characterId}/stats`);
if (res && res.success) {
$('#stat-currency').text(res.total_currency_collected || 0);
$('#stat-bricks').text(res.total_bricks_collected || 0);
$('#stat-smashables').text(res.total_smashables || 0);
$('#stat-quickbuilds').text(res.total_quickbuilds_completed || 0);
$('#stat-enemies').text(res.total_enemies_smashed || 0);
$('#stat-rockets').text(res.total_rockets_used || 0);
$('#stat-missions').text(res.total_missions_completed || 0);
$('#stat-pets').text(res.total_pets_tamed || 0);
}
} catch (err) {
console.error('Failed to load character stats', err);
}
}
async function loadCharacterActivity() {
try {
const res = await API.get(`/api/characters/${characterId}/activity`);
const data = (res && Array.isArray(res.data)) ? res.data : [];
const tbody = $('#activity-tbody');
tbody.empty();
if (data.length === 0) {
tbody.append('<tr><td colspan="3" class="text-center">No activity found</td></tr>');
} else {
data.forEach(activity => {
const row = $('<tr>');
row.append($('<td>').text(new Date(activity.timestamp * 1000).toLocaleString()));
row.append($('<td>').text(activity.activity));
row.append($('<td>').text(activity.map_id));
tbody.append(row);
});
}
} catch (err) {
console.error('Failed to load character activity', err);
$('#activity-tbody').html('<tr><td colspan="3" class="text-center text-danger">Failed to load activity</td></tr>');
}
}
// Load inventory when the tab is clicked
$('#inventory-tab').on('shown.bs.tab', async function() {
try {
const res = await API.get(`/api/characters/${characterId}/inventory`);
const data = (res && Array.isArray(res.data)) ? res.data : [];
const tbody = $('#inventory-tbody');
tbody.empty();
if (data.length === 0) {
tbody.append('<tr><td colspan="3" class="text-center">No items found</td></tr>');
} else {
data.forEach(item => {
const row = $('<tr>');
row.append($('<td>').text(item.item_id));
row.append($('<td>').text(item.count || 1));
row.append($('<td>').text(item.slot || '-'));
tbody.append(row);
});
}
} catch (err) {
console.error('Failed to load inventory', err);
$('#inventory-tbody').html('<tr><td colspan="3" class="text-center text-danger">Failed to load inventory</td></tr>');
}
});
// Initialize when libraries are ready
safeInit(function($) {
loadCharacter();
document.getElementById('rescue-char').addEventListener('click', async function() {
if (!confirm('Rescue this character to a safe location?')) return;
try {
const res = await API.post(`/api/characters/${characterId}/rescue`, {});
if (res && res.success) {
alert('Character rescued');
loadCharacter();
} else {
alert(res.error || 'Failed to rescue character');
}
} catch (err) { alert(err.message); }
});
document.getElementById('approve-name').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/approve-name`, {});
if (res && res.success) {
alert('Name approved');
loadCharacter();
} else {
alert(res.error || 'Failed to approve name');
}
} catch (err) { alert(err.message); }
});
document.getElementById('delete-char').addEventListener('click', async function() {
const confirmMsg = 'DELETE this character? This action is irreversible!';
if (!confirm(confirmMsg)) return;
const doubleConfirm = prompt('Type "DELETE" to confirm:');
if (doubleConfirm !== 'DELETE') return;
try {
const res = await API.post(`/api/characters/${characterId}/delete`, {});
if (res && res.success) {
alert('Character deleted');
window.location.href = '/characters';
} else {
alert(res.error || 'Failed to delete');
}
} catch (err) { alert(err.message); }
});
document.getElementById('toggle-mail').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/toggle-mail`, {});
if (res && res.success) { alert('Mail restriction toggled'); loadCharacter(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('toggle-trade').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/toggle-trade`, {});
if (res && res.success) { alert('Trade restriction toggled'); loadCharacter(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('toggle-chat').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/toggle-chat`, {});
if (res && res.success) { alert('Chat restriction toggled'); loadCharacter(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
}, { requireApi: true, timeout: 8000 });
</script>

View File

@@ -1,293 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">Dashboard</h1>
</div>
</div>
{{#is_authenticated}}
<div class="row">
<!-- Account Info Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-person-circle text-primary"></i>
Your Account
</h5>
<p class="card-text">
<strong>Username:</strong> {{username}}<br>
<strong>Account ID:</strong> {{account_id}}<br>
<strong>GM Level:</strong> {{gm_level}} ({{gm_level_name}})
</p>
</div>
</div>
</div>
{{#is_gm_3_plus}}
<!-- Server Stats Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-server text-success"></i>
Server Status
</h5>
<div id="server-stats">
<p class="card-text">
<strong>Master:</strong> <span id="master-status" class="badge bg-secondary">Loading...</span><br>
<strong>Connected Clients:</strong> <span id="client-count">-</span><br>
<strong>Packets Sent:</strong> <span id="packets-sent">-</span><br>
<strong>Packets Received:</strong> <span id="packets-received">-</span>
</p>
</div>
</div>
</div>
</div>
<!-- Accounts Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-people text-info"></i>
Accounts
</h5>
<p class="card-text">
<strong>Total Accounts:</strong> <span id="total-accounts">-</span><br>
<strong>Banned:</strong> <span id="banned-accounts">-</span><br>
<strong>Locked:</strong> <span id="locked-accounts">-</span>
</p>
<a href="/accounts" class="btn btn-sm btn-primary">Manage Accounts</a>
</div>
</div>
</div>
<!-- Characters Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-person-badge text-warning"></i>
Characters
</h5>
<p class="card-text">
<strong>Total Characters:</strong> <span id="total-characters">-</span><br>
<strong>Pending Names:</strong> <span id="pending-names">-</span>
</p>
<a href="/characters" class="btn btn-sm btn-primary">Manage Characters</a>
</div>
</div>
</div>
{{/is_gm_3_plus}}
</div>
{{#is_gm_3_plus}}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-activity"></i>
Recent Activity
</h5>
</div>
<div class="card-body">
<table id="recent-activity-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Time</th>
<th>Character</th>
<th>Activity</th>
<th>Map</th>
</tr>
</thead>
<tbody>
<!-- Populated via API -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{{/is_gm_3_plus}}
<!-- Character Cards for All Authenticated Users -->
<div class="row mt-4">
<div class="col-12">
<h3 class="mb-3">
<i class="bi bi-person-badge"></i>
Your Characters
</h3>
<hr>
</div>
</div>
<div class="row" id="character-cards-container">
<!-- Character cards will be populated via JavaScript -->
<div class="col-12 text-center">
<p class="text-muted">Loading characters...</p>
</div>
</div>
{{/is_authenticated}}
{{^is_authenticated}}
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-body text-center">
<h3>Welcome to DarkflameServer Dashboard</h3>
<p class="lead">Please log in to access the dashboard.</p>
<a href="/login" class="btn btn-primary btn-lg">Login</a>
</div>
</div>
</div>
</div>
{{/is_authenticated}}
<script>
{{#is_gm_3_plus}}
// Load dashboard stats
async function loadDashboardStats() {
try {
// Server stats
const serverStats = await API.get('/api/stats/server');
if (serverStats) {
updateServerStats(serverStats);
}
// Account stats
const accountStats = await API.get('/api/stats/accounts');
if (accountStats) {
updateAccountStats(accountStats);
}
// Character stats
const characterStats = await API.get('/api/stats/characters');
if (characterStats) {
updateCharacterStats(characterStats);
}
// Recent activity
const activities = await API.get('/api/stats/recent-activity');
if (activities && activities.data) {
updateRecentActivity(activities.data);
}
} catch (error) {
console.error('Error loading dashboard stats:', error);
}
}
// Update server stats on UI
function updateServerStats(data) {
document.getElementById('master-status').textContent = data.master_connected ? 'Connected' : 'Disconnected';
document.getElementById('master-status').className = data.master_connected ? 'badge bg-success' : 'badge bg-danger';
document.getElementById('client-count').textContent = data.connected_clients || 0;
document.getElementById('packets-sent').textContent = data.packets_sent || 0;
document.getElementById('packets-received').textContent = data.packets_received || 0;
}
// Update account stats on UI
function updateAccountStats(data) {
document.getElementById('total-accounts').textContent = data.total || 0;
document.getElementById('banned-accounts').textContent = data.banned || 0;
document.getElementById('locked-accounts').textContent = data.locked || 0;
}
// Update character stats on UI
function updateCharacterStats(data) {
document.getElementById('total-characters').textContent = data.total || 0;
document.getElementById('pending-names').textContent = data.pending_names || 0;
}
// Update recent activity table
function updateRecentActivity(data) {
// Avoid reinitialising the DataTable on repeated calls (e.g. interval refreshs).
// If the table already exists, update its data and redraw. Otherwise initialize it.
if ($.fn.DataTable.isDataTable('#recent-activity-table')) {
const table = $('#recent-activity-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
const table = $('#recent-activity-table').DataTable({
data: data,
columns: [
{ data: 'timestamp' },
{ data: 'character_name' },
{ data: 'activity' },
{ data: 'map_id' }
],
pageLength: 10,
order: [[0, 'desc']]
});
}
}
// Initial load
document.addEventListener('DOMContentLoaded', loadDashboardStats);
// Auto-refresh stats every 30 seconds
setInterval(loadDashboardStats, 30000);
{{/is_gm_3_plus}}
{{#is_authenticated}}
// Load user's characters for character cards
async function loadUserCharacters() {
try {
const res = await API.get('/api/user/characters');
const characters = (res && Array.isArray(res.data)) ? res.data : (res || []);
const container = document.getElementById('character-cards-container');
container.innerHTML = '';
if (characters.length === 0) {
container.innerHTML = `
<div class="col-12 text-center">
<p class="text-muted">You don't have any characters yet. Log in to the game to create one!</p>
</div>
`;
return;
}
characters.forEach(char => {
const card = document.createElement('div');
card.className = 'col-md-6 col-lg-4 mb-4';
card.innerHTML = `
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-person-circle"></i>
${char.name}
</h5>
<p class="card-text">
<strong>Level:</strong> ${char.level || 0}<br>
<strong>Universe Score:</strong> ${char.uscore || 0}<br>
<strong>Current Zone:</strong> ${char.zone_id || 'Unknown'}<br>
<strong>Last Login:</strong> ${char.last_login && char.last_login > 0 ? new Date(char.last_login * 1000).toLocaleString() : 'Never'}
</p>
${char.pending_name ? `<span class="badge bg-warning mb-2">Pending Name: ${char.pending_name}</span><br>` : ''}
${char.needs_rename ? '<span class="badge bg-danger mb-2">Needs Rename</span><br>' : ''}
<a href="/characters/view/${char.id}" class="btn btn-sm btn-primary mt-2">View Details</a>
</div>
</div>
`;
container.appendChild(card);
});
} catch (error) {
console.error('Error loading user characters:', error);
const container = document.getElementById('character-cards-container');
container.innerHTML = `
<div class="col-12">
<div class="alert alert-warning">
Failed to load your characters. Please try refreshing the page.
</div>
</div>
`;
}
}
// Load character cards on page load
document.addEventListener('DOMContentLoaded', loadUserCharacters);
{{/is_authenticated}}
</script>

View File

@@ -1,157 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{page_title}} - DarkflameServer Dashboard</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<!-- Custom CSS consolidated -->
<link rel="stylesheet" href="/static/css/dashboard.css">
{{#extra_head}}
{{{extra_head}}}
{{/extra_head}}
</head>
<body>
{{#show_navbar}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-grid-3x3-gap-fill"></i>
DarkflameServer Dashboard
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link{{#nav_home}} active{{/nav_home}}" href="/">
<i class="bi bi-house-door"></i> Home
</a>
</li>
{{#is_gm_3_plus}}
<li class="nav-item">
<a class="nav-link{{#nav_accounts}} active{{/nav_accounts}}" href="/accounts">
<i class="bi bi-people"></i> Accounts
</a>
</li>
<li class="nav-item">
<a class="nav-link{{#nav_characters}} active{{/nav_characters}}" href="/characters">
<i class="bi bi-person-badge"></i> Characters
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-check"></i> Moderation
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/moderation/pending">Pending Pets</a></li>
<li><a class="dropdown-item" href="/properties">Properties</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link{{#nav_mail}} active{{/nav_mail}}" href="/mail/send">
<i class="bi bi-envelope"></i> Mail
</a>
</li>
{{/is_gm_3_plus}}
{{#is_gm_5_plus}}
<li class="nav-item">
<a class="nav-link{{#nav_playkeys}} active{{/nav_playkeys}}" href="/playkeys">
<i class="bi bi-key"></i> Play Keys
</a>
</li>
{{/is_gm_5_plus}}
{{#is_gm_8_plus}}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-journal-text"></i> Logs
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/logs/activities">Activity Logs</a></li>
<li><a class="dropdown-item" href="/logs/commands">Command Logs</a></li>
<li><a class="dropdown-item" href="/logs/audits">Audit Logs</a></li>
</ul>
</li>
{{/is_gm_8_plus}}
<li class="nav-item">
<a class="nav-link{{#nav_bugs}} active{{/nav_bugs}}" href="/bugs">
<i class="bi bi-bug"></i> Bug Reports
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{username}}
{{#gm_level_name}}
<span class="badge bg-primary">{{gm_level_name}}</span>
{{/gm_level_name}}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/about">About</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="logout(); return false;">
<i class="bi bi-box-arrow-right"></i> Logout
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{{/show_navbar}}
<main class="{{#show_navbar}}container-fluid mt-4{{/show_navbar}}">
{{#flash_messages}}
<div class="alert alert-{{type}} alert-dismissible fade show" role="alert">
{{message}}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{{/flash_messages}}
{{{content}}}
</main>
<footer class="footer mt-auto py-3 bg-dark border-top">
<div class="container text-center">
<span class="text-muted">DarkflameServer Dashboard &copy; 2025 | Powered by Crow C++</span>
</div>
</footer>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<!-- DataTables JS -->
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<!-- Shared helper: wait for jQuery/DataTables (keeps pages resilient to CDN timing) -->
<script src="/static/js/wait-for-jq-dt.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Custom JS -->
<script src="/static/js/api.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/login.js"></script>
{{#extra_scripts}}
{{{extra_scripts}}}
{{/extra_scripts}}
</body>
</html>

View File

@@ -1,31 +0,0 @@
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow-lg mt-5">
<div class="card-header bg-primary text-white text-center">
<h4 class="mb-0">
<i class="bi bi-shield-lock"></i>
DarkflameServer Dashboard
</h4>
</div>
<div class="card-body">
<form id="login-form">
<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>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right"></i>
Login
</button>
</div>
</form>
<div id="login-message" class="mt-3" style="display: none;"></div>
</div>
</div>
</div>
</div>

View File

@@ -1,73 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-activity"></i>
Activity Logs
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Player Activity</h5>
</div>
<div class="card-body">
<table id="activity-log-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Time</th>
<th>Character</th>
<th>Activity</th>
<th>Map ID</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Initialize when libraries are ready
safeInit(function($) {
$('#activity-log-table').DataTable({
processing: true,
serverSide: true,
ajax: {
url: '/api/activity-log',
type: 'GET'
},
columns: [
{
data: 'timestamp',
render: function(data, type, row) {
if (type === 'display' || type === 'filter') {
const date = new Date(data * 1000);
return date.toLocaleString();
}
return data;
}
},
{
data: 'character_name',
render: function(data, type, row) {
return `<a href="/characters/view/${row.character_id}">${data}</a>`;
}
},
{
data: 'activity_name'
},
{
data: 'map_id'
}
],
order: [[0, 'desc']],
pageLength: 25
});
}, { requireApi: false, timeout: 8000 });
</script>

View File

@@ -1,139 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-journal-check"></i>
Audit Logs
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Dashboard Audit Trail</h5>
</div>
<div class="card-body">
<table id="audits-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function showLibraryError(message) {
const el = document.getElementById('audits-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
function loadAuditLogs() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 8) {
showLibraryError('You do not have permission to view audit logs. GM Level 8+ required.');
return;
}
API.get('/api/logs/audits').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#audits-table')) {
const table = $('#audits-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#audits-table').DataTable({
data: data,
columns: [
{
data: 'timestamp',
render: function(d) {
if (!d || d === 0) return '-';
return new Date(d * 1000).toLocaleString();
}
},
{
data: 'admin_username',
render: function(d, t, row) {
if (row.admin_account_id) {
return `<a href="/accounts/view/${row.admin_account_id}">${d || row.admin_account_id}</a>`;
}
return d || '-';
}
},
{
data: 'action',
render: function(d) {
// Color-code actions
const badges = {
'ban': 'danger',
'unban': 'success',
'lock': 'warning',
'unlock': 'success',
'mute': 'warning',
'unmute': 'success',
'delete': 'danger',
'create': 'success',
'update': 'info',
'gm_level_change': 'primary'
};
const action = d.toLowerCase();
const badgeClass = badges[action] || 'secondary';
return `<span class="badge bg-${badgeClass}">${d}</span>`;
}
},
{
data: 'target_type',
render: function(d, t, row) {
if (!d) return '-';
if (d === 'account' && row.target_id) {
return `<a href="/accounts/view/${row.target_id}">Account ${row.target_id}</a>`;
} else if (d === 'character' && row.target_id) {
return `<a href="/characters/view/${row.target_id}">Character ${row.target_id}</a>`;
}
return `${d} ${row.target_id || ''}`;
}
},
{ data: 'details', render: d => d || '-' }
],
pageLength: 25,
order: [[0, 'desc']],
processing: true
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load audit logs';
showLibraryError(`Error loading audit logs: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadAuditLogs();
}, { requireApi: true, timeout: 8000 });
</script>

View File

@@ -1,106 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-terminal"></i>
Command Logs
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Recent Commands</h5>
</div>
<div class="card-body">
<table id="commands-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Character</th>
<th>Command</th>
<th>Arguments</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function showLibraryError(message) {
const el = document.getElementById('commands-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
function loadCommandLogs() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 8) {
showLibraryError('You do not have permission to view command logs. GM Level 8+ required.');
return;
}
API.get('/api/logs/commands').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#commands-table')) {
const table = $('#commands-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#commands-table').DataTable({
data: data,
columns: [
{
data: 'timestamp',
render: function(d) {
if (!d || d === 0) return '-';
return new Date(d * 1000).toLocaleString();
}
},
{
data: 'character_name',
render: function(d, t, row) {
if (row.character_id) {
return `<a href="/characters/view/${row.character_id}">${d || row.character_id}</a>`;
}
return d || '-';
}
},
{ data: 'command' },
{ data: 'arguments', render: d => d || '-' }
],
pageLength: 25,
order: [[0, 'desc']],
processing: true
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load command logs';
showLibraryError(`Error loading command logs: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadCommandLogs();
}, { requireApi: true, timeout: 8000 });
</script>

View File

@@ -1,80 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-envelope"></i> Send Mail</h1>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">Compose Mail</div>
<div class="card-body">
<form id="send-mail-form">
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="send-to-all">
<label class="form-check-label" for="send-to-all">Send to all characters</label>
</div>
<div class="mb-3">
<label class="form-label">Recipient Character ID (leave blank if sending to all)</label>
<input type="number" id="recipient-id" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" id="subject" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Message</label>
<textarea id="body" class="form-control" rows="6" required></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Attachment LOT (optional)</label>
<input type="number" id="attachment-lot" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Attachment Count</label>
<input type="number" id="attachment-count" class="form-control" value="1" min="1">
</div>
</div>
<button type="submit" class="btn btn-primary">Send Mail</button>
</form>
<div id="mail-result" class="mt-3"></div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('send-mail-form');
form.addEventListener('submit', async function(e) {
e.preventDefault();
const sendToAll = document.getElementById('send-to-all').checked;
const recipientId = parseInt(document.getElementById('recipient-id').value) || 0;
const subject = document.getElementById('subject').value.trim();
const body = document.getElementById('body').value.trim();
const lot = parseInt(document.getElementById('attachment-lot').value) || 0;
const count = parseInt(document.getElementById('attachment-count').value) || 1;
const payload = { subject: subject, body: body };
if (sendToAll) payload.send_to_all = true;
else payload.recipient_id = recipientId;
if (lot > 0) {
payload.attachment_lot = lot;
payload.attachment_count = count;
}
try {
const res = await API.post('/api/mail/send', payload);
if (res && res.success) {
document.getElementById('mail-result').innerHTML = `<div class="alert alert-success">Sent to ${res.recipients} recipient(s)</div>`;
form.reset();
} else {
document.getElementById('mail-result').innerHTML = `<div class="alert alert-danger">${res.error || 'Failed to send mail'}</div>`;
}
} catch (err) {
document.getElementById('mail-result').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
});
});
</script>

View File

@@ -1,85 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-paw"></i> Pet Name Moderation</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">Pending Pet Names</div>
<div class="card-body">
<table id="pets-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Character</th>
<th>Pet Name</th>
<th>Submitted</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let petsTable = null;
function loadPets() {
API.get('/api/moderation/pets').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#pets-table')) {
const table = $('#pets-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
petsTable = table;
} else {
petsTable = $('#pets-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'character_name' },
{ data: 'pet_name' },
{ data: 'submitted', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<button class="btn btn-sm btn-success" onclick="approvePet(${row.id})">Approve</button>
<button class="btn btn-sm btn-danger" onclick="rejectPet(${row.id})">Reject</button>
`;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
}
}).catch(err => { alert(err && err.message ? err.message : 'Failed to load pets'); });
}
// Initialize when libraries are ready
safeInit(function($) {
loadPets();
}, { requireApi: true, timeout: 8000 });
window.approvePet = async function(id) {
if (!confirm('Approve this pet name?')) return;
try {
const res = await API.post(`/api/moderation/pets/${id}/approve`);
if (res && res.success) { loadPets(); alert('Approved'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
window.rejectPet = async function(id) {
if (!confirm('Reject this pet name?')) return;
try {
const res = await API.post(`/api/moderation/pets/${id}/reject`);
if (res && res.success) { loadPets(); alert('Rejected'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
</script>

View File

@@ -1,82 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-house"></i> Property Moderation</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">Pending Properties</div>
<div class="card-body">
<table id="properties-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Owner (Character)</th>
<th>Property Name</th>
<th>Submitted</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let propertiesTable = null;
function loadProperties() {
API.get('/api/moderation/properties').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#properties-table')) {
const table = $('#properties-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
propertiesTable = table;
} else {
propertiesTable = $('#properties-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'character_name' },
{ data: 'property_name' },
{ data: 'submitted', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<button class="btn btn-sm btn-success" onclick="approveProperty(${row.id})">Approve</button>
<button class="btn btn-sm btn-danger" onclick="rejectProperty(${row.id})">Reject</button>
`;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
}
}).catch(err => { alert(err && err.message ? err.message : 'Failed to load properties'); });
}
// Initialize when libraries are ready
safeInit(function($) { loadProperties(); }, { requireApi: true, timeout: 8000 });
window.approveProperty = async function(id) {
if (!confirm('Approve this property?')) return;
try {
const res = await API.post(`/api/moderation/properties/${id}/approve`);
if (res && res.success) { loadProperties(); alert('Approved'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
window.rejectProperty = async function(id) {
if (!confirm('Reject this property?')) return;
try {
const res = await API.post(`/api/moderation/properties/${id}/reject`);
if (res && res.success) { loadProperties(); alert('Rejected'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
</script>

View File

@@ -1,155 +0,0 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-key"></i> Play Keys</h1>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<div class="card-header">Create Play Keys</div>
<div class="card-body">
<form id="create-keys-form" class="row g-2">
<div class="col-auto">
<label class="form-label">Count</label>
<input type="number" id="key-count" class="form-control" value="1" min="1" max="100">
</div>
<div class="col-auto">
<label class="form-label">Uses</label>
<input type="number" id="key-uses" class="form-control" value="1" min="1">
</div>
<div class="col-6">
<label class="form-label">Notes</label>
<input type="text" id="key-notes" class="form-control" placeholder="Optional notes">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
<div id="created-keys" class="mt-3"></div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">Existing Play Keys</div>
<div class="card-body">
<table id="playkeys-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Key</th>
<th>Uses</th>
<th>Times Used</th>
<th>Active</th>
<th>Notes</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let playkeysTable = null;
function loadPlaykeys() {
API.get('/api/playkeys').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#playkeys-table')) {
const table = $('#playkeys-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
playkeysTable = table;
} else {
playkeysTable = $('#playkeys-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'key_string' },
{ data: 'key_uses' },
{ data: 'times_used' },
{ data: 'active', render: d => d ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>' },
{ data: 'notes' },
{ data: 'created_at', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<button class="btn btn-sm btn-danger" onclick="deleteKey(${row.id})">Delete</button>
<button class="btn btn-sm btn-info" onclick="viewKey(${row.id})">View</button>
`;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
// Create keys form handler
$('#create-keys-form').on('submit', async function(e) {
e.preventDefault();
const count = parseInt($('#key-count').val()) || 1;
const uses = parseInt($('#key-uses').val()) || 1;
const notes = $('#key-notes').val() || '';
try {
const res = await API.post('/api/playkeys/create', { count: count, uses: uses, notes: notes });
if (res && res.success) {
$('#created-keys').html(`<div class="alert alert-success">Created ${res.count} key(s): <pre>${JSON.stringify(res.keys)}</pre></div>`);
loadPlaykeys();
} else {
$('#created-keys').html(`<div class="alert alert-danger">${res.error || 'Failed to create keys'}</div>`);
}
} catch (err) {
$('#created-keys').html(`<div class="alert alert-danger">${err.message}</div>`);
}
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load play keys';
document.getElementById('created-keys').innerHTML = `<div class="alert alert-danger">${msg}</div>`;
});
}
// Use safeInit to ensure jQuery/DataTables and API are present
safeInit(function($) {
loadPlaykeys();
}, { requireApi: true, timeout: 8000 });
async function deleteKey(id) {
if (!confirm('Delete this play key?')) return;
try {
const res = await API.delete(`/api/playkeys/${id}`);
if (res && res.success) {
loadPlaykeys();
alert('Play key deleted');
} else {
alert(res.error || 'Failed to delete key');
}
} catch (err) {
alert(err.message);
}
}
async function viewKey(id) {
try {
const res = await API.get(`/api/playkeys/${id}`);
if (res && res.success) {
const info = `ID: ${res.id}\nKey: ${res.key_string}\nUses: ${res.key_uses}\nTimes used: ${res.times_used}\nActive: ${res.active}\nNotes: ${res.notes}`;
alert(info);
} else {
alert(res.error || 'Failed to get key');
}
} catch (err) {
alert(err.message);
}
}
</script>

View File

@@ -1,31 +0,0 @@
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">Register</div>
<div class="card-body">
<form id="register-form">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" required>
</div>
<div class="mb-3">
<label for="play_key" class="form-label">Play Key</label>
<input type="text" class="form-control" id="play_key" placeholder="XXXX-XXXX-XXXX-XXXX" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Create Account</button>
</div>
</form>
<div id="register-alert" class="mt-3" style="display:none;"></div>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/register.js"></script>

View File

@@ -1,6 +1,5 @@
#include "CDActivitiesTable.h"
void CDActivitiesTable::LoadValuesFromDatabase() {
// First, get the size of the table
uint32_t size = 0;
@@ -56,3 +55,13 @@ std::vector<CDActivities> CDActivitiesTable::Query(std::function<bool(CDActiviti
return data;
}
std::optional<const CDActivities> CDActivitiesTable::GetActivity(const uint32_t activityID) {
auto& entries = GetEntries();
for (const auto& entry : entries) {
if (entry.ActivityID == activityID) {
return entry;
}
}
return std::nullopt;
}

View File

@@ -2,6 +2,7 @@
// Custom Classes
#include "CDTable.h"
#include <optional>
struct CDActivities {
uint32_t ActivityID;
@@ -31,4 +32,5 @@ public:
// Queries the table with a custom "where" clause
std::vector<CDActivities> Query(std::function<bool(CDActivities)> predicate);
std::optional<const CDActivities> GetActivity(const uint32_t activityID);
};

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({
std::stoull(amountSplit[0]),
std::stoi(amountSplit[1])
GeneralUtils::TryParse<LOT>(amountSplit[0], LOT_NULL),
GeneralUtils::TryParse<uint32_t>(amountSplit[1], 0)
});
}
}

View File

@@ -93,13 +93,14 @@ 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) {
return const_cast<CDMissions*>(&entry);
toReturn = &entry;
}
}
return &Default;
return toReturn;
}
const CDMissions& CDMissionsTable::GetByMissionID(uint32_t missionID, bool& found) const {

View File

@@ -66,6 +66,7 @@ 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(ntry.description = tableData.getStringField(4, ""));
UNUSED(entry.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 { std::stoi(split.at(0)), GeneralUtils::ASCIIToUTF16(split.at(1)) };
return { GeneralUtils::TryParse(split.at(0), 0), GeneralUtils::ASCIIToUTF16(split.at(1)) };
}
return {};

View File

@@ -25,8 +25,6 @@
#include "IAccountsRewardCodes.h"
#include "IBehaviors.h"
#include "IUgcModularBuild.h"
#include "IDashboardAuditLog.h"
#include "IDashboardConfig.h"
#ifdef _DEBUG
# define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0)
@@ -40,8 +38,7 @@ class GameDatabase :
public IPropertyContents, public IProperty, public IPetNames, public ICharXml,
public IMigrationHistory, public IUgc, public IFriends, public ICharInfo,
public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList,
public IBehaviors, public IUgcModularBuild,
public IDashboardAuditLog, public IDashboardConfig {
public IBehaviors, public IUgcModularBuild {
public:
virtual ~GameDatabase() = default;
// TODO: These should be made private.

View File

@@ -5,7 +5,6 @@
#include <optional>
#include <string>
#include <string_view>
#include <vector>
enum class eGameMasterLevel : uint8_t;
@@ -39,62 +38,7 @@ public:
// Update the GameMaster level of an account.
virtual void UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) = 0;
// Set the play_key_id for an account (used during registration)
virtual void UpdateAccountPlayKey(const uint32_t accountId, const uint32_t playKeyId) = 0;
// Get counts for dashboard/stats
virtual uint32_t GetBannedAccountCount() = 0;
virtual uint32_t GetLockedAccountCount() = 0;
virtual uint32_t GetAccountCount() = 0;
struct ListInfo {
uint32_t id{};
std::string name;
eGameMasterLevel gm_level{};
bool banned{};
bool locked{};
uint64_t mute_expire{};
uint32_t play_key_id{};
};
struct DetailedInfo {
uint32_t id{};
std::string name;
std::string email;
eGameMasterLevel gm_level{};
bool banned{};
bool locked{};
uint64_t mute_expire{};
uint32_t play_key_id{};
uint64_t created_at{};
};
struct SessionInfo {
uint64_t sessionId{};
std::string ipAddress;
uint64_t loginTime{};
uint64_t logoutTime{};
bool active{};
};
// Return all accounts for dashboard listing
virtual std::vector<ListInfo> GetAllAccounts() = 0;
// Update an account's locked status
virtual void UpdateAccountLock(const uint32_t accountId, const bool locked) = 0;
// Get detailed account info by ID (for dashboard viewing)
virtual std::optional<DetailedInfo> GetAccountById(const uint32_t accountId) = 0;
// Update account email (for dashboard)
virtual void UpdateAccountEmail(const uint32_t accountId, const std::string_view email) = 0;
// Delete account and all associated data
virtual void DeleteAccount(const uint32_t accountId) = 0;
// Get account session history
virtual std::vector<SessionInfo> GetAccountSessions(const uint32_t accountId, uint32_t limit = 50) = 0;
};
#endif //!__IACCOUNTS__H__

View File

@@ -15,27 +15,6 @@ class IActivityLog {
public:
// Update the activity log for the given account.
virtual void UpdateActivityLog(const LWOOBJID characterId, const eActivityType activityType, const LWOMAPID mapId) = 0;
struct Entry {
LWOOBJID characterId{};
eActivityType activity{};
uint32_t timestamp{};
LWOMAPID mapId{};
};
// Retrieve recent activity entries ordered by time desc.
virtual std::vector<Entry> GetRecentActivity(const uint32_t limit) = 0;
// Get total count of activity log entries
virtual uint32_t GetActivityLogCount() = 0;
// Get paginated activity log entries with ordering
virtual std::vector<Entry> GetActivityLogPaginated(
uint32_t offset,
uint32_t limit,
const std::string& orderColumn = "time",
const std::string& orderDir = "DESC"
) = 0;
};
#endif //!__IACTIVITYLOG__H__

View File

@@ -2,10 +2,7 @@
#define __IBUGREPORTS__H__
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
#include <optional>
class IBugReports {
public:
@@ -17,29 +14,7 @@ public:
LWOOBJID characterId{};
};
struct DetailedInfo {
uint64_t id{};
std::string body;
std::string clientVersion;
std::string otherPlayer;
std::string selection;
LWOOBJID characterId{};
uint64_t submitted{};
uint64_t resolved_time{};
uint32_t resolved_by_id{};
std::string resolution;
};
// Add a new bug report to the database.
virtual void InsertNewBugReport(const Info& info) = 0;
// Dashboard methods
virtual std::vector<DetailedInfo> GetAllBugReports() = 0;
virtual std::vector<DetailedInfo> GetUnresolvedBugReports() = 0;
virtual std::vector<DetailedInfo> GetResolvedBugReports() = 0;
virtual std::optional<DetailedInfo> GetBugReportById(const uint64_t reportId) = 0;
virtual void ResolveBugReport(const uint64_t reportId, const uint32_t resolvedById, const std::string_view resolution) = 0;
virtual uint32_t GetBugReportCount() = 0;
virtual uint32_t GetUnresolvedBugReportCount() = 0;
};
#endif //!__IBUGREPORTS__H__

View File

@@ -9,9 +9,6 @@
#include "ePermissionMap.h"
// Forward declare eActivityType for Activity struct
enum class eActivityType : uint32_t;
class ICharInfo {
public:
struct Info {
@@ -22,35 +19,6 @@ public:
bool needsRename{};
LWOCLONEID cloneId{};
ePermissionMap permissionMap{};
// Extended fields for dashboard
uint32_t level{};
uint64_t uscore{};
uint32_t zoneId{};
uint64_t lastLogin{};
uint64_t createdOn{};
};
struct Stats {
uint64_t totalCurrencyCollected{};
uint64_t totalBricksCollected{};
uint64_t totalSmashables{};
uint64_t totalQuickbuildsCompleted{};
uint64_t totalEnemiesSmashed{};
uint64_t totalRocketsUsed{};
uint64_t totalMissionsCompleted{};
uint64_t totalPetsTamed{};
};
struct InventoryItem {
LWOOBJID itemId{};
uint32_t count{};
int32_t slot{};
};
struct Activity {
uint64_t timestamp{};
eActivityType activity{};
uint32_t mapId{};
};
// Get the approved names of all characters.
@@ -78,41 +46,6 @@ public:
virtual void UpdateLastLoggedInCharacter(const LWOOBJID characterId) = 0;
virtual bool IsNameInUse(const std::string_view name) = 0;
// Get total count of characters
virtual uint32_t GetCharacterCount() = 0;
// Get paginated list of all characters
virtual std::vector<Info> GetAllCharactersPaginated(
uint32_t offset,
uint32_t limit,
const std::string& orderColumn = "id",
const std::string& orderDir = "DESC"
) = 0;
// Get characters with pending names (for moderation)
virtual std::vector<Info> GetCharactersWithPendingNames() = 0;
// Update character permission map (for restrictions)
virtual void UpdateCharacterPermissions(const LWOOBJID characterId, ePermissionMap permissions) = 0;
// Set needs rename flag
virtual void SetCharacterNeedsRename(const LWOOBJID characterId, bool needsRename) = 0;
// Get character statistics
virtual std::optional<Stats> GetCharacterStats(const LWOOBJID characterId) = 0;
// Get character inventory
virtual std::vector<InventoryItem> GetCharacterInventory(const LWOOBJID characterId) = 0;
// Get character activity history
virtual std::vector<Activity> GetCharacterActivity(const LWOOBJID characterId, uint32_t limit = 50) = 0;
// Rescue character to a safe zone
virtual void RescueCharacter(const LWOOBJID characterId, uint32_t zoneId) = 0;
// Delete character and all associated data
virtual void DeleteCharacter(const LWOOBJID characterId) = 0;
};
#endif //!__ICHARINFO__H__

View File

@@ -2,24 +2,13 @@
#define __ICOMMANDLOG__H__
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
class ICommandLog {
public:
struct Entry {
uint64_t timestamp{};
LWOOBJID characterId{};
std::string command;
std::string arguments;
};
public:
// Insert a new slash command log entry.
virtual void InsertSlashCommandUsage(const LWOOBJID characterId, const std::string_view command) = 0;
// Get recent command log entries
virtual std::vector<Entry> GetCommandLogs(uint32_t limit = 100) = 0;
};
#endif //!__ICOMMANDLOG__H__

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