Remove access token from asset url when unnecessary (#10548)

* Remove access token when unnecessary

* Add missing translations

* Refactor naming convention

* Refactor naming convention

* Refactor access token usage for media upload

* Use static token for previews

* Add missing imageToken for image upload

* Rename imageToken to staticAccessToken

* Remove temporary access token from embedUrl

* Catch invalid URLs when replacing token

* Fix embedUrl not updating when source changed

* Patch audio and iframe uploads

* Hide tinymce offscreen selection

* Fix merge of interface fields

* Use static tokens except for preview

* Remove dirty checks through v-model for wysiwyg

* Add missing translations

* Fix lint warnings

* Revert naming from staticAccessToken to imageToken
This commit is contained in:
ian
2022-02-17 00:56:14 +08:00
committed by GitHub
parent 968d15ccd4
commit b3f7bd94a1
8 changed files with 117 additions and 114 deletions

View File

@@ -39,6 +39,9 @@ body.mce-content-readonly {
color: ${cssVar('--foreground-subdued')};
background-color: ${cssVar('--background-subdued')};
}
.mce-offscreen-selection {
display: none;
}
h1, h2, h3, h4, h5, h6 {
font-family: ${cssVar(`--family-${font}`)}, serif;
color: ${cssVar('--foreground-normal-alt')};
@@ -135,12 +138,15 @@ blockquote {
margin-left: 0px;
}
video,
iframe,
img {
max-width: 100%;
border-radius: ${cssVar('--border-radius')};
height: auto;
}
iframe {
max-width: 100%;
border-radius: ${cssVar('--border-radius')};
}
hr {
background-color: ${cssVar('--border-normal')};
height: 1px;

View File

@@ -6,7 +6,6 @@
:init="editorOptions"
:disabled="disabled"
model-events="change keydown blur focus paste ExecCommand SetContent"
@dirty="setDirty"
@focusin="setFocus(true)"
@focusout="setFocus(false)"
/>
@@ -60,7 +59,7 @@
<interface-input-code
:value="code"
language="htmlmixed"
line-wrapping="true"
:line-wrapping="true"
@input="code = $event"
></interface-input-code>
</div>
@@ -117,9 +116,14 @@
<v-tabs-items v-model="openMediaTab">
<v-tab-item value="video">
<template v-if="mediaSelection">
<video class="media-preview" controls="controls">
<source :src="mediaSelection.source" />
<video v-if="mediaSelection.tag !== 'iframe'" class="media-preview" controls="controls">
<source :src="mediaSelection.previewUrl" />
</video>
<iframe
v-if="mediaSelection.tag === 'iframe'"
class="media-preview"
:src="mediaSelection.previewUrl"
></iframe>
<div class="grid">
<div class="field">
<div class="type-label">{{ t('source') }}</div>
@@ -182,13 +186,10 @@ import 'tinymce/icons/default';
import Editor from '@tinymce/tinymce-vue';
import getEditorStyles from './get-editor-styles';
import { escapeRegExp } from 'lodash';
import useImage from './useImage';
import useMedia from './useMedia';
import useLink from './useLink';
import useSourceCode from './useSourceCode';
import { getToken } from '@/api';
import { getPublicURL } from '@/utils/get-root-path';
import { percentage } from '@/utils/percentage';
type CustomFormat = {
@@ -265,13 +266,13 @@ export default defineComponent({
const { t } = useI18n();
const editorRef = ref<any | null>(null);
const editorElement = ref<ComponentPublicInstance | null>(null);
const isEditorDirty = ref(false);
const { imageToken } = toRefs(props);
let tinymceEditor: HTMLElement | null;
let count = ref(0);
onMounted(() => {
let iframe;
let contentLoaded = false;
const wysiwyg = document.getElementById(props.field);
if (wysiwyg) iframe = wysiwyg.getElementsByTagName('iframe');
@@ -282,6 +283,11 @@ export default defineComponent({
if (tinymceEditor) {
const observer = new MutationObserver((_mutations) => {
count.value = tinymceEditor?.textContent?.replace('\n', '')?.length ?? 0;
if (!contentLoaded) {
contentLoaded = true;
} else {
emit('input', editorRef.value.getContent());
}
});
const config = { characterData: true, childList: true, subtree: true };
@@ -291,7 +297,6 @@ export default defineComponent({
const { imageDrawerOpen, imageSelection, closeImageDrawer, onImageSelect, saveImage, imageButton } = useImage(
editorRef,
isEditorDirty,
imageToken
);
@@ -307,51 +312,18 @@ export default defineComponent({
mediaWidth,
mediaSource,
mediaButton,
} = useMedia(editorRef, isEditorDirty, imageToken);
} = useMedia(editorRef, imageToken);
const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection } = useLink(editorRef, isEditorDirty);
const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection } = useLink(editorRef);
const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(
editorRef,
isEditorDirty
);
const replaceTokens = (value: string, token: string | null) => {
const url = getPublicURL();
const regex = new RegExp(
`(<[^]+?=")(${escapeRegExp(
url
)}assets/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\\?[^#"]*)?(?:#[^"]*)?)("[^>]*>)`,
'gi'
);
return value.replace(regex, (_, pre, matchedUrl, post) => {
const matched = new URL(matchedUrl.replace(/&amp;/g, '&'));
const params = new URLSearchParams(matched.search);
if (!token) {
params.delete('access_token');
} else {
params.set('access_token', token);
}
const paramsString = params.toString().length > 0 ? `?${params.toString().replace(/&/g, '&amp;')}` : '';
return `${pre}${matched.origin}${matched.pathname}${paramsString}${post}`;
});
};
const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(editorRef);
const internalValue = computed({
get() {
if (!props.value) return '';
return replaceTokens(props.value, getToken());
return props.value || '';
},
set(newValue: string) {
if (!isEditorDirty.value) return;
if (newValue !== props.value && (props.value === null && newValue === '') === false) {
const removeToken = replaceTokens(newValue, props.imageToken ?? null);
emit('input', removeToken);
}
set() {
return;
},
});
@@ -390,7 +362,7 @@ export default defineComponent({
menubar: false,
convert_urls: false,
image_dimensions: false,
extended_valid_elements: 'audio[loop],source',
extended_valid_elements: 'audio[loop|controls],source',
toolbar: toolbarString,
style_formats: styleFormats,
file_picker_types: 'customImage customMedia image media',
@@ -410,7 +382,6 @@ export default defineComponent({
editorOptions,
internalValue,
setFocus,
setDirty,
onImageSelect,
saveImage,
imageDrawerOpen,
@@ -447,10 +418,6 @@ export default defineComponent({
editor.ui.registry.addButton('customCode', sourceCodeButton);
}
function setDirty() {
isEditorDirty.value = true;
}
function setFocus(val: boolean) {
if (editorElement.value == null) return;
const body = editorElement.value.$el.parentElement?.querySelector('.tox-tinymce');

View File

@@ -1,4 +1,4 @@
import { addTokenToURL } from '@/api';
import { getToken } from '@/api';
import { i18n } from '@/lang';
import { addQueryToPath } from '@/utils/add-query-to-path';
import { getPublicURL } from '@/utils/get-root-path';
@@ -28,11 +28,7 @@ type UsableImage = {
imageButton: ImageButton;
};
export default function useImage(
editor: Ref<any>,
isEditorDirty: Ref<boolean>,
imageToken: Ref<string | undefined>
): UsableImage {
export default function useImage(editor: Ref<any>, imageToken: Ref<string | undefined>): UsableImage {
const imageDrawerOpen = ref(false);
const imageSelection = ref<ImageSelection | null>(null);
@@ -59,7 +55,7 @@ export default function useImage(
alt,
width,
height,
previewUrl: imageUrl,
previewUrl: replaceUrlAccessToken(imageUrl, imageToken.value ?? getToken()),
};
} else {
imageSelection.value = null;
@@ -86,14 +82,14 @@ export default function useImage(
}
function onImageSelect(image: Record<string, any>) {
const imageUrl = addTokenToURL(getPublicURL() + 'assets/' + image.id, imageToken.value);
const assetUrl = getPublicURL() + 'assets/' + image.id;
imageSelection.value = {
imageUrl,
imageUrl: replaceUrlAccessToken(assetUrl, imageToken.value),
alt: image.title,
width: image.width,
height: image.height,
previewUrl: imageUrl,
previewUrl: replaceUrlAccessToken(assetUrl, imageToken.value ?? getToken()),
};
}
@@ -105,8 +101,30 @@ export default function useImage(
...(img.height ? { height: img.height.toString() } : {}),
});
const imageHtml = `<img src="${resizedImageUrl}" alt="${img.alt}" />`;
isEditorDirty.value = true;
editor.value.selection.setContent(imageHtml);
closeImageDrawer();
}
function replaceUrlAccessToken(url: string, token: string | null | undefined): string {
// Only process assets URL
if (!url.includes(getPublicURL() + 'assets/')) {
return url;
}
try {
const parsedUrl = new URL(url);
const params = new URLSearchParams(parsedUrl.search);
if (!token) {
params.delete('access_token');
} else {
params.set('access_token', token);
}
return Array.from(params).length > 0
? `${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`
: `${parsedUrl.origin}${parsedUrl.pathname}`;
} catch {
return url;
}
}
}

View File

@@ -23,7 +23,7 @@ type UsableLink = {
linkButton: LinkButton;
};
export default function useLink(editor: Ref<any>, isEditorDirty: Ref<boolean>): UsableLink {
export default function useLink(editor: Ref<any>): UsableLink {
const linkDrawerOpen = ref(false);
const defaultLinkSelection = {
url: null,
@@ -94,7 +94,6 @@ export default function useLink(editor: Ref<any>, isEditorDirty: Ref<boolean>):
link.displayText || link.url
}</a>`;
isEditorDirty.value = true;
editor.value.selection.setContent(linkHtml);
closeLinkDrawer();
}

View File

@@ -1,14 +1,15 @@
import { addTokenToURL } from '@/api';
import { getToken } from '@/api';
import { i18n } from '@/lang';
import { getPublicURL } from '@/utils/get-root-path';
import { computed, Ref, ref, watch } from 'vue';
type MediaSelection = {
source: string;
sourceUrl: string;
width?: number;
height?: number;
tag?: 'video' | 'audio';
tag?: 'video' | 'audio' | 'iframe';
type?: string;
previewUrl?: string;
};
type MediaButton = {
@@ -33,11 +34,7 @@ type UsableMedia = {
mediaButton: MediaButton;
};
export default function useMedia(
editor: Ref<any>,
isEditorDirty: Ref<boolean>,
imageToken: Ref<string | undefined>
): UsableMedia {
export default function useMedia(editor: Ref<any>, imageToken: Ref<string | undefined>): UsableMedia {
const mediaDrawerOpen = ref(false);
const mediaSelection = ref<MediaSelection | null>(null);
const openMediaTab = ref(['video', 'audio']);
@@ -76,10 +73,14 @@ export default function useMedia(
const mediaSource = computed({
get() {
return mediaSelection.value?.source;
return mediaSelection.value?.sourceUrl;
},
set(newSource: any) {
mediaSelection.value = { ...mediaSelection.value, source: newSource };
mediaSelection.value = {
...mediaSelection.value,
sourceUrl: newSource,
};
mediaSelection.value.previewUrl = replaceUrlAccessToken(newSource, imageToken.value || getToken());
},
});
@@ -106,15 +107,15 @@ export default function useMedia(
watch(mediaSelection, (vid) => {
if (embed.value === '') {
if (vid === null) return;
embed.value = `<${vid.tag} width="${vid.width}" height="${vid.height}" controls><source src="${vid.source}" type="${vid.type}" /></${vid.tag}>`;
embed.value = `<${vid.tag} width="${vid.width}" height="${vid.height}" controls><source src="${vid.sourceUrl}" type="${vid.type}" /></${vid.tag}>`;
} else {
embed.value = embed.value
.replace(/src=".*?"/g, `src="${vid?.source}"`)
.replace(/src=".*?"/g, `src="${vid?.sourceUrl}"`)
.replace(/width=".*?"/g, `width="${vid?.width}"`)
.replace(/height=".*?"/g, `height="${vid?.height}"`)
.replace(/type=".*?"/g, `type="${vid?.type}"`)
.replaceAll(/<(video|audio)/g, `<${vid?.tag}`)
.replaceAll(/<\/(video|audio)/g, `</${vid?.tag}`);
.replaceAll(/<(video|audio|iframe)/g, `<${vid?.tag}`)
.replaceAll(/<\/(video|audio|iframe)/g, `</${vid?.tag}`);
}
});
@@ -122,20 +123,24 @@ export default function useMedia(
if (newEmbed === '') {
mediaSelection.value = null;
} else {
const tag = /<(video|audio)/g.exec(newEmbed)?.[1];
const source = /src="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
const tag = /<(video|audio|iframe)/g.exec(newEmbed)?.[1];
const sourceUrl = /src="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
const width = Number(/width="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
const height = Number(/height="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
const type = /type="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
if (source === undefined) return;
if (sourceUrl === undefined) return;
// Add temporarily access token for preview
const previewUrl = replaceUrlAccessToken(sourceUrl, imageToken.value || getToken());
mediaSelection.value = {
tag: tag === 'audio' ? 'audio' : 'video',
source,
tag: tag === 'audio' ? 'audio' : tag === 'iframe' ? 'iframe' : 'video',
sourceUrl,
width,
height,
type,
previewUrl,
};
}
});
@@ -164,23 +169,22 @@ export default function useMedia(
}
function onMediaSelect(media: Record<string, any>) {
const url = getPublicURL() + 'assets/' + media.id;
const sourceUrl = getPublicURL() + 'assets/' + media.id;
const tag = media.type.startsWith('audio') ? 'audio' : 'video';
const source = imageToken.value ? addTokenToURL(url, imageToken.value) : url;
mediaSelection.value = {
source,
sourceUrl: replaceUrlAccessToken(sourceUrl, imageToken.value),
width: media.width || 300,
height: media.height || 150,
tag,
type: media.type,
previewUrl: replaceUrlAccessToken(sourceUrl, imageToken.value || getToken()),
};
}
function saveMedia() {
if (embed.value === '') return;
isEditorDirty.value = true;
if (startEmbed.value !== '') {
const updatedContent = editor.value.getContent().replace(startEmbed.value, embed.value);
editor.value.setContent(updatedContent);
@@ -189,4 +193,27 @@ export default function useMedia(
}
closeMediaDrawer();
}
function replaceUrlAccessToken(url: string, token: string | null | undefined): string {
// Only process assets URL
if (!url.includes(getPublicURL() + 'assets/')) {
return url;
}
try {
const parsedUrl = new URL(url);
const params = new URLSearchParams(parsedUrl.search);
if (!token) {
params.delete('access_token');
} else {
params.set('access_token', token);
}
return Array.from(params).length > 0
? `${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`
: `${parsedUrl.origin}${parsedUrl.pathname}`;
} catch {
return url;
}
}
}

View File

@@ -15,7 +15,7 @@ type UsableSourceCode = {
sourceCodeButton: SourceCodeButton;
};
export default function useSourceCode(editor: Ref<any>, isEditorDirty: Ref<boolean>): UsableSourceCode {
export default function useSourceCode(editor: Ref<any>): UsableSourceCode {
const codeDrawerOpen = ref(false);
const code = ref<string>();
@@ -35,7 +35,6 @@ export default function useSourceCode(editor: Ref<any>, isEditorDirty: Ref<boole
}
function saveCode() {
isEditorDirty.value = true;
editor.value.setContent(code.value);
closeCodeDrawer();
}

View File

@@ -204,8 +204,6 @@ import 'codemirror/addon/display/placeholder.js';
import { applyEdit, CustomSyntax, Alteration } from './edits';
import { getPublicURL } from '@/utils/get-root-path';
import { addTokenToURL } from '@/api';
import escapeStringRegexp from 'escape-string-regexp';
import useShortcut from '@/composables/use-shortcut';
import translateShortcut from '@/utils/translate-shortcut';
import { percentage } from '@/utils/percentage';
@@ -338,20 +336,7 @@ export default defineComponent({
});
const markdownString = computed(() => {
let mdString = props.value || '';
if (!props.imageToken) {
const baseUrl = getPublicURL() + 'assets/';
const regex = new RegExp(`\\]\\((${escapeStringRegexp(baseUrl)}[^\\s\\)]*)`, 'gm');
const images = Array.from(mdString.matchAll(regex));
for (const image of images) {
mdString = mdString.replace(image[1], addTokenToURL(image[1]));
}
}
return mdString;
return props.value || '';
});
const table = reactive({
@@ -400,7 +385,7 @@ export default defineComponent({
url += '?access_token=' + props.imageToken;
}
codemirror.replaceSelection(`![](${url})`);
codemirror.replaceSelection(`![${codemirror.getSelection()}](${url})`);
imageDialogOpen.value = false;
}

View File

@@ -742,6 +742,8 @@ unlimited: Unlimited
open_link_in: Open link in
new_tab: New tab
current_tab: Current tab
save_image: Save Image
save_media: Save Media
wysiwyg_options:
aligncenter: Align Center
alignjustify: Align Justify
@@ -1419,8 +1421,8 @@ interfaces:
customSyntax_label: Add custom syntax types
customSyntax_add: Add custom syntax
box: Block / Inline
imageToken: Image Token
imageToken_label: What (static) token to append to image sources
imageToken: Static Access Token
imageToken_label: Static access token is appended to the assets' URL
map:
map: Map
description: Select a location on a map
@@ -1527,8 +1529,8 @@ interfaces:
custom_formats: Custom Formats
options_override: Options Override
folder_note: Folder for uploaded files. Does not affect existing files.
imageToken: Image Token
imageToken_label: What (static) token to append to image sources
imageToken: Static Access Token
imageToken_label: Static access token is appended to the assets' URL
input-autocomplete-api:
input-autocomplete-api: Autocomplete Input (API)
description: A search typeahead for external API values.