From d5b372f7a92e3892addb306fc6b62b169e2bfc41 Mon Sep 17 00:00:00 2001 From: Marie Date: Fri, 4 Oct 2024 02:31:22 +0200 Subject: [PATCH] upd&merge: Merge Cherrypick/MisskeyIO's external url popup, delete old popup warning and modify script to handle undefined domains --- locales/en-US.yml | 8 +- locales/index.d.ts | 38 ++++- locales/ja-JP.yml | 8 ++ .../1711008460816-external-website-warn.js | 16 +++ .../src/core/entities/MetaEntityService.ts | 1 + packages/backend/src/models/Meta.ts | 8 ++ .../backend/src/models/json-schema/meta.ts | 8 ++ .../src/server/api/endpoints/admin/meta.ts | 9 ++ .../server/api/endpoints/admin/update-meta.ts | 9 ++ packages/frontend/src/components/MkLink.vue | 14 +- .../src/components/MkUrlWarningDialog.vue | 131 ++++++++++++++++++ .../frontend/src/pages/admin/moderation.vue | 9 ++ packages/frontend/src/plugin.ts | 13 +- .../src/scripts/warning-external-website.ts | 48 +++++++ packages/frontend/src/store.ts | 4 + packages/misskey-js/src/autogen/types.ts | 3 + 16 files changed, 298 insertions(+), 29 deletions(-) create mode 100644 packages/backend/migration/1711008460816-external-website-warn.js create mode 100644 packages/frontend/src/components/MkUrlWarningDialog.vue create mode 100644 packages/frontend/src/scripts/warning-external-website.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index 221ade4028..541917677c 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -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 user’s notes" reply: "Reply" @@ -291,7 +294,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" @@ -2827,3 +2829,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" diff --git a/locales/index.d.ts b/locales/index.d.ts index f93ef14325..a7f24eea04 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -260,6 +260,18 @@ export interface Locale extends ILocale { * プロフィールURLをコピー */ "copyProfileUrl": string; + /** + * 外部サイトへのリンク警告 除外リスト + */ + "trustedLinkUrlPatterns": string; + /** + * スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。 + */ + "trustedLinkUrlPatternsDescription": string; + /** + * 開く + */ + "open": string; /** * ユーザーを検索 */ @@ -3128,6 +3140,10 @@ export interface Locale extends ILocale { * 返信にサーバー情報を表示する */ "showTickerOnReplies": string; + /** + * 猫の話し方を無効にする + */ + "disableCatSpeak": string; /** * 検索MFMの検索エンジン */ @@ -4429,10 +4445,6 @@ export interface Locale extends ILocale { * 連合なしにする */ "disableFederationOk": string; - /** - * 猫の話し方を無効にする - */ - "disableCatSpeak": string; /** * 現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。 */ @@ -5777,7 +5789,7 @@ export interface Locale extends ILocale { */ "social": string; /** - * バッッブルタイムラインでは、管理者が選択した接続サーバーからのメモを表示できます。 + * バブルタイムラインでは、管理者が選択した接続サーバーからの投稿を表示できます。 */ "bubble": string; /** @@ -9139,7 +9151,7 @@ export interface Locale extends ILocale { */ "global": string; /** - * バッッブル + * バブル */ "bubble": string; }; @@ -10913,6 +10925,20 @@ export interface Locale extends ILocale { */ "native": string; }; + "_externalNavigationWarning": { + /** + * 外部サイトに移動します + */ + "title": string; + /** + * {host}を離れて外部サイトに移動します + */ + "description": ParameterizedString<"host">; + /** + * このデバイスで今後このドメインを信頼する + */ + "trustThisDomain": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 70acc3adf4..49cd717465 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -61,6 +61,9 @@ copyNoteId: "ノートIDをコピー" copyFileId: "ファイルIDをコピー" copyFolderId: "フォルダーIDをコピー" copyProfileUrl: "プロフィールURLをコピー" +trustedLinkUrlPatterns: "外部サイトへのリンク警告 除外リスト" +trustedLinkUrlPatternsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。" +open: "開く" searchUser: "ユーザーを検索" searchThisUsersNotes: "ユーザーのノートを検索" reply: "返信" @@ -2902,3 +2905,8 @@ _contextMenu: app: "アプリケーション" appWithShift: "Shiftキーでアプリケーション" native: "ブラウザのUI" + +_externalNavigationWarning: + title: "外部サイトに移動します" + description: "{host}を離れて外部サイトに移動します" + trustThisDomain: "このデバイスで今後このドメインを信頼する" diff --git a/packages/backend/migration/1711008460816-external-website-warn.js b/packages/backend/migration/1711008460816-external-website-warn.js new file mode 100644 index 0000000000..d36639459b --- /dev/null +++ b/packages/backend/migration/1711008460816-external-website-warn.js @@ -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"`); + } +} \ No newline at end of file diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index afeefc9033..fa4ddc0bd6 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -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, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 29e1dd032a..0e244931d9 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -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[]; } diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 74b6cfe883..8915436b9e 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -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; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 5a69fbf679..395da384ab 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -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 { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index c56dd053d3..cbde554428 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -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 { // 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); diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index d2819f9f4c..b04edd1150 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -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 > @@ -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'); -} diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 6297b9a182..0a5b06a969 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -50,6 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + @@ -105,6 +111,7 @@ const bubbleTimeline = ref(''); const tosUrl = ref(null); const privacyPolicyUrl = ref(null); const inquiryUrl = ref(null); +const trustedLinkUrlPatterns = ref(''); 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); }); diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 9640c988eb..c0034d414c 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -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(); @@ -92,16 +93,8 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record { - (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'); - })(); + utils.assertString(url); + warningExternalWebsite(url.value); }), 'Plugin:config': values.OBJ(config), }; diff --git a/packages/frontend/src/scripts/warning-external-website.ts b/packages/frontend/src/scripts/warning-external-website.ts new file mode 100644 index 0000000000..c0050112ce --- /dev/null +++ b/packages/frontend/src/scripts/warning-external-website.ts @@ -0,0 +1,48 @@ +/* + * 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 ? result : { canceled: true }); + }, + closed: () => dispose(), + }); + }); + + if (confirm.canceled) return false; + + window.open(url, '_blank', 'nofollow noopener popup=false'); + } + + return true; +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 036e43a4b6..ab5fbf0dd1 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -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', diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 0e83bdfcca..65cf76affd 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -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; }; }; };