2 Commits

Author SHA1 Message Date
hnzio 1839328dfe Harden local secrets and backup restore 2026-06-05 16:23:38 +02:00
hnzio 01b8128835 Add Home Assistant shopping API 2026-05-03 14:43:00 +02:00
10 changed files with 451 additions and 4 deletions
+4
View File
@@ -12,3 +12,7 @@ dist
build
data
instance
.cloudron-push.env
.env.local
.env.push.local
nouri.sqlite3
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
data/
instance/
nouri.sqlite3
.cloudron-push.env
.env.local
.env.push.local
+210
View File
@@ -0,0 +1,210 @@
# Home Assistant
Nouri stellt für Home Assistant eine kleine token-geschützte JSON-API bereit. Sie ist für Sprachbefehle wie diese gedacht:
- `Füge Erbsen auf die Einkaufsliste hinzu`
- `Füge Erbsen mit Hinweis TK auf die Einkaufsliste hinzu`
- `Füge Blumenerde als Einkaufsartikel hinzu`
- `Füge Skyr als Lebensmittel hinzu`
## Nouri konfigurieren
Setze in der Nouri-Umgebung:
```bash
NOURI_HA_TOKEN=<langes-zufälliges-token>
NOURI_HA_USER_ID=<optionale-nouri-user-id>
```
`NOURI_HA_USER_ID` ist optional. Ohne diesen Wert nutzt Nouri zuerst den ersten aktiven Admin und danach den ersten aktiven Nutzer.
Lokal zum Testen:
```bash
export NOURI_HA_TOKEN='ein-langes-zufälliges-token'
flask --app wsgi run --debug
```
Auf Cloudron:
```bash
cloudron env set --app <nouri-app> NOURI_HA_TOKEN='ein-langes-zufälliges-token'
cloudron restart --app <nouri-app>
```
## Home Assistant vorbereiten
Lege in Home Assistant in `secrets.yaml` diese Werte an:
```yaml
nouri_ha_token: ein-langes-zufälliges-token
nouri_url: https://deine-nouri-domain.example.org
```
Wenn Home Assistant lokal auf Nouri zugreift, nimm die aus Home Assistant erreichbare Adresse, zum Beispiel:
```yaml
nouri_url: http://192.168.178.50:5000
```
`127.0.0.1` funktioniert nur, wenn Home Assistant und Nouri im selben Prozess- oder Container-Kontext laufen.
## Eintrag hinzufügen
```http
POST /api/home-assistant/shopping
Authorization: Bearer <NOURI_HA_TOKEN>
Content-Type: application/json
```
```json
{
"name": "Erbsen",
"note": "TK"
}
```
Wenn Nouri den Eintrag kennt, wird er direkt auf die Einkaufsliste gesetzt. `note` landet im bestehenden Feld `Einkaufshinweis`.
Wenn Nouri den Eintrag nicht kennt, kommt eine Rückfrage-Antwort:
```json
{
"ok": true,
"status": "needs_confirmation",
"name": "Erbsen",
"note": "TK",
"options": ["food", "shopping"],
"message": "Erbsen kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?"
}
```
## Rückfrage bestätigen
```http
POST /api/home-assistant/shopping/confirm
Authorization: Bearer <NOURI_HA_TOKEN>
Content-Type: application/json
```
```json
{
"name": "Erbsen",
"note": "TK",
"create_as": "food"
}
```
Mögliche Werte für `create_as`:
- `food` für Lebensmittel
- `shopping` für reine Einkaufsartikel
Alternativ kann der erste Endpoint mit `confirm_create: true` verwendet werden:
```json
{
"name": "Blumenerde",
"create_as": "shopping",
"confirm_create": true
}
```
## REST Commands
Trage in `configuration.yaml` ein:
```yaml
rest_command:
nouri_shopping:
url: "{{ url }}/api/home-assistant/shopping"
method: post
headers:
X-Nouri-Token: !secret nouri_ha_token
Content-Type: "application/json"
payload: >
{
"name": "{{ name }}",
"note": "{{ note | default('') }}",
"create_as": "{{ create_as | default('') }}",
"confirm_create": {{ confirm_create | default(false) | tojson }}
}
nouri_shopping_confirm:
url: "{{ url }}/api/home-assistant/shopping/confirm"
method: post
headers:
X-Nouri-Token: !secret nouri_ha_token
Content-Type: "application/json"
payload: >
{
"name": "{{ name }}",
"note": "{{ note | default('') }}",
"create_as": "{{ create_as }}"
}
```
Danach Home Assistant neu starten oder die YAML-Konfiguration neu laden.
## Manuell testen
In Home Assistant:
1. Öffne `Entwicklerwerkzeuge`.
2. Öffne `Aktionen`.
3. Wähle `rest_command.nouri_shopping`.
4. Nutze zum Testen:
```yaml
url: !secret nouri_url
name: Erbsen
note: TK
```
Wenn `Erbsen` in Nouri existiert, wird es auf die Einkaufsliste gesetzt. Wenn nicht, antwortet Nouri mit `needs_confirmation`.
## Assist-Sprachsätze
Minimal in `configuration.yaml`:
```yaml
conversation:
intents:
NouriShopping:
- "füge {name} auf die einkaufsliste hinzu"
- "füge {name} mit hinweis {note} auf die einkaufsliste hinzu"
- "füge {name} als lebensmittel hinzu"
- "füge {name} als einkaufsartikel hinzu"
```
Dazu ein Intent Script:
```yaml
intent_script:
NouriShopping:
action:
- action: rest_command.nouri_shopping
data:
url: !secret nouri_url
name: "{{ name }}"
note: "{{ note | default('') }}"
create_as: >
{% if 'lebensmittel' in text | default('') | lower %}food{% elif 'einkaufsartikel' in text | default('') | lower %}shopping{% else %}{% endif %}
confirm_create: false
response_variable: nouri_response
- stop: "Nouri Antwort"
response_variable: nouri_response
speech:
text: >
{{ action_response.content.message }}
```
## Rückfrage-Logik
Der erste Schritt ist damit fertig: bekannte Lebensmittel oder Einkaufsartikel landen direkt auf der Nouri-Einkaufsliste.
Wenn Nouri mit `needs_confirmation` antwortet, muss Home Assistant im nächsten Schritt nachfragen:
> Soll ich das als Lebensmittel oder als Einkaufsartikel anlegen?
Danach ruft Home Assistant `/api/home-assistant/shopping/confirm` mit `create_as: food` oder `create_as: shopping` auf. Diese zweite Stufe kann je nach Setup über Assist, eine Automation, eine mobile Benachrichtigung oder später über Alexa umgesetzt werden.
+4
View File
@@ -47,6 +47,8 @@ Wichtige Umgebungsvariablen:
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
- `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin`
- `NOURI_HA_TOKEN`: optionaler Bearer Token fuer die Home-Assistant-API
- `NOURI_HA_USER_ID`: optionaler Nutzer, unter dem Home Assistant Eintraege anlegt
- `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push
- `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
@@ -102,6 +104,8 @@ Für eine saubere produktive Installation sind diese Werte sinnvoll:
- `NOURI_SECURE_COOKIES=1`
- `NOURI_TIMEZONE=Europe/Berlin`
- `NOURI_SECRET_KEY=<eigenes-secret>`
- `NOURI_HA_TOKEN=<eigenes-home-assistant-token>`
- `NOURI_HA_USER_ID=<optionale-nouri-user-id>`
- `NOURI_VAPID_PUBLIC_KEY=<public-key>`
- `NOURI_VAPID_PRIVATE_KEY=<private-key>`
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
View File
+2
View File
@@ -125,6 +125,8 @@ def create_app() -> Flask:
APP_VERSION=app_version,
RELEASE_URL=release_url,
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
HOME_ASSISTANT_TOKEN=os.environ.get("NOURI_HA_TOKEN", "").strip(),
HOME_ASSISTANT_USER_ID=os.environ.get("NOURI_HA_USER_ID", "").strip(),
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
+6 -2
View File
@@ -73,6 +73,10 @@ def url_with_scroll_position(url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def is_home_assistant_api_endpoint(endpoint: str) -> bool:
return endpoint.startswith("main.home_assistant_")
def normalize_login_value(raw: str) -> str:
return raw.strip().lower()
@@ -136,10 +140,10 @@ def load_logged_in_user():
g.user = None
endpoint = request.endpoint or ""
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"} and not is_home_assistant_api_endpoint(endpoint):
return redirect(url_for("auth.setup"))
if request.method == "POST" and endpoint != "static":
if request.method == "POST" and endpoint != "static" and not is_home_assistant_api_endpoint(endpoint):
token = session.get("_csrf_token")
form_token = request.form.get("csrf_token")
if not token or token != form_token:
+5 -1
View File
@@ -77,6 +77,7 @@ def export_backup_archive(
def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-"))
temp_root = temp_dir.resolve()
for member in archive.infolist():
if not member.filename.startswith("uploads/") or member.is_dir():
continue
@@ -84,8 +85,11 @@ def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
if not relative_target:
continue
target_path = temp_dir / relative_target
resolved_target = target_path.resolve()
if not resolved_target.is_relative_to(temp_root):
raise ValueError("Das Backup enthält einen ungültigen Upload-Pfad.")
target_path.parent.mkdir(parents=True, exist_ok=True)
with archive.open(member, "r") as source, target_path.open("wb") as destination:
with archive.open(member, "r") as source, resolved_target.open("wb") as destination:
shutil.copyfileobj(source, destination)
return temp_dir
+218
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, timedelta
import functools
import secrets
from io import BytesIO
from itertools import product
from pathlib import Path
@@ -1009,6 +1011,143 @@ def normalize_new_item_name(value: str | None) -> str:
return " ".join((value or "").strip().split())[:120]
def normalize_home_assistant_create_kind(value: str | None) -> str | None:
normalized = (value or "").strip().lower()
if normalized in {"food", "lebensmittel"}:
return "food"
if normalized in {"shopping", "article", "artikel", "einkaufsartikel"}:
return "shopping"
return None
def home_assistant_api_user():
configured_user_id = current_app.config.get("HOME_ASSISTANT_USER_ID", "")
if str(configured_user_id).isdigit():
return get_db().execute(
"""
SELECT users.*,
households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.is_active = 1
AND users.id = ?
LIMIT 1
""",
(int(configured_user_id),),
).fetchone()
row = get_db().execute(
"""
SELECT users.*,
households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.is_active = 1
AND users.role = 'admin'
ORDER BY users.id
LIMIT 1
""",
).fetchone()
if row is not None:
return row
return get_db().execute(
"""
SELECT users.*,
households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.is_active = 1
ORDER BY users.id
LIMIT 1
"""
).fetchone()
def home_assistant_api_required(view):
@functools.wraps(view)
def wrapped_view(*args, **kwargs):
configured_token = current_app.config.get("HOME_ASSISTANT_TOKEN", "")
if not configured_token:
return jsonify({
"ok": False,
"status": "not_configured",
"message": "Home Assistant ist in Nouri noch nicht konfiguriert.",
}), 503
auth_header = request.headers.get("Authorization", "")
bearer_token = auth_header.removeprefix("Bearer ").strip() if auth_header.startswith("Bearer ") else ""
request_token = bearer_token or request.headers.get("X-Nouri-Token", "").strip()
if not request_token or not secrets.compare_digest(request_token, configured_token):
return jsonify({
"ok": False,
"status": "unauthorized",
"message": "Der Home-Assistant-Token passt nicht.",
}), 401
api_user = home_assistant_api_user()
if api_user is None:
return jsonify({
"ok": False,
"status": "no_user",
"message": "In Nouri gibt es noch keinen aktiven Nutzer fuer Home Assistant.",
}), 503
g.user = api_user
return view(*args, **kwargs)
return wrapped_view
def home_assistant_payload() -> dict:
payload = request.get_json(silent=True)
return payload if isinstance(payload, dict) else {}
def home_assistant_truthy(value) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "ja", "on"}
def item_api_payload(item: dict) -> dict:
return {
"id": item["id"],
"name": item["name"],
"kind": item["kind"],
"visibility": item["visibility"],
}
def shopping_api_message(status: str, item_name: str, note: str = "") -> str:
note_suffix = f" mit Hinweis {note}" if note else ""
if status == "added":
return f"{item_name} wurde{note_suffix} auf die Einkaufsliste gesetzt."
if status == "duplicate":
return f"{item_name} steht{note_suffix} schon auf der Einkaufsliste."
return f"{item_name} wurde verarbeitet."
def add_item_to_shopping_api(item: dict, note: str) -> dict:
result = ensure_item_or_missing_components_are_shopped(
item["id"],
g.user["id"],
item["visibility"],
shopping_note=note,
)
status = "added" if result["count"] else "duplicate"
return {
"ok": True,
"status": status,
"item": item_api_payload(item),
"note": note,
"result": result,
"message": shopping_api_message(status, item["name"], note),
}
def schedule_shopping_need(
*,
item_id: int,
@@ -4588,6 +4727,85 @@ def item_remove_from_shopping(item_id: int):
return redirect(request.referrer or url_for("main.shopping_list"))
@main_bp.post("/api/home-assistant/shopping")
@home_assistant_api_required
def home_assistant_shopping():
payload = home_assistant_payload()
item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query"))
note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note"))
create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind"))
confirm_create = home_assistant_truthy(payload.get("confirm_create"))
if not item_name:
return jsonify({
"ok": False,
"status": "missing_name",
"message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.",
}), 400
item = find_shopping_item_by_name(item_name)
if item is not None:
return jsonify(add_item_to_shopping_api(item, note))
if confirm_create:
if create_as is None:
return jsonify({
"ok": False,
"status": "missing_create_kind",
"message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.",
"name": item_name,
"note": note,
"options": ["food", "shopping"],
}), 400
existing_before_create = find_shopping_item_by_name(item_name)
item = create_shopping_search_item(item_name, create_as)
response = add_item_to_shopping_api(item, note)
response["created"] = existing_before_create is None
return jsonify(response)
suggested_kind = create_as or "shopping"
return jsonify({
"ok": True,
"status": "needs_confirmation",
"name": item_name,
"note": note,
"suggested_create_as": suggested_kind,
"options": ["food", "shopping"],
"message": f"{item_name} kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?",
})
@main_bp.post("/api/home-assistant/shopping/confirm")
@home_assistant_api_required
def home_assistant_shopping_confirm():
payload = home_assistant_payload()
item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query"))
note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note"))
create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind"))
if not item_name:
return jsonify({
"ok": False,
"status": "missing_name",
"message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.",
}), 400
if create_as is None:
return jsonify({
"ok": False,
"status": "missing_create_kind",
"message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.",
"name": item_name,
"note": note,
"options": ["food", "shopping"],
}), 400
existing_before_create = find_shopping_item_by_name(item_name)
item = create_shopping_search_item(item_name, create_as)
response = add_item_to_shopping_api(item, note)
response["created"] = existing_before_create is None
return jsonify(response)
@main_bp.route("/shopping", methods=("GET", "POST"))
@login_required
def shopping_list():
+1 -1
View File
@@ -1,5 +1,5 @@
Flask==3.1.1
gunicorn==23.0.0
pywebpush==2.3.0
Pillow==11.2.1; python_version < "3.14"
Pillow==12.2.0; python_version < "3.14"
fpdf2==2.8.3