This commit is contained in:
かっこかり 2026-01-22 12:18:49 +00:00 committed by GitHub
commit 9c40c9c53c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 339 additions and 3 deletions

View File

@ -14,6 +14,8 @@
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
- Enhance: ウィジェットの設定項目のラベルの多言語対応
- Enhance: 画面幅が広いときにメディアを横並びで表示できるようにするオプションを追加
- Enhance: 外部サイトへのリンクは移動の前に警告を表示するように
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/558 and https://github.com/MisskeyIO/misskey/commit/f7ec503b9ceb34d61a0dbd658858915eb7399c5d)
- Enhance: パフォーマンスの向上
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正

View File

@ -1260,6 +1260,8 @@ useGroupedNotifications: "通知をグルーピング"
emailVerificationFailedError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
doReaction: "リアクションする"
trustedLinkUrlPatterns: "外部サイトへのリンク警告 除外リスト"
trustedLinkUrlPatternsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。"
code: "コード"
reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。"
remainingN: "残り: {n}"
@ -1408,6 +1410,7 @@ frame: "フレーム"
presets: "プリセット"
zeroPadding: "ゼロ埋め"
nothingToConfigure: "設定項目はありません"
open: "開く"
_imageEditing:
_vars:
@ -3201,6 +3204,11 @@ _urlPreviewSetting:
summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。"
summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。"
_externalNavigationWarning:
title: "外部サイトに移動します"
description: "{host}を離れて外部サイトに移動します"
trustThisDomain: "このデバイスで今後このドメインを信頼する"
_mediaControls:
pip: "ピクチャインピクチャ"
playbackRate: "再生速度"

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ExternalWebsiteWarn1762172837314 {
name = 'ExternalWebsiteWarn1762172837314'
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"`);
}
}

View File

@ -119,6 +119,7 @@ export class MetaEntityService {
dayOfWeek: ad.dayOfWeek,
isSensitive: ad.isSensitive ? true : undefined,
})),
trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns,
notesPerOneAd: instance.notesPerOneAd,
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,

View File

@ -639,6 +639,18 @@ export class MiMeta {
})
public urlPreviewRequireContentLength: boolean;
/**
* 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.
*/
@Column('varchar', {
length: 3072,
array: true,
default: '{}',
})
public trustedLinkUrlPatterns: string[];
@Column('varchar', {
length: 1024,
nullable: true,

View File

@ -201,6 +201,14 @@ export const packedMetaLiteSchema = {
},
},
},
trustedLinkUrlPatterns: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
notesPerOneAd: {
type: 'number',
optional: false, nullable: false,

View File

@ -407,6 +407,14 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
trustedLinkUrlPatterns: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
backgroundImageUrl: {
type: 'string',
optional: false, nullable: true,
@ -730,6 +738,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns,
enableReactionsBuffering: instance.enableReactionsBuffering,
notesPerOneAd: instance.notesPerOneAd,
summalyProxy: instance.urlPreviewSummaryProxyUrl,

View File

@ -178,6 +178,11 @@ export const paramDef = {
type: 'string', nullable: true,
description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.',
},
trustedLinkUrlPatterns: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
urlPreviewEnabled: { type: 'boolean' },
urlPreviewAllowRedirect: { type: 'boolean' },
urlPreviewTimeout: { type: 'integer' },
@ -267,6 +272,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return h !== '' && h !== lv && !set.blockedHosts?.includes(h);
});
}
if (Array.isArray(ps.trustedLinkUrlPatterns)) {
set.trustedLinkUrlPatterns = ps.trustedLinkUrlPatterns.filter(Boolean);
}
if (Array.isArray(ps.mediaSilencedHosts)) {
let lastValue = '';
set.mediaSilencedHosts = ps.mediaSilencedHosts.sort().filter((h) => {
@ -275,6 +285,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return h !== '' && h !== lv && !set.blockedHosts?.includes(h);
});
}
if (ps.themeColor !== undefined) {
set.themeColor = ps.themeColor;
}

View File

@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="maybeRelativeUrl" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
:title="url"
@click="(ev: MouseEvent) => warningExternalWebsite(ev, props.url)"
>
<slot></slot>
<i v-if="target === '_blank'" class="ti ti-external-link" :class="$style.icon"></i>
@ -22,18 +23,20 @@ import type { MkABehavior } from '@/components/global/MkA.vue';
import { useTooltip } from '@/composables/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
const props = withDefaults(defineProps<{
url: string;
rel?: null | string;
navigationBehavior?: MkABehavior;
}>(), {
rel: 'nofollow noopener',
});
const maybeRelativeUrl = maybeMakeRelative(props.url, local);
const self = maybeRelativeUrl !== props.url;
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
const target = self ? undefined : '_blank';
const el = ref<HTMLElement | { $el: HTMLElement }>();

View File

@ -44,7 +44,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<div v-else>
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="maybeRelativeUrl" rel="nofollow noopener" :target="target" :title="url">
<component
:is="self ? 'MkA' : 'a'"
:class="[$style.link, { [$style.compact]: compact }]"
:[attr]="maybeRelativeUrl"
rel="nofollow noopener"
:target="target"
:title="url"
@click="(ev: MouseEvent) => warningExternalWebsite(ev, props.url)"
>
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreviewThumbnail ? '' : { backgroundImage: `url('${thumbnail}')` }">
</div>
<article :class="$style.body">
@ -94,6 +102,7 @@ import MkButton from '@/components/MkButton.vue';
import { transformPlayerUrl } from '@/utility/url-preview.js';
import { store } from '@/store.js';
import { prefer } from '@/preferences.js';
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
import { maybeMakeRelative } from '@@/js/url.js';
type SummalyResult = Awaited<ReturnType<typeof summaly>>;

View File

@ -0,0 +1,141 @@
<!--
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 inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary rounded @click="ok"><i class="ti ti-external-link"></i> {{ i18n.ts.open }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts">
type Result = string | number | true | null;
export type MkUrlWarningDialogDoneEvent = { canceled: true } | { canceled: false, result: Result };
</script>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import { store } from '@/store.js';
import { instanceName } from '@@/js/config.js';
const props = defineProps<{
url: string;
}>();
const emit = defineEmits<{
(ev: 'done', v: MkUrlWarningDialogDoneEvent): 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 MkUrlWarningDialogDoneEvent);
modal.value?.close();
}
async function ok() {
const result = true;
if (!store.s.trustedDomains.includes(domain.value) && trustThisDomain.value) {
store.set('trustedDomains', store.s.trustedDomains.concat(domain.value));
}
done(false, result);
}
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
onMounted(() => {
window.document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.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(--MI_THEME-panel);
border-radius: 16px;
}
.header {
display: flex;
align-items: center;
gap: 0.75em;
}
.icon {
font-size: 18px;
color: var(--MI_THEME-warn);
}
.title {
font-weight: bold;
font-size: 1.1em;
}
.urlAddress {
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--MI_THEME-divider);
overflow-x: auto;
white-space: nowrap;
}
.buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: right;
}
</style>

View File

@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="maybeRelativeUrl" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
@contextmenu.stop="() => {}"
@click="(ev: MouseEvent) => warningExternalWebsite(ev, props.url)"
>
<template v-if="!self">
<span :class="$style.schema">{{ schema }}//</span>
@ -33,6 +34,7 @@ import type { MkABehavior } from '@/components/global/MkA.vue';
import * as os from '@/os.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
function safeURIDecode(str: string): string {
try {
@ -48,6 +50,7 @@ const props = withDefaults(defineProps<{
showUrlPreview?: boolean;
navigationBehavior?: MkABehavior;
}>(), {
rel: 'nofollow noopener',
showUrlPreview: true,
});
@ -76,7 +79,7 @@ const pathname = safeURIDecode(url.pathname);
const query = safeURIDecode(url.search);
const hash = safeURIDecode(url.hash);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
const target = self ? undefined : '_blank';
</script>
<style lang="scss" module>

View File

@ -106,6 +106,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['trust', 'url', 'link']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-link"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.trustedLinkUrlPatterns }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="trustedLinkUrlPatterns">
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['silenced', 'servers', 'hosts']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template>
@ -194,6 +208,7 @@ const preservedUsernames = ref(meta.preservedUsernames.join('\n'));
const blockedHosts = ref(meta.blockedHosts.join('\n'));
const silencedHosts = ref(meta.silencedHosts?.join('\n') ?? '');
const mediaSilencedHosts = ref(meta.mediaSilencedHosts.join('\n'));
const trustedLinkUrlPatterns = ref(meta.trustedLinkUrlPatterns.join('\n'));
async function onChange_enableRegistration(value: boolean) {
if (value) {
@ -269,6 +284,14 @@ function save_hiddenTags() {
});
}
function save_trustedLinkUrlPatterns() {
os.apiWithDialog('admin/update-meta', {
trustedLinkUrlPatterns: trustedLinkUrlPatterns.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_blockedHosts() {
os.apiWithDialog('admin/update-meta', {
blockedHosts: blockedHosts.value.split('\n') || [],

View File

@ -119,6 +119,11 @@ export const store = markRaw(new Pizzax('base', {
default: true,
},
trustedDomains: {
where: 'device',
default: [] as string[],
},
//#region TODO: そのうち消す (preferに移行済み)
defaultWithReplies: {
where: 'account',

View File

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { url as local } from '@@/js/config.js';
import { extractDomain } from '@@/js/url.js';
import { instance } from '@/instance.js';
import { store } from '@/store.js';
import * as os from '@/os.js';
import MkUrlWarningDialog from '@/components/MkUrlWarningDialog.vue';
import type { MkUrlWarningDialogDoneEvent } from '@/components/MkUrlWarningDialog.vue';
const isRegExp = /^\/(.+)\/(.*)$/;
export async function warningExternalWebsite(ev: MouseEvent, url: string) {
const domain = extractDomain(url);
const self = (domain == null || url.startsWith(local));
const isTrustedByInstance = self || 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 = domain != null && store.s.trustedDomains.includes(domain);
if (!self && !isTrustedByInstance && !isTrustedByUser) {
ev.preventDefault();
ev.stopPropagation();
const confirm = await new Promise<{ canceled: boolean }>(resolve => {
const { dispose } = os.popup(MkUrlWarningDialog, {
url,
}, {
done: (result: MkUrlWarningDialogDoneEvent) => {
resolve(result ? result : { canceled: true });
},
closed: () => {
dispose();
},
});
});
if (confirm.canceled) return false;
window.open(url, '_blank', 'noopener');
}
return true;
}

View File

@ -5052,6 +5052,14 @@ export interface Locale extends ILocale {
*
*/
"doReaction": string;
/**
*
*/
"trustedLinkUrlPatterns": string;
/**
* AND指定になりOR指定になります
*/
"trustedLinkUrlPatternsDescription": string;
/**
*
*/
@ -5647,6 +5655,10 @@ export interface Locale extends ILocale {
*
*/
"nothingToConfigure": string;
/**
*
*/
"open": string;
"_imageEditing": {
"_vars": {
/**
@ -12023,6 +12035,20 @@ export interface Locale extends ILocale {
*/
"summaryProxyDescription2": string;
};
"_externalNavigationWarning": {
/**
*
*/
"title": string;
/**
* {host}
*/
"description": ParameterizedString<"host">;
/**
*
*/
"trustThisDomain": string;
};
"_mediaControls": {
/**
*

View File

@ -5479,6 +5479,7 @@ export type components = {
dayOfWeek: number;
isSensitive?: boolean;
}[];
trustedLinkUrlPatterns: string[];
/** @default 0 */
notesPerOneAd: number;
enableEmail: boolean;
@ -9469,6 +9470,7 @@ export interface operations {
perUserListTimelineCacheMax: number;
enableReactionsBuffering: boolean;
notesPerOneAd: number;
trustedLinkUrlPatterns: string[];
backgroundImageUrl: string | null;
deeplAuthKey: string | null;
deeplIsPro: boolean;
@ -12822,6 +12824,7 @@ export interface operations {
mediaSilencedHosts?: string[] | null;
/** @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead. */
summalyProxy?: string | null;
trustedLinkUrlPatterns?: string[] | null;
urlPreviewEnabled?: boolean;
urlPreviewAllowRedirect?: boolean;
urlPreviewTimeout?: number;