Add recipe share links with configurable validity, public guest access, and revocation
This commit is contained in:
+170
-2
@@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, date
|
import secrets
|
||||||
from typing import List, Dict, Any, Optional
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -524,6 +525,21 @@ def init_db():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
cursor.execute("DELETE FROM dishes")
|
||||||
else:
|
else:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -631,6 +647,21 @@ def init_db():
|
|||||||
if "note" not in existing_shop_cols:
|
if "note" not in existing_shop_cols:
|
||||||
cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''")
|
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")
|
cursor.execute("DELETE FROM dishes")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
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
@@ -60,7 +60,12 @@ from .database import (
|
|||||||
reset_default_shopping_categories,
|
reset_default_shopping_categories,
|
||||||
update_shopping_item_category,
|
update_shopping_item_category,
|
||||||
update_shopping_item,
|
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__))
|
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")
|
@app.middleware("http")
|
||||||
async def auth_middleware(request: Request, call_next):
|
async def auth_middleware(request: Request, call_next):
|
||||||
path = request.url.path
|
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)
|
return await call_next(request)
|
||||||
|
|
||||||
token = request.cookies.get(COOKIE_NAME)
|
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)
|
@app.get("/cookbook/new", response_class=HTMLResponse)
|
||||||
async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)):
|
async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)):
|
||||||
"""Eingabemaske für neues Rezept"""
|
"""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))
|
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
|
# API: ZUFALLSGENERATOR & GERICHTE
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
+2990
-2691
File diff suppressed because it is too large
Load Diff
+991
-399
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user