1
1
mirror of https://github.com/kokonect-link/cherrypick synced 2024-11-27 14:28:53 +09:00

enhance(frontend): 외부 사이트로 이동할 때 경고 표시 (MisskeyIO/misskey#558)

This commit is contained in:
NoriDev 2024-10-03 00:38:49 +09:00
commit a44d9fcce0
19 changed files with 321 additions and 6 deletions

View File

@ -51,6 +51,7 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE
- Enhance: 리액션 수신 범위에 따라 리액션 버튼의 툴팁 내용이 변경됨
- Enhance: 설정 페이지 개선
- 일반 설정에 있던 설정 중 디자인과 관련된 설정을 모양으로 옮겼습니다.
- Enhance: 외부 사이트로 이동할 때 경고 표시 (MisskeyIO/misskey#558)
- Fix: 환경설정 백업 시 일부 설정이 누락되어 백업될 수 있음
---

View File

@ -1389,6 +1389,9 @@ additionalPermissionsForFlash: "Allow to add permission to Play"
thisFlashRequiresTheFollowingPermissions: "This Play requires the following permissions"
doYouWantToAllowThisPlayToAccessYourAccount: "Do you want to allow this Play to access your account?"
translateProfile: "Translate profile"
trustedLinkUrlPatterns: "Link to external site warning exclusion list"
trustedLinkUrlPatternsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition. Using surrounding keywords with slashes will turn them into a regular expression. If you write only the domain name, it will be a backward match."
open: "Open"
_nsfwOpenBehavior:
click: "Click to open"
doubleClick: "Double click to open"
@ -3008,3 +3011,7 @@ _imageCompressionMode:
noResizeCompress: "Compression without resize"
resizeCompressLossy: "Resize and lossy compression"
noResizeCompressLossy: "Lossy compression without resize"
_externalNavigationWarning:
title: "Navigate to an external site"
description: "Leave {host} and go to an external site"
trustThisDomain: "Trust this domain on this device in the future"

26
locales/index.d.ts vendored
View File

@ -5653,6 +5653,18 @@ export interface Locale extends ILocale {
*
*/
"translateProfile": string;
/**
*
*/
"trustedLinkUrlPatterns": string;
/**
* AND指定になりOR指定になります
*/
"trustedLinkUrlPatternsDescription": string;
/**
*
*/
"open": string;
"_nsfwOpenBehavior": {
/**
*
@ -11764,6 +11776,20 @@ export interface Locale extends ILocale {
*/
"noResizeCompressLossy": string;
};
"_externalNavigationWarning": {
/**
*
*/
"title": string;
/**
* {host}
*/
"description": ParameterizedString<"host">;
/**
*
*/
"trustThisDomain": string;
};
}
declare const locales: {
[lang: string]: Locale;

View File

@ -1407,6 +1407,9 @@ additionalPermissionsForFlash: "Playへの追加許可"
thisFlashRequiresTheFollowingPermissions: "このPlayは以下の権限を要求しています"
doYouWantToAllowThisPlayToAccessYourAccount: "このPlayによるアカウントへのアクセスを許可しますか"
translateProfile: "プロフィールを翻訳する"
trustedLinkUrlPatterns: "外部サイトへのリンク警告 除外リスト"
trustedLinkUrlPatternsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。"
open: "開く"
_nsfwOpenBehavior:
click: "タップして開く"
@ -3131,3 +3134,8 @@ _imageCompressionMode:
noResizeCompress: "縮小せず再圧縮する"
resizeCompressLossy: "縮小して非可逆圧縮する"
noResizeCompressLossy: "縮小せず非可逆圧縮する"
_externalNavigationWarning:
title: "外部サイトに移動します"
description: "{host}を離れて外部サイトに移動します"
trustThisDomain: "このデバイスで今後このドメインを信頼する"

View File

@ -1406,6 +1406,9 @@ additionalPermissionsForFlash: "Play에 대한 추가 권한"
thisFlashRequiresTheFollowingPermissions: "이 Play는 다음 권한을 요구해요"
doYouWantToAllowThisPlayToAccessYourAccount: "이 Play가 계정에 접근하도록 허용할까요?"
translateProfile: "프로필 번역하기"
trustedLinkUrlPatterns: "외부 사이트 링크 경고 제외 목록"
trustedLinkUrlPatternsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정할 수 있어요. 슬래시로 둘러싸면 정규 표현식이 돼요. 도메인만 쓰면 후방 탐색으로 작동해요."
open: "열기"
_nsfwOpenBehavior:
click: "탭하여 열기"
doubleClick: "두 번 탭하여 열기"
@ -3039,3 +3042,7 @@ _imageCompressionMode:
noResizeCompress: "해상도를 축소하지 않고 압축"
resizeCompressLossy: "해상도 축소 및 손실 압축"
noResizeCompressLossy: "해상도를 축소하지 않고 손실 압축"
_externalNavigationWarning:
title: "외부 사이트로 이동할까요?"
description: "{host}을(를) 떠나 외부 사이트로 이동하려고 해요"
trustThisDomain: "이 장치에서 앞으로 이 도메인을 신뢰할게요"

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ExternalWebsiteWarn1711008460816 {
name = 'ExternalWebsiteWarn1711008460816'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "trustedLinkUrlPatterns" character varying(3072) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "trustedLinkUrlPatterns"`);
}
}

View File

@ -118,6 +118,7 @@ export class MetaEntityService {
imageUrl: ad.imageUrl,
dayOfWeek: ad.dayOfWeek,
})),
trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns,
notesPerOneAd: instance.notesPerOneAd,
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,

View File

@ -727,6 +727,14 @@ export class MiMeta {
})
public urlPreviewRequireContentLength: boolean;
@Column('varchar', {
length: 3072,
array: true,
default: '{}',
comment: 'An array of URL strings or regex that can be used to omit warnings about redirects to external sites. Separate them with spaces to specify AND, and enclose them with slashes to specify regular expressions. Each item is regarded as an OR.',
})
public trustedLinkUrlPatterns: string[];
@Column('varchar', {
length: 1024,
nullable: true,

View File

@ -190,6 +190,14 @@ export const packedMetaLiteSchema = {
},
},
},
trustedLinkUrlPatterns: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
notesPerOneAd: {
type: 'number',
optional: false, nullable: false,

View File

@ -583,6 +583,14 @@ export const meta = {
type: 'string',
optional: true, nullable: true,
},
trustedLinkUrlPatterns: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
},
},
} as const;
@ -748,6 +756,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
enableReceivePrerelease: instance.enableReceivePrerelease,
skipVersion: instance.skipVersion,
skipCherryPickVersion: instance.skipCherryPickVersion,
trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns,
};
});
}

View File

@ -204,6 +204,11 @@ export const paramDef = {
enableReceivePrerelease: { type: 'boolean' },
skipVersion: { type: 'boolean' },
skipCherryPickVersion: { type: 'string', nullable: true },
trustedLinkUrlPatterns: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
},
required: [],
} as const;
@ -782,6 +787,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.skipCherryPickVersion = ps.skipCherryPickVersion;
}
if (Array.isArray(ps.trustedLinkUrlPatterns)) {
set.trustedLinkUrlPatterns = ps.trustedLinkUrlPatterns.filter(Boolean);
}
const before = await this.metaService.fetch(true);
await this.metaService.update(set);

View File

@ -5319,6 +5319,7 @@ export type components = {
imageUrl: string;
dayOfWeek: number;
}[];
trustedLinkUrlPatterns: string[];
/** @default 0 */
notesPerOneAd: number;
enableEmail: boolean;
@ -5511,6 +5512,7 @@ export type operations = {
perUserListTimelineCacheMax: number;
enableReactionsBuffering: boolean;
notesPerOneAd: number;
trustedLinkUrlPatterns: string[];
backgroundImageUrl: string | null;
deeplAuthKey: string | null;
deeplIsPro: boolean;
@ -10232,6 +10234,7 @@ export type operations = {
mediaSilencedHosts?: string[] | null;
/** @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead. */
summalyProxy?: string | null;
trustedLinkUrlPatterns?: string[] | null;
urlPreviewEnabled?: boolean;
urlPreviewTimeout?: number;
urlPreviewMaximumContentLength?: number;

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
:title="url"
@click.stop
@click.stop="(ev: MouseEvent) => warningExternalWebsite(ev, props.url)"
>
<slot></slot>
<i v-if="target === '_blank'" class="ti ti-external-link" :class="$style.icon"></i>
@ -22,6 +22,7 @@ import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { MkABehavior } from '@/components/global/MkA.vue';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
const props = withDefaults(defineProps<{
url: string;
@ -32,7 +33,7 @@ const props = withDefaults(defineProps<{
const self = props.url.startsWith(local);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
const target = self ? undefined : '_blank';
const el = ref<HTMLElement | { $el: HTMLElement }>();

View File

@ -44,8 +44,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<div v-else>
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url" @click.stop>
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url" @click.stop="(ev: MouseEvent) => warningExternalWebsite(ev, url)">
<div v-if="thumbnail && !sensitive" :class="[$style.thumbnail, { [$style.thumbnailBlur]: sensitive }]" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
</div>
<article :class="$style.body">
<header :class="$style.header">
@ -93,6 +93,7 @@ import { deviceKind } from '@/scripts/device-kind.js';
import MkButton from '@/components/MkButton.vue';
import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
import { defaultStore } from '@/store.js';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
type SummalyResult = Awaited<ReturnType<typeof summaly>>;

View File

@ -0,0 +1,136 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root" class="_gaps">
<div class="_gaps_s">
<div :class="$style.header">
<div :class="$style.icon">
<i class="ti ti-alert-triangle"></i>
</div>
<div :class="$style.title">{{ i18n.ts._externalNavigationWarning.title }}</div>
</div>
<div><Mfm :text="i18n.tsx._externalNavigationWarning.description({ host: instanceName })"/></div>
<div class="_monospace" :class="$style.urlAddress">{{ url }}</div>
<div>
<MkSwitch v-model="trustThisDomain">{{ i18n.ts._externalNavigationWarning.trustThisDomain }}</MkSwitch>
</div>
</div>
<div :class="$style.buttons">
<MkButton data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton data-cy-modal-dialog-ok inline primary rounded @click="ok"><i class="ti ti-external-link"></i> {{ i18n.ts.open }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
import { instanceName } from '@@/js/config.js';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
type Result = string | number | true | null;
const props = defineProps<{
url: string;
}>();
const emit = defineEmits<{
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'closed'): void;
}>();
const modal = shallowRef<InstanceType<typeof MkModal>>();
const trustThisDomain = ref(false);
const domain = computed(() => new URL(props.url).hostname);
// overload function 使 lint
function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
modal.value?.close();
}
async function ok() {
const result = true;
if (!defaultStore.state.trustedDomains.includes(domain.value) && trustThisDomain.value) {
await defaultStore.set('trustedDomains', defaultStore.state.trustedDomains.concat(domain.value));
}
done(false, result);
}
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
onMounted(() => {
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
width: 100%;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
background: var(--panel);
border-radius: 16px;
}
.header {
display: flex;
align-items: center;
gap: 0.75em;
}
.icon {
font-size: 18px;
color: var(--warn);
}
.title {
font-weight: bold;
font-size: 1.1em;
}
.urlAddress {
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--divider);
overflow-x: auto;
white-space: nowrap;
}
.buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: right;
}
</style>

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<component
:is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
@click.stop
@click.stop="(ev: MouseEvent) => warningExternalWebsite(ev, props.url)"
@contextmenu.stop="() => {}"
>
<template v-if="!self">
@ -33,6 +33,7 @@ import * as os from '@/os.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { MkABehavior } from '@/components/global/MkA.vue';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
function safeURIDecode(str: string): string {
try {
@ -75,7 +76,7 @@ const pathname = safeURIDecode(url.pathname);
const query = safeURIDecode(url.search);
const hash = safeURIDecode(url.hash);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
const target = self ? undefined : '_blank';
</script>
<style lang="scss" module>

View File

@ -56,6 +56,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.trustedLinkUrlPatterns }}</template>
<div class="_gaps">
<MkTextarea v-model="trustedLinkUrlPatterns">
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.hiddenTags }}</template>
@ -135,6 +147,7 @@ const preservedUsernames = ref<string>('');
const blockedHosts = ref<string>('');
const silencedHosts = ref<string>('');
const mediaSilencedHosts = ref<string>('');
const trustedLinkUrlPatterns = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
@ -147,6 +160,7 @@ async function init() {
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts.join('\n');
mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
trustedLinkUrlPatterns.value = meta.trustedLinkUrlPatterns.join('\n');
}
function onChange_enableRegistration(value: boolean) {
@ -189,6 +203,14 @@ function save_prohibitedWords() {
});
}
function save_trustedLinkUrlPatterns() {
os.apiWithDialog('admin/update-meta', {
trustedLinkUrlPatterns: trustedLinkUrlPatterns.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_hiddenTags() {
os.apiWithDialog('admin/update-meta', {
hiddenTags: hiddenTags.value.split('\n'),

View File

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { url as local } from '@@/js/config.js';
import { instance } from '@/instance.js';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
import MkUrlWarningDialog from '@/components/MkUrlWarningDialog.vue';
const extractDomain = /^(https?:\/\/|\/\/)?([^@/\s]+@)?(www\.)?([^:/\s]+)/i;
const isRegExp = /^\/(.+)\/(.*)$/;
export async function warningExternalWebsite(ev: MouseEvent, url: string) {
const domain = extractDomain.exec(url)?.[4];
const self = !domain || url.startsWith(local);
const isTrustedByInstance = self || instance.trustedLinkUrlPatterns.some(expression => {
const r = isRegExp.exec(expression);
if (r) {
return new RegExp(r[1], r[2]).test(url);
} else if (expression.includes(' ')) return expression.split(' ').every(keyword => url.includes(keyword));
else return domain.endsWith(expression);
});
const isTrustedByUser = defaultStore.reactiveState.trustedDomains.value.includes(domain);
if (!self && !isTrustedByInstance && !isTrustedByUser) {
ev.preventDefault();
ev.stopPropagation();
const confirm = await new Promise<{ canceled: boolean }>(resolve => {
os.popup(MkUrlWarningDialog, {
url,
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
});
if (confirm.canceled) return false;
window.open(url, '_blank', 'noopener');
}
return true;
}

View File

@ -486,6 +486,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'deviceAccount',
default: false,
},
trustedDomains: {
where: 'device',
default: [] as string[],
},
sound_masterVolume: {
where: 'device',