summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorMarie <github@yuugi.dev>2024-10-04 02:31:22 +0200
committerMarie <github@yuugi.dev>2024-10-04 02:31:22 +0200
commitd5b372f7a92e3892addb306fc6b62b169e2bfc41 (patch)
treeb17986d43be7213a56cefa8ccd7db05f472311c7 /packages/frontend/src
parentmerge: Feat: Implement "Show Below Avatar" for Avatar Decorations (!645) (diff)
downloadsharkey-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.vue14
-rw-r--r--packages/frontend/src/components/MkUrlWarningDialog.vue131
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue9
-rw-r--r--packages/frontend/src/plugin.ts13
-rw-r--r--packages/frontend/src/scripts/warning-external-website.ts48
-rw-r--r--packages/frontend/src/store.ts4
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',