Skip to content

Commit fa2ecc4

Browse files
committed
feat: 表示上のURLが実際のURLと異なる際に警告
1 parent 8cc3f65 commit fa2ecc4

File tree

4 files changed

+146
-9
lines changed

4 files changed

+146
-9
lines changed

packages/frontend/src/components/MkNote.vue

+39-2
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
7070
:enableEmojiMenu="true"
7171
:enableEmojiMenuReaction="true"
7272
/>
73+
<MkPhishingCaution v-if="isSuspectPhishingLink" :cautionMessage="i18n.ts.shortPhishingCaution" />
7374
<div v-if="translating || translation" :class="$style.translation">
7475
<MkLoading v-if="translating" mini/>
7576
<div v-else-if="translation">
@@ -82,7 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
8283
<MkMediaList :mediaList="appearNote.files"/>
8384
</div>
8485
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
85-
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
86+
<MkUrlPreview v-for="url in previewUrls" :key="url" :url="url.href" :compact="true" :detail="false" :class="$style.urlPreview"/>
8687
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
8788
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
8889
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
@@ -168,6 +169,7 @@ import MkPoll from '@/components/MkPoll.vue';
168169
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
169170
import MkUrlPreview from '@/components/MkUrlPreview.vue';
170171
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
172+
import MkPhishingCaution from '@/components/MkPhishingCaution.vue';
171173
import { pleaseLogin } from '@/scripts/please-login.js';
172174
import { focusPrev, focusNext } from '@/scripts/focus.js';
173175
import { checkWordMute } from '@/scripts/check-word-mute.js';
@@ -250,7 +252,8 @@ const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entiti
250252
const isMyRenote = $i && ($i.id === note.value.userId);
251253
const showContent = ref(false);
252254
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
253-
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
255+
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url.href && appearNote.value.renote?.uri !== url.href) : null);
256+
const previewUrls = computed(() => urls.value.filter(url => url.preview))
254257
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
255258
const collapsed = ref(appearNote.value.cw == null && isLong);
256259
const isDeleted = ref(false);
@@ -266,6 +269,40 @@ const renoteCollapsed = ref(
266269
(appearNote.value.myReaction != null)
267270
)
268271
);
272+
function isURL(str) {
273+
try {
274+
new URL(str);
275+
return true;
276+
} catch (e) {
277+
return false;
278+
}
279+
}
280+
function getDomain(url) {
281+
try {
282+
const domain = new URL(url).hostname;
283+
return domain;
284+
} catch (e) {
285+
return null;
286+
}
287+
}
288+
const isSuspectPhishingLink = computed(() => {
289+
return urls.value.some(url => {
290+
// url.textが配列でない場合、配列に変換
291+
const text = Array.isArray(url.text) ? url.text.join('') : url.text;
292+
293+
// textがURLでない場合、すぐに次のurlへ
294+
console.log(text)
295+
if (!isURL((text.startsWith('https://') || text.startsWith('http://')) ? text : `https://${text}`)) return false;
296+
297+
// hrefとtextのドメインを比較
298+
const hrefDomain = getDomain(url.href);
299+
const textDomain = getDomain(text);
300+
console.log(hrefDomain, textDomain)
301+
302+
// ドメインが一致しない場合、フィッシングの疑いあり
303+
return hrefDomain !== textDomain && textDomain;
304+
});
305+
});
269306

270307
/* Overload FunctionにLintが対応していないのでコメントアウト
271308
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;

packages/frontend/src/components/MkNoteDetailed.vue

+38-2
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
8383
:enableEmojiMenu="true"
8484
:enableEmojiMenuReaction="true"
8585
/>
86+
<MkPhishingCaution v-if="isSuspectPhishingLink" :cautionMessage="i18n.ts.phishingCaution" />
8687
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
8788
<div v-if="translating || translation" :class="$style.translation">
8889
<MkLoading v-if="translating" mini/>
@@ -95,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
9596
<MkMediaList :mediaList="appearNote.files"/>
9697
</div>
9798
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
98-
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
99+
<MkUrlPreview v-for="url in previewUrls" :key="url" :url="url.href" :compact="true" :detail="true" style="margin-top: 6px;"/>
99100
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
100101
</div>
101102
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
@@ -202,6 +203,7 @@ import MkPoll from '@/components/MkPoll.vue';
202203
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
203204
import MkUrlPreview from '@/components/MkUrlPreview.vue';
204205
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
206+
import MkPhishingCaution from '@/components/MkPhishingCaution.vue';
205207
import { pleaseLogin } from '@/scripts/please-login.js';
206208
import { checkWordMute } from '@/scripts/check-word-mute.js';
207209
import { userPage } from '@/filters/user.js';
@@ -274,12 +276,46 @@ const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : fals
274276
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
275277
const translating = ref(false);
276278
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
277-
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
279+
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url.href && appearNote.value.renote?.uri !== url.href) : null;
280+
const previewUrls = urls.filter(url => url.preview)
278281
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
279282
const conversation = ref<Misskey.entities.Note[]>([]);
280283
const replies = ref<Misskey.entities.Note[]>([]);
281284
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
282285

286+
function isURL(str) {
287+
try {
288+
new URL(str);
289+
return true;
290+
} catch (e) {
291+
return false;
292+
}
293+
}
294+
function getDomain(url) {
295+
try {
296+
const domain = new URL(url).hostname;
297+
return domain;
298+
} catch (e) {
299+
return null;
300+
}
301+
}
302+
const isSuspectPhishingLink = urls.some(url => {
303+
// url.textが配列でない場合、配列に変換
304+
const text = Array.isArray(url.text) ? url.text.join('') : url.text;
305+
306+
// textがURLでない場合、すぐに次のurlへ
307+
console.log(text)
308+
if (!isURL((text.startsWith('https://') || text.startsWith('http://')) ? text : `https://${text}`)) return false;
309+
310+
// hrefとtextのドメインを比較
311+
const hrefDomain = getDomain(url.href);
312+
const textDomain = getDomain(text);
313+
console.log(hrefDomain, textDomain)
314+
315+
// ドメインが一致しない場合、フィッシングの疑いあり
316+
return hrefDomain !== textDomain && textDomain;
317+
});
318+
283319
const keymap = {
284320
'r': () => reply(true),
285321
'e|a|plus': () => react(true),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!--
2+
SPDX-FileCopyrightText: syuilo and misskey-project
3+
SPDX-License-Identifier: AGPL-3.0-only
4+
-->
5+
6+
<template>
7+
<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ cautionMessage }}</div>
8+
</template>
9+
10+
<script lang="ts" setup>
11+
12+
defineProps<{
13+
cautionMessage: string;
14+
}>();
15+
</script>
16+
17+
<style lang="scss" module>
18+
.root {
19+
font-size: 0.8em;
20+
padding: 8px 12px;
21+
margin-top: 10px;
22+
margin-bottom: 10px;
23+
background: var(--infoWarnBg);
24+
color: var(--error);
25+
border-radius: var(--radius);
26+
overflow: clip;
27+
}
28+
29+
.link {
30+
margin-left: 4px;
31+
color: var(--accent);
32+
}
33+
</style>

packages/frontend/src/scripts/extract-url-from-mfm.ts

+36-5
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,44 @@ const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
1212

1313
export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
1414
const urlNodes = mfm.extract(nodes, (node) => {
15-
return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
15+
return (node.type === 'url') || (node.type === 'link');
1616
});
17-
const urls: string[] = unique(urlNodes.map(x => x.props.url));
17+
const urls = unique(urlNodes.map(x => {
18+
return x.type === 'url'
19+
? ({
20+
href: x.props.url,
21+
text: x.props.url,
22+
preview: true,
23+
})
24+
: ({
25+
href: x.props.url,
26+
text: extractTextValues(x.children) ?? [],
27+
preview: (!respectSilentFlag || !x.props.silent),
28+
})
29+
}));
1830

1931
return urls.reduce((array, url) => {
20-
const urlWithoutHash = removeHash(url);
21-
if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
32+
const urlWithoutHash = removeHash(url.href);
33+
if (!array.map(x => removeHash(x.href)).includes(urlWithoutHash)) array.push(url);
2234
return array;
23-
}, [] as string[]);
35+
}, []);
36+
}
37+
38+
function extractTextValues(obj: mfm.MfmNode) {
39+
const textValues = [];
40+
41+
function traverse(o) {
42+
if (Array.isArray(o)) {
43+
o.forEach(item => traverse(item));
44+
} else if (o && typeof o === 'object') {
45+
if (o.type === 'text') {
46+
textValues.push(o.props.text);
47+
} else if (o.children) {
48+
o.children.forEach(child => traverse(child));
49+
}
50+
}
51+
}
52+
53+
traverse(obj);
54+
return textValues;
2455
}

0 commit comments

Comments
 (0)