diff --git a/frontend/pages/g/_groupSlug/r/_slug/index.vue b/frontend/pages/g/_groupSlug/r/_slug/index.vue index ffdb262ed40..2e6abf18af8 100644 --- a/frontend/pages/g/_groupSlug/r/_slug/index.vue +++ b/frontend/pages/g/_groupSlug/r/_slug/index.vue @@ -6,7 +6,9 @@ diff --git a/mealie/routes/spa/__init__.py b/mealie/routes/spa/__init__.py index 357fea08501..abf59d5142c 100644 --- a/mealie/routes/spa/__init__.py +++ b/mealie/routes/spa/__init__.py @@ -1,6 +1,8 @@ import json import pathlib +from dataclasses import dataclass +from bs4 import BeautifulSoup from fastapi import Depends, FastAPI, Response from fastapi.encoders import jsonable_encoder from fastapi.staticfiles import StaticFiles @@ -16,6 +18,13 @@ from mealie.schema.user.user import PrivateUser +@dataclass +class MetaTag: + hid: str + property_name: str + content: str + + class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope): try: @@ -33,10 +42,51 @@ async def get_response(self, path: str, scope): __contents = "" +def inject_meta(contents: str, tags: list[MetaTag]) -> str: + soup = BeautifulSoup(contents, "lxml") + scraped_meta_tags = soup.find_all("meta") + + tags_by_hid = {tag.hid: tag for tag in tags} + for scraped_meta_tag in scraped_meta_tags: + try: + scraped_hid = scraped_meta_tag["data-hid"] + except KeyError: + continue + + if not (matched_tag := tags_by_hid.pop(scraped_hid, None)): + continue + + scraped_meta_tag["property"] = matched_tag.property_name + scraped_meta_tag["content"] = matched_tag.content + + # add any tags we didn't find + if soup.html and soup.html.head: + for tag in tags_by_hid.values(): + html_tag = soup.new_tag( + "meta", + **{"data-n-head": "1", "data-hid": tag.hid, "property": tag.property_name, "content": tag.content}, + ) + soup.html.head.append(html_tag) + + return str(soup) + + +def inject_recipe_json(contents: str, schema: dict) -> str: + schema_as_html_tag = f"""""" + return contents.replace("", schema_as_html_tag + "\n", 1) + + def content_with_meta(group_slug: str, recipe: Recipe) -> str: # Inject meta tags recipe_url = f"{__app_settings.BASE_URL}/g/{group_slug}/r/{recipe.slug}" - image_url = f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={recipe.image}" + if recipe.image: + image_url = ( + f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={recipe.image}" + ) + else: + image_url = ( + "https://raw.githubusercontent.com/hay-kot/mealie/dev/frontend/public/img/icons/android-chrome-512x512.png" + ) ingredients: list[str] = [] if recipe.settings.disable_amount: # type: ignore @@ -84,20 +134,22 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str: "nutrition": nutrition, } - tags = [ - f'', - f'', - f'', - f'', - '', - f'', - f'', - f'', - f'', - f"""""", + meta_tags = [ + MetaTag(hid="og:title", property_name="og:title", content=recipe.name or ""), + MetaTag(hid="og:description", property_name="og:description", content=recipe.description or ""), + MetaTag(hid="og:image", property_name="og:image", content=image_url), + MetaTag(hid="og:url", property_name="og:url", content=recipe_url), + MetaTag(hid="twitter:card", property_name="twitter:card", content="summary_large_image"), + MetaTag(hid="twitter:title", property_name="twitter:title", content=recipe.name or ""), + MetaTag(hid="twitter:description", property_name="twitter:description", content=recipe.description or ""), + MetaTag(hid="twitter:image", property_name="twitter:image", content=image_url), + MetaTag(hid="twitter:url", property_name="twitter:url", content=recipe_url), ] - return __contents.replace("", "\n".join(tags) + "\n", 1) + global __contents + __contents = inject_recipe_json(__contents, as_schema_org) + __contents = inject_meta(__contents, meta_tags) + return __contents def response_404(): @@ -133,7 +185,7 @@ async def serve_recipe_with_meta( user: PrivateUser | None = Depends(try_get_current_user), session: Session = Depends(generate_session), ): - if not user: + if not user or user.group_slug != group_slug: return serve_recipe_with_meta_public(group_slug, recipe_slug, session) try: @@ -149,6 +201,19 @@ async def serve_recipe_with_meta( return response_404() +async def serve_shared_recipe_with_meta(group_slug: str, token_id: str, session: Session = Depends(generate_session)): + try: + repos = AllRepositories(session) + token_summary = repos.recipe_share_tokens.get_one(token_id) + if token_summary is None: + raise Exception("Token Not Found") + + return Response(content_with_meta(group_slug, token_summary.recipe), media_type="text/html") + + except Exception: + return response_404() + + def mount_spa(app: FastAPI): if not os.path.exists(__app_settings.STATIC_FILES): return @@ -157,4 +222,5 @@ def mount_spa(app: FastAPI): __contents = pathlib.Path(__app_settings.STATIC_FILES).joinpath("index.html").read_text() app.get("/g/{group_slug}/r/{recipe_slug}")(serve_recipe_with_meta) + app.get("/g/{group_slug}/shared/r/{token_id}")(serve_shared_recipe_with_meta) app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa") diff --git a/tests/data/__init__.py b/tests/data/__init__.py index 445426def3e..588d2c85419 100644 --- a/tests/data/__init__.py +++ b/tests/data/__init__.py @@ -22,6 +22,8 @@ images_test_image_2 = CWD / "images/test-image-2.png" +html_mealie_recipe = CWD / "html/mealie-recipe.html" + html_sous_vide_smoked_beef_ribs = CWD / "html/sous-vide-smoked-beef-ribs.html" html_sous_vide_shrimp = CWD / "html/sous-vide-shrimp.html" diff --git a/tests/data/html/mealie-recipe.html b/tests/data/html/mealie-recipe.html new file mode 100644 index 00000000000..4df529d1f82 --- /dev/null +++ b/tests/data/html/mealie-recipe.html @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + Mealie + + + + + + + + + + + + + + +
+ + +
+
Loading...
+
+
+ + + + diff --git a/tests/integration_tests/test_spa.py b/tests/integration_tests/test_spa.py new file mode 100644 index 00000000000..f4c3dc190c8 --- /dev/null +++ b/tests/integration_tests/test_spa.py @@ -0,0 +1,70 @@ +from bs4 import BeautifulSoup + +from mealie.routes.spa import MetaTag, inject_meta, inject_recipe_json +from tests import data as test_data +from tests.utils.factories import random_string + + +def test_spa_metadata_injection(): + fp = test_data.html_mealie_recipe + with open(fp) as f: + soup = BeautifulSoup(f, "lxml") + assert soup.html and soup.html.head + + tags = soup.find_all("meta") + assert tags + + title_tag = None + for tag in tags: + if tag.get("data-hid") == "og:title": + title_tag = tag + break + + assert title_tag and title_tag["content"] + + new_title_tag = MetaTag(hid="og:title", property_name="og:title", content=random_string()) + new_arbitrary_tag = MetaTag(hid=random_string(), property_name=random_string(), content=random_string()) + new_html = inject_meta(str(soup), [new_title_tag, new_arbitrary_tag]) + + # verify changes were injected + soup = BeautifulSoup(new_html, "lxml") + assert soup.html and soup.html.head + + tags = soup.find_all("meta") + assert tags + + title_tag = None + for tag in tags: + if tag.get("data-hid") == "og:title": + title_tag = tag + break + + assert title_tag and title_tag["content"] == new_title_tag.content + + arbitrary_tag = None + for tag in tags: + if tag.get("data-hid") == new_arbitrary_tag.hid: + arbitrary_tag = tag + break + + assert arbitrary_tag and arbitrary_tag["content"] == new_arbitrary_tag.content + + +def test_spa_recipe_json_injection(): + recipe_name = random_string() + schema = { + "@context": "https://schema.org", + "@type": "Recipe", + "name": recipe_name, + } + + fp = test_data.html_mealie_recipe + with open(fp) as f: + soup = BeautifulSoup(f, "lxml") + assert "https://schema.org" not in str(soup) + + html = inject_recipe_json(str(soup), schema) + + assert "@context" in html + assert "https://schema.org" in html + assert recipe_name in html