Add recipe share links with configurable validity, public guest access, and revocation
This commit is contained in:
+109
-2
@@ -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
|
||||
# ==========================================
|
||||
|
||||
Reference in New Issue
Block a user