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
+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
# ==========================================