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:
Claude (Chronicler #47)
2026-03-29 14:10:47 +00:00
parent 6d7349cc18
commit cfa838e86a
5 changed files with 791 additions and 0 deletions

View 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. |

View 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()">&#8635; 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 &nbsp;|&nbsp; FOR CHILDREN NOT YET BORN &nbsp;💙</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>

View 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)
];

View 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']);
}

View 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;
}
}