pl-fe: Allow client-side image metadata removal

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-10-18 23:42:32 +02:00
parent d8f2b1613a
commit 219de2da1f
8 changed files with 264 additions and 17 deletions

View File

@ -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

View File

@ -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);
}

View 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",

View 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,

View 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 };

View File

@ -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()),

View File

@ -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();

View File

@ -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 };