Merge branch 'develop' of https://activitypub.software/TransFem-org/Sharkey into feat/instance-admin-ui
This commit is contained in:
commit
fadae347ff
37 changed files with 541 additions and 65 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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -158,7 +158,12 @@ export class MiInstance {
|
|||
default: false,
|
||||
})
|
||||
public isNSFW: boolean;
|
||||
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public rejectReports: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 16384, default: '',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
BIN
packages/frontend/assets/status/error.png
Normal file
BIN
packages/frontend/assets/status/error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
packages/frontend/assets/status/missingpage.webp
Normal file
BIN
packages/frontend/assets/status/missingpage.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
packages/frontend/assets/status/nothinghere.png
Normal file
BIN
packages/frontend/assets/status/nothinghere.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
|
|
@ -216,19 +216,25 @@ export async function mainBoot() {
|
|||
claimAchievement('collectAchievements30');
|
||||
}
|
||||
|
||||
window.setInterval(() => {
|
||||
if (Math.floor(Math.random() * 20000) === 0) {
|
||||
claimAchievement('justPlainLucky');
|
||||
}
|
||||
}, 1000 * 10);
|
||||
if (!claimedAchievements.includes('justPlainLucky')) {
|
||||
window.setInterval(() => {
|
||||
if (Math.floor(Math.random() * 20000) === 0) {
|
||||
claimAchievement('justPlainLucky');
|
||||
}
|
||||
}, 1000 * 10);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
claimAchievement('client30min');
|
||||
}, 1000 * 60 * 30);
|
||||
if (!claimedAchievements.includes('client30min')) {
|
||||
window.setTimeout(() => {
|
||||
claimAchievement('client30min');
|
||||
}, 1000 * 60 * 30);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
claimAchievement('client60min');
|
||||
}, 1000 * 60 * 60);
|
||||
if (!claimedAchievements.includes('client60min')) {
|
||||
window.setTimeout(() => {
|
||||
claimAchievement('client60min');
|
||||
}, 1000 * 60 * 60);
|
||||
}
|
||||
|
||||
// 邪魔
|
||||
//const lastUsed = miLocalStorage.getItem('lastUsed');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -142,6 +142,7 @@ function showMenu(ev: MouseEvent) {
|
|||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.mainFg {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[]> = {
|
||||
|
|
|
|||
|
|
@ -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 *;"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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,15 +450,25 @@ onUnmounted(() => {
|
|||
background: linear-gradient(transparent, rgba(#000, 0.7));
|
||||
}
|
||||
|
||||
> .followed {
|
||||
> .info-badges {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 4px 8px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
font-size: 0.7em;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
> * {
|
||||
padding: 4px 8px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
font-size: 0.7em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .actions {
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue