Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Create Recipe From HTML or JSON #4274

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/docs/contributors/developers-guide/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ We have renamed the `updateAt` field to `updatedAt`. While the API will still ac
### Backend Endpoint Changes

These endpoints have moved, but are otherwise unchanged:
- `/recipes/create-url` -> `/recipes/create/url`
- `/recipes/create-url/bulk` -> `/recipes/create/url/bulk`
- `/recipes/create-from-zip` -> `/recipes/create/zip`
- `/recipes/create-from-image` -> `/recipes/create/image`
- `/groups/webhooks` -> `/households/webhooks`
- `/groups/shopping/items` -> `/households/shopping/items`
- `/groups/shopping/lists` -> `/households/shopping/lists`
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/documentation/community-guide/bulk-url-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function import_from_file () {
do
echo $line
curl -X 'POST' \
"$3/api/recipes/create-url" \
"$3/api/recipes/create/url" \
-H "Authorization: Bearer $2" \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
Expand Down Expand Up @@ -81,7 +81,7 @@ def import_from_file(input_file, token, mealie_url):
data = {
'url': line
}
response = requests.post(mealie_url + "/api/recipes/create-url", headers=headers, json=data)
response = requests.post(mealie_url + "/api/recipes/create/url", headers=headers, json=data)
print(response.text)

input_file="list"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Use your best judgement when deciding what to do.

By default, the API is **not** rate limited. This leaves Mealie open to a potential **Denial of Service Attack**. While it's possible to perform a **Denial of Service Attack** on any endpoint, there are a few key endpoints that are more vulnerable than others.

- `/api/recipes/create-url`
- `/api/recipes/create/url`
- `/api/recipes/{id}/image`

These endpoints are used to scrape data based off a user provided URL. It is possible for a malicious user to issue multiple requests to download an arbitrarily large external file (e.g a Debian ISO) and sufficiently saturate a CPU assigned to the container. While we do implement some protections against this by chunking the response, and using a timeout strategy, it's still possible to overload the CPU if an attacker issues multiple requests concurrently.
Expand All @@ -33,7 +33,7 @@ If you'd like to mitigate this risk, we suggest that you rate limit the API in g

## Server Side Request Forgery

- `/api/recipes/create-url`
- `/api/recipes/create/url`
- `/api/recipes/{id}/image`

Given the nature of these APIs it's possible to perform a **Server Side Request Forgery** attack. This is where a malicious user can issue a request to an internal network resource, and potentially exfiltrate data. We _do_ perform some checks to mitigate access to resources within your network but at the end of the day, users of Mealie are allowed to trigger HTTP requests on **your server**.
Expand Down
6 changes: 5 additions & 1 deletion frontend/components/global/RecipeJsonEditor.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<VJsoneditor
:value="value"
height="1500px"
:height="height"
:options="options"
:attrs="$attrs"
@input="$emit('input', $event)"
Expand All @@ -20,6 +20,10 @@ export default defineComponent({
type: Object,
default: () => ({}),
},
height: {
type: String,
default: "1500px",
},
options: {
type: Object,
default: () => ({}),
Expand Down
7 changes: 7 additions & 0 deletions frontend/lang/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list",
"recipe-markup-specification": "Recipe Markup Specification",
"recipe-url": "Recipe URL",
"recipe-html-or-json": "Recipe HTML or JSON",
"upload-a-recipe": "Upload a Recipe",
"upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.",
"url-form-hint": "Copy and paste a link from your favorite recipe website",
Expand Down Expand Up @@ -604,10 +605,16 @@
"scrape-recipe-description": "Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the recipe from that site and add it to your collection.",
"scrape-recipe-have-a-lot-of-recipes": "Have a lot of recipes you want to scrape at once?",
"scrape-recipe-suggest-bulk-importer": "Try out the bulk importer",
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
"import-original-keywords-as-tags": "Import original keywords as tags",
"stay-in-edit-mode": "Stay in Edit mode",
"import-from-zip": "Import from Zip",
"import-from-zip-description": "Import a single recipe that was exported from another Mealie instance.",
"import-from-html-or-json": "Import from HTML or JSON",
"import-from-html-or-json-description": "Import a single recipe from raw HTML or JSON. This is useful if you have a recipe from a site that Mealie can't scrape normally, or from some other external source.",
"json-import-format-description-colon": "To import via JSON, it must be in valid format:",
"json-editor": "JSON Editor",
"zip-files-must-have-been-exported-from-mealie": ".zip files must have been exported from Mealie",
"create-a-recipe-by-uploading-a-scan": "Create a recipe by uploading a scan.",
"upload-a-png-image-from-a-recipe-book": "Upload a png image from a recipe book",
Expand Down
7 changes: 7 additions & 0 deletions frontend/lib/api/types/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,15 @@ export interface SaveIngredientUnit {
groupId: string;
}
export interface ScrapeRecipe {
includeTags?: boolean;
url: string;
}
export interface ScrapeRecipeBase {
includeTags?: boolean;
}
export interface ScrapeRecipeData {
includeTags?: boolean;
data: string;
}
export interface ScrapeRecipeTest {
url: string;
Expand Down
13 changes: 9 additions & 4 deletions frontend/lib/api/user/recipes/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ const routes = {
recipesCreate: `${prefix}/recipes/create`,
recipesBase: `${prefix}/recipes`,
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
recipesCreateUrl: `${prefix}/recipes/create-url`,
recipesCreateUrlBulk: `${prefix}/recipes/create-url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
recipesCreateFromImage: `${prefix}/recipes/create-from-image`,
recipesCreateUrl: `${prefix}/recipes/create/url`,
recipesCreateUrlBulk: `${prefix}/recipes/create/url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create/zip`,
recipesCreateFromImage: `${prefix}/recipes/create/image`,
recipesCreateFromHtmlOrJson: `${prefix}/recipes/create/html-or-json`,
recipesCategory: `${prefix}/recipes/category`,
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
Expand Down Expand Up @@ -134,6 +135,10 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI });
}

async createOneByHtmlOrJson(data: string, includeTags: boolean) {
return await this.requests.post<string>(routes.recipesCreateFromHtmlOrJson, { data, includeTags });
}

async createOneByUrl(url: string, includeTags: boolean) {
return await this.requests.post<string>(routes.recipesCreateUrl, { url, includeTags });
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/lib/icons/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ import {
mdiRotateRight,
mdiBookOpenPageVariant,
mdiFileCabinet,
mdiSilverwareForkKnife
mdiSilverwareForkKnife,
mdiCodeTags,
} from "@mdi/js";

export const icons = {
Expand Down Expand Up @@ -192,6 +193,7 @@ export const icons = {
clockOutline: mdiClockTimeFourOutline,
codeBraces: mdiCodeJson,
codeJson: mdiCodeJson,
codeTags: mdiCodeTags,
cog: mdiCog,
commentTextMultiple: mdiCommentTextMultiple,
commentTextMultipleOutline: mdiCommentTextMultipleOutline,
Expand Down
5 changes: 5 additions & 0 deletions frontend/pages/g/_groupSlug/r/create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export default defineComponent({
text: i18n.tc("recipe.bulk-url-import"),
value: "bulk",
},
{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at some point we should try to simplify that list somehow. It kepps getting longer and longer...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree, we're gonna have to do something about it soon

icon: $globals.icons.codeTags,
text: i18n.tc("recipe.import-from-html-or-json"),
value: "html",
},
{
icon: $globals.icons.fileImage,
text: i18n.tc("recipe.create-from-image"),
Expand Down
171 changes: 171 additions & 0 deletions frontend/pages/g/_groupSlug/r/create/html.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<template>
<v-form ref="domUrlForm" @submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, stayInEditMode)">
<div>
<v-card-title class="headline"> {{ $tc('recipe.import-from-html-or-json') }} </v-card-title>
<v-card-text>
<p>
{{ $tc("recipe.import-from-html-or-json-description") }}
</p>
<p>
{{ $tc("recipe.json-import-format-description-colon") }}
<a href="https://schema.org/Recipe" target="_blank">https://schema.org/Recipe</a>
</p>
<v-switch
v-model="isEditJSON"
:label="$tc('recipe.json-editor')"
class="mt-2"
@change="handleIsEditJson"
/>
<LazyRecipeJsonEditor
v-if="isEditJSON"
v-model="newRecipeData"
height="250px"
class="mt-10"
:options="EDITOR_OPTIONS"
/>
<v-textarea
v-else
v-model="newRecipeData"
:label="$tc('new-recipe.recipe-html-or-json')"
:prepend-inner-icon="$globals.icons.codeTags"
validate-on-blur
autofocus
filled
clearable
class="rounded-lg mt-2"
rounded
:hint="$tc('new-recipe.url-form-hint')"
persistent-hint
/>
<v-checkbox v-model="importKeywordsAsTags" hide-details :label="$tc('recipe.import-original-keywords-as-tags')" />
<v-checkbox v-model="stayInEditMode" hide-details :label="$tc('recipe.stay-in-edit-mode')" />
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="!newRecipeData"
large
rounded
block
type="submit"
:loading="loading"
/>
</div>
</v-card-actions>
</div>
</v-form>
</template>

<script lang="ts">
import { computed, defineComponent, reactive, toRefs, ref, useContext, useRoute, useRouter } from "@nuxtjs/composition-api";
import { AxiosResponse } from "axios";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify";

const EDITOR_OPTIONS = {
mode: "code",
search: false,
mainMenuBar: false,
};

export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
isEditJSON: false,
});
const { $auth } = useContext();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);

const api = useUserApi();
const router = useRouter();
const tags = useTagStore();

const importKeywordsAsTags = computed({
get() {
return route.value.query.use_keywords === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.value.query, use_keywords: v ? "1" : "0" } });
},
});

const stayInEditMode = computed({
get() {
return route.value.query.edit === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.value.query, edit: v ? "1" : "0" } });
},
});

function handleResponse(response: AxiosResponse<string> | null, edit = false, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
if (refreshTags) {
tags.actions.refresh();
}

router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`);
}

const newRecipeData = ref<string | object | null>(null);

function handleIsEditJson() {
if (state.isEditJSON) {
if (newRecipeData.value) {
try {
newRecipeData.value = JSON.parse(newRecipeData.value as string);
} catch {
newRecipeData.value = { "data": newRecipeData.value };
}
} else {
newRecipeData.value = {};
}
} else if (newRecipeData.value && Object.keys(newRecipeData.value).length > 0) {
newRecipeData.value = JSON.stringify(newRecipeData.value);
} else {
newRecipeData.value = null;
}
}
handleIsEditJson();

async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, stayInEditMode: boolean) {
if (!htmlOrJsonData || !domUrlForm.value?.validate()) {
return;
}

let dataString;
if (typeof htmlOrJsonData === "string") {
dataString = htmlOrJsonData;
} else {
dataString = JSON.stringify(htmlOrJsonData);
}

state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags);
handleResponse(response, stayInEditMode, importKeywordsAsTags);
}

return {
EDITOR_OPTIONS,
domUrlForm,
importKeywordsAsTags,
stayInEditMode,
newRecipeData,
handleIsEditJson,
createFromHtmlOrJson,
...toRefs(state),
validators,
};
},
});
</script>
10 changes: 9 additions & 1 deletion frontend/pages/g/_groupSlug/r/create/url.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
<v-card-title class="headline"> {{ $t('recipe.scrape-recipe') }} </v-card-title>
<v-card-text>
<p>{{ $t('recipe.scrape-recipe-description') }}</p>
<p>{{ $t('recipe.scrape-recipe-have-a-lot-of-recipes') }} <a :href="bulkImporterTarget">{{ $t('recipe.scrape-recipe-suggest-bulk-importer') }}</a>.</p>
<p>
{{ $t('recipe.scrape-recipe-have-a-lot-of-recipes') }}
<a :href="bulkImporterTarget">{{ $t('recipe.scrape-recipe-suggest-bulk-importer') }}</a>.
<br />
{{ $t('recipe.scrape-recipe-have-raw-html-or-json-data') }}
<a :href="htmlOrJsonImporterTarget">{{ $t('recipe.scrape-recipe-you-can-import-from-raw-data-directly') }}</a>.
</p>
<v-text-field
v-model="recipeUrl"
:label="$t('new-recipe.recipe-url')"
Expand Down Expand Up @@ -96,6 +102,7 @@ export default defineComponent({
const tags = useTagStore();

const bulkImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/bulk`);
const htmlOrJsonImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/html`);

function handleResponse(response: AxiosResponse<string> | null, edit = false, refreshTags = false) {
if (response?.status !== 201) {
Expand Down Expand Up @@ -171,6 +178,7 @@ export default defineComponent({

return {
bulkImporterTarget,
htmlOrJsonImporterTarget,
recipeUrl,
importKeywordsAsTags,
stayInEditMode,
Expand Down
2 changes: 1 addition & 1 deletion frontend/pages/g/_groupSlug/r/create/zip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default defineComponent({
const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value);

const { response } = await api.upload.file("/api/recipes/create-from-zip", formData);
const { response } = await api.upload.file("/api/recipes/create/zip", formData);
handleResponse(response);
}

Expand Down
Loading
Loading