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
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
@@ -2690,3 +2690,302 @@ body.cook-mode-on .cookmode-badge {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
REZEPT-FREIGABE & SHARE LINKS (v4.7.1)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.recipe-shared-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: linear-gradient(135deg, rgba(30, 77, 59, 0.08) 0%, rgba(212, 160, 23, 0.1) 100%);
|
||||||
|
border: 2px dashed var(--tannengruen);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-shared-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-shared-icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-shared-main-text {
|
||||||
|
font-family: var(--font-hand);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tannengruen);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-shared-sub-text {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #4a5d52;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-shared-badge {
|
||||||
|
background: var(--tannengruen);
|
||||||
|
color: var(--creme);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-create-box {
|
||||||
|
background: rgba(30, 77, 59, 0.04);
|
||||||
|
border: 1.5px solid rgba(30, 77, 59, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--tannengruen);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-expiry-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-pill {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-pill input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-pill span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 7px 13px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid #ccc;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--schwarz);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-pill input:checked + span {
|
||||||
|
background: var(--tannengruen);
|
||||||
|
border-color: var(--tannengruen);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(30, 77, 59, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-pill:hover span {
|
||||||
|
border-color: var(--tannengruen);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-input {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1.5px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
background: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-input:focus {
|
||||||
|
border-color: var(--tannengruen);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-result-box {
|
||||||
|
background: #eef7f2;
|
||||||
|
border: 2px solid var(--tannengruen);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tannengruen);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-result-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-url-field {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1.5px solid var(--tannengruen);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-result-hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-existing-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-existing-title {
|
||||||
|
font-family: var(--font-hand);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--tannengruen);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid #e0ded9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-card.is-expired {
|
||||||
|
background: #fdf6f6;
|
||||||
|
border-color: #f0d0d0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-status-pill {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-badge-active {
|
||||||
|
background: #e1f5ec;
|
||||||
|
color: #127c52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-badge-expired {
|
||||||
|
background: #fbeae9;
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #777;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card-url-preview {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-mini-url {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #666;
|
||||||
|
background: #f6f5f1;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-btn-sm {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-btn-danger {
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid #dc3545;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-btn-danger:hover {
|
||||||
|
background: #dc3545;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-empty-state {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
|
|
||||||
<!-- Navigation Bar -->
|
<!-- Navigation Bar -->
|
||||||
<header class="cb-nav">
|
<header class="cb-nav">
|
||||||
<a href="/cookbook" class="cb-nav-brand">
|
<a href="{% if is_shared %}#{% else %}/cookbook{% endif %}" class="cb-nav-brand">
|
||||||
<span class="cb-brand-icon">📖</span>
|
<span class="cb-brand-icon">📖</span>
|
||||||
<h1 class="cb-nav-title">Kochbuch</h1>
|
<h1 class="cb-nav-title">{% if is_shared %}Mein Kochbuch{% else %}Kochbuch{% endif %}</h1>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
@@ -25,18 +25,25 @@
|
|||||||
<span class="cb-slider"></span>
|
<span class="cb-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not is_shared %}
|
||||||
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
||||||
<span>📖</span> <span>Alle Rezepte</span>
|
<span>📖</span> <span>Alle Rezepte</span>
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" onclick="openShareModal()" class="cb-btn cb-btn-outline" title="Rezept per Link teilen">
|
||||||
|
<span>🔗</span> <span>Teilen</span>
|
||||||
|
</button>
|
||||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
|
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
|
||||||
<span>✏️</span> <span>Bearbeiten</span>
|
<span>✏️</span> <span>Bearbeiten</span>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
<button onclick="window.print()" class="cb-btn cb-btn-outline" title="Rezeptseite drucken">
|
<button onclick="window.print()" class="cb-btn cb-btn-outline" title="Rezeptseite drucken">
|
||||||
<span>🖨️</span> <span>Drucken</span>
|
<span>🖨️</span> <span>Drucken</span>
|
||||||
</button>
|
</button>
|
||||||
|
{% if not is_shared %}
|
||||||
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
|
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
|
||||||
<span>🚪</span> <span>Abmelden</span>
|
<span>🚪</span> <span>Abmelden</span>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Kebab (3-Punkte) Menü -->
|
<!-- Mobile Kebab (3-Punkte) Menü -->
|
||||||
@@ -53,19 +60,26 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<hr class="cb-mobile-divider">
|
<hr class="cb-mobile-divider">
|
||||||
|
{% if not is_shared %}
|
||||||
|
<button type="button" onclick="openShareModal()" class="cb-mobile-item">
|
||||||
|
<span>🔗</span> <span>Rezept teilen</span>
|
||||||
|
</button>
|
||||||
<a href="/cookbook" class="cb-mobile-item">
|
<a href="/cookbook" class="cb-mobile-item">
|
||||||
<span>📖</span> <span>Zum Kochbuch</span>
|
<span>📖</span> <span>Zum Kochbuch</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
|
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
|
||||||
<span>✏️</span> <span>Rezept bearbeiten</span>
|
<span>✏️</span> <span>Rezept bearbeiten</span>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
<button type="button" onclick="window.print()" class="cb-mobile-item">
|
<button type="button" onclick="window.print()" class="cb-mobile-item">
|
||||||
<span>🖨️</span> <span>Drucken & PDF</span>
|
<span>🖨️</span> <span>Drucken & PDF</span>
|
||||||
</button>
|
</button>
|
||||||
|
{% if not is_shared %}
|
||||||
<hr class="cb-mobile-divider">
|
<hr class="cb-mobile-divider">
|
||||||
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
|
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
|
||||||
<span>🚪</span> <span>Abmelden</span>
|
<span>🚪</span> <span>Abmelden</span>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -75,6 +89,28 @@
|
|||||||
<!-- EINHEITLICHE REZEPTSEITE -->
|
<!-- EINHEITLICHE REZEPTSEITE -->
|
||||||
<article class="page recipe-page">
|
<article class="page recipe-page">
|
||||||
|
|
||||||
|
{% if is_shared %}
|
||||||
|
<!-- GAST FREIGABE-BANNER -->
|
||||||
|
<div class="recipe-shared-banner no-print">
|
||||||
|
<div class="recipe-shared-left">
|
||||||
|
<span class="recipe-shared-icon">📤</span>
|
||||||
|
<div class="recipe-shared-text-group">
|
||||||
|
<span class="recipe-shared-main-text">Geteiltes Rezept</span>
|
||||||
|
<span class="recipe-shared-sub-text">
|
||||||
|
{% if share.expires_at %}
|
||||||
|
⏳ {{ share.relative_status }} (Gültig bis {{ share.expires_at_formatted }} Uhr)
|
||||||
|
{% else %}
|
||||||
|
✨ Dauerhafter Link (unbegrenzt gültig)
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-shared-right">
|
||||||
|
<span class="recipe-shared-badge">Gastansicht</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- KOCHMODUS SCHIEBEREGLER BANNER (Mobil, Tablet & Desktop) -->
|
<!-- KOCHMODUS SCHIEBEREGLER BANNER (Mobil, Tablet & Desktop) -->
|
||||||
<div class="recipe-cookmode-banner no-print">
|
<div class="recipe-cookmode-banner no-print">
|
||||||
<div class="recipe-cookmode-left">
|
<div class="recipe-cookmode-left">
|
||||||
@@ -246,6 +282,176 @@
|
|||||||
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{% if not is_shared %}
|
||||||
|
<!-- SHARE MODAL -->
|
||||||
|
<div id="share-modal" class="cb-modal-backdrop" onclick="closeShareModal(event)">
|
||||||
|
<div class="cb-modal-card share-modal-card" onclick="event.stopPropagation()">
|
||||||
|
<div class="cb-modal-header">
|
||||||
|
<h3 class="cb-modal-title">
|
||||||
|
<span>🔗</span> <span>Rezept teilen</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="cb-modal-close" onclick="closeShareModal()" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cb-modal-body">
|
||||||
|
<p style="margin-top: 0; color: #4a4a4a; line-height: 1.5; font-size: 0.96rem;">
|
||||||
|
Erstelle einen Link, um <strong>{{ recipe.title }}</strong> mit Freunden oder Familie zu teilen.
|
||||||
|
Empfänger können das Rezept direkt ansehen, kochen und ausdrucken – <em>ohne eigenes Login</em>!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Neue Freigabe konfigurieren -->
|
||||||
|
<div class="share-create-box">
|
||||||
|
<label class="share-label">Gültigkeitsdauer des Links:</label>
|
||||||
|
<div class="share-expiry-pills">
|
||||||
|
<label class="expiry-pill">
|
||||||
|
<input type="radio" name="share_expiry" value="24h">
|
||||||
|
<span>24 Stunden</span>
|
||||||
|
</label>
|
||||||
|
<label class="expiry-pill">
|
||||||
|
<input type="radio" name="share_expiry" value="3d">
|
||||||
|
<span>3 Tage</span>
|
||||||
|
</label>
|
||||||
|
<label class="expiry-pill">
|
||||||
|
<input type="radio" name="share_expiry" value="7d" checked>
|
||||||
|
<span>7 Tage (Empfohlen)</span>
|
||||||
|
</label>
|
||||||
|
<label class="expiry-pill">
|
||||||
|
<input type="radio" name="share_expiry" value="30d">
|
||||||
|
<span>30 Tage</span>
|
||||||
|
</label>
|
||||||
|
<label class="expiry-pill">
|
||||||
|
<input type="radio" name="share_expiry" value="unlimited">
|
||||||
|
<span>Unbegrenzt</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="share-input-row" style="margin-top: 14px;">
|
||||||
|
<input type="text" id="share-label-input" class="share-input" placeholder="Optionale Notiz (z. B. WhatsApp, Familie...)" maxlength="50">
|
||||||
|
<button type="button" id="btn-create-share" class="cb-btn cb-btn-green" onclick="submitCreateShare()">
|
||||||
|
<span>✨ Link erstellen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Neuer erstellter Link (Highlight Box) -->
|
||||||
|
<div id="share-result-box" class="share-result-box" style="display: none;">
|
||||||
|
<div class="share-result-header">
|
||||||
|
<span>✓ Neuer Share-Link bereit:</span>
|
||||||
|
<span id="share-result-expiry-badge" class="share-status-pill share-badge-active"></span>
|
||||||
|
</div>
|
||||||
|
<div class="share-result-input-group">
|
||||||
|
<input type="text" id="share-result-url" class="share-url-field" readonly onclick="this.select()">
|
||||||
|
<button type="button" id="btn-copy-share" class="cb-btn cb-btn-green" onclick="copyCurrentShareUrl()">
|
||||||
|
<span id="copy-btn-icon">📋</span> <span id="copy-btn-text">Kopieren</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-native-share" class="cb-btn cb-btn-outline" style="display: none;" onclick="triggerNativeShare()">
|
||||||
|
<span>📤 Teilen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="share-result-hint">Jeder mit diesem Link hat für den gewählten Zeitraum vollen Lese- und Kochzugriff.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste existierender Freigaben -->
|
||||||
|
<div class="share-existing-section">
|
||||||
|
<h4 class="share-existing-title">Aktive Freigaben für dieses Rezept</h4>
|
||||||
|
<div id="share-links-list" class="share-links-list">
|
||||||
|
<div class="share-links-loading">Lade aktive Links...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cb-modal-footer" style="padding: 12px 18px; border-top: 1.5px solid #e0ded9; display: flex; justify-content: flex-end; background: #faf6f0;">
|
||||||
|
<button type="button" class="cb-btn cb-btn-outline" onclick="closeShareModal()">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not is_shared %}
|
||||||
|
|
||||||
|
<!-- SHARE MODAL -->
|
||||||
|
|
||||||
|
<div id="share-modal" class="cb-modal-backdrop" onclick="closeShareModal(event)">
|
||||||
|
|
||||||
|
<div class="cb-modal-card share-modal-card" onclick="event.stopPropagation()">
|
||||||
|
|
||||||
|
<div class="cb-modal-header">
|
||||||
|
|
||||||
|
<h3 class="cb-modal-title">
|
||||||
|
|
||||||
|
<span>🔗</span> <span>Rezept teilen</span>
|
||||||
|
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<button type="button" class="cb-modal-close" onclick="closeShareModal()" aria-label="Schließen">×</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="cb-modal-body">
|
||||||
|
|
||||||
|
<p style="margin-top: 0; color: #4a4a4a; line-height: 1.5; font-size: 0.96rem;">
|
||||||
|
|
||||||
|
Erstelle einen Link, um <strong>{{ recipe.title }}</strong> mit Freunden oder Familie zu teilen.
|
||||||
|
|
||||||
|
Empfänger können das Rezept direkt ansehen, kochen und ausdrucken – <em>ohne eigenes Login</em>!
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Neue Freigabe konfigurieren -->
|
||||||
|
|
||||||
|
<div class="share-create-box">
|
||||||
|
|
||||||
|
<label class="share-label">Gültigkeitsdauer des Links:</label>
|
||||||
|
|
||||||
|
<div class="share-expiry-pills">
|
||||||
|
|
||||||
|
<label class="expiry-pill">
|
||||||
|
|
||||||
|
<input type="radio" name="share_expiry" value="24h">
|
||||||
|
|
||||||
|
<span>24 Stunden</span>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="expiry-pill">
|
||||||
|
|
||||||
|
<input type="radio" name="share_expiry" value="3d">
|
||||||
|
|
||||||
|
<span>3 Tage</span>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="expiry-pill">
|
||||||
|
|
||||||
|
<input type="radio" name="share_expiry" value="7d" checked>
|
||||||
|
|
||||||
|
<span>7 Tage (Empfohlen)</span>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="expiry-pill">
|
||||||
|
|
||||||
|
<input type="radio" name="share_expiry" value="30d">
|
||||||
|
|
||||||
|
<span>30 Tage</span>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="expiry-pill">
|
||||||
|
|
||||||
|
<input type="radio" name="share_expiry" value="unlimited">
|
||||||
|
|
||||||
|
<span>Unbegrenzt</span>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="share-input-row" style="margin-top: 14px;">
|
<div class="share-input-row" style="margin-top: 14px;">
|
||||||
|
|
||||||
@@ -394,6 +600,392 @@
|
|||||||
switches.forEach(sw => { sw.checked = active; });
|
switches.forEach(sw => { sw.checked = active; });
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
|
const supported = await requestWakeLock();
|
||||||
|
setCookModeUI(true, supported);
|
||||||
|
showToast(supported ? '✓ Kochmodus an: Display bleibt an 💡' : '✓ Kochmodus aktiv');
|
||||||
|
} else {
|
||||||
|
await releaseWakeLock();
|
||||||
|
setCookModeUI(false, true);
|
||||||
|
showToast('Kochmodus ausgeschaltet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookModeUI(active, supported = true) {
|
||||||
|
if (active) {
|
||||||
|
document.body.classList.add('cook-mode-on');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('cook-mode-on');
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = document.getElementById('cook-mode-badge');
|
||||||
|
const subText = document.getElementById('cookmode-sub-text');
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
if (active) {
|
||||||
|
badge.textContent = supported ? 'Aktiv 💡' : 'Aktiv';
|
||||||
|
} else {
|
||||||
|
badge.textContent = 'Aus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subText) {
|
||||||
|
if (active) {
|
||||||
|
subText.textContent = supported
|
||||||
|
? 'Display bleibt während des Kochens dauerhaft eingeschaltet 💡'
|
||||||
|
: 'Display-Wachhalten nicht vom Browser unterstützt (Interaktive Schritte aktiv)';
|
||||||
|
} else {
|
||||||
|
subText.textContent = 'Verhindert, dass das Display während des Kochens sperrt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn der Nutzer die App/den Tab wechselt und zurückkommt: Wake Lock erneuern!
|
||||||
|
document.addEventListener('visibilitychange', async () => {
|
||||||
|
if (isCookModeActive && document.visibilityState === 'visible') {
|
||||||
|
await requestWakeLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gespeicherten Zustand beim Laden wiederherstellen
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('wtc_cookmode_active') === 'true';
|
||||||
|
if (saved) {
|
||||||
|
const sw = document.getElementById('cook-mode-toggle');
|
||||||
|
if (sw) {
|
||||||
|
sw.checked = true;
|
||||||
|
toggleCookingMode(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interaktive Zutaten-Checkliste (zum Abhaken beim Vorbereiten)
|
||||||
|
function toggleIngredientCheck(el) {
|
||||||
|
el.classList.toggle('is-checked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interaktiver Schritt-Tracker (zum Markieren erledigter Schritte)
|
||||||
|
function toggleStepCheck(el) {
|
||||||
|
el.classList.toggle('is-done');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toast Benachrichtigung
|
||||||
|
function showToast(msg) {
|
||||||
|
let toast = document.getElementById('cook-toast');
|
||||||
|
if (!toast) {
|
||||||
|
toast = document.createElement('div');
|
||||||
|
toast.id = 'cook-toast';
|
||||||
|
toast.className = 'cb-toast';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
}
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.style.display = 'flex';
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.display = 'none';
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SHARE MODAL & FREIGABELINKS
|
||||||
|
// ==========================================
|
||||||
|
let currentShareUrl = '';
|
||||||
|
|
||||||
|
function openShareModal() {
|
||||||
|
const modal = document.getElementById('share-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('active');
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
loadActiveShareLinks();
|
||||||
|
}
|
||||||
|
const menu = document.getElementById('cb-mobile-menu');
|
||||||
|
if (menu) menu.classList.remove('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShareModal(e) {
|
||||||
|
if (e && e.target && e.target.classList && !e.target.classList.contains('cb-modal-backdrop') && !e.target.classList.contains('cb-modal-close') && e.target.tagName !== 'BUTTON') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modal = document.getElementById('share-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveShareLinks() {
|
||||||
|
const listEl = document.getElementById('share-links-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/recipes/{{ recipe.id }}/shares');
|
||||||
|
if (!resp.ok) throw new Error('Fehler beim Laden');
|
||||||
|
const data = await resp.json();
|
||||||
|
renderShareLinks(data.shares || []);
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = '<div class="share-empty-state">Konnte aktive Links nicht laden.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShareLinks(shares) {
|
||||||
|
const listEl = document.getElementById('share-links-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
if (!shares || shares.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="share-empty-state">Noch keine Share-Links für dieses Rezept erstellt.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = shares.map(s => {
|
||||||
|
const isExp = s.is_expired;
|
||||||
|
const statusClass = isExp ? 'share-badge-expired' : 'share-badge-active';
|
||||||
|
const labelText = s.label ? `<span class="share-link-label">🏷️ ${escapeHtml(s.label)}</span>` : '';
|
||||||
|
const viewsText = `<span class="share-link-views">👁️ ${s.views_count || 0} ${s.views_count === 1 ? 'Aufruf' : 'Aufrufe'}</span>`;
|
||||||
|
const canonicalUrl = window.location.origin + '/shared/recipe/' + s.token;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="share-link-card ${isExp ? 'is-expired' : ''}">
|
||||||
|
<div class="share-card-main">
|
||||||
|
<div class="share-card-header">
|
||||||
|
${labelText}
|
||||||
|
<span class="share-status-pill ${statusClass}">${s.relative_status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="share-card-meta">
|
||||||
|
<span>Erstellt: ${s.created_at || ''}</span>
|
||||||
|
${viewsText}
|
||||||
|
</div>
|
||||||
|
<div class="share-card-url-preview">
|
||||||
|
<input type="text" class="share-mini-url" value="${canonicalUrl}" readonly onclick="this.select()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-card-actions">
|
||||||
|
${!isExp ? `<button type="button" class="cb-btn cb-btn-sm cb-btn-outline" onclick="copyTextToClipboard('${canonicalUrl}', this)" title="In Zwischenablage kopieren">📋 Kopieren</button>` : ''}
|
||||||
|
<button type="button" class="cb-btn cb-btn-sm cb-btn-danger" onclick="deleteShareLink(${s.id})" title="Freigabe widerrufen">🗑️ Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateShare() {
|
||||||
|
const btn = document.getElementById('btn-create-share');
|
||||||
|
const expiryRadio = document.querySelector('input[name="share_expiry"]:checked');
|
||||||
|
const labelInput = document.getElementById('share-label-input');
|
||||||
|
const expiryVal = expiryRadio ? expiryRadio.value : '7d';
|
||||||
|
const labelVal = labelInput ? labelInput.value.trim() : '';
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/recipes/{{ recipe.id }}/shares', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ expires_in: expiryVal, label: labelVal })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Erstellen fehlgeschlagen');
|
||||||
|
const data = await resp.json();
|
||||||
|
const share = data.share;
|
||||||
|
|
||||||
|
currentShareUrl = window.location.origin + '/shared/recipe/' + share.token;
|
||||||
|
|
||||||
|
const resBox = document.getElementById('share-result-box');
|
||||||
|
const resUrl = document.getElementById('share-result-url');
|
||||||
|
const badge = document.getElementById('share-result-expiry-badge');
|
||||||
|
if (resBox && resUrl) {
|
||||||
|
resUrl.value = currentShareUrl;
|
||||||
|
if (badge) badge.textContent = share.relative_status;
|
||||||
|
resBox.style.display = 'block';
|
||||||
|
resUrl.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeBtn = document.getElementById('btn-native-share');
|
||||||
|
if (nativeBtn && navigator.share) {
|
||||||
|
nativeBtn.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelInput) labelInput.value = '';
|
||||||
|
showToast('✓ Share-Link erfolgreich erstellt!');
|
||||||
|
loadActiveShareLinks();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Fehler beim Erstellen des Links');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCurrentShareUrl() {
|
||||||
|
const input = document.getElementById('share-result-url');
|
||||||
|
if (input && input.value) {
|
||||||
|
await copyTextToClipboard(input.value);
|
||||||
|
const copyBtnText = document.getElementById('copy-btn-text');
|
||||||
|
if (copyBtnText) {
|
||||||
|
copyBtnText.textContent = 'Kopiert! ✓';
|
||||||
|
setTimeout(() => { copyBtnText.textContent = 'Kopieren'; }, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text, btnEl) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
showToast('✓ In Zwischenablage kopiert!');
|
||||||
|
if (btnEl) {
|
||||||
|
const orig = btnEl.innerHTML;
|
||||||
|
btnEl.innerHTML = '✓ Kopiert!';
|
||||||
|
setTimeout(() => { btnEl.innerHTML = orig; }, 2000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Konnte Link nicht automatisch kopieren');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerNativeShare() {
|
||||||
|
if (navigator.share && currentShareUrl) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'Rezept: {{ recipe.title }}',
|
||||||
|
text: 'Hier ist das Rezept für {{ recipe.title }}:',
|
||||||
|
url: currentShareUrl
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteShareLink(shareId) {
|
||||||
|
if (!confirm('Diesen Share-Link wirklich löschen? Empfänger können das Rezept danach nicht mehr über diesen Link aufrufen.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/recipes/{{ recipe.id }}/shares/${shareId}`, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) throw new Error('Fehler beim Löschen');
|
||||||
|
showToast('Share-Link gelöscht');
|
||||||
|
loadActiveShareLinks();
|
||||||
|
const resBox = document.getElementById('share-result-box');
|
||||||
|
if (resBox) resBox.style.display = 'none';
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Konnte Share-Link nicht löschen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// SHARE MODAL & FREIGABELINKS
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
let currentShareUrl = '';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function openShareModal() {
|
||||||
|
|
||||||
|
const modal = document.getElementById('share-modal');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
|
||||||
|
loadActiveShareLinks();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = document.getElementById('cb-mobile-menu');
|
||||||
|
|
||||||
|
if (menu) menu.classList.remove('is-open');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function closeShareModal(e) {
|
||||||
|
|
||||||
|
if (e && e.target && e.target.classList && !e.target.classList.contains('cb-modal-backdrop') && !e.target.classList.contains('cb-modal-close') && e.target.tagName !== 'BUTTON') {
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('share-modal');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
|
||||||
|
modal.classList.remove('active');
|
||||||
|
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function loadActiveShareLinks() {
|
||||||
|
|
||||||
|
const listEl = document.getElementById('share-links-list');
|
||||||
|
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const resp = await fetch('/api/recipes/{{ recipe.id }}/shares');
|
||||||
|
|
||||||
|
if (!resp.ok) throw new Error('Fehler beim Laden');
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
renderShareLinks(data.shares || []);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
listEl.innerHTML = '<div class="share-empty-state">Konnte aktive Links nicht laden.</div>';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function renderShareLinks(shares) {
|
||||||
|
|
||||||
|
const listEl = document.getElementById('share-links-list');
|
||||||
|
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
if (!shares || shares.length === 0) {
|
||||||
|
|
||||||
|
listEl.innerHTML = '<div class="share-empty-state">Noch keine Share-Links für dieses Rezept erstellt.</div>';
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
listEl.innerHTML = shares.map(s => {
|
||||||
|
|
||||||
|
const isExp = s.is_expired;
|
||||||
|
|
||||||
|
const statusClass = isExp ? 'share-badge-expired' : 'share-badge-active';
|
||||||
|
|
||||||
|
const labelText = s.label ? `<span class="share-link-label">🏷️ ${escapeHtml(s.label)}</span>` : '';
|
||||||
|
|
||||||
|
const viewsText = `<span class="share-link-views">👁️ ${s.views_count || 0} ${s.views_count === 1 ? 'Aufruf' : 'Aufrufe'}</span>`;
|
||||||
|
|
||||||
const canonicalUrl = window.location.origin + '/shared/recipe/' + s.token;
|
const canonicalUrl = window.location.origin + '/shared/recipe/' + s.token;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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