Coverage for opt/mealie/lib/python3.12/site-packages/mealie/routes/spa/__init__.py: 26%
142 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
1import html
2import json
3import pathlib
4from dataclasses import dataclass
5from typing import Any
7from bs4 import BeautifulSoup
8from fastapi import Depends, FastAPI, Response
9from fastapi.encoders import jsonable_encoder
10from fastapi.staticfiles import StaticFiles
11from sqlalchemy.orm.session import Session
12from starlette.exceptions import HTTPException
13from text_unidecode import os
15from mealie.core.config import get_app_settings
16from mealie.core.dependencies.dependencies import try_get_current_user
17from mealie.db.db_setup import generate_session
18from mealie.repos.repository_factory import AllRepositories
19from mealie.schema.recipe.recipe import Recipe
20from mealie.schema.user.user import PrivateUser
23@dataclass
24class MetaTag:
25 hid: str
26 property_name: str
27 content: str
29 def __post_init__(self):
30 self.content = escape(self.content) # escape HTML to prevent XSS attacks
33class SPAStaticFiles(StaticFiles):
34 async def get_response(self, path: str, scope):
35 try:
36 return await super().get_response(path, scope)
37 except HTTPException as ex:
38 if ex.status_code == 404: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true
39 return await super().get_response("index.html", scope)
40 else:
41 raise ex
42 except Exception as e:
43 raise e
46__app_settings = get_app_settings()
47__contents = ""
50def escape(content: Any) -> Any:
51 if isinstance(content, str):
52 return html.escape(content)
53 elif isinstance(content, list | tuple | set):
54 return [escape(item) for item in content]
55 elif isinstance(content, dict):
56 return {escape(k): escape(v) for k, v in content.items()}
57 else:
58 return content
61def inject_meta(contents: str, tags: list[MetaTag]) -> str:
62 soup = BeautifulSoup(contents, "lxml")
63 scraped_meta_tags = soup.find_all("meta")
65 tags_by_hid = {tag.hid: tag for tag in tags}
66 tags_by_property = {tag.property_name: tag for tag in tags}
68 for scraped_meta_tag in scraped_meta_tags:
69 # Try to match by data-hid first
70 scraped_hid = scraped_meta_tag.get("data-hid")
71 matched_tag = tags_by_hid.pop(scraped_hid, None) if scraped_hid else None
73 # If no match by data-hid, try matching by property name
74 if not matched_tag:
75 scraped_property = scraped_meta_tag.get("property")
76 matched_tag = tags_by_property.get(scraped_property) if scraped_property else None
77 if matched_tag:
78 tags_by_hid.pop(matched_tag.hid, None)
79 tags_by_property.pop(scraped_property, None)
81 if not matched_tag:
82 continue
84 scraped_meta_tag["property"] = matched_tag.property_name
85 scraped_meta_tag["content"] = matched_tag.content
86 # Add data-hid if it doesn't exist
87 if "data-hid" not in scraped_meta_tag.attrs:
88 scraped_meta_tag["data-hid"] = matched_tag.hid
90 # add any tags we didn't find
91 if soup.html and soup.html.head:
92 for tag in tags_by_hid.values():
93 html_tag = soup.new_tag(
94 "meta",
95 **{"data-n-head": "1", "data-hid": tag.hid, "property": tag.property_name, "content": tag.content},
96 )
97 soup.html.head.append(html_tag)
99 return str(soup)
102def inject_recipe_json(contents: str, schema: dict) -> str:
103 schema_as_html_tag = f"""<script type="application/ld+json">{json.dumps(jsonable_encoder(schema))}</script>"""
104 return contents.replace("</head>", schema_as_html_tag + "\n</head>", 1)
107def content_with_meta(group_slug: str, recipe: Recipe) -> str:
108 # Inject meta tags
109 recipe_url = f"{__app_settings.BASE_URL}/g/{group_slug}/r/{recipe.slug}"
110 if recipe.image:
111 image_url = f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={escape(recipe.image)}"
112 else:
113 image_url = "https://raw.githubusercontent.com/mealie-recipes/mealie/9571816ac4eed5beacfc0abf6c03eff1427fd0eb/frontend/static/icons/android-chrome-512x512.png"
115 ingredients: list[str] = []
116 for ing in recipe.recipe_ingredient:
117 s = ""
118 if ing.quantity:
119 s += f"{ing.quantity} "
120 if ing.unit:
121 s += f"{ing.unit.name} "
122 if ing.food:
123 s += f"{ing.food.name} "
124 if ing.note:
125 s += f"{ing.note}"
127 ingredients.append(escape(s))
129 nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {}
130 for k, v in nutrition.items():
131 if v:
132 nutrition[k] = escape(v)
134 as_schema_org: dict[str, Any] = {
135 "@context": "https://schema.org",
136 "@type": "Recipe",
137 "name": escape(recipe.name),
138 "description": escape(recipe.description),
139 "image": [image_url],
140 "datePublished": recipe.created_at,
141 "prepTime": escape(recipe.prep_time),
142 "cookTime": escape(recipe.cook_time),
143 "totalTime": escape(recipe.total_time),
144 "recipeYield": escape(recipe.recipe_yield_display),
145 "recipeIngredient": ingredients,
146 "recipeInstructions": [escape(i.text) for i in recipe.recipe_instructions]
147 if recipe.recipe_instructions
148 else [],
149 "recipeCategory": [escape(c.name) for c in recipe.recipe_category] if recipe.recipe_category else [],
150 "keywords": [escape(t.name) for t in recipe.tags] if recipe.tags else [],
151 "nutrition": nutrition,
152 }
154 meta_tags = [
155 MetaTag(hid="og:title", property_name="og:title", content=recipe.name or ""),
156 MetaTag(hid="og:description", property_name="og:description", content=recipe.description or ""),
157 MetaTag(hid="og:image", property_name="og:image", content=image_url),
158 MetaTag(hid="og:url", property_name="og:url", content=recipe_url),
159 MetaTag(hid="twitter:card", property_name="twitter:card", content="summary_large_image"),
160 MetaTag(hid="twitter:title", property_name="twitter:title", content=recipe.name or ""),
161 MetaTag(hid="twitter:description", property_name="twitter:description", content=recipe.description or ""),
162 MetaTag(hid="twitter:image", property_name="twitter:image", content=image_url),
163 MetaTag(hid="twitter:url", property_name="twitter:url", content=recipe_url),
164 ]
166 global __contents
167 contents = __contents # make a local copy so we don't modify the global contents
168 contents = inject_recipe_json(contents, as_schema_org)
169 contents = inject_meta(contents, meta_tags)
171 return contents
174def response_404():
175 return Response(__contents, media_type="text/html", status_code=404)
178def serve_recipe_with_meta_public(
179 group_slug: str,
180 recipe_slug: str,
181 session: Session = Depends(generate_session),
182):
183 try:
184 public_repos = AllRepositories(session)
185 group = public_repos.groups.get_by_slug_or_id(group_slug)
187 if not (group and group.preferences) or group.preferences.private_group:
188 return response_404()
190 group_repos = AllRepositories(session, group_id=group.id, household_id=None)
191 recipe = group_repos.recipes.get_one(recipe_slug)
193 if not (recipe and recipe.settings) or not recipe.settings.public:
194 return response_404()
196 # Inject meta tags
197 return Response(content_with_meta(group_slug, recipe), media_type="text/html")
198 except Exception:
199 return response_404()
202async def serve_recipe_with_meta(
203 group_slug: str,
204 recipe_slug: str,
205 user: PrivateUser | None = Depends(try_get_current_user),
206 session: Session = Depends(generate_session),
207):
208 if not user or user.group_slug != group_slug:
209 return serve_recipe_with_meta_public(group_slug, recipe_slug, session)
211 try:
212 group_repos = AllRepositories(session, group_id=user.group_id, household_id=None)
214 recipe = group_repos.recipes.get_one(recipe_slug, "slug")
215 if recipe is None:
216 return response_404()
218 # Serve contents as HTML
219 return Response(content_with_meta(group_slug, recipe), media_type="text/html")
220 except Exception:
221 return response_404()
224async def serve_shared_recipe_with_meta(group_slug: str, token_id: str, session: Session = Depends(generate_session)):
225 try:
226 public_repos = AllRepositories(session, group_id=None)
227 token_summary = public_repos.recipe_share_tokens.get_one(token_id)
228 if token_summary is None:
229 raise Exception("Token Not Found")
231 return Response(content_with_meta(group_slug, token_summary.recipe), media_type="text/html")
233 except Exception:
234 return response_404()
237def mount_spa(app: FastAPI):
238 if not os.path.exists(__app_settings.STATIC_FILES): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 return
241 global __contents
242 __contents = pathlib.Path(__app_settings.STATIC_FILES).joinpath("index.html").read_text()
244 app.get("/g/{group_slug}/r/{recipe_slug}", include_in_schema=False)(serve_recipe_with_meta)
245 app.get("/g/{group_slug}/shared/r/{token_id}", include_in_schema=False)(serve_shared_recipe_with_meta)
246 app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa")