Add recipe share links with configurable validity, public guest access, and revocation

This commit is contained in:
2026-10-23 14:47:34 +02:00
parent 42f3de191c
commit e3bf88f018
6 changed files with 4498 additions and 3094 deletions
+170 -2
View File
@@ -1,8 +1,9 @@
import os
import re
import sqlite3
from datetime import datetime, date
from typing import List, Dict, Any, Optional
import secrets
from datetime import datetime, date, timedelta
from typing import List, Dict, Any, Optional, Tuple
try:
from dotenv import load_dotenv
@@ -524,6 +525,21 @@ def init_db():
except Exception:
pass
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipe_shares (
id INT AUTO_INCREMENT PRIMARY KEY,
recipe_id INT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NULL DEFAULT NULL,
views_count INT DEFAULT 0,
revoked TINYINT DEFAULT 0,
label VARCHAR(100) DEFAULT '',
INDEX idx_share_token (token),
INDEX idx_share_recipe (recipe_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
@@ -631,6 +647,21 @@ def init_db():
if "note" not in existing_shop_cols:
cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipe_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NULL,
views_count INTEGER DEFAULT 0,
revoked INTEGER DEFAULT 0,
label TEXT DEFAULT ''
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_token ON recipe_shares (token)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_recipe ON recipe_shares (recipe_id)")
cursor.execute("DELETE FROM dishes")
conn.commit()
finally:
@@ -1633,3 +1664,140 @@ def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]:
# --- Rezept-Freigaben (Share Links) ---
def parse_db_datetime(dt_val: Any) -> Optional[datetime]:
if not dt_val:
return None
if isinstance(dt_val, datetime):
return dt_val
try:
return datetime.fromisoformat(str(dt_val))
except Exception:
try:
return datetime.strptime(str(dt_val).split(".")[0], "%Y-%m-%d %H:%M:%S")
except Exception:
return None
def format_share_expiry(dt_val: Any) -> Tuple[str, str, bool]:
"""
Gibt (formatted_date, relative_status, is_expired) zurück.
"""
dt = parse_db_datetime(dt_val)
if not dt:
return ("Unbegrenzt", "Unbegrenzt gültig", False)
now = datetime.now()
is_expired = now > dt
date_str = dt.strftime("%d.%m.%Y %H:%M")
if is_expired:
return (date_str, f"Abgelaufen am {date_str} Uhr", True)
delta = dt - now
if delta.days > 0:
days = delta.days
rel = f"Noch {days} {'Tag' if days == 1 else 'Tage'} gültig"
else:
hours = max(1, int(delta.total_seconds() // 3600))
rel = f"Noch {hours} {'Stunde' if hours == 1 else 'Stunden'} gültig"
return (date_str, rel, False)
def create_recipe_share(recipe_id: int, expires_in_seconds: Optional[int] = None, label: str = "") -> Dict[str, Any]:
token = secrets.token_urlsafe(16)
expires_at_dt = None
expires_at_sql = None
if expires_in_seconds is not None and expires_in_seconds > 0:
expires_at_dt = datetime.now() + timedelta(seconds=expires_in_seconds)
expires_at_sql = expires_at_dt.strftime("%Y-%m-%d %H:%M:%S")
res = execute_query(
"INSERT INTO recipe_shares (recipe_id, token, expires_at, label) VALUES (?, ?, ?, ?)",
(recipe_id, token, expires_at_sql, label.strip()),
fetch="none",
commit=True
)
last_id = res.get("lastrowid") if isinstance(res, dict) else None
fmt_date, rel_status, is_exp = format_share_expiry(expires_at_dt)
return {
"id": last_id,
"recipe_id": recipe_id,
"token": token,
"created_at": datetime.now().isoformat(),
"expires_at": expires_at_dt.isoformat() if expires_at_dt else None,
"expires_at_formatted": fmt_date,
"relative_status": rel_status,
"is_expired": is_exp,
"views_count": 0,
"revoked": False,
"label": label.strip()
}
def get_recipe_share_by_token(token: str, record_view: bool = False) -> Optional[Dict[str, Any]]:
row = execute_query(
"SELECT * FROM recipe_shares WHERE token = ? LIMIT 1",
(token.strip(),),
fetch="one"
)
if not row:
return None
fmt_date, rel_status, is_exp = format_share_expiry(row.get("expires_at"))
is_revoked = bool(row.get("revoked", 0))
if record_view and not is_revoked and not is_exp:
execute_query("UPDATE recipe_shares SET views_count = views_count + 1 WHERE id = ?", (row["id"],), commit=True)
row["views_count"] = row.get("views_count", 0) + 1
row["is_expired"] = is_exp
row["revoked"] = is_revoked
row["expires_at_formatted"] = fmt_date
row["relative_status"] = rel_status
row["is_active"] = (not is_revoked and not is_exp)
return row
def get_recipe_shares_for_recipe(recipe_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"SELECT * FROM recipe_shares WHERE recipe_id = ? ORDER BY id DESC",
(recipe_id,),
fetch="all"
)
results = []
for r in (rows or []):
fmt_date, rel_status, is_exp = format_share_expiry(r.get("expires_at"))
is_revoked = bool(r.get("revoked", 0))
r["is_expired"] = is_exp
r["revoked"] = is_revoked
r["expires_at_formatted"] = fmt_date
r["relative_status"] = rel_status
r["is_active"] = (not is_revoked and not is_exp)
results.append(r)
return results
def delete_recipe_share(share_id: int, recipe_id: int) -> bool:
res = execute_query(
"DELETE FROM recipe_shares WHERE id = ? AND recipe_id = ?",
(share_id, recipe_id),
fetch="none",
commit=True
)
return bool(res and res.get("rowcount", 0) > 0)
def revoke_recipe_share(share_id: int, recipe_id: int) -> bool:
res = execute_query(
"UPDATE recipe_shares SET revoked = 1 WHERE id = ? AND recipe_id = ?",
(share_id, recipe_id),
fetch="none",
commit=True
)
return bool(res and res.get("rowcount", 0) > 0)
+109 -2
View File
@@ -60,7 +60,12 @@ from .database import (
reset_default_shopping_categories,
update_shopping_item_category,
update_shopping_item,
SHELF_TYPES
SHELF_TYPES,
create_recipe_share,
get_recipe_share_by_token,
get_recipe_shares_for_recipe,
delete_recipe_share,
revoke_recipe_share
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -110,7 +115,7 @@ app = FastAPI(title="What To Cook? - Zufallsgenerator & Kochbuch", lifespan=life
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/static/") or path in PUBLIC_PATHS:
if path.startswith("/static/") or path.startswith("/shared/") or path in PUBLIC_PATHS:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME)
@@ -512,6 +517,44 @@ async def recipe_detail_view(request: Request, recipe_id: int):
}
)
@app.get("/shared/recipe/{token}", response_class=HTMLResponse)
async def shared_recipe_view(request: Request, token: str):
"""Öffentlich freigegebene Rezeptseite (mit Gültigkeitsprüfung)"""
share = get_recipe_share_by_token(token, record_view=True)
if not share or share.get("is_expired") or share.get("revoked"):
return templates.TemplateResponse(
request=request,
name="shared_expired.html",
context={
"share": share,
"reason": "revoked" if (share and share.get("revoked")) else ("expired" if (share and share.get("is_expired")) else "not_found")
},
status_code=410 if (share and (share.get("is_expired") or share.get("revoked"))) else 404
)
recipe = get_recipe(share["recipe_id"])
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
recipe["has_portion_table"] = is_matrix
if is_matrix:
parsed = parse_ingredients_block(recipe.get("ingredients", ""))
recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8))
return templates.TemplateResponse(
request=request,
name="recipe_detail.html",
context={
"recipe": recipe,
"chapters": CHAPTERS,
"is_shared": True,
"share": share
}
)
@app.get("/cookbook/new", response_class=HTMLResponse)
async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)):
"""Eingabemaske für neues Rezept"""
@@ -720,6 +763,70 @@ def api_toggle_recipe_printed(recipe_id: int):
raise HTTPException(status_code=404, detail=str(e))
# --- Rezept-Share API ---
class CreateShareRequest(BaseModel):
expires_in: Optional[str] = "7d"
label: Optional[str] = ""
def parse_expiry_to_seconds(val: Optional[str]) -> Optional[int]:
if not val or val == "unlimited" or val == "0" or val == "never":
return None
val = val.lower().strip()
if val.endswith("h"):
return int(float(val[:-1]) * 3600)
elif val.endswith("d"):
return int(float(val[:-1]) * 86400)
elif val.endswith("w"):
return int(float(val[:-1]) * 7 * 86400)
elif val.endswith("m"):
return int(float(val[:-1]) * 30 * 86400)
try:
sec = int(val)
return sec if sec > 0 else None
except ValueError:
return 7 * 86400
def get_base_url_for_request(request: Request) -> str:
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
return f"{scheme}://{host}".rstrip("/")
@app.get("/api/recipes/{recipe_id}/shares")
def api_get_recipe_shares(request: Request, recipe_id: int):
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
shares = get_recipe_shares_for_recipe(recipe_id)
base_url = get_base_url_for_request(request)
for s in shares:
s["share_url"] = f"{base_url}/shared/recipe/{s['token']}"
s["share_path"] = f"/shared/recipe/{s['token']}"
if isinstance(s.get("created_at"), (datetime, date)):
s["created_at"] = s["created_at"].strftime("%d.%m.%Y %H:%M")
return {"status": "ok", "shares": shares}
@app.post("/api/recipes/{recipe_id}/shares")
def api_create_recipe_share(request: Request, recipe_id: int, payload: CreateShareRequest):
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
sec = parse_expiry_to_seconds(payload.expires_in)
share = create_recipe_share(recipe_id, expires_in_seconds=sec, label=payload.label or "")
base_url = get_base_url_for_request(request)
share["share_url"] = f"{base_url}/shared/recipe/{share['token']}"
share["share_path"] = f"/shared/recipe/{share['token']}"
return {"status": "ok", "share": share}
@app.delete("/api/recipes/{recipe_id}/shares/{share_id}")
def api_delete_recipe_share(recipe_id: int, share_id: int):
success = delete_recipe_share(share_id, recipe_id)
if not success:
raise HTTPException(status_code=404, detail="Share-Link nicht gefunden oder bereits gelöscht.")
return {"status": "ok", "message": "Share-Link wurde erfolgreich gelöscht."}
# ==========================================
# API: ZUFALLSGENERATOR & GERICHTE
# ==========================================
+2990 -2691
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Link nicht mehr gültig – Mein Kochbuch</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.7.1">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⏳</text></svg>">
<style>
.expired-page-wrapper {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.expired-card {
background: var(--creme, #faf6f0);
border: 2.5px solid var(--schwarz, #2a2a2a);
border-radius: 16px;
box-shadow: 6px 6px 0px rgba(30, 77, 59, 0.4);
max-width: 520px;
width: 100%;
padding: 36px 28px;
text-align: center;
position: relative;
}
.expired-icon {
font-size: 3.5rem;
margin-bottom: 16px;
display: inline-block;
animation: float-gentle 3s ease-in-out infinite;
}
.expired-title {
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 2.2rem;
color: var(--tannengruen, #1e4d3b);
margin: 0 0 14px 0;
line-height: 1.2;
}
.expired-desc {
font-size: 1.05rem;
color: #4a4a4a;
line-height: 1.6;
margin-bottom: 20px;
}
.expired-meta-box {
background: rgba(30, 77, 59, 0.07);
border: 1.5px dashed var(--tannengruen, #1e4d3b);
border-radius: 10px;
padding: 12px 16px;
font-size: 0.92rem;
color: var(--tannengruen, #1e4d3b);
margin-bottom: 24px;
}
.expired-actions {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
@keyframes float-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
</style>
</head>
<body class="cookbook-body">
<header class="cb-nav">
<div class="cb-nav-brand">
<span class="cb-brand-icon">📖</span>
<h1 class="cb-nav-title">Mein Kochbuch</h1>
</div>
</header>
<main class="expired-page-wrapper">
<div class="expired-card">
<div class="expired-icon">
{% if reason == 'revoked' %}🔒{% else %}⏳{% endif %}
</div>
<h2 class="expired-title">
{% if reason == 'revoked' %}
Freigabe wurde deaktiviert
{% elif reason == 'expired' %}
Rezept-Link ist abgelaufen
{% else %}
Link nicht gefunden
{% endif %}
</h2>
<p class="expired-desc">
{% if reason == 'revoked' %}
Dieser Share-Link wurde vom Ersteller des Rezepts vorzeitig zurückgezogen.
{% elif reason == 'expired' %}
Die zeitliche Gültigkeit für diesen geteilten Rezept-Link ist leider abgelaufen. Bitte frage die Person, die das Rezept geteilt hat, nach einem neuen Link.
{% else %}
Der aufgerufene Share-Link ist ungültig oder existiert nicht mehr.
{% endif %}
</p>
{% if share and share.expires_at_formatted and reason == 'expired' %}
<div class="expired-meta-box">
Ablaufzeitpunkt: <strong>{{ share.expires_at_formatted }} Uhr</strong>
</div>
{% endif %}
<div class="expired-actions">
<a href="/login" class="cb-btn cb-btn-green">
<span>📖</span> <span>Zum Kochbuch / Anmelden</span>
</a>
</div>
</div>
</main>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
import os
import sys
# Ensure project root is in sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from fastapi.testclient import TestClient
from app.main import app, create_auth_token, COOKIE_NAME
from app.database import (
create_recipe_share,
get_recipe_share_by_token,
get_recipe_shares_for_recipe,
delete_recipe_share,
get_recipe
)
def run_tests():
client = TestClient(app)
recipe = get_recipe(1)
assert recipe is not None, "Recipe ID 1 must exist for testing"
recipe_id = recipe["id"]
print("--- 1. Testing Database Share Creation & Expiry Logic ---")
# Share for 24 hours
s24 = create_recipe_share(recipe_id, expires_in_seconds=86400, label="WhatsApp Familie")
assert s24["token"], "Must have generated token"
assert s24["is_expired"] is False, "Should not be expired"
assert "Noch 23" in s24["relative_status"] or "Noch 24" in s24["relative_status"] or "Noch 1 Tag" in s24["relative_status"]
print(f"[OK] 24h share created: {s24['token']} -> {s24['relative_status']}")
# Unlimited share
sunlim = create_recipe_share(recipe_id, expires_in_seconds=None, label="Dauerhaft")
assert sunlim["expires_at"] is None, "Unlimited share should have None expires_at"
assert sunlim["relative_status"] == "Unbegrenzt gültig"
print(f"[OK] Unlimited share created: {sunlim['token']} -> {sunlim['relative_status']}")
print("\n--- 2. Testing Public Access to Shared Recipe (No Auth Cookie) ---")
resp = client.get(f"/shared/recipe/{s24['token']}")
assert resp.status_code == 200, f"Expected 200 OK for public share link, got {resp.status_code}"
assert recipe["title"] in resp.text, "Shared page must contain recipe title"
assert "recipe-shared-banner" in resp.text, "Must render shared guest banner"
assert "Gastansicht" in resp.text, "Must have guest badge"
assert "Kochmodus" in resp.text, "Guest must have access to Kochmodus"
# Ensure private admin buttons are hidden in guest view
assert "Alle Rezepte" not in resp.text or "cb-nav-actions" not in resp.text or "/logout" not in resp.text
print("[OK] Public access without auth verified: Recipe renders in guest view with Kochmodus!")
# Check view count incremented
fetched = get_recipe_share_by_token(s24["token"])
assert fetched["views_count"] >= 1, f"Views count should be >= 1, got {fetched['views_count']}"
print(f"[OK] View count incremented: {fetched['views_count']} views recorded")
print("\n--- 3. Testing Non-Existent & Expired Links ---")
resp_invalid = client.get("/shared/recipe/does_not_exist_xyz123")
assert resp_invalid.status_code == 404, f"Expected 404, got {resp_invalid.status_code}"
assert "Link nicht mehr" in resp_invalid.text or "ungültig" in resp_invalid.text
print("[OK] Invalid token handled with friendly 404 page")
# Expired token test (past expiry)
from datetime import datetime, timedelta
from app.database import execute_query
expired_token = "test_expired_token_123"
past_date = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
execute_query("DELETE FROM recipe_shares WHERE token = ?", (expired_token,), fetch="none", commit=True)
execute_query("INSERT INTO recipe_shares (recipe_id, token, expires_at, label) VALUES (?, ?, ?, ?)", (recipe_id, expired_token, past_date, "Old"), fetch="none", commit=True)
resp_exp = client.get(f"/shared/recipe/{expired_token}")
assert resp_exp.status_code == 410, f"Expected 410 Gone for expired link, got {resp_exp.status_code}"
assert "abgelaufen" in resp_exp.text.lower()
print("[OK] Expired link correctly returns 410 Gone with expiration notice")
print("\n--- 4. Testing Authenticated Share Management APIs ---")
# Anonymous request to API must fail
resp_unauth = client.get(f"/api/recipes/{recipe_id}/shares")
assert resp_unauth.status_code == 401, f"Expected 401 Unauthorized for API without cookie, got {resp_unauth.status_code}"
print("[OK] Unauthenticated API request correctly rejected with 401")
# Authenticated request
auth_token = create_auth_token("admin", days=1)
client.cookies.set(COOKIE_NAME, auth_token)
# List shares
resp_list = client.get(f"/api/recipes/{recipe_id}/shares")
assert resp_list.status_code == 200, f"Expected 200, got {resp_list.status_code}"
shares = resp_list.json()["shares"]
assert any(s["token"] == s24["token"] for s in shares)
print(f"[OK] Authenticated GET /api/recipes/{recipe_id}/shares returned {len(shares)} shares")
# Create new share via API
resp_create = client.post(
f"/api/recipes/{recipe_id}/shares",
json={"expires_in": "3d", "label": "Kollege"}
)
assert resp_create.status_code == 200, f"Expected 200, got {resp_create.status_code}"
created_share = resp_create.json()["share"]
assert created_share["token"]
assert "share_url" in created_share
assert "Noch 2" in created_share["relative_status"] or "Noch 3" in created_share["relative_status"]
print(f"[OK] POST /api/recipes/{recipe_id}/shares created: {created_share['share_url']}")
# Delete share via API
resp_del = client.delete(f"/api/recipes/{recipe_id}/shares/{created_share['id']}")
assert resp_del.status_code == 200, f"Expected 200, got {resp_del.status_code}"
# Verify it is no longer accessible
resp_after_del = client.get(f"/shared/recipe/{created_share['token']}")
assert resp_after_del.status_code in (404, 410)
print("[OK] DELETE /api/recipes/{recipe_id}/shares/{share_id} deleted and revoked access")
# Clean up test shares
delete_recipe_share(s24["id"], recipe_id)
delete_recipe_share(sunlim["id"], recipe_id)
execute_query("DELETE FROM recipe_shares WHERE token = ?", (expired_token,), fetch="none", commit=True)
print("[OK] Test data cleanup complete")
print("\n==========================================")
print("ALL RECIPE SHARING TESTS PASSED SUCCESSFULLY!")
print("==========================================")
if __name__ == "__main__":
run_tests()