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

Все 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
+84
View File
@@ -269,6 +269,90 @@ finally:
ollama.reset_breaker()
r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True)
print("\n== Экспорт и импорт ==")
import json as _json # noqa: E402
import zipfile # noqa: E402
# Кладём снимок, чтобы в архиве было чему проверяться: предыдущие блоки
# свои фотографии за собой убирают.
_some = c.get("/api/items").get_json()["items"][0]["id"]
upload("item", _some)
before = c.get("/api/items").get_json()["items"]
resp = c.get("/api/export")
r.check("архив отдаётся", resp.status_code == 200, resp.status_code)
r.check("это zip", resp.data[:2] == b"PK", resp.data[:4])
archive = zipfile.ZipFile(io.BytesIO(resp.data))
names = archive.namelist()
r.check("внутри есть база", "db.json" in names, names[:5])
r.check("внутри есть фотографии", any(n.startswith("photos/") for n in names), names[:5])
r.check("локальные копии в архив не попали",
not any("backups" in n for n in names), [n for n in names if "backups" in n])
d = c.post("/api/import/check", content_type="multipart/form-data",
data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json()
r.check("проверка архива видит содержимое",
d.get("ok") and d["archive"]["items"] == len(before), d)
# Восстановление в ту же песочницу: данные должны остаться прежними,
# а прежнее состояние — отложиться для отката.
d = c.post("/api/import", content_type="multipart/form-data",
data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json()
r.check("импорт прошёл", d.get("ok"), d)
after = c.get("/api/items").get_json()["items"]
r.check("вещи на месте после импорта", len(after) == len(before),
(len(before), len(after)))
rollback = config.DATA_DIR / "backups" / d["result"]["rollback"]
r.check("страховка перед импортом создана", rollback.exists(), rollback)
# Архив с путём наружу: имя из архива не должно превращаться в путь.
evil = io.BytesIO()
with zipfile.ZipFile(evil, "w") as zf:
zf.writestr("db.json", _json.dumps({"version": 1, "items": {}, "places": {},
"containers": {}}))
zf.writestr("photos/../../evil.png", b"\x89PNG\r\n\x1a\n")
evil.seek(0)
outside = config.PROJECT_DIR.parent / "evil.png"
d = c.post("/api/import", content_type="multipart/form-data",
data={"archive": (evil, "evil.zip")}).get_json()
r.check("злой архив не пишет за пределы папки", not outside.exists(), outside)
if outside.exists():
outside.unlink()
r.check("чужой файл отвергается",
c.post("/api/import", content_type="multipart/form-data",
data={"archive": (io.BytesIO(b"not a zip"), "x.zip")}).status_code == 400)
r.check("импорт без файла — 400",
c.post("/api/import", content_type="multipart/form-data", data={}).status_code == 400)
# Возвращаем данные песочницы: злой архив заменил базу на пустую.
c.post("/api/import", content_type="multipart/form-data",
data={"archive": (io.BytesIO(resp.data), "a.zip")})
print("\n== Внешние копии ==")
r.check("состояние отдаётся", "backup" in c.get("/api/config").get_json())
r.check("без пути — копировать нечего",
c.post("/api/backup/now").status_code == 400 if not config.BACKUP_DIR else True)
dest = SANDBOX / "external"
config.save_settings({"HLAMINGO_BACKUP_DIR": str(dest)})
r.check("путь принят", config.BACKUP_DIR == str(dest), config.BACKUP_DIR)
d = c.post("/api/backup/now").get_json()
r.check("копия сделана", d.get("ok"), d)
r.check("база скопирована", (dest / "db.json").exists())
r.check("фотографии скопированы", (dest / "photos").exists())
# Недоступный путь не должен мешать записи вещи — сетевой диск отваливается.
config.save_settings({"HLAMINGO_BACKUP_DIR": "Z:\\нет-такого-диска\\backup"})
import time as _t2 # noqa: E402
t0 = _t2.time()
d = j("POST", "/api/put", name="вещь при отвалившемся диске")
r.check("запись прошла, несмотря на недоступный путь",
d.get("ok") and _t2.time() - t0 < 10, "%.1f с" % (_t2.time() - t0))
config.save_settings({"HLAMINGO_BACKUP_DIR": ""})
r.check("путь можно очистить", config.BACKUP_DIR == "", config.BACKUP_DIR)
print("\n== Батники: только ASCII ==")
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки