feat : サーバーサイレンスを追加

This commit is contained in:
mattyatea 2023-10-14 11:31:19 +09:00
parent 096fa16c4c
commit a92ef26cbb
15 changed files with 305 additions and 115 deletions

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class InstanceSilence1697247230117 {
name = 'InstanceSilence1697247230117'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "silencedHosts" character varying(1024) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" DROP NOT NULL`);
}
}

View File

@ -5,7 +5,7 @@
import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { IsNull } from 'typeorm'; import {DataSource, IsNull } from 'typeorm';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
@ -29,6 +29,8 @@ import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { AccountMoveService } from '@/core/AccountMoveService.js'; import { AccountMoveService } from '@/core/AccountMoveService.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
import { shouldSilenceInstance } from "@/misc/should-block-instance.js";
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -52,6 +54,9 @@ export class UserFollowingService implements OnModuleInit {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(DI.db)
private db: DataSource,
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -121,12 +126,14 @@ export class UserFollowingService implements OnModuleInit {
// フォロー対象が鍵アカウントである or // フォロー対象が鍵アカウントである or
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである or
// フォロワーがローカルユーザーであり、フォロー対象がサイレンスされているサーバーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
if ( if (
followee.isLocked || followee.isLocked ||
(followeeProfile.carefulBot && follower.isBot) || (followeeProfile.carefulBot && follower.isBot) ||
(this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') ||
( this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && await shouldSilenceInstance(follower.host,this.db))
) { ) {
let autoAccept = false; let autoAccept = false;

View File

@ -3,17 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Injectable } from '@nestjs/common'; import {Inject, Injectable} from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js'; import type { } from '@/models/Blocking.js';
import type { MiInstance } from '@/models/Instance.js'; import type { MiInstance } from '@/models/Instance.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UtilityService } from '../UtilityService.js'; import { UtilityService } from '../UtilityService.js';
import {shouldSilenceInstance} from "@/misc/should-block-instance.js";
import { DataSource } from 'typeorm';
import {DI} from "@/di-symbols.js";
@Injectable() @Injectable()
export class InstanceEntityService { export class InstanceEntityService {
constructor( constructor(
@Inject(DI.db)
private db: DataSource,
private metaService: MetaService, private metaService: MetaService,
private utilityService: UtilityService, private utilityService: UtilityService,
@ -43,6 +49,7 @@ export class InstanceEntityService {
description: instance.description, description: instance.description,
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail, maintainerEmail: instance.maintainerEmail,
isSilenced: await shouldSilenceInstance(instance.host,this.db),
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl, faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor, themeColor: instance.themeColor,

View File

@ -0,0 +1,40 @@
import { DataSource } from 'typeorm';
import { MiMeta } from "@/models/Meta.js";
let cache: MiMeta;
export async function fetchMeta(noCache = false , db: DataSource): Promise<MiMeta> {
if (!noCache && cache) return cache;
return await db.transaction(async (transactionalEntityManager) => {
// New IDs are prioritized because multiple records may have been created due to past bugs.
const metas = await transactionalEntityManager.find(MiMeta, {
order: {
id: "DESC",
},
});
const meta = metas[0];
if (meta) {
cache = meta;
return meta;
} else {
// If fetchMeta is called at the same time when meta is empty, this part may be called at the same time, so use fail-safe upsert.
const saved = await transactionalEntityManager
.upsert(
MiMeta,
{
id: "x",
},
["id"],
)
.then((x) =>
transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0]),
);
cache = saved;
return saved;
}
});
}

View File

@ -0,0 +1,15 @@
import { fetchMeta } from "@/misc/fetch-meta.js";
import type { MiInstance } from "@/models/Instance.js";
import type { MiMeta } from "@/models/Meta.js";
import { DataSource } from "typeorm";
export async function shouldSilenceInstance(
host: MiInstance["host"],
db : DataSource,
meta?: MiMeta,
): Promise<boolean> {
const { silencedHosts } = meta ?? (await fetchMeta(true,db));
return silencedHosts.some(
(limitedHost: string) => host === limitedHost || host.endsWith(`.${limitedHost}`),
);
}

View File

@ -76,6 +76,11 @@ export class MiMeta {
}) })
public sensitiveWords: string[]; public sensitiveWords: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public silencedHosts: string[];
@Column('varchar', { @Column('varchar', {
length: 1024, length: 1024,
nullable: true, nullable: true,

View File

@ -93,6 +93,11 @@ export const packedFederationInstanceSchema = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
isSilenced: {
type: "boolean",
optional: false,
nullable: false,
},
infoUpdatedAt: { infoUpdatedAt: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View File

@ -105,6 +105,16 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
silencedHosts: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false,
},
},
pinnedUsers: { pinnedUsers: {
type: 'array', type: 'array',
optional: false, nullable: false, optional: false, nullable: false,
@ -367,6 +377,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
pinnedUsers: instance.pinnedUsers, pinnedUsers: instance.pinnedUsers,
hiddenTags: instance.hiddenTags, hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
silencedHosts: instance.silencedHosts,
sensitiveWords: instance.sensitiveWords, sensitiveWords: instance.sensitiveWords,
preservedUsernames: instance.preservedUsernames, preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey, hcaptchaSecretKey: instance.hcaptchaSecretKey,

View File

@ -20,18 +20,26 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
disableRegistration: {type: 'boolean', nullable: true}, disableRegistration: {type: 'boolean', nullable: true},
pinnedUsers: { type: 'array', nullable: true, items: { pinnedUsers: {
type: 'array', nullable: true, items: {
type: 'string', type: 'string',
} }, }
hiddenTags: { type: 'array', nullable: true, items: { },
hiddenTags: {
type: 'array', nullable: true, items: {
type: 'string', type: 'string',
} }, }
blockedHosts: { type: 'array', nullable: true, items: { },
blockedHosts: {
type: 'array', nullable: true, items: {
type: 'string', type: 'string',
} }, }
sensitiveWords: { type: 'array', nullable: true, items: { },
sensitiveWords: {
type: 'array', nullable: true, items: {
type: 'string', type: 'string',
} }, }
},
themeColor: {type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$'}, themeColor: {type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$'},
mascotImageUrl: {type: 'string', nullable: true}, mascotImageUrl: {type: 'string', nullable: true},
bannerUrl: {type: 'string', nullable: true}, bannerUrl: {type: 'string', nullable: true},
@ -67,9 +75,11 @@ export const paramDef = {
proxyAccountId: {type: 'string', format: 'misskey:id', nullable: true}, proxyAccountId: {type: 'string', format: 'misskey:id', nullable: true},
maintainerName: {type: 'string', nullable: true}, maintainerName: {type: 'string', nullable: true},
maintainerEmail: {type: 'string', nullable: true}, maintainerEmail: {type: 'string', nullable: true},
langs: { type: 'array', items: { langs: {
type: 'array', items: {
type: 'string', type: 'string',
} }, }
},
summalyProxy: {type: 'string', nullable: true}, summalyProxy: {type: 'string', nullable: true},
deeplAuthKey: {type: 'string', nullable: true}, deeplAuthKey: {type: 'string', nullable: true},
deeplIsPro: {type: 'boolean'}, deeplIsPro: {type: 'boolean'},
@ -115,6 +125,13 @@ export const paramDef = {
perUserHomeTimelineCacheMax: {type: 'integer'}, perUserHomeTimelineCacheMax: {type: 'integer'},
perUserListTimelineCacheMax: {type: 'integer'}, perUserListTimelineCacheMax: {type: 'integer'},
notesPerOneAd: {type: 'integer'}, notesPerOneAd: {type: 'integer'},
silencedHosts: {
type: "array",
nullable: true,
items: {
type: "string",
},
},
}, },
required: [], required: [],
} as const; } as const;
@ -147,7 +164,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (Array.isArray(ps.sensitiveWords)) { if (Array.isArray(ps.sensitiveWords)) {
set.sensitiveWords = ps.sensitiveWords.filter(Boolean); set.sensitiveWords = ps.sensitiveWords.filter(Boolean);
} }
if (Array.isArray(ps.silencedHosts)) {
let lastValue = "";
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
const lv = lastValue;
lastValue = h;
return h !== "" && h !== lv && !set.blockedHosts?.includes(h);
});
}
if (ps.themeColor !== undefined) { if (ps.themeColor !== undefined) {
set.themeColor = ps.themeColor; set.themeColor = ps.themeColor;
} }

View File

@ -36,6 +36,7 @@ export const paramDef = {
blocked: { type: 'boolean', nullable: true }, blocked: { type: 'boolean', nullable: true },
notResponding: { type: 'boolean', nullable: true }, notResponding: { type: 'boolean', nullable: true },
suspended: { type: 'boolean', nullable: true }, suspended: { type: 'boolean', nullable: true },
silenced: { type: "boolean", nullable: true },
federating: { type: 'boolean', nullable: true }, federating: { type: 'boolean', nullable: true },
subscribing: { type: 'boolean', nullable: true }, subscribing: { type: 'boolean', nullable: true },
publishing: { type: 'boolean', nullable: true }, publishing: { type: 'boolean', nullable: true },
@ -102,6 +103,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
if (typeof ps.silenced === "boolean") {
const meta = await this.metaService.fetch(true);
if (ps.silenced) {
if (meta.silencedHosts.length === 0) {
return [];
}
query.andWhere("instance.host IN (:...silences)", {
silences: meta.silencedHosts,
});
} else if (meta.silencedHosts.length > 0) {
query.andWhere("instance.host NOT IN (:...silences)", {
silences: meta.silencedHosts,
});
}
}
if (typeof ps.federating === 'boolean') { if (typeof ps.federating === 'boolean') {
if (ps.federating) { if (ps.federating) {
query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))');

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]"> <div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended , blue:instance.isSilenced }]">
<img class="icon" :src="getInstanceIcon(instance)" alt="" loading="lazy"/> <img class="icon" :src="getInstanceIcon(instance)" alt="" loading="lazy"/>
<div class="body"> <div class="body">
<span class="host">{{ instance.name ?? instance.host }}</span> <span class="host">{{ instance.name ?? instance.host }}</span>
@ -89,6 +89,12 @@ function getInstanceIcon(instance): string {
height: 30px; height: 30px;
} }
&:global(.blue) {
--c: rgba(0, 42, 255, 0.15);
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
background-size: 16px 16px;
}
&:global(.yellow) { &:global(.yellow) {
--c: rgb(255 196 0 / 15%); --c: rgb(255 196 0 / 15%);
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);

View File

@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="subscribing">{{ i18n.ts.subscribing }}</option> <option value="subscribing">{{ i18n.ts.subscribing }}</option>
<option value="publishing">{{ i18n.ts.publishing }}</option> <option value="publishing">{{ i18n.ts.publishing }}</option>
<option value="suspended">{{ i18n.ts.suspended }}</option> <option value="suspended">{{ i18n.ts.suspended }}</option>
<option value="silenced">{{ i18n.ts.silence }}</option>
<option value="blocked">{{ i18n.ts.blocked }}</option> <option value="blocked">{{ i18n.ts.blocked }}</option>
<option value="notResponding">{{ i18n.ts.notResponding }}</option> <option value="notResponding">{{ i18n.ts.notResponding }}</option>
</MkSelect> </MkSelect>
@ -75,6 +76,7 @@ const pagination = {
state === 'publishing' ? { publishing: true } : state === 'publishing' ? { publishing: true } :
state === 'suspended' ? { suspended: true } : state === 'suspended' ? { suspended: true } :
state === 'blocked' ? { blocked: true } : state === 'blocked' ? { blocked: true } :
state === 'silenced' ? {silenced: true} :
state === 'notResponding' ? { notResponding: true } : state === 'notResponding' ? { notResponding: true } :
{}), {}),
})), })),
@ -83,6 +85,7 @@ const pagination = {
function getStatus(instance) { function getStatus(instance) {
if (instance.isSuspended) return 'Suspended'; if (instance.isSuspended) return 'Suspended';
if (instance.isBlocked) return 'Blocked'; if (instance.isBlocked) return 'Blocked';
if (instance.isSilenced) return 'Silenced'
if (instance.isNotResponding) return 'Error'; if (instance.isNotResponding) return 'Error';
return 'Alive'; return 'Alive';
} }

View File

@ -5,14 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><XHeader :actions="headerActions" v-model:tab="tab" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32" > <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32" >
<FormSuspense :p="init"> <FormSuspense :p="init">
<MkTextarea v-model="blockedHosts"> <MkTextarea v-if="tab === 'block'" v-model="blockedHosts">
<span>{{ i18n.ts.blockedInstances }}</span> <span>{{ i18n.ts.blockedInstances }}</span>
<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
</MkTextarea> </MkTextarea>
<MkTextarea v-else-if="tab === 'silence'" v-model="silencedHosts" class="_formBlock">
<span>{{ i18n.ts.silencedInstances }}</span>
<template #caption>{{
i18n.ts.silencedInstancesDescription
}}</template>
</MkTextarea>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</FormSuspense> </FormSuspense>
</MkSpacer> </MkSpacer>
@ -31,15 +36,20 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
let blockedHosts: string = $ref(''); let blockedHosts: string = $ref('');
let silencedHosts: string = $ref("");
let tab = $ref("block");
async function init() { async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
blockedHosts = meta.blockedHosts.join('\n'); blockedHosts = meta.blockedHosts.join('\n');
silencedHosts = meta.silencedHosts.join('\n');
} }
function save() { function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
blockedHosts: blockedHosts.split('\n') || [], blockedHosts: blockedHosts.split('\n') || [],
silencedHosts: silencedHosts.split("\n") || [],
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();
}); });
@ -47,7 +57,18 @@ function save() {
const headerActions = $computed(() => []); const headerActions = $computed(() => []);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => [
{
key: "block",
title: i18n.ts.block,
icon: "ph-prohibit ph-bold ph-lg",
},
{
key: "silence",
title: i18n.ts.silence,
icon: "ph-eye-slash ph-bold ph-lg",
},
]);
definePageMetadata({ definePageMetadata({
title: i18n.ts.instanceBlocking, title: i18n.ts.instanceBlocking,

View File

@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s"> <div class="_gaps_s">
<MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch> <MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> <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.silence }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
</div> </div>
</FormSection> </FormSection>
@ -147,6 +148,7 @@ let meta = $ref<Misskey.entities.AdminInstanceMetadata | null>(null);
let instance = $ref<Misskey.entities.Instance | null>(null); let instance = $ref<Misskey.entities.Instance | null>(null);
let suspended = $ref(false); let suspended = $ref(false);
let isBlocked = $ref(false); let isBlocked = $ref(false);
let isSilenced = $ref(false);
let faviconUrl = $ref<string | null>(null); let faviconUrl = $ref<string | null>(null);
const usersPagination = { const usersPagination = {
@ -169,6 +171,7 @@ async function fetch(): Promise<void> {
}); });
suspended = instance.isSuspended; suspended = instance.isSuspended;
isBlocked = instance.isBlocked; isBlocked = instance.isBlocked;
isSilenced = instance.isSilenced;
faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview'); faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
} }
@ -180,7 +183,14 @@ async function toggleBlock(): Promise<void> {
blockedHosts: isBlocked ? meta.blockedHosts.concat([host]) : meta.blockedHosts.filter(x => x !== host), blockedHosts: isBlocked ? meta.blockedHosts.concat([host]) : meta.blockedHosts.filter(x => x !== host),
}); });
} }
async function toggleSilenced(): Promise<void> {
if (!meta) throw new Error('No meta?');
if (!instance) throw new Error('No instance?');
const { host } = instance;
await os.api('admin/update-meta', {
silencedHosts: isSilenced ? meta.silencedHosts.concat([host]) : meta.silencedHosts.filter(x => x !== host),
});
}
async function toggleSuspend(): Promise<void> { async function toggleSuspend(): Promise<void> {
if (!instance) throw new Error('No instance?'); if (!instance) throw new Error('No instance?');
await os.api('admin/federation/update-instance', { await os.api('admin/federation/update-instance', {

View File

@ -385,6 +385,7 @@ export type InstanceMetadata = LiteInstanceMetadata | DetailedInstanceMetadata;
export type AdminInstanceMetadata = DetailedInstanceMetadata & { export type AdminInstanceMetadata = DetailedInstanceMetadata & {
// TODO: There are more fields. // TODO: There are more fields.
blockedHosts: string[]; blockedHosts: string[];
silencedHosts: string[];
app192IconUrl: string | null; app192IconUrl: string | null;
app512IconUrl: string | null; app512IconUrl: string | null;
manifestJsonOverride: string; manifestJsonOverride: string;
@ -544,6 +545,7 @@ export type Instance = {
lastCommunicatedAt: DateString; lastCommunicatedAt: DateString;
isNotResponding: boolean; isNotResponding: boolean;
isSuspended: boolean; isSuspended: boolean;
isSilenced: boolean;
isBlocked: boolean; isBlocked: boolean;
softwareName: string | null; softwareName: string | null;
softwareVersion: string | null; softwareVersion: string | null;