feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component";
const permissions = {
allowFullScreen: true,
allowPayment: false,
allowAutoPlay: true,
allowMicrophone: false,
allowCamera: false,
allowGeolocation: false,
allowModals: true,
};
describe("iframe policy", () => {
it("keeps sandbox-only modal permission out of Permissions Policy", () => {
expect(getAllowedPermissions(permissions)).toBe("fullscreen *; autoplay *");
expect(getSandboxFlags(permissions)).toContain("allow-modals");
});
it("creates a display title without credentials, paths, or query parameters", () => {
expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com");
});
it("keeps a non-default port in the display title", () => {
expect(getFrameTitle("http://user:secret@example.com:8080/private?token=sensitive")).toBe("example.com:8080");
});
});
+40 -54
View File
@@ -1,7 +1,7 @@
"use client";
import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { IconBrowserOff } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
@@ -10,83 +10,60 @@ import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const t = useI18n("widget.iframe");
const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return <NoUrl />;
if (!isSupportedProtocol(embedUrl)) {
return <UnsupportedProtocol />;
}
return (
<Box h="100%" w="100%">
<iframe
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title="widget iframe"
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
>
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
</iframe>
</Box>
<Stack h="100%" w="100%" gap={0}>
<Box style={{ flex: 1, minHeight: 0 }}>
<iframe
loading="lazy"
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title={getFrameTitle(embedUrl)}
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
>
<Text>{t("error.noBrowerSupport")}</Text>
</iframe>
</Box>
</Stack>
);
}
const supportedProtocols = ["http", "https"];
const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
} catch {
return false;
}
};
const NoUrl = () => {
const t = useI18n();
const t = useI18n("widget.iframe");
return (
<Stack align="center" justify="center" h="100%">
<IconBrowserOff />
<Title order={4}>{t("widget.iframe.error.noUrl")}</Title>
<Title order={4}>{t("error.noUrl")}</Title>
</Stack>
);
};
const UnsupportedProtocol = () => {
const t = useI18n();
return (
<Stack align="center" justify="center" h="100%">
<IconProtocol />
<Title order={4} ta="center">
{t("widget.iframe.error.unsupportedProtocol", {
supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
})}
</Title>
</Stack>
);
};
const getAllowedPermissions = (
export const getAllowedPermissions = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
return (
objectEntries(permissions)
.filter(([_key, value]) => value)
// * means it applies to all origins
.map(([key]) => `${permissionMapping[key]} *`)
// * means it applies to all origins. Sandbox-only flags such as
// allow-modals intentionally have no Permissions Policy mapping.
.flatMap(([key, value]) => {
const permission = permissionMapping[key];
return value && permission ? [`${permission} *`] : [];
})
.join("; ")
);
};
const getSandboxFlags = (
export const getSandboxFlags = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
const baseSandbox = [
@@ -112,12 +89,21 @@ const getSandboxFlags = (
return baseSandbox;
};
const permissionMapping = {
const permissionMapping: Partial<
Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>
> = {
allowAutoPlay: "autoplay",
allowCamera: "camera",
allowFullScreen: "fullscreen",
allowGeolocation: "geolocation",
allowMicrophone: "microphone",
allowPayment: "payment",
allowModals: "allow-modals",
} satisfies Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>;
};
export const getFrameTitle = (url: string) => {
try {
return new URL(url).host;
} catch {
return "iframe";
}
};
+30 -15
View File
@@ -1,23 +1,38 @@
import { IconBrowser } from "@tabler/icons-react";
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const createOptions = () =>
optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
allowModals: factory.switch(),
}));
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
supportsAdvancedFocus: false,
icon: IconBrowser,
createOptions() {
return optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
allowModals: factory.switch(),
}));
contextActions: ({ options }) => {
const embedUrl = options.embedUrl;
if (typeof embedUrl !== "string" || embedUrl.trim() === "") return [];
return [
{
key: "open-iframe",
label: "widget.common.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
];
},
createOptions,
}).withDynamicImport(() => import("./component"));