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:
commit
42dbe999e1
17 changed files with 297 additions and 28 deletions
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
131
packages/frontend/src/components/MkUrlWarningDialog.vue
Normal file
131
packages/frontend/src/components/MkUrlWarningDialog.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
51
packages/frontend/src/scripts/warning-external-website.ts
Normal file
51
packages/frontend/src/scripts/warning-external-website.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue