Экспорт, импорт и копии вне компьютера
Все 40 резервных копий лежали на том же диске, что и оригинал, а фотографии не дублировались нигде: смерть диска уносила и 59 записей, и 69 снимков. Что появилось: - «📦 Скачать архив» — zip с базой и всеми фотографиями (13 МБ на текущих данных). Работает и с телефона, годится для переноса на другой компьютер. - «📥 Загрузить архив» — восстановление. Сначала показывает, что внутри (сколько вещей в архиве против текущих), и только после подтверждения заменяет данные. Прежнее состояние откладывается в data/backups — и база, и папка со старыми фотографиями. - HLAMINGO_BACKUP_DIR — путь, куда сама уходит копия базы и новых фотографий. Не чаще раза в час и только если путь доступен: сетевой диск отваливается регулярно, а терять запись о вещи из-за этого недопустимо. Состояние и кнопка «Скопировать сейчас» — на вкладке настроек. Имена файлов из архива как пути не используются: только базовое имя и расширение из белого списка. Иначе архив со строкой «../../» писал бы куда угодно — тест это проверяет отдельно. Попутно исправлены два дефекта: - предел загрузки был 8 МБ, и архив в 13 МБ не прошёл бы. Разделён на общий предел запроса и отдельный предел для одного снимка; - настройки нельзя было менять по одной: непереданные поля приходили как null и валились на проверке. Теперь null означает «не трогать», а пустая строка — осознанное «очистить». Про защиту копий на сервере. Задумывался .htaccess, но сайты обслуживает nginx, который его игнорирует, — файл создавал бы ложное чувство защиты. Настоящая защита в расположении: папка лежит рядом с корнем сайта, а не внутри, и nginx за пределы своего root не выпускает. Проверено запросами извне: и сама папка, и db.json, и фотографии дают 404, включая попытку выйти через «/../». .htaccess положен рядом как подстраховка на случай переезда на Apache, о чём честно написано внутри самого файла. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+112
-1
@@ -263,6 +263,29 @@
|
||||
|
||||
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
|
||||
<p class="hint" id="cfgWhere"></p>
|
||||
|
||||
<h3 style="margin-top:26px">Резервные копии</h3>
|
||||
<p class="hint">Копии внутри программы лежат на этом же диске. Если он умрёт,
|
||||
пропадут и записи, и фотографии — поэтому копию стоит держать ещё где-то.</p>
|
||||
|
||||
<label>Папка для копий вне компьютера</label>
|
||||
<input type="text" id="cfgBackupDir" placeholder="T:\hlamingo-backup" autocomplete="off">
|
||||
<p class="hint">Сетевой диск или папка. Пусто — внешние копии выключены.
|
||||
Копирование идёт само, не чаще раза в час, и не мешает работе, если
|
||||
диск отвалился.</p>
|
||||
<div id="backupState"></div>
|
||||
<div class="row mt">
|
||||
<button class="act ghost" id="backupNow">Скопировать сейчас</button>
|
||||
</div>
|
||||
|
||||
<label>Архив со всеми данными</label>
|
||||
<div class="row">
|
||||
<button class="act ghost" id="exportBtn">📦 Скачать архив</button>
|
||||
<button class="act ghost" id="importBtn">📥 Загрузить архив</button>
|
||||
</div>
|
||||
<p class="hint">В архиве база и все фотографии. Загрузка ЗАМЕНЯЕТ текущие
|
||||
данные — прежние сохранятся отдельно, чтобы можно было вернуться.</p>
|
||||
<div id="importInfo"></div>
|
||||
</section>
|
||||
|
||||
<!-- ================= Обзор ================= -->
|
||||
@@ -281,6 +304,7 @@
|
||||
<input type="file" id="galInput" accept="image/*" hidden>
|
||||
<input type="file" id="formCamInput" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="formGalInput" accept="image/*" hidden>
|
||||
<input type="file" id="archiveInput" accept=".zip,application/zip" hidden>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
@@ -346,8 +370,94 @@ async function loadConfig() {
|
||||
$('#cfgProbeResult').innerHTML =
|
||||
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
|
||||
}
|
||||
showBackupState(d.backup || {});
|
||||
}
|
||||
|
||||
function showBackupState(b) {
|
||||
$('#cfgBackupDir').value = b.path || '';
|
||||
const box = $('#backupState');
|
||||
if (!b.enabled) {
|
||||
box.innerHTML = '<div class="card"><div class="meta">Внешние копии выключены. '
|
||||
+ 'Укажите папку выше, чтобы данные жили не только на этом диске.</div></div>';
|
||||
return;
|
||||
}
|
||||
const good = b.reachable;
|
||||
let html = '<div class="card"><div class="addr' + (good ? '' : ' taken') + '">'
|
||||
+ (good ? 'Папка доступна' : 'Папка сейчас недоступна') + '</div>'
|
||||
+ '<div class="meta">' + esc(b.path) + '</div>';
|
||||
if (b.at) {
|
||||
html += '<div class="meta">Последняя копия: ' + esc(b.at)
|
||||
+ (b.ok ? '' : ' — с ошибкой') + '</div>';
|
||||
} else {
|
||||
html += '<div class="meta">Копий ещё не было.</div>';
|
||||
}
|
||||
if (b.error) html += '<div class="meta">' + esc(b.error) + '</div>';
|
||||
box.innerHTML = html + '</div>';
|
||||
}
|
||||
|
||||
$('#backupNow').onclick = async () => {
|
||||
const btn = $('#backupNow');
|
||||
btn.disabled = true; btn.textContent = 'Копирую…';
|
||||
try {
|
||||
const d = await api('/api/backup/now', {method: 'POST'});
|
||||
say(d.reply);
|
||||
if (d.state) showBackupState(d.state);
|
||||
} finally { btn.disabled = false; btn.textContent = 'Скопировать сейчас'; }
|
||||
};
|
||||
|
||||
// Экспорт: обычная ссылка на скачивание — так файл уходит в загрузки телефона.
|
||||
$('#exportBtn').onclick = () => { window.location = '/api/export'; };
|
||||
|
||||
$('#importBtn').onclick = () => $('#archiveInput').click();
|
||||
$('#archiveInput').onchange = async () => {
|
||||
const file = $('#archiveInput').files[0];
|
||||
$('#archiveInput').value = '';
|
||||
if (!file) return;
|
||||
|
||||
const box = $('#importInfo');
|
||||
box.innerHTML = '<div class="card"><div class="meta">Смотрю, что в архиве…</div></div>';
|
||||
|
||||
// Сначала показываем, что внутри: импорт заменяет данные целиком,
|
||||
// и человек должен видеть, на что меняет.
|
||||
const fd = new FormData();
|
||||
fd.append('archive', file);
|
||||
const check = await api('/api/import/check', {method: 'POST', body: fd});
|
||||
if (!check.ok) {
|
||||
box.innerHTML = '<div class="card"><div class="addr taken">' + esc(check.reply) + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const a = check.archive, c = check.current;
|
||||
box.innerHTML = '<div class="card">'
|
||||
+ '<h3>Заменить данные?</h3>'
|
||||
+ '<div class="meta">Сейчас: ' + c.items + ' вещей, ' + c.places + ' мест, '
|
||||
+ c.containers + ' коробок</div>'
|
||||
+ '<div class="addr">В архиве: ' + a.items + ' вещей, ' + a.places + ' мест, '
|
||||
+ a.containers + ' коробок, ' + a.photos + ' фото</div>'
|
||||
+ '<div class="meta">Текущие данные сохранятся отдельно — вернуться можно.</div>'
|
||||
+ '</div>';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'acts';
|
||||
const go = document.createElement('button');
|
||||
go.textContent = 'Заменить';
|
||||
go.onclick = async () => {
|
||||
go.disabled = true; go.textContent = 'Восстанавливаю…';
|
||||
const fd2 = new FormData();
|
||||
fd2.append('archive', file);
|
||||
const d = await api('/api/import', {method: 'POST', body: fd2});
|
||||
say(d.reply);
|
||||
box.innerHTML = '<div class="card"><div class="' + (d.ok ? 'addr' : 'addr taken') + '">'
|
||||
+ esc(d.reply) + '</div></div>';
|
||||
if (d.ok) { loadPlaces().then(loadBoxes); loadTree(); }
|
||||
};
|
||||
const no = document.createElement('button');
|
||||
no.textContent = 'Отмена';
|
||||
no.onclick = () => { box.innerHTML = ''; };
|
||||
bar.appendChild(go); bar.appendChild(no);
|
||||
box.firstChild.appendChild(bar);
|
||||
};
|
||||
|
||||
// Модель выбирается из установленных; текущее значение показываем даже если
|
||||
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
|
||||
function fillModels(models, chat, embed) {
|
||||
@@ -384,7 +494,8 @@ $('#cfgSave').onclick = async () => {
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(),
|
||||
chat_model: $('#cfgChat').value,
|
||||
embed_model: $('#cfgEmbed').value})});
|
||||
embed_model: $('#cfgEmbed').value,
|
||||
backup_dir: $('#cfgBackupDir').value.trim()})});
|
||||
say(d.reply);
|
||||
if (d.ok) {
|
||||
$('#banner').style.display = d.available ? 'none' : 'block';
|
||||
|
||||
Reference in New Issue
Block a user