Merge branch 'develop' of https://activitypub.software/TransFem-org/Sharkey into feat/instance-admin-ui

This commit is contained in:
PrivateGER 2024-10-06 23:13:10 +02:00
commit fadae347ff
37 changed files with 541 additions and 65 deletions

View file

@ -61,6 +61,9 @@ copyNoteId: "Copy note ID"
copyFileId: "Copy file ID"
copyFolderId: "Copy folder ID"
copyProfileUrl: "Copy profile URL"
trustedLinkUrlPatterns: "Link to external site warning exclusion list"
trustedLinkUrlPatternsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition. Using surrounding keywords with slashes will turn them into a regular expression. If you write only the domain name, it will be a backward match."
open: "Open"
searchUser: "Search for a user"
searchThisUsersNotes: "Search this users notes"
reply: "Reply"
@ -143,12 +146,15 @@ markAsSensitive: "Mark as sensitive"
unmarkAsSensitive: "Unmark as sensitive"
enterFileName: "Enter filename"
mute: "Mute"
muted: "Muted"
unmute: "Unmute"
renoteMute: "Mute Boosts"
renoteMuted: "Boosts muted"
renoteUnmute: "Unmute Boosts"
block: "Block"
unblock: "Unblock"
markAsNSFW: "Mark all media from user as NSFW"
markInstanceAsNSFW: "Mark as NSFW"
suspend: "Suspend"
unsuspend: "Unsuspend"
blockConfirm: "Are you sure that you want to block this account?"
@ -223,6 +229,7 @@ stopActivityDelivery: "Stop sending activities"
blockThisInstance: "Block this instance"
silenceThisInstance: "Silence this instance"
mediaSilenceThisInstance: "Silence media from this instance"
rejectReports: "Reject reports from this instance"
operations: "Operations"
software: "Software"
version: "Version"
@ -263,6 +270,9 @@ noCustomEmojis: "There are no emoji"
noJobs: "There are no jobs"
federating: "Federating"
blocked: "Blocked"
blockedByBase: "This host is blocked implicitly because a base domain is blocked. To unblock this host, first unblock the base domain(s)."
silencedByBase: "This host is silenced implicitly because a base domain is silenced. To un-silence this host, first un-silence the base domain(s)."
mediaSilencedByBase: "This host's media is silenced implicitly because a base domain's media is silenced. To un-silence this host, first un-silence the base domain(s)."
suspended: "Suspended"
all: "All"
subscribing: "Subscribing"
@ -291,7 +301,6 @@ removeAreYouSure: "Are you sure that you want to remove \"{x}\"?"
deleteAreYouSure: "Are you sure that you want to delete \"{x}\"?"
resetAreYouSure: "Really reset?"
areYouSure: "Are you sure?"
confirmRemoteUrl: "Are you sure that you want to go to \"{x}\"?"
saved: "Saved"
messaging: "Chat"
upload: "Upload"
@ -2577,6 +2586,10 @@ _moderationLogTypes:
resetPassword: "Password reset"
suspendRemoteInstance: "Remote instance suspended"
unsuspendRemoteInstance: "Remote instance unsuspended"
setRemoteInstanceNSFW: "Set remote instance as NSFW"
unsetRemoteInstanceNSFW: "Set remote instance as NSFW"
rejectRemoteInstanceReports: "Rejected reports from remote instance"
acceptRemoteInstanceReports: "Accepted reports from remote instance"
updateRemoteInstanceNote: "Moderation note updated for remote instance."
markSensitiveDriveFile: "File marked as sensitive"
unmarkSensitiveDriveFile: "File unmarked as sensitive"
@ -2832,3 +2845,7 @@ _contextMenu:
app: "Application"
appWithShift: "Application with shift key"
native: "Native"
_externalNavigationWarning:
title: "Navigate to an external site"
description: "Leave {host} and go to an external site"
trustThisDomain: "Trust this domain on this device in the future"

74
locales/index.d.ts vendored
View file

@ -260,6 +260,18 @@ export interface Locale extends ILocale {
* URLをコピー
*/
"copyProfileUrl": string;
/**
*
*/
"trustedLinkUrlPatterns": string;
/**
* AND指定になりOR指定になります
*/
"trustedLinkUrlPatternsDescription": string;
/**
*
*/
"open": string;
/**
*
*/
@ -588,6 +600,10 @@ export interface Locale extends ILocale {
*
*/
"mute": string;
/**
* Muted
*/
"muted": string;
/**
*
*/
@ -596,6 +612,10 @@ export interface Locale extends ILocale {
*
*/
"renoteMute": string;
/**
* Boosts muted
*/
"renoteMuted": string;
/**
*
*/
@ -612,6 +632,10 @@ export interface Locale extends ILocale {
* NSFWとしてマークする
*/
"markAsNSFW": string;
/**
* Mark as NSFW
*/
"markInstanceAsNSFW": string;
/**
*
*/
@ -908,6 +932,10 @@ export interface Locale extends ILocale {
*
*/
"mediaSilenceThisInstance": string;
/**
* Reject reports from this instance
*/
"rejectReports": string;
/**
*
*/
@ -1068,6 +1096,18 @@ export interface Locale extends ILocale {
*
*/
"blocked": string;
/**
* This host is blocked implicitly because a base domain is blocked. To unblock this host, first unblock the base domain(s).
*/
"blockedByBase": string;
/**
* This host is silenced implicitly because a base domain is silenced. To un-silence this host, first un-silence the base domain(s).
*/
"silencedByBase": string;
/**
* This host's media is silenced implicitly because a base domain's media is silenced. To un-silence this host, first un-silence the base domain(s).
*/
"mediaSilencedByBase": string;
/**
*
*/
@ -1180,10 +1220,6 @@ export interface Locale extends ILocale {
*
*/
"areYouSure": string;
/**
* {x}
*/
"confirmRemoteUrl": ParameterizedString<"x">;
/**
*
*/
@ -9988,6 +10024,22 @@ export interface Locale extends ILocale {
*
*/
"unsuspendRemoteInstance": string;
/**
* Set remote instance as NSFW
*/
"setRemoteInstanceNSFW": string;
/**
* Set remote instance as NSFW
*/
"unsetRemoteInstanceNSFW": string;
/**
* Rejected reports from remote instance
*/
"rejectRemoteInstanceReports": string;
/**
* Accepted reports from remote instance
*/
"acceptRemoteInstanceReports": string;
/**
*
*/
@ -10929,6 +10981,20 @@ export interface Locale extends ILocale {
*/
"native": string;
};
"_externalNavigationWarning": {
/**
*
*/
"title": string;
/**
* {host}
*/
"description": ParameterizedString<"host">;
/**
*
*/
"trustThisDomain": string;
};
}
declare const locales: {
[lang: string]: Locale;

View file

@ -61,6 +61,9 @@ copyNoteId: "ートIDをコピー"
copyFileId: "ファイルIDをコピー"
copyFolderId: "フォルダーIDをコピー"
copyProfileUrl: "プロフィールURLをコピー"
trustedLinkUrlPatterns: "外部サイトへのリンク警告 除外リスト"
trustedLinkUrlPatternsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。"
open: "開く"
searchUser: "ユーザーを検索"
searchThisUsersNotes: "ユーザーのノートを検索"
reply: "返信"
@ -143,12 +146,15 @@ markAsSensitive: "センシティブとして設定"
unmarkAsSensitive: "センシティブを解除する"
enterFileName: "ファイル名を入力"
mute: "ミュート"
muted: "Muted"
unmute: "ミュート解除"
renoteMute: "ブーストをミュート"
renoteMuted: "Boosts muted"
renoteUnmute: "ブーストのミュートを解除"
block: "ブロック"
unblock: "ブロック解除"
markAsNSFW: "ユーザーのすべてのメディアをNSFWとしてマークする"
markInstanceAsNSFW: "Mark as NSFW"
suspend: "凍結"
unsuspend: "解凍"
blockConfirm: "ブロックしますか?"
@ -223,6 +229,7 @@ stopActivityDelivery: "アクティビティの配送を停止"
blockThisInstance: "このサーバーをブロック"
silenceThisInstance: "サーバーをサイレンス"
mediaSilenceThisInstance: "サーバーをメディアサイレンス"
rejectReports: "Reject reports from this instance"
operations: "操作"
software: "ソフトウェア"
version: "バージョン"
@ -263,6 +270,9 @@ noCustomEmojis: "絵文字はありません"
noJobs: "ジョブはありません"
federating: "連合中"
blocked: "ブロック中"
blockedByBase: "This host is blocked implicitly because a base domain is blocked. To unblock this host, first unblock the base domain(s)."
silencedByBase: "This host is silenced implicitly because a base domain is silenced. To un-silence this host, first un-silence the base domain(s)."
mediaSilencedByBase: "This host's media is silenced implicitly because a base domain's media is silenced. To un-silence this host, first un-silence the base domain(s)."
suspended: "配信停止"
all: "全て"
subscribing: "購読中"
@ -291,7 +301,6 @@ removeAreYouSure: "「{x}」を削除しますか?"
deleteAreYouSure: "「{x}」を削除しますか?"
resetAreYouSure: "リセットしますか?"
areYouSure: "よろしいですか?"
confirmRemoteUrl: "「{x}」を開きますか?"
saved: "保存しました"
messaging: "チャット"
upload: "アップロード"
@ -2644,6 +2653,10 @@ _moderationLogTypes:
resetPassword: "パスワードをリセット"
suspendRemoteInstance: "リモートサーバーを停止"
unsuspendRemoteInstance: "リモートサーバーを再開"
setRemoteInstanceNSFW: "Set remote instance as NSFW"
unsetRemoteInstanceNSFW: "Set remote instance as NSFW"
rejectRemoteInstanceReports: "Rejected reports from remote instance"
acceptRemoteInstanceReports: "Accepted reports from remote instance"
updateRemoteInstanceNote: "リモートサーバーのモデレーションノート更新"
markSensitiveDriveFile: "ファイルをセンシティブ付与"
unmarkSensitiveDriveFile: "ファイルをセンシティブ解除"
@ -2906,3 +2919,8 @@ _contextMenu:
app: "アプリケーション"
appWithShift: "Shiftキーでアプリケーション"
native: "ブラウザのUI"
_externalNavigationWarning:
title: "外部サイトに移動します"
description: "{host}を離れて外部サイトに移動します"
trustThisDomain: "このデバイスで今後このドメインを信頼する"

View file

@ -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"`);
}
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddRejectReports1728177700920 {
name = 'AddRejectReports1728177700920'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" ADD "rejectReports" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "rejectReports"`);
}
}

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import * as Bull from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@ -29,6 +30,7 @@ import { bindThis } from '@/decorators.js';
import type { MiRemoteUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
@ -83,6 +85,7 @@ export class ApInboxService {
private apQuestionService: ApQuestionService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private federatedInstanceService: FederatedInstanceService,
) {
this.logger = this.apLoggerService.logger;
}
@ -530,6 +533,12 @@ export class ApInboxService {
@bindThis
private async flag(actor: MiRemoteUser, activity: IFlag): Promise<string> {
// Make sure the source instance is allowed to send reports.
const instance = await this.federatedInstanceService.fetch(actor.host);
if (instance.rejectReports) {
throw new Bull.UnrecoverableError(`Rejecting report from instance: ${actor.host}`);
}
// objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので
// 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する
const uris = getApIds(activity.object);

View file

@ -526,6 +526,7 @@ export class ApRendererService {
publicKey: this.renderKey(user, keypair, '#main-key'),
isCat: user.isCat,
noindex: user.noindex,
indexable: !user.noindex,
speakAsCat: user.speakAsCat,
attachment: attachment.length ? attachment : undefined,
};

View file

@ -545,6 +545,7 @@ const extension_context_definition = {
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
indexable: 'toot:indexable',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',

View file

@ -587,7 +587,7 @@ export class ApNoteService {
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
return await this.createNote(createFrom, options.resolver, true);
return await this.createNote(createFrom, options.resolver, false);
} finally {
unlock();
}

View file

@ -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,

View file

@ -159,6 +159,11 @@ export class MiInstance {
})
public isNSFW: boolean;
@Column('boolean', {
default: false,
})
public rejectReports: boolean;
@Column('varchar', {
length: 16384, default: '',
})

View file

@ -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[];
}

View file

@ -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;

View file

@ -25,6 +25,7 @@ export const paramDef = {
host: { type: 'string' },
isSuspended: { type: 'boolean' },
isNSFW: { type: 'boolean' },
rejectReports: { type: 'boolean' },
moderationNote: { type: 'string' },
},
required: ['host'],
@ -57,6 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.federatedInstanceService.update(instance.id, {
suspensionState,
isNSFW: ps.isNSFW,
rejectReports: ps.rejectReports,
moderationNote: ps.moderationNote,
});
@ -74,6 +76,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
if (ps.isNSFW != null && instance.isNSFW !== ps.isNSFW) {
const message = ps.rejectReports ? 'setRemoteInstanceNSFW' : 'unsetRemoteInstanceNSFW';
this.moderationLogService.log(me, message, {
id: instance.id,
host: instance.host,
});
}
if (ps.rejectReports != null && instance.rejectReports !== ps.rejectReports) {
const message = ps.rejectReports ? 'rejectRemoteInstanceReports' : 'acceptRemoteInstanceReports';
this.moderationLogService.log(me, message, {
id: instance.id,
host: instance.host,
});
}
if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) {
this.moderationLogService.log(me, 'updateRemoteInstanceNote', {
id: instance.id,

View file

@ -128,7 +128,7 @@ export const meta = {
},
silencedHosts: {
type: 'array',
optional: true,
optional: false,
nullable: false,
items: {
type: 'string',
@ -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,
};
});
}

View file

@ -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);

View file

@ -193,9 +193,9 @@ export class ClientServerService {
icon: meta.iconUrl,
appleTouchIcon: meta.app512IconUrl,
themeColor: meta.themeColor,
serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://launcher.moe/error.png',
infoImageUrl: meta.infoImageUrl ?? 'https://launcher.moe/nothinghere.png',
notFoundImageUrl: meta.notFoundImageUrl ?? 'https://launcher.moe/missingpage.webp',
serverErrorImageUrl: meta.serverErrorImageUrl ?? '/status/error.png',
infoImageUrl: meta.infoImageUrl ?? '/status/nothinghere.png',
notFoundImageUrl: meta.notFoundImageUrl ?? '/status/missingpage.webp',
instanceUrl: this.config.url,
randomMOTD: this.config.customMOTD ? this.config.customMOTD[Math.floor(Math.random() * this.config.customMOTD.length)] : undefined,
metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(meta)),

View file

@ -77,8 +77,12 @@ export const moderationLogTypes = [
'deleteGlobalAnnouncement',
'deleteUserAnnouncement',
'resetPassword',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'suspendRemoteInstance',
'unsuspendRemoteInstance',
'rejectRemoteInstanceReports',
'acceptRemoteInstanceReports',
'updateRemoteInstanceNote',
'markSensitiveDriveFile',
'unmarkSensitiveDriveFile',
@ -227,6 +231,14 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
setRemoteInstanceNSFW: {
id: string;
host: string;
};
unsetRemoteInstanceNSFW: {
id: string;
host: string;
};
suspendRemoteInstance: {
id: string;
host: string;
@ -235,6 +247,14 @@ export type ModerationLogPayloads = {
id: string;
host: string;
};
rejectRemoteInstanceReports: {
id: string;
host: string;
};
acceptRemoteInstanceReports: {
id: string;
host: string;
};
updateRemoteInstanceNote: {
id: string;
host: string;

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -216,19 +216,25 @@ export async function mainBoot() {
claimAchievement('collectAchievements30');
}
if (!claimedAchievements.includes('justPlainLucky')) {
window.setInterval(() => {
if (Math.floor(Math.random() * 20000) === 0) {
claimAchievement('justPlainLucky');
}
}, 1000 * 10);
}
if (!claimedAchievements.includes('client30min')) {
window.setTimeout(() => {
claimAchievement('client30min');
}, 1000 * 60 * 30);
}
if (!claimedAchievements.includes('client60min')) {
window.setTimeout(() => {
claimAchievement('client60min');
}, 1000 * 60 * 60);
}
// 邪魔
//const lastUsed = miLocalStorage.getItem('lastUsed');

View file

@ -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>

View file

@ -630,11 +630,22 @@ async function onPaste(ev: ClipboardEvent) {
if (paste.length > 1000) {
ev.preventDefault();
os.confirm({
type: 'info',
os.actions({
type: 'question',
text: i18n.ts.attachAsFileQuestion,
}).then(({ canceled }) => {
if (canceled) {
actions: [
{
value: 'yes',
text: i18n.ts.yes,
primary: true,
},
{
value: 'no',
text: i18n.ts.no,
},
],
}).then(({ result }) => {
if (result !== 'yes') {
insertTextAtCursor(textareaEl.value, paste);
return;
}

View 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>

View file

@ -142,6 +142,7 @@ function showMenu(ev: MouseEvent) {
height: 32px;
border-radius: var(--radius-sm);
font-size: 18px;
z-index: 50;
}
.mainFg {

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]="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;

View file

@ -160,9 +160,9 @@ export const ROLE_POLICIES = [
export const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
export const CURRENT_STICKY_BOTTOM = 'CURRENT_STICKY_BOTTOM';
export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://launcher.moe/error.png';
export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://launcher.moe/missingpage.webp';
export const DEFAULT_INFO_IMAGE_URL = 'https://launcher.moe/nothinghere.png';
export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/status/error.png';
export const DEFAULT_NOT_FOUND_IMAGE_URL = '/status/missingpage.webp';
export const DEFAULT_INFO_IMAGE_URL = '/status/nothinghere.png';
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade', 'followmouse'];
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {

View file

@ -20,7 +20,7 @@
worker-src 'self';
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org;
frame-src *;"

View file

@ -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);
});

View file

@ -23,6 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
'markSensitiveDriveFile',
'resetPassword',
'suspendRemoteInstance',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'rejectRemoteInstanceReports',
'acceptRemoteInstanceReports',
].includes(log.type),
[$style.logRed]: [
'suspend',
@ -61,6 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'unmarkSensitiveDriveFile'">: @{{ log.info.fileUserUsername }}{{ log.info.fileUserHost ? '@' + log.info.fileUserHost : '' }}</span>
<span v-else-if="log.type === 'suspendRemoteInstance'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'unsuspendRemoteInstance'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'setRemoteInstanceNSFW'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'unsetRemoteInstanceNSFW'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'rejectRemoteInstanceReports'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'acceptRemoteInstanceReports'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'createGlobalAnnouncement'">: {{ log.info.announcement.title }}</span>
<span v-else-if="log.type === 'updateGlobalAnnouncement'">: {{ log.info.before.title }}</span>
<span v-else-if="log.type === 'deleteGlobalAnnouncement'">: {{ log.info.announcement.title }}</span>

View file

@ -49,10 +49,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="suspensionState === 'none'" inline :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton>
<MkButton v-if="suspensionState !== 'none'" inline :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
</div>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch>
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
<MkInfo v-if="isBaseBlocked" warn>{{ i18n.ts.blockedByBase }}</MkInfo>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance || isBaseBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch>
<MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch>
<MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo>
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
<MkTextarea v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template>
@ -160,6 +164,7 @@ import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
import { dateString } from '@/filters/date.js';
import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
const props = defineProps<{
host: string;
@ -174,10 +179,26 @@ const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'au
const isBlocked = ref(false);
const isSilenced = ref(false);
const isNSFW = ref(false);
const rejectReports = ref(false);
const isMediaSilenced = ref(false);
const faviconUrl = ref<string | null>(null);
const moderationNote = ref('');
const baseDomains = computed(() => {
const domains: string[] = [];
const parts = props.host.toLowerCase().split('.');
for (let s = 1; s < parts.length; s++) {
const domain = parts.slice(s).join('.');
domains.push(domain);
}
return domains;
});
const isBaseBlocked = computed(() => meta.value && baseDomains.value.some(d => meta.value?.blockedHosts.includes(d)));
const isBaseSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.silencedHosts.includes(d)));
const isBaseMediaSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.mediaSilencedHosts.includes(d)));
const usersPagination = {
endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
limit: 10,
@ -204,6 +225,7 @@ async function fetch(): Promise<void> {
isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
isNSFW.value = instance.value?.isNSFW ?? false;
rejectReports.value = instance.value?.rejectReports ?? false;
isMediaSilenced.value = instance.value?.isMediaSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
moderationNote.value = instance.value?.moderationNote ?? '';
@ -264,6 +286,14 @@ async function toggleNSFW(): Promise<void> {
});
}
async function toggleRejectReports(): Promise<void> {
if (!instance.value) throw new Error('No instance?');
await misskeyApi('admin/federation/update-instance', {
host: instance.value.host,
rejectReports: rejectReports.value,
});
}
function refreshMetadata(): void {
if (!instance.value) throw new Error('No instance?');
misskeyApi('admin/federation/refresh-remote-instance-metadata', {

View file

@ -30,7 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</div>
</div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
<div v-if="$i && $i.id != user.id" class="info-badges">
<span v-if="user.isFollowed">{{ i18n.ts.followsYou }}</span>
<span v-if="user.isMuted">{{ i18n.ts.muted }}</span>
<span v-if="user.isRenoteMuted">{{ i18n.ts.renoteMuted }}</span>
<span v-if="user.isBlocking">{{ i18n.ts.blocked }}</span>
</div>
<div class="actions">
<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
@ -445,10 +450,15 @@ onUnmounted(() => {
background: linear-gradient(transparent, rgba(#000, 0.7));
}
> .followed {
> .info-badges {
position: absolute;
top: 12px;
left: 12px;
display: flex;
flex-direction: row;
> * {
padding: 4px 8px;
color: #fff;
background: rgba(0, 0, 0, 0.7);
@ -456,6 +466,11 @@ onUnmounted(() => {
border-radius: var(--radius-sm);
}
> :not(:first-child) {
margin-left: 8px;
}
}
> .actions {
position: absolute;
top: 12px;

View file

@ -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');
})();
warningExternalWebsite(url.value);
}),
'Plugin:config': values.OBJ(config),
};

View 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');
}

View file

@ -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',

View file

@ -5098,6 +5098,7 @@ export type components = {
* @enum {string}
*/
noteSearchableScope: 'local' | 'global';
trustedLinkUrlPatterns: string[];
};
MetaDetailedOnly: {
features?: {
@ -5199,7 +5200,7 @@ export type operations = {
enableEmail: boolean;
enableServiceWorker: boolean;
translatorAvailable: boolean;
silencedHosts?: string[];
silencedHosts: string[];
mediaSilencedHosts: string[];
pinnedUsers: string[];
hiddenTags: string[];
@ -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;
};
};
};