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

Все 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
+5
View File
@@ -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
+46 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+188
View File
@@ -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
View File
@@ -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';
+84
View File
@@ -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 пытается выполнить обрывки