swanky admin console

This commit is contained in:
UnitedAirforce
2025-09-22 07:54:57 +08:00
parent e154c8f5e0
commit b46832b4dd
7 changed files with 1109 additions and 6 deletions

View File

@@ -257,6 +257,10 @@ A `save_id` will be generated upon saving the data to the server. It is availabl
Note that your old record is not backed up. Thus, this feature should only be used to migrate your own save file, or some save file that you trust. Note that your old record is not backed up. Thus, this feature should only be used to migrate your own save file, or some save file that you trust.
### Admin Console
A database CRUD web console can be accessed at /Login. Create your own username and bcrypt hash in the database `admins` table first.
## Coin Multiplier ## Coin Multiplier
A coin multiplier [0, 5] can be set by the user in the user center. A coin multiplier [0, 5] can be set by the user in the user center.
@@ -540,6 +544,10 @@ PC用文本编辑器打开服务器文件夹的 `config.env`,将`IPV4`填写
小心,你的旧存档不会被备份。因此,此功能仅适合来迁移自己的存档,或者迁移受信任的存档。 小心,你的旧存档不会被备份。因此,此功能仅适合来迁移自己的存档,或者迁移受信任的存档。
### 管理员平台
一个数据库增删改查网页在/Login。先在数据库`admins`表创建自己的账号和bcrypt密码哈希。
## 金币倍数调整 ## 金币倍数调整
你可以在用户中心调整获得的金币倍数 [0, 5]。 你可以在用户中心调整获得的金币倍数 [0, 5]。

View File

@@ -1,5 +1,5 @@
import sqlalchemy import sqlalchemy
from sqlalchemy import Table, Column, Integer, String, DateTime from sqlalchemy import Table, Column, Integer, String, DateTime, Boolean
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -96,6 +96,15 @@ batch_token = Table(
Column("expire_at", Integer, default=int(time.time()) + 1800), Column("expire_at", Integer, default=int(time.time()) + 1800),
) )
admins = Table(
"admins",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("username", String(32), unique=True, nullable=False),
Column("password", String(64), nullable=False),
Column("token", String(256), unique=True, nullable=True)
)
async def init_db(): async def init_db():
global redis global redis
if not os.path.exists(DB_PATH): if not os.path.exists(DB_PATH):

View File

@@ -53,7 +53,7 @@ def crc32_decimal(data):
def hash_password(password): def hash_password(password):
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt) hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed_password return hashed_password.decode('utf-8')
def verify_password(password, hashed_password): def verify_password(password, hashed_password):
if type(hashed_password) == str: if type(hashed_password) == str:

View File

@@ -116,7 +116,7 @@ async def password_reset(request: Request):
return HTMLResponse(inform_page("FAILED:<br>User does not exist.<br>This should not happen.", 0)) return HTMLResponse(inform_page("FAILED:<br>User does not exist.<br>This should not happen.", 0))
async def coin_mp(request: Request): async def user_coin_mp(request: Request):
form = await request.form() form = await request.form()
mp = int(form.get("coin_mp")) mp = int(form.get("coin_mp"))
@@ -207,8 +207,9 @@ async def register(request: Request):
username=username, username=username,
password_hash=hash_password(password), password_hash=hash_password(password),
device_id=decrypted_fields[b'vid'][0].decode(), device_id=decrypted_fields[b'vid'][0].decode(),
data="", data=None,
crc=0, timestamp=None,
crc=None,
coin_mp=1, coin_mp=1,
) )
await database.execute(insert_query) await database.execute(insert_query)
@@ -661,7 +662,7 @@ routes = [
Route('/gcm/php/register.php', reg, methods=['GET']), Route('/gcm/php/register.php', reg, methods=['GET']),
Route('/name_reset/', name_reset, methods=['POST']), Route('/name_reset/', name_reset, methods=['POST']),
Route('/password_reset/', password_reset, methods=['POST']), Route('/password_reset/', password_reset, methods=['POST']),
Route('/coin_mp/', coin_mp, methods=['POST']), Route('/coin_mp/', user_coin_mp, methods=['POST']),
Route('/save_migration/', save_migration, methods=['POST']), Route('/save_migration/', save_migration, methods=['POST']),
Route('/register/', register, methods=['POST']), Route('/register/', register, methods=['POST']),
Route('/logout/', logout, methods=['POST']), Route('/logout/', logout, methods=['POST']),

371
api/web.py Normal file
View File

@@ -0,0 +1,371 @@
from starlette.requests import Request
from starlette.routing import Route
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
import secrets
import bcrypt
import sqlalchemy
import json
import datetime
from api.database import database, user, daily_reward, result, whitelist, blacklist, batch_token, admins
from api.misc import crc32_decimal
TABLE_MAP = {
"users": (user, ["id", "username", "password_hash", "device_id", "crc", "timestamp", "coin_mp", "save_id"]),
"results": (result, ["rid", "vid", "tid", "sid", "stts", "id", "mode", "avatar", "score", "high_score", "play_rslt", "item", "os", "os_ver", "ver"]),
"daily_rewards": (daily_reward, ["id", "device_id", "timestamp", "day", "my_stage", "my_avatar", "coin", "item", "lvl", "title", "avatar"]),
"whitelist": (whitelist, ["id"]),
"blacklist": (blacklist, ["id", "reason"]),
"batch_tokens": (batch_token, ["id", "token", "sid", "verification_name", "verification_id", "expire_at"]),
"admins": (admins, ["id", "username", "password", "token"]),
}
async def is_admin(request: Request):
token = request.cookies.get("token")
if not token:
return False
query = admins.select().where(admins.c.token == token)
admin = await database.fetch_one(query)
if not admin:
return False
return True
async def web_login_page(request: Request):
with open("web/login.html", "r", encoding="utf-8") as file:
html_template = file.read()
return HTMLResponse(content=html_template)
async def web_admin_page(request: Request):
adm = await is_admin(request)
if not adm:
response = RedirectResponse(url="/Login")
response.delete_cookie("token")
return response
with open("web/admin.html", "r", encoding="utf-8") as file:
html_template = file.read()
return HTMLResponse(content=html_template)
async def web_login_login(request: Request):
form_data = await request.json()
username = form_data.get("username")
password = form_data.get("password")
query = admins.select().where(admins.c.username == username)
admin = await database.fetch_one(query)
if not admin:
return JSONResponse({"status": "failed", "message": "Invalid username or password."}, status_code=400)
if not bcrypt.checkpw(password.encode('utf-8'), admin['password'].encode('utf-8')):
return JSONResponse({"status": "failed", "message": "Invalid username or password."}, status_code=400)
token = secrets.token_hex(64)
admin_update = admins.update().where(admins.c.id == admin["id"]).values(token=token)
await database.execute(admin_update)
return JSONResponse({"status": "success", "message": token})
def serialize_row(row, allowed_fields):
result = {}
for field in allowed_fields:
value = row[field]
if hasattr(value, "isoformat"): # Check if it's a datetime object
result[field] = value.isoformat()
else:
result[field] = value
return result
async def web_admin_get_table(request: Request):
params = request.query_params
adm = await is_admin(request)
if not adm:
return JSONResponse({"data": [], "last_page": 1, "total": 0}, status_code=400)
table_name = params.get("table")
page = int(params.get("page", 1))
size = int(params.get("size", 25))
sort = params.get("sort")
dir_ = params.get("dir", "asc")
search = params.get("search", "").strip()
schema = params.get("schema", "0") == "1"
if schema:
table, _ = TABLE_MAP[table_name]
columns = table.columns # This is a ColumnCollection
schema = {col.name: str(col.type).upper() for col in columns}
return JSONResponse(schema)
# Validate table
if table_name not in TABLE_MAP:
return JSONResponse({"data": [], "last_page": 1, "total": 0}, status_code=400)
# Validate size
if size < 10:
size = 10
if size > 100:
size = 100
table, allowed_fields = TABLE_MAP[table_name]
# Build query
query = table.select()
# Search
if search:
search_clauses = []
for field in allowed_fields:
col = getattr(table.c, field, None)
if col is not None:
search_clauses.append(col.like(f"%{search}%"))
if search_clauses:
query = query.where(sqlalchemy.or_(*search_clauses))
# Sort
if sort in allowed_fields:
col = getattr(table.c, sort, None)
if col is not None:
if isinstance(col.type, sqlalchemy.types.String):
if dir_ == "desc":
query = query.order_by(sqlalchemy.func.lower(col).desc())
else:
query = query.order_by(sqlalchemy.func.lower(col).asc())
else:
if dir_ == "desc":
query = query.order_by(col.desc())
else:
query = query.order_by(col.asc())
# Pagination
offset = (page - 1) * size
query = query.offset(offset).limit(size)
# total count
count_query = sqlalchemy.select(sqlalchemy.func.count()).select_from(table)
if search:
search_clauses = []
for field in allowed_fields:
col = getattr(table.c, field, None)
if col is not None:
search_clauses.append(col.like(f"%{search}%"))
if search_clauses:
count_query = count_query.where(sqlalchemy.or_(*search_clauses))
total = await database.fetch_val(count_query)
last_page = max(1, (total + size - 1) // size)
# Fetch data
rows = await database.fetch_all(query)
data = [serialize_row(row, allowed_fields) for row in rows]
response_data = {"data": data, "last_page": last_page, "total": total}
print(response_data)
return JSONResponse(response_data)
async def web_admin_table_set(request: Request):
params = await request.json()
adm = await is_admin(request)
if not adm:
return JSONResponse({"status": "failed", "message": "Invalid token."}, status_code=400)
table_name = params.get("table")
row = params.get("row")
if table_name not in TABLE_MAP:
return JSONResponse({"status": "failed", "message": "Invalid table name."}, status_code=401)
table, _ = TABLE_MAP[table_name]
columns = table.columns # This is a ColumnCollection
schema = {col.name: str(col.type) for col in columns}
try:
row_data = row
if not isinstance(row_data, dict):
raise ValueError("Row data must be a JSON object.")
id_field = None
# Find primary key field (id or objectId)
for pk in ["id", "objectId"]:
if pk in row_data:
id_field = pk
break
if not id_field:
raise ValueError("Row data must contain a primary key ('id' or 'objectId').")
for key, value in row_data.items():
if key not in schema:
raise ValueError(f"Field '{key}' does not exist in table schema.")
# Type checking
expected_type = schema[key]
if expected_type.startswith("INTEGER"):
if not isinstance(value, int):
raise ValueError(f"Field '{key}' must be an integer.")
elif expected_type.startswith("FLOAT"):
if not isinstance(value, float) and not isinstance(value, int):
raise ValueError(f"Field '{key}' must be a float.")
elif expected_type.startswith("BOOLEAN"):
if not isinstance(value, bool):
raise ValueError(f"Field '{key}' must be a boolean.")
elif expected_type.startswith("JSON"):
if not isinstance(value, dict) and not isinstance(value, list):
raise ValueError(f"Field '{key}' must be a JSON object or array.")
elif expected_type.startswith("VARCHAR") or expected_type.startswith("STRING"):
if not isinstance(value, str):
raise ValueError(f"Field '{key}' must be a string.")
elif expected_type.startswith("DATETIME"):
# Try to convert to datetime object
try:
if isinstance(value, str):
dt_obj = datetime.datetime.fromisoformat(value)
row_data[key] = dt_obj
elif isinstance(value, (int, float)):
dt_obj = datetime.datetime.fromtimestamp(value)
row_data[key] = dt_obj
elif isinstance(value, datetime.datetime):
pass # already datetime
else:
raise ValueError
except Exception:
raise ValueError(f"Field '{key}' must be a valid ISO datetime string or timestamp.")
except Exception as e:
return JSONResponse({"status": "failed", "message": f"Invalid row data: {str(e)}"}, status_code=402)
update_data = {k: v for k, v in row_data.items() if k != id_field}
update_query = table.update().where(getattr(table.c, id_field) == row_data[id_field]).values(**update_data)
await database.execute(update_query)
return JSONResponse({"status": "success", "message": "Row updated successfully."})
async def web_admin_table_delete(request: Request):
params = await request.json()
adm = await is_admin(request)
if not adm:
return JSONResponse({"status": "failed", "message": "Invalid token."}, status_code=400)
table_name = params.get("table")
row_id = params.get("id")
if table_name not in TABLE_MAP:
return JSONResponse({"status": "failed", "message": "Invalid table name."}, status_code=401)
if not row_id:
return JSONResponse({"status": "failed", "message": "Row ID is required."}, status_code=402)
table, _ = TABLE_MAP[table_name]
if table_name in ["results"]:
delete_query = table.delete().where(table.c.rid == row_id)
else:
delete_query = table.delete().where(table.c.id == row_id)
result = await database.execute(delete_query)
return JSONResponse({"status": "success", "message": "Row deleted successfully."})
async def web_admin_table_insert(request: Request):
params = await request.json()
adm = await is_admin(request)
if not adm:
return JSONResponse({"status": "failed", "message": "Invalid token."}, status_code=400)
table_name = params.get("table")
row = params.get("row")
if table_name not in TABLE_MAP:
return JSONResponse({"status": "failed", "message": "Invalid table name."}, status_code=401)
table, _ = TABLE_MAP[table_name]
columns = table.columns
schema = {col.name: str(col.type) for col in columns}
# VERIFY that the row data conforms to the schema
try:
row_data = row
if not isinstance(row_data, dict):
raise ValueError("Row data must be a JSON object.")
for key, value in row_data.items():
if key not in schema:
raise ValueError(f"Field '{key}' does not exist in table schema.")
expected_type = schema[key]
if expected_type.startswith("INTEGER"):
if not isinstance(value, int):
raise ValueError(f"Field '{key}' must be an integer.")
elif expected_type.startswith("FLOAT"):
if not isinstance(value, float) and not isinstance(value, int):
raise ValueError(f"Field '{key}' must be a float.")
elif expected_type.startswith("BOOLEAN"):
if not isinstance(value, bool):
raise ValueError(f"Field '{key}' must be a boolean.")
elif expected_type.startswith("JSON"):
try:
json.loads(value)
except:
raise ValueError(f"Field '{key}' must be a valid JSON string.")
elif expected_type.startswith("VARCHAR") or expected_type.startswith("STRING"):
if not isinstance(value, str):
raise ValueError(f"Field '{key}' must be a string.")
elif expected_type.startswith("DATETIME"):
try:
if isinstance(value, str):
dt_obj = datetime.datetime.fromisoformat(value)
row_data[key] = dt_obj
elif isinstance(value, (int, float)):
dt_obj = datetime.datetime.fromtimestamp(value)
row_data[key] = dt_obj
elif isinstance(value, datetime.datetime):
pass
else:
raise ValueError
except Exception:
raise ValueError(f"Field '{key}' must be a valid ISO datetime string or timestamp.")
except Exception as e:
return JSONResponse({"status": "failed", "message": f"Invalid row data: {str(e)}"}, status_code=402)
insert_data = {k: v for k, v in row_data.items() if k in schema}
insert_query = table.insert().values(**insert_data)
result = await database.execute(insert_query)
return JSONResponse({"status": "success", "message": "Row inserted successfully.", "inserted_id": result})
async def web_admin_data_get(request: Request):
adm = await is_admin(request)
if not adm:
return JSONResponse({"status": "failed", "message": "Invalid token."}, status_code=400)
params = request.query_params
uid = params.get("id")
query = user.select().where(user.c.id == uid)
data = await database.fetch_one(query)
return JSONResponse({"status": "success", "data": data['data']})
async def web_admin_data_save(request: Request):
adm = await is_admin(request)
if not adm:
return JSONResponse({"status": "failed", "message": "Invalid token."}, status_code=400)
params = await request.json()
uid = params['id']
save_data = params['data']
crc = crc32_decimal(save_data)
formatted_time = datetime.datetime.now()
query = user.update().where(user.c.id == uid).values(data=save_data, crc=crc, timestamp=formatted_time)
await database.execute(query)
return JSONResponse({"status": "success", "message": "Data saved successfully."})
routes = [
Route("/Login", web_login_page, methods=["GET"]),
Route("/Login/", web_login_page, methods=["GET"]),
Route("/Login/Login", web_login_login, methods=["POST"]),
Route("/Admin", web_admin_page, methods=["GET"]),
Route("/Admin/", web_admin_page, methods=["GET"]),
Route("/Admin/Table", web_admin_get_table, methods=["GET"]),
Route("/Admin/Table/Update", web_admin_table_set, methods=["POST"]),
Route("/Admin/Table/Delete", web_admin_table_delete, methods=["POST"]),
Route("/Admin/Table/Insert", web_admin_table_insert, methods=["POST"]),
Route("/Admin/Data", web_admin_data_get, methods=["GET"]),
Route("/Admin/Data/Save", web_admin_data_save, methods=["POST"]),
]

599
web/admin.html Normal file
View File

@@ -0,0 +1,599 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>GC2OS Admin</title>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://unpkg.com/tabulator-tables@5.5.2/dist/css/tabulator.min.css" rel="stylesheet">
<script src="https://unpkg.com/tabulator-tables@5.5.2/dist/js/tabulator.min.js"></script>
<style>
body {
background: #343a40;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
min-height: 100vh;
}
.container {
max-width: 1700px;
}
#tabulatorTable {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
.tabulator .tabulator-header .tabulator-col {
font-size: clamp(0.4rem, 2vw, 0.7rem);
}
#changeModal .modal-dialog {
max-height: 80vh;
margin-top: 5vh;
margin-bottom: 5vh;
display: flex;
align-items: center;
}
#changeModal .modal-content {
max-height: 80vh;
display: flex;
flex-direction: column;
}
#changeModal .modal-body {
overflow-y: auto;
max-height: 55vh;
}
#insertModal .modal-dialog {
max-height: 80vh;
margin-top: 5vh;
margin-bottom: 5vh;
display: flex;
align-items: center;
}
#insertModal .modal-content {
max-height: 80vh;
display: flex;
flex-direction: column;
}
#insertModal .modal-body {
overflow-y: auto;
max-height: 55vh;
}
</style>
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h4 text-white">GC2OS Management</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar" aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNavbar">
<ul class="navbar-nav ms-4">
<li class="nav-item"><a class="nav-link" href="#" id="usersTab">Users</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="resultsTab">Results</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="dailyRewardsTab">DailyRewards</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="whitelistTab">Whitelist</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="blacklistTab">Blacklist</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="batchTokensTab">BatchTokens</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="adminsTab">Admins</a></li>
</ul>
<div class="d-flex ms-auto">
<button class="btn btn-primary me-2" id="searchBtn">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-success me-2" id="addRowBtn">
<i class="bi bi-plus-lg"></i>
</button>
<button class="btn btn-outline-danger" id="logoutBtn">
<i class="bi bi-box-arrow-right"></i>
</button>
</div>
</div>
</div>
</div>
</nav>
<div class="container">
<div id="tabulatorTable" class="mt-4"></div>
</div>
<!-- Change Modal -->
<div class="modal fade" id="changeModal" tabindex="-1" aria-labelledby="changeModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="changeModalLabel"></h5>
</div>
<div class="modal-body" id="changeModalBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<!-- Insert Modal -->
<div class="modal fade" id="insertModal" tabindex="-1" aria-labelledby="insertModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="insertModalLabel">Insert New Row</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="insertRowForm">
<div class="modal-body" id="insertModalBody">
<!-- Dynamic form fields will be inserted here -->
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-success">Insert</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- Save data Modal -->
<div class="modal fade" id="dataModal" tabindex="-1" aria-labelledby="dataModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="dataModalLabel">Edit Save Data</h5>
</div>
<div class="modal-body">
<textarea id="dataModalTextarea" class="form-control" style="height: 300px; width: 100%;"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" id="dataModalSaveBtn">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Search Modal -->
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="searchModalLabel">Search</h5>
</div>
<div class="modal-body">
<input type="text" id="searchInput" class="form-control" placeholder="Enter search term">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="searchModalBtn">Search</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<script>
window.currentTableName = null;
let dataUserId = -1;
function showTable(tabName) {
window.currentTableName = tabName;
if (window.currentTable) {
window.currentTable.destroy();
}
setCookie("lastTab", tabName, {path: '/'});
window.currentTable = new Tabulator("#tabulatorTable", {
ajaxURL: "/Admin/Table",
ajaxConfig: "GET",
pagination: true,
paginationMode:"remote",
paginationSize: 25,
paginationSizeSelector: [25, 50, 100],
layout: "fitColumns",
height: window.innerHeight * 0.9 + "px",
autoColumns: true,
responsiveLayout: "collapse",
sortMode: "remote",
autoColumnsDefinitions: function(definitions){
if (tabName === "users") {
definitions.push({
title: "data",
field: "__data",
hozAlign: "center",
formatter: function(cell, formatterParams, onRendered){
return `<button class="btn btn-sm btn-info">View</button>`;
},
cellClick: async function(e, cell){
const rowData = cell.getRow().getData();
const userId = rowData.id;
dataUserId = userId;
const resp = await fetch(`/Admin/Data?id=${userId}`);
const result = await resp.json();
document.getElementById('dataModalTextarea').value = typeof result.data === "object"
? JSON.stringify(result.data, null, 2)
: result.data;
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('dataModal'));
modal.show();
}
});
}
definitions.push({
title: "Delete",
field: "__delete",
hozAlign: "center",
formatter: function(cell, formatterParams, onRendered){
return `<button class="btn btn-sm btn-danger">Delete</button>`;
},
cellClick: function(e, cell){
const rowData = cell.getRow().getData();
const idValue = rowData.id !== undefined ? rowData.id : rowData.objectId;
const tableName = window.currentTableName;
document.getElementById('changeModalLabel').innerText = "Confirm Delete";
document.getElementById('changeModalBody').innerHTML =
`<strong>ID:</strong> ${idValue}<br>
<strong>Table:</strong> ${tableName}<br>
<p class="text-danger">Are you sure you want to delete this row?</p>`;
const modalFooter = document.querySelector("#changeModal .modal-footer");
modalFooter.innerHTML = "";
const confirmBtn = document.createElement('button');
confirmBtn.className = "btn btn-danger";
confirmBtn.textContent = "Delete";
confirmBtn.onclick = async function() {
modal.hide();
const response = await fetch("/Admin/Table/Delete", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
table: tableName,
id: rowData.id !== undefined ? rowData.id : rowData.objectId
})
});
const result = await response.json();
if (result.status === "failed") {
alert(result.message || "Delete failed.");
} else {
showTable(tableName);
}
};
modalFooter.appendChild(confirmBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = "btn btn-secondary ms-2";
cancelBtn.textContent = "Cancel";
cancelBtn.setAttribute("data-bs-dismiss", "modal");
modalFooter.appendChild(cancelBtn);
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('changeModal'));
modal.show();
}
});
definitions.forEach(function(col){
col.formatter = function(cell){
const value = cell.getValue();
if (typeof value === "object" && value !== null) {
let str = JSON.stringify(value);
if (str.length > 60) {
str = str.substring(0, 57) + "...";
}
return `<span title="${JSON.stringify(value, null, 2)}">${str}</span>`;
}
return value;
};
if (col.field !== "id" && col.field !== "objectId" && col.field !== "__delete" && col.field !== "__data") {
col.editor = function(cell, onRendered, success, cancel){
const value = cell.getValue();
let input;
if ((typeof value === "object" && value !== null) ||
(typeof value === "string" && value.includes('\n'))) {
input = document.createElement("textarea");
input.style.width = "100%";
input.style.height = "8em";
input.value = (typeof value === "object" && value !== null) ? JSON.stringify(value, null, 2) : value;
} else {
input = document.createElement("input");
input.type = "text";
input.style.width = "100%";
input.value = value;
}
onRendered(function(){
input.focus();
input.select();
});
function parseValue(original, edited) {
if (typeof original === "boolean") {
return (edited === "true");
}
else if (typeof original === "number" && Number.isInteger(original)) {
let parsed = parseInt(edited, 10);
return isNaN(parsed) ? original : parsed;
}
else if (typeof original === "number") {
let parsed = parseFloat(edited);
return isNaN(parsed) ? original : parsed;
}
else if (typeof original === "object" && original !== null) {
try {
return JSON.parse(edited);
} catch {
alert("Invalid JSON format. Please correct your input.");
return "__CANCEL__";
}
}
return edited;
}
function handleEdit() {
let original = cell.getValue();
let edited = input.value;
let parsed = parseValue(original, edited);
if (parsed === "__CANCEL__") {
cancel();
return;
}
let changed = false;
if (typeof original === "object" && original !== null) {
changed = JSON.stringify(original) !== JSON.stringify(parsed);
} else {
changed = original !== parsed;
}
if (changed) {
success(parsed);
} else {
cancel();
}
}
input.addEventListener("keydown", function(e){
if(e.keyCode == 13 && !(input instanceof HTMLTextAreaElement)){
handleEdit();
} else if(e.keyCode == 27){
cancel();
}
});
input.addEventListener("blur", handleEdit);
return input;
};
}
});
return definitions;
},
ajaxURLGenerator: function(url, config, params) {
let query = [];
query.push(`table=${tabName.toLowerCase()}`);
if (params.page) query.push(`page=${params.page}`);
if (params.size) query.push(`size=${params.size}`);
if (params.sort && params.sort.length) {
query.push(`sort=${params.sort[0].field}`);
query.push(`dir=${params.sort[0].dir}`);
}
if (params.filters && params.filters.length) {
query.push(`filter=${params.filters.map(f => `${f.field}:${f.value}`).join(",")}`);
}
if (searchTerm) {
query.push(`search=${encodeURIComponent(searchTerm)}`);
}
return url + "?" + query.join("&");
},
ajaxResponse: function(url, params, response) {
return response;
},
paginationDataReceived: {
last_page: "last_page",
total: "total"
}
});
window.currentTable.on('cellEdited', (cell) => {
const updatedRow = cell.getRow().getData();
const field = cell.getField();
const newValue = cell.getValue();
const idValue = updatedRow.id !== undefined ? updatedRow.id : updatedRow.objectId;
window.pendingUpdateRow = updatedRow;
window.pendingUpdateTable = cell.getTable().options.ajaxParams.table;
window.pendingUpdateCell = cell;
window.pendingOldValue = cell.getOldValue();
const oldType = typeof window.pendingOldValue;
const newType = typeof newValue;
document.getElementById('changeModalLabel').innerText = "Confirm Update";
document.getElementById('changeModalBody').innerHTML =
`<strong>ID:</strong> ${idValue}<br>
<strong>Field:</strong> ${field}<br>
<strong>Old Value:</strong> <pre>${typeof window.pendingOldValue === "object" ? JSON.stringify(window.pendingOldValue, null, 2) : window.pendingOldValue}</pre>
<strong>Old Type:</strong> ${oldType}<br>
<strong>New Value:</strong> <pre>${typeof newValue === "object" ? JSON.stringify(newValue, null, 2) : newValue}</pre>
<strong>New Type:</strong> ${newType}<br>
<p>Do you want to update this cell?</p>`;
const modalFooter = document.querySelector("#changeModal .modal-footer");
modalFooter.innerHTML = "";
const confirmBtn = document.createElement('button');
confirmBtn.className = "btn btn-primary";
confirmBtn.textContent = "Confirm";
confirmBtn.onclick = async function() {
modal.hide();
const response = await fetch("/Admin/Table/Update", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
table: window.currentTableName,
row: window.pendingUpdateRow
})
});
const result = await response.json();
if (result.status === "failed") {
alert(result.message || "Update failed.");
} else {
showTable(window.currentTableName);
}
};
modalFooter.appendChild(confirmBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = "btn btn-secondary ms-2";
cancelBtn.textContent = "Cancel";
cancelBtn.setAttribute("data-bs-dismiss", "modal");
cancelBtn.onclick = function() {
window.pendingUpdateCell.setValue(window.pendingOldValue, true);
};
modalFooter.appendChild(cancelBtn);
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('changeModal'));
modal.show();
});
}
document.getElementById('dataModalSaveBtn').onclick = async function() {
const dataContent = document.getElementById('dataModalTextarea').value;
const resp = await fetch("/Admin/Data/Save", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
id: dataUserId,
data: dataContent
})
});
const result = await resp.json();
if (result.status === "failed") {
alert(result.message || "Save failed.");
} else {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('dataModal'));
modal.hide();
showTable("users");
}
};
document.getElementById('usersTab').onclick = () => showTable("users");
document.getElementById('resultsTab').onclick = () => showTable("results");
document.getElementById('dailyRewardsTab').onclick = () => showTable("daily_rewards");
document.getElementById('whitelistTab').onclick = () => showTable("whitelist");
document.getElementById('blacklistTab').onclick = () => showTable("blacklist");
document.getElementById('batchTokensTab').onclick = () => showTable("batch_tokens");
document.getElementById('adminsTab').onclick = () => showTable("admins");
document.getElementById('logoutBtn').addEventListener('click', function() {
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
window.location.href = "/Login";
});
let searchTerm = "";
document.getElementById('searchBtn').onclick = function() {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('searchModal'));
document.getElementById('searchInput').value = searchTerm;
modal.show();
};
document.getElementById('searchModalBtn').onclick = function() {
searchTerm = document.getElementById('searchInput').value.trim();
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('searchModal'));
modal.hide();
showTable(window.currentTableName);
// Highlight searchBtn if search is active
const searchBtn = document.getElementById('searchBtn');
if (searchTerm) {
searchBtn.style.backgroundColor = "yellow";
searchBtn.style.color = "black";
} else {
searchBtn.style.backgroundColor = "";
searchBtn.style.color = "";
}
};
document.getElementById('addRowBtn').onclick = async function() {
const tableName = window.currentTableName;
const schemaResp = await fetch(`/Admin/Table?table=${tableName}&schema=1`);
const schema = await schemaResp.json();
const modalBody = document.getElementById('insertModalBody');
modalBody.innerHTML = "";
Object.entries(schema).forEach(([field, type]) => {
if (field === "id" || field === "objectId") return;
let inputType = "text";
if (type.startsWith("INTEGER")) inputType = "number";
if (type.startsWith("BOOLEAN")) inputType = "checkbox";
modalBody.innerHTML += `
<div class="mb-3">
<label class="form-label">${field} (${type})</label>
${inputType === "checkbox"
? `<input type="checkbox" class="form-check-input" name="${field}">`
: `<input type="${inputType}" class="form-control" name="${field}">`
}
</div>
`;
});
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('insertModal'));
modal.show();
};
document.getElementById('insertRowForm').onsubmit = async function(e) {
e.preventDefault();
const tableName = window.currentTableName;
const form = e.target;
const data = {};
Array.from(form.elements).forEach(el => {
if (!el.name) return;
if (el.type === "checkbox") {
data[el.name] = el.checked;
} else if (el.type === "number") {
data[el.name] = el.value ? parseInt(el.value, 10) : null;
} else {
data[el.name] = el.value;
}
});
const response = await fetch("/Admin/Table/Insert", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
table: tableName,
row: data
})
});
const result = await response.json();
if (result.status === "failed") {
alert(result.message || "Insert failed.");
} else {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('insertModal'));
modal.hide();
showTable(tableName);
}
};
const validTabs = ["users", "results", "daily_rewards", "whitelist", "blacklist", "batch_tokens", "admins"];
const lastTab = getCookie("lastTab");
if (validTabs.includes(lastTab)) {
showTable(lastTab);
} else {
showTable("users");
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
function setCookie(name, value, days) {
let expires = "";
if (days) {
const date = new Date();
date.setTime(date.getTime() + (days*24*60*60*1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
</script>
</body>
</html>

115
web/login.html Normal file
View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Pianista Admin Login</title>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #1a1a1b;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
min-height: 100vh;
overflow: hidden;
}
.login-card {
background: rgba(255,255,255,0.9);
border-radius: 12px;
padding: 2rem;
max-width: 400px;
margin: 5% auto;
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
}
.form-control {
font-family: monospace;
font-size: 1rem;
letter-spacing: 1px;
}
</style>
</head>
<body>
<div class="login-card">
<div class="d-flex justify-content-center align-items-center mb-4">
<h3 class="text-center mb-0">Admin Login</h3>
<button type="button" class="btn btn-link ms-2 p-0" id="infoBtn" aria-label="Info" style="font-size:1.5rem;">
<span class="bi bi-info-circle" style="vertical-align:middle;"></span>
</button>
</div>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</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>
<button type="submit" class="btn btn-primary w-100">Submit</button>
</form>
<div id="result" class="mt-3"></div>
</div>
<div class="modal fade" id="loginModal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginModalLabel"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="loginModalBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const response = await fetch('/Login/Login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({"username": username, "password": password})
});
const result = await response.json();
if (result.status === "success") {
// Set token cookie and redirect
document.cookie = `token=${result.message}; path=/;`;
window.location.href = "/Admin";
} else {
// Show modal with error message
document.getElementById('loginModalLabel').innerText = "Login Failed";
document.getElementById('loginModalBody').innerHTML =
`${result.message}`;
const modal = new bootstrap.Modal(document.getElementById('loginModal'));
modal.show();
}
});
document.getElementById('infoBtn').addEventListener('click', function() {
document.getElementById('loginModalLabel').innerText = "Admin Login";
document.getElementById('loginModalBody').innerHTML =
`<p>Don't do anything funny! Don't even think about it!</p>`;
const modal = new bootstrap.Modal(document.getElementById('loginModal'));
modal.show();
});
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
if (getCookie("token")) {
window.location.href = "/Admin";
}
</script>
</body>
</html>