v1.4.0 — Mise à jour firmware hors-ligne par upload ZIP

Nouvelle fonctionnalité permettant de mettre à jour le firmware sans
connexion internet, via upload d'un fichier .zip depuis l'interface admin.

Fichiers ajoutés:
- update_firmware_from_file.sh (rsync + exclusions + chown + restart services)
- .update-exclude (liste d'exclusions évolutive, versionnée)
- html/.htaccess (limite upload PHP 50MB)

Fichiers modifiés:
- html/launcher.php (handler upload_firmware)
- html/admin.html (UI upload + barre de progression)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
PaulVua
2026-03-10 16:30:51 +01:00
parent 1298e79688
commit 98b5b43190
7 changed files with 460 additions and 1 deletions

3
html/.htaccess Normal file
View File

@@ -0,0 +1,3 @@
php_value upload_max_filesize 50M
php_value post_max_size 55M
php_value max_execution_time 300

View File

@@ -244,6 +244,20 @@
<span id="updateSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
<hr class="my-3">
<label class="form-label fw-bold">Mise à jour hors-ligne (upload)</label>
<div class="input-group mb-2">
<input type="file" class="form-control" id="firmwareFileInput" accept=".zip">
<button class="btn btn-warning" type="button" onclick="uploadFirmware()" id="uploadBtn">
<span id="uploadBtnText">Upload & Install</span>
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
<div class="progress mb-2" id="uploadProgressBar" style="display: none; height: 20px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%" id="uploadProgress">0%</div>
</div>
<small class="text-muted">Télécharger le .zip depuis Gitea, puis le déposer ici</small>
<!-- Update Output Console -->
<div id="updateOutput" class="mt-3" style="display: none;">
<div class="card">
@@ -824,6 +838,116 @@ function updateFirmware() {
});
}
function uploadFirmware() {
const fileInput = document.getElementById('firmwareFileInput');
const file = fileInput.files[0];
if (!file) {
showToast('Please select a .zip file first', 'warning');
return;
}
// Validate extension
if (!file.name.toLowerCase().endsWith('.zip')) {
showToast('Only .zip files are allowed', 'error');
return;
}
// Validate size (50MB)
if (file.size > 50 * 1024 * 1024) {
showToast('File too large (max 50MB)', 'error');
return;
}
if (!confirm('Install firmware from "' + file.name + '"?\nThis will update the system files and restart services.')) {
return;
}
// UI elements
const uploadBtn = document.getElementById('uploadBtn');
const uploadBtnText = document.getElementById('uploadBtnText');
const uploadSpinner = document.getElementById('uploadSpinner');
const progressBar = document.getElementById('uploadProgressBar');
const progress = document.getElementById('uploadProgress');
const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent');
// Show loading state
uploadBtn.disabled = true;
uploadBtnText.textContent = 'Uploading...';
uploadSpinner.style.display = 'inline-block';
progressBar.style.display = 'flex';
progress.style.width = '0%';
progress.textContent = '0%';
updateOutput.style.display = 'block';
updateOutputContent.textContent = 'Uploading firmware file...\n';
// Build FormData
const formData = new FormData();
formData.append('firmware_file', file);
// Use XMLHttpRequest for upload progress
const xhr = new XMLHttpRequest();
xhr.timeout = 300000; // 5 minutes
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progress.style.width = pct + '%';
progress.textContent = pct + '%';
if (pct >= 100) {
uploadBtnText.textContent = 'Installing...';
updateOutputContent.textContent = 'Upload complete. Installing firmware...\n';
}
}
});
xhr.addEventListener('load', function() {
try {
const response = JSON.parse(xhr.responseText);
if (response.success && response.output) {
const formattedOutput = response.output
.replace(/✓/g, '<span style="color: #28a745;">✓</span>')
.replace(/✗/g, '<span style="color: #dc3545;">✗</span>')
.replace(/⚠/g, '<span style="color: #ffc107;">⚠</span>')
.replace(//g, '<span style="color: #17a2b8;"></span>');
updateOutputContent.innerHTML = formattedOutput;
showToast('Firmware updated: ' + (response.old_version || '?') + ' → ' + (response.new_version || '?'), 'success');
document.getElementById('reloadBtn').style.display = 'inline-block';
} else {
updateOutputContent.textContent = 'Error: ' + (response.message || 'Unknown error');
showToast('Upload failed: ' + (response.message || 'Unknown error'), 'error');
}
} catch (e) {
updateOutputContent.textContent = 'Error parsing response: ' + xhr.responseText;
showToast('Update failed: invalid server response', 'error');
}
resetUploadUI();
});
xhr.addEventListener('error', function() {
updateOutputContent.textContent = 'Network error during upload';
showToast('Upload failed: network error', 'error');
resetUploadUI();
});
xhr.addEventListener('timeout', function() {
updateOutputContent.textContent = 'Upload timed out (5 min limit)';
showToast('Upload timed out', 'error');
resetUploadUI();
});
function resetUploadUI() {
uploadBtn.disabled = false;
uploadBtnText.textContent = 'Upload & Install';
uploadSpinner.style.display = 'none';
progressBar.style.display = 'none';
}
xhr.open('POST', 'launcher.php?type=upload_firmware');
xhr.send(formData);
}
function clearUpdateOutput() {
const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent');

View File

@@ -410,6 +410,107 @@ if ($type == "update_firmware") {
]);
}
if ($type == "upload_firmware") {
// Firmware update via ZIP file upload (offline mode)
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'POST method required']);
exit;
}
// Check file upload
if (!isset($_FILES['firmware_file']) || $_FILES['firmware_file']['error'] !== UPLOAD_ERR_OK) {
$upload_errors = [
UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit',
UPLOAD_ERR_FORM_SIZE => 'File exceeds form upload limit',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
];
$error_code = $_FILES['firmware_file']['error'] ?? UPLOAD_ERR_NO_FILE;
$error_msg = $upload_errors[$error_code] ?? 'Unknown upload error';
echo json_encode(['success' => false, 'message' => $error_msg]);
exit;
}
$file = $_FILES['firmware_file'];
// Validate extension
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'zip') {
echo json_encode(['success' => false, 'message' => 'Only .zip files are allowed']);
exit;
}
// Validate size (50MB max)
if ($file['size'] > 50 * 1024 * 1024) {
echo json_encode(['success' => false, 'message' => 'File too large (max 50MB)']);
exit;
}
// Get current version before update
$old_version = 'unknown';
if (file_exists('/var/www/nebuleair_pro_4g/VERSION')) {
$old_version = trim(file_get_contents('/var/www/nebuleair_pro_4g/VERSION'));
}
// Prepare extraction directory
$tmp_dir = '/tmp/nebuleair_update';
$extract_dir = "$tmp_dir/extracted";
shell_exec("rm -rf $tmp_dir");
mkdir($extract_dir, 0755, true);
// Move uploaded file
$zip_path = "$tmp_dir/firmware.zip";
if (!move_uploaded_file($file['tmp_name'], $zip_path)) {
echo json_encode(['success' => false, 'message' => 'Failed to move uploaded file']);
exit;
}
// Extract ZIP
$unzip_output = shell_exec("unzip -o '$zip_path' -d '$extract_dir' 2>&1");
// Detect project root folder (Gitea creates nebuleair_pro_4g-main/ inside the zip)
$source_dir = $extract_dir;
$entries = scandir($extract_dir);
$subdirs = array_filter($entries, function($e) use ($extract_dir) {
return $e !== '.' && $e !== '..' && is_dir("$extract_dir/$e");
});
if (count($subdirs) === 1) {
$subdir = reset($subdirs);
$candidate = "$extract_dir/$subdir";
if (file_exists("$candidate/VERSION")) {
$source_dir = $candidate;
}
}
// Validate VERSION exists in the archive
if (!file_exists("$source_dir/VERSION")) {
shell_exec("rm -rf $tmp_dir");
echo json_encode(['success' => false, 'message' => 'Invalid archive: VERSION file not found']);
exit;
}
$new_version = trim(file_get_contents("$source_dir/VERSION"));
// Execute update script
$command = "sudo /var/www/nebuleair_pro_4g/update_firmware_from_file.sh '$source_dir' 2>&1";
$output = shell_exec($command);
// Cleanup (also done in script, but just in case)
shell_exec("rm -rf $tmp_dir");
echo json_encode([
'success' => true,
'output' => $output,
'old_version' => $old_version,
'new_version' => $new_version,
'timestamp' => date('Y-m-d H:i:s')
]);
exit;
}
if ($type == "set_RTC_withNTP") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_NTP.py';
$output = shell_exec($command);