Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
729fed2
H4: conf_update.sh Phase-3-Pattern + theme-substring-bug fix
wowa1990 May 8, 2026
092edf3
H7: validate Spotify-API pagination + LRU cache eviction
wowa1990 May 8, 2026
fab281d
H3: cache GitHub-API calls in index.php with 60min TTL
wowa1990 May 8, 2026
f82b734
robustness: cache header.php exec()s with 5s TTL (R3-B-1)
wowa1990 May 8, 2026
91e6062
fix(admin): centralize mupiboxconfig.json writes with flock (B8)
wowa1990 May 15, 2026
72b6b58
fix(hat): VREG attribute typo so charge-voltage-limit reads correctly…
wowa1990 May 16, 2026
5fffce8
fix(hat): battery_soc returns "0%" below v_0 instead of empty string
wowa1990 May 16, 2026
44a5926
perf: parallelise paginated Spotify API fetches (concatMap→mergeMap(8))
wowa1990 May 8, 2026
29b0e43
perf: artist click fetches only that artist's albums, not whole category
wowa1990 May 8, 2026
95ea028
perf: swiper progressive render + structuredClone + stable trackBy
wowa1990 May 8, 2026
cbd96f5
ux: child-friendly swiper — progressive render reliability + feel tuning
wowa1990 May 9, 2026
a4d7eec
perf(api): async cache writes + in-memory LRU on top of SD cache (M3,…
wowa1990 May 15, 2026
c210a57
perf(admin): request-scoped mupibox_config() helper (M5)
wowa1990 May 15, 2026
36ff424
perf(ops): reduce sudo+fork volume from save_rrd and get_monitor (M7)
wowa1990 May 15, 2026
a94ba91
perf(frontend): cap mergeMap concurrency in updateMedia pipeline (L2)
wowa1990 May 15, 2026
ad75d9d
feat(cover-cache): SD+RAM-LRU für Spotify-Cover (Phase 11)
wowa1990 May 15, 2026
a6a2eaf
feat(hat): granular 5%-step battery percent display (Phase 12)
wowa1990 May 16, 2026
d778ce5
fix(frontend): enable noImplicitAny in tsconfig + fix the 37 it caugh…
wowa1990 May 15, 2026
303c480
fix(frontend): polling tuning -- monitor catchError+timeout, mupihat …
wowa1990 May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 97 additions & 31 deletions AdminInterface/www/admin.php
Original file line number Diff line number Diff line change
@@ -1,46 +1,113 @@
<?php

// B8: writes go through save_mupiboxconfig() (loaded by header.php) for
// flock-serialised concurrent-save safety. Note that write_json() is
// CALLED below the header include, so the helper is loaded by then —
// the definition itself is parsed at file-load time and only executed
// when invoked.
function write_json($data)
{
$json_object = json_encode($data);
$save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object);
exec("sudo chmod 755 /etc/mupibox/mupiboxconfig.json");
exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json");
save_mupiboxconfig($data);
exec("sudo /usr/local/bin/mupibox/./setting_update.sh");
exec("sudo -i -u dietpi /usr/local/bin/mupibox/./restart_kiosk.sh");
}

// Narrow header-only auth gate for the submitfile upload handler below.
// The handler lives ABOVE `include 'includes/header.php'`, so without
// this an unauthenticated LAN POST can drop a crafted zip and have it
// extracted to / via `unzip -d /`. All other POST handlers in this
// file run AFTER the include — header.php's own auth gate already
// blocks them on unauth, so we explicitly do NOT block other POSTs
// here. In particular the login POST (password=...) must flow through
// to header.php so the user can authenticate in the first place.
session_start();
// M5: route the pre-header auth gate through the shared reader so
// header.php's later read hits the same static cache rather than
// doing a second file_get_contents + json_decode round.
require_once __DIR__ . '/includes/save_config.php';
$__authCfg = mupibox_config();
$__loginRequired = !empty($__authCfg['interfacelogin']['state']);
$__loggedIn = isset($_SESSION['logged_in']) && $_SESSION['logged_in'] === true;
if ($__loginRequired && !$__loggedIn && !empty($_POST['submitfile'])) {
http_response_code(403);
exit('Authentication required');
}

$shutdown=0;
$reboot=0;

if( $_POST['submitfile'] )
if( !empty($_POST['submitfile']) )
{
$target_dir = "/tmp/";
$target_file = $target_dir . basename($_FILES["fileToUpload"]["name"]);
// Strip any directory components from the user-controlled filename.
// The filename is later interpolated into a shell command, so even
// after escapeshellarg() we want the basename so the file lands in
// /tmp/ and not somewhere else via a relative path inside the name.
$rawName = basename($_FILES["fileToUpload"]["name"]);
// Conservative whitelist on filename: letters, digits, dot, dash,
// underscore. Anything else (spaces, quotes, semicolons, …) is
// rejected outright. Backup zips produced by backup.php/fullbackup.php
// match this pattern.
$uploadOk = 1;
$FileType = strtolower(pathinfo($target_file,PATHINFO_EXTENSION));
// Allow zip file format
if($FileType != "zip" )
{
if (!preg_match('/^[A-Za-z0-9._-]+\.zip$/', $rawName)) {
$uploadOk = 0;
}
}
$target_file = $target_dir . $rawName;
// Check if $uploadOk is set to 0 by an error
if ($uploadOk == 0)
{
$CHANGE_TXT=$CHANGE_TXT."<li>WARNING: Please upload a .zip-File!</li>";
$CHANGE_TXT=$CHANGE_TXT."<li>WARNING: Please upload a .zip-File! (only A-Z, 0-9, ._- allowed in filename)</li>";
$change=0;
}
else
{
if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file))
{
$string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$data = json_decode($string, true);
// ZIP-Slip / arbitrary-path defence. backup.php and
// fullbackup.php only ever pack files under three roots —
// reject any zip entry that escapes them. Without this,
// `unzip -o -a -d /` happily writes anywhere on disk.
$allowedPrefixes = [
'home/dietpi/MuPiBox/media/',
'etc/mupibox/mupiboxconfig.json',
'home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/data.json',
];
$zip = new ZipArchive();
$zipOk = false;
$badEntry = '';
if ($zip->open($target_file) === true) {
$zipOk = true;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
// Normalise: strip leading slash, forbid `..`
$norm = ltrim($entry, '/');
if (strpos($norm, '..') !== false) {
$zipOk = false;
$badEntry = $entry;
break;
}
$matched = false;
foreach ($allowedPrefixes as $p) {
if (strpos($norm, $p) === 0) { $matched = true; break; }
}
if (!$matched) {
$zipOk = false;
$badEntry = $entry;
break;
}
}
$zip->close();
}
if (!$zipOk) {
exec("sudo rm " . escapeshellarg($target_file));
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: Backup rejected (entry outside whitelist: ".htmlspecialchars($badEntry).")</li>";
$change=0;
} else {
// M5: external command above just mutated the config -- force fresh re-read.
$data = mupibox_config(true);
$old_version = $data["mupibox"]["version"];

$command = "sudo unzip -o -a '".$target_file."' -d / >> /tmp/restore.log";
#$command = "sudo su - -c \"unzip -o -a '".$target_file."' -d / >> /tmp/restore.log && sleep 1\"";
#$command = "sudo su - -c 'tar xvzf ".$target_file." >> /tmp/restore.log'";
$command = "sudo unzip -o -a " . escapeshellarg($target_file) . " -d / >> /tmp/restore.log";
exec($command, $output, $result );
exec("sudo chown root:www-data /etc/mupibox/mupiboxconfig.json");
exec("sudo chmod 644 /etc/mupibox/mupiboxconfig.json");
Expand All @@ -50,22 +117,23 @@ function write_json($data)
$command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/conf_update.sh | sudo bash";
exec($command, $output, $result );

$string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$data = json_decode($string, true);
// M5: external command above just mutated the config -- force fresh re-read.
$data = mupibox_config(true);
$data["mupibox"]["version"] = $old_version;
write_json($data);

$command = "sudo /boot/dietpi/func/change_hostname " . $data["mupibox"]["host"];
$command = "sudo /boot/dietpi/func/change_hostname " . escapeshellarg($data["mupibox"]["host"]);
$change_hostname = exec($command, $output, $change_hostname );
$command = "sudo su dietpi -c '/usr/local/bin/mupibox/./set_hostname.sh'";
exec($command);
$command = "sudo rm '".$target_file."'";

$command = "sudo rm " . escapeshellarg($target_file);
exec($command, $output, $result );
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Backup-File restored! The MuPiBox will reboot now!</li>";
$reboot=1;
}
}
else
{
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: Error on uploading Backup-File!</li>";
Expand Down Expand Up @@ -136,8 +204,8 @@ function write_json($data)
{
$command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/start_mupibox_update.sh | sudo bash -s -- stable";
exec($command, $output, $result );
$string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$data = json_decode($string, true);
// M5: external command above just mutated the config -- force fresh re-read.
$data = mupibox_config(true);
$change=3;
$reboot=1;
$CHANGE_TXT=$CHANGE_TXT."<li>Update complete to Version ".$data["mupibox"]["version"]."</li>";
Expand All @@ -146,8 +214,8 @@ function write_json($data)
{
$command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/start_mupibox_update.sh | sudo bash -s -- beta";
exec($command, $output, $result );
$string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$data = json_decode($string, true);
// M5: external command above just mutated the config -- force fresh re-read.
$data = mupibox_config(true);
$change=1;
$reboot=1;
$data["mupibox"]["version"]=$data["mupibox"]["version"]." BETA";
Expand All @@ -158,8 +226,8 @@ function write_json($data)
$command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/start_mupibox_update.sh | sudo bash -s -- dev";

exec($command, $output, $result );
$string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$data = json_decode($string, true);
// M5: external command above just mutated the config -- force fresh re-read.
$data = mupibox_config(true);
$change=1;
$reboot=1;
$data["mupibox"]["version"]=$data["mupibox"]["version"]." DEVELOPMENT";
Expand Down Expand Up @@ -272,9 +340,7 @@ function write_json($data)
}
if( $change == 2 )
{
$json_object = json_encode($data);
$save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object);
exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json");
save_mupiboxconfig($data);
exec("sudo /usr/local/bin/mupibox/./setting_update.sh");
}
if( $change == 3 )
Expand Down
112 changes: 83 additions & 29 deletions AdminInterface/www/includes/header.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
<!DOCTYPE html>
<?php
session_start();

// CSRF helpers are defined in includes/csrf.php — pages that need
// CSRF protection must `require csrf.php` BEFORE include('header.php')
// because header.php emits HTML chrome and a post-output csrf_check()
// can no longer set 403 headers. Including csrf.php here too keeps
// the helpers available for csrf_field() calls deeper in the body.
require_once __DIR__ . '/csrf.php';

// B8: shared save_mupiboxconfig($data) writer with flock serialisation.
// Replaces the ~15 inline `file_put_contents+sudo mv` patterns across
// admin.php, mupi.php, mupihat.php, spotify.php and smart.php.
require_once __DIR__ . '/save_config.php';

if (isset($_POST['spotifyget']) && $_POST['spotifyget'] === 'saving') {
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
$http_url = 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
Expand All @@ -23,38 +36,73 @@
$_SESSION['last_activity'] = time(); // Zeit aktualisieren
}

$string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$data = json_decode($string, true);
$loginEnabled = $data['interfacelogin']['state'];
$hashedPassword = $data['interfacelogin']['password'];
// M5: route through the request-scoped reader so the same file isn't
// re-parsed 7× per admin page load. $data stays exposed as a local for
// downstream PHP that reads $data directly.
$data = mupibox_config();
$loginEnabled = $data['interfacelogin']['state'] ?? false;
$hashedPassword = $data['interfacelogin']['password'] ?? '';

$change=0;
$CHANGE_TXT="<div id='lbinfo'><ul id='lbinfo'>";

// R3-B-1: header.php is included by every admin page and previously
// fired three exec()s on every render — `sudo iwgetid -r`,
// `sudo iwconfig wlan0`, and the websockify ps-grep below. Each
// exec forks a sudo helper and (for iw*) opens a netlink socket;
// across navigation that's ~60 wasted forks/min and noticeable lag
// on the Pi. Cache results to /tmp with a 5s TTL — fresh enough
// that the wifi-quality readout stays current, but coarse enough
// to absorb burst navigation.
function mupibox_cached_exec($cacheKey, $ttlSeconds, $command) {
$cacheFile = '/tmp/.mupibox.headercache.' . $cacheKey;
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $ttlSeconds) {
$cached = @file_get_contents($cacheFile);
if ($cached !== false) {
return rtrim($cached, "\n");
}
}
$value = (string)exec($command);
// Use LOCK_EX so concurrent header renders don't mangle the file.
@file_put_contents($cacheFile, $value, LOCK_EX);
return $value;
}

$commandSSID="sudo iwgetid -r";
$WIFI=exec($commandSSID);
$WIFI = mupibox_cached_exec('wifi_ssid', 5, $commandSSID);
$commandLQ="sudo iwconfig wlan0 | awk '/Link Quality/{split($2,a,\"=|/\");print int((a[2]/a[3])*100)\"\"}' | tr -d '%'";
$LINKQ=exec($commandLQ);
$LINKQ = mupibox_cached_exec('wifi_linkq', 5, $commandLQ);

if ($_GET['hshutdown']) {
$shutdown = 1;
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Shutdown MuPiBox</li>";
}
if ($_GET['hreboot']) {
$reboot = 1;
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Reboot MuPiBox</li>";
}
if ($_GET['hchromerestart']) {
exec("sudo -i -u dietpi /usr/local/bin/mupibox/./restart_kiosk.sh");
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Restart Chrome kiosk</li>";
}
if ($_GET['hrefreshdatabase']) {
exec("sudo /usr/local/bin/mupibox/./m3u_generator.sh");
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Update media database finished</li>";
}
// These GET handlers reboot/shutdown the box and run privileged scripts
// (restart_kiosk.sh, m3u_generator.sh). They MUST be gated by the login
// check; otherwise an unauthenticated LAN attacker can curl
// `?hshutdown=1` and DoS the box, or `?hrefreshdatabase=1` to grind the
// SD card. The auth gate further down the file is the single source of
// truth for whether the caller is allowed in — mirror it here.
$authGatePassed = !$loginEnabled
|| (isset($_SESSION['logged_in']) && $_SESSION['logged_in'] === true);
if ($authGatePassed) {
if (isset($_GET['hshutdown'])) {
$shutdown = 1;
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Shutdown MuPiBox</li>";
}
if (isset($_GET['hreboot'])) {
$reboot = 1;
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Reboot MuPiBox</li>";
}
if (isset($_GET['hchromerestart'])) {
exec("sudo -i -u dietpi /usr/local/bin/mupibox/./restart_kiosk.sh");
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Restart Chrome kiosk</li>";
}
if (isset($_GET['hrefreshdatabase'])) {
exec("sudo /usr/local/bin/mupibox/./m3u_generator.sh");
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Update media database finished</li>";
}
}

$mupihat_file = '/tmp/mupihat.json';
$mupihat_state = false;
Expand Down Expand Up @@ -157,9 +205,15 @@
<a href="<?= $link ?>index.php"><i class="fa fa-fw fa-home"></i> Home</a>
<a href="<?= $link ?>content.php"><i class="fa-solid fa-music"></i> MuPiBox</a>
<?php
$command = "ps -ef | grep websockify | grep -v grep";
exec($command, $vncoutput, $vncresult );
if( $vncoutput[0] )
// R3-B-1: same caching rationale as wifi exec()s above. The
// `ps -ef | grep websockify` invocation forks ps + grep on every
// page render; cache the boolean result for 5s.
$vnc_active = mupibox_cached_exec(
'vnc_active',
5,
"ps -ef | grep websockify | grep -v grep | head -n1"
);
if ($vnc_active !== '')
{
echo '<a href="' . $link . 'vnc.php"><i class="fa-solid fa-display"></i> VNC</a>';
}
Expand Down
Loading