diff --git a/packages/pl-fe/src/actions/media.ts b/packages/pl-fe/src/actions/media.ts index d5d9d4f23..aa5e12cbe 100644 --- a/packages/pl-fe/src/actions/media.ts +++ b/packages/pl-fe/src/actions/media.ts @@ -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 diff --git a/packages/pl-fe/src/hooks/forms/use-image-field.ts b/packages/pl-fe/src/hooks/forms/use-image-field.ts index f622f33de..80cf7dbd1 100644 --- a/packages/pl-fe/src/hooks/forms/use-image-field.ts +++ b/packages/pl-fe/src/hooks/forms/use-image-field.ts @@ -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 ``, and optionally resizes the file. */ const useImageField = (opts: UseImageFieldOpts = {}) => { + const { stripMetadata } = useSettings(); + const [file, setFile] = useState(); 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); } diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 547bf13ee..c46dae7e1 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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", diff --git a/packages/pl-fe/src/modals/manage-group-modal/steps/details-step.tsx b/packages/pl-fe/src/modals/manage-group-modal/steps/details-step.tsx index e269632ab..728b6d7d3 100644 --- a/packages/pl-fe/src/modals/manage-group-modal/steps/details-step.tsx +++ b/packages/pl-fe/src/modals/manage-group-modal/steps/details-step.tsx @@ -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 = ({ params, onChange }) => { const intl = useIntl(); const instance = useInstance(); + const { stripMetadata } = useSettings(); const { display_name: displayName = '', @@ -49,7 +51,7 @@ const DetailsStep: React.FC = ({ 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, diff --git a/packages/pl-fe/src/pages/settings/privacy.tsx b/packages/pl-fe/src/pages/settings/privacy.tsx new file mode 100644 index 000000000..d6a2d9297 --- /dev/null +++ b/packages/pl-fe/src/pages/settings/privacy.tsx @@ -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) => { + if (redirectLinksMode === 'auto' && event.target.value === 'manual') { + KVStore.getItem(`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 ( + + + + + + + +
+ + }> + setDisplayTargetHost(target.checked)} /> + + + + + }> + setClearLinksInCompose(target.checked)} /> + + + }> + setClearLinksInContent(target.checked)} /> + + + + } + hintText={} + > + setRulesUrl(target.value)} + /> + + + } + hintText={} + > + setHashUrl(target.value)} + /> + + + + }> + + + + + {redirectLinksMode === 'auto' && ( + } + hintText={} + > + setRedirectServicesUrl(target.value)} + /> + + )} + + {redirectLinksMode === 'manual' && ( + mappings.map((service) => ( + } + hintText={ }} />} + > + setRedirectServices((services) => { + services[service.name] = e.target.value; + })} + placeholder={intl.formatMessage(messages.redirectServicePlaceholder)} + /> + + )) + )} + + + } + hint={ + hasCanvasExtractPermission + ? + : + } + > + setStripMetadata(target.checked)} disabled={!hasCanvasExtractPermission} /> + + + + + + +
+
+
+
+ ); +}; + +export { Privacy as default }; diff --git a/packages/pl-fe/src/schemas/pl-fe/settings.ts b/packages/pl-fe/src/schemas/pl-fe/settings.ts index e4147e6a0..9cbf2f410 100644 --- a/packages/pl-fe/src/schemas/pl-fe/settings.ts +++ b/packages/pl-fe/src/schemas/pl-fe/settings.ts @@ -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()), diff --git a/packages/pl-fe/src/utils/favicon-service.ts b/packages/pl-fe/src/utils/favicon-service.ts index dca417b44..2ac7a76df 100644 --- a/packages/pl-fe/src/utils/favicon-service.ts +++ b/packages/pl-fe/src/utils/favicon-service.ts @@ -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(); diff --git a/packages/pl-fe/src/utils/resize-image.ts b/packages/pl-fe/src/utils/resize-image.ts index 7bf6117ab..205e7222a 100644 --- a/packages/pl-fe/src/utils/resize-image.ts +++ b/packages/pl-fe/src/utils/resize-image.ts @@ -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 => { +const resize = async (inputFile: File, maxPixels = DEFAULT_MAX_PIXELS, force = false): Promise => { + 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 };