Upload via GUI (194 Dateien)

This commit is contained in:
2026-10-07 07:32:04 +00:00
parent 3ac5a6c14d
commit d976cb4595
8 changed files with 6244 additions and 6054 deletions
+4624 -4914
View File
File diff suppressed because it is too large Load Diff
+64 -54
View File
@@ -1,54 +1,64 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.2.1",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
"main": "src/main.js",
"author": "M_Viper",
"license": "SEE LICENSE IN LICENSE",
"private": true,
"scripts": {
"start": "electron .",
"prebuild": "node scripts/write-build-config.js",
"prebuild:portable": "node scripts/write-build-config.js",
"build": "electron-builder --win dir && node scripts/build-setup.js",
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
},
"build": {
"appId": "com.aeromc.launcher",
"productName": "AeroMC",
"artifactName": "AeroMC.${ext}",
"compression": "store",
"files": [
"src/**/*",
"package.json",
"LICENSE"
],
"asarUnpack": [
"node_modules/loopback-capture/**/*",
"node_modules/node-window-manager/**/*",
"node_modules/ffmpeg-static/**/*"
],
"win": {
"target": "dir",
"icon": "src/assets/icon.ico"
}
},
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^24.0.0"
},
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
"archiver": "^7.0.1",
"ffmpeg-static": "^5.3.0",
"loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2",
"sql.js": "^1.14.2",
"undici": "^6.28.0",
"unzipper": "^0.12.5"
}
}
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.2.3",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
"main": "src/main.js",
"author": "M_Viper",
"license": "SEE LICENSE IN LICENSE",
"private": true,
"scripts": {
"start": "electron .",
"prebuild": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
"prebuild:portable": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
"build": "electron-builder --win dir && node scripts/build-setup.js && node scripts/sign-release.js setup",
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\" && node scripts/sign-release.js portable"
},
"build": {
"appId": "com.aeromc.launcher",
"productName": "AeroMC",
"artifactName": "AeroMC.${ext}",
"compression": "store",
"files": [
"src/**/*",
"package.json",
"LICENSE"
],
"asarUnpack": [
"node_modules/loopback-capture/**/*",
"node_modules/node-window-manager/**/*",
"node_modules/ffmpeg-static/**/*"
],
"win": {
"target": "dir",
"icon": "src/assets/icon.ico"
},
"electronFuses": {
"runAsNode": false,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false,
"enableEmbeddedAsarIntegrityValidation": true,
"onlyLoadAppFromAsar": true
}
},
"devDependencies": {
"electron": "^44.6.0",
"electron-builder": "^26.15.3"
},
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
"archiver": "^7.0.1",
"ffmpeg-static": "^5.3.0",
"loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2",
"sql.js": "^1.14.2",
"undici": "^6.28.0",
"unzipper": "^0.12.5"
},
"overrides": {
"brace-expansion@^2.0.0": "^2.1.7"
}
}
+588 -536
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -839,6 +839,32 @@ select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border
.erfolg-karte small{display:block;font-size:11px;color:var(--dim);line-height:1.3}
.erfolg-karte.offen{opacity:.45;filter:grayscale(1)}
/* Mod-Schalter: zwei Listen wie beim Ressourcenpaket-Bildschirm */
#modal-modschalter.popout-host .modal-body{display:flex;flex-direction:column}
.ms-instanz{font-weight:400;color:var(--dim);font-size:12px;margin-left:6px}
.ms-kopf{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.ms-kopf input{flex:1;max-width:320px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
.ms-kopf input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px rgba(64,126,232,.25)}
.ms-spalten{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:12px}
.ms-spalte{display:flex;flex-direction:column;min-height:0;border:1px solid var(--border);border-radius:8px;background:rgba(127,127,127,.06)}
.ms-titel{padding:8px 12px;font-weight:600;font-size:12.5px;border-bottom:1px solid var(--border)}
.ms-liste{flex:1 1 auto;min-height:0;overflow:auto;padding:6px}
.ms-mod{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;cursor:pointer;border:1px solid transparent}
.ms-mod:hover{background:rgba(127,127,127,.12);border-color:var(--border)}
.ms-icon{width:32px;height:32px;flex:none;border-radius:6px;display:grid;place-items:center;background:rgba(127,127,127,.15);font-size:17px;overflow:hidden}
.ms-icon img{width:100%;height:100%;object-fit:cover}
.ms-info{flex:1;min-width:0}
.ms-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ms-meta{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ms-warn{font-size:11px;color:#d9822b}
.ms-pfeil{flex:none;width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--win);color:var(--text);cursor:pointer;font-size:14px;opacity:.55}
.ms-mod:hover .ms-pfeil{opacity:1;border-color:var(--accent);color:var(--accent)}
.ms-neu{font-size:10.5px;font-weight:700;color:var(--accent);margin-left:6px}
.ms-leer{color:var(--dim);font-size:12px;padding:14px;text-align:center}
.ms-status{margin-right:auto}
.ui-animiert .ms-mod.kommt{animation:ms-rein .28s cubic-bezier(.2,.8,.2,1)}
@keyframes ms-rein{from{opacity:0;transform:translateX(var(--ms-von,12px))}}
/* Konto-Menü oben rechts (wie in MultiMC) */
.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
.konto-menu{min-width:220px}
@@ -915,3 +941,20 @@ select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border
.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-fuss{align-items:center}
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Manipulationsschutz: gesperrte Instanz (Verbotszeichen, siehe integritaet.js) */
.card.gesperrt .card-icon,.card.gesperrt .card-icon-img{filter:grayscale(1) brightness(.55)}
.card-verbot{position:absolute;left:50%;top:6px;width:60px;height:60px;margin-left:-30px;opacity:.95;pointer-events:none;z-index:2;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}
.card.gesperrt .card-name{color:#e05555}
.card.gesperrt.sel .card-name{color:#fff;background:#b02020}
.side-ico{position:relative}
.side-ico.gesperrt > :not(.side-verbot){filter:grayscale(1) brightness(.55)}
.side-ico img.side-verbot{position:absolute;left:-4px;top:-4px;width:60px;height:60px;object-fit:contain;border-radius:0;pointer-events:none}
.side-gesperrt{margin:8px 6px 2px;padding:9px 10px;border:1px solid rgba(200,0,0,.55);background:rgba(200,0,0,.10);border-radius:6px;font-size:12px}
.side-gesperrt-titel{display:flex;align-items:center;gap:6px;font-weight:700;color:#e04848;font-size:13px;margin-bottom:4px}
.side-gesperrt-titel img{width:18px;height:18px}
.side-gesperrt-text{line-height:1.35;margin:4px 0}
.side-gesperrt-text.dim{opacity:.7;font-size:11px}
.side-gesperrt-gruende{margin:4px 0 8px;padding-left:16px;max-height:110px;overflow:auto;word-break:break-word;line-height:1.35}
.side-gesperrt-gruende .dim{opacity:.7;list-style:none;margin-left:-16px}
#p-wiederherstellen{width:100%;margin:4px 0}
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXxt1cCntDQzvej4hjyVZt6Onj6Uv3hSlneJaOxxmsds=
-----END PUBLIC KEY-----
+495 -370
View File
@@ -1,370 +1,495 @@
'use strict';
/*
* updater.js – automatische Updates über die Gitea-Releases
* ---------------------------------------------------------
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
* bei Bedarf den Windows-Installer herunterladen und starten.
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
const UA = 'AeroMC-Launcher';
/*
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
*
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
* 'unknown' und gar kein automatisches Update anbieten.
*/
let cachedKind = null;
function detectInstallKind() {
if (cachedKind) return cachedKind;
cachedKind = computeInstallKind();
return cachedKind;
}
function computeInstallKind() {
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
// electron-builder – das ist das verlässlichste Signal überhaupt.
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
const exe = process.execPath || '';
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
if (/portable/i.test(path.basename(exe))) return 'portable';
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
// da ist, ist es wirklich eine Installation.
if (hasUninstaller(path.dirname(exe))) return 'setup';
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
return 'unknown';
}
function hasUninstaller(dir) {
try {
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
} catch {
return false;
}
}
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
function portableTarget() {
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
if (p) return p;
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
const exe = process.execPath || '';
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
}
function updateDir() {
return path.join(os.tmpdir(), 'aeromc-update');
}
/*
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
* von einem laufenden Update benutzt werden.
*/
function cleanupUpdateCache() {
try {
const dir = updateDir();
if (!fs.existsSync(dir)) return;
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
try {
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
fs.rmSync(p, { force: true, recursive: true });
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
}
} catch { /* egal */ }
}
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
function parseVersion(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
if (!m) return null;
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
}
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
function parsePreRelease(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
return m ? m[1].split('.') : null;
}
/*
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
*
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
*/
function comparePreRelease(a, b) {
if (!a || !b) return 0;
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i]; const y = b[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
const nx = /^\d+$/.test(x) ? Number(x) : null;
const ny = /^\d+$/.test(y) ? Number(y) : null;
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
if (nx !== null) return -1; // Zahlen zählen weniger als Text
if (ny !== null) return 1;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
// >0 wenn a neuer als b
function compareVersions(a, b) {
const x = parseVersion(a); const y = parseVersion(b);
if (!x || !y) return 0;
for (let i = 0; i < 3; i++) {
if (x[i] !== y[i]) return x[i] - y[i];
}
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
}
/*
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
* das falsche.
*/
function assetKind(name) {
const n = String(name || '');
if (!/\.exe$/i.test(n)) return null;
const portable = /portable/i.test(n);
const setup = /setup|install(er)?/i.test(n);
if (portable && setup) return null;
if (portable) return 'portable';
if (setup) return 'setup';
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
}
// passendes Windows-Artefakt im Release finden
function pickAsset(release, installKind) {
if (installKind !== 'portable' && installKind !== 'setup') return null;
const assets = release.assets || [];
return assets.find((a) => assetKind(a.name) === installKind) || null;
}
/*
* Prüft auf Updates.
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
*/
async function checkForUpdate(currentVersion) {
const installKind = detectInstallKind();
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
const list = await res.json();
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
if (!releases.length) {
return { available: false, noReleases: true, currentVersion };
}
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
const latest = releases[0];
const available = compareVersions(latest.tag_name, currentVersion) > 0;
const asset = pickAsset(latest, installKind);
return {
available,
currentVersion,
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
installKind,
notes: latest.body || '',
published: latest.published_at || latest.created_at || null,
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
url: latest.html_url,
};
}
/*
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
*/
function assertMatchingKind(fileName, installKind) {
if (installKind !== 'portable' && installKind !== 'setup') {
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
}
const kind = assetKind(fileName);
if (!kind) {
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
}
if (kind !== installKind) {
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
}
}
/*
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
* onProgress: ({ phase, loaded, total, percent })
*/
async function downloadAndInstall(asset, onProgress) {
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
const installKind = detectInstallKind();
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
// sonst kontrolliert sich die Angabe nur selbst.
assertMatchingKind(asset.name, installKind);
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
const target = installKind === 'portable' ? portableTarget() : '';
if (installKind === 'portable' && !target) {
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
}
const dir = updateDir();
fs.mkdirSync(dir, { recursive: true });
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
try {
fs.rmSync(dest, { force: true });
} catch {
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
const parsed = path.parse(dest);
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
}
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
const total = Number(res.headers.get('content-length')) || asset.size || 0;
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
const out = fs.createWriteStream(dest);
const hash = crypto.createHash('sha256');
let loaded = 0;
let lastReport = 0;
try {
for await (const chunk of res.body) {
const buf = Buffer.from(chunk);
hash.update(buf);
loaded += buf.length;
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
// sonst sammeln sich über den ganzen Download hunderte davon an
if (!out.write(buf)) await new Promise((resolve, reject) => {
const onDrain = () => { out.off('error', onError); resolve(); };
const onError = (e) => { out.off('drain', onDrain); reject(e); };
out.once('drain', onDrain);
out.once('error', onError);
});
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
if (onProgress && Date.now() - lastReport >= 250) {
lastReport = Date.now();
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
}
}
await new Promise((resolve, reject) => {
out.once('error', reject);
out.end((err) => (err ? reject(err) : resolve()));
});
} catch (err) {
out.destroy();
fs.rmSync(dest, { force: true });
throw err;
}
if (total && loaded !== total) {
fs.rmSync(dest, { force: true });
throw new Error('Download unvollständig.');
}
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
}
/*
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
*/
const SWAP_SCRIPT = `@echo off
setlocal
set "SRC=%~1"
set "DST=%~2"
set "LOG=%~dp0update.log"
set TRIES=0
:loop
ping -n 2 127.0.0.1 >nul
copy /y "%SRC%" "%DST%" >nul 2>&1
if not errorlevel 1 goto done
set /a TRIES+=1
if %TRIES% GEQ 60 goto fail
goto loop
:done
del /f /q "%SRC%" >nul 2>&1
start "" "%DST%"
exit /b 0
:fail
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
exit /b 1
`;
/*
* Wendet das geladene Update an.
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
* Der Launcher muss sich danach beenden.
*/
function applyUpdate(file) {
const installKind = detectInstallKind();
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
// wirklich zu dieser Paketart?
assertMatchingKind(path.basename(file.path || ''), installKind);
const kind = installKind;
if (kind !== 'portable') {
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
child.unref();
return { started: 'installer' };
}
const target = file.target || portableTarget();
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
const script = path.join(updateDir(), 'aeromc-swap.cmd');
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
const child = spawn(process.env.COMSPEC || 'cmd.exe',
['/c', 'start', '', '/min', script, file.path, target],
{ detached: true, stdio: 'ignore', windowsHide: true });
child.unref();
return { started: 'swap', target };
}
// Rückwärtskompatibel: nur für die Setup-Variante
function runInstaller(installerPath) {
return applyUpdate({ path: installerPath, kind: 'setup' });
}
module.exports = {
checkForUpdate,
downloadAndInstall,
applyUpdate,
runInstaller,
cleanupUpdateCache,
compareVersions,
parseVersion,
detectInstallKind,
assetKind,
portableTarget,
};
'use strict';
/*
* updater.js – automatische Updates über die Gitea-Releases
* ---------------------------------------------------------
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
* bei Bedarf den Windows-Installer herunterladen und starten.
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
const UA = 'AeroMC-Launcher';
// Downloads nur von hier – eine Release-Datei mit fremder Adresse wird abgelehnt
const ERLAUBTER_HOST = 'git.viper.ipv64.net';
// Die Signaturen liegen nicht im Gitea-Release, sondern auf der WordPress-Seite
// (Plugin aeromc-friends, includes/signaturen.php). Hochgeladen werden sie beim
// Bauen automatisch von scripts/sign-release.js.
const SIGNATUR_API = 'https://viper-network.de/wp-json/aeromc/v1/update-signatur';
/*
* Sicherheit: Jede Update-Datei muss mit dem privaten Release-Schlüssel signiert
* sein (Ed25519). Der liegt nur auf dem Rechner, auf dem gebaut wird – nie auf
* dem Server. Wer den Gitea-Server oder das Konto übernimmt, kann deshalb zwar
* Dateien hochladen, aber keine gültige Signatur erzeugen; der Launcher
* installiert so etwas nicht. Signiert wird beim Bauen von
* scripts/sign-release.js; die Signatur liegt auf einem zweiten Server
* (SIGNATUR_API) – woher sie kommt, ist für die Sicherheit aber egal, fälschen
* kann sie ohne den Schlüssel niemand.
*
* Unterschrieben wird nicht nur der Inhalt (SHA-256), sondern auch Dateiname
* und Version. So lässt sich weder eine Portable- als Setup-Datei ausgeben noch
* eine alte, signierte Fassung als "neues" Update unterschieben.
*/
const SIGNATUR_FORMAT = 'AeroMC-Update/1';
let oeffentlicherSchluessel = null;
function releaseSchluessel() {
if (!oeffentlicherSchluessel) {
const pem = fs.readFileSync(path.join(__dirname, 'update-schluessel.pem'), 'utf8');
oeffentlicherSchluessel = crypto.createPublicKey(pem);
}
return oeffentlicherSchluessel;
}
function normVersion(v) {
return String(v || '').trim().replace(/^v/i, '');
}
// Der Text, der unterschrieben wird (identisch in scripts/sign-release.js)
function signaturText(datei, version, sha256) {
return Buffer.from([SIGNATUR_FORMAT, datei, normVersion(version), String(sha256).toLowerCase()].join('\n'), 'utf8');
}
// Prüft eine Signatur (Inhalt der .sig-Datei) gegen Name, Version und Hash
function pruefeSignatur(sig, datei, version, sha256) {
if (!sig || sig.format !== SIGNATUR_FORMAT) throw new Error('Signaturdatei hat ein unbekanntes Format.');
if (sig.datei !== datei) throw new Error('Signatur gehört zu einer anderen Datei.');
if (normVersion(sig.version) !== normVersion(version)) throw new Error('Signatur gehört zu einer anderen Version.');
if (String(sig.sha256 || '').toLowerCase() !== String(sha256).toLowerCase()) {
throw new Error('Die heruntergeladene Datei passt nicht zur Signatur – Update abgebrochen.');
}
const ok = crypto.verify(null, signaturText(datei, version, sha256),
releaseSchluessel(), Buffer.from(String(sig.signatur || ''), 'base64'));
if (!ok) throw new Error('Ungültige Signatur – die Datei stammt nicht vom AeroMC-Entwickler. Update abgebrochen.');
}
function erlaubteAdresse(adresse) {
try {
const u = new URL(String(adresse || ''));
return u.protocol === 'https:' && u.hostname.toLowerCase() === ERLAUBTER_HOST;
} catch { return false; }
}
/*
* Signatur einer Release-Datei vom WordPress-Server holen.
* -> Signatur-Objekt, null wenn es (noch) keine gibt; wirft bei Netzwerkfehlern.
*/
async function holeSignatur(datei, version) {
const adresse = `${SIGNATUR_API}?datei=${encodeURIComponent(datei)}&version=${encodeURIComponent(normVersion(version))}`;
const res = await fetch(adresse, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (res.status === 404) return null;
if (!res.ok) throw new Error('Signatur-Server antwortet mit HTTP ' + res.status);
const text = await res.text();
if (text.length > 16384) throw new Error('Signatur ist ungültig.');
const sig = JSON.parse(text);
if (!sig || typeof sig !== 'object' || sig.datei !== datei || normVersion(sig.version) !== normVersion(version)) {
throw new Error('Signatur passt nicht zu diesem Update.');
}
return { format: sig.format, datei: sig.datei, version: sig.version, sha256: sig.sha256, signatur: sig.signatur };
}
function sha256Datei(datei) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
});
}
/*
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
*
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
* 'unknown' und gar kein automatisches Update anbieten.
*/
let cachedKind = null;
function detectInstallKind() {
if (cachedKind) return cachedKind;
cachedKind = computeInstallKind();
return cachedKind;
}
function computeInstallKind() {
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
// electron-builder – das ist das verlässlichste Signal überhaupt.
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
const exe = process.execPath || '';
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
if (/portable/i.test(path.basename(exe))) return 'portable';
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
// da ist, ist es wirklich eine Installation.
if (hasUninstaller(path.dirname(exe))) return 'setup';
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
return 'unknown';
}
function hasUninstaller(dir) {
try {
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
} catch {
return false;
}
}
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
function portableTarget() {
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
if (p) return p;
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
const exe = process.execPath || '';
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
}
function updateDir() {
return path.join(os.tmpdir(), 'aeromc-update');
}
/*
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
* von einem laufenden Update benutzt werden.
*/
function cleanupUpdateCache() {
try {
const dir = updateDir();
if (!fs.existsSync(dir)) return;
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
try {
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
fs.rmSync(p, { force: true, recursive: true });
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
}
} catch { /* egal */ }
}
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
function parseVersion(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
if (!m) return null;
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
}
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
function parsePreRelease(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
return m ? m[1].split('.') : null;
}
/*
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
*
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
*/
function comparePreRelease(a, b) {
if (!a || !b) return 0;
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i]; const y = b[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
const nx = /^\d+$/.test(x) ? Number(x) : null;
const ny = /^\d+$/.test(y) ? Number(y) : null;
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
if (nx !== null) return -1; // Zahlen zählen weniger als Text
if (ny !== null) return 1;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
// >0 wenn a neuer als b
function compareVersions(a, b) {
const x = parseVersion(a); const y = parseVersion(b);
if (!x || !y) return 0;
for (let i = 0; i < 3; i++) {
if (x[i] !== y[i]) return x[i] - y[i];
}
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
}
/*
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
* das falsche.
*/
function assetKind(name) {
const n = String(name || '');
if (!/\.exe$/i.test(n)) return null;
const portable = /portable/i.test(n);
const setup = /setup|install(er)?/i.test(n);
if (portable && setup) return null;
if (portable) return 'portable';
if (setup) return 'setup';
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
}
// passendes Windows-Artefakt im Release finden
function pickAsset(release, installKind) {
if (installKind !== 'portable' && installKind !== 'setup') return null;
const assets = release.assets || [];
return assets.find((a) => assetKind(a.name) === installKind) || null;
}
/*
* Prüft auf Updates.
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
*/
async function checkForUpdate(currentVersion) {
const installKind = detectInstallKind();
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
const list = await res.json();
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
if (!releases.length) {
return { available: false, noReleases: true, currentVersion };
}
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
const latest = releases[0];
const available = compareVersions(latest.tag_name, currentVersion) > 0;
const latestVersion = normVersion(latest.tag_name);
let asset = pickAsset(latest, installKind);
// ohne Signatur oder von fremder Adresse wird nichts automatisch installiert
let unsigniert = false;
let signaturHinweis = '';
let sig = null;
if (asset && !erlaubteAdresse(asset.browser_download_url)) {
asset = null;
unsigniert = true;
} else if (asset && available) {
try {
sig = await holeSignatur(asset.name, latestVersion);
} catch (err) {
signaturHinweis = 'Die Signatur des Updates ließ sich gerade nicht abrufen (' + (err.message || err) + '). Bitte später erneut versuchen.';
}
if (!sig) {
asset = null;
unsigniert = !signaturHinweis;
}
}
return {
available,
currentVersion,
latestVersion,
installKind,
unsigniert,
signaturHinweis,
notes: latest.body || '',
published: latest.published_at || latest.created_at || null,
asset: asset ? {
name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind,
version: latestVersion, signatur: sig,
} : null,
url: latest.html_url,
};
}
/*
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
*/
function assertMatchingKind(fileName, installKind) {
if (installKind !== 'portable' && installKind !== 'setup') {
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
}
const kind = assetKind(fileName);
if (!kind) {
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
}
if (kind !== installKind) {
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
}
}
/*
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
* onProgress: ({ phase, loaded, total, percent })
*/
async function downloadAndInstall(asset, onProgress) {
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
const installKind = detectInstallKind();
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
// sonst kontrolliert sich die Angabe nur selbst.
assertMatchingKind(asset.name, installKind);
if (!erlaubteAdresse(asset.url)) {
throw new Error('Das Update kommt nicht vom AeroMC-Server – abgebrochen.');
}
// ohne Signatur wird gar nicht erst heruntergeladen
const signatur = asset.signatur;
if (!signatur || signatur.datei !== asset.name || normVersion(signatur.version) !== normVersion(asset.version)) {
throw new Error('Signatur passt nicht zu diesem Update – abgebrochen.');
}
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
const target = installKind === 'portable' ? portableTarget() : '';
if (installKind === 'portable' && !target) {
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
}
const dir = updateDir();
fs.mkdirSync(dir, { recursive: true });
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
try {
fs.rmSync(dest, { force: true });
} catch {
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
const parsed = path.parse(dest);
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
}
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
const total = Number(res.headers.get('content-length')) || asset.size || 0;
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
const out = fs.createWriteStream(dest);
const hash = crypto.createHash('sha256');
let loaded = 0;
let lastReport = 0;
try {
for await (const chunk of res.body) {
const buf = Buffer.from(chunk);
hash.update(buf);
loaded += buf.length;
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
// sonst sammeln sich über den ganzen Download hunderte davon an
if (!out.write(buf)) await new Promise((resolve, reject) => {
const onDrain = () => { out.off('error', onError); resolve(); };
const onError = (e) => { out.off('drain', onDrain); reject(e); };
out.once('drain', onDrain);
out.once('error', onError);
});
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
if (onProgress && Date.now() - lastReport >= 250) {
lastReport = Date.now();
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
}
}
await new Promise((resolve, reject) => {
out.once('error', reject);
out.end((err) => (err ? reject(err) : resolve()));
});
} catch (err) {
out.destroy();
fs.rmSync(dest, { force: true });
throw err;
}
if (total && loaded !== total) {
fs.rmSync(dest, { force: true });
throw new Error('Download unvollständig.');
}
const sha256 = hash.digest('hex');
try {
pruefeSignatur(signatur, asset.name, asset.version, sha256);
} catch (err) {
fs.rmSync(dest, { force: true });
throw err;
}
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
return { path: dest, bytes: loaded, sha256, signatur, name: asset.name, version: asset.version, kind: installKind, target };
}
/*
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
*/
const SWAP_SCRIPT = `@echo off
setlocal
set "SRC=%~1"
set "DST=%~2"
set "LOG=%~dp0update.log"
set TRIES=0
:loop
ping -n 2 127.0.0.1 >nul
copy /y "%SRC%" "%DST%" >nul 2>&1
if not errorlevel 1 goto done
set /a TRIES+=1
if %TRIES% GEQ 60 goto fail
goto loop
:done
del /f /q "%SRC%" >nul 2>&1
start "" "%DST%"
exit /b 0
:fail
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
exit /b 1
`;
/*
* Wendet das geladene Update an.
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
* Der Launcher muss sich danach beenden.
*/
async function applyUpdate(file) {
const installKind = detectInstallKind();
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
// wirklich zu dieser Paketart – und ist sie noch genau die signierte? (Zwischen
// Download und Start könnte sie im Temp-Ordner ausgetauscht worden sein.)
assertMatchingKind(path.basename(file.path || ''), installKind);
pruefeSignatur(file.signatur, file.name, file.version, await sha256Datei(file.path));
const kind = installKind;
if (kind !== 'portable') {
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
child.unref();
return { started: 'installer' };
}
const target = file.target || portableTarget();
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
const script = path.join(updateDir(), 'aeromc-swap.cmd');
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
const child = spawn(process.env.COMSPEC || 'cmd.exe',
['/c', 'start', '', '/min', script, file.path, target],
{ detached: true, stdio: 'ignore', windowsHide: true });
child.unref();
return { started: 'swap', target };
}
module.exports = {
checkForUpdate,
downloadAndInstall,
applyUpdate,
pruefeSignatur,
signaturText,
holeSignatur,
SIGNATUR_API,
cleanupUpdateCache,
compareVersions,
parseVersion,
detectInstallKind,
assetKind,
portableTarget,
};
+181 -180
View File
@@ -1,180 +1,181 @@
<?php
/**
* Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.5.1
* Author: M_Viper
*/
if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei
}
define('AEROMC_FRIENDS_VERSION', '1.5.1');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
// (ältere Launcher-Versionen funktionieren weiter). Sobald alle Nutzer
// aktualisiert haben, in der wp-config.php auf true setzen:
// define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
}
/**
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
*/
function aeromc_friends_install() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
$sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE;
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
// in includes/routes.php) - vermeidet doppelte Zeilen durch
// unterschiedliche Schreibweisen derselben UUID
$sql_presence = "CREATE TABLE $presence_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
instance_name varchar(128) DEFAULT NULL,
server_label varchar(128) DEFAULT NULL,
mc_version varchar(32) DEFAULT NULL,
last_seen datetime NOT NULL,
PRIMARY KEY (uuid)
) $charset_collate;";
dbDelta($sql_presence);
$sql_friends = "CREATE TABLE $friends_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
uuid_a varchar(32) NOT NULL,
uuid_b varchar(32) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY uuid_a (uuid_a),
KEY uuid_b (uuid_b)
) $charset_collate;";
dbDelta($sql_friends);
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
$sql_invites = "CREATE TABLE $invites_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
from_uuid varchar(32) NOT NULL,
to_uuid varchar(32) NOT NULL,
server varchar(128) NOT NULL,
mc_version varchar(32) DEFAULT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY to_uuid (to_uuid)
) $charset_collate;";
dbDelta($sql_invites);
// Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
$sql_sessions = "CREATE TABLE $sessions_table (
token_hash char(64) NOT NULL,
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
created_at datetime NOT NULL,
last_used datetime NOT NULL,
PRIMARY KEY (token_hash),
KEY uuid (uuid)
) $charset_collate;";
dbDelta($sql_sessions);
// per Code geteilte Instanzen (Mod-Liste, keine Dateien)
$sql_shares = "CREATE TABLE $shares_table (
code varchar(9) NOT NULL,
uuid varchar(32) NOT NULL,
manifest longtext NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (code),
KEY uuid (uuid)
) $charset_collate;";
dbDelta($sql_shares);
// Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
$messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE;
$sql_messages = "CREATE TABLE $messages_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
from_uuid varchar(32) NOT NULL,
to_uuid varchar(32) NOT NULL,
text varchar(400) NOT NULL,
image_url varchar(255) DEFAULT NULL,
created_at datetime NOT NULL,
read_at datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY to_uuid (to_uuid),
KEY from_uuid (from_uuid)
) $charset_collate;";
dbDelta($sql_messages);
// Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
$reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE;
$sql_reports = "CREATE TABLE $reports_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
uuid varchar(32) DEFAULT NULL,
username varchar(32) DEFAULT NULL,
launcher_version varchar(32) DEFAULT NULL,
mc_version varchar(32) DEFAULT NULL,
loader varchar(32) DEFAULT NULL,
loader_version varchar(64) DEFAULT NULL,
system varchar(128) DEFAULT NULL,
funde text,
mods longtext,
bericht longtext,
kommentar text,
status varchar(20) NOT NULL DEFAULT 'neu',
PRIMARY KEY (id),
KEY status (status)
) $charset_collate;";
dbDelta($sql_reports);
// eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
$capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
$sql_capes = "CREATE TABLE $capes_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
datei varchar(64) NOT NULL DEFAULT '',
breite smallint(5) unsigned NOT NULL DEFAULT 0,
hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
hash varchar(40) NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'aktiv',
updated_at datetime NOT NULL,
PRIMARY KEY (uuid),
KEY status (status)
) $charset_collate;";
dbDelta($sql_capes);
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
}
register_activation_hook(__FILE__, 'aeromc_friends_install');
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
add_action('plugins_loaded', function () {
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
aeromc_friends_install();
}
});
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
if (is_admin()) {
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
}
<?php
/**
* Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.5.5
* Author: M_Viper
*/
if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei
}
define('AEROMC_FRIENDS_VERSION', '1.5.5');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
// Anmeldung ist Pflicht (seit Plugin 1.5.5): Jede Anfrage "als Spieler X" braucht
// das Sitzungs-Token aus der Mojang-Anmeldung (Launcher ab 1.1.5). Launcher vor
// 1.1.5 können das Freundesystem damit nicht mehr nutzen. Nur falls doch noch
// nötig, lässt sich der alte Übergangsmodus in der wp-config.php zurückholen:
// define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
}
/**
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
*/
function aeromc_friends_install() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
$sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE;
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
// in includes/routes.php) - vermeidet doppelte Zeilen durch
// unterschiedliche Schreibweisen derselben UUID
$sql_presence = "CREATE TABLE $presence_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
instance_name varchar(128) DEFAULT NULL,
server_label varchar(128) DEFAULT NULL,
mc_version varchar(32) DEFAULT NULL,
last_seen datetime NOT NULL,
PRIMARY KEY (uuid)
) $charset_collate;";
dbDelta($sql_presence);
$sql_friends = "CREATE TABLE $friends_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
uuid_a varchar(32) NOT NULL,
uuid_b varchar(32) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY uuid_a (uuid_a),
KEY uuid_b (uuid_b)
) $charset_collate;";
dbDelta($sql_friends);
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
$sql_invites = "CREATE TABLE $invites_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
from_uuid varchar(32) NOT NULL,
to_uuid varchar(32) NOT NULL,
server varchar(128) NOT NULL,
mc_version varchar(32) DEFAULT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY to_uuid (to_uuid)
) $charset_collate;";
dbDelta($sql_invites);
// Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
$sql_sessions = "CREATE TABLE $sessions_table (
token_hash char(64) NOT NULL,
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
created_at datetime NOT NULL,
last_used datetime NOT NULL,
PRIMARY KEY (token_hash),
KEY uuid (uuid)
) $charset_collate;";
dbDelta($sql_sessions);
// per Code geteilte Instanzen (Mod-Liste, keine Dateien)
$sql_shares = "CREATE TABLE $shares_table (
code varchar(9) NOT NULL,
uuid varchar(32) NOT NULL,
manifest longtext NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (code),
KEY uuid (uuid)
) $charset_collate;";
dbDelta($sql_shares);
// Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
$messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE;
$sql_messages = "CREATE TABLE $messages_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
from_uuid varchar(32) NOT NULL,
to_uuid varchar(32) NOT NULL,
text varchar(400) NOT NULL,
image_url varchar(255) DEFAULT NULL,
created_at datetime NOT NULL,
read_at datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY to_uuid (to_uuid),
KEY from_uuid (from_uuid)
) $charset_collate;";
dbDelta($sql_messages);
// Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
$reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE;
$sql_reports = "CREATE TABLE $reports_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
uuid varchar(32) DEFAULT NULL,
username varchar(32) DEFAULT NULL,
launcher_version varchar(32) DEFAULT NULL,
mc_version varchar(32) DEFAULT NULL,
loader varchar(32) DEFAULT NULL,
loader_version varchar(64) DEFAULT NULL,
system varchar(128) DEFAULT NULL,
funde text,
mods longtext,
bericht longtext,
kommentar text,
status varchar(20) NOT NULL DEFAULT 'neu',
PRIMARY KEY (id),
KEY status (status)
) $charset_collate;";
dbDelta($sql_reports);
// eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
$capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
$sql_capes = "CREATE TABLE $capes_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
datei varchar(64) NOT NULL DEFAULT '',
breite smallint(5) unsigned NOT NULL DEFAULT 0,
hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
hash varchar(40) NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'aktiv',
updated_at datetime NOT NULL,
PRIMARY KEY (uuid),
KEY status (status)
) $charset_collate;";
dbDelta($sql_capes);
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
}
register_activation_hook(__FILE__, 'aeromc_friends_install');
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
add_action('plugins_loaded', function () {
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
aeromc_friends_install();
}
});
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
require_once plugin_dir_path(__FILE__) . 'includes/signaturen.php';
if (is_admin()) {
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
}
@@ -0,0 +1,246 @@
<?php
/**
* Update-Signaturen für den AeroMC Launcher
* -----------------------------------------
* Der Launcher installiert ein Update nur, wenn es mit dem Release-Schlüssel des
* Entwicklers unterschrieben ist (Ed25519). Die Unterschriften liegen hier statt
* im Gitea-Release; die EXE selbst kommt weiter aus Gitea.
*
* GET /wp-json/aeromc/v1/update-signatur?datei=AeroMC-Setup.exe&version=1.2.2
* öffentlich – liefert die Unterschrift (sie ist nicht geheim)
* POST /wp-json/aeromc/v1/update-signatur
* nur Administratoren (Anwendungspasswort) – vom Build-Skript
* scripts/sign-release.js automatisch aufgerufen
* GET /wp-json/aeromc/v1/update-signatur/zugang
* prüft beim Einrichten, ob der Zugang stimmt, und sagt sonst, woran es scheitert
*
* Wichtig: Der geheime Schlüssel liegt NIE auf diesem Server. Hier wird nur
* mit dem öffentlichen Schlüssel geprüft, dass eine hochgeladene Unterschrift
* echt ist – selbst ein übernommener Admin-Zugang kann keine gültige erzeugen.
*/
if (!defined('ABSPATH')) {
exit;
}
define('AEROMC_SIG_OPTION', 'aeromc_update_signaturen');
define('AEROMC_SIG_FORMAT', 'AeroMC-Update/1');
// öffentlicher Release-Schlüssel (identisch mit src/update-schluessel.pem im Launcher)
define('AEROMC_SIG_SCHLUESSEL', 'MCowBQYDK2VwAyEAXxt1cCntDQzvej4hjyVZt6Onj6Uv3hSlneJaOxxmsds=');
define('AEROMC_SIG_DATEIEN', ['AeroMC-Setup.exe', 'AeroMC-Portable.exe']);
define('AEROMC_SIG_MAX', 40); // so viele Einträge werden aufgehoben (älteste fliegen raus)
add_action('rest_api_init', function () {
register_rest_route('aeromc/v1', '/update-signatur', [
[
'methods' => 'GET',
'callback' => 'aeromc_sig_route_get',
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => 'aeromc_sig_route_post',
'permission_callback' => 'aeromc_sig_ist_admin',
],
]);
// Zugangsprüfung mit Diagnose: sagt dem Build-Skript genau, woran eine
// Anmeldung scheitert (Header kommt nicht an, falsches Passwort, Rolle fehlt …)
register_rest_route('aeromc/v1', '/update-signatur/zugang', [
'methods' => 'GET',
'callback' => 'aeromc_sig_route_zugang',
'permission_callback' => '__return_true',
]);
});
/*
* Eigene Rolle "AeroMC Release": darf NUR Signaturen hochladen (sonst nichts
* außer dem eigenen Profil). Für das Build-Skript gedacht – so muss kein
* Administrator-Zugang auf dem Entwickler-PC liegen.
*/
define('AEROMC_SIG_RECHT', 'aeromc_signaturen_hochladen');
add_action('init', function () {
if (!get_role('aeromc_release')) {
add_role('aeromc_release', 'AeroMC Release', ['read' => true, AEROMC_SIG_RECHT => true]);
}
});
function aeromc_sig_route_zugang() {
$authorization = !empty($_SERVER['HTTP_AUTHORIZATION']) || !empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])
|| !empty($_SERVER['PHP_AUTH_USER']);
$eigener = !empty($_SERVER['HTTP_X_AEROMC_AUTH']);
$grund = '';
$user = is_user_logged_in() ? wp_get_current_user() : null;
if (!$user && $eigener) {
$roh = base64_decode((string) $_SERVER['HTTP_X_AEROMC_AUTH'], true);
if ($roh && strpos($roh, ':') !== false) {
list($benutzer, $passwort) = explode(':', $roh, 2);
$r = wp_authenticate_application_password(null, $benutzer, $passwort);
if ($r instanceof WP_User) {
$user = $r;
wp_set_current_user($r->ID);
} elseif (is_wp_error($r)) {
$grund = $r->get_error_code();
} else {
$grund = 'nicht_geprueft';
}
} else {
$grund = 'kopf_ungueltig';
}
}
$darf = $user && (user_can($user, AEROMC_SIG_RECHT) || user_can($user, 'manage_options'));
return new WP_REST_Response([
'ok' => (bool) $darf,
'version' => AEROMC_FRIENDS_VERSION,
'authorization_kommt_an' => $authorization,
'eigener_kopf_kommt_an' => $eigener,
'anwendungspasswoerter' => function_exists('wp_is_application_passwords_available') && wp_is_application_passwords_available(),
'benutzer' => $user ? $user->user_login : null,
'darf_hochladen' => (bool) $darf,
'grund' => $grund,
], $darf ? 200 : 401);
}
function aeromc_sig_ist_admin() {
aeromc_sig_anmelden_ueber_kopf();
return current_user_can(AEROMC_SIG_RECHT) || current_user_can('manage_options');
}
/*
* Manche Server (Apache mit FastCGI/PHP-FPM, Proxys) geben den Header
* "Authorization" nicht an PHP weiter – die normale Anmeldung per
* Anwendungspasswort kommt dann nie an. Das Build-Skript schickt dieselben
* Daten deshalb zusätzlich als "X-AeroMC-Auth: base64(benutzer:passwort)".
* Geprüft wird mit der WordPress-eigenen Funktion für Anwendungspasswörter –
* das normale Login-Passwort wird hier NICHT angenommen.
*/
function aeromc_sig_anmelden_ueber_kopf() {
if (is_user_logged_in()) {
return;
}
$kopf = isset($_SERVER['HTTP_X_AEROMC_AUTH']) ? (string) $_SERVER['HTTP_X_AEROMC_AUTH'] : '';
$roh = $kopf !== '' ? base64_decode($kopf, true) : false;
if (!$roh || strpos($roh, ':') === false) {
return;
}
list($benutzer, $passwort) = explode(':', $roh, 2);
$user = wp_authenticate_application_password(null, $benutzer, $passwort);
if ($user instanceof WP_User) {
wp_set_current_user($user->ID);
}
}
function aeromc_sig_version_norm($v) {
return preg_replace('/^v/i', '', trim((string) $v));
}
function aeromc_sig_schluessel_von($datei, $version) {
return $datei . '|' . aeromc_sig_version_norm($version);
}
function aeromc_sig_alle() {
$alle = get_option(AEROMC_SIG_OPTION, []);
return is_array($alle) ? $alle : [];
}
function aeromc_sig_route_get($request) {
$datei = (string) $request->get_param('datei');
$version = aeromc_sig_version_norm($request->get_param('version'));
$alle = aeromc_sig_alle();
$eintrag = $alle[aeromc_sig_schluessel_von($datei, $version)] ?? null;
if (!$eintrag) {
return new WP_REST_Response(['ok' => false, 'message' => 'Keine Signatur für diese Datei/Version.'], 404);
}
unset($eintrag['hochgeladen']);
$antwort = new WP_REST_Response($eintrag, 200);
$antwort->header('Cache-Control', 'no-store');
return $antwort;
}
/**
* Prüft Aufbau und – sofern PHP libsodium mitbringt – die Unterschrift selbst.
* Gibt null zurück, wenn alles stimmt, sonst die Fehlermeldung.
*/
function aeromc_sig_pruefe(array $s) {
if (($s['format'] ?? '') !== AEROMC_SIG_FORMAT) return 'Unbekanntes Format.';
if (!in_array($s['datei'] ?? '', AEROMC_SIG_DATEIEN, true)) return 'Unbekannter Dateiname.';
if (!preg_match('/^\d+\.\d+\.\d+(-[0-9A-Za-z.\-]+)?$/', aeromc_sig_version_norm($s['version'] ?? ''))) return 'Ungültige Version.';
if (!preg_match('/^[0-9a-f]{64}$/', (string) ($s['sha256'] ?? ''))) return 'Ungültige Prüfsumme.';
$unterschrift = base64_decode((string) ($s['signatur'] ?? ''), true);
if ($unterschrift === false || strlen($unterschrift) !== 64) return 'Ungültige Signatur.';
if (function_exists('sodium_crypto_sign_verify_detached')) {
$der = base64_decode(AEROMC_SIG_SCHLUESSEL);
$oeffentlich = substr($der, -32); // SPKI-Hülle (12 Byte) + 32 Byte Schlüssel
$text = implode("\n", [AEROMC_SIG_FORMAT, $s['datei'], aeromc_sig_version_norm($s['version']), $s['sha256']]);
if (!sodium_crypto_sign_verify_detached($unterschrift, $text, $oeffentlich)) {
return 'Signatur passt nicht zum Release-Schlüssel.';
}
}
return null;
}
function aeromc_sig_route_post($request) {
$s = $request->get_json_params();
if (!is_array($s)) {
return new WP_REST_Response(['ok' => false, 'message' => 'Kein JSON erhalten.'], 400);
}
$s = [
'format' => (string) ($s['format'] ?? ''),
'datei' => (string) ($s['datei'] ?? ''),
'version' => aeromc_sig_version_norm($s['version'] ?? ''),
'sha256' => strtolower((string) ($s['sha256'] ?? '')),
'signatur' => (string) ($s['signatur'] ?? ''),
];
$fehler = aeromc_sig_pruefe($s);
if ($fehler) {
return new WP_REST_Response(['ok' => false, 'message' => $fehler], 400);
}
$alle = aeromc_sig_alle();
$s['hochgeladen'] = current_time('mysql');
$alle[aeromc_sig_schluessel_von($s['datei'], $s['version'])] = $s;
// nur die neuesten Einträge behalten
uasort($alle, function ($a, $b) { return strcmp($b['hochgeladen'], $a['hochgeladen']); });
$alle = array_slice($alle, 0, AEROMC_SIG_MAX, true);
update_option(AEROMC_SIG_OPTION, $alle, false);
return ['ok' => true, 'datei' => $s['datei'], 'version' => $s['version'],
'geprueft' => function_exists('sodium_crypto_sign_verify_detached')];
}
// Übersicht im Admin-Menü (unter "AeroMC Fehler")
add_action('admin_menu', function () {
add_submenu_page('aeromc-reports', 'AeroMC Update-Signaturen', 'Update-Signaturen', 'manage_options',
'aeromc-signaturen', 'aeromc_sig_admin_seite');
}, 20);
function aeromc_sig_admin_seite() {
if (!current_user_can('manage_options')) {
return;
}
if (isset($_POST['aeromc_sig_loeschen']) && check_admin_referer('aeromc_signaturen')) {
$alle = aeromc_sig_alle();
unset($alle[(string) wp_unslash($_POST['aeromc_sig_loeschen'])]);
update_option(AEROMC_SIG_OPTION, $alle, false);
echo '<div class="notice notice-success"><p>Signatur entfernt.</p></div>';
}
echo '<div class="wrap"><h1>AeroMC Update-Signaturen</h1>';
echo '<p>Diese Unterschriften lädt das Build-Skript automatisch hoch. Der Launcher installiert ein Update nur, '
. 'wenn hier eine passende, gültige Signatur liegt. Sie sind nicht geheim – der geheime Schlüssel bleibt auf dem Entwickler-PC.</p>';
if (!function_exists('sodium_crypto_sign_verify_detached')) {
echo '<div class="notice notice-warning"><p>PHP hat kein libsodium – hochgeladene Signaturen werden hier nicht gegengeprüft '
. '(der Launcher prüft sie trotzdem selbst).</p></div>';
}
$alle = aeromc_sig_alle();
if (!$alle) {
echo '<p><em>Noch keine Signaturen vorhanden.</em></p></div>';
return;
}
echo '<table class="widefat striped"><thead><tr><th>Datei</th><th>Version</th><th>SHA-256</th><th>Hochgeladen</th><th></th></tr></thead><tbody>';
foreach ($alle as $schl => $s) {
echo '<tr><td>' . esc_html($s['datei']) . '</td><td>' . esc_html($s['version']) . '</td>'
. '<td><code>' . esc_html(substr($s['sha256'], 0, 16)) . '…</code></td><td>' . esc_html($s['hochgeladen']) . '</td><td>'
. '<form method="post" onsubmit="return confirm(\'Signatur wirklich entfernen? Dann bietet der Launcher dieses Update nicht mehr an.\');">';
wp_nonce_field('aeromc_signaturen');
echo '<input type="hidden" name="aeromc_sig_loeschen" value="' . esc_attr($schl) . '" />'
. '<button class="button button-link-delete">Entfernen</button></form></td></tr>';
}
echo '</tbody></table></div>';
}