mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-06-20 05:34:22 +00:00
Compare commits
54 Commits
web-dashbo
...
issue-1339
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd24e20165 | ||
|
|
54dc3a0b80 | ||
|
|
a156a8fcba | ||
|
|
f6c9a27a2b | ||
|
|
8e09ffd6e8 | ||
|
|
0c1808686c | ||
|
|
4d6a624da2 | ||
|
|
4ab09cf1aa | ||
|
|
4ef9f43266 | ||
|
|
f3a5add038 | ||
|
|
f5d33a773a | ||
|
|
67bbe4c1f0 | ||
|
|
482ff82656 | ||
|
|
8061f512aa | ||
|
|
247576e101 | ||
|
|
8dfdca7fbd | ||
|
|
8283d1fa95 | ||
|
|
434c9b6315 | ||
|
|
3c64b26c39 | ||
|
|
347b1d17d4 | ||
|
|
c723ce2588 | ||
|
|
66b7d3606e | ||
|
|
40fef36530 | ||
|
|
bf020baa17 | ||
|
|
a713216540 | ||
|
|
ea86a708e4 | ||
|
|
ca7424cbeb | ||
|
|
991e55f305 | ||
|
|
5410acffaa | ||
|
|
86f8601bbd | ||
|
|
4658318a3a | ||
|
|
11d44ffb98 | ||
|
|
2fb16420f3 | ||
|
|
96089a8d9a | ||
|
|
eac50acfcc | ||
|
|
ca60787055 | ||
|
|
396dcb0465 | ||
|
|
6e545eb1b9 | ||
|
|
46aac016fd | ||
|
|
83823fa64f | ||
|
|
0dd504c803 | ||
|
|
a70c365c23 | ||
|
|
281d9762ef | ||
|
|
002aa896d8 | ||
|
|
f3a5f60d81 | ||
|
|
4c9c773ec5 | ||
|
|
ec6253c80c | ||
|
|
c2dba31f70 | ||
|
|
74630b56c8 | ||
|
|
fd6029ae10 | ||
|
|
ff645a6662 | ||
|
|
e051229fb6 | ||
|
|
ce28834dce | ||
|
|
cbdd5d9bc6 |
29
.github/copilot-instructions.md
vendored
Normal file
29
.github/copilot-instructions.md
vendored
Normal 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.
|
||||
33
.github/workflows/build-and-push-docker.yml
vendored
33
.github/workflows/build-and-push-docker.yml
vendored
@@ -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
|
||||
|
||||
74
.github/workflows/build-and-test.yml
vendored
74
.github/workflows/build-and-test.yml
vendored
@@ -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
93
.github/workflows/canary.yml
vendored
Normal 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
38
.github/workflows/pr-title-check.yml
vendored
Normal 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
70
.github/workflows/release.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -126,3 +126,5 @@ docker-compose.override.yml
|
||||
# CMake scripts
|
||||
!cmake/*
|
||||
!cmake/toolchains/*
|
||||
.mcp.json
|
||||
.claude/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
35
cliff.toml
Normal 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"
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ TeamData* TeamContainer::CreateLocalTeam(std::vector<LWOOBJID> members) {
|
||||
}
|
||||
}
|
||||
|
||||
newTeam->lootFlag = 1;
|
||||
newTeam->lootFlag = 0;
|
||||
|
||||
TeamStatusUpdate(newTeam);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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__
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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 */
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 © 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", ""));
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user