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 (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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 };