Экспорт, импорт и копии вне компьютера
Все 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:
@@ -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 пытается выполнить обрывки
|
||||
|
||||
Reference in New Issue
Block a user