Spaces:
Runtime error
Runtime error
File size: 37,973 Bytes
732ca45 de1eee8 732ca45 936e7b5 732ca45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 | # web/api.py
from quart import Quart, jsonify, render_template, redirect, request, session, current_app, make_response
import httpx
import os
import sys
import json
import math
import time
import asyncio
import logging
from datetime import datetime, timedelta
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import DB_DSN
from database.db import db
from utils.image_prep import prepare_database_image
from services.gacha_service import GachaService
import services.importer_service as imp_srv
# ==========================================
# 📡 THE LIVE CONSOLE INTERCEPTOR
# ==========================================
CONSOLE_LOGS = asyncio.Queue()
LOG_HISTORY = []
class DashboardLogHandler(logging.Handler):
def emit(self, record):
timestamp = self.formatter.formatTime(record, "%H:%M:%S")
# ⚡ THE FIX: .replace('\n', '<br>') ensures tracebacks don't break the SSE stream
msg = self.format(record).replace('<', '<').replace('>', '>').replace('\n', '<br>')
if record.levelno >= logging.ERROR:
color = "text-red-500 font-bold"
elif record.levelno == logging.WARNING:
color = "text-yellow-400"
elif record.levelno == logging.INFO:
color = "text-gray-300"
else:
color = "text-gray-500"
log_entry = f'<span class="text-gray-600">[{timestamp}]</span> <span class="text-purple-400">[{record.name}]</span> <span class="{color}">{msg}</span>'
LOG_HISTORY.append(log_entry)
if len(LOG_HISTORY) > 300:
LOG_HISTORY.pop(0)
try:
loop = asyncio.get_running_loop()
loop.call_soon_threadsafe(CONSOLE_LOGS.put_nowait, log_entry)
except RuntimeError:
pass
dash_handler = DashboardLogHandler()
dash_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(name)s] %(levelname)s: %(message)s'))
logging.getLogger().addHandler(dash_handler)
# ==========================================
CLIENT_ID = os.environ.get("DISCORD_CLIENT_ID")
CLIENT_SECRET = os.environ.get("DISCORD_CLIENT_SECRET")
ADMIN_ID = os.environ.get("ADMIN_DISCORD_ID")
REDIRECT_URI = os.environ.get("REDIRECT_URI", "https://amit9011-gacha-bot.hf.space/callback")
SUPABASE_URL = os.environ.get("SUPABASE_URL")
SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
BUCKET_NAME = os.environ.get("SUPABASE_BUCKET", "gacha-images")
app = Quart(__name__)
app.secret_key = os.environ.get("QUART_SECRET_KEY", os.urandom(24))
@app.route("/login")
async def login():
discord_auth_url = (f"https://discord.com/api/oauth2/authorize?client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&response_type=code&scope=identify")
return redirect(discord_auth_url)
@app.route("/callback")
async def callback():
code = request.args.get("code")
if not code: return "Error", 400
data = {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI}
async with httpx.AsyncClient() as client:
token_resp = await client.post("https://discord.com/api/oauth2/token", data=data)
access_token = token_resp.json().get("access_token")
if not access_token: return "Failed to get token", 400
user_resp = await client.get("https://discord.com/api/users/@me", headers={"Authorization": f"Bearer {access_token}"})
user_json = user_resp.json()
if str(user_json.get("id")) == ADMIN_ID:
session["admin_logged_in"] = True
session["username"] = user_json.get("username")
return redirect("/")
return "❌ ACCESS DENIED.", 403
@app.route("/logout")
async def logout():
session.clear()
return redirect("/")
@app.route("/")
async def analytics():
if not session.get("admin_logged_in"): return redirect("/login")
if not db.pool: return "Database not connected", 500
def format_huge(num):
if num >= 1_000_000_000_000_000: return f"{num / 1_000_000_000_000_000:.2f}Q"
if num >= 1_000_000_000_000: return f"{num / 1_000_000_000_000:.2f}T"
if num >= 1_000_000_000: return f"{num / 1_000_000_000:.2f}B"
if num >= 1_000_000: return f"{num / 1_000_000:.2f}M"
return f"{num:,}"
async with db.pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS economy_logs (
id SERIAL PRIMARY KEY,
user_id BIGINT,
amount INT NOT NULL,
currency_type VARCHAR(10) NOT NULL,
transaction_type VARCHAR(10) NOT NULL,
source VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
velocity_rows = await conn.fetch("""
SELECT
TO_CHAR(created_at, 'YYYY-MM-DD') as log_date,
SUM(CASE WHEN transaction_type = 'mint' THEN amount ELSE 0 END) as minted,
SUM(CASE WHEN transaction_type = 'burn' THEN amount ELSE 0 END) as burned
FROM economy_logs
WHERE created_at >= NOW() - INTERVAL '7 days' AND currency_type = 'coins'
GROUP BY TO_CHAR(created_at, 'YYYY-MM-DD')
ORDER BY log_date ASC
""")
dates = [(datetime.utcnow() - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(6, -1, -1)]
mint_data = {d: 0 for d in dates}
burn_data = {d: 0 for d in dates}
for r in velocity_rows:
d = r['log_date']
if d in mint_data:
mint_data[d] = r['minted']
burn_data[d] = r['burned']
economy = await conn.fetchrow("SELECT SUM(coins) as t_coins, SUM(dust) as t_dust FROM users")
total_coins = economy['t_coins'] or 0
total_dust = economy['t_dust'] or 0
total_players = await conn.fetchval("SELECT COUNT(*) FROM users")
cards_in_wild = await conn.fetchval("SELECT COUNT(*) FROM user_characters")
top_players = await conn.fetch("SELECT user_id, username, coins, dust FROM users ORDER BY coins DESC NULLS LAST LIMIT 5")
top_players_list = [{"username": p["username"], "formatted_coins": format_huge(p["coins"] or 0), "formatted_dust": format_huge(p["dust"] or 0), "coins": p["coins"], "dust": p["dust"]} for p in top_players]
elements = await conn.fetch("SELECT COALESCE(element, 'None') as elem, COUNT(*) as cnt FROM characters GROUP BY elem")
popular_cards = await conn.fetch("SELECT c.name, COUNT(uc.instance_id) as count FROM user_characters uc JOIN characters c ON uc.char_id = c.char_id GROUP BY c.char_id, c.name ORDER BY count DESC LIMIT 5")
market_stats = await conn.fetch("SELECT currency_type, COUNT(*) as cnt FROM market_listings GROUP BY currency_type")
market_dict = {row['currency_type']: row['cnt'] for row in market_stats}
total_market = market_dict.get('coins', 0) + market_dict.get('dust', 0)
user_ids = await conn.fetch("SELECT user_id FROM users")
user_id_set = {r['user_id'] for r in user_ids}
bot = current_app.bot
server_count = len(bot.guilds) if bot else 0
guild_stats = []
if bot:
for guild in bot.guilds:
players_in_guild = sum(1 for m in guild.members if m.id in user_id_set)
if players_in_guild > 0:
icon_url = guild.icon.url if guild.icon else "https://cdn.discordapp.com/embed/avatars/0.png"
guild_stats.append({"name": guild.name, "players": players_in_guild, "icon": icon_url, "id": str(guild.id)})
guild_stats.sort(key=lambda x: x['players'], reverse=True)
top_guilds = guild_stats[:10]
return await render_template(
"analytics.html", username=session.get("username"), raw_coins=total_coins, raw_dust=total_dust,
total_coins=format_huge(total_coins), total_dust=format_huge(total_dust), total_players=total_players,
cards_in_wild=cards_in_wild, top_players=top_players_list, chart_labels=json.dumps([r['elem'] for r in elements]),
chart_data=json.dumps([r['cnt'] for r in elements]), pop_labels=json.dumps([r['name'] for r in popular_cards]),
pop_data=json.dumps([r['count'] for r in popular_cards]), market_coins=market_dict.get('coins', 0),
market_dust=market_dict.get('dust', 0), total_market=total_market, server_count=server_count, top_guilds=top_guilds,
vel_labels=json.dumps(dates), vel_mint=json.dumps(list(mint_data.values())), vel_burn=json.dumps(list(burn_data.values()))
)
@app.route("/wipe_inventory/<int:user_id>", methods=["POST"])
async def wipe_inventory(user_id):
if not session.get("admin_logged_in"): return redirect("/login")
async with db.pool.acquire() as conn:
async with conn.transaction():
await conn.execute("UPDATE users SET profile_slot_1=NULL, profile_slot_2=NULL, profile_slot_3=NULL, profile_slot_4=NULL, profile_slot_5=NULL, profile_slot_6=NULL, profile_slot_7=NULL, profile_slot_8=NULL, showcase_id=NULL WHERE user_id=$1", user_id)
await conn.execute("DELETE FROM user_teams WHERE user_id=$1", user_id)
await conn.execute("DELETE FROM expeditions WHERE user_id=$1", user_id)
await conn.execute("DELETE FROM active_auctions WHERE seller_id=$1", user_id)
await conn.execute("DELETE FROM market_listings WHERE seller_id=$1", user_id)
await conn.execute("DELETE FROM user_characters WHERE user_id=$1", user_id)
return redirect("/players")
@app.route("/global_wipe_cards", methods=["POST"])
async def global_wipe_cards():
if not session.get("admin_logged_in"): return redirect("/login")
async with db.pool.acquire() as conn:
async with conn.transaction():
await conn.execute("UPDATE users SET profile_slot_1=NULL, profile_slot_2=NULL, profile_slot_3=NULL, profile_slot_4=NULL, profile_slot_5=NULL, profile_slot_6=NULL, profile_slot_7=NULL, profile_slot_8=NULL, showcase_id=NULL")
await conn.execute("TRUNCATE TABLE user_characters CASCADE")
return redirect("/")
@app.route("/characters")
async def characters_tab():
if not session.get("admin_logged_in"): return redirect("/login")
page = int(request.args.get("page", 1))
search_query = request.args.get("q", "").strip()
rarity_filter = request.args.get("rarity", "").strip()
series_filter = request.args.get("series", "").strip()
per_page = 50
offset = (page - 1) * per_page
async with db.pool.acquire() as conn:
series_rows = await conn.fetch("SELECT DISTINCT series FROM characters WHERE series IS NOT NULL AND series != '' ORDER BY series")
all_series = [r['series'] for r in series_rows]
base_query = "FROM characters WHERE 1=1"
args = []
arg_idx = 1
if search_query:
base_query += f" AND (name ILIKE ${arg_idx} OR series ILIKE ${arg_idx})"
args.append(f"%{search_query}%")
arg_idx += 1
if rarity_filter:
base_query += f" AND rarity = ${arg_idx}"
args.append(rarity_filter)
arg_idx += 1
if series_filter:
base_query += f" AND series ILIKE ${arg_idx}"
args.append(f"%{series_filter}%")
arg_idx += 1
total_chars = await conn.fetchval(f"SELECT COUNT(*) {base_query}", *args)
total_pages = math.ceil(total_chars / per_page) if total_chars else 1
args.extend([per_page, offset])
rows = await conn.fetch(
f"SELECT char_id, name, series, rarity, element, atk, def, image_url, c6_gif_url, custom_frame_url {base_query} ORDER BY char_id DESC LIMIT ${arg_idx} OFFSET ${arg_idx+1}",
*args
)
return await render_template(
"characters.html", characters=[dict(row) for row in rows], username=session.get("username"),
current_page=page, total_pages=total_pages, total_chars=total_chars,
search_query=search_query, rarity_filter=rarity_filter, series_filter=series_filter, all_series=all_series
)
@app.route("/add_character_web", methods=["POST"])
async def add_character_web():
if not session.get("admin_logged_in"): return redirect("/login")
form_data = await request.form
files = await request.files
image = files.get("image")
if not image or image.filename == '': return "❌ Error: You must upload an image file.", 400
name = form_data.get("name").strip()
series = form_data.get("series", "Unknown").strip()
rarity = form_data.get("rarity", "C")
element = form_data.get("element", "None").strip()
atk = int(form_data.get("atk", 50))
defense = int(form_data.get("def", 50))
try:
raw_bytes = image.read()
optimized_bytes = await asyncio.to_thread(prepare_database_image, raw_bytes)
safe_filename = f"web_{int(time.time())}_{name.replace(' ', '_').lower()}.webp"
upload_url = f"{SUPABASE_URL}/storage/v1/object/{BUCKET_NAME}/{safe_filename}"
headers = {"Authorization": f"Bearer {SUPABASE_KEY}", "apikey": SUPABASE_KEY, "Content-Type": "image/webp"}
async with httpx.AsyncClient() as client:
resp = await client.post(upload_url, headers=headers, content=optimized_bytes)
if resp.status_code not in [200, 201]: return f"❌ Supabase API Error: {resp.text}", 500
public_url = f"{SUPABASE_URL}/storage/v1/object/public/{BUCKET_NAME}/{safe_filename}"
async with db.pool.acquire() as conn:
await conn.execute("INSERT INTO characters (name, series, rarity, element, atk, def, image_url) VALUES ($1, $2, $3, $4, $5, $6, $7)", name, series, rarity, element, atk, defense, public_url)
await GachaService.rebuild_pools()
return redirect("/characters")
except Exception as e: return f"❌ Internal Error: {e}", 500
@app.route("/edit/<int:char_id>", methods=["POST"])
async def edit_character(char_id):
if not session.get("admin_logged_in"): return redirect("/login")
f = await request.form
gif_url = f.get("c6_gif_url")
gif_url = gif_url if gif_url and gif_url.strip() else None
frame_url = f.get("custom_frame_url")
frame_url = frame_url if frame_url and frame_url.strip() else None
async with db.pool.acquire() as conn:
await conn.execute("UPDATE characters SET name=$1, series=$2, rarity=$3, element=$4, atk=$5, def=$6, c6_gif_url=$7, custom_frame_url=$8 WHERE char_id=$9", f.get("name"), f.get("series"), f.get("rarity"), f.get("element"), int(f.get("atk", 0)), int(f.get("def", 0)), gif_url, frame_url, char_id)
await GachaService.rebuild_pools()
return redirect("/characters")
@app.route("/delete/<int:char_id>", methods=["POST"])
async def delete_character(char_id):
if not session.get("admin_logged_in"): return redirect("/login")
async with db.pool.acquire() as conn:
ownership_count = await conn.fetchval("SELECT COUNT(*) FROM user_characters WHERE char_id = $1", char_id)
if ownership_count > 5: return (f"<h1 style='color:red; text-align:center; margin-top:50px;'>🚨 WIPE PROTECTION TRIGGERED 🚨<br><br>Cannot delete! <b>{ownership_count} players</b> currently own this card.</h1>"), 403
char_data = await conn.fetchrow("SELECT image_url FROM characters WHERE char_id = $1", char_id)
if char_data and char_data['image_url'] and SUPABASE_URL and SUPABASE_KEY:
try:
filename = char_data['image_url'].split('/')[-1]
delete_url = f"{SUPABASE_URL}/storage/v1/object/{BUCKET_NAME}/{filename}"
headers = {"Authorization": f"Bearer {SUPABASE_KEY}", "apikey": SUPABASE_KEY}
async with httpx.AsyncClient() as client:
await client.delete(delete_url, headers=headers)
except Exception as e: print(f"Failed to delete image: {e}")
async with conn.transaction():
await conn.execute("UPDATE users SET showcase_id = NULL WHERE showcase_id IN (SELECT instance_id FROM user_characters WHERE char_id = $1)", char_id)
await conn.execute("UPDATE user_teams SET slot_1 = NULL WHERE slot_1 IN (SELECT instance_id FROM user_characters WHERE char_id = $1)", char_id)
await conn.execute("UPDATE user_teams SET slot_2 = NULL WHERE slot_2 IN (SELECT instance_id FROM user_characters WHERE char_id = $1)", char_id)
await conn.execute("UPDATE user_teams SET slot_3 = NULL WHERE slot_3 IN (SELECT instance_id FROM user_characters WHERE char_id = $1)", char_id)
await conn.execute("DELETE FROM expeditions WHERE instance_id IN (SELECT instance_id FROM user_characters WHERE char_id = $1)", char_id)
await conn.execute("DELETE FROM user_characters WHERE char_id = $1", char_id)
await conn.execute("DELETE FROM characters WHERE char_id = $1", char_id)
await GachaService.rebuild_pools()
return redirect("/characters")
@app.route("/players")
async def players_tab():
if not session.get("admin_logged_in"): return redirect("/login")
page = int(request.args.get("page", 1))
per_page = 50
offset = (page - 1) * per_page
async with db.pool.acquire() as conn:
total_users = await conn.fetchval("SELECT COUNT(*) FROM users")
total_pages = math.ceil(total_users / per_page) if total_users else 1
rows = await conn.fetch(
"SELECT user_id, username, coins, dust, is_banned, is_locked, "
"(SELECT COUNT(*) FROM user_characters WHERE user_characters.user_id = users.user_id) as card_count "
"FROM users ORDER BY coins DESC NULLS LAST LIMIT $1 OFFSET $2",
per_page, offset
)
return await render_template("players.html", players=[dict(r) for r in rows], username=session.get("username"), current_page=page, total_pages=total_pages, total_users=total_users)
@app.route("/edit_wallet/<int:user_id>", methods=["POST"])
async def edit_wallet(user_id):
if not session.get("admin_logged_in"): return redirect("/login")
f = await request.form
async with db.pool.acquire() as conn:
await conn.execute("UPDATE users SET coins=$1, dust=$2 WHERE user_id=$3", int(f.get("coins", 0)), int(f.get("dust", 0)), user_id)
return redirect("/players")
@app.route("/toggle_ban/<int:user_id>", methods=["POST"])
async def toggle_ban(user_id):
if not session.get("admin_logged_in"): return redirect("/login")
async with db.pool.acquire() as conn:
current = await conn.fetchval("SELECT is_banned FROM users WHERE user_id = $1", user_id)
await conn.execute("UPDATE users SET is_banned = $1 WHERE user_id = $2", not current, user_id)
return redirect("/players")
@app.route("/toggle_lock/<int:user_id>", methods=["POST"])
async def toggle_lock(user_id):
if not session.get("admin_logged_in"): return redirect("/login")
async with db.pool.acquire() as conn:
current = await conn.fetchval("SELECT is_locked FROM users WHERE user_id = $1", user_id)
await conn.execute("UPDATE users SET is_locked = $1 WHERE user_id = $2", not current, user_id)
return redirect("/players")
@app.route("/api/inventory/<int:user_id>")
async def get_inventory(user_id):
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
async with db.pool.acquire() as conn:
rows = await conn.fetch("SELECT uc.instance_id, c.name, c.rarity, c.element, c.image_url FROM user_characters uc JOIN characters c ON uc.char_id = c.char_id WHERE uc.user_id = $1 ORDER BY c.rarity DESC, c.name ASC", user_id)
return jsonify([dict(r) for r in rows])
@app.route("/banner")
async def banner_tab():
if not session.get("admin_logged_in"): return redirect("/login")
async with db.pool.acquire() as conn:
await conn.execute("CREATE TABLE IF NOT EXISTS banner_rates (rarity VARCHAR(50) PRIMARY KEY, drop_rate FLOAT)")
count = await conn.fetchval("SELECT COUNT(*) FROM banner_rates")
if count == 0:
for r, d in [("C", 50.0), ("R", 30.0), ("SR", 15.0), ("SSR", 4.0), ("UR", 1.0)]:
await conn.execute("INSERT INTO banner_rates (rarity, drop_rate) VALUES ($1, $2) ON CONFLICT (rarity) DO NOTHING", r, d)
rows = await conn.fetch("SELECT rarity, drop_rate FROM banner_rates")
rates = {row['rarity']: row['drop_rate'] for row in rows}
await conn.execute("""
CREATE TABLE IF NOT EXISTS current_event (
id INT PRIMARY KEY DEFAULT 1,
event_type VARCHAR(50),
event_value VARCHAR(255),
is_active BOOLEAN DEFAULT FALSE,
end_time TIMESTAMP WITH TIME ZONE
)
""")
try: await conn.execute("ALTER TABLE current_event ADD COLUMN IF NOT EXISTS end_time TIMESTAMP WITH TIME ZONE")
except: pass
await conn.execute("INSERT INTO current_event (id, event_type, event_value, is_active) VALUES (1, 'series', '', FALSE) ON CONFLICT DO NOTHING")
current_event = await conn.fetchrow("SELECT *, (end_time > NOW()) as is_valid FROM current_event WHERE id = 1")
return await render_template("banner.html", rates=rates, current_event=dict(current_event) if current_event else {}, username=session.get("username"))
@app.route("/edit_banner", methods=["POST"])
async def edit_banner_rates():
if not session.get("admin_logged_in"): return redirect("/login")
f = await request.form
async with db.pool.acquire() as conn:
for rarity in ["C", "R", "SR", "SSR", "UR"]:
await conn.execute("UPDATE banner_rates SET drop_rate=$1 WHERE rarity=$2", float(f.get(rarity, 0)), rarity)
from services.gacha_service import GachaService
await GachaService.clear_rates_cache()
return redirect("/banner")
@app.route("/toggle_event", methods=["POST"])
async def toggle_event():
if not session.get("admin_logged_in"): return redirect("/login")
f = await request.form
action = f.get("action")
async with db.pool.acquire() as conn:
if action == "start":
event_type = f.get("event_type")
event_value = f.get("event_value").strip()
days = int(f.get("duration_days", 7))
await conn.execute(
"UPDATE current_event SET event_type=$1, event_value=$2, is_active=TRUE, end_time=NOW() + INTERVAL '1 day' * $3 WHERE id=1",
event_type, event_value, days
)
elif action == "stop":
await conn.execute("UPDATE current_event SET is_active=FALSE WHERE id=1")
return redirect("/banner")
@app.route("/api/search_characters")
async def search_characters():
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
q = request.args.get("q", "")
async with db.pool.acquire() as conn:
if q: rows = await conn.fetch("SELECT char_id, name, rarity, image_url FROM characters WHERE name ILIKE $1 LIMIT 12", f"%{q}%")
else: rows = await conn.fetch("SELECT char_id, name, rarity, image_url FROM characters ORDER BY char_id DESC LIMIT 12")
return jsonify([dict(r) for r in rows])
@app.route("/send_player_mail", methods=["POST"])
async def send_player_mail():
if not session.get("admin_logged_in"): return redirect("/login")
f = await request.form
char_id = int(f.get("char_id")) if f.get("char_id") else None
async with db.pool.acquire() as conn:
await conn.execute("ALTER TABLE user_mail ADD COLUMN IF NOT EXISTS char_id INT DEFAULT NULL;")
await conn.execute("INSERT INTO user_mail (user_id, title, message, char_id) VALUES ($1, $2, $3, $4)", int(f.get("user_id")), f.get("title", "A Special Gift!"), f.get("message", ""), char_id)
return redirect("/players")
@app.route("/mail")
async def mail_tab():
if not session.get("admin_logged_in"): return redirect("/login")
success = request.args.get("success")
async with db.pool.acquire() as conn:
await conn.execute("CREATE TABLE IF NOT EXISTS user_mail (mail_id SERIAL PRIMARY KEY, user_id BIGINT REFERENCES users(user_id) ON DELETE CASCADE, title VARCHAR(255), message TEXT, coins INT DEFAULT 0, dust INT DEFAULT 0, char_id INT DEFAULT NULL, is_claimed BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
player_count = await conn.fetchval("SELECT COUNT(*) FROM users")
return await render_template("mail.html", username=session.get("username"), player_count=player_count, success=success)
@app.route("/send_mail", methods=["POST"])
async def send_global_mail():
if not session.get("admin_logged_in"): return redirect("/login")
f = await request.form
async with db.pool.acquire() as conn:
await conn.execute("INSERT INTO user_mail (user_id, title, message, coins, dust) SELECT user_id, $1, $2, $3, $4 FROM users", f.get("title"), f.get("message"), int(f.get("coins", 0)), int(f.get("dust", 0)))
return redirect("/mail?success=1")
@app.route("/assets", methods=["GET", "POST"])
async def assets_tab():
if not session.get("admin_logged_in"): return redirect("/login")
message = ""
os.makedirs("assets", exist_ok=True)
if request.method == "POST":
files = await request.files
frame_file = files.get("frame_image")
if frame_file and frame_file.filename != '':
await frame_file.save(os.path.join("assets", "frame.png"))
message += "✅ Card Frame updated successfully! "
font_file = files.get("font_file")
if font_file and font_file.filename != '':
await font_file.save(os.path.join("assets", "font.ttf"))
message += "✅ Font updated successfully! "
return await render_template("assets.html", username=session.get("username"), message=message)
@app.route("/health", methods=["GET"])
async def health_check():
return "OK", 200
# --- IMPORTER ROUTES ---
@app.route("/importer")
async def importer_tab():
if not session.get("admin_logged_in"): return redirect("/login")
return await render_template("importer.html", username=session.get("username"))
@app.route("/api/run_importer", methods=["POST"])
async def run_importer():
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
data = await request.json
anime_name = data.get("anime_name", "").strip()
pages = int(data.get("pages", 1))
if imp_srv.CURRENT_IMPORT_TASK and not imp_srv.CURRENT_IMPORT_TASK.done():
return jsonify({"status": "already_running"})
imp_srv.CURRENT_IMPORT_TASK = asyncio.create_task(imp_srv.ImporterService.run_import_task(anime_name, pages))
return jsonify({"status": "started"})
@app.route("/api/stop_importer", methods=["POST"])
async def stop_importer():
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
if imp_srv.CURRENT_IMPORT_TASK and not imp_srv.CURRENT_IMPORT_TASK.done():
imp_srv.CURRENT_IMPORT_TASK.cancel()
return jsonify({"status": "stopped"})
return jsonify({"status": "not_running"})
@app.route("/api/importer_stream")
async def importer_stream():
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
async def log_generator():
for msg in list(imp_srv.LOG_HISTORY):
yield f"data: {msg}\n\n"
while True:
try:
# ⚡ FAST 5s TIMEOUT
msg = await asyncio.wait_for(imp_srv.IMPORTER_LOGS.get(), timeout=5.0)
yield f"data: {msg}\n\n"
except asyncio.TimeoutError:
# ⚡ W3C STRICT SSE COMMENT (Browser ignores this, but it keeps the TCP socket alive)
yield ": keepalive\n\n"
response = await make_response(log_generator(), {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
})
response.timeout = None
return response
# --- TERMINAL CONSOLE ROUTES ---
@app.route("/console")
async def console_tab():
if not session.get("admin_logged_in"): return redirect("/login")
return await render_template("console.html", username=session.get("username"))
@app.route("/api/console_stream")
async def console_stream():
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
async def log_generator():
for msg in list(LOG_HISTORY):
yield f"data: {msg}\n\n"
while True:
try:
msg = await asyncio.wait_for(CONSOLE_LOGS.get(), timeout=5.0)
yield f"data: {msg}\n\n"
except asyncio.TimeoutError:
# ⚡ W3C STRICT SSE COMMENT
yield ": keepalive\n\n"
response = await make_response(log_generator(), {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
})
response.timeout = None
return response
# 🚨 INSTANT CARD CONFISCATION ROUTE
@app.route("/api/confiscate/<int:instance_id>", methods=["POST"])
async def api_confiscate(instance_id):
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
async with db.pool.acquire() as conn:
async with conn.transaction():
exists = await conn.fetchval("SELECT 1 FROM user_characters WHERE instance_id = $1", instance_id)
if not exists:
return jsonify({"status": "error", "message": "Card not found"})
# Wipe it from any visual profile slots before deleting
await conn.execute("UPDATE users SET showcase_id = NULL WHERE showcase_id = $1", instance_id)
for i in range(1, 9):
await conn.execute(f"UPDATE users SET profile_slot_{i} = NULL WHERE profile_slot_{i} = $1", instance_id)
# The ON DELETE CASCADE in your schema automatically handles user_teams, expeditions, and the market
await conn.execute("DELETE FROM user_characters WHERE instance_id = $1", instance_id)
return jsonify({"status": "success"})
@app.route("/recover_db")
async def recover_db():
if not session.get("admin_logged_in"): return redirect("/login")
CLEAN_KEY = str(SUPABASE_KEY).strip(' \n\r"\'')
if CLEAN_KEY.startswith("sb_"):
CLEAN_KEY = CLEAN_KEY[3:]
recovered_count = 0
offset = 0
limit = 1000
async with httpx.AsyncClient() as client:
async with db.pool.acquire() as conn:
while True:
url = f"{SUPABASE_URL}/storage/v1/object/list/{BUCKET_NAME}"
headers = {
"Authorization": f"Bearer {CLEAN_KEY}",
"apikey": CLEAN_KEY,
"Content-Type": "application/json"
}
payload = {"prefix": "", "limit": limit, "offset": offset, "sortBy": {"column": "name", "order": "asc"}}
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code != 200:
safe_key_preview = CLEAN_KEY[:15] + "..." if len(CLEAN_KEY) > 15 else "TOO_SHORT"
error_html = f"""
<body style="background:#111; color:white; font-family:sans-serif; text-align:center; padding-top:50px;">
<h1 style="color:#ef4444;">🛑 STILL BLOCKED!</h1>
<p style="font-size: 20px;"><b>Status Code:</b> {resp.status_code}</p>
<div style="background:#222; padding:20px; border-radius:10px; display:inline-block; margin-top:20px; text-align:left; color:#ffb86c; font-family:monospace;">
{resp.text}
</div>
<p style="margin-top:30px; color:#9ca3af;">Key Preview (first 15 chars): {safe_key_preview}</p>
</body>
"""
return error_html
files = resp.json()
if not isinstance(files, list) or len(files) == 0:
break
for file in files:
filename = file.get("name")
if not filename or filename.startswith(".emptyFolder"):
continue
public_url = f"{SUPABASE_URL}/storage/v1/object/public/{BUCKET_NAME}/{filename}"
exists = await conn.fetchval("SELECT 1 FROM characters WHERE image_url = $1", public_url)
if exists:
continue
clean_filename = filename.replace(".webp", "").replace(".png", "").replace(".jpg", "")
parts = clean_filename.split("_")
if parts[0] == "web": name_parts = parts[2:]
elif parts[0].isdigit() and len(parts) > 2: name_parts = parts[2:]
else: name_parts = parts
char_name = " ".join(name_parts).title()
if not char_name: char_name = "Unknown"
await conn.execute(
"INSERT INTO characters (name, rarity, element, atk, def, image_url, series, source) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
char_name, "C", "None", 50, 50, public_url, "Recovered Series", "RecoveryScript"
)
recovered_count += 1
if len(files) < limit:
break
offset += limit
from services.gacha_service import GachaService
await GachaService.rebuild_pools()
html = f"""
<body style="background:#111; color:white; font-family:sans-serif; text-align:center; padding-top:100px;">
<h1 style="color:#22c55e;">✅ Resurrection Complete!</h1>
<p>Successfully scanned the storage bucket and linked <b>{recovered_count}</b> images back into the database.</p>
<br><br>
<a href="/characters" style="background:#3b82f6; color:white; padding:10px 20px; text-decoration:none; border-radius:5px; font-weight:bold;">Return to Characters</a>
</body>
"""
return html
@app.route("/api/bulk_delete", methods=["POST"])
async def bulk_delete_characters():
if not session.get("admin_logged_in"): return jsonify({"error": "Unauthorized"}), 403
data = await request.json
char_ids = data.get("char_ids", [])
if not char_ids: return jsonify({"status": "error", "message": "No characters selected."})
try: char_ids = [int(cid) for cid in char_ids]
except ValueError: return jsonify({"status": "error", "message": "Invalid ID format."})
async with db.pool.acquire() as conn:
protected_rows = await conn.fetch("SELECT char_id FROM user_characters WHERE char_id = ANY($1) GROUP BY char_id HAVING COUNT(*) > 5", char_ids)
protected_ids = {r['char_id'] for r in protected_rows}
to_delete = [cid for cid in char_ids if cid not in protected_ids]
if not to_delete:
return jsonify({"status": "protected", "message": f"All {len(char_ids)} selected characters are protected (owned by >5 players)."})
char_data = await conn.fetch("SELECT image_url FROM characters WHERE char_id = ANY($1)", to_delete)
if SUPABASE_URL and SUPABASE_KEY:
headers = {"Authorization": f"Bearer {SUPABASE_KEY}", "apikey": SUPABASE_KEY}
async def delete_storage_files():
async with httpx.AsyncClient() as client:
tasks = []
for row in char_data:
if row['image_url']:
filename = row['image_url'].split('/')[-1]
delete_url = f"{SUPABASE_URL}/storage/v1/object/{BUCKET_NAME}/{filename}"
tasks.append(client.delete(delete_url, headers=headers))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
asyncio.create_task(delete_storage_files())
async with conn.transaction():
await conn.execute("UPDATE users SET showcase_id = NULL WHERE showcase_id IN (SELECT instance_id FROM user_characters WHERE char_id = ANY($1))", to_delete)
await conn.execute("UPDATE user_teams SET slot_1 = NULL WHERE slot_1 IN (SELECT instance_id FROM user_characters WHERE char_id = ANY($1))", to_delete)
await conn.execute("UPDATE user_teams SET slot_2 = NULL WHERE slot_2 IN (SELECT instance_id FROM user_characters WHERE char_id = ANY($1))", to_delete)
await conn.execute("UPDATE user_teams SET slot_3 = NULL WHERE slot_3 IN (SELECT instance_id FROM user_characters WHERE char_id = ANY($1))", to_delete)
await conn.execute("DELETE FROM expeditions WHERE instance_id IN (SELECT instance_id FROM user_characters WHERE char_id = ANY($1))", to_delete)
await conn.execute("DELETE FROM user_characters WHERE char_id = ANY($1)", to_delete)
await conn.execute("DELETE FROM characters WHERE char_id = ANY($1)", to_delete)
from services.gacha_service import GachaService
await GachaService.rebuild_pools()
return jsonify({
"status": "success",
"deleted": len(to_delete),
"protected": len(protected_ids),
"message": f"Successfully deleted {len(to_delete)} characters."
})
|