db16371f47
Cloudflare edge caps request bodies (~100 MB, probed live: 120 MiB -> edge 413,
80 MiB -> origin), so the client slices the file into 64 MiB strictly-sequential
chunks; the server streams each to a .part file in the default drive's exports
dir and finalize renames atomically. Scan/validate/import pipeline untouched.
upload/{init,chunk,finalize,abort} inside ServeExportAPI (inherits auth+CSRF);
single-flight; offset==received or 409+echo; free-space gate; collision ->
lowest-free "name (N).fab"; startup GC of *.part-*; 15-min idle abort.
appexport.DiskFree exported (seam web.uploadDiskFree). Scenarios A-F tested,
red-proofs run (traversal / out-of-order / overwrite).
438 lines
18 KiB
HTML
438 lines
18 KiB
HTML
{{define "app_import"}}
|
|
{{template "layout_start" .}}
|
|
|
|
<div class="page-header">
|
|
<div style="display:flex;align-items:center;gap:1rem">
|
|
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
|
<h2>Alkalmazás importálás</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Browser .fab upload (v0.128.0) — chunked, because the Cloudflare tunnel caps request bodies (~100 MB) -->
|
|
<div class="card" style="max-width:900px;margin-bottom:1rem">
|
|
<div id="fabUploadZone" style="border:1px dashed var(--line);border-radius:var(--radius);padding:1.25rem;text-align:center;cursor:pointer" onclick="fabPick()">
|
|
<p style="color:var(--text-2);margin:0 0 .75rem">.fab csomag feltöltése — húzza ide, vagy válassza ki a fájlt</p>
|
|
<button type="button" class="btn btn-sm btn-outline">Fájl kiválasztása</button>
|
|
<input type="file" id="fabFileInput" accept=".fab" style="display:none">
|
|
</div>
|
|
<div id="fabUpProgress" style="display:none;margin-top:1rem">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem">
|
|
<span id="fabUpText" style="color:var(--text-2)">Feltöltés: 0%</span>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="fabCancel()">Megszakítás</button>
|
|
</div>
|
|
<div style="background:var(--bg-2);border-radius:var(--radius);height:6px;margin-top:.5rem;overflow:hidden">
|
|
<div id="fabUpBar" style="background:var(--blue);height:100%;width:0%"></div>
|
|
</div>
|
|
</div>
|
|
<div id="fabUpError" style="display:none;color:var(--crit);margin-top:.75rem"></div>
|
|
</div>
|
|
|
|
{{if not .Bundles}}
|
|
<div class="card" style="max-width:700px">
|
|
<p style="color:var(--text-3)">Nem található .fab csomag a regisztrált tárolókon.</p>
|
|
<p style="color:var(--text-3);font-size:.85rem">Exportálj egy alkalmazást az alkalmazás oldaláról, tölts fel egy .fab fájlt itt fent, vagy másolj egyet a <code>{tároló}/felhom-data/exports/</code> könyvtárba.</p>
|
|
</div>
|
|
{{else}}
|
|
<div class="card" style="max-width:900px">
|
|
<table class="table" style="width:100%">
|
|
<thead>
|
|
<tr>
|
|
<th>Alkalmazás</th>
|
|
<th>Dátum</th>
|
|
<th>Méret</th>
|
|
<th>Tároló</th>
|
|
<th>Titkos</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Bundles}}
|
|
<tr>
|
|
<td><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</strong></td>
|
|
<td>{{.ExportedAt}}</td>
|
|
<td>{{.SizeHuman}}</td>
|
|
<td>{{if .DriveLabel}}{{.DriveLabel}}{{else}}{{.DrivePath}}{{end}}</td>
|
|
<td>{{if .Encrypted}}🔒{{end}}</td>
|
|
<td><button class="btn btn-sm btn-outline" onclick="showPreview('{{.Path}}', {{.Encrypted}})">Részletek »</button></td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Preview / password modal -->
|
|
<div id="previewCard" class="card" style="max-width:700px;display:none">
|
|
<h3 id="previewTitle">Csomag részletei</h3>
|
|
|
|
<div id="passwordPrompt" style="display:none;margin-bottom:1rem">
|
|
<p>Ez a csomag jelszóval védett. Kérlek add meg a jelszót:</p>
|
|
<div style="display:flex;gap:.5rem">
|
|
<input type="password" id="importPassword" class="form-input" autocomplete="off" placeholder="Jelszó" style="flex:1">
|
|
<button class="btn btn-primary" onclick="loadManifest()">Megnyitás</button>
|
|
</div>
|
|
<div id="passwordError" style="display:none;color:var(--crit);margin-top:.5rem"></div>
|
|
</div>
|
|
|
|
<div id="manifestInfo" style="display:none">
|
|
<div style="background:var(--bg-2);border-radius:8px;padding:1rem;margin-bottom:1rem">
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>Alkalmazás:</span>
|
|
<strong id="mfName">-</strong>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>Exportálva:</span>
|
|
<span id="mfDate">-</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>Controller verzió:</span>
|
|
<span id="mfVersion">-</span>
|
|
</div>
|
|
<div id="mfDBRow" style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>Adatbázis:</span>
|
|
<span id="mfDB">-</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>Adatok:</span>
|
|
<span id="mfDataType">-</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between">
|
|
<span>Méret:</span>
|
|
<span id="mfSize">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="overwriteWarning" style="display:none;background:var(--warning-bg, #fff3cd);border:1px solid var(--warning-border, #ffc107);border-radius:8px;padding:1rem;margin-bottom:1rem">
|
|
<strong>⚠ FIGYELEM:</strong> A meglévő <span id="overwriteAppName"></span> alkalmazás konfigurációja és összes adata felül lesz írva!
|
|
</div>
|
|
|
|
<button id="importBtn" class="btn btn-primary" onclick="startImport()" style="width:100%">
|
|
Visszaállítás indítása
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress -->
|
|
<div id="progressCard" class="card" style="max-width:700px;display:none">
|
|
<h3>Importálás folyamata</h3>
|
|
<div id="progressSteps"></div>
|
|
<div id="progressError" style="display:none;color:var(--crit);margin-top:1rem;font-weight:600"></div>
|
|
</div>
|
|
|
|
<div id="doneCard" class="card" style="max-width:700px;display:none">
|
|
<h3 style="color:var(--blue)">Importálás kész!</h3>
|
|
<p>Az alkalmazás sikeresen visszaállítva.</p>
|
|
<a id="doneLink" href="/stacks" class="btn btn-primary">Alkalmazások megtekintése</a>
|
|
</div>
|
|
|
|
<script>
|
|
var selectedPath = '';
|
|
var selectedEncrypted = false;
|
|
var selectedManifest = null;
|
|
var pollTimer = null;
|
|
|
|
function csrfH() {
|
|
var el = document.querySelector('meta[name="csrf-token"]');
|
|
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/json'} : {'Content-Type': 'application/json'};
|
|
}
|
|
|
|
function showPreview(path, encrypted) {
|
|
selectedPath = path;
|
|
selectedEncrypted = encrypted;
|
|
selectedManifest = null;
|
|
|
|
document.getElementById('previewCard').style.display = 'block';
|
|
document.getElementById('manifestInfo').style.display = 'none';
|
|
document.getElementById('passwordPrompt').style.display = 'none';
|
|
document.getElementById('passwordError').style.display = 'none';
|
|
document.getElementById('progressCard').style.display = 'none';
|
|
document.getElementById('doneCard').style.display = 'none';
|
|
|
|
if (encrypted) {
|
|
document.getElementById('passwordPrompt').style.display = 'block';
|
|
document.getElementById('importPassword').value = '';
|
|
document.getElementById('importPassword').focus();
|
|
} else {
|
|
loadManifest();
|
|
}
|
|
}
|
|
|
|
async function loadManifest() {
|
|
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
|
|
|
|
try {
|
|
var resp = await fetch('/api/export/manifest', {
|
|
method: 'POST',
|
|
headers: csrfH(),
|
|
body: JSON.stringify({path: selectedPath, password: password})
|
|
});
|
|
var data = await resp.json();
|
|
|
|
if (!data.ok) {
|
|
if (selectedEncrypted) {
|
|
document.getElementById('passwordError').textContent = data.error || 'Hibás jelszó';
|
|
document.getElementById('passwordError').style.display = 'block';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.needs_password) {
|
|
document.getElementById('passwordPrompt').style.display = 'block';
|
|
document.getElementById('importPassword').focus();
|
|
return;
|
|
}
|
|
|
|
selectedManifest = data.manifest;
|
|
showManifest(data.manifest);
|
|
} catch(e) {
|
|
console.error('Manifest error:', e);
|
|
}
|
|
}
|
|
|
|
function showManifest(m) {
|
|
document.getElementById('passwordPrompt').style.display = 'none';
|
|
document.getElementById('manifestInfo').style.display = 'block';
|
|
|
|
document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' — Csomag részletei';
|
|
document.getElementById('mfName').textContent = m.display_name || m.app_name;
|
|
document.getElementById('mfDate').textContent = m.exported_at ? new Date(m.exported_at).toLocaleString('hu-HU') : '-';
|
|
document.getElementById('mfVersion').textContent = m.controller_version || '-';
|
|
|
|
if (m.has_database && m.db_type) {
|
|
document.getElementById('mfDB').textContent = m.db_type;
|
|
document.getElementById('mfDBRow').style.display = 'flex';
|
|
} else {
|
|
document.getElementById('mfDBRow').style.display = 'none';
|
|
}
|
|
|
|
var dataType = [];
|
|
if (m.has_hdd_data) dataType.push('HDD');
|
|
if (m.has_volume_data) dataType.push('Docker volume');
|
|
document.getElementById('mfDataType').textContent = dataType.length ? dataType.join(', ') : 'Nincs';
|
|
|
|
// Format size
|
|
var bytes = m.total_size_bytes || 0;
|
|
var sizeStr = bytes > 1073741824 ? (bytes / 1073741824).toFixed(1) + ' GB' :
|
|
bytes > 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' :
|
|
bytes > 1024 ? (bytes / 1024).toFixed(1) + ' KB' : bytes + ' B';
|
|
document.getElementById('mfSize').textContent = sizeStr;
|
|
|
|
// Check if app already exists — show overwrite warning
|
|
// We detect this by checking if the app link exists in the nav
|
|
var warn = document.getElementById('overwriteWarning');
|
|
// Simple approach: always show warning for existing app names
|
|
document.getElementById('overwriteAppName').textContent = m.display_name || m.app_name;
|
|
warn.style.display = 'block';
|
|
}
|
|
|
|
async function startImport() {
|
|
document.getElementById('importBtn').disabled = true;
|
|
document.getElementById('progressCard').style.display = 'block';
|
|
document.getElementById('doneCard').style.display = 'none';
|
|
|
|
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
|
|
|
|
try {
|
|
var resp = await fetch('/api/export/import', {
|
|
method: 'POST',
|
|
headers: csrfH(),
|
|
body: JSON.stringify({path: selectedPath, password: password})
|
|
});
|
|
var data = await resp.json();
|
|
if (!data.ok) {
|
|
showError(data.error || 'Hiba történt');
|
|
return;
|
|
}
|
|
pollStatus();
|
|
} catch(e) {
|
|
showError(e.message);
|
|
}
|
|
}
|
|
|
|
function pollStatus() {
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
pollTimer = setInterval(async function() {
|
|
try {
|
|
var resp = await fetch('/api/export/import/status');
|
|
var data = await resp.json();
|
|
renderSteps(data.steps || []);
|
|
|
|
if (data.error) {
|
|
showError(data.error);
|
|
clearInterval(pollTimer);
|
|
return;
|
|
}
|
|
|
|
if (data.done && !data.error) {
|
|
clearInterval(pollTimer);
|
|
document.getElementById('progressCard').style.display = 'none';
|
|
document.getElementById('doneCard').style.display = 'block';
|
|
if (data.stack_name) {
|
|
document.getElementById('doneLink').href = '/stacks/' + data.stack_name + '/deploy';
|
|
document.getElementById('doneLink').textContent = 'Alkalmazás megtekintése';
|
|
}
|
|
}
|
|
} catch(e) {
|
|
console.error('Poll error:', e);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function renderSteps(steps) {
|
|
var html = '';
|
|
for (var i = 0; i < steps.length; i++) {
|
|
var s = steps[i];
|
|
var icon = '○';
|
|
if (s.status === 'running') icon = '⟳';
|
|
if (s.status === 'done') icon = '✓';
|
|
if (s.status === 'failed') icon = '✗';
|
|
var cls = s.status === 'failed' ? 'color:var(--crit)' : s.status === 'done' ? 'color:var(--blue)' : s.status === 'running' ? 'color:var(--blue)' : '';
|
|
html += '<div style="padding:.25rem 0;' + cls + '">' + icon + ' ' + s.label;
|
|
if (s.error) html += ' <span style="font-size:.85rem">(' + s.error + ')</span>';
|
|
html += '</div>';
|
|
}
|
|
document.getElementById('progressSteps').innerHTML = html;
|
|
}
|
|
|
|
function showError(msg) {
|
|
var el = document.getElementById('progressError');
|
|
el.textContent = msg;
|
|
el.style.display = 'block';
|
|
document.getElementById('importBtn').disabled = false;
|
|
}
|
|
|
|
// --- Browser .fab upload (v0.128.0): File.slice sequential chunk loop. One retry per chunk on a
|
|
// network error, re-syncing from the server's 409 received_bytes echo; progress from bytes acked.
|
|
var fabUploadId = null;
|
|
var fabCancelled = false;
|
|
var fabNetErrText = 'A feltöltés megszakadt — ellenőrizze a kapcsolatot, majd próbálja újra.';
|
|
|
|
function csrfRawH() {
|
|
var el = document.querySelector('meta[name="csrf-token"]');
|
|
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/octet-stream'} : {'Content-Type': 'application/octet-stream'};
|
|
}
|
|
|
|
function fabPick() {
|
|
document.getElementById('fabFileInput').click();
|
|
}
|
|
|
|
function fabGB(b) {
|
|
return (b / 1073741824).toFixed(2);
|
|
}
|
|
|
|
function fabProgress(done, total) {
|
|
var pct = total > 0 ? Math.floor(done * 100 / total) : 0;
|
|
document.getElementById('fabUpText').textContent =
|
|
'Feltöltés: ' + pct + '% (' + fabGB(done) + ' / ' + fabGB(total) + ' GB)';
|
|
document.getElementById('fabUpBar').style.width = pct + '%';
|
|
}
|
|
|
|
function fabFail(msg) {
|
|
document.getElementById('fabUpProgress').style.display = 'none';
|
|
document.getElementById('fabUploadZone').style.display = 'block';
|
|
var el = document.getElementById('fabUpError');
|
|
el.textContent = msg;
|
|
el.style.display = 'block';
|
|
fabUploadId = null;
|
|
}
|
|
|
|
async function fabCancel() {
|
|
fabCancelled = true;
|
|
if (fabUploadId) {
|
|
try {
|
|
await fetch('/api/export/upload/abort', {
|
|
method: 'POST', headers: csrfH(),
|
|
body: JSON.stringify({upload_id: fabUploadId})
|
|
});
|
|
} catch(e) { /* best-effort — the server's idle timeout collects the rest */ }
|
|
}
|
|
fabUploadId = null;
|
|
document.getElementById('fabUpProgress').style.display = 'none';
|
|
document.getElementById('fabUploadZone').style.display = 'block';
|
|
}
|
|
|
|
async function fabStart(file) {
|
|
if (!file) return;
|
|
document.getElementById('fabUpError').style.display = 'none';
|
|
if (!file.name.toLowerCase().endsWith('.fab')) {
|
|
fabFail('Csak .fab fájl tölthető fel.');
|
|
return;
|
|
}
|
|
fabCancelled = false;
|
|
document.getElementById('fabUploadZone').style.display = 'none';
|
|
document.getElementById('fabUpProgress').style.display = 'block';
|
|
fabProgress(0, file.size);
|
|
|
|
try {
|
|
var resp = await fetch('/api/export/upload/init', {
|
|
method: 'POST', headers: csrfH(),
|
|
body: JSON.stringify({filename: file.name, size_bytes: file.size})
|
|
});
|
|
var data = await resp.json();
|
|
if (!data.ok) { fabFail(data.error || fabNetErrText); return; }
|
|
fabUploadId = data.upload_id;
|
|
var chunkBytes = data.chunk_bytes;
|
|
|
|
var offset = 0;
|
|
var retriedThisChunk = false;
|
|
while (offset < file.size) {
|
|
if (fabCancelled) return;
|
|
var r = null;
|
|
try {
|
|
r = await fetch('/api/export/upload/chunk?id=' + fabUploadId + '&offset=' + offset, {
|
|
method: 'POST', headers: csrfRawH(),
|
|
body: file.slice(offset, Math.min(offset + chunkBytes, file.size))
|
|
});
|
|
} catch(e) {
|
|
if (retriedThisChunk) { fabFail(fabNetErrText); return; }
|
|
retriedThisChunk = true;
|
|
continue; // same offset — the server only counts appended bytes
|
|
}
|
|
var d = await r.json().catch(function(){ return {}; });
|
|
if (r.status === 409 && typeof d.received_bytes === 'number') {
|
|
if (retriedThisChunk) { fabFail(d.error || fabNetErrText); return; }
|
|
retriedThisChunk = true;
|
|
offset = d.received_bytes; // re-sync one step from the echo
|
|
fabProgress(offset, file.size);
|
|
continue;
|
|
}
|
|
if (!r.ok || !d.ok) { fabFail(d.error || fabNetErrText); return; }
|
|
offset = d.received_bytes;
|
|
retriedThisChunk = false;
|
|
fabProgress(offset, file.size);
|
|
}
|
|
if (fabCancelled) return;
|
|
|
|
var fin = await fetch('/api/export/upload/finalize', {
|
|
method: 'POST', headers: csrfH(),
|
|
body: JSON.stringify({upload_id: fabUploadId})
|
|
});
|
|
var fd = await fin.json();
|
|
if (!fd.ok) { fabFail(fd.error || fabNetErrText); return; }
|
|
fabUploadId = null;
|
|
document.getElementById('fabUpText').textContent = 'Feltöltés kész: ' + fd.filename;
|
|
document.getElementById('fabUpBar').style.width = '100%';
|
|
// Refresh the bundle list through the EXISTING scan (the page render runs it).
|
|
setTimeout(function(){ window.location.reload(); }, 800);
|
|
} catch(e) {
|
|
fabFail(fabNetErrText);
|
|
}
|
|
}
|
|
|
|
(function(){
|
|
var input = document.getElementById('fabFileInput');
|
|
input.addEventListener('change', function(){ fabStart(input.files[0]); input.value = ''; });
|
|
var zone = document.getElementById('fabUploadZone');
|
|
zone.addEventListener('dragover', function(e){ e.preventDefault(); zone.style.borderColor = 'var(--blue)'; });
|
|
zone.addEventListener('dragleave', function(){ zone.style.borderColor = 'var(--line)'; });
|
|
zone.addEventListener('drop', function(e){
|
|
e.preventDefault();
|
|
zone.style.borderColor = 'var(--line)';
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) fabStart(e.dataTransfer.files[0]);
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{{template "layout_end" .}}
|
|
{{end}}
|