pl-fe: Allow client-side image metadata removal
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -9,6 +9,7 @@ import { getClient } from '../api';
|
||||
|
||||
import type { MediaAttachment, UpdateMediaParams, UploadMediaParams } from 'pl-api';
|
||||
import type { AppDispatch, RootState } from 'pl-fe/store';
|
||||
import { useSettingsStore } from 'pl-fe/stores/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
|
||||
@ -35,10 +36,14 @@ const uploadFile = (
|
||||
changeTotal: (value: number) => void = () => {},
|
||||
) => async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
const { stripMetadata } = useSettingsStore.getState().settings;
|
||||
|
||||
const maxImageSize = getState().instance.configuration.media_attachments.image_size_limit;
|
||||
const maxVideoSize = getState().instance.configuration.media_attachments.video_size_limit;
|
||||
const maxVideoDuration = getState().instance.configuration.media_attachments.video_duration_limit;
|
||||
|
||||
const imageMatrixLimit = getState().instance.configuration.media_attachments.image_matrix_limit;
|
||||
|
||||
const isImage = file.type.match(/image.*/);
|
||||
const isVideo = file.type.match(/video.*/);
|
||||
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(file) : 0;
|
||||
@ -63,7 +68,7 @@ const uploadFile = (
|
||||
}
|
||||
|
||||
// FIXME: Don't define const in loop
|
||||
resizeImage(file).then(resized => {
|
||||
resizeImage(file, imageMatrixLimit, stripMetadata).then(resized => {
|
||||
const data = new FormData();
|
||||
data.append('file', resized);
|
||||
// Account for disparity in size of original image and resized data
|
||||
|
||||
@ -2,6 +2,8 @@ import { useState } from 'react';
|
||||
|
||||
import resizeImage from 'pl-fe/utils/resize-image';
|
||||
|
||||
import { useSettings } from '../use-settings';
|
||||
|
||||
import { usePreview } from './use-preview';
|
||||
|
||||
interface UseImageFieldOpts {
|
||||
@ -13,6 +15,8 @@ interface UseImageFieldOpts {
|
||||
|
||||
/** Returns props for `<input type="file">`, and optionally resizes the file. */
|
||||
const useImageField = (opts: UseImageFieldOpts = {}) => {
|
||||
const { stripMetadata } = useSettings();
|
||||
|
||||
const [file, setFile] = useState<File | null>();
|
||||
const src = usePreview(file) || (file === null ? undefined : opts.preview);
|
||||
|
||||
@ -21,7 +25,7 @@ const useImageField = (opts: UseImageFieldOpts = {}) => {
|
||||
if (!file) return;
|
||||
|
||||
if (typeof opts.maxPixels === 'number') {
|
||||
setFile(await resizeImage(file, opts.maxPixels));
|
||||
setFile(await resizeImage(file, opts.maxPixels, stripMetadata));
|
||||
} else {
|
||||
setFile(file);
|
||||
}
|
||||
|
||||
@ -1824,6 +1824,9 @@
|
||||
"url_privacy.rules_url.label": "URL cleaning rules database address",
|
||||
"url_privacy.rules_url.placeholder": "Rules URL",
|
||||
"url_privacy.save": "Save",
|
||||
"url_privacy.strip_metadata": "Strip metadata from uploaded images",
|
||||
"url_privacy.strip_metadata.hint": "Removes EXIF metadata such as geolocation from images before hitting the server. This is usually done server-side, regardless of client settings.",
|
||||
"url_privacy.strip_metadata.hint_no_permission": "This option requires additional permissions to function. Please enable canvas extraction permission in your browser settings.",
|
||||
"url_privacy.update.fail": "Failed to update rules database URL",
|
||||
"url_privacy.update.success": "Successfully updated rules database",
|
||||
"video.download": "Download file",
|
||||
|
||||
@ -10,6 +10,7 @@ import HeaderPicker from 'pl-fe/features/edit-profile/components/header-picker';
|
||||
import { usePreview } from 'pl-fe/hooks/forms/use-preview';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
import { useInstance } from 'pl-fe/hooks/use-instance';
|
||||
import { useSettings } from 'pl-fe/hooks/use-settings';
|
||||
import resizeImage from 'pl-fe/utils/resize-image';
|
||||
|
||||
import type { CreateGroupParams } from 'pl-api';
|
||||
@ -28,6 +29,7 @@ interface IDetailsStep {
|
||||
const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
||||
const intl = useIntl();
|
||||
const instance = useInstance();
|
||||
const { stripMetadata } = useSettings();
|
||||
|
||||
const {
|
||||
display_name: displayName = '',
|
||||
@ -49,7 +51,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
||||
async (files: FileList | null) => {
|
||||
const file = files ? files[0] : undefined;
|
||||
if (file) {
|
||||
const resized = await resizeImage(file, maxPixels);
|
||||
const resized = await resizeImage(file, maxPixels, stripMetadata);
|
||||
onChange({
|
||||
...params,
|
||||
[property]: resized,
|
||||
|
||||
231
packages/pl-fe/src/pages/settings/privacy.tsx
Normal file
231
packages/pl-fe/src/pages/settings/privacy.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { mappings } from '@mkljczk/url-purify';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedList, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useMutative } from 'use-mutative';
|
||||
|
||||
import { changeSetting, saveSettings } from 'pl-fe/actions/settings';
|
||||
import List, { ListItem } from 'pl-fe/components/list';
|
||||
import Button from 'pl-fe/components/ui/button';
|
||||
import Card, { CardBody, CardHeader, CardTitle } from 'pl-fe/components/ui/card';
|
||||
import Column from 'pl-fe/components/ui/column';
|
||||
import Form from 'pl-fe/components/ui/form';
|
||||
import FormActions from 'pl-fe/components/ui/form-actions';
|
||||
import FormGroup from 'pl-fe/components/ui/form-group';
|
||||
import Input from 'pl-fe/components/ui/input';
|
||||
import Toggle from 'pl-fe/components/ui/toggle';
|
||||
import { SelectDropdown } from 'pl-fe/features/forms';
|
||||
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
import { useSettings } from 'pl-fe/hooks/use-settings';
|
||||
import KVStore from 'pl-fe/storage/kv-store';
|
||||
import { hasCanvasExtractPermission } from 'pl-fe/utils/favicon-service';
|
||||
import { KVStoreRedirectServicesItem } from 'pl-fe/utils/url-purify';
|
||||
|
||||
const messages = defineMessages({
|
||||
urlPrivacy: { id: 'settings.url_privacy', defaultMessage: 'URL privacy' },
|
||||
rulesUrlPlaceholder: { id: 'url_privacy.rules_url.placeholder', defaultMessage: 'Rules URL' },
|
||||
hashUrlPlaceholder: { id: 'url_privacy.hash_url.placeholder', defaultMessage: 'Hash URL' },
|
||||
redirectLinksModeOff: { id: 'url_privacy.redirect_links_mode.off', defaultMessage: 'Disabled' },
|
||||
redirectLinksModeAuto: { id: 'url_privacy.redirect_links_mode.auto', defaultMessage: 'From URL' },
|
||||
redirectLinksModeManual: { id: 'url_privacy.redirect_links_mode.manual', defaultMessage: 'Specify manually' },
|
||||
redirectServicesUrlPlaceholder: { id: 'url_privacy.redirect_services_url.placeholder', defaultMessage: 'Rules URL' },
|
||||
redirectServicePlaceholder: { id: 'url_privacy.redirect_services_url.placeholder', defaultMessage: 'eg. https://proxy.example.org' },
|
||||
});
|
||||
|
||||
const Privacy = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const intl = useIntl();
|
||||
|
||||
const settings = useSettings();
|
||||
const { urlPrivacy } = settings;
|
||||
|
||||
const [displayTargetHost, setDisplayTargetHost] = useState(urlPrivacy.displayTargetHost);
|
||||
const [clearLinksInCompose, setClearLinksInCompose] = useState(urlPrivacy.clearLinksInCompose);
|
||||
const [clearLinksInContent, setClearLinksInContent] = useState(urlPrivacy.clearLinksInContent);
|
||||
const [hashUrl, setHashUrl] = useState(urlPrivacy.hashUrl);
|
||||
const [rulesUrl, setRulesUrl] = useState(urlPrivacy.rulesUrl);
|
||||
const [redirectLinksMode, setRedirectLinksMode] = useState(urlPrivacy.redirectLinksMode);
|
||||
const [redirectServicesUrl, setRedirectServicesUrl] = useState(urlPrivacy.redirectServicesUrl);
|
||||
const [redirectServices, setRedirectServices] = useMutative(urlPrivacy.redirectServices);
|
||||
const [stripMetadata, setStripMetadata] = useState(settings.stripMetadata);
|
||||
|
||||
const onSubmit = () => {
|
||||
const value = {
|
||||
...urlPrivacy,
|
||||
displayTargetHost,
|
||||
clearLinksInCompose,
|
||||
clearLinksInContent,
|
||||
hashUrl,
|
||||
rulesUrl,
|
||||
redirectLinksMode,
|
||||
redirectServicesUrl,
|
||||
redirectServices,
|
||||
};
|
||||
|
||||
switch (redirectLinksMode) {
|
||||
case 'off':
|
||||
value.redirectServicesUrl = '';
|
||||
value.redirectServices = {};
|
||||
break;
|
||||
case 'manual':
|
||||
value.redirectServicesUrl = '';
|
||||
break;
|
||||
case 'auto':
|
||||
value.redirectServices = {};
|
||||
break;
|
||||
}
|
||||
|
||||
dispatch(changeSetting(['urlPrivacy'], value));
|
||||
dispatch(changeSetting(['stripMetadata'], stripMetadata));
|
||||
|
||||
dispatch(saveSettings({
|
||||
showAlert: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleChangeRedirectLinksMode = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (redirectLinksMode === 'auto' && event.target.value === 'manual') {
|
||||
KVStore.getItem<KVStoreRedirectServicesItem>(`url-purify-redirect-services:${me}`).then((services) => {
|
||||
if (!services?.redirectServices) return;
|
||||
|
||||
setRedirectServices(
|
||||
Object.fromEntries(
|
||||
mappings.map(({ name, targets }) => ([
|
||||
name,
|
||||
services.redirectServices.find((service) => targets.includes(service.type) && service.instances.length)?.instances[0].split('|')[0] || '',
|
||||
])),
|
||||
),
|
||||
);
|
||||
}).catch(() => { });
|
||||
}
|
||||
return setRedirectLinksMode(event.target.value as 'off');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.urlPrivacy)} transparent withHeader={false}>
|
||||
<Card className='space-y-4' variant='rounded'>
|
||||
<CardHeader backHref='/settings'>
|
||||
<CardTitle title={intl.formatMessage(messages.urlPrivacy)} />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<List>
|
||||
<ListItem label={<FormattedMessage id='url_privacy.display_target_host' defaultMessage='Always display the domain external links lead to' />}>
|
||||
<Toggle checked={displayTargetHost} onChange={({ target }) => setDisplayTargetHost(target.checked)} />
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ListItem label={<FormattedMessage id='url_privacy.clear_links_in_compose' defaultMessage='Suggest removing tracking parameters when composing a post' />}>
|
||||
<Toggle checked={clearLinksInCompose} onChange={({ target }) => setClearLinksInCompose(target.checked)} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='url_privacy.clear_links_in_content' defaultMessage='Remove tracking parameters from displayed posts' />}>
|
||||
<Toggle checked={clearLinksInContent} onChange={({ target }) => setClearLinksInContent(target.checked)} />
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='url_privacy.rules_url.label' defaultMessage='URL cleaning rules database address' />}
|
||||
hintText={<FormattedMessage id='url_privacy.rules_url.hint' defaultMessage='Rules database in ClearURLs-compatible format, eg. {url}' values={{ url: 'https://rules2.clearurls.xyz/data.minify.json' }} />}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.rulesUrlPlaceholder)}
|
||||
value={rulesUrl}
|
||||
onChange={({ target }) => setRulesUrl(target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='url_privacy.hash_url.label' defaultMessage='URL cleaning rules hash address (optional)' />}
|
||||
hintText={<FormattedMessage id='url_privacy.hash_url.hint' defaultMessage='SHA256 hash of rules database, used to avoid unnecessary fetches, eg. {url}' values={{ url: 'https://rules2.clearurls.xyz/rules.minify.hash' }} />}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.hashUrlPlaceholder)}
|
||||
value={hashUrl}
|
||||
onChange={({ target }) => setHashUrl(target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<List>
|
||||
<ListItem label={<FormattedMessage id='url_privacy.redirect_links_mode' defaultMessage='Redirect links to popular websites to privacy-respecting proxy services' />}>
|
||||
<SelectDropdown
|
||||
className='max-w-fit'
|
||||
items={{
|
||||
off: intl.formatMessage(messages.redirectLinksModeOff),
|
||||
auto: intl.formatMessage(messages.redirectLinksModeAuto),
|
||||
manual: intl.formatMessage(messages.redirectLinksModeManual),
|
||||
}}
|
||||
defaultValue={redirectLinksMode}
|
||||
onChange={handleChangeRedirectLinksMode}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{redirectLinksMode === 'auto' && (
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='url_privacy.redirect_services_url.label' defaultMessage='Redirect services URLs database address' />}
|
||||
hintText={<FormattedMessage id='url_privacy.redirect_services_url.hint' defaultMessage='URLs database in Farside-compatible format, eg. {url}' values={{ url: 'https://raw.githubusercontent.com/benbusby/farside/refs/heads/main/services.json' }} />}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.redirectServicesUrlPlaceholder)}
|
||||
value={redirectServicesUrl}
|
||||
onChange={({ target }) => setRedirectServicesUrl(target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{redirectLinksMode === 'manual' && (
|
||||
mappings.map((service) => (
|
||||
<FormGroup
|
||||
key={service.name}
|
||||
labelText={<FormattedMessage id='url_privacy.redirect_services.name' defaultMessage='{name}' values={{ name: service.name }} />}
|
||||
hintText={<FormattedMessage id='url_privacy.redirect_services.patterns' defaultMessage='Matches: {pattern}, eg. {services}, leave empty for no redirect' values={{ pattern: service.urlPattern, services: <FormattedList value={service.targets} /> }} />}
|
||||
>
|
||||
<Input
|
||||
outerClassName='grow'
|
||||
type='text'
|
||||
value={redirectServices[service.name]}
|
||||
onChange={(e) => setRedirectServices((services) => {
|
||||
services[service.name] = e.target.value;
|
||||
})}
|
||||
placeholder={intl.formatMessage(messages.redirectServicePlaceholder)}
|
||||
/>
|
||||
</FormGroup>
|
||||
))
|
||||
)}
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='url_privacy.strip_metadata' defaultMessage='Strip metadata from uploaded images' />}
|
||||
hint={
|
||||
hasCanvasExtractPermission
|
||||
? <FormattedMessage id='url_privacy.strip_metadata.hint' defaultMessage='Removes EXIF metadata such as geolocation from images before hitting the server. This is usually done server-side, regardless of client settings.' />
|
||||
: <FormattedMessage id='url_privacy.strip_metadata.hint_no_permission' defaultMessage='This option requires additional permissions to function. Please enable canvas extraction permission in your browser settings.' />
|
||||
}
|
||||
>
|
||||
<Toggle checked={stripMetadata} onChange={({ target }) => setStripMetadata(target.checked)} disabled={!hasCanvasExtractPermission} />
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit'>
|
||||
<FormattedMessage id='url_privacy.save' defaultMessage='Save' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { Privacy as default };
|
||||
@ -54,6 +54,7 @@ const settingsSchema = v.object({
|
||||
}),
|
||||
checkEmojiReactsSupport: v.fallback(v.boolean(), false),
|
||||
disableUserProvidedMedia: v.fallback(v.boolean(), false),
|
||||
stripMetadata: v.fallback(v.boolean(), false),
|
||||
|
||||
theme: v.optional(coerceObject({
|
||||
brandColor: v.optional(v.string()),
|
||||
|
||||
@ -14,7 +14,7 @@ const checkCanvasExtractPermission = () => {
|
||||
|
||||
const { data } = ctx.getImageData(0, 0, 1, 1);
|
||||
|
||||
return data.join(',') === '4,130,216,255';
|
||||
return data.join(',') === '216,4,130,255';
|
||||
};
|
||||
|
||||
const hasCanvasExtractPermission = checkCanvasExtractPermission();
|
||||
|
||||
@ -157,20 +157,21 @@ const resizeImage = async (
|
||||
img: HTMLImageElement,
|
||||
inputFile: File,
|
||||
maxPixels: number,
|
||||
force = false,
|
||||
) => {
|
||||
const { width, height } = img;
|
||||
let { width, height } = img;
|
||||
const type = inputFile.type || 'image/png';
|
||||
|
||||
const newWidth = Math.round(Math.sqrt(maxPixels * (width / height)));
|
||||
const newHeight = Math.round(Math.sqrt(maxPixels * (height / width)));
|
||||
|
||||
if (!hasCanvasExtractPermission) throw new Error();
|
||||
if (!force && width * height <= maxPixels) {
|
||||
width = Math.round(Math.sqrt(maxPixels * (width / height)));
|
||||
height = Math.round(Math.sqrt(maxPixels * (height / width)));
|
||||
}
|
||||
|
||||
const orientation = await getOrientation(img, type);
|
||||
|
||||
return processImage(img, {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
width,
|
||||
height,
|
||||
name: inputFile.name,
|
||||
orientation,
|
||||
type,
|
||||
@ -178,7 +179,9 @@ const resizeImage = async (
|
||||
};
|
||||
|
||||
/** Resize an image to the maximum number of pixels. */
|
||||
const resize = async (inputFile: File, maxPixels = DEFAULT_MAX_PIXELS): Promise<File> => {
|
||||
const resize = async (inputFile: File, maxPixels = DEFAULT_MAX_PIXELS, force = false): Promise<File> => {
|
||||
if (!hasCanvasExtractPermission) return inputFile;
|
||||
|
||||
if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
|
||||
return inputFile;
|
||||
}
|
||||
@ -186,12 +189,12 @@ const resize = async (inputFile: File, maxPixels = DEFAULT_MAX_PIXELS): Promise<
|
||||
try {
|
||||
const img = await loadImage(inputFile);
|
||||
|
||||
if (img.width * img.height < maxPixels) {
|
||||
if (!force && img.width * img.height <= maxPixels) {
|
||||
return inputFile;
|
||||
}
|
||||
|
||||
try {
|
||||
return await resizeImage(img, inputFile, maxPixels);
|
||||
return await resizeImage(img, inputFile, maxPixels, force);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return (inputFile);
|
||||
@ -201,6 +204,4 @@ const resize = async (inputFile: File, maxPixels = DEFAULT_MAX_PIXELS): Promise<
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
resize as default,
|
||||
};
|
||||
export { resize as default };
|
||||
|
||||
Reference in New Issue
Block a user