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:48 +0000

1import html 1a

2import json 1a

3import pathlib 1a

4from dataclasses import dataclass 1a

5from typing import Any 1a

6 

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

14 

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

21 

22 

23@dataclass 1a

24class MetaTag: 1a

25 hid: str 1a

26 property_name: str 1a

27 content: str 1a

28 

29 def __post_init__(self): 1a

30 self.content = escape(self.content) # escape HTML to prevent XSS attacks 

31 

32 

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 

44 

45 

46__app_settings = get_app_settings() 1a

47__contents = "" 1a

48 

49 

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 

59 

60 

61def inject_meta(contents: str, tags: list[MetaTag]) -> str: 1a

62 soup = BeautifulSoup(contents, "lxml") 

63 scraped_meta_tags = soup.find_all("meta") 

64 

65 tags_by_hid = {tag.hid: tag for tag in tags} 

66 tags_by_property = {tag.property_name: tag for tag in tags} 

67 

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 

72 

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) 

80 

81 if not matched_tag: 

82 continue 

83 

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 

89 

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) 

98 

99 return str(soup) 

100 

101 

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) 

105 

106 

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" 

114 

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}" 

126 

127 ingredients.append(escape(s)) 

128 

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) 

133 

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 } 

153 

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 ] 

165 

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) 

170 

171 return contents 

172 

173 

174def response_404(): 1a

175 return Response(__contents, media_type="text/html", status_code=404) 

176 

177 

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) 

186 

187 if not (group and group.preferences) or group.preferences.private_group: 

188 return response_404() 

189 

190 group_repos = AllRepositories(session, group_id=group.id, household_id=None) 

191 recipe = group_repos.recipes.get_one(recipe_slug) 

192 

193 if not (recipe and recipe.settings) or not recipe.settings.public: 

194 return response_404() 

195 

196 # Inject meta tags 

197 return Response(content_with_meta(group_slug, recipe), media_type="text/html") 

198 except Exception: 

199 return response_404() 

200 

201 

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) 

210 

211 try: 

212 group_repos = AllRepositories(session, group_id=user.group_id, household_id=None) 

213 

214 recipe = group_repos.recipes.get_one(recipe_slug, "slug") 

215 if recipe is None: 

216 return response_404() 

217 

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() 

222 

223 

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") 

230 

231 return Response(content_with_meta(group_slug, token_summary.recipe), media_type="text/html") 

232 

233 except Exception: 

234 return response_404() 

235 

236 

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 

240 

241 global __contents 

242 __contents = pathlib.Path(__app_settings.STATIC_FILES).joinpath("index.html").read_text() 1a

243 

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