diff --git a/.env.example b/.env.example index ddd285d..dff7487 100644 --- a/.env.example +++ b/.env.example @@ -19,5 +19,10 @@ OLLAMA_HOST=http://localhost:11434 # ollama pull gemma3:4b # HLAMINGO_VISION_MODEL=gemma3:4b +# Куда складывать копию базы и фотографий ВНЕ этого компьютера. +# Сетевой диск или папка; пусто — внешние копии выключены. +# Локальные копии в data/backups лежат на том же диске и от его смерти не спасают. +# HLAMINGO_BACKUP_DIR=T:\hlamingo-backup + # Порт веб-интерфейса. # HLAMINGO_PORT=5000 diff --git a/README.md b/README.md index 0d84db6..f3d7da1 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,12 @@ start.cmd запускает сервер и печатает адреса | `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги | | `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу | | `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено | +| `HLAMINGO_BACKUP_DIR` | — | папка для копий вне компьютера; пусто — выключено | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | -Из браузера правятся только `OLLAMA_HOST` и две модели — по белому списку. +Из браузера правятся `OLLAMA_HOST`, две модели и путь для копий — по белому +списку. Произвольные ключи в `.env` через веб записать нельзя: этот файл читает приложение при запуске. Адрес проверяется на формат, имена моделей — на недопустимые символы, и если хоть одно значение неверно, не применяется @@ -329,9 +331,10 @@ static\photos\ фотографии (в .gitignore) и та же коробка. - **Резервные копии.** Перед каждой перезаписью прошлое состояние базы уходит в `data\backups\db-ГГГГММДД-ЧЧММ.json`, хранятся последние 40. Откат — просто - скопировать нужный файл обратно в `data\db.json`. Полноценный бэкап папки они - не заменяют, но спасают от затёртой или испорченной базы: однажды она уже - потерялась целиком, и откатываться было некуда. + скопировать нужный файл обратно в `data\db.json`. Спасают от затёртой или + испорченной базы: однажды она уже потерялась целиком, и откатываться было + некуда. **Но лежат они на том же диске**, поэтому есть ещё два способа + ниже — архив и внешняя папка. - **Запись базы атомарная** (временный файл + подмена): обрыв на середине не уничтожит всё, что записано про вещи. - **Битая база не роняет приложение** — запускается с пустой, файл не трогает. @@ -375,6 +378,45 @@ venv\Scripts\python.exe tools\make_icons.py из `static/` он ушёл бы как обычный файл, и браузер мог не признать его манифестом. +## Резервные копии и перенос + +Копии внутри программы лежат на том же диске, что и оригинал. Диск умрёт — +пропадут и записи, и фотографии. Поэтому есть два способа держать данные ещё +где-то, оба на вкладке **⚙**. + +### Архив + +**«📦 Скачать архив»** — zip с базой и всеми фотографиями (у 59 вещей это +около 13 МБ). Работает и с телефона: файл уходит в загрузки. Годится и для +переноса на другой компьютер. + +**«📥 Загрузить архив»** — восстановление. Сначала показывает, что внутри +(сколько вещей в архиве против текущих), и только после подтверждения заменяет +данные. Прежнее состояние при этом откладывается в `data\backups`: +`db-before-import-*.json` и папка с прежними фотографиями — вернуться можно +всегда. + +Имена файлов из архива как пути не используются: берётся только базовое имя +и расширение из белого списка. Иначе архив со строкой `../../` внутри писал бы +куда угодно на диске. + +### Внешняя папка + +`HLAMINGO_BACKUP_DIR` — путь, куда само уходит копия базы и новых фотографий: +сетевой диск, шара, что угодно. Пусто — выключено. + +Копирование идёт не чаще раза в час и **не мешает работе, если путь +недоступен**: сетевой диск отваливается регулярно, а терять запись о вещи +из-за этого недопустимо. Состояние (путь, доступен ли, когда была последняя +копия) видно на вкладке ⚙, там же кнопка «Скопировать сейчас». + +**Если папка лежит на веб-сервере — проверьте, не отдаётся ли она наружу.** +В нашем случае копии кладутся в `sambadata\hlamingo-backup`, рядом с корнем +сайта, но не внутри него, поэтому nginx до них не дотягивается — проверено +запросами извне, все дают 404. Файл `.htaccess` там тоже лежит, но nginx его +игнорирует: это подстраховка на случай переезда на Apache, а не текущая защита. +Полагаться на `.htaccess` при nginx нельзя. + ## Тесты ``` diff --git a/hlamingo/app.py b/hlamingo/app.py index 77da679..e9f6905 100644 --- a/hlamingo/app.py +++ b/hlamingo/app.py @@ -5,7 +5,9 @@ import logging import webbrowser 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 .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT, @@ -68,9 +70,84 @@ def api_config(): "available": ollama.check(), "models": ollama.models(), "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"]) def api_config_probe(): """Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели.""" @@ -103,6 +180,7 @@ def api_config_save(): "OLLAMA_HOST": data.get("ollama_host"), "HLAMINGO_CHAT_MODEL": data.get("chat_model"), "HLAMINGO_EMBED_MODEL": data.get("embed_model"), + "HLAMINGO_BACKUP_DIR": data.get("backup_dir"), }) except ValueError as e: return jsonify({"ok": False, "reply": str(e)}), 400 @@ -717,9 +795,16 @@ def api_photo(): if ext not in ALLOWED_PHOTO_EXT: 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) filename = uuid.uuid4().hex[:12] + ext - upload.save(str(PHOTOS_DIR / filename)) + (PHOTOS_DIR / filename).write_bytes(blob) db = store.load() target = request.form.get("target") diff --git a/hlamingo/config.py b/hlamingo/config.py index 738ce0a..3e74d3b 100644 --- a/hlamingo/config.py +++ b/hlamingo/config.py @@ -48,7 +48,8 @@ ENV_FILE = (_ROOT_OVERRIDE / ".env") if _ROOT_OVERRIDE else (_PROJECT / ".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 нужен только при сохранении @@ -62,6 +63,13 @@ def _validate(key, raw): if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value): raise ValueError("Адрес Ollama должен быть вида http://хост:11434") 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): raise ValueError("Недопустимое имя модели: %s" % value) return value @@ -79,8 +87,14 @@ def save_settings(values): """ checked = {} for key, raw in (values or {}).items(): - if key in EDITABLE: - checked[key] = _validate(key, raw) + if key not in EDITABLE: + continue + # None — «поле не передали, не трогать». Пустая строка — это уже + # осознанное «очистить», и она проходит проверку по правилам ключа. + # Без этого различия нельзя поменять одну настройку, не прислав все. + if raw is None: + continue + checked[key] = _validate(key, raw) changed = [key for key, value in checked.items() if _apply(key, value)] if changed: @@ -92,18 +106,22 @@ def current(key): """Текущее значение настройки по имени переменной окружения.""" return {"OLLAMA_HOST": OLLAMA_HOST, "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): """Обновляет значение в этом модуле. 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: OLLAMA_HOST = value elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL: CHAT_MODEL = value elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL: EMBED_MODEL = value + elif key == "HLAMINGO_BACKUP_DIR" and value != BACKUP_DIR: + # Пустое значение допустимо: так внешние копии выключают. + BACKUP_DIR = value else: return False 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 +# ============================================================ +# Внешние резервные копии +# ============================================================ +# Куда складывать копию базы и фотографий ВНЕ этого компьютера. Пусто — +# выключено, работают только локальные копии в 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) except ValueError: 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"} diff --git a/hlamingo/store.py b/hlamingo/store.py index 8206923..01107fb 100644 --- a/hlamingo/store.py +++ b/hlamingo/store.py @@ -6,14 +6,19 @@ item — предмет; лежит ЛИБО в ёмкости, ЛИБО прямо в месте (лопата в углу). """ +import io import os import re import json +import time import uuid import shutil +import zipfile import logging from datetime import datetime +from pathlib import Path + from .config import DB_FILE, DATA_DIR logger = logging.getLogger(__name__) @@ -98,6 +103,189 @@ def save(db): tmp = DB_FILE.with_suffix(".json.tmp") tmp.write_text(json.dumps(db, ensure_ascii=False, indent=1), encoding="utf-8") 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 # ============================================================ diff --git a/templates/index.html b/templates/index.html index b4947cc..0f01930 100644 --- a/templates/index.html +++ b/templates/index.html @@ -263,6 +263,29 @@

+ +

Резервные копии

+

Копии внутри программы лежат на этом же диске. Если он умрёт, + пропадут и записи, и фотографии — поэтому копию стоит держать ещё где-то.

+ + + +

Сетевой диск или папка. Пусто — внешние копии выключены. + Копирование идёт само, не чаще раза в час, и не мешает работе, если + диск отвалился.

+
+
+ +
+ + +
+ + +
+

В архиве база и все фотографии. Загрузка ЗАМЕНЯЕТ текущие + данные — прежние сохранятся отдельно, чтобы можно было вернуться.

+
@@ -281,6 +304,7 @@ +