feat: add legal page with configuration options (#724)

* Addconfig entries for legal notice

* Add legal route handling to middleware

* Make legal notice public

* Add legal category to config sidebar

* Add legal notice page

* Add German translations for legal notice and configuration options

* Replace legal page with separate imprint and privacy pages

* Update middleware

* Add footer component

* Update legal text descriptions to indicate Markdown support again

* Refactor footer layout

* Add zIndex to footer component

* improve mobile layout

* run formatter

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Aaron
2025-01-02 17:29:01 +01:00
committed by GitHub
parent 53c05518df
commit df1ffaa2bc
12 changed files with 297 additions and 15 deletions

View File

@@ -19,6 +19,7 @@ import {
TbBucket,
TbBinaryTree,
TbSettings,
TbScale,
} from "react-icons/tb";
import { FormattedMessage } from "react-intl";
@@ -30,6 +31,7 @@ const categories = [
{ name: "OAuth", icon: <TbSocial /> },
{ name: "LDAP", icon: <TbBinaryTree /> },
{ name: "S3", icon: <TbBucket /> },
{ name: "Legal", icon: <TbScale /> },
];
const useStyles = createStyles((theme) => ({

View File

@@ -11,12 +11,16 @@ const multipliers = {
GiB: 1024 ** 3,
TB: 1000 ** 4,
TiB: 1024 ** 4,
}
};
const units = (["B", "KB", "KiB", "MB", "MiB", "GB", "GiB", "TB", "TiB"] as const).map(unit => ({ label: unit, value: unit }));
const units = (
["B", "KB", "KiB", "MB", "MiB", "GB", "GiB", "TB", "TiB"] as const
).map((unit) => ({ label: unit, value: unit }));
function getLargestApplicableUnit(value: number) {
return units.findLast(unit => value % multipliers[unit.value] === 0) || units[0];
return (
units.findLast((unit) => value % multipliers[unit.value] === 0) || units[0]
);
}
const FileSizeInput = ({
@@ -46,8 +50,9 @@ const FileSizeInput = ({
marginRight: -2,
},
}}
onChange={event => {
const unit = event.currentTarget.value as typeof units[number]["value"];
onChange={(event) => {
const unit = event.currentTarget
.value as (typeof units)[number]["value"];
setUnit(unit);
onChange(multipliers[unit] * inputValue);
}}
@@ -63,7 +68,7 @@ const FileSizeInput = ({
precision={0}
rightSection={unitSelect}
rightSectionWidth={76}
onChange={value => {
onChange={(value) => {
const inputVal = value || 0;
setInputValue(inputVal);
onChange(multipliers[unit] * inputVal);

View File

@@ -0,0 +1,62 @@
import { Anchor, Footer as MFooter, SimpleGrid, Text } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
const Footer = () => {
const t = useTranslate();
const config = useConfig();
const hasImprint = !!(
config.get("legal.imprintUrl") || config.get("legal.imprintText")
);
const hasPrivacy = !!(
config.get("legal.privacyPolicyUrl") ||
config.get("legal.privacyPolicyText")
);
const imprintUrl =
(!config.get("legal.imprintText") && config.get("legal.imprintUrl")) ||
"/imprint";
const privacyUrl =
(!config.get("legal.privacyPolicyText") &&
config.get("legal.privacyPolicyUrl")) ||
"/privacy";
const isMobile = useMediaQuery("(max-width: 700px)");
return (
<MFooter height="auto" py={6} px="xl" zIndex={100}>
<SimpleGrid cols={isMobile ? 2 : 3} m={0}>
{!isMobile && <div></div>}
<Text size="xs" color="dimmed" align={isMobile ? "left" : "center"}>
Powered by{" "}
<Anchor
size="xs"
href="https://github.com/stonith404/pingvin-share"
target="_blank"
>
Pingvin Share
</Anchor>
</Text>
<div>
{config.get("legal.enabled") && (
<Text size="xs" color="dimmed" align="right">
{hasImprint && (
<Anchor size="xs" href={imprintUrl}>
{t("imprint.title")}
</Anchor>
)}
{hasImprint && hasPrivacy && " • "}
{hasPrivacy && (
<Anchor size="xs" href={privacyUrl}>
{t("privacy.title")}
</Anchor>
)}
</Text>
)}
</div>
</SimpleGrid>
</MFooter>
);
};
export default Footer;

View File

@@ -295,6 +295,12 @@ export default {
"share.edit.notify.generic-error": "Während der Erstellung der Freigabe ist ein Fehler aufgetreten.",
"share.edit.notify.save-success": "Freigabe erfolgreich aktualisiert",
// END /share/[id]/edit
// /imprint
"imprint.title": "Imprint",
// END /imprint
// /privacy
"privacy.title": "Privacy Policy",
// END /privacy
// /admin/config
"admin.config.title": "Einstellungen",
"admin.config.category.general": "Allgemein",
@@ -457,6 +463,17 @@ export default {
"admin.config.s3.key.description": "Der Schlüssel, der den Zugriff auf den S3-Bucket ermöglicht.",
"admin.config.s3.secret": "Geheimnis",
"admin.config.s3.secret.description": "Das Geheimnis, das den Zugriff auf den S3-Bucket ermöglicht.",
"admin.config.category.legal": "Rechtliches",
"admin.config.legal.enabled": "Impressum und Datenschutz aktivieren",
"admin.config.legal.enabled.description": "Gibt an, ob die Links zum Impressum und zur Datenschutzerklärung im Footer angezeigt werden sollen.",
"admin.config.legal.imprint-text": "Impressum-Text",
"admin.config.legal.imprint-text.description": "Der Text, der im Impressum angezeigt wird. Unterstützt Markdown. Leer lassen, um auf eine externe Impressumsseite zu verlinken.",
"admin.config.legal.imprint-url": "Impressum-URL",
"admin.config.legal.imprint-url.description": "Wenn bereits eine Impressumsseite vorhanden ist, kann sie hier verlinkt werden, anstatt den Text einzugeben.",
"admin.config.legal.privacy-policy-text": "Datenschutzerklärungstext",
"admin.config.legal.privacy-policy-text.description": "Der Text, der in der Datenschutzerklärung angezeigt wird. Unterstützt Markdown. Leer lassen, um auf eine externe Datenschutzerklärungsseite zu verlinken.",
"admin.config.legal.privacy-policy-url": "Datenschutzerklärungs-URL",
"admin.config.legal.privacy-policy-url.description": "Wenn bereits eine Datenschutzerklärungsseite vorhanden ist, kann sie hier verlinkt werden, anstatt den Text einzugeben.",
// 404
"404.description": "Ups, diese Seite existiert nicht.",
"404.button.home": "Zurück zur Startseite",

View File

@@ -405,6 +405,14 @@ export default {
"share.edit.notify.save-success": "Share updated successfully",
// END /share/[id]/edit
// /imprint
"imprint.title": "Imprint",
// END /imprint
// /privacy
"privacy.title": "Privacy Policy",
// END /privacy
// /admin/config
"admin.config.title": "Configuration",
"admin.config.category.general": "General",
@@ -645,6 +653,18 @@ export default {
"admin.config.s3.secret": "Secret",
"admin.config.s3.secret.description": "The secret which allows you to access the S3 bucket.",
"admin.config.category.legal": "Legal",
"admin.config.legal.enabled": "Enable legal notices",
"admin.config.legal.enabled.description": "Whether to show a link to imprint and privacy policy in the footer.",
"admin.config.legal.imprint-text": "Imprint text",
"admin.config.legal.imprint-text.description": "The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.",
"admin.config.legal.imprint-url": "Imprint URL",
"admin.config.legal.imprint-url.description": "If you already have an imprint page you can link it here instead of using the text field.",
"admin.config.legal.privacy-policy-text": "Privacy policy text",
"admin.config.legal.privacy-policy-text.description": "The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.",
"admin.config.legal.privacy-policy-url": "Privacy policy URL",
"admin.config.legal.privacy-policy-url.description": "If you already have a privacy policy page you can link it here instead of using the text field.",
// 404
"404.description": "Oops this page doesn't exist.",
"404.button.home": "Bring me back home",

View File

@@ -14,7 +14,14 @@ export const config = {
export async function middleware(request: NextRequest) {
const routes = {
unauthenticated: new Routes(["/auth/*", "/"]),
public: new Routes(["/share/*", "/s/*", "/upload/*", "/error"]),
public: new Routes([
"/share/*",
"/s/*",
"/upload/*",
"/error",
"/imprint",
"/privacy",
]),
admin: new Routes(["/admin/*"]),
account: new Routes(["/account*"]),
disabled: new Routes([]),
@@ -55,6 +62,20 @@ export async function middleware(request: NextRequest) {
routes.disabled.routes.push("/auth/resetPassword*");
}
if (!getConfig("legal.enabled")) {
routes.disabled.routes.push("/imprint", "/privacy");
} else {
if (!getConfig("legal.imprintText") && !getConfig("legal.imprintUrl")) {
routes.disabled.routes.push("/imprint");
}
if (
!getConfig("legal.privacyPolicyText") &&
!getConfig("legal.privacyPolicyUrl")
) {
routes.disabled.routes.push("/privacy");
}
}
// prettier-ignore
const rules = [
// Disabled routes
@@ -86,6 +107,16 @@ export async function middleware(request: NextRequest) {
condition: (!getConfig("general.showHomePage") || user) && route == "/",
path: "/upload",
},
// Imprint redirect
{
condition: route == "/imprint" && !getConfig("legal.imprintText") && getConfig("legal.imprintUrl"),
path: getConfig("legal.imprintUrl"),
},
// Privacy redirect
{
condition: route == "/privacy" && !getConfig("legal.privacyPolicyText") && getConfig("legal.privacyPolicyUrl"),
path: getConfig("legal.privacyPolicyUrl"),
},
];
for (const rule of rules) {
if (rule.condition) {

View File

@@ -3,6 +3,7 @@ import {
ColorSchemeProvider,
Container,
MantineProvider,
Stack,
} from "@mantine/core";
import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals";
@@ -30,6 +31,7 @@ import Config from "../types/config.type";
import { CurrentUser } from "../types/user.type";
import i18nUtil from "../utils/i18n.util";
import userPreferences from "../utils/userPreferences.util";
import Footer from "../components/footer/Footer";
const excludeDefaultLayoutRoutes = ["/admin/config/[category]"];
@@ -134,10 +136,18 @@ function App({ Component, pageProps }: AppProps) {
<Component {...pageProps} />
) : (
<>
<Header />
<Container>
<Component {...pageProps} />
</Container>
<Stack
justify="space-between"
sx={{ minHeight: "100vh" }}
>
<div>
<Header />
<Container>
<Component {...pageProps} />
</Container>
</div>
<Footer />
</Stack>
</>
)}
</UserContext.Provider>

View File

@@ -0,0 +1,55 @@
import { Anchor, Title, useMantineTheme } from "@mantine/core";
import Meta from "../../components/Meta";
import useTranslate from "../../hooks/useTranslate.hook";
import { FormattedMessage } from "react-intl";
import useConfig from "../../hooks/config.hook";
import Markdown from "markdown-to-jsx";
const Imprint = () => {
const t = useTranslate();
const { colorScheme } = useMantineTheme();
const config = useConfig();
return (
<>
<Meta title={t("imprint.title")} />
<Title mb={30} order={1}>
<FormattedMessage id="imprint.title" />
</Title>
<Markdown
options={{
forceBlock: true,
overrides: {
pre: {
props: {
style: {
backgroundColor:
colorScheme == "dark"
? "rgba(50, 50, 50, 0.5)"
: "rgba(220, 220, 220, 0.5)",
padding: "0.75em",
whiteSpace: "pre-wrap",
},
},
},
table: {
props: {
className: "md",
},
},
a: {
props: {
target: "_blank",
rel: "noreferrer",
},
component: Anchor,
},
},
}}
>
{config.get("legal.imprintText")}
</Markdown>
</>
);
};
export default Imprint;

View File

@@ -0,0 +1,55 @@
import { Anchor, Title, useMantineTheme } from "@mantine/core";
import Meta from "../../components/Meta";
import useTranslate from "../../hooks/useTranslate.hook";
import { FormattedMessage } from "react-intl";
import useConfig from "../../hooks/config.hook";
import Markdown from "markdown-to-jsx";
const PrivacyPolicy = () => {
const t = useTranslate();
const { colorScheme } = useMantineTheme();
const config = useConfig();
return (
<>
<Meta title={t("privacy.title")} />
<Title mb={30} order={1}>
<FormattedMessage id="privacy.title" />
</Title>
<Markdown
options={{
forceBlock: true,
overrides: {
pre: {
props: {
style: {
backgroundColor:
colorScheme == "dark"
? "rgba(50, 50, 50, 0.5)"
: "rgba(220, 220, 220, 0.5)",
padding: "0.75em",
whiteSpace: "pre-wrap",
},
},
},
table: {
props: {
className: "md",
},
},
a: {
props: {
target: "_blank",
rel: "noreferrer",
},
component: Anchor,
},
},
}}
>
{config.get("legal.privacyPolicyText")}
</Markdown>
</>
);
};
export default PrivacyPolicy;

View File

@@ -27,8 +27,7 @@ const get = (key: string, configVariables: Config[]): any => {
if (configVariable.type == "number" || configVariable.type == "filesize")
return parseInt(value);
if (configVariable.type == "boolean")
return value == "true";
if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string" || configVariable.type == "text")
return value;
};