Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 表示上のURLが実際のURLと異なる際に警告 #16

Merged
merged 2 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions packages/frontend/src/components/MkNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkPhishingCaution v-if="isSuspectPhishingLink" :cautionMessage="i18n.ts.shortPhishingCaution" />
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
Expand All @@ -82,7 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
<MkUrlPreview v-for="url in previewUrls" :key="url" :url="url.href" :compact="true" :detail="false" :class="$style.urlPreview"/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
Expand Down Expand Up @@ -168,6 +169,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkPhishingCaution from '@/components/MkPhishingCaution.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
Expand Down Expand Up @@ -250,7 +252,8 @@ const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entiti
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false);
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 ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url.href && appearNote.value.renote?.uri !== url.href) : null);
const previewUrls = computed(() => urls.value.filter(url => url.preview));
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
Expand All @@ -267,6 +270,43 @@ const renoteCollapsed = ref(
)
);

function isURL(str) {
try {
new URL(str);
return true;
} catch (error) {
return false;
}
}

function getDomain(url) {
try {
const domain = new URL(url).hostname;
return domain;
} catch (error) {
return null;
}
}

const isSuspectPhishingLink = computed(() => {
return urls.value.some(url => {
// url.textが配列でない場合、配列に変換
const text = Array.isArray(url.text) ? url.text.join('') : url.text;

// textがURLでない場合、すぐに次のurlへ
console.log(text);
if (!isURL((text.startsWith('https://') || text.startsWith('http://')) ? text : `https://${text}`)) return false;

// hrefとtextのドメインを比較
const hrefDomain = getDomain(url.href);
const textDomain = getDomain(text);
console.log(hrefDomain, textDomain);

// ドメインが一致しない場合、フィッシングの疑いあり
return hrefDomain !== textDomain && textDomain;
});
});

/* Overload FunctionにLintが対応していないのでコメントアウト
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
Expand Down
42 changes: 40 additions & 2 deletions packages/frontend/src/components/MkNoteDetailed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkPhishingCaution v-if="isSuspectPhishingLink" :cautionMessage="i18n.ts.phishingCaution" />
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
Expand All @@ -95,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
<MkUrlPreview v-for="url in previewUrls" :key="url" :url="url.href" :compact="true" :detail="true" style="margin-top: 6px;"/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</div>
<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>
Expand Down Expand Up @@ -202,6 +203,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkPhishingCaution from '@/components/MkPhishingCaution.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
Expand Down Expand Up @@ -274,12 +276,48 @@ const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : fals
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(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 urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url.href && appearNote.value.renote?.uri !== url.href) : null;
const previewUrls = urls.filter(url => url.preview);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);

function isURL(str) {
try {
new URL(str);
return true;
} catch (error) {
return false;
}
}

function getDomain(url) {
try {
const domain = new URL(url).hostname;
return domain;
} catch (error) {
return null;
}
}

const isSuspectPhishingLink = urls.some(url => {
// url.textが配列でない場合、配列に変換
const text = Array.isArray(url.text) ? url.text.join('') : url.text;

// textがURLでない場合、すぐに次のurlへ
console.log(text);
if (!isURL((text.startsWith('https://') || text.startsWith('http://')) ? text : `https://${text}`)) return false;

// hrefとtextのドメインを比較
const hrefDomain = getDomain(url.href);
const textDomain = getDomain(text);
console.log(hrefDomain, textDomain);

// ドメインが一致しない場合、フィッシングの疑いあり
return hrefDomain !== textDomain && textDomain;
});

const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
Expand Down
33 changes: 33 additions & 0 deletions packages/frontend/src/components/MkPhishingCaution.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->

<template>
<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ cautionMessage }}</div>
</template>

<script lang="ts" setup>

defineProps<{
cautionMessage: string;
}>();
</script>

<style lang="scss" module>
.root {
font-size: 0.8em;
padding: 8px 12px;
margin-top: 10px;
margin-bottom: 10px;
background: var(--infoWarnBg);
color: var(--error);
border-radius: var(--radius);
overflow: clip;
}

.link {
margin-left: 4px;
color: var(--accent);
}
</style>
41 changes: 36 additions & 5 deletions packages/frontend/src/scripts/extract-url-from-mfm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,44 @@ const removeHash = (x: string) => x.replace(/#[^#]*$/, '');

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));
return (node.type === 'url') || (node.type === 'link');
});
const urls: string[] = unique(urlNodes.map(x => x.props.url));
const urls = unique(urlNodes.map(x => {
return x.type === 'url'
? ({
href: x.props.url,
text: x.props.url,
preview: true,
})
: ({
href: x.props.url,
text: extractTextValues(x.children) ?? [],
preview: (!respectSilentFlag || !x.props.silent),
});
}));

return urls.reduce((array, url) => {
const urlWithoutHash = removeHash(url);
if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
const urlWithoutHash = removeHash(url.href);
if (!array.map(x => removeHash(x.href)).includes(urlWithoutHash)) array.push(url);
return array;
}, [] as string[]);
}, []);
}

function extractTextValues(obj: mfm.MfmNode) {
const textValues = [];

function traverse(o) {
if (Array.isArray(o)) {
o.forEach(item => traverse(item));
} else if (o && typeof o === 'object') {
if (o.type === 'text') {
textValues.push(o.props.text);
} else if (o.children) {
o.children.forEach(child => traverse(child));
}
}
}

traverse(obj);
return textValues;
}
Loading