[enhancement]: custom css

This commit is contained in:
Kendall Garner 2024-08-27 08:26:34 -07:00
parent 004c9a8d06
commit 6125901023
No known key found for this signature in database
GPG key ID: 18D2767419676C87
10 changed files with 332 additions and 325 deletions

View file

@ -384,6 +384,7 @@
"title": "$t(entity.playlist_other)"
},
"setting": {
"advanced": "advanced",
"generalTab": "general",
"hotkeysTab": "hotkeys",
"playbackTab": "playback",
@ -467,6 +468,11 @@
"crossfadeDuration_description": "sets the duration of the crossfade effect",
"crossfadeStyle": "crossfade style",
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
"customCssEnable": "enable custom css",
"customCssEnable_description": "allow for writing custom css.",
"customCssNotice": "Warning: while there is some sanitization (disallowing url() and content:), using custom CSS can still pose risks by changing the interface.",
"customCss": "custom css",
"customCss_description": "custom css content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization.",
"customFontPath": "custom font path",
"customFontPath_description": "sets the path to the custom font to use for the application",
"disableAutomaticUpdates": "disable automatic updates",

View file

@ -20,13 +20,14 @@ import { ContextMenuProvider } from '/@/renderer/features/context-menu';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { PlayerState, useCssSettings, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import i18n from '/@/i18n/i18n';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@ -43,12 +44,14 @@ export const App = () => {
const language = useSettingsStore((store) => store.general.language);
const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { enabled, content } = useCssSettings();
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls();
const remoteSettings = useRemoteSettings();
const textStyleRef = useRef<HTMLStyleElement>();
const cssRef = useRef<HTMLStyleElement>();
useDiscordRpc();
useServerVersion();
@ -87,6 +90,26 @@ export const App = () => {
}
}, [builtIn, custom, system, type]);
useEffect(() => {
if (enabled && content) {
// Yes, CSS is sanitized here as well. Prevent a suer from changing the
// localStorage to bypass sanitizing.
const sanitized = sanitizeCss(content);
if (!cssRef.current) {
cssRef.current = document.createElement('style');
document.body.appendChild(cssRef.current);
}
cssRef.current.textContent = sanitized;
return () => {
cssRef.current!.textContent = '';
};
}
return () => {};
}, [content, enabled]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--primary-color', accent);

View file

@ -0,0 +1,10 @@
import { Stack } from '@mantine/core';
import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings';
export const AdvancedTab = () => {
return (
<Stack spacing="md">
<StylesSettings />
</Stack>
);
};

View file

@ -0,0 +1,126 @@
import { useState } from 'react';
import { Button, ConfirmModal, Switch, Text, Textarea } from '/@/renderer/components';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { Code } from '@mantine/core';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
import { useCssSettings, useSettingsStoreActions } from '/@/renderer/store';
export const StylesSettings = () => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const { enabled, content } = useCssSettings();
const [css, setCss] = useState(content);
const { setSettings } = useSettingsStoreActions();
const handleSave = () => {
setSettings({
css: {
content: css,
enabled,
},
});
};
const handleResetToDefault = () => {
setSettings({
css: {
content,
enabled: true,
},
});
closeAllModals();
};
const openConfirmModal = () => {
openModal({
children: (
<ConfirmModal onConfirm={handleResetToDefault}>
<Text color="red !important">
{t('setting.customCssNotice', { postProcess: 'sentenceCase' })}
</Text>
</ConfirmModal>
),
title: t('setting.customCssEnable', { postProcess: 'sentenceCase' }),
});
};
return (
<>
<SettingsOptions
control={
<Switch
checked={enabled}
onChange={(e) => {
if (!e.currentTarget.checked) {
setSettings({
css: {
content,
enabled: false,
},
});
} else {
openConfirmModal();
}
}}
/>
}
description={t('setting.customCssEnable', {
context: 'description',
postProcess: 'sentenceCase',
})}
title={t('setting.customCssEnable', { postProcess: 'sentenceCase' })}
/>
{enabled && (
<>
<SettingsOptions
control={
<>
{open && (
<Button
compact
// disabled={isSaveButtonDisabled}
variant="filled"
onClick={handleSave}
>
{t('common.save', { postProcess: 'titleCase' })}
</Button>
)}
<Button
compact
variant="filled"
onClick={() => setOpen(!open)}
>
{t(open ? 'common.close' : 'common.edit', {
postProcess: 'titleCase',
})}
</Button>
</>
}
description={t('setting.customCss', {
context: 'description',
postProcess: 'sentenceCase',
})}
title={t('setting.customCss', { postProcess: 'sentenceCase' })}
/>
{open && (
<>
<Textarea
autosize
defaultValue={css}
onBlur={(e) =>
setCss(sanitizeCss(`<style>${e.currentTarget.value}`))
}
/>
<Text>{t('common.preview', { postProcess: 'sentenceCase' })}: </Text>
<Code block>{css}</Code>
</>
)}
</>
)}
</>
);
};

View file

@ -29,6 +29,12 @@ const HotkeysTab = lazy(() =>
})),
);
const AdvancedTab = lazy(() =>
import('/@/renderer/features/settings/components/advanced/advanced-tab').then((module) => ({
default: module.AdvancedTab,
})),
);
const TabContainer = styled.div`
width: 100%;
height: 100%;
@ -65,6 +71,9 @@ export const SettingsContent = () => {
{t('page.setting.windowTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
)}
<Tabs.Tab value="advanced">
{t('page.setting.advanced', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<GeneralTab />
@ -80,6 +89,9 @@ export const SettingsContent = () => {
<ApplicationTab />
</Tabs.Panel>
)}
<Tabs.Panel value="advanced">
<AdvancedTab />
</Tabs.Panel>
</Tabs>
</TabContainer>
);

View file

@ -178,6 +178,10 @@ export enum GenreTarget {
}
export interface SettingsState {
css: {
content: string;
enabled: boolean;
};
discord: {
clientId: string;
enableIdle: boolean;
@ -308,6 +312,10 @@ const getPlatformDefaultWindowBarStyle = (): Platform => {
const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle();
const initialState: SettingsState = {
css: {
content: '',
enabled: false,
},
discord: {
clientId: '1165957668758900787',
enableIdle: false,
@ -714,3 +722,5 @@ export const useRemoteSettings = () => useSettingsStore((state) => state.remote,
export const useFontSettings = () => useSettingsStore((state) => state.font, shallow);
export const useDiscordSetttings = () => useSettingsStore((state) => state.discord, shallow);
export const useCssSettings = () => useSettingsStore((state) => state.css, shallow);

View file

@ -16,10 +16,11 @@ export const replaceURLWithHTMLLinks = (text: string) => {
}
const link = match[0];
const prefix = link.startsWith('http') ? '' : 'https://';
elements.push(
<a
key={lastIndex}
href={link}
href={prefix + link}
rel="noopener noreferrer"
target="_blank"
>

View file

@ -1,16 +1,97 @@
import sanitizeHtml, { IOptions, simpleTransform } from 'sanitize-html';
import DomPurify, { Config } from 'dompurify';
const SANITIZE_OPTIONS: IOptions = {
allowedAttributes: {
a: ['href', 'rel', 'target'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedTags: ['a', 'b', 'div', 'em', 'i', 'p', 'strong'],
transformTags: {
a: simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }),
},
const SANITIZE_OPTIONS: Config = {
ALLOWED_ATTR: ['href'],
ALLOWED_TAGS: ['a', 'b', 'div', 'em', 'i', 'p', 'span', 'strong'],
// allow http://, https://, and // (mapped to https)
ALLOWED_URI_REGEXP: /^(http(s?):)?\/\/.+/i,
};
const regex = /(url\("?)(?!data:)/gim;
const addStyles = (output: string[], styles: CSSStyleDeclaration) => {
for (let prop = styles.length - 1; prop >= 0; prop -= 1) {
const key = styles[prop] as keyof CSSStyleDeclaration;
if (key !== 'content' && styles[key]) {
const value = styles[key];
const priority = styles.getPropertyPriority(key as string);
const priorityString = priority === 'important' ? ` !important` : '';
if (typeof value === 'string') {
if (!value.match(regex)) {
output.push(`${key}:${value}${priorityString};`);
}
} else if (typeof value === 'number') {
output.push(`${key}:${value}${priorityString};`);
}
}
}
};
const addCssRules = (rules: CSSRuleList, output: string[]) => {
for (let index = rules.length - 1; index >= 0; index -= 1) {
const rule = rules[index];
if (rule.constructor.name === 'CSSStyleRule') {
const cssRule = rule as CSSStyleRule;
output.push(`${cssRule.selectorText} {`);
if (cssRule.style) {
addStyles(output, cssRule.style);
}
output.push('}');
} else if (rule.constructor.name === 'CSSMediaRule') {
const mediaRule = rule as CSSMediaRule;
output.push(`@media ${mediaRule.media.mediaText}{`);
addCssRules(mediaRule.cssRules, output);
output.push('}');
} else if (rule.constructor.name === 'CSSKeyframesRule') {
const keyFrameRule = rule as CSSKeyframesRule;
for (let i = keyFrameRule.cssRules.length - 1; i >= 0; i -= 1) {
const frame = keyFrameRule.cssRules[i];
if (frame.constructor.name === 'CSSKeyframeRule') {
const keyframeRule = frame as CSSKeyframeRule;
if (keyframeRule.keyText) {
output.push(`${keyframeRule.keyText}{`);
if (keyframeRule.style) {
addStyles(output, keyframeRule.style);
}
output.push('}');
}
}
}
output.push('}');
}
}
};
DomPurify.addHook('afterSanitizeAttributes', (node: Element) => {
if (node.tagName === 'A') {
if (node.getAttribute('href')?.startsWith('//')) {
node.setAttribute('href', `https:${node.getAttribute('href')}`);
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
DomPurify.addHook('uponSanitizeElement', (node: Element) => {
if (node.tagName === 'STYLE') {
const rules = (node as HTMLStyleElement).sheet?.cssRules;
if (rules) {
const output: string[] = [];
addCssRules(rules, output);
node.textContent = output.join('\n');
}
}
});
export const sanitize = (text: string): string => {
return sanitizeHtml(text, SANITIZE_OPTIONS);
return DomPurify.sanitize(text, SANITIZE_OPTIONS);
};
export const sanitizeCss = (text: string): string => {
return DomPurify.sanitize(text, {
ALLOWED_ATTR: [],
ALLOWED_TAGS: ['style'],
RETURN_DOM: true,
WHOLE_DOCUMENT: true,
}).innerText;
};