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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
@@ -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"));
|
||||
Reference in new issue
Block a user