diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index dcf477f74d..2ffa2778fc 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -236,6 +236,7 @@ import { useRouter } from '@/router.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -303,7 +304,7 @@ const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(prefer.s.uncollapseCW); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); -const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index c05b8afcfb..f5f4bb64ec 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -286,6 +286,7 @@ import { DI } from '@/di.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -338,10 +339,10 @@ const isDeleted = ref(false); const renoted = ref(false); const translation = ref(null); const translating = ref(false); -const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); -const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref([]); diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 49ed815af8..621f732caa 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -236,6 +236,7 @@ import { useRouter } from '@/router.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -303,7 +304,7 @@ const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(prefer.s.uncollapseCW); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); -const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 7dab05d157..e96c80e3d4 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -291,6 +291,7 @@ import { DI } from '@/di.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -344,10 +345,10 @@ const isDeleted = ref(false); const renoted = ref(false); const translation = ref(null); const translating = ref(false); -const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); -const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm ? true : false); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref([]); diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index 50c500dbea..b6dbec81c5 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -86,7 +86,6 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import { userPage } from '@/filters/user.js'; -import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/utility/clone.js'; import { dateTimeFormat } from '@/utility/intl-const.js'; @@ -94,6 +93,7 @@ import { prefer } from '@/preferences'; import { getPluginHandlers } from '@/plugin.js'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -142,14 +142,13 @@ const isRenote = ( ); const el = shallowRef(); -let appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; +const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); const showContent = ref(false); const translation = ref(null); const translating = ref(false); -const urls = appearNote.value.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null; +const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); diff --git a/packages/frontend/src/utility/extract-preview-urls.ts b/packages/frontend/src/utility/extract-preview-urls.ts new file mode 100644 index 0000000000..e14ed68f27 --- /dev/null +++ b/packages/frontend/src/utility/extract-preview-urls.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { host } from '@@/js/config.js'; +import type * as Misskey from 'misskey-js'; +import type * as mfm from '@transfem-org/sfm-js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; + +/** + * Extracts all previewable URLs from a note. + */ +export function extractPreviewUrls(note: Misskey.entities.Note, contents: mfm.MfmNode[]): string[] { + const links = extractUrlFromMfm(contents); + return links.filter(url => + // Remote note + url !== note.url && + url !== note.uri && + // Local note + url !== `https://${host}/notes/${note.id}` && + // Remote renote or quote + url !== note.renote?.url && + url !== note.renote?.uri && + // Local renote or quote + url !== `https://${host}/notes/${note.renote?.id}` && + // Remote renote *of* a quote + url !== note.renote?.renote?.url && + url !== note.renote?.renote?.uri && + // Local renote *of* a quote + url !== `https://${host}/notes/${note.renote?.renote?.id}`); +} diff --git a/packages/frontend/src/utility/extract-url-from-mfm.ts b/packages/frontend/src/utility/extract-url-from-mfm.ts index baebbff8ae..e1b9df138e 100644 --- a/packages/frontend/src/utility/extract-url-from-mfm.ts +++ b/packages/frontend/src/utility/extract-url-from-mfm.ts @@ -10,6 +10,7 @@ import { unique } from '@/utility/array.js'; // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] const removeHash = (x: string) => x.replace(/#[^#]*$/, ''); +// TODO this is O(n^2) which could introduce a frontend DoS with a large enough character limit export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] { const urlNodes = mfm.extract(nodes, (node) => { return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));