From 01ec286f3f479d030bd4a59ab666b513b1175bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Fri, 22 Mar 2024 07:43:59 +0900 Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=83=88=E3=81=B8=E3=81=AE=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=AF=E3=81=AF=E7=A7=BB=E5=8B=95=E3=81=AE=E5=89=8D=E3=81=AB?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=E3=82=92=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=20(MisskeyIO#558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en-US.yml | 4 +++ locales/index.d.ts | 19 +++++++++++ locales/ja-JP.yml | 4 +++ locales/ko-KR.yml | 4 +++ .../1711008460816-external-website-warn.js | 11 +++++++ .../src/core/entities/MetaEntityService.ts | 1 + packages/backend/src/models/Meta.ts | 5 +++ .../backend/src/models/json-schema/meta.ts | 8 +++++ .../src/server/api/endpoints/admin/meta.ts | 11 ++++++- .../server/api/endpoints/admin/update-meta.ts | 9 +++++ packages/frontend/src/components/MkLink.vue | 10 +++++- .../frontend/src/components/global/MkUrl.vue | 10 +++++- .../frontend/src/pages/admin/moderation.vue | 14 ++++++-- .../src/scripts/warning-external-website.ts | 33 +++++++++++++++++++ packages/misskey-js/src/autogen/types.ts | 5 ++- 15 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 packages/backend/migration/1711008460816-external-website-warn.js create mode 100644 packages/frontend/src/scripts/warning-external-website.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index 1fb581d8df..527beb4714 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1212,6 +1212,10 @@ useGroupedNotifications: "Display grouped notifications" signupPendingError: "There was a problem verifying the email address. The link may have expired." cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided." doReaction: "Add reaction" +wellKnownWebsites: "Well-known websites" +wellKnownWebsitesDescription: "Separate with spaces for AND, new lines for OR. Surround with slashes for regular expressions. Matching will allow redirection to external sites without a warning." +warningRedirectingExternalWebsiteTitle: "You are leaving our site!" +warningRedirectingExternalWebsiteDescription: "You are about to jump to another site.\nPlease make sure this link is reliable before proceeding.\n\n{url}" code: "Code" reloadRequiredToApplySettings: "Reloading is required to apply the settings." remainingN: "Remaining: {n}" diff --git a/locales/index.d.ts b/locales/index.d.ts index 44b24d83ce..bcca401dab 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4860,6 +4860,25 @@ export interface Locale extends ILocale { * リアクションする */ "doReaction": string; + /** + * よく知られたウェブサイト + */ + "wellKnownWebsites": string; + /** + * スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、外部サイトへのリダイレクトの警告を省略させることができます。 + */ + "wellKnownWebsitesDescription": string; + /** + * 外部サイトへ移動します + */ + "warningRedirectingExternalWebsiteTitle": string; + /** + * 別のサイトにジャンプしようとしています。 + * リンク先の安全性を十分に確認した上で進んでください。 + * + * {url} + */ + "warningRedirectingExternalWebsiteDescription": ParameterizedString<"url">; /** * サムネイルの表示を制限するURL */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fd84e0318d..d30ded3a52 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1211,6 +1211,10 @@ useGroupedNotifications: "通知をグルーピングして表示する" signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" doReaction: "リアクションする" +wellKnownWebsites: "よく知られたウェブサイト" +wellKnownWebsitesDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、外部サイトへのリダイレクトの警告を省略させることができます。" +warningRedirectingExternalWebsiteTitle: "外部サイトへ移動します" +warningRedirectingExternalWebsiteDescription: "別のサイトにジャンプしようとしています。\nリンク先の安全性を十分に確認した上で進んでください。\n\n{url}" urlPreviewDenyList: "サムネイルの表示を制限するURL" urlPreviewDenyListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルがぼかされて表示されます。" code: "コード" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 5b3d5538a0..e105a0df1e 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1209,6 +1209,10 @@ useGroupedNotifications: "알림을 그룹화하고 표시" signupPendingError: "메일 주소 확인중에 문제가 발생했습니다. 링크의 유효기간이 지났을 가능성이 있습니다." cwNotationRequired: "'내용을 숨기기'를 체크한 경우 주석을 써야 합니다." doReaction: "리액션 추가" +wellKnownWebsites: "잘 알려진 웹사이트" +wellKnownWebsitesDescription: "공백으로 구분하면 AND 지정이 되며, 개행으로 구분하면 OR 지정이 됩니다. 슬래시로 둘러싸면 정규 표현식이 됩니다. 일치하는 경우, 외부 사이트로의 리다이렉트 경고를 생략할 수 있습니다." +warningRedirectingExternalWebsiteTitle: "외부 사이트로 이동합니다" +warningRedirectingExternalWebsiteDescription: "다른 사이트로 이동하려고 합니다.\n링크가 안전한지 충분히 확인한 후 이동해주세요.\n\n{url}" code: "문자열" reloadRequiredToApplySettings: "설정을 적용하려면 새로고침을 해야 합니다." remainingN: "나머지: {n}" 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..6e6ae75ef6 --- /dev/null +++ b/packages/backend/migration/1711008460816-external-website-warn.js @@ -0,0 +1,11 @@ +export class ExternalWebsiteWarn1711008460816 { + name = 'ExternalWebsiteWarn1711008460816' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "wellKnownWebsites" character varying(3072) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "wellKnownWebsites"`); + } +} diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index b21185183d..ad35a440d5 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -99,6 +99,7 @@ export class MetaEntityService { imageUrl: ad.imageUrl, dayOfWeek: ad.dayOfWeek, })), + wellKnownWebsites: instance.wellKnownWebsites, 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 3255117d88..08451cff3a 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -594,6 +594,11 @@ export class MiMeta { }) public notesPerOneAd: number; + @Column('varchar', { + length: 3072, array: true, default: '{}', + }) + public wellKnownWebsites: string[]; + @Column('varchar', { length: 3072, array: true, default: '{}', }) diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 150b94a18f..dd440b4b18 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -183,6 +183,14 @@ export const packedMetaLiteSchema = { }, }, }, + wellKnownWebsites: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, notesPerOneAd: { type: 'number', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 107608d791..2d533b2557 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -381,9 +381,17 @@ export const meta = { type: 'number', optional: false, nullable: false, }, + wellKnownWebsites: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, urlPreviewDenyList: { type: 'array', - optional: true, nullable: false, + optional: false, nullable: false, items: { type: 'string', optional: false, nullable: false, @@ -602,6 +610,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, + wellKnownWebsites: instance.wellKnownWebsites, notesPerOneAd: instance.notesPerOneAd, urlPreviewDenyList: instance.urlPreviewDenyList, featuredGameChannels: instance.featuredGameChannels, 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 439116a1a7..baad5eff7a 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -155,6 +155,11 @@ export const paramDef = { type: 'string', }, }, + wellKnownWebsites: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, urlPreviewDenyList: { type: 'array', nullable: true, items: { type: 'string', @@ -220,6 +225,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }).map(x => x.toLowerCase()); } + if (Array.isArray(ps.wellKnownWebsites)) { + set.wellKnownWebsites = ps.wellKnownWebsites.filter(Boolean); + } + if (Array.isArray(ps.urlPreviewDenyList)) { set.urlPreviewDenyList = ps.urlPreviewDenyList.filter(Boolean); } diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 3f7aba2fe4..3ff5fcc752 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -5,8 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <component - :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" + :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" :title="url" + @click="(ev: MouseEvent) => warningExternalWebsite(ev, url)" > <slot></slot> <i v-if="target === '_blank'" class="ti ti-external-link" :class="$style.icon"></i> @@ -17,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@/config.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; +import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; import * as os from '@/os.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 8d29a4da8c..c400be3da2 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <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" + :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" + @click="(ev: MouseEvent) => warningExternalWebsite(ev, props.url)" @contextmenu.stop="() => {}" > <template v-if="!self"> @@ -30,6 +37,7 @@ import { url as local } from '@/config.js'; import * as os from '@/os.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; +import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; const props = withDefaults(defineProps<{ url: string; diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 2a2929dacd..e156d12539 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -45,6 +45,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template> </MkTextarea> + <MkTextarea v-model="wellKnownWebsites"> + <template #label>{{ i18n.ts.wellKnownWebsites }}</template> + <template #caption>{{ i18n.ts.wellKnownWebsitesDescription }}</template> + </MkTextarea> + <MkTextarea v-model="urlPreviewDenyList"> <template #label>{{ i18n.ts.urlPreviewDenyList }}</template> <template #caption>{{ i18n.ts.urlPreviewDenyListDescription }}</template> @@ -91,7 +96,8 @@ const hiddenTags = ref<string>(''); const preservedUsernames = ref<string>(''); const tosUrl = ref<string | null>(null); const privacyPolicyUrl = ref<string | null>(null); -const urlPreviewDenyList = ref<string | undefined>(''); +const wellKnownWebsites = ref<string>(''); +const urlPreviewDenyList = ref<string>(''); async function init() { const meta = await misskeyApi('admin/meta'); @@ -103,7 +109,8 @@ async function init() { preservedUsernames.value = meta.preservedUsernames.join('\n'); tosUrl.value = meta.tosUrl; privacyPolicyUrl.value = meta.privacyPolicyUrl; - urlPreviewDenyList.value = meta.urlPreviewDenyList?.join('\n'); + wellKnownWebsites.value = meta.wellKnownWebsites.join('\n'); + urlPreviewDenyList.value = meta.urlPreviewDenyList.join('\n'); } function save() { @@ -116,7 +123,8 @@ function save() { prohibitedWords: prohibitedWords.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'), preservedUsernames: preservedUsernames.value.split('\n'), - urlPreviewDenyList: urlPreviewDenyList.value?.split('\n'), + wellKnownWebsites: wellKnownWebsites.value.split('\n'), + urlPreviewDenyList: urlPreviewDenyList.value.split('\n'), }).then(() => { fetchInstance(true); }); 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..04109af8a5 --- /dev/null +++ b/packages/frontend/src/scripts/warning-external-website.ts @@ -0,0 +1,33 @@ +import { url as local } from '@/config.js'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +const isRegExp = /^\/(.+)\/(.*)$/; + +export async function warningExternalWebsite(ev: MouseEvent, url: string) { + const self = url.startsWith(local); + const isWellKnownWebsite = self || instance.wellKnownWebsites.some(expression => { + const r = isRegExp.exec(expression); + if (r) { + return new RegExp(r[1], r[2]).test(url); + } else return expression.split(' ').every(keyword => url.includes(keyword)); + }); + + if (!self && !isWellKnownWebsite) { + ev.preventDefault(); + ev.stopPropagation(); + + const confirm = await os.confirm({ + type: 'warning', + title: i18n.ts.warningRedirectingExternalWebsiteTitle, + text: i18n.tsx.warningRedirectingExternalWebsiteDescription({ url }), + }); + + if (confirm.canceled) return false; + + window.open(url, '_blank', 'noopener'); + } + + return true; +} diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 17adb4b418..f18d293ab9 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4991,6 +4991,7 @@ export type components = { imageUrl: string; dayOfWeek: number; })[]; + wellKnownWebsites: string[]; /** @default 0 */ notesPerOneAd: number; enableEmail: boolean; @@ -5181,7 +5182,8 @@ export type operations = { perUserHomeTimelineCacheMax: number; perUserListTimelineCacheMax: number; notesPerOneAd: number; - urlPreviewDenyList?: string[]; + wellKnownWebsites: string[]; + urlPreviewDenyList: string[]; featuredGameChannels: string[]; backgroundImageUrl: string | null; deeplAuthKey: string | null; @@ -9657,6 +9659,7 @@ export type operations = { notesPerOneAd?: number; silencedHosts?: string[] | null; sensitiveMediaHosts?: string[] | null; + wellKnownWebsites?: string[] | null; urlPreviewDenyList?: string[] | null; featuredGameChannels?: string[] | null; };