feat: modpack version checker dashboard + PHP proxy (v1.0)
WHAT WAS DONE: - Built browser dashboard (dashboard.html) showing installed vs latest version for all Pterodactyl game servers - Built PHP proxy (proxy.php + config.php) for Billing VPS deployment - Created isolated Nginx server block (version-proxy.conf) - Created full deployment guide (DEPLOYMENT-GUIDE.md) ARCHITECTURE: - PHP proxy at /var/www/version-proxy on Billing VPS (38.68.14.188) - Isolated from Paymenter/Laravel routing — separate directory + port - API keys (Pterodactyl ptlc_, CurseForge) live server-side only - FTB packs: fully automatic via .manifest.json + FTB public API - CurseForge packs: reads manifest.json, needs CF Project ID + API key - config.php blocked from direct web access via Nginx PENDING AT DEPLOYMENT: - Verify port 8080 is free (ss -tlnp) before enabling Nginx block - Fill real API keys into config.php on server - Enter CurseForge Project IDs for CF packs (saved in localStorage) COLLABORATION: - PHP proxy architecture designed by Gemini (consultation session 2026-03-29) - Dashboard HTML and detection logic by Chronicler #47 - Gemini identified Laravel routing conflict and content-type gotcha WHY: - Interim solution before full Blueprint extension (post-launch) - Hands-off modpack update monitoring for staff - Zero manual checking required after initial CF Project ID setup Signed-off-by: claude@firefrostgaming.com
This commit is contained in:
126
docs/tasks/modpack-version-checker/DEPLOYMENT-GUIDE.md
Normal file
126
docs/tasks/modpack-version-checker/DEPLOYMENT-GUIDE.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Modpack Version Checker — Deployment Guide
|
||||
|
||||
**Status:** READY TO DEPLOY
|
||||
**Created:** 2026-03-29
|
||||
**Created By:** Chronicler #47 + Gemini (architecture consultation)
|
||||
**Prerequisite:** PHP proxy must be deployed before the dashboard will work
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A browser-based dashboard that automatically checks all Pterodactyl game servers for modpack version status (up to date vs update available). Uses a PHP proxy on the Billing VPS to handle API calls securely — no API keys in the browser.
|
||||
|
||||
**Platforms supported:**
|
||||
- FTB — fully automatic (reads `.manifest.json`, compares against FTB public API)
|
||||
- CurseForge — reads installed version from `manifest.json`; needs CF Project ID entered once per pack + CurseForge API key in `config.php`
|
||||
- Modrinth — detects installed version; latest version lookup TBD
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `dashboard.html` | The browser dashboard — can be hosted anywhere |
|
||||
| `proxy/proxy.php` | PHP proxy — deploy to Billing VPS |
|
||||
| `proxy/config.php` | API keys config — deploy to Billing VPS, fill in real keys |
|
||||
| `proxy/version-proxy.conf` | Nginx server block — deploy to Billing VPS |
|
||||
|
||||
---
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### Step 1: Verify Port 8080 is Free
|
||||
|
||||
SSH to Billing VPS and run:
|
||||
|
||||
```bash
|
||||
ss -tlnp | grep 8080
|
||||
```
|
||||
|
||||
If 8080 is taken, pick 8081 or 8090. Update the port in:
|
||||
- `version-proxy.conf` (`listen 8080;`)
|
||||
- `dashboard.html` (`const PROXY_URL = ...`)
|
||||
|
||||
### Step 2: Create Proxy Directory
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/version-proxy
|
||||
sudo chown -R www-data:www-data /var/www/version-proxy
|
||||
```
|
||||
|
||||
### Step 3: Upload Proxy Files
|
||||
|
||||
Copy `proxy.php` and `config.php` to `/var/www/version-proxy/` on the Billing VPS.
|
||||
|
||||
### Step 4: Fill In Real Keys
|
||||
|
||||
Edit `/var/www/version-proxy/config.php` with real values:
|
||||
- `panel_key` — Pterodactyl client API key (`ptlc_NDkYX6yPPBHZacPmViFWtl4AvopzgxNcnHoQTOOtQEl`)
|
||||
- `cf_key` — CurseForge API key (get from https://console.curseforge.com)
|
||||
|
||||
### Step 5: Enable Nginx Config
|
||||
|
||||
```bash
|
||||
sudo cp version-proxy.conf /etc/nginx/sites-available/version-proxy.conf
|
||||
sudo ln -s /etc/nginx/sites-available/version-proxy.conf /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Step 6: Open Firewall Port
|
||||
|
||||
```bash
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
(Change 8080 to whichever port you confirmed is free.)
|
||||
|
||||
### Step 7: Test the Proxy
|
||||
|
||||
Open in browser:
|
||||
```
|
||||
http://38.68.14.188:8080/proxy.php?action=servers
|
||||
```
|
||||
|
||||
Should return JSON list of your Pterodactyl servers.
|
||||
|
||||
### Step 8: Open the Dashboard
|
||||
|
||||
Open `dashboard.html` in any browser and click **Check All**.
|
||||
|
||||
---
|
||||
|
||||
## CurseForge Pack IDs
|
||||
|
||||
For packs without automatic version detection, enter the CurseForge Project ID in the card's input field. The dashboard saves these in browser localStorage so you only enter them once.
|
||||
|
||||
Known packs on your servers:
|
||||
|
||||
| Server | Pack | CF Project ID |
|
||||
|--------|------|---------------|
|
||||
| Mythcraft 5 - NC | MYTHCRAFT 5 | (find on CurseForge) |
|
||||
| Ars Eclectica - TX | Ars Eclectica | (find on CurseForge) |
|
||||
| Create Plus - TX | Create V0.9.0 | (find on CurseForge) |
|
||||
| Society: Sunlit Valley - TX | Society: Sunlit Valley | (find on CurseForge) |
|
||||
|
||||
ATM10, ATM Sky, All The Mons — no version manifest detected on disk. Version will show "Not detected" until a manifest file exists.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- PHP proxy lives at `/var/www/version-proxy/` — **isolated from Paymenter's Laravel routing**
|
||||
- `config.php` is blocked from direct web access via Nginx (`location = /config.php { deny all; }`)
|
||||
- FTB API calls are made directly from the browser (public API, no CORS issues)
|
||||
- All Pterodactyl and CurseForge calls go through the proxy (API keys never reach the browser)
|
||||
- CurseForge Project IDs stored in browser localStorage (persist between sessions)
|
||||
|
||||
---
|
||||
|
||||
## Revision History
|
||||
|
||||
| Version | Date | Author | Notes |
|
||||
|---------|------|--------|-------|
|
||||
| 1.0 | 2026-03-29 | Chronicler #47 + Gemini | Initial build. PHP proxy architecture by Gemini. Dashboard HTML by Chronicler #47. |
|
||||
546
docs/tasks/modpack-version-checker/dashboard.html
Normal file
546
docs/tasks/modpack-version-checker/dashboard.html
Normal file
@@ -0,0 +1,546 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Firefrost — Modpack Version Checker</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap');
|
||||
|
||||
:root {
|
||||
--fire: #FF6B35;
|
||||
--frost: #4ECDC4;
|
||||
--gold: #FFD700;
|
||||
--dark: #0F0F1E;
|
||||
--dark2: #151528;
|
||||
--dark3: #1a1a35;
|
||||
--border: rgba(78,205,196,0.15);
|
||||
--up-to-date: #4ade80;
|
||||
--outdated: #f87171;
|
||||
--unknown: #94a3b8;
|
||||
--checking: #60a5fa;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--dark);
|
||||
color: #e2e8f0;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(78,205,196,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(78,205,196,0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo { display: flex; align-items: center; gap: 12px; }
|
||||
.logo-text { font-size: 28px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; }
|
||||
.logo-fire { color: var(--fire); }
|
||||
.logo-frost { color: var(--frost); }
|
||||
.subtitle { font-size: 13px; color: #64748b; letter-spacing: 3px; text-transform: uppercase; font-family: 'Share Tech Mono', monospace; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.last-checked {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 12px; color: #475569;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--dark2);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 14px; font-weight: 600;
|
||||
letter-spacing: 1px; text-transform: uppercase;
|
||||
padding: 8px 20px; border: none; border-radius: 4px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-refresh { background: var(--frost); color: var(--dark); }
|
||||
.btn-refresh:hover { background: #5ee8de; transform: translateY(-1px); }
|
||||
.btn-refresh:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.summary { display: flex; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
|
||||
.summary-card {
|
||||
flex: 1; min-width: 140px;
|
||||
background: var(--dark2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 16px 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.summary-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
.summary-label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.summary-count { font-size: 28px; font-weight: 700; line-height: 1; }
|
||||
|
||||
.servers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
background: var(--dark2); border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: hidden;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
}
|
||||
.server-card:hover { border-color: rgba(78,205,196,0.35); transform: translateY(-2px); }
|
||||
.server-card.outdated { border-color: rgba(248,113,113,0.3); }
|
||||
.server-card.up-to-date { border-color: rgba(74,222,128,0.2); }
|
||||
|
||||
.card-header {
|
||||
padding: 14px 18px; background: var(--dark3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
.server-name { font-size: 15px; font-weight: 600; letter-spacing: 0.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.node-badge { font-family: 'Share Tech Mono', monospace; font-size: 10px; padding: 2px 8px; border-radius: 3px; white-space: nowrap; flex-shrink: 0; }
|
||||
.node-nc { background: rgba(255,107,53,0.15); color: var(--fire); border: 1px solid rgba(255,107,53,0.3); }
|
||||
.node-tx { background: rgba(78,205,196,0.12); color: var(--frost); border: 1px solid rgba(78,205,196,0.25); }
|
||||
|
||||
.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.platform-row { display: flex; align-items: center; gap: 8px; }
|
||||
.platform-badge { font-family: 'Share Tech Mono', monospace; font-size: 10px; padding: 2px 8px; border-radius: 3px; font-weight: 600; }
|
||||
.platform-ftb { background: rgba(255,107,53,0.15); color: #fb923c; border: 1px solid rgba(255,107,53,0.3); }
|
||||
.platform-curseforge { background: rgba(249,115,22,0.12); color: #f97316; border: 1px solid rgba(249,115,22,0.25); }
|
||||
.platform-modrinth { background: rgba(34,197,94,0.12); color: #4ade80; border: 1px solid rgba(34,197,94,0.25); }
|
||||
.platform-unknown { background: rgba(148,163,184,0.1); color: #94a3b8; border: 1px solid rgba(148,163,184,0.2); }
|
||||
|
||||
.pack-name { font-size: 13px; color: #94a3b8; font-family: 'Share Tech Mono', monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.version-comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.version-box { background: var(--dark3); border-radius: 6px; padding: 10px 12px; border: 1px solid rgba(255,255,255,0.05); }
|
||||
.version-label { font-size: 10px; color: #475569; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
.version-value { font-family: 'Share Tech Mono', monospace; font-size: 16px; font-weight: 600; }
|
||||
|
||||
.v-installed { color: #e2e8f0; }
|
||||
.v-latest-current { color: var(--up-to-date); }
|
||||
.v-latest-newer { color: var(--outdated); }
|
||||
.v-unknown { color: #475569; }
|
||||
|
||||
.status-row { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; letter-spacing: 0.5px; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-up-to-date { background: rgba(74,222,128,0.08); border: 1px solid rgba(74,222,128,0.2); color: var(--up-to-date); }
|
||||
.status-outdated { background: rgba(248,113,113,0.08); border: 1px solid rgba(248,113,113,0.2); color: var(--outdated); }
|
||||
.status-checking { background: rgba(96,165,250,0.08); border: 1px solid rgba(96,165,250,0.2); color: var(--checking); }
|
||||
.status-unknown { background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.15); color: var(--unknown); }
|
||||
|
||||
.mc-version { font-family: 'Share Tech Mono', monospace; font-size: 11px; color: #475569; }
|
||||
|
||||
.curseforge-id-row { display: flex; align-items: center; gap: 8px; }
|
||||
.curseforge-id-row label { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: 1px; white-space: nowrap; flex-shrink: 0; }
|
||||
.curseforge-id-row input {
|
||||
background: var(--dark3); border: 1px solid rgba(78,205,196,0.15);
|
||||
border-radius: 3px; padding: 4px 8px; color: #94a3b8;
|
||||
font-family: 'Share Tech Mono', monospace; font-size: 12px;
|
||||
outline: none; width: 100%; transition: border-color 0.2s;
|
||||
}
|
||||
.curseforge-id-row input:focus { border-color: var(--frost); color: #e2e8f0; }
|
||||
|
||||
.btn-check-single {
|
||||
background: transparent; border: 1px solid rgba(78,205,196,0.3);
|
||||
color: var(--frost); padding: 4px 12px; font-size: 11px;
|
||||
border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
.btn-check-single:hover { background: rgba(78,205,196,0.1); }
|
||||
|
||||
.spinner {
|
||||
display: inline-block; width: 12px; height: 12px;
|
||||
border: 2px solid rgba(96,165,250,0.3); border-top-color: var(--checking);
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.error-state {
|
||||
font-family: 'Share Tech Mono', monospace; font-size: 11px; color: #f87171;
|
||||
padding: 6px 10px; background: rgba(248,113,113,0.06);
|
||||
border-radius: 4px; border: 1px solid rgba(248,113,113,0.15);
|
||||
}
|
||||
|
||||
footer { margin-top: 40px; text-align: center; font-size: 11px; color: #1e293b; font-family: 'Share Tech Mono', monospace; letter-spacing: 2px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.servers-grid { grid-template-columns: 1fr; }
|
||||
.version-comparison { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo">
|
||||
<div>
|
||||
<div class="logo-text"><span class="logo-fire">FIRE</span><span class="logo-frost">FROST</span></div>
|
||||
<div class="subtitle">Modpack Version Monitor</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="last-checked" id="lastChecked">Never checked</div>
|
||||
<button class="btn-refresh" id="refreshBtn" onclick="checkAll()">↻ Check All</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<div class="summary-dot" style="background:var(--up-to-date)"></div>
|
||||
<div><div class="summary-label">Up to Date</div><div class="summary-count" id="countCurrent" style="color:var(--up-to-date)">—</div></div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-dot" style="background:var(--outdated)"></div>
|
||||
<div><div class="summary-label">Outdated</div><div class="summary-count" id="countOutdated" style="color:var(--outdated)">—</div></div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-dot" style="background:var(--unknown)"></div>
|
||||
<div><div class="summary-label">Unknown</div><div class="summary-count" id="countUnknown" style="color:var(--unknown)">—</div></div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-dot" style="background:var(--frost)"></div>
|
||||
<div><div class="summary-label">Total Servers</div><div class="summary-count" id="countTotal" style="color:var(--frost)">—</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="servers-grid" id="serversGrid">
|
||||
<div style="color:#475569;font-family:'Share Tech Mono',monospace;font-size:13px;grid-column:1/-1;padding:40px;text-align:center;">
|
||||
Click "Check All" to load servers and check versions.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>FIRE + FROST + FOUNDATION | FOR CHILDREN NOT YET BORN 💙</footer>
|
||||
|
||||
<script>
|
||||
// ─── CONFIGURATION ───────────────────────────────────────────────
|
||||
// Point this to your PHP proxy. Verify port with `ss -tlnp` before deploying.
|
||||
// Default: http://38.68.14.188:8080/proxy.php
|
||||
// If 8080 is taken, change to 8081 or 8090 here and in your Nginx config.
|
||||
const PROXY_URL = 'http://38.68.14.188:8080/proxy.php';
|
||||
|
||||
// ─── STATE ───────────────────────────────────────────────────────
|
||||
const serverStates = {};
|
||||
const packMappings = JSON.parse(localStorage.getItem('ffg_pack_mappings') || '{}');
|
||||
|
||||
function saveMappings() {
|
||||
localStorage.setItem('ffg_pack_mappings', JSON.stringify(packMappings));
|
||||
}
|
||||
|
||||
// ─── PROXY API CALLS ─────────────────────────────────────────────
|
||||
// All requests go through the PHP proxy — no API keys in the browser.
|
||||
|
||||
async function apiCall(action, params = '') {
|
||||
const resp = await fetch(`${PROXY_URL}?action=${action}${params}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
return action === 'read' ? resp.text() : resp.json();
|
||||
}
|
||||
|
||||
async function getFileContents(identifier, path) {
|
||||
try {
|
||||
return await apiCall('read', `&server=${identifier}&file=${encodeURIComponent(path)}`);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function listFiles(identifier) {
|
||||
try {
|
||||
const data = await apiCall('files', `&server=${identifier}`);
|
||||
return data.data.map(f => f.attributes.name);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ─── VERSION DETECTION ───────────────────────────────────────────
|
||||
|
||||
async function detectInstalledVersion(identifier) {
|
||||
const files = await listFiles(identifier);
|
||||
|
||||
// FTB (.manifest.json)
|
||||
if (files.includes('.manifest.json')) {
|
||||
const content = await getFileContents(identifier, '.manifest.json');
|
||||
if (content) {
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
return { platform: 'ftb', packName: data.name, version: data.versionName, ftbPackId: data.id, ftbVersionId: data.versionId };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Modrinth (modrinth.index.json)
|
||||
if (files.includes('modrinth.index.json')) {
|
||||
const content = await getFileContents(identifier, 'modrinth.index.json');
|
||||
if (content) {
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
return { platform: 'modrinth', packName: data.name, version: data.versionId || 'unknown' };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// CurseForge (manifest.json)
|
||||
if (files.includes('manifest.json')) {
|
||||
const content = await getFileContents(identifier, 'manifest.json');
|
||||
if (content) {
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
if (data.minecraft || data.name) {
|
||||
return { platform: 'curseforge', packName: data.name || 'Unknown', version: data.version || 'unknown' };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return { platform: 'unknown', packName: null, version: null };
|
||||
}
|
||||
|
||||
// ─── LATEST VERSION LOOKUPS ──────────────────────────────────────
|
||||
|
||||
async function getLatestFtbVersion(packId) {
|
||||
try {
|
||||
// FTB API is public — no proxy needed, no CORS issue
|
||||
const resp = await fetch(`https://api.modpacks.ch/public/modpack/${packId}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
const versions = (data.versions || []).filter(v => v.type === 'Release' || v.type === 'release');
|
||||
if (versions.length === 0) return data.versions?.[0]?.name || null;
|
||||
return versions[versions.length - 1].name;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function getLatestCurseForgeVersion(projectId) {
|
||||
// Routes through proxy — CF API key stays server-side
|
||||
if (!projectId) return null;
|
||||
try {
|
||||
const data = await apiCall('curseforge', `&project=${projectId}`);
|
||||
const file = data.data?.[0];
|
||||
if (!file) return null;
|
||||
return file.displayName || file.fileName;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ─── VERSION COMPARISON ──────────────────────────────────────────
|
||||
|
||||
function compareVersions(installed, latest) {
|
||||
if (!installed || !latest) return 'unknown';
|
||||
const a = installed.trim().toLowerCase().replace(/^v/, '');
|
||||
const b = latest.trim().toLowerCase().replace(/^v/, '');
|
||||
if (a === b) return 'current';
|
||||
const aParts = a.split('.').map(x => parseInt(x) || 0);
|
||||
const bParts = b.split('.').map(x => parseInt(x) || 0);
|
||||
const len = Math.max(aParts.length, bParts.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const av = aParts[i] || 0;
|
||||
const bv = bParts[i] || 0;
|
||||
if (bv > av) return 'outdated';
|
||||
if (av > bv) return 'current';
|
||||
}
|
||||
return 'current';
|
||||
}
|
||||
|
||||
// ─── CARD RENDERING ──────────────────────────────────────────────
|
||||
|
||||
function renderCard(server, state) {
|
||||
const identifier = server.identifier;
|
||||
const name = server.name;
|
||||
const node = name.includes('- NC') ? 'NC' : name.includes('- TX') ? 'TX' : '?';
|
||||
const mapping = packMappings[identifier] || {};
|
||||
const platformLabels = { ftb: 'FTB', curseforge: 'CurseForge', modrinth: 'Modrinth', unknown: 'Unknown' };
|
||||
const platformClass = { ftb: 'platform-ftb', curseforge: 'platform-curseforge', modrinth: 'platform-modrinth', unknown: 'platform-unknown' };
|
||||
|
||||
let statusHtml = '', installedVersionHtml = '', latestVersionHtml = '', cardClass = '', extraControls = '';
|
||||
|
||||
if (state.loading) {
|
||||
statusHtml = `<div class="status-row status-checking"><div class="spinner"></div> Checking...</div>`;
|
||||
installedVersionHtml = `<span class="v-unknown">—</span>`;
|
||||
latestVersionHtml = `<span class="v-unknown">—</span>`;
|
||||
} else if (state.error) {
|
||||
statusHtml = `<div class="error-state">Error: ${state.error}</div>`;
|
||||
installedVersionHtml = `<span class="v-unknown">—</span>`;
|
||||
latestVersionHtml = `<span class="v-unknown">—</span>`;
|
||||
} else {
|
||||
const installed = state.version;
|
||||
const latest = state.latestVersion;
|
||||
const platform = state.platform || 'unknown';
|
||||
const status = state.status || 'unknown';
|
||||
|
||||
installedVersionHtml = installed
|
||||
? `<span class="v-installed">${installed}</span>`
|
||||
: `<span class="v-unknown">Not detected</span>`;
|
||||
|
||||
latestVersionHtml = latest
|
||||
? `<span class="${status === 'outdated' ? 'v-latest-newer' : 'v-latest-current'}">${latest}</span>`
|
||||
: `<span class="v-unknown">—</span>`;
|
||||
|
||||
if (status === 'current') {
|
||||
statusHtml = `<div class="status-row status-up-to-date"><div class="status-dot" style="background:var(--up-to-date)"></div> Up to Date</div>`;
|
||||
cardClass = 'up-to-date';
|
||||
} else if (status === 'outdated') {
|
||||
statusHtml = `<div class="status-row status-outdated"><div class="status-dot" style="background:var(--outdated)"></div> Update Available</div>`;
|
||||
cardClass = 'outdated';
|
||||
} else {
|
||||
statusHtml = `<div class="status-row status-unknown"><div class="status-dot" style="background:var(--unknown)"></div> Unknown</div>`;
|
||||
}
|
||||
|
||||
if (platform === 'curseforge' || platform === 'unknown') {
|
||||
const cfId = mapping.cfId || '';
|
||||
extraControls = `
|
||||
<div class="curseforge-id-row">
|
||||
<label>CF Project ID</label>
|
||||
<input type="text" placeholder="e.g. 389615" value="${cfId}"
|
||||
onchange="updateMapping('${identifier}', 'cfId', this.value)" />
|
||||
<button class="btn-check-single" onclick="checkSingle('${identifier}')">Check</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
const packNameDisplay = state.packName
|
||||
? `<span class="pack-name" title="${state.packName}">${state.packName}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="server-card ${cardClass}" id="card-${identifier}">
|
||||
<div class="card-header">
|
||||
<div class="server-name" title="${name}">${name.replace(' - TX','').replace(' - NC','')}</div>
|
||||
<span class="node-badge node-${node.toLowerCase()}">${node}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="platform-row">
|
||||
<span class="platform-badge ${platformClass[state.platform || 'unknown']}">${platformLabels[state.platform || 'unknown']}</span>
|
||||
${packNameDisplay}
|
||||
</div>
|
||||
<div class="version-comparison">
|
||||
<div class="version-box"><div class="version-label">Installed</div><div class="version-value">${installedVersionHtml}</div></div>
|
||||
<div class="version-box"><div class="version-label">Latest</div><div class="version-value">${latestVersionHtml}</div></div>
|
||||
</div>
|
||||
${statusHtml}
|
||||
${extraControls}
|
||||
<div class="mc-version">MC ${server.mcVersion || '?'}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── STATE MANAGEMENT ────────────────────────────────────────────
|
||||
|
||||
function updateMapping(identifier, key, value) {
|
||||
if (!packMappings[identifier]) packMappings[identifier] = {};
|
||||
packMappings[identifier][key] = value;
|
||||
saveMappings();
|
||||
}
|
||||
|
||||
function updateCardDisplay(identifier) {
|
||||
const card = document.getElementById(`card-${identifier}`);
|
||||
if (!card) return;
|
||||
card.outerHTML = renderCard(serverStates[identifier]._serverData, serverStates[identifier]);
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const states = Object.values(serverStates);
|
||||
document.getElementById('countCurrent').textContent = states.filter(s => s.status === 'current').length;
|
||||
document.getElementById('countOutdated').textContent = states.filter(s => s.status === 'outdated').length;
|
||||
document.getElementById('countUnknown').textContent = states.filter(s => s.status === 'unknown' || s.loading).length;
|
||||
document.getElementById('countTotal').textContent = states.length;
|
||||
}
|
||||
|
||||
// ─── CORE CHECK LOGIC ────────────────────────────────────────────
|
||||
|
||||
async function checkServerVersion(identifier) {
|
||||
const state = serverStates[identifier];
|
||||
const detected = await detectInstalledVersion(identifier);
|
||||
|
||||
state.platform = detected.platform;
|
||||
state.packName = detected.packName;
|
||||
state.version = detected.version;
|
||||
|
||||
const mapping = packMappings[identifier] || {};
|
||||
|
||||
if (detected.platform === 'ftb' && detected.ftbPackId) {
|
||||
state.latestVersion = await getLatestFtbVersion(detected.ftbPackId);
|
||||
} else if (detected.platform === 'curseforge' && mapping.cfId) {
|
||||
state.latestVersion = await getLatestCurseForgeVersion(mapping.cfId);
|
||||
} else if (mapping.cfId) {
|
||||
state.platform = 'curseforge';
|
||||
state.latestVersion = await getLatestCurseForgeVersion(mapping.cfId);
|
||||
} else {
|
||||
state.latestVersion = null;
|
||||
}
|
||||
|
||||
state.status = compareVersions(state.version, state.latestVersion);
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
async function checkSingle(identifier) {
|
||||
if (!serverStates[identifier]) return;
|
||||
serverStates[identifier].loading = true;
|
||||
serverStates[identifier].error = null;
|
||||
updateCardDisplay(identifier);
|
||||
try {
|
||||
await checkServerVersion(identifier);
|
||||
} catch(e) {
|
||||
serverStates[identifier].loading = false;
|
||||
serverStates[identifier].error = e.message;
|
||||
}
|
||||
updateCardDisplay(identifier);
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
async function checkAll() {
|
||||
const btn = document.getElementById('refreshBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Checking...';
|
||||
|
||||
const grid = document.getElementById('serversGrid');
|
||||
grid.innerHTML = '<div style="color:#475569;font-family:\'Share Tech Mono\',monospace;font-size:13px;grid-column:1/-1;padding:40px;text-align:center;">Loading servers...</div>';
|
||||
|
||||
try {
|
||||
const allServersResp = await apiCall('servers');
|
||||
const servers = allServersResp.data.map(s => ({
|
||||
identifier: s.attributes.identifier,
|
||||
name: s.attributes.name,
|
||||
mcVersion: null,
|
||||
}));
|
||||
|
||||
for (const server of servers) {
|
||||
serverStates[server.identifier] = {
|
||||
loading: true, error: null, platform: 'unknown',
|
||||
packName: null, version: null, latestVersion: null, status: 'unknown',
|
||||
_serverData: server
|
||||
};
|
||||
}
|
||||
|
||||
grid.innerHTML = servers.map(s => renderCard(s, serverStates[s.identifier])).join('');
|
||||
document.getElementById('countTotal').textContent = servers.length;
|
||||
|
||||
await Promise.all(servers.map(async (server) => {
|
||||
try {
|
||||
await checkServerVersion(server.identifier);
|
||||
} catch(e) {
|
||||
serverStates[server.identifier].loading = false;
|
||||
serverStates[server.identifier].error = e.message;
|
||||
}
|
||||
updateCardDisplay(server.identifier);
|
||||
updateSummary();
|
||||
}));
|
||||
|
||||
document.getElementById('lastChecked').textContent = 'Last: ' + new Date().toLocaleTimeString();
|
||||
} catch(e) {
|
||||
grid.innerHTML = `<div style="color:#f87171;font-family:'Share Tech Mono',monospace;font-size:13px;grid-column:1/-1;padding:40px;text-align:center;">Error: ${e.message}<br><br><small style="color:#475569">Is the proxy running? Check http://38.68.14.188:[PORT]/proxy.php</small></div>`;
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '↻ Check All';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
10
docs/tasks/modpack-version-checker/proxy/config.php
Normal file
10
docs/tasks/modpack-version-checker/proxy/config.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
// config.php
|
||||
// DO NOT commit real keys. Store this file outside web root or restrict access.
|
||||
// Copy this file to /var/www/version-proxy/config.php on the Billing VPS.
|
||||
|
||||
return [
|
||||
'panel_url' => 'https://panel.firefrostgaming.com',
|
||||
'panel_key' => 'ptlc_YOUR_CLIENT_KEY', // Pterodactyl client API key (ptlc_...)
|
||||
'cf_key' => 'YOUR_CURSEFORGE_KEY' // CurseForge API key (optional, for CF version lookups)
|
||||
];
|
||||
71
docs/tasks/modpack-version-checker/proxy/proxy.php
Normal file
71
docs/tasks/modpack-version-checker/proxy/proxy.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
// proxy.php
|
||||
// Deploy to /var/www/version-proxy/ on Billing VPS (38.68.14.188)
|
||||
// Serves as CORS-safe API bridge between the dashboard HTML and:
|
||||
// - Pterodactyl Panel API (panel.firefrostgaming.com)
|
||||
// - CurseForge API (api.curseforge.com)
|
||||
|
||||
$config = require 'config.php';
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
// Allow requests from any origin (dashboard can be hosted anywhere)
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
function makeRequest($url, $headers) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
http_response_code($httpCode);
|
||||
echo json_encode(['error' => "HTTP $httpCode"]);
|
||||
exit;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
$panelHeaders = [
|
||||
"Authorization: Bearer {$config['panel_key']}",
|
||||
"Accept: application/json"
|
||||
];
|
||||
|
||||
if ($action === 'servers') {
|
||||
// List all servers
|
||||
header('Content-Type: application/json');
|
||||
echo makeRequest("{$config['panel_url']}/api/client/servers", $panelHeaders);
|
||||
|
||||
} elseif ($action === 'files') {
|
||||
// List files in server root
|
||||
header('Content-Type: application/json');
|
||||
$id = urlencode($_GET['server'] ?? '');
|
||||
echo makeRequest("{$config['panel_url']}/api/client/servers/{$id}/files/list", $panelHeaders);
|
||||
|
||||
} elseif ($action === 'read') {
|
||||
// Read a specific file from server filesystem
|
||||
// Note: Pterodactyl returns raw text here, not JSON
|
||||
header('Content-Type: text/plain');
|
||||
$id = urlencode($_GET['server'] ?? '');
|
||||
$file = urlencode($_GET['file'] ?? '');
|
||||
echo makeRequest("{$config['panel_url']}/api/client/servers/{$id}/files/contents?file={$file}", $panelHeaders);
|
||||
|
||||
} elseif ($action === 'curseforge') {
|
||||
// Get latest CurseForge mod files (API key stays server-side)
|
||||
header('Content-Type: application/json');
|
||||
$projectId = urlencode($_GET['project'] ?? '');
|
||||
$cfHeaders = [
|
||||
"x-api-key: {$config['cf_key']}",
|
||||
"Accept: application/json"
|
||||
];
|
||||
echo makeRequest("https://api.curseforge.com/v1/mods/{$projectId}/files?pageSize=1&sortField=5&sortOrder=desc", $cfHeaders);
|
||||
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid action']);
|
||||
}
|
||||
38
docs/tasks/modpack-version-checker/proxy/version-proxy.conf
Normal file
38
docs/tasks/modpack-version-checker/proxy/version-proxy.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
# /etc/nginx/sites-available/version-proxy.conf
|
||||
# Isolated Nginx block for the modpack version checker PHP proxy
|
||||
# Hosted on Billing VPS (38.68.14.188) in its own directory
|
||||
# to avoid conflicts with Paymenter (Laravel) and Mailcow routing.
|
||||
#
|
||||
# BEFORE ENABLING: verify port 8080 is free with `ss -tlnp`
|
||||
# If 8080 is taken, change to 8081 or 8090 here AND in the HTML dashboard's PROXY_URL.
|
||||
|
||||
server {
|
||||
# Custom port — avoids Paymenter/Mailcow conflicts
|
||||
# Verify with: ss -tlnp | grep 8080
|
||||
listen 8080;
|
||||
|
||||
server_name _;
|
||||
|
||||
root /var/www/version-proxy;
|
||||
index proxy.php;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /proxy.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
# PHP 8.3 socket (matches existing Billing VPS environment)
|
||||
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
|
||||
}
|
||||
|
||||
# Block access to config.php directly
|
||||
location = /config.php {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user