diff options
| author | Marie <github@yuugi.dev> | 2024-10-04 02:31:22 +0200 |
|---|---|---|
| committer | Marie <github@yuugi.dev> | 2024-10-04 02:31:22 +0200 |
| commit | d5b372f7a92e3892addb306fc6b62b169e2bfc41 (patch) | |
| tree | b17986d43be7213a56cefa8ccd7db05f472311c7 /packages/frontend/src | |
| parent | merge: Feat: Implement "Show Below Avatar" for Avatar Decorations (!645) (diff) | |
| download | sharkey-d5b372f7a92e3892addb306fc6b62b169e2bfc41.tar.gz sharkey-d5b372f7a92e3892addb306fc6b62b169e2bfc41.tar.bz2 sharkey-d5b372f7a92e3892addb306fc6b62b169e2bfc41.zip | |
upd&merge: Merge Cherrypick/MisskeyIO's external url popup, delete old popup warning and modify script to handle undefined domains
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkLink.vue | 14 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUrlWarningDialog.vue | 131 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/moderation.vue | 9 | ||||
| -rw-r--r-- | packages/frontend/src/plugin.ts | 13 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/warning-external-website.ts | 48 | ||||
| -rw-r--r-- | packages/frontend/src/store.ts | 4 |
6 files changed, 197 insertions, 22 deletions
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 > <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> diff --git a/packages/frontend/src/components/MkUrlWarningDialog.vue b/packages/frontend/src/components/MkUrlWarningDialog.vue new file mode 100644 index 0000000000..5a37a36ee5 --- /dev/null +++ b/packages/frontend/src/components/MkUrlWarningDialog.vue @@ -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> 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 <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); }); 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<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'); - })(); + 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', |