mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-03 23:50:12 -04:00
Compare commits
13 Commits
v3.19.2
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed4aec228 | ||
|
|
47c6d01617 | ||
|
|
653be9a604 | ||
|
|
2d8b74282a | ||
|
|
48752bcd06 | ||
|
|
a46620d236 | ||
|
|
3bde6df958 | ||
|
|
e1ddc06eff | ||
|
|
262b531add | ||
|
|
364af97060 | ||
|
|
7b0d1fde64 | ||
|
|
0af9633193 | ||
|
|
b5987f5a46 |
@@ -44,6 +44,7 @@
|
||||
8000, // used by mkdocs
|
||||
9000,
|
||||
9091, // used by docker production
|
||||
51204, // used for test coverage report
|
||||
24678 // used by nuxt when hot-reloading using polling
|
||||
],
|
||||
// Use 'onCreateCommand' to run commands at the end of container creation.
|
||||
|
||||
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -20,6 +20,10 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Install from the committed lockfile; never re-resolve (see pyproject
|
||||
# [tool.uv] exclude-newer cooling window).
|
||||
UV_FROZEN: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
|
||||
4
.github/workflows/locale-sync.yml
vendored
4
.github/workflows/locale-sync.yml
vendored
@@ -14,6 +14,10 @@ permissions:
|
||||
jobs:
|
||||
sync-locales:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Install from the committed lockfile; never re-resolve (see pyproject
|
||||
# [tool.uv] exclude-newer cooling window).
|
||||
UV_FROZEN: "1"
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
|
||||
49
.github/workflows/pull-request-lint.yml
vendored
49
.github/workflows/pull-request-lint.yml
vendored
@@ -3,7 +3,7 @@ name: Pull Request Linter
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [edited] # This captures the PR title changing
|
||||
types: [edited, reopened] # This captures the PR title/body changing
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
@@ -41,3 +41,50 @@ jobs:
|
||||
ignoreLabels: |
|
||||
bot
|
||||
ignore-semantic-pull-request
|
||||
|
||||
validate-template:
|
||||
name: Validate PR template
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check required PR template sections
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
if (pr.user.type === "Bot") {
|
||||
console.log("Skipping template check for bot");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: ".github/pull_request_template.md",
|
||||
});
|
||||
|
||||
const template = Buffer.from(response.data.content, "base64").toString("utf8");
|
||||
const lines = template.split("\n");
|
||||
|
||||
const requiredHeadings = [];
|
||||
let lastHeading = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
lastHeading = line.trim();
|
||||
} else if (line.trim() === "_(REQUIRED)_" && lastHeading) {
|
||||
requiredHeadings.push(lastHeading);
|
||||
lastHeading = null;
|
||||
}
|
||||
}
|
||||
|
||||
const body = pr.body || "";
|
||||
const missing = requiredHeadings.filter(h => !body.includes(h));
|
||||
|
||||
if (missing.length > 0) {
|
||||
core.setFailed(`Missing headings:\n${missing.join("\n")}`);
|
||||
} else {
|
||||
console.log("All required headings present");
|
||||
}
|
||||
|
||||
2
.github/workflows/pull-requests.yml
vendored
2
.github/workflows/pull-requests.yml
vendored
@@ -18,6 +18,8 @@ jobs:
|
||||
name: "Lint PR"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/pull-request-lint.yml
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
backend-tests:
|
||||
name: "Backend Server Tests"
|
||||
|
||||
4
.github/workflows/test-backend.yml
vendored
4
.github/workflows/test-backend.yml
vendored
@@ -13,6 +13,10 @@ jobs:
|
||||
|
||||
env:
|
||||
PRODUCTION: false
|
||||
# Install from the committed lockfile; never re-resolve. The rolling
|
||||
# `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise make
|
||||
# every uv command re-resolve and fail on in-window pins.
|
||||
UV_FROZEN: "1"
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
|
||||
30
.vscode/test-block.code-snippets
vendored
Normal file
30
.vscode/test-block.code-snippets
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Test Block": {
|
||||
"prefix": "mtest",
|
||||
"body": [
|
||||
"import { mount } from \"@vue/test-utils\";",
|
||||
"import { describe, expect, test, vi } from \"vitest\";",
|
||||
"import { makeWrapper } from \"~/tests/utils\";",
|
||||
"",
|
||||
"const wrapper = () => makeWrapper(() => {",
|
||||
" return ${1:composable}();",
|
||||
"});",
|
||||
"",
|
||||
"describe(\"${TM_FILENAME_BASE/(.*)\\..+$/$1/}\", () => {",
|
||||
" describe(\"${2:method}\", () => {",
|
||||
" test(\"It does the thing\", () => {",
|
||||
" const { ${2:method} } = wrapper();",
|
||||
" const result = ${2:method}();",
|
||||
" expect(result).toBe(EXPECTED);",
|
||||
" });",
|
||||
" });",
|
||||
"});",
|
||||
"",
|
||||
],
|
||||
"description": "Insert a test block",
|
||||
"scope": "typescript",
|
||||
"include": [
|
||||
"**/*.test.{ts,tsx,vue}"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ env:
|
||||
DEFAULT_GROUP: Home
|
||||
DEFAULT_HOUSEHOLD: Family
|
||||
PRODUCTION: false
|
||||
# Install from the committed lockfile; never re-resolve. Required because the
|
||||
# rolling `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise
|
||||
# make every `uv run`/`uv sync` re-resolve and fail on in-window pins.
|
||||
UV_FROZEN: "1"
|
||||
API_PORT: 9000
|
||||
API_DOCS: True
|
||||
TOKEN_TIME: 256 # hours
|
||||
|
||||
@@ -52,6 +52,11 @@ RUN apt-get update \
|
||||
|
||||
RUN pip install uv
|
||||
|
||||
# Install from the committed lockfile; never re-resolve. The rolling
|
||||
# `exclude-newer` cooling window (pyproject [tool.uv]) would otherwise make
|
||||
# `uv export` below re-resolve and fail on in-window pins.
|
||||
ENV UV_FROZEN=1
|
||||
|
||||
WORKDIR /mealie
|
||||
|
||||
# copy project files here to ensure they will be cached.
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
| --------------------------- | :-----: | ----------------------------------------------------------------------------------- |
|
||||
| SECURITY_MAX_LOGIN_ATTEMPTS | 5 | Maximum times a user can provide an invalid password before their account is locked |
|
||||
| SECURITY_USER_LOCKOUT_TIME | 24 | Time in hours for how long a users account is locked |
|
||||
| ALLOWED_IFRAME_HOSTS | `""` | Comma-separated extra hostnames allowed as `<iframe>` sources in recipe content. Extends the built-in list of trusted video providers (YouTube, Vimeo). Subdomains are included automatically. Only `https` sources are permitted. Adding hosts here opts into rendering embeds from those origins to all viewers, including the public, so add only origins you trust. |
|
||||
|
||||
### Database
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>
|
||||
To harden Mealie against malicious content, <code><iframe></code> embeds in recipe
|
||||
instructions, notes, and descriptions are now restricted to a trusted set of hosts.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
By default, embeds are allowed only from well-known video providers:
|
||||
<ul class="ml-6">
|
||||
<li>YouTube</li>
|
||||
<li>Vimeo</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
Existing recipes that embed content from <strong>other</strong> hosts will no longer render
|
||||
those embeds. The rest of the recipe is unaffected.
|
||||
</p>
|
||||
<div v-if="user?.admin">
|
||||
<hr class="mt-2 mb-4">
|
||||
<p>
|
||||
As an admin, you can allow additional hosts with the <code>ALLOWED_IFRAME_HOSTS</code>
|
||||
environment variable (comma-separated). It extends the built-in defaults, and only
|
||||
<code>https</code> sources are permitted. See the configuration docs for details:
|
||||
<br>
|
||||
<v-btn
|
||||
class="mt-2"
|
||||
color="primary"
|
||||
href="https://docs.mealie.io/documentation/getting-started/installation/backend-config/"
|
||||
target="_blank"
|
||||
>
|
||||
Backend Configuration
|
||||
</v-btn>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AnnouncementMeta } from "~/composables/use-announcements";
|
||||
|
||||
const { user } = useMealieAuth();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const meta: AnnouncementMeta = {
|
||||
title: "Recipe embeds restricted to trusted hosts",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
p {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -59,9 +59,10 @@
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.confirm-delete-generic") }}
|
||||
<p v-if="deleteTarget" class="mt-4 ml-4">
|
||||
<p v-if="deleteTarget" class="mt-4 mb-0 font-weight-bold">
|
||||
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
|
||||
</p>
|
||||
<slot name="delete-dialog-bottom" />
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -88,6 +89,7 @@
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</v-card>
|
||||
<slot name="delete-dialog-bottom" />
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -151,7 +153,7 @@ const createDialog = defineModel("createDialog", { type: Boolean, default: false
|
||||
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
|
||||
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -185,6 +187,10 @@ defineProps({
|
||||
type: String,
|
||||
default: "name",
|
||||
},
|
||||
onDeleteDialogOpen: {
|
||||
type: Function as PropType<(items: any[]) => Promise<void>>,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -212,8 +218,11 @@ const editEventHandler = (item: any) => {
|
||||
const deleteTarget = ref<any>(null);
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
function deleteEventHandler(item: any) {
|
||||
async function deleteEventHandler(item: any) {
|
||||
deleteTarget.value = item;
|
||||
if (props.onDeleteDialogOpen) {
|
||||
await props.onDeleteDialogOpen([item]);
|
||||
}
|
||||
deleteDialog.value = true;
|
||||
}
|
||||
|
||||
@@ -222,8 +231,11 @@ function deleteEventHandler(item: any) {
|
||||
const bulkDeleteTarget = ref<Array<any>>([]);
|
||||
const bulkDeleteDialog = ref(false);
|
||||
|
||||
function bulkDeleteEventHandler(items: Array<any>) {
|
||||
async function bulkDeleteEventHandler(items: Array<any>) {
|
||||
bulkDeleteTarget.value = items;
|
||||
if (props.onDeleteDialogOpen) {
|
||||
await props.onDeleteDialogOpen(items);
|
||||
}
|
||||
bulkDeleteDialog.value = true;
|
||||
console.log("Bulk Delete Event Handler", items);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { truncateText as truncatePlainText } from "~/lib/sanitize/text";
|
||||
|
||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||
|
||||
@@ -50,10 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
defineEmits(["item-selected"]);
|
||||
function truncateText(text: string, length = 20, clamp = "...") {
|
||||
if (!props.truncate) return text;
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = text;
|
||||
const content = node.textContent || "";
|
||||
return content.length > length ? content.slice(0, length) + clamp : content;
|
||||
return truncatePlainText(text, length, clamp);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
enum DOMPurifyHook {
|
||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
||||
}
|
||||
import { sanitizeMarkdownHtml } from "~/lib/sanitize/markdown";
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
@@ -18,48 +14,11 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const ALLOWED_STYLE_TAGS = [
|
||||
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
|
||||
];
|
||||
|
||||
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
|
||||
if (!rawHtml) {
|
||||
return "";
|
||||
}
|
||||
|
||||
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (node, data) => {
|
||||
if (data.attrName === "style") {
|
||||
const styles = data.attrValue.split(";").filter((style) => {
|
||||
const [property] = style.split(":");
|
||||
return ALLOWED_STYLE_TAGS.includes(property.trim().toLowerCase());
|
||||
});
|
||||
data.attrValue = styles.join(";");
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: [
|
||||
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
|
||||
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
|
||||
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
|
||||
],
|
||||
});
|
||||
|
||||
Object.values(DOMPurifyHook).forEach((hook) => {
|
||||
DOMPurify.removeHook(hook);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
const { $appInfo } = useNuxtApp();
|
||||
|
||||
const value = computed(() => {
|
||||
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
|
||||
return sanitizeMarkdown(rawHtml);
|
||||
return sanitizeMarkdownHtml(rawHtml, $appInfo?.allowedIframeHosts ?? []);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { IngredientFood, RecipeSummary, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household";
|
||||
|
||||
export const MOCK_ITEM: ShoppingListItemOut = {
|
||||
shoppingListId: "",
|
||||
id: "",
|
||||
groupId: "",
|
||||
householdId: "",
|
||||
display: "MOCK_ITEM",
|
||||
updatedAt: "100",
|
||||
position: 1,
|
||||
checked: false,
|
||||
createdAt: "100",
|
||||
};
|
||||
|
||||
export const MOCK_RECIPE: RecipeSummary = {
|
||||
id: "recipe-id",
|
||||
name: "Recipe!",
|
||||
};
|
||||
|
||||
export const MOCK_RECIPE2: RecipeSummary = {
|
||||
...MOCK_RECIPE,
|
||||
id: undefined,
|
||||
name: "Recipe 2!",
|
||||
};
|
||||
|
||||
export const MOCK_FOOD: IngredientFood = {
|
||||
id: "1",
|
||||
name: "food 1",
|
||||
};
|
||||
|
||||
export const MOCK_FOOD2: IngredientFood = {
|
||||
id: "2",
|
||||
name: "food 2",
|
||||
};
|
||||
|
||||
export const MOCK_LABEL: ShoppingListMultiPurposeLabelOut = {
|
||||
shoppingListId: "",
|
||||
labelId: "",
|
||||
id: "",
|
||||
label: {
|
||||
name: "MOCK_LABEL",
|
||||
groupId: "",
|
||||
id: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_LABEL2: ShoppingListMultiPurposeLabelOut = {
|
||||
shoppingListId: "",
|
||||
labelId: "",
|
||||
id: "",
|
||||
label: {
|
||||
name: "MOCK_LABEL2",
|
||||
groupId: "",
|
||||
id: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_SHOPPING_LIST: ShoppingListOut = {
|
||||
groupId: "",
|
||||
userId: "",
|
||||
id: "",
|
||||
householdId: "",
|
||||
labelSettings: [
|
||||
MOCK_LABEL,
|
||||
MOCK_LABEL2,
|
||||
],
|
||||
listItems: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
recipeReferences: [{
|
||||
id: "",
|
||||
shoppingListId: "",
|
||||
recipeId: "",
|
||||
recipeQuantity: 0,
|
||||
recipe: MOCK_RECIPE,
|
||||
}, {
|
||||
id: "",
|
||||
shoppingListId: "",
|
||||
recipeId: "",
|
||||
recipeQuantity: 0,
|
||||
recipe: MOCK_RECIPE2,
|
||||
}],
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as vueusecore from "@vueuse/core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import { makeWrapper } from "~/tests/utils";
|
||||
import { useShoppingListCopy } from "../use-shopping-list-copy";
|
||||
import { MOCK_ITEM } from "./mocks";
|
||||
|
||||
vi.mock("@vueuse/core", { spy: true });
|
||||
|
||||
const mockCopy = vi.fn().mockImplementation(args => new Promise(resolve => resolve(args)));
|
||||
|
||||
vi.mocked(vueusecore.useClipboard).mockImplementation(() => {
|
||||
return {
|
||||
isSupported: computed(() => true),
|
||||
copied: computed(() => true),
|
||||
text: computed(() => ""),
|
||||
copy: mockCopy,
|
||||
};
|
||||
});
|
||||
const wrapper = () => makeWrapper(useShoppingListCopy);
|
||||
|
||||
const TEST_HEADER = "SPECIAL HEADER!";
|
||||
|
||||
const MOCK_LIST: { [key: string]: ShoppingListItemOut[] } = {
|
||||
[TEST_HEADER]: [MOCK_ITEM],
|
||||
[TEST_HEADER + "2"]: [MOCK_ITEM],
|
||||
};
|
||||
|
||||
describe("Shopping list copy composable", () => {
|
||||
describe("copyListItems", () => {
|
||||
test("copies markdown lists correctly", () => {
|
||||
const { copyListItems } = wrapper();
|
||||
copyListItems(MOCK_LIST, "markdown");
|
||||
const expected = [
|
||||
"# SPECIAL HEADER!",
|
||||
"- [ ] MOCK_ITEM",
|
||||
"",
|
||||
"# SPECIAL HEADER!2",
|
||||
"- [ ] MOCK_ITEM",
|
||||
].join("\n");
|
||||
|
||||
expect(mockCopy).toBeCalledWith(expected);
|
||||
});
|
||||
test("copies plain text lists correctly", () => {
|
||||
const { copyListItems } = wrapper();
|
||||
copyListItems(MOCK_LIST, "plain");
|
||||
const expected = [
|
||||
"[SPECIAL HEADER!]",
|
||||
"MOCK_ITEM",
|
||||
"",
|
||||
"[SPECIAL HEADER!2]",
|
||||
"MOCK_ITEM",
|
||||
].join("\n");
|
||||
|
||||
expect(mockCopy).toBeCalledWith(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCopiedLabelHeading", () => {
|
||||
test("copies markdown headers correctly", () => {
|
||||
const { formatCopiedLabelHeading } = wrapper();
|
||||
const header = formatCopiedLabelHeading("markdown", TEST_HEADER);
|
||||
expect(header).toEqual(`# ${TEST_HEADER}`);
|
||||
});
|
||||
test("copies plain text headers correctly", () => {
|
||||
const { formatCopiedLabelHeading } = wrapper();
|
||||
const header = formatCopiedLabelHeading("plain", TEST_HEADER);
|
||||
expect(header).toEqual(`[${TEST_HEADER}]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCopiedListItem", () => {
|
||||
test("copies markdown items correctly", () => {
|
||||
const { formatCopiedListItem } = wrapper();
|
||||
const header = formatCopiedListItem("markdown", MOCK_ITEM);
|
||||
expect(header).toEqual(`- [ ] ${MOCK_ITEM.display}`);
|
||||
});
|
||||
test("copies plain text items correctly", () => {
|
||||
const { formatCopiedListItem } = wrapper();
|
||||
const header = formatCopiedListItem("plain", MOCK_ITEM);
|
||||
expect(header).toEqual(MOCK_ITEM.display);
|
||||
});
|
||||
test("copies items without a display as empty", () => {
|
||||
const { formatCopiedListItem } = wrapper();
|
||||
const header = formatCopiedListItem("plain", { ...MOCK_ITEM, display: undefined });
|
||||
expect(header).toEqual("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||
import { makeWrapper } from "~/tests/utils";
|
||||
import { useShoppingListSorting } from "../use-shopping-list-sorting";
|
||||
import { MOCK_FOOD, MOCK_FOOD2, MOCK_ITEM, MOCK_LABEL, MOCK_LABEL2, MOCK_SHOPPING_LIST } from "./mocks";
|
||||
|
||||
const wrapper = () => makeWrapper(() => {
|
||||
const { t } = useI18n();
|
||||
return {
|
||||
t,
|
||||
...useShoppingListSorting(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("use-shopping-list-sorting", () => {
|
||||
describe("sortItems", () => {
|
||||
const { sortItems } = wrapper();
|
||||
test("sorts by position first", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, position: 0 });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, position: 0 }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
test("sorts by createdAt next", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, createdAt: "0" });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, createdAt: "0" }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
test("sorts similar items into the same spot", () => {
|
||||
const result = sortItems(MOCK_ITEM, MOCK_ITEM);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, position: undefined });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, position: undefined }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const result = sortItems(MOCK_ITEM, { ...MOCK_ITEM, createdAt: undefined });
|
||||
const result2 = sortItems({ ...MOCK_ITEM, createdAt: undefined }, MOCK_ITEM);
|
||||
expect(result).toBe(1);
|
||||
expect(result2).toBe(-1);
|
||||
});
|
||||
});
|
||||
describe("sortListItems", () => {
|
||||
const { sortListItems } = wrapper();
|
||||
test("sorts by position first", () => {
|
||||
const sortedList = { ...MOCK_SHOPPING_LIST, listItems: [MOCK_ITEM, { ...MOCK_ITEM, position: 0 }, { ...MOCK_ITEM, createdAt: "0" }] };
|
||||
sortListItems(sortedList);
|
||||
expect(sortedList.listItems).toEqual([
|
||||
{ ...MOCK_ITEM, position: 0 },
|
||||
{ ...MOCK_ITEM, createdAt: "0" },
|
||||
MOCK_ITEM,
|
||||
]);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const sortedList = { ...MOCK_SHOPPING_LIST, listItems: undefined };
|
||||
sortListItems(sortedList);
|
||||
expect(sortedList.listItems).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
describe("updateItemsByLabel", () => {
|
||||
const { updateItemsByLabel, t } = wrapper();
|
||||
test("sorts by group", () => {
|
||||
const sortedList = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
MOCK_ITEM,
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
};
|
||||
const result = updateItemsByLabel(sortedList);
|
||||
expect(result).toEqual({
|
||||
[t("shopping-list.no-label")]: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
[MOCK_LABEL.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
[MOCK_LABEL2.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
test("ignores checked items", () => {
|
||||
const sortedList = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
MOCK_ITEM,
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1", checked: true },
|
||||
],
|
||||
};
|
||||
const result = updateItemsByLabel(sortedList);
|
||||
expect(result).toEqual({
|
||||
[t("shopping-list.no-label")]: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
[MOCK_LABEL.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
[MOCK_LABEL2.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
test("returns unordered labels if no ordering is specified", () => {
|
||||
const sortedList = {
|
||||
...MOCK_SHOPPING_LIST,
|
||||
labelSettings: undefined,
|
||||
listItems: [
|
||||
MOCK_ITEM,
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1", checked: true },
|
||||
],
|
||||
};
|
||||
const result = updateItemsByLabel(sortedList);
|
||||
expect(result).toEqual({
|
||||
[t("shopping-list.no-label")]: [
|
||||
MOCK_ITEM,
|
||||
],
|
||||
[MOCK_LABEL2.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL2.label, labelId: "2" },
|
||||
],
|
||||
[MOCK_LABEL.label.name]: [
|
||||
{ ...MOCK_ITEM, label: MOCK_LABEL.label, labelId: "1" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("groupAndSortListItemsByFood", () => {
|
||||
const { groupAndSortListItemsByFood } = wrapper();
|
||||
test("sorts by group", () => {
|
||||
const sortedList = { ...MOCK_SHOPPING_LIST };
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual(MOCK_SHOPPING_LIST.listItems);
|
||||
});
|
||||
test("groups checked items together", () => {
|
||||
const sortedList: ShoppingListOut = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD },
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD2 },
|
||||
],
|
||||
};
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual([
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD },
|
||||
{ ...MOCK_ITEM, checked: true, food: MOCK_FOOD2, position: 1 },
|
||||
]);
|
||||
});
|
||||
test("populates position and created at if not present", () => {
|
||||
const sortedList: ShoppingListOut = {
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD, position: undefined },
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD2, createdAt: undefined },
|
||||
],
|
||||
};
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual([
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD2, createdAt: undefined },
|
||||
{ ...MOCK_ITEM, food: MOCK_FOOD, position: 1 },
|
||||
]);
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const sortedList: ShoppingListOut = { ...MOCK_SHOPPING_LIST, listItems: undefined };
|
||||
groupAndSortListItemsByFood(sortedList);
|
||||
expect(sortedList.listItems).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||
import { makeWrapper } from "~/tests/utils";
|
||||
import { useShoppingListState } from "../use-shopping-list-state";
|
||||
import { MOCK_ITEM, MOCK_RECIPE, MOCK_RECIPE2, MOCK_SHOPPING_LIST } from "./mocks";
|
||||
|
||||
const wrapper = (list: ShoppingListOut = MOCK_SHOPPING_LIST) => makeWrapper(() => {
|
||||
const { shoppingList, ...state } = useShoppingListState();
|
||||
shoppingList.value = list;
|
||||
return {
|
||||
shoppingList,
|
||||
...state,
|
||||
};
|
||||
});
|
||||
|
||||
describe("use-shopping-list-state", () => {
|
||||
describe("checked items are sorted", () => {
|
||||
const { sortCheckedItems } = wrapper();
|
||||
|
||||
test("by timestamp", () => {
|
||||
const sorted = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, updatedAt: "200" });
|
||||
const sorted2 = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, updatedAt: "0" });
|
||||
expect(sorted).toBe(1);
|
||||
expect(sorted2).toBe(-1);
|
||||
});
|
||||
test("by position if timestamps match", () => {
|
||||
const sorted = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, position: 2 });
|
||||
const sorted2 = sortCheckedItems(MOCK_ITEM, { ...MOCK_ITEM, position: 0 });
|
||||
const sorted3 = sortCheckedItems({ ...MOCK_ITEM, position: undefined }, { ...MOCK_ITEM, position: undefined });
|
||||
expect(sorted).toBe(1);
|
||||
expect(sorted2).toBe(-1);
|
||||
expect(sorted3).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipeMap", () => {
|
||||
test("Updates to match shopping list recipe references", () => {
|
||||
const { recipeMap } = wrapper();
|
||||
expect(recipeMap).toEqual(new Map([
|
||||
[MOCK_RECIPE.id, MOCK_RECIPE],
|
||||
["", MOCK_RECIPE2],
|
||||
]));
|
||||
});
|
||||
test("handles nulls", () => {
|
||||
const { recipeMap } = wrapper({ ...MOCK_SHOPPING_LIST, recipeReferences: undefined });
|
||||
expect(recipeMap).toEqual(new Map([]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("checked and unchecked items", () => {
|
||||
test("update appropriately", () => {
|
||||
const mockCheckedItem = { ...MOCK_ITEM, checked: true };
|
||||
const { listItems: { checked, unchecked } } = wrapper({
|
||||
...MOCK_SHOPPING_LIST, listItems: [
|
||||
MOCK_ITEM,
|
||||
mockCheckedItem,
|
||||
],
|
||||
});
|
||||
expect(unchecked[0]).toEqual(MOCK_ITEM);
|
||||
expect(checked[0]).toEqual(mockCheckedItem);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Svenska (Swedish)",
|
||||
value: "sv-SE",
|
||||
progress: 75,
|
||||
progress: 76,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -52,7 +52,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Slovenščina (Slovenian)",
|
||||
value: "sl-SI",
|
||||
progress: 56,
|
||||
progress: 57,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -73,7 +73,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Română (Romanian)",
|
||||
value: "ro-RO",
|
||||
progress: 59,
|
||||
progress: 60,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -108,7 +108,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 97,
|
||||
progress: 98,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -150,7 +150,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Íslenska (Icelandic)",
|
||||
value: "is-IS",
|
||||
progress: 56,
|
||||
progress: 57,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -248,14 +248,14 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 98,
|
||||
progress: 99,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 99,
|
||||
progress: 100,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
@@ -276,7 +276,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Български (Bulgarian)",
|
||||
value: "bg-BG",
|
||||
progress: 71,
|
||||
progress: 72,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
|
||||
"plantoeat": {
|
||||
"title": "Plan to Eat",
|
||||
"description-long": "Mealie can import recipies from Plan to Eat."
|
||||
"description-long": "Mealie can import recipes from Plan to Eat. Upload a ZIP archive, CSV, or TXT file exported from Plan to Eat."
|
||||
},
|
||||
"myrecipebox": {
|
||||
"title": "My Recipe Box",
|
||||
@@ -1144,6 +1144,8 @@
|
||||
},
|
||||
"data-pages": {
|
||||
"foods": {
|
||||
"delete-affects-recipes": "Warning: this food is used in {count} recipe(s). Deleting it will leave an empty ingredient in the recipe(s).",
|
||||
"delete-affects-recipes-more": "View all {count} recipes",
|
||||
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
|
||||
"merge-food-example": "Merging {food1} into {food2}",
|
||||
"seed-dialog-text": "Seed the database with foods based on your local language. This will create ~2700 common foods that can be used to organize your database. Foods are translated via a community effort.",
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface AppInfo {
|
||||
oidcRedirect: boolean;
|
||||
oidcProviderName: string;
|
||||
tokenTime: number;
|
||||
allowedIframeHosts?: string[];
|
||||
}
|
||||
export interface AppStartupInfo {
|
||||
isFirstLogin: boolean;
|
||||
|
||||
74
frontend/app/lib/sanitize/markdown.test.ts
Normal file
74
frontend/app/lib/sanitize/markdown.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { sanitizeMarkdownHtml } from "./markdown";
|
||||
|
||||
describe("sanitizeMarkdownHtml", () => {
|
||||
test("returns empty string for nullish input", () => {
|
||||
expect(sanitizeMarkdownHtml(null)).toEqual("");
|
||||
expect(sanitizeMarkdownHtml(undefined)).toEqual("");
|
||||
expect(sanitizeMarkdownHtml("")).toEqual("");
|
||||
});
|
||||
|
||||
test("keeps allowed formatting tags", () => {
|
||||
const html = sanitizeMarkdownHtml("<p>Mix <strong>flour</strong> and <em>water</em></p>");
|
||||
expect(html).toContain("<strong>flour</strong>");
|
||||
expect(html).toContain("<em>water</em>");
|
||||
});
|
||||
|
||||
test("strips script tags and event handlers", () => {
|
||||
const html = sanitizeMarkdownHtml("<p onclick=\"alert(1)\">hi</p><script>alert(1)</script>");
|
||||
expect(html).not.toContain("script");
|
||||
expect(html).not.toContain("onclick");
|
||||
expect(html).not.toContain("alert");
|
||||
});
|
||||
|
||||
test("strips img onerror payloads", () => {
|
||||
const html = sanitizeMarkdownHtml("<img src=x onerror=alert(1)>");
|
||||
expect(html).not.toContain("onerror");
|
||||
});
|
||||
|
||||
// Form controls must never render in user content.
|
||||
test("strips form, input, and button elements", () => {
|
||||
const html = sanitizeMarkdownHtml("<form action=/x><input name=p><button>go</button></form>");
|
||||
expect(html).not.toContain("<form");
|
||||
expect(html).not.toContain("<input");
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
|
||||
test("strips iframes when no allowed hosts are configured", () => {
|
||||
const html = sanitizeMarkdownHtml("<iframe src=\"https://evil.example/x\"></iframe>", []);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
test("strips iframes whose src host is not allowlisted", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"https://evil.example/x\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
test("strips non-https iframes even for an allowlisted host", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"http://www.youtube.com/embed/abc\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
test("keeps iframes from an allowlisted host (incl. subdomains)", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"https://www.youtube.com/embed/abc\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).toContain("<iframe");
|
||||
expect(html).toContain("https://www.youtube.com/embed/abc");
|
||||
});
|
||||
|
||||
test("does not allow a lookalike host to pass the suffix check", () => {
|
||||
const html = sanitizeMarkdownHtml(
|
||||
"<iframe src=\"https://notyoutube.com/embed/abc\"></iframe>",
|
||||
["youtube.com"],
|
||||
);
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
});
|
||||
98
frontend/app/lib/sanitize/markdown.ts
Normal file
98
frontend/app/lib/sanitize/markdown.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
enum DOMPurifyHook {
|
||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
||||
AfterSanitizeAttributes = "afterSanitizeAttributes",
|
||||
}
|
||||
|
||||
const ALLOWED_STYLE_PROPERTIES = [
|
||||
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
|
||||
];
|
||||
|
||||
const BASE_ALLOWED_TAGS = [
|
||||
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
|
||||
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote",
|
||||
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
|
||||
];
|
||||
|
||||
const BASE_ALLOWED_ATTR = [
|
||||
"href", "src", "alt", "height", "width", "class", "title",
|
||||
"cite", "datetime", "name", "abbr", "target", "border", "start", "style",
|
||||
];
|
||||
|
||||
// Attributes only meaningful on an <iframe>; added to the allowlist solely when iframe embeds
|
||||
// are enabled via a configured host allowlist.
|
||||
const IFRAME_ALLOWED_ATTR = ["allow", "allowfullscreen", "frameborder", "scrolling"];
|
||||
|
||||
/**
|
||||
* Returns true if an iframe `src` points at one of the allowed hosts. Only https URLs are
|
||||
* accepted, and a configured host matches the URL's hostname exactly or as a parent domain
|
||||
* (e.g. "youtube.com" matches "www.youtube.com").
|
||||
*/
|
||||
function isAllowedIframeSrc(src: string, allowedHosts: string[]): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(src);
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
return allowedHosts.some((host) => {
|
||||
const allowed = host.toLowerCase();
|
||||
return hostname === allowed || hostname.endsWith(`.${allowed}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes pre-rendered HTML (from markdown) for display in user content such as recipe
|
||||
* instructions, notes, and descriptions.
|
||||
*
|
||||
* Only the tags in `BASE_ALLOWED_TAGS` and attributes in `BASE_ALLOWED_ATTR` survive; everything
|
||||
* else (scripts, event handlers, form controls, ...) is dropped. `style` attributes are filtered
|
||||
* down to the properties in `ALLOWED_STYLE_PROPERTIES`. `<iframe>` is only kept when
|
||||
* `allowedIframeHosts` is non-empty, and even then any iframe whose `src` is not an https URL on
|
||||
* the host allowlist is removed.
|
||||
*/
|
||||
export function sanitizeMarkdownHtml(rawHtml: string | null | undefined, allowedIframeHosts: string[] = []): string {
|
||||
if (!rawHtml) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const allowIframe = allowedIframeHosts.length > 0;
|
||||
|
||||
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (_node, data) => {
|
||||
if (data.attrName === "style") {
|
||||
const styles = data.attrValue.split(";").filter((style) => {
|
||||
const [property] = style.split(":");
|
||||
return ALLOWED_STYLE_PROPERTIES.includes(property.trim().toLowerCase());
|
||||
});
|
||||
data.attrValue = styles.join(";");
|
||||
}
|
||||
});
|
||||
|
||||
if (allowIframe) {
|
||||
DOMPurify.addHook(DOMPurifyHook.AfterSanitizeAttributes, (node) => {
|
||||
if (node.nodeName === "IFRAME" && !isAllowedIframeSrc(node.getAttribute("src") || "", allowedIframeHosts)) {
|
||||
node.parentNode?.removeChild(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: allowIframe ? [...BASE_ALLOWED_TAGS, "iframe"] : BASE_ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: allowIframe ? [...BASE_ALLOWED_ATTR, ...IFRAME_ALLOWED_ATTR] : BASE_ALLOWED_ATTR,
|
||||
});
|
||||
|
||||
Object.values(DOMPurifyHook).forEach((hook) => {
|
||||
DOMPurify.removeHook(hook);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
35
frontend/app/lib/sanitize/text.test.ts
Normal file
35
frontend/app/lib/sanitize/text.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { truncateText } from "./text";
|
||||
|
||||
describe("truncateText", () => {
|
||||
test("returns short text unchanged", () => {
|
||||
expect(truncateText("Dinner")).toEqual("Dinner");
|
||||
});
|
||||
|
||||
test("truncates long text with clamp", () => {
|
||||
expect(truncateText("a".repeat(25))).toEqual(`${"a".repeat(20)}...`);
|
||||
});
|
||||
|
||||
test("respects custom length and clamp", () => {
|
||||
expect(truncateText("abcdef", 3, "~")).toEqual("abc~");
|
||||
});
|
||||
|
||||
test("does not clamp text exactly at the length boundary", () => {
|
||||
expect(truncateText("abcde", 5)).toEqual("abcde");
|
||||
expect(truncateText("abcdef", 5)).toEqual("abcde...");
|
||||
});
|
||||
|
||||
// Markup in the input must be treated as plain text and never parsed into the live document.
|
||||
test("does not parse or execute HTML payloads", () => {
|
||||
const createElement = vi.spyOn(document, "createElement");
|
||||
const payload = "<img src=x onerror=alert(1)>";
|
||||
|
||||
const result = truncateText(payload);
|
||||
|
||||
// The payload is returned verbatim (truncated only by length), proving it is treated as text.
|
||||
expect(result).toEqual(`${payload.slice(0, 20)}...`);
|
||||
// No DOM element is constructed, so no <img> can fire its onerror handler.
|
||||
expect(createElement).not.toHaveBeenCalled();
|
||||
createElement.mockRestore();
|
||||
});
|
||||
});
|
||||
9
frontend/app/lib/sanitize/text.ts
Normal file
9
frontend/app/lib/sanitize/text.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Truncates plain text to `length` characters, appending `clamp` when truncated.
|
||||
*
|
||||
* The input is treated strictly as text and is never parsed as HTML, so markup in the input is
|
||||
* returned verbatim rather than interpreted.
|
||||
*/
|
||||
export function truncateText(text: string, length = 20, clamp = "..."): string {
|
||||
return text.length > length ? text.slice(0, length) + clamp : text;
|
||||
}
|
||||
@@ -69,11 +69,7 @@
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
|
||||
<v-alert
|
||||
v-if="foods && foods.length > 0"
|
||||
type="error"
|
||||
class="mb-0 text-body-2"
|
||||
>
|
||||
<v-alert v-if="foods && foods.length > 0" type="error" class="mb-0 text-body-2">
|
||||
{{ $t("data-pages.foods.seed-dialog-warning") }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
@@ -112,11 +108,7 @@
|
||||
:label="$t('data-pages.foods.food-label')"
|
||||
/>
|
||||
<v-card variant="outlined">
|
||||
<v-virtual-scroll
|
||||
height="400"
|
||||
item-height="25"
|
||||
:items="bulkAssignTarget"
|
||||
>
|
||||
<v-virtual-scroll height="400" item-height="25" :items="bulkAssignTarget">
|
||||
<template #default="{ item }">
|
||||
<v-list-item class="pb-2">
|
||||
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
||||
@@ -141,6 +133,7 @@
|
||||
]"
|
||||
:create-form="createForm"
|
||||
:edit-form="editForm"
|
||||
:on-delete-dialog-open="onDeleteDialogOpen"
|
||||
@create-one="handleCreate"
|
||||
@edit-one="handleEdit"
|
||||
@delete-one="foodStore.actions.deleteOne"
|
||||
@@ -151,15 +144,12 @@
|
||||
<template #icon>
|
||||
{{ $globals.icons.externalLink }}
|
||||
</template>
|
||||
{{ $t('data-pages.combine') }}
|
||||
{{ $t("data-pages.combine") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<template #[`item.label`]="{ item }">
|
||||
<MultiPurposeLabel
|
||||
v-if="item.label"
|
||||
:label="item.label"
|
||||
>
|
||||
<MultiPurposeLabel v-if="item.label" :label="item.label">
|
||||
{{ item.label.name }}
|
||||
</MultiPurposeLabel>
|
||||
</template>
|
||||
@@ -171,7 +161,7 @@
|
||||
</template>
|
||||
|
||||
<template #[`item.createdAt`]="{ item }">
|
||||
{{ item.createdAt ? $d(new Date(item.createdAt)) : '' }}
|
||||
{{ item.createdAt ? $d(new Date(item.createdAt)) : "" }}
|
||||
</template>
|
||||
|
||||
<template #table-button-bottom>
|
||||
@@ -179,18 +169,33 @@
|
||||
<template #icon>
|
||||
{{ $globals.icons.database }}
|
||||
</template>
|
||||
{{ $t('data-pages.seed') }}
|
||||
{{ $t("data-pages.seed") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<template #edit-dialog-custom-action>
|
||||
<BaseButton
|
||||
edit
|
||||
@click="aliasManagerDialog = true"
|
||||
>
|
||||
{{ $t('data-pages.manage-aliases') }}
|
||||
<BaseButton edit @click="aliasManagerDialog = true">
|
||||
{{ $t("data-pages.manage-aliases") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<template #delete-dialog-bottom>
|
||||
<v-alert v-if="affectedRecipes.length > 0" type="warning" density="compact" class="mt-4 mb-0">
|
||||
{{ $t("data-pages.foods.delete-affects-recipes", { count: affectedRecipesTotal }) }}
|
||||
<ul class="mt-1 pl-5 mb-0">
|
||||
<li v-for="recipe in affectedRecipes.slice(0, 5)" :key="recipe.slug">
|
||||
<NuxtLink :to="recipe.url" class="text-white">{{ recipe.name }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink
|
||||
v-if="affectedRecipesTotal > 5"
|
||||
:to="affectedRecipesMoreLink"
|
||||
class="text-white d-inline-block mt-1"
|
||||
>
|
||||
{{ $t("data-pages.foods.delete-affects-recipes-more", { count: affectedRecipesTotal }) }}
|
||||
</NuxtLink>
|
||||
</v-alert>
|
||||
</template>
|
||||
</GroupDataPage>
|
||||
</div>
|
||||
</template>
|
||||
@@ -218,6 +223,7 @@ interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
|
||||
interface IngredientFoodWithOnHand extends IngredientFood {
|
||||
onHand: boolean;
|
||||
}
|
||||
|
||||
const userApi = useUserApi();
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
@@ -274,11 +280,14 @@ const tableHeaders: TableHeaders[] = [
|
||||
];
|
||||
|
||||
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
|
||||
const userGroup = computed(() => auth.user.value?.groupSlug || "");
|
||||
const foodStore = useFoodStore();
|
||||
const foods = computed(() => foodStore.store.value.map((food) => {
|
||||
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
|
||||
return { ...food, onHand } as IngredientFoodWithOnHand;
|
||||
}));
|
||||
const foods = computed(() =>
|
||||
foodStore.store.value.map((food) => {
|
||||
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
|
||||
return { ...food, onHand } as IngredientFoodWithOnHand;
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Labels
|
||||
@@ -383,6 +392,9 @@ async function handleBulkAction(event: string, items: IngredientFoodWithOnHand[]
|
||||
if (event === "delete-selected") {
|
||||
const ids = items.map(item => item.id);
|
||||
await foodStore.actions.deleteMany(ids);
|
||||
affectedRecipes.value = [];
|
||||
affectedRecipesTotal.value = 0;
|
||||
affectedRecipesMoreLink.value = "";
|
||||
}
|
||||
else if (event === "assign-selected") {
|
||||
bulkAssignEventHandler(items);
|
||||
@@ -401,6 +413,26 @@ function updateFoodAlias(newAliases: IngredientFoodAlias[]) {
|
||||
aliasManagerDialog.value = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Delete Foods
|
||||
|
||||
// fetch affected recipes before confirming deletion
|
||||
const affectedRecipes = ref<{ name: string; slug: string; url: string }[]>([]);
|
||||
const affectedRecipesTotal = ref(0);
|
||||
const affectedRecipesMoreLink = ref("");
|
||||
|
||||
async function onDeleteDialogOpen(items: IngredientFoodWithOnHand[]) {
|
||||
const ids = items.map(item => item.id);
|
||||
const { data } = await userApi.recipes.search({ foods: ids, perPage: 5 });
|
||||
affectedRecipes.value = (data?.items ?? []).map(r => ({
|
||||
name: r.name ?? "",
|
||||
slug: r.slug ?? "",
|
||||
url: `/g/${userGroup.value}/r/${r.slug}`,
|
||||
}));
|
||||
affectedRecipesTotal.value = data?.total ?? 0;
|
||||
affectedRecipesMoreLink.value = `/g/${userGroup.value}?${ids.map(id => `foods=${id}`).join("&")}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Merge Foods
|
||||
|
||||
|
||||
@@ -337,16 +337,8 @@ const _content: Record<string, MigrationContent> = {
|
||||
},
|
||||
[MIGRATIONS.plantoeat]: {
|
||||
text: i18n.t("migration.plantoeat.description-long"),
|
||||
acceptedFileType: ".zip",
|
||||
tree: [
|
||||
{
|
||||
icon: $globals.icons.zip,
|
||||
title: "plantoeat-recipes-508318_10-13-2023.zip",
|
||||
children: [
|
||||
{ title: "plantoeat-recipes-508318_10-13-2023.csv", icon: $globals.icons.codeJson },
|
||||
],
|
||||
},
|
||||
],
|
||||
acceptedFileType: ".zip,.csv,.txt",
|
||||
tree: false,
|
||||
},
|
||||
[MIGRATIONS.recipekeeper]: {
|
||||
text: i18n.t("migration.recipekeeper.description-long"),
|
||||
|
||||
18
frontend/app/tests/setup.ts
Normal file
18
frontend/app/tests/setup.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { config } from "@vue/test-utils";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
function loadEnLocales() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require("../lang/messages/en-US.json") as Record<string, string>;
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: "en-US",
|
||||
messages: {
|
||||
"en-US": loadEnLocales(),
|
||||
},
|
||||
});
|
||||
|
||||
config.global.plugins = [...(config.global.plugins ?? []), i18n];
|
||||
|
||||
export { i18n };
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
function loadEnLocales() {
|
||||
@@ -14,3 +15,12 @@ export function stubI18n() {
|
||||
});
|
||||
return i18n.global;
|
||||
}
|
||||
|
||||
export const makeWrapper = <T>(setup: () => T) => {
|
||||
const Wrapper = {
|
||||
template: "<div />",
|
||||
setup,
|
||||
};
|
||||
const { vm } = mount(Wrapper);
|
||||
return vm as unknown as ReturnType<typeof Wrapper.setup>;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export default withNuxt({
|
||||
"@stylistic/no-tabs": ["error"],
|
||||
"@stylistic/no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"import/no-extraneous-dependencies": ["error"],
|
||||
"vue/first-attribute-linebreak": "error",
|
||||
"vue/html-closing-bracket-newline": "error",
|
||||
"vue/max-attributes-per-line": [
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"lint:log": "yarn lint:js --debug",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest --watch=false",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"cleanup": "nuxt cleanup"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -21,18 +23,24 @@
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxtjs/i18n": "^9.2.1",
|
||||
"@sphinxxxx/color-conversion": "^2.2.2",
|
||||
"@vite-pwa/nuxt": "^0.10.6",
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"@vueuse/shared": "^14.3.0",
|
||||
"axios": "^1.8.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"fuse.js": "^7.1.0",
|
||||
"isomorphic-dompurify": "^3.4.0",
|
||||
"json-editor-vue": "^0.18.1",
|
||||
"marked": "^15.0.12",
|
||||
"nuxt": "^4.4.2",
|
||||
"sse.js": "^2.8.0",
|
||||
"ufo": "^1.6.4",
|
||||
"vue": "^3.5.35",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "^11.4.4",
|
||||
"vuetify": "^4.0.5",
|
||||
"vuetify-nuxt-module": "^0.19.5"
|
||||
},
|
||||
@@ -41,6 +49,10 @@
|
||||
"@stylistic/eslint-plugin": "^5.4.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.0.2",
|
||||
"eslint-plugin-format": "^1.0.1",
|
||||
@@ -50,8 +62,9 @@
|
||||
"prettier": "^3.5.2",
|
||||
"sass-embedded": "^1.85.1",
|
||||
"typescript": "^5.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"vite-plugin-commonjs": "^0.10.4",
|
||||
"vitest": "^3.0.7"
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import path from "path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
|
||||
export default {
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ["vue", "@vueuse/core", "vue-i18n"],
|
||||
dts: false,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./app/tests/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["app/{lib,components,composables,layouts,pages}/**/*.{ts,tsx,vue}"],
|
||||
exclude: ["**/*.test.*", "node_modules/**", "dist/**", "coverage/**", "**/__tests__/**"],
|
||||
reporter: ["html", "text-summary"],
|
||||
all: true,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -179,7 +179,7 @@ def validate_file_token(token: str | None = None) -> Path:
|
||||
@contextmanager
|
||||
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]:
|
||||
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
||||
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")
|
||||
temp_path = app_dirs.TEMP_DIR / f"{uuid4().hex}.zip"
|
||||
try:
|
||||
yield temp_path
|
||||
finally:
|
||||
|
||||
@@ -33,6 +33,16 @@ class FeatureDetails(NamedTuple):
|
||||
return s
|
||||
|
||||
|
||||
DEFAULT_ALLOWED_IFRAME_HOSTS = [
|
||||
"youtube.com",
|
||||
"youtube-nocookie.com",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
]
|
||||
"""Secure-by-default hostnames permitted as `<iframe>` sources in user content. Limited to
|
||||
well-known video providers. Subdomains of these hosts are also allowed (e.g. `www.youtube.com`)."""
|
||||
|
||||
|
||||
MaskedNoneString = Annotated[
|
||||
str | None,
|
||||
PlainSerializer(lambda x: None if x is None else "*****", return_type=str | None),
|
||||
@@ -150,6 +160,19 @@ class AppSettings(AppLoggingSettings):
|
||||
ALLOW_SIGNUP: bool = False
|
||||
ALLOW_PASSWORD_LOGIN: bool = True
|
||||
|
||||
ALLOWED_IFRAME_HOSTS: str = ""
|
||||
"""Comma-separated list of additional hostnames allowed as `<iframe>` sources in user content
|
||||
(recipe instructions, notes, descriptions). Extends `DEFAULT_ALLOWED_IFRAME_HOSTS`. Subdomains of
|
||||
a listed host are also allowed. Adding hosts is opt-in to riskier behavior; the defaults are
|
||||
limited to well-known video providers."""
|
||||
|
||||
@property
|
||||
def allowed_iframe_hosts(self) -> list[str]:
|
||||
"""The full set of hostnames permitted as `<iframe>` sources, secure defaults plus any
|
||||
admin-configured additions via `ALLOWED_IFRAME_HOSTS`."""
|
||||
extra = [host.strip().lower() for host in self.ALLOWED_IFRAME_HOSTS.split(",") if host.strip()]
|
||||
return list(dict.fromkeys(DEFAULT_ALLOWED_IFRAME_HOSTS + extra))
|
||||
|
||||
DAILY_SCHEDULE_TIME: str = "23:45"
|
||||
"""Local server time, in HH:MM format. See `DAILY_SCHEDULE_TIME_UTC` for the parsed UTC equivalent"""
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ def get_app_info(session: Session = Depends(generate_session)):
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
|
||||
token_time=settings.TOKEN_TIME,
|
||||
allowed_iframe_hosts=settings.allowed_iframe_hosts,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,13 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if file.exists():
|
||||
return FileResponse(file, filename=file.name, content_disposition_type="attachment")
|
||||
# Force download and disable MIME sniffing so uploaded assets cannot be
|
||||
# served as active content in Mealie's origin.
|
||||
return FileResponse(
|
||||
file,
|
||||
filename=file.name,
|
||||
content_disposition_type="attachment",
|
||||
headers={"X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@@ -22,6 +22,7 @@ class AppInfo(MealieModel):
|
||||
oidc_redirect: bool
|
||||
oidc_provider_name: str
|
||||
token_time: int
|
||||
allowed_iframe_hosts: list[str] = []
|
||||
|
||||
|
||||
class AppTheme(MealieModel):
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.settings.static import APP_VERSION
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
||||
from mealie.services.backups_v2.backup_file import BackupFile
|
||||
@@ -43,8 +44,15 @@ class BackupV2(BaseService):
|
||||
def backup(self) -> Path:
|
||||
# sourcery skip: merge-nested-ifs, reintroduce-else, remove-redundant-continue
|
||||
timestamp = datetime.datetime.now(datetime.UTC).strftime("%Y.%m.%d.%H.%M.%S")
|
||||
short_hash = self.settings.GIT_COMMIT_HASH[:7]
|
||||
|
||||
if APP_VERSION == "develop":
|
||||
backup_name = f"mealie_dev-{short_hash}_{timestamp}.zip"
|
||||
elif APP_VERSION == "nightly":
|
||||
backup_name = f"mealie_nightly-{short_hash}_{timestamp}.zip"
|
||||
else:
|
||||
backup_name = f"mealie_{APP_VERSION}_{timestamp}.zip"
|
||||
|
||||
backup_name = f"mealie_{timestamp}.zip"
|
||||
backup_file = self.directories.BACKUP_DIR / backup_name
|
||||
|
||||
database_json = self.db_exporter.dump()
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from slugify import slugify
|
||||
|
||||
from mealie.pkgs.cache import cache_key
|
||||
from mealie.schema.reports.reports import ReportEntryCreate
|
||||
from mealie.services.scraper import cleaner
|
||||
|
||||
from ._migration_base import BaseMigrator
|
||||
@@ -15,15 +16,23 @@ from .utils.migration_helpers import scrape_image, split_by_comma
|
||||
|
||||
|
||||
def plantoeat_recipes(file: Path):
|
||||
"""Yields all recipes inside the export file as dict"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with zipfile.ZipFile(file) as zip_file:
|
||||
zip_file.extractall(tmpdir)
|
||||
"""Yields all recipes inside the export file as dict.
|
||||
|
||||
for name in Path(tmpdir).glob("**/[!.]*.csv"):
|
||||
with open(name, newline="") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
yield from reader
|
||||
Accepts a ZIP archive containing a CSV, or a raw CSV/TXT file.
|
||||
"""
|
||||
if zipfile.is_zipfile(file):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with zipfile.ZipFile(file) as zip_file:
|
||||
zip_file.extractall(tmpdir)
|
||||
|
||||
for name in Path(tmpdir).glob("**/[!.]*.csv"):
|
||||
with open(name, newline="") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
yield from reader
|
||||
else:
|
||||
with open(file, newline="", encoding="utf-8", errors="ignore") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
yield from reader
|
||||
|
||||
|
||||
def get_value_as_string_or_none(dictionary: dict, key: str):
|
||||
@@ -112,7 +121,32 @@ class PlanToEatMigrator(BaseMigrator):
|
||||
|
||||
return recipe_dict
|
||||
|
||||
def _validate_archive(self) -> bool:
|
||||
"""Returns False and appends a failure report entry if the file is not a ZIP, CSV, or TXT."""
|
||||
if zipfile.is_zipfile(self.archive):
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(self.archive, encoding="utf-8", errors="strict") as f:
|
||||
f.read(512)
|
||||
return True
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
self.report_entries.append(
|
||||
ReportEntryCreate(
|
||||
report_id=self.report_id,
|
||||
success=False,
|
||||
message="Unsupported file format. Please upload a ZIP archive, CSV file, or TXT file.",
|
||||
exception="",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
def _migrate(self) -> None:
|
||||
if not self._validate_archive():
|
||||
return
|
||||
|
||||
recipe_image_urls = {}
|
||||
|
||||
recipes = []
|
||||
|
||||
@@ -12,9 +12,7 @@ dependencies = [
|
||||
"SQLAlchemy==2.0.50",
|
||||
"aiofiles==25.1.0",
|
||||
"alembic==1.18.4",
|
||||
"aniso8601==10.0.1",
|
||||
"appdirs==1.4.4",
|
||||
"apprise==1.10.0",
|
||||
"apprise==1.11.0",
|
||||
"bcrypt==5.0.0",
|
||||
"extruct==0.18.0",
|
||||
"fastapi==0.136.3",
|
||||
@@ -71,7 +69,7 @@ dev = [
|
||||
"pre-commit==4.6.0",
|
||||
"pylint==4.0.5",
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"pytest-asyncio==1.4.0",
|
||||
"rich==15.0.0",
|
||||
"ruff==0.15.14",
|
||||
"types-PyYAML==6.0.12.20260518",
|
||||
@@ -178,3 +176,7 @@ max-complexity = 24 # Default is 10.
|
||||
|
||||
[tool.uv]
|
||||
add-bounds = "exact"
|
||||
# Cooling period: ignore package releases newer than 5 days to mitigate
|
||||
# supply-chain attacks (compromised releases are usually caught and yanked
|
||||
# within days). Evaluated at resolve time as a rolling window.
|
||||
exclude-newer = "5 days"
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"minimumReleaseAge": "5 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"addLabels": [
|
||||
"dependencies"
|
||||
],
|
||||
@@ -51,8 +53,7 @@
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"automergeStrategy": "squash",
|
||||
"minimumReleaseAge": "5 days"
|
||||
"automergeStrategy": "squash"
|
||||
},
|
||||
{
|
||||
"description": "Auto-merge Docker digest and patch updates",
|
||||
@@ -66,8 +67,7 @@
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"automergeStrategy": "squash",
|
||||
"minimumReleaseAge": "5 days"
|
||||
"automergeStrategy": "squash"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ migrations_tandoor = CWD / "migrations/tandoor.zip"
|
||||
|
||||
migrations_plantoeat = CWD / "migrations/plantoeat.zip"
|
||||
|
||||
migrations_plantoeat_csv = CWD / "migrations/plantoeat.csv"
|
||||
|
||||
migrations_myrecipebox = CWD / "migrations/myrecipebox.csv"
|
||||
|
||||
migrations_recipekeeper = CWD / "migrations/recipekeeper.zip"
|
||||
|
||||
13
tests/data/migrations/plantoeat.csv
Normal file
13
tests/data/migrations/plantoeat.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
Title,Course,Cuisine,Main Ingredient,Description,Source,Url,Url Host,Prep Time,Cook Time,Total Time,Servings,Yield,Ingredients,Directions,Tags,Rating,Public Url,Photo Url,Private,Nutritional Score (generic),Calories,Fat,Saturated Fat,Cholesterol,Sodium,Sugar,Carbohydrate,Fiber,Protein,Cost,Created At,Updated At
|
||||
Test Recipe,Main Course,American,Beans,"This is a description.
|
||||
Here is new line.",Manually entered source,https://eatwithclarity.com/sushi-bowl-with-sesame-tofu/,,75,75,150,7,1 loaf,", Heading
|
||||
2 itm Test, note
|
||||
, Heading2
|
||||
3 pkg Two, note2
|
||||
|
||||
","Directions.
|
||||
Will go here.","Allergen-Friendly, Cheap, Test",3,https://app.plantoeat.com/recipes/38843883,https://plantoeat.s3.amazonaws.com/recipes/29516709/470292506c8d9b71582487a7879ab7b197d06490-large.jpg?1628205591,yes,,13,16,17,18,19,22,20,21,23,,2023-10-13 20:29:29,2023-10-13 20:32:48
|
||||
Test Recipe2,,,,,,,,,,,,,"2 itm Test, note
|
||||
3 pkg Two, note2
|
||||
","Directions.
|
||||
Will go here.",,,,,,,,,,,,,,,,,2023-10-13 20:29:29,2023-10-13 20:32:48
|
||||
|
@@ -94,6 +94,15 @@ test_cases = [
|
||||
"transFatContent",
|
||||
},
|
||||
),
|
||||
MigrationTestData(
|
||||
typ=SupportedMigrations.plantoeat,
|
||||
archive=test_data.migrations_plantoeat_csv,
|
||||
search_slug="test-recipe",
|
||||
nutrition_filter={
|
||||
"unsaturatedFatContent",
|
||||
"transFatContent",
|
||||
},
|
||||
),
|
||||
MigrationTestData(
|
||||
typ=SupportedMigrations.myrecipebox,
|
||||
archive=test_data.migrations_myrecipebox,
|
||||
@@ -124,6 +133,7 @@ test_ids = [
|
||||
"mealie_alpha_archive",
|
||||
"tandoor_archive",
|
||||
"plantoeat_archive",
|
||||
"plantoeat_csv",
|
||||
"myrecipebox_csv",
|
||||
"recipekeeper_archive",
|
||||
"cookn_archive",
|
||||
@@ -190,6 +200,30 @@ def test_recipe_migration(api_client: TestClient, unique_user_fn_scoped: TestUse
|
||||
# TODO: validate other types of content
|
||||
|
||||
|
||||
def test_plantoeat_rejects_invalid_file_type(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# Simulate uploading a binary file (e.g. PDF) that is neither ZIP nor CSV/TXT
|
||||
binary_content = bytes(range(256)) * 4 # arbitrary binary data that is not valid UTF-8
|
||||
payload = {"migration_type": SupportedMigrations.plantoeat.value}
|
||||
file_payload = {"archive": binary_content}
|
||||
|
||||
response = api_client.post(
|
||||
api_routes.groups_migrations,
|
||||
data=payload,
|
||||
files=file_payload,
|
||||
headers=unique_user.token,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
report_id = response.json()["id"]
|
||||
|
||||
response = api_client.get(api_routes.groups_reports_item_id(report_id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
report = response.json()
|
||||
assert report["entries"]
|
||||
assert not report["entries"][0]["success"]
|
||||
assert "ZIP" in report["entries"][0]["message"] or "CSV" in report["entries"][0]["message"]
|
||||
|
||||
|
||||
def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
with ZipFile(test_data.migrations_mealie) as zf:
|
||||
|
||||
@@ -104,6 +104,33 @@ def test_recipe_asset_dangerous_extension_blocked(
|
||||
assert response.status_code == 400, f"expected 400 for extension={ext}, got {response.status_code}"
|
||||
|
||||
|
||||
def test_recipe_asset_served_as_attachment(
|
||||
api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe
|
||||
):
|
||||
"""Assets must be served as downloads with MIME sniffing disabled so uploaded files cannot
|
||||
execute as active content in Mealie's origin."""
|
||||
recipe = recipe_ingredient_only
|
||||
payload = {"name": random_string(10), "icon": "mdi-file", "extension": "txt"}
|
||||
file_payload = {"file": b"<script>alert(1)</script>"}
|
||||
|
||||
response = api_client.post(
|
||||
f"/api/recipes/{recipe.slug}/assets",
|
||||
data=payload,
|
||||
files=file_payload,
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
recipe_response = api_client.get(f"/api/recipes/{recipe.slug}", headers=unique_user.token).json()
|
||||
recipe_id = recipe_response["id"]
|
||||
file_name = recipe_response["assets"][0]["fileName"]
|
||||
|
||||
media_response = api_client.get(f"/api/media/recipes/{recipe_id}/assets/{file_name}")
|
||||
assert media_response.status_code == 200
|
||||
assert "attachment" in media_response.headers["content-disposition"].lower()
|
||||
assert media_response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
|
||||
data_payload = {"extension": "jpg"}
|
||||
file_payload = {"image": data.images_test_image_1.read_bytes()}
|
||||
|
||||
@@ -27,6 +27,31 @@ def test_non_default_settings(monkeypatch):
|
||||
assert app_settings.DOCS_URL is None
|
||||
|
||||
|
||||
def test_allowed_iframe_hosts_defaults(monkeypatch):
|
||||
monkeypatch.delenv("ALLOWED_IFRAME_HOSTS", raising=False)
|
||||
get_app_settings.cache_clear()
|
||||
app_settings = get_app_settings()
|
||||
|
||||
# Secure defaults are always present and never empty (empty would disable iframe embeds).
|
||||
assert "youtube.com" in app_settings.allowed_iframe_hosts
|
||||
assert "vimeo.com" in app_settings.allowed_iframe_hosts
|
||||
|
||||
|
||||
def test_allowed_iframe_hosts_extends_defaults(monkeypatch):
|
||||
monkeypatch.setenv("ALLOWED_IFRAME_HOSTS", " Example.com , trusted.tld ,, ")
|
||||
get_app_settings.cache_clear()
|
||||
app_settings = get_app_settings()
|
||||
|
||||
hosts = app_settings.allowed_iframe_hosts
|
||||
# Configured hosts are normalized, blanks dropped, and defaults retained.
|
||||
assert "example.com" in hosts
|
||||
assert "trusted.tld" in hosts
|
||||
assert "youtube.com" in hosts
|
||||
assert "" not in hosts
|
||||
# No duplicates.
|
||||
assert len(hosts) == len(set(hosts))
|
||||
|
||||
|
||||
def test_default_connection_args(monkeypatch):
|
||||
monkeypatch.setenv("DB_ENGINE", "sqlite")
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
42
uv.lock
generated
42
uv.lock
generated
@@ -2,6 +2,10 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.12.*"
|
||||
|
||||
[options]
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P5D"
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "25.1.0"
|
||||
@@ -25,15 +29,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aniso8601"
|
||||
version = "10.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/52179c4e3f1978d3d9a285f98c706642522750ef343e9738286130423730/aniso8601-10.0.1.tar.gz", hash = "sha256:25488f8663dd1528ae1f54f94ac1ea51ae25b4d531539b8bc707fed184d16845", size = 47190, upload-time = "2025-04-18T17:29:42.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/75/e0e10dc7ed1408c28e03a6cb2d7a407f99320eb953f229d008a7a6d05546/aniso8601-10.0.1-py2.py3-none-any.whl", hash = "sha256:eb19717fd4e0db6de1aab06f12450ab92144246b257423fe020af5748c0cb89e", size = 52848, upload-time = "2025-04-18T17:29:41.492Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
@@ -65,18 +60,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "appdirs"
|
||||
version = "1.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apprise"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -87,9 +73,9 @@ dependencies = [
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/74/9c16829d3e7e45ce7daf1b704687fa4fde7ea00d72eafe8de18c72bf5995/apprise-1.10.0.tar.gz", hash = "sha256:b768f32d99e45ed5f4c3eef1f67903e803c97f97ba61a531a5d0a45d40df90a8", size = 2188611, upload-time = "2026-04-26T14:23:51.928Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/f8/83f4e2aaaa0342dc67f783bf84427d9ff5cfa4ecde3a52d9b587740a91ee/apprise-1.11.0.tar.gz", hash = "sha256:3b1e6f5365b302d1fae270c0c8007958e54224b9b7808acec69006ea27f5b8a2", size = 2337248, upload-time = "2026-05-29T17:52:29.198Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/f9/177a73589d34e676d10bc4c6a8328710e28af5907234e9f25bb149a04eec/apprise-1.10.0-py3-none-any.whl", hash = "sha256:e685303d3568bb7a057d6ddeafd27ee12fff183ca36483ad4bacc0b9b4efa82c", size = 1632292, upload-time = "2026-04-26T14:23:49.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/79/1483693160917522703708e45b4045946d86be634b8af2c2ac9f361f8fa3/apprise-1.11.0-py3-none-any.whl", hash = "sha256:6abc6d9f8dddedfe14b2918c3553146894ac6e3dad401deaf854b20b4c455d38", size = 1712975, upload-time = "2026-05-29T17:52:26.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -902,8 +888,6 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "alembic" },
|
||||
{ name = "aniso8601" },
|
||||
{ name = "appdirs" },
|
||||
{ name = "apprise" },
|
||||
{ name = "authlib" },
|
||||
{ name = "bcrypt" },
|
||||
@@ -978,9 +962,7 @@ docs = [
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = "==25.1.0" },
|
||||
{ name = "alembic", specifier = "==1.18.4" },
|
||||
{ name = "aniso8601", specifier = "==10.0.1" },
|
||||
{ name = "appdirs", specifier = "==1.4.4" },
|
||||
{ name = "apprise", specifier = "==1.10.0" },
|
||||
{ name = "apprise", specifier = "==1.11.0" },
|
||||
{ name = "authlib", specifier = "==1.7.2" },
|
||||
{ name = "bcrypt", specifier = "==5.0.0" },
|
||||
{ name = "beautifulsoup4", specifier = "==4.14.3" },
|
||||
@@ -1034,7 +1016,7 @@ dev = [
|
||||
{ name = "pydantic-to-typescript2", specifier = "==1.0.6" },
|
||||
{ name = "pylint", specifier = "==4.0.5" },
|
||||
{ name = "pytest", specifier = "==9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.3.0" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.4.0" },
|
||||
{ name = "rich", specifier = "==15.0.0" },
|
||||
{ name = "ruff", specifier = "==0.15.14" },
|
||||
{ name = "types-python-dateutil", specifier = "==2.9.0.20260518" },
|
||||
@@ -1601,15 +1583,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user