Экспорт, импорт и копии вне компьютера
Все 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:
@@ -19,5 +19,10 @@ OLLAMA_HOST=http://localhost:11434
|
|||||||
# ollama pull gemma3:4b
|
# ollama pull gemma3:4b
|
||||||
# HLAMINGO_VISION_MODEL=gemma3:4b
|
# HLAMINGO_VISION_MODEL=gemma3:4b
|
||||||
|
|
||||||
|
# Куда складывать копию базы и фотографий ВНЕ этого компьютера.
|
||||||
|
# Сетевой диск или папка; пусто — внешние копии выключены.
|
||||||
|
# Локальные копии в data/backups лежат на том же диске и от его смерти не спасают.
|
||||||
|
# HLAMINGO_BACKUP_DIR=T:\hlamingo-backup
|
||||||
|
|
||||||
# Порт веб-интерфейса.
|
# Порт веб-интерфейса.
|
||||||
# HLAMINGO_PORT=5000
|
# HLAMINGO_PORT=5000
|
||||||
|
|||||||
@@ -119,10 +119,12 @@ start.cmd запускает сервер и печатает адреса
|
|||||||
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
|
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
|
||||||
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
|
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
|
||||||
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
|
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
|
||||||
|
| `HLAMINGO_BACKUP_DIR` | — | папка для копий вне компьютера; пусто — выключено |
|
||||||
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
|
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
|
||||||
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
|
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
|
||||||
|
|
||||||
Из браузера правятся только `OLLAMA_HOST` и две модели — по белому списку.
|
Из браузера правятся `OLLAMA_HOST`, две модели и путь для копий — по белому
|
||||||
|
списку.
|
||||||
Произвольные ключи в `.env` через веб записать нельзя: этот файл читает
|
Произвольные ключи в `.env` через веб записать нельзя: этот файл читает
|
||||||
приложение при запуске. Адрес проверяется на формат, имена моделей — на
|
приложение при запуске. Адрес проверяется на формат, имена моделей — на
|
||||||
недопустимые символы, и если хоть одно значение неверно, не применяется
|
недопустимые символы, и если хоть одно значение неверно, не применяется
|
||||||
@@ -329,9 +331,10 @@ static\photos\ фотографии (в .gitignore)
|
|||||||
и та же коробка.
|
и та же коробка.
|
||||||
- **Резервные копии.** Перед каждой перезаписью прошлое состояние базы уходит
|
- **Резервные копии.** Перед каждой перезаписью прошлое состояние базы уходит
|
||||||
в `data\backups\db-ГГГГММДД-ЧЧММ.json`, хранятся последние 40. Откат — просто
|
в `data\backups\db-ГГГГММДД-ЧЧММ.json`, хранятся последние 40. Откат — просто
|
||||||
скопировать нужный файл обратно в `data\db.json`. Полноценный бэкап папки они
|
скопировать нужный файл обратно в `data\db.json`. Спасают от затёртой или
|
||||||
не заменяют, но спасают от затёртой или испорченной базы: однажды она уже
|
испорченной базы: однажды она уже потерялась целиком, и откатываться было
|
||||||
потерялась целиком, и откатываться было некуда.
|
некуда. **Но лежат они на том же диске**, поэтому есть ещё два способа
|
||||||
|
ниже — архив и внешняя папка.
|
||||||
- **Запись базы атомарная** (временный файл + подмена): обрыв на середине
|
- **Запись базы атомарная** (временный файл + подмена): обрыв на середине
|
||||||
не уничтожит всё, что записано про вещи.
|
не уничтожит всё, что записано про вещи.
|
||||||
- **Битая база не роняет приложение** — запускается с пустой, файл не трогает.
|
- **Битая база не роняет приложение** — запускается с пустой, файл не трогает.
|
||||||
@@ -375,6 +378,45 @@ venv\Scripts\python.exe tools\make_icons.py
|
|||||||
из `static/` он ушёл бы как обычный файл, и браузер мог не признать его
|
из `static/` он ушёл бы как обычный файл, и браузер мог не признать его
|
||||||
манифестом.
|
манифестом.
|
||||||
|
|
||||||
|
## Резервные копии и перенос
|
||||||
|
|
||||||
|
Копии внутри программы лежат на том же диске, что и оригинал. Диск умрёт —
|
||||||
|
пропадут и записи, и фотографии. Поэтому есть два способа держать данные ещё
|
||||||
|
где-то, оба на вкладке **⚙**.
|
||||||
|
|
||||||
|
### Архив
|
||||||
|
|
||||||
|
**«📦 Скачать архив»** — zip с базой и всеми фотографиями (у 59 вещей это
|
||||||
|
около 13 МБ). Работает и с телефона: файл уходит в загрузки. Годится и для
|
||||||
|
переноса на другой компьютер.
|
||||||
|
|
||||||
|
**«📥 Загрузить архив»** — восстановление. Сначала показывает, что внутри
|
||||||
|
(сколько вещей в архиве против текущих), и только после подтверждения заменяет
|
||||||
|
данные. Прежнее состояние при этом откладывается в `data\backups`:
|
||||||
|
`db-before-import-*.json` и папка с прежними фотографиями — вернуться можно
|
||||||
|
всегда.
|
||||||
|
|
||||||
|
Имена файлов из архива как пути не используются: берётся только базовое имя
|
||||||
|
и расширение из белого списка. Иначе архив со строкой `../../` внутри писал бы
|
||||||
|
куда угодно на диске.
|
||||||
|
|
||||||
|
### Внешняя папка
|
||||||
|
|
||||||
|
`HLAMINGO_BACKUP_DIR` — путь, куда само уходит копия базы и новых фотографий:
|
||||||
|
сетевой диск, шара, что угодно. Пусто — выключено.
|
||||||
|
|
||||||
|
Копирование идёт не чаще раза в час и **не мешает работе, если путь
|
||||||
|
недоступен**: сетевой диск отваливается регулярно, а терять запись о вещи
|
||||||
|
из-за этого недопустимо. Состояние (путь, доступен ли, когда была последняя
|
||||||
|
копия) видно на вкладке ⚙, там же кнопка «Скопировать сейчас».
|
||||||
|
|
||||||
|
**Если папка лежит на веб-сервере — проверьте, не отдаётся ли она наружу.**
|
||||||
|
В нашем случае копии кладутся в `sambadata\hlamingo-backup`, рядом с корнем
|
||||||
|
сайта, но не внутри него, поэтому nginx до них не дотягивается — проверено
|
||||||
|
запросами извне, все дают 404. Файл `.htaccess` там тоже лежит, но nginx его
|
||||||
|
игнорирует: это подстраховка на случай переезда на Apache, а не текущая защита.
|
||||||
|
Полагаться на `.htaccess` при nginx нельзя.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+87
-2
@@ -5,7 +5,9 @@ import logging
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from flask import Flask, request, jsonify, send_from_directory
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify, send_from_directory, send_file
|
||||||
|
|
||||||
from . import store, search, brain, ollama, config
|
from . import store, search, brain, ollama, config
|
||||||
from .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT,
|
from .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT,
|
||||||
@@ -68,9 +70,84 @@ def api_config():
|
|||||||
"available": ollama.check(),
|
"available": ollama.check(),
|
||||||
"models": ollama.models(),
|
"models": ollama.models(),
|
||||||
"env_file": str(config.ENV_FILE),
|
"env_file": str(config.ENV_FILE),
|
||||||
|
"backup": store.external_state(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/export")
|
||||||
|
def api_export():
|
||||||
|
"""Отдаёт архив со всем: базой и фотографиями."""
|
||||||
|
archive = store.export_archive()
|
||||||
|
name = "hlamingo-%s.zip" % datetime.now().strftime("%Y%m%d-%H%M")
|
||||||
|
return send_file(archive, mimetype="application/zip",
|
||||||
|
as_attachment=True, download_name=name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/import/check", methods=["POST"])
|
||||||
|
def api_import_check():
|
||||||
|
"""Что в архиве — до того, как что-либо менять.
|
||||||
|
|
||||||
|
Импорт заменяет данные целиком, поэтому человек должен видеть, что
|
||||||
|
он получит взамен: сколько вещей в архиве против того, что есть сейчас.
|
||||||
|
"""
|
||||||
|
upload = request.files.get("archive")
|
||||||
|
if not upload:
|
||||||
|
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
|
||||||
|
|
||||||
|
parsed, error = store.inspect_archive(upload.read())
|
||||||
|
if error:
|
||||||
|
return jsonify({"ok": False, "reply": error}), 400
|
||||||
|
|
||||||
|
db = store.load()
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"archive": {"items": len(parsed["db"].get("items", {})),
|
||||||
|
"places": len(parsed["db"].get("places", {})),
|
||||||
|
"containers": len(parsed["db"].get("containers", {})),
|
||||||
|
"photos": len(parsed["photos"])},
|
||||||
|
"current": {"items": len(db["items"]), "places": len(db["places"]),
|
||||||
|
"containers": len(db["containers"])},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/import", methods=["POST"])
|
||||||
|
def api_import():
|
||||||
|
"""Восстанавливает базу и фотографии из архива."""
|
||||||
|
upload = request.files.get("archive")
|
||||||
|
if not upload:
|
||||||
|
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
|
||||||
|
|
||||||
|
result, error = store.import_archive(upload.read())
|
||||||
|
if error:
|
||||||
|
return jsonify({"ok": False, "reply": error}), 400
|
||||||
|
|
||||||
|
# Векторы могли не приехать (архив со старой моделью эмбеддера) —
|
||||||
|
# досчитываем, иначе поиск по смыслу молча не работал бы.
|
||||||
|
db = store.load()
|
||||||
|
if search.ensure_embeddings(db):
|
||||||
|
store.save(db)
|
||||||
|
|
||||||
|
return jsonify({"ok": True, "result": result,
|
||||||
|
"reply": "Восстановлено: %d вещей, %d фото. "
|
||||||
|
"Прежнее состояние сохранено как %s."
|
||||||
|
% (result["items"], result["photos"], result["rollback"])})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backup/now", methods=["POST"])
|
||||||
|
def api_backup_now():
|
||||||
|
"""Скопировать во внешнюю папку прямо сейчас, не дожидаясь расписания."""
|
||||||
|
if not config.BACKUP_DIR:
|
||||||
|
return jsonify({"ok": False, "reply": "Путь для копий не задан."}), 400
|
||||||
|
ok = store.external_backup(force=True)
|
||||||
|
state = store.external_state()
|
||||||
|
if ok:
|
||||||
|
return jsonify({"ok": True, "state": state,
|
||||||
|
"reply": "Скопировано в %s" % config.BACKUP_DIR})
|
||||||
|
return jsonify({"ok": False, "state": state,
|
||||||
|
"reply": "Не удалось скопировать: %s"
|
||||||
|
% (state.get("error") or "путь недоступен")})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/config/probe", methods=["POST"])
|
@app.route("/api/config/probe", methods=["POST"])
|
||||||
def api_config_probe():
|
def api_config_probe():
|
||||||
"""Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели."""
|
"""Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели."""
|
||||||
@@ -103,6 +180,7 @@ def api_config_save():
|
|||||||
"OLLAMA_HOST": data.get("ollama_host"),
|
"OLLAMA_HOST": data.get("ollama_host"),
|
||||||
"HLAMINGO_CHAT_MODEL": data.get("chat_model"),
|
"HLAMINGO_CHAT_MODEL": data.get("chat_model"),
|
||||||
"HLAMINGO_EMBED_MODEL": data.get("embed_model"),
|
"HLAMINGO_EMBED_MODEL": data.get("embed_model"),
|
||||||
|
"HLAMINGO_BACKUP_DIR": data.get("backup_dir"),
|
||||||
})
|
})
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"ok": False, "reply": str(e)}), 400
|
return jsonify({"ok": False, "reply": str(e)}), 400
|
||||||
@@ -717,9 +795,16 @@ def api_photo():
|
|||||||
if ext not in ALLOWED_PHOTO_EXT:
|
if ext not in ALLOWED_PHOTO_EXT:
|
||||||
return jsonify({"ok": False, "reply": "Такой формат не принимаю: %s" % (ext or "?")}), 400
|
return jsonify({"ok": False, "reply": "Такой формат не принимаю: %s" % (ext or "?")}), 400
|
||||||
|
|
||||||
|
# Общий лимит запроса поднят ради архива восстановления, поэтому размер
|
||||||
|
# снимка проверяем здесь отдельно.
|
||||||
|
blob = upload.read()
|
||||||
|
if len(blob) > config.MAX_PHOTO_BYTES:
|
||||||
|
return jsonify({"ok": False, "reply": "Слишком большой снимок (%.1f МБ)."
|
||||||
|
% (len(blob) / 1e6)}), 400
|
||||||
|
|
||||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
filename = uuid.uuid4().hex[:12] + ext
|
filename = uuid.uuid4().hex[:12] + ext
|
||||||
upload.save(str(PHOTOS_DIR / filename))
|
(PHOTOS_DIR / filename).write_bytes(blob)
|
||||||
|
|
||||||
db = store.load()
|
db = store.load()
|
||||||
target = request.form.get("target")
|
target = request.form.get("target")
|
||||||
|
|||||||
+46
-5
@@ -48,7 +48,8 @@ ENV_FILE = (_ROOT_OVERRIDE / ".env") if _ROOT_OVERRIDE else (_PROJECT / ".env")
|
|||||||
# Что разрешено менять из браузера. Белый список, а не любые ключи: в .env
|
# Что разрешено менять из браузера. Белый список, а не любые ключи: в .env
|
||||||
# нельзя позволять писать произвольные строки — этот файл читает приложение
|
# нельзя позволять писать произвольные строки — этот файл читает приложение
|
||||||
# при запуске.
|
# при запуске.
|
||||||
EDITABLE = ("OLLAMA_HOST", "HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL")
|
EDITABLE = ("OLLAMA_HOST", "HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL",
|
||||||
|
"HLAMINGO_BACKUP_DIR")
|
||||||
|
|
||||||
_HOST_RE = None # ленивая компиляция: re нужен только при сохранении
|
_HOST_RE = None # ленивая компиляция: re нужен только при сохранении
|
||||||
|
|
||||||
@@ -62,6 +63,13 @@ def _validate(key, raw):
|
|||||||
if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value):
|
if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value):
|
||||||
raise ValueError("Адрес Ollama должен быть вида http://хост:11434")
|
raise ValueError("Адрес Ollama должен быть вида http://хост:11434")
|
||||||
return value.rstrip("/")
|
return value.rstrip("/")
|
||||||
|
if key == "HLAMINGO_BACKUP_DIR":
|
||||||
|
# Пустая строка — выключить внешние копии. Иначе путь: диск (T:\...)
|
||||||
|
# или сетевая шара (\\сервер\шара\...). Переводы строк недопустимы —
|
||||||
|
# значение уходит в .env, где строка есть строка.
|
||||||
|
if value and ("\n" in value or "\r" in value):
|
||||||
|
raise ValueError("Путь не может содержать перевод строки.")
|
||||||
|
return value
|
||||||
if value and not _re.match(r"^[\w./:\-]+$", value):
|
if value and not _re.match(r"^[\w./:\-]+$", value):
|
||||||
raise ValueError("Недопустимое имя модели: %s" % value)
|
raise ValueError("Недопустимое имя модели: %s" % value)
|
||||||
return value
|
return value
|
||||||
@@ -79,7 +87,13 @@ def save_settings(values):
|
|||||||
"""
|
"""
|
||||||
checked = {}
|
checked = {}
|
||||||
for key, raw in (values or {}).items():
|
for key, raw in (values or {}).items():
|
||||||
if key in EDITABLE:
|
if key not in EDITABLE:
|
||||||
|
continue
|
||||||
|
# None — «поле не передали, не трогать». Пустая строка — это уже
|
||||||
|
# осознанное «очистить», и она проходит проверку по правилам ключа.
|
||||||
|
# Без этого различия нельзя поменять одну настройку, не прислав все.
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
checked[key] = _validate(key, raw)
|
checked[key] = _validate(key, raw)
|
||||||
|
|
||||||
changed = [key for key, value in checked.items() if _apply(key, value)]
|
changed = [key for key, value in checked.items() if _apply(key, value)]
|
||||||
@@ -92,18 +106,22 @@ def current(key):
|
|||||||
"""Текущее значение настройки по имени переменной окружения."""
|
"""Текущее значение настройки по имени переменной окружения."""
|
||||||
return {"OLLAMA_HOST": OLLAMA_HOST,
|
return {"OLLAMA_HOST": OLLAMA_HOST,
|
||||||
"HLAMINGO_CHAT_MODEL": CHAT_MODEL,
|
"HLAMINGO_CHAT_MODEL": CHAT_MODEL,
|
||||||
"HLAMINGO_EMBED_MODEL": EMBED_MODEL}.get(key, "")
|
"HLAMINGO_EMBED_MODEL": EMBED_MODEL,
|
||||||
|
"HLAMINGO_BACKUP_DIR": BACKUP_DIR}.get(key, "")
|
||||||
|
|
||||||
|
|
||||||
def _apply(key, value):
|
def _apply(key, value):
|
||||||
"""Обновляет значение в этом модуле. True, если оно действительно менялось."""
|
"""Обновляет значение в этом модуле. True, если оно действительно менялось."""
|
||||||
global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL
|
global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL, BACKUP_DIR
|
||||||
if key == "OLLAMA_HOST" and value != OLLAMA_HOST:
|
if key == "OLLAMA_HOST" and value != OLLAMA_HOST:
|
||||||
OLLAMA_HOST = value
|
OLLAMA_HOST = value
|
||||||
elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL:
|
elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL:
|
||||||
CHAT_MODEL = value
|
CHAT_MODEL = value
|
||||||
elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL:
|
elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL:
|
||||||
EMBED_MODEL = value
|
EMBED_MODEL = value
|
||||||
|
elif key == "HLAMINGO_BACKUP_DIR" and value != BACKUP_DIR:
|
||||||
|
# Пустое значение допустимо: так внешние копии выключают.
|
||||||
|
BACKUP_DIR = value
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
os.environ[key] = value
|
os.environ[key] = value
|
||||||
@@ -212,6 +230,24 @@ PHOTOS_DIR = (_ROOT / "photos") if _ROOT_OVERRIDE else (PROJECT_DIR / "static" /
|
|||||||
# и отказываются что-либо удалять, если флаг стоит.
|
# и отказываются что-либо удалять, если флаг стоит.
|
||||||
IS_LIVE_DATA = _ROOT_OVERRIDE is None
|
IS_LIVE_DATA = _ROOT_OVERRIDE is None
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Внешние резервные копии
|
||||||
|
# ============================================================
|
||||||
|
# Куда складывать копию базы и фотографий ВНЕ этого компьютера. Пусто —
|
||||||
|
# выключено, работают только локальные копии в data/backups.
|
||||||
|
# HLAMINGO_BACKUP_DIR=T:\hlamingo-backup
|
||||||
|
# HLAMINGO_BACKUP_DIR=\\192.168.0.115\rw\hlamingo-backup
|
||||||
|
#
|
||||||
|
# Смысл именно во «вне»: локальные копии лежат на том же диске, что и оригинал,
|
||||||
|
# и от смерти диска не спасают.
|
||||||
|
BACKUP_DIR = os.environ.get("HLAMINGO_BACKUP_DIR", "").strip()
|
||||||
|
# Как часто копировать наружу. Каждое сохранение гонять по сети незачем —
|
||||||
|
# база полтора мегабайта, а пишется она при каждом действии.
|
||||||
|
EXTERNAL_BACKUP_EVERY = 3600 # секунд
|
||||||
|
# Сетевой диск может отвалиться (у T: это регулярно). Ждём недолго и идём
|
||||||
|
# дальше: потерять запись о вещи из-за недоступной шары недопустимо.
|
||||||
|
BACKUP_TIMEOUT = 3.0
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Веб
|
# Веб
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -220,5 +256,10 @@ try:
|
|||||||
PORT = int(os.environ.get("HLAMINGO_PORT") or 5000)
|
PORT = int(os.environ.get("HLAMINGO_PORT") or 5000)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
PORT = 5000
|
PORT = 5000
|
||||||
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # фото жмётся на клиенте; 8 МБ — щедрый запас
|
# Общий предел запроса. Считается по архиву при восстановлении: он тем больше,
|
||||||
|
# чем больше фотографий (сейчас ~15 МБ на 69 снимков), поэтому с запасом на рост.
|
||||||
|
MAX_UPLOAD_BYTES = 512 * 1024 * 1024
|
||||||
|
# Предел для одной фотографии — проверяется отдельно. Снимок ужимается
|
||||||
|
# на клиенте до 1280 px, так что 8 МБ хватает с избытком.
|
||||||
|
MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||||
ALLOWED_PHOTO_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
ALLOWED_PHOTO_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
|||||||
@@ -6,14 +6,19 @@
|
|||||||
item — предмет; лежит ЛИБО в ёмкости, ЛИБО прямо в месте (лопата в углу).
|
item — предмет; лежит ЛИБО в ёмкости, ЛИБО прямо в месте (лопата в углу).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import shutil
|
import shutil
|
||||||
|
import zipfile
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import DB_FILE, DATA_DIR
|
from .config import DB_FILE, DATA_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -98,6 +103,189 @@ def save(db):
|
|||||||
tmp = DB_FILE.with_suffix(".json.tmp")
|
tmp = DB_FILE.with_suffix(".json.tmp")
|
||||||
tmp.write_text(json.dumps(db, ensure_ascii=False, indent=1), encoding="utf-8")
|
tmp.write_text(json.dumps(db, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
os.replace(tmp, DB_FILE)
|
os.replace(tmp, DB_FILE)
|
||||||
|
external_backup()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Внешняя копия: база и фотографии вне этого компьютера
|
||||||
|
# ============================================================
|
||||||
|
_last_external = 0.0
|
||||||
|
_external_note = {"at": None, "ok": None, "error": None}
|
||||||
|
|
||||||
|
|
||||||
|
def external_state():
|
||||||
|
"""Что показать в настройках: путь, доступность, когда копировали."""
|
||||||
|
from . import config
|
||||||
|
path = config.BACKUP_DIR
|
||||||
|
state = dict(_external_note)
|
||||||
|
state["path"] = path
|
||||||
|
state["enabled"] = bool(path)
|
||||||
|
state["reachable"] = _reachable(path) if path else None
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable(path):
|
||||||
|
"""Доступна ли папка. Коротко: сетевая шара умеет отваливаться надолго."""
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
target = Path(path)
|
||||||
|
if target.exists():
|
||||||
|
return os.access(str(target), os.W_OK)
|
||||||
|
# Папки ещё нет — проверяем, доступен ли родитель (диск, шара).
|
||||||
|
parent = target.parent
|
||||||
|
return parent.exists() and os.access(str(parent), os.W_OK)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def external_backup(force=False):
|
||||||
|
"""Копирует базу и новые фотографии в BACKUP_DIR. Ошибки не пробрасывает.
|
||||||
|
|
||||||
|
Недоступность внешнего пути не должна мешать работе: сетевой диск
|
||||||
|
отваливается регулярно, а запись о вещи важнее копии.
|
||||||
|
"""
|
||||||
|
global _last_external
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
path = config.BACKUP_DIR
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
# Не `now` — в модуле уже есть функция now(), и затенять её нельзя.
|
||||||
|
moment = time.time()
|
||||||
|
if not force and moment - _last_external < config.EXTERNAL_BACKUP_EVERY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = Path(path)
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if DB_FILE.exists():
|
||||||
|
tmp = target / "db.json.tmp"
|
||||||
|
shutil.copy2(str(DB_FILE), str(tmp))
|
||||||
|
os.replace(str(tmp), str(target / "db.json"))
|
||||||
|
|
||||||
|
# Фотографии копируем только новые: их десятки мегабайт, и гонять
|
||||||
|
# всё по сети каждый раз незачем.
|
||||||
|
copied = 0
|
||||||
|
photos_src = config.PHOTOS_DIR
|
||||||
|
if photos_src.exists():
|
||||||
|
photos_dst = target / "photos"
|
||||||
|
photos_dst.mkdir(exist_ok=True)
|
||||||
|
for photo in photos_src.iterdir():
|
||||||
|
if not photo.is_file():
|
||||||
|
continue
|
||||||
|
mirror = photos_dst / photo.name
|
||||||
|
if not mirror.exists() or mirror.stat().st_size != photo.stat().st_size:
|
||||||
|
shutil.copy2(str(photo), str(mirror))
|
||||||
|
copied += 1
|
||||||
|
|
||||||
|
_last_external = moment
|
||||||
|
_external_note.update({"at": now(), "ok": True, "error": None})
|
||||||
|
logger.info("Внешняя копия сделана: %s (новых фото: %d)", path, copied)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_external_note.update({"at": now(), "ok": False, "error": str(e)})
|
||||||
|
logger.warning("Внешняя копия не удалась (%s): %s", path, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Архив: выгрузить всё одним файлом и вернуть обратно
|
||||||
|
# ============================================================
|
||||||
|
ARCHIVE_DB = "db.json"
|
||||||
|
ARCHIVE_PHOTOS = "photos/"
|
||||||
|
|
||||||
|
|
||||||
|
def export_archive():
|
||||||
|
"""Собирает zip с базой и фотографиями. Возвращает BytesIO.
|
||||||
|
|
||||||
|
Локальные копии из data/backups не кладём: это десятки мегабайт истории,
|
||||||
|
а восстановление от них не зависит.
|
||||||
|
"""
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
if DB_FILE.exists():
|
||||||
|
zf.write(str(DB_FILE), ARCHIVE_DB)
|
||||||
|
if config.PHOTOS_DIR.exists():
|
||||||
|
for photo in sorted(config.PHOTOS_DIR.iterdir()):
|
||||||
|
if photo.is_file():
|
||||||
|
zf.write(str(photo), ARCHIVE_PHOTOS + photo.name)
|
||||||
|
zf.writestr("hlamingo.txt",
|
||||||
|
"Архив hlamingo от %s\n"
|
||||||
|
"db.json — база (места, ёмкости, вещи)\n"
|
||||||
|
"photos/ — фотографии вещей\n"
|
||||||
|
"Восстановить: кнопка «Загрузить архив» на вкладке настроек.\n"
|
||||||
|
% now())
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_archive(data):
|
||||||
|
"""Смотрит, что в архиве, ничего не меняя. Возвращает (db, photos) или (None, причина)."""
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||||
|
except Exception:
|
||||||
|
return None, "Это не zip-архив."
|
||||||
|
|
||||||
|
names = zf.namelist()
|
||||||
|
if ARCHIVE_DB not in names:
|
||||||
|
return None, "В архиве нет db.json — это не архив hlamingo."
|
||||||
|
try:
|
||||||
|
db = json.loads(zf.read(ARCHIVE_DB).decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
return None, "db.json в архиве не читается: %s" % e
|
||||||
|
if not isinstance(db, dict) or "items" not in db:
|
||||||
|
return None, "db.json в архиве не похож на базу hlamingo."
|
||||||
|
|
||||||
|
photos = [n for n in names if n.startswith(ARCHIVE_PHOTOS) and not n.endswith("/")]
|
||||||
|
return {"db": db, "photos": photos, "zip": zf}, None
|
||||||
|
|
||||||
|
|
||||||
|
def import_archive(data):
|
||||||
|
"""Заменяет базу и фотографии содержимым архива.
|
||||||
|
|
||||||
|
Перед заменой откладывает текущее состояние — откатиться можно всегда.
|
||||||
|
Возвращает (итог, ошибка).
|
||||||
|
"""
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
parsed, error = inspect_archive(data)
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
zf, db, photos = parsed["zip"], parsed["db"], parsed["photos"]
|
||||||
|
|
||||||
|
# Страховка до единой правки: и база, и фотографии.
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
safety = DATA_DIR / "backups"
|
||||||
|
safety.mkdir(parents=True, exist_ok=True)
|
||||||
|
if DB_FILE.exists():
|
||||||
|
shutil.copy2(str(DB_FILE), str(safety / ("db-before-import-%s.json" % stamp)))
|
||||||
|
if config.PHOTOS_DIR.exists() and any(config.PHOTOS_DIR.iterdir()):
|
||||||
|
keep = safety / ("photos-before-import-%s" % stamp)
|
||||||
|
shutil.copytree(str(config.PHOTOS_DIR), str(keep), dirs_exist_ok=True)
|
||||||
|
|
||||||
|
config.PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
restored = 0
|
||||||
|
for name in photos:
|
||||||
|
# Имя из архива как путь не используем: строка «../../» увела бы
|
||||||
|
# запись куда угодно. Берём только базовое имя и известное расширение.
|
||||||
|
safe = os.path.basename(name.replace("\\", "/"))
|
||||||
|
if not safe or safe.startswith("."):
|
||||||
|
continue
|
||||||
|
if os.path.splitext(safe)[1].lower() not in config.ALLOWED_PHOTO_EXT:
|
||||||
|
continue
|
||||||
|
(config.PHOTOS_DIR / safe).write_bytes(zf.read(name))
|
||||||
|
restored += 1
|
||||||
|
|
||||||
|
save(db)
|
||||||
|
return {"items": len(db.get("items", {})),
|
||||||
|
"places": len(db.get("places", {})),
|
||||||
|
"containers": len(db.get("containers", {})),
|
||||||
|
"photos": restored,
|
||||||
|
"rollback": "db-before-import-%s.json" % stamp}, None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
+112
-1
@@ -263,6 +263,29 @@
|
|||||||
|
|
||||||
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
|
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
|
||||||
<p class="hint" id="cfgWhere"></p>
|
<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>
|
</section>
|
||||||
|
|
||||||
<!-- ================= Обзор ================= -->
|
<!-- ================= Обзор ================= -->
|
||||||
@@ -281,6 +304,7 @@
|
|||||||
<input type="file" id="galInput" accept="image/*" hidden>
|
<input type="file" id="galInput" accept="image/*" hidden>
|
||||||
<input type="file" id="formCamInput" accept="image/*" capture="environment" hidden>
|
<input type="file" id="formCamInput" accept="image/*" capture="environment" hidden>
|
||||||
<input type="file" id="formGalInput" accept="image/*" hidden>
|
<input type="file" id="formGalInput" accept="image/*" hidden>
|
||||||
|
<input type="file" id="archiveInput" accept=".zip,application/zip" hidden>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
@@ -346,8 +370,94 @@ async function loadConfig() {
|
|||||||
$('#cfgProbeResult').innerHTML =
|
$('#cfgProbeResult').innerHTML =
|
||||||
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
|
'<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) {
|
function fillModels(models, chat, embed) {
|
||||||
@@ -384,7 +494,8 @@ $('#cfgSave').onclick = async () => {
|
|||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(),
|
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(),
|
||||||
chat_model: $('#cfgChat').value,
|
chat_model: $('#cfgChat').value,
|
||||||
embed_model: $('#cfgEmbed').value})});
|
embed_model: $('#cfgEmbed').value,
|
||||||
|
backup_dir: $('#cfgBackupDir').value.trim()})});
|
||||||
say(d.reply);
|
say(d.reply);
|
||||||
if (d.ok) {
|
if (d.ok) {
|
||||||
$('#banner').style.display = d.available ? 'none' : 'block';
|
$('#banner').style.display = d.available ? 'none' : 'block';
|
||||||
|
|||||||
@@ -269,6 +269,90 @@ finally:
|
|||||||
ollama.reset_breaker()
|
ollama.reset_breaker()
|
||||||
r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True)
|
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 ==")
|
print("\n== Батники: только ASCII ==")
|
||||||
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
|
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
|
||||||
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
|
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
|
||||||
|
|||||||
Reference in New Issue
Block a user