merge: Change the recent external url warning popup to the one from Cherrypick/MisskeyIO (!648)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/648

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
This commit is contained in:
Hazelnoot 2024-10-06 17:56:36 +00:00
commit 42dbe999e1
17 changed files with 297 additions and 28 deletions

View File

@ -61,6 +61,9 @@ copyNoteId: "Copy note ID"
copyFileId: "Copy file ID"
copyFolderId: "Copy folder ID"
copyProfileUrl: "Copy profile URL"
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"
searchUser: "Search for a user"
searchThisUsersNotes: "Search this users notes"
reply: "Reply"
@ -296,7 +299,6 @@ removeAreYouSure: "Are you sure that you want to remove \"{x}\"?"
deleteAreYouSure: "Are you sure that you want to delete \"{x}\"?"
resetAreYouSure: "Really reset?"
areYouSure: "Are you sure?"
confirmRemoteUrl: "Are you sure that you want to go to \"{x}\"?"
saved: "Saved"
messaging: "Chat"
upload: "Upload"
@ -2832,3 +2834,7 @@ _contextMenu:
app: "Application"
appWithShift: "Application with shift key"
native: "Native"
_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"

30
locales/index.d.ts vendored
View File

@ -260,6 +260,18 @@ export interface Locale extends ILocale {
* URLをコピー
*/
"copyProfileUrl": string;
/**
*
*/
"trustedLinkUrlPatterns": string;
/**
* AND指定になりOR指定になります
*/
"trustedLinkUrlPatternsDescription": string;
/**
*
*/
"open": string;
/**
*
*/
@ -1200,10 +1212,6 @@ export interface Locale extends ILocale {
*
*/
"areYouSure": string;
/**
* {x}
*/
"confirmRemoteUrl": ParameterizedString<"x">;
/**
*
*/
@ -10933,6 +10941,20 @@ export interface Locale extends ILocale {
*/
"native": string;
};
"_externalNavigationWarning": {
/**
*
*/
"title": string;
/**
* {host}
*/
"description": ParameterizedString<"host">;
/**
*
*/
"trustThisDomain": string;
};
}
declare const locales: {
[lang: string]: Locale;

View File

@ -61,6 +61,9 @@ copyNoteId: "ートIDをコピー"
copyFileId: "ファイルIDをコピー"
copyFolderId: "フォルダーIDをコピー"
copyProfileUrl: "プロフィールURLをコピー"
trustedLinkUrlPatterns: "外部サイトへのリンク警告 除外リスト"
trustedLinkUrlPatternsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。"
open: "開く"
searchUser: "ユーザーを検索"
searchThisUsersNotes: "ユーザーのノートを検索"
reply: "返信"
@ -296,7 +299,6 @@ removeAreYouSure: "「{x}」を削除しますか?"
deleteAreYouSure: "「{x}」を削除しますか?"
resetAreYouSure: "リセットしますか?"
areYouSure: "よろしいですか?"
confirmRemoteUrl: "「{x}」を開きますか?"
saved: "保存しました"
messaging: "チャット"
upload: "アップロード"
@ -2907,3 +2909,8 @@ _contextMenu:
app: "アプリケーション"
appWithShift: "Shiftキーでアプリケーション"
native: "ブラウザのUI"
_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

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

View File

@ -674,4 +674,12 @@ export class MiMeta {
nullable: true,
})
public urlPreviewUserAgent: string | null;
@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[];
}

View File

@ -273,6 +273,14 @@ export const packedMetaLiteSchema = {
optional: false, nullable: false,
default: 'local',
},
trustedLinkUrlPatterns: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
},
} as const;

View File

@ -526,6 +526,14 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
trustedLinkUrlPatterns: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
},
},
} as const;
@ -669,6 +677,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
urlPreviewUserAgent: instance.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns,
};
});
}

View File

@ -176,6 +176,11 @@ export const paramDef = {
urlPreviewRequireContentLength: { type: 'boolean' },
urlPreviewUserAgent: { type: 'string', nullable: true },
urlPreviewSummaryProxyUrl: { type: 'string', nullable: true },
trustedLinkUrlPatterns: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
},
required: [],
} as const;
@ -665,6 +670,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.urlPreviewSummaryProxyUrl = value === '' ? null : value;
}
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

@ -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.prevent="self ? true : promptConfirm()"
@click.prevent="self ? true : warningExternalWebsite(url)"
@click.stop
>
<slot></slot>
@ -23,7 +23,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 { i18n } from '@/i18n.js';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
const props = withDefaults(defineProps<{
url: string;
@ -49,16 +49,6 @@ if (isEnabledUrlPreview.value) {
});
});
}
async function promptConfirm() {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.confirmRemoteUrl({ x: props.url }),
plain: true,
});
if (canceled) return;
window.open(props.url, '_blank', 'nofollow noopener popup=false');
}
</script>
<style lang="scss" module>

View File

@ -0,0 +1,131 @@
<!--
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 '@/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 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

@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
: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"
@contextmenu.stop="() => {}"
@click.prevent="self ? true : warningExternalWebsite(props.url)"
@click.stop
>
<template v-if="!self">
@ -34,6 +35,7 @@ import { useTooltip } from '@/scripts/use-tooltip.js';
import { safeURIDecode } from '@/scripts/safe-uri-decode.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;

View File

@ -50,6 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
</MkTextarea>
<MkTextarea v-model="trustedLinkUrlPatterns">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.trustedLinkUrlPatterns }}</template>
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
</MkTextarea>
<MkTextarea v-model="sensitiveWords">
<template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
@ -105,6 +111,7 @@ const bubbleTimeline = ref<string>('');
const tosUrl = ref<string | null>(null);
const privacyPolicyUrl = ref<string | null>(null);
const inquiryUrl = ref<string | null>(null);
const trustedLinkUrlPatterns = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
@ -120,6 +127,7 @@ async function init() {
bubbleTimeline.value = meta.bubbleInstances.join('\n');
bubbleTimelineEnabled.value = meta.policies.btlAvailable;
inquiryUrl.value = meta.inquiryUrl;
trustedLinkUrlPatterns.value = meta.trustedLinkUrlPatterns.join('\n');
}
function save() {
@ -135,6 +143,7 @@ function save() {
hiddenTags: hiddenTags.value.split('\n'),
preservedUsernames: preservedUsernames.value.split('\n'),
bubbleInstances: bubbleTimeline.value.split('\n'),
trustedLinkUrlPatterns: trustedLinkUrlPatterns.value.split('\n'),
}).then(() => {
fetchInstance(true);
});

View File

@ -9,6 +9,7 @@ import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
const parser = new Parser();
const pluginContexts = new Map<string, Interpreter>();
@ -92,16 +93,8 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s
registerPageViewInterruptor({ pluginId: opts.plugin.id, handler });
}),
'Plugin:open_url': values.FN_NATIVE(([url]) => {
(async () => {
utils.assertString(url);
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.confirmRemoteUrl({ x: url.value }),
plain: true,
});
if (canceled) return;
window.open(url.value, '_blank', 'noopener');
})();
warningExternalWebsite(url.value);
}),
'Plugin:config': values.OBJ(config),
};

View File

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
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(url: string) {
const domain = extractDomain.exec(url)?.[4];
if (!domain) return false;
const isTrustedByInstance = 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 (!isTrustedByInstance && !isTrustedByUser) {
const confirm = await new Promise<{ canceled: boolean }>(resolve => {
const { dispose } = os.popup(MkUrlWarningDialog, {
url,
}, {
done: result => {
resolve(result ?? { canceled: true });
},
closed: () => dispose(),
});
});
if (confirm.canceled) return false;
return window.open(url, '_blank', 'nofollow noopener popup=false');
}
return window.open(url, '_blank', 'nofollow noopener popup=false');
}

View File

@ -165,6 +165,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: 'public' as 'public' | 'home' | 'followers',
},
trustedDomains: {
where: 'account',
default: [] as string[],
},
menu: {
where: 'deviceAccount',

View File

@ -5098,6 +5098,7 @@ export type components = {
* @enum {string}
*/
noteSearchableScope: 'local' | 'global';
trustedLinkUrlPatterns: string[];
};
MetaDetailedOnly: {
features?: {
@ -5294,6 +5295,7 @@ export type operations = {
urlPreviewRequireContentLength: boolean;
urlPreviewUserAgent: string | null;
urlPreviewSummaryProxyUrl: string | null;
trustedLinkUrlPatterns: string[];
};
};
};
@ -9815,6 +9817,7 @@ export type operations = {
urlPreviewRequireContentLength?: boolean;
urlPreviewUserAgent?: string | null;
urlPreviewSummaryProxyUrl?: string | null;
trustedLinkUrlPatterns?: string[] | null;
};
};
};