Coverage for opt/mealie/lib/python3.12/site-packages/mealie/routes/spa/__init__.py: 22%
142 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:32 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:32 +0000
1import html 1a
2import json 1a
3import pathlib 1a
4from dataclasses import dataclass 1a
5from typing import Any 1a
7from bs4 import BeautifulSoup 1a
8from fastapi import Depends, FastAPI, Response 1a
9from fastapi.encoders import jsonable_encoder 1a
10from fastapi.staticfiles import StaticFiles 1a
11from sqlalchemy.orm.session import Session 1a
12from starlette.exceptions import HTTPException 1a
13from text_unidecode import os 1a
15from mealie.core.config import get_app_settings 1a
16from mealie.core.dependencies.dependencies import try_get_current_user 1a
17from mealie.db.db_setup import generate_session 1a
18from mealie.repos.repository_factory import AllRepositories 1a
19from mealie.schema.recipe.recipe import Recipe 1a
20from mealie.schema.user.user import PrivateUser 1a
23@dataclass 1a
24class MetaTag: 1a
25 hid: str 1a
26 property_name: str 1a
27 content: str 1a
29 def __post_init__(self): 1a
30 self.content = escape(self.content) # escape HTML to prevent XSS attacks
33class SPAStaticFiles(StaticFiles): 1a
34 async def get_response(self, path: str, scope): 1a
35 try:
36 return await super().get_response(path, scope)
37 except HTTPException as ex:
38 if ex.status_code == 404:
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() 1a
47__contents = "" 1a
50def escape(content: Any) -> Any: 1a
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: 1a
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: 1a
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: 1a
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(): 1a
175 return Response(__contents, media_type="text/html", status_code=404)
178def serve_recipe_with_meta_public( 1a
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( 1a
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)): 1a
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): 1a
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 true1a
239 return
241 global __contents
242 __contents = pathlib.Path(__app_settings.STATIC_FILES).joinpath("index.html").read_text() 1a
244 app.get("/g/{group_slug}/r/{recipe_slug}", include_in_schema=False)(serve_recipe_with_meta) 1a
245 app.get("/g/{group_slug}/shared/r/{token_id}", include_in_schema=False)(serve_shared_recipe_with_meta) 1a
246 app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa") 1a