Экспорт, импорт и копии вне компьютера

Все 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:
2026-07-31 11:17:08 +03:00
co-authored by Claude Opus 5
parent 99e392d777
commit 1ae98b0b6f
7 changed files with 569 additions and 13 deletions
+112 -1
View File
@@ -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';