mirror of
https://github.com/qwerfd2/Groove_Coaster_2_Server.git
synced 2025-12-22 03:30:18 +00:00
swanky admin console
This commit is contained in:
@@ -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]。
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
371
api/web.py
Normal 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
599
web/admin.html
Normal 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
115
web/login.html
Normal 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>
|
||||||
Reference in New Issue
Block a user