From 93869a5f34386a7bd6e99df779150733fb1730c4 Mon Sep 17 00:00:00 2001
From: Mar0xy <marie@kaifa.ch>
Date: Tue, 5 Dec 2023 22:19:53 +0100
Subject: [PATCH] add: mark instance as NSFW

Closes transfem-org/Sharkey#197
---
 .../migration/1701809447000-NSFW-Instance.js  | 11 ++++++
 .../core/activitypub/models/ApImageService.ts | 13 ++++++-
 .../core/entities/InstanceEntityService.ts    |  1 +
 packages/backend/src/models/Instance.ts       |  5 +++
 .../models/json-schema/federation-instance.ts |  5 +++
 .../admin/federation/update-instance.ts       | 39 ++++++++++++-------
 .../api/endpoints/federation/instances.ts     |  9 +++++
 .../frontend/src/pages/about.federation.vue   |  3 ++
 .../frontend/src/pages/admin/federation.vue   |  3 ++
 packages/frontend/src/pages/instance-info.vue | 11 ++++++
 packages/misskey-js/src/entities.ts           |  1 +
 11 files changed, 85 insertions(+), 16 deletions(-)
 create mode 100644 packages/backend/migration/1701809447000-NSFW-Instance.js

diff --git a/packages/backend/migration/1701809447000-NSFW-Instance.js b/packages/backend/migration/1701809447000-NSFW-Instance.js
new file mode 100644
index 0000000000..882aa9865d
--- /dev/null
+++ b/packages/backend/migration/1701809447000-NSFW-Instance.js
@@ -0,0 +1,11 @@
+export class NSFWInstance1701809447000 {
+    name = 'NSFWInstance1701809447000'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "instance" ADD "isNSFW" boolean NOT NULL DEFAULT false`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isNSFW"`);
+    }
+}
diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts
index a4cd533892..b7b8acd661 100644
--- a/packages/backend/src/core/activitypub/models/ApImageService.ts
+++ b/packages/backend/src/core/activitypub/models/ApImageService.ts
@@ -5,7 +5,7 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
-import type { DriveFilesRepository } from '@/models/_.js';
+import type { DriveFilesRepository, InstancesRepository } from '@/models/_.js';
 import type { MiRemoteUser } from '@/models/User.js';
 import type { MiDriveFile } from '@/models/DriveFile.js';
 import { MetaService } from '@/core/MetaService.js';
@@ -18,6 +18,7 @@ import { checkHttps } from '@/misc/check-https.js';
 import { ApResolverService } from '../ApResolverService.js';
 import { ApLoggerService } from '../ApLoggerService.js';
 import type { IObject } from '../type.js';
+import { UtilityService } from '@/core/UtilityService.js';
 
 @Injectable()
 export class ApImageService {
@@ -27,10 +28,14 @@ export class ApImageService {
 		@Inject(DI.driveFilesRepository)
 		private driveFilesRepository: DriveFilesRepository,
 
+		@Inject(DI.instancesRepository)
+		private instancesRepository: InstancesRepository,
+
 		private metaService: MetaService,
 		private apResolverService: ApResolverService,
 		private driveService: DriveService,
 		private apLoggerService: ApLoggerService,
+		private utilityService: UtilityService,
 	) {
 		this.logger = this.apLoggerService.logger;
 	}
@@ -68,6 +73,12 @@ export class ApImageService {
 		// 2. or the image is not sensitive
 		const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive);
 
+		const shouldBeSensitive = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(actor.host), isNSFW: true });
+
+		if (shouldBeSensitive) {
+			image.sensitive = true;
+		}
+
 		const file = await this.driveService.uploadFromUrl({
 			url: image.url,
 			user: actor,
diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts
index 7d16a7a80e..515b356dee 100644
--- a/packages/backend/src/core/entities/InstanceEntityService.ts
+++ b/packages/backend/src/core/entities/InstanceEntityService.ts
@@ -48,6 +48,7 @@ export class InstanceEntityService {
 			themeColor: instance.themeColor,
 			infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
 			latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
+			isNSFW: instance.isNSFW,
 		};
 	}
 
diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts
index b225d918d6..4200b1b461 100644
--- a/packages/backend/src/models/Instance.ts
+++ b/packages/backend/src/models/Instance.ts
@@ -144,4 +144,9 @@ export class MiInstance {
 		nullable: true,
 	})
 	public infoUpdatedAt: Date | null;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public isNSFW: boolean;
 }
diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts
index 442e1076f2..ac4b37fb57 100644
--- a/packages/backend/src/models/json-schema/federation-instance.ts
+++ b/packages/backend/src/models/json-schema/federation-instance.ts
@@ -108,5 +108,10 @@ export const packedFederationInstanceSchema = {
 			optional: false, nullable: true,
 			format: 'date-time',
 		},
+		isNSFW: {
+			type: 'boolean',
+			optional: false,
+			nullable: false,
+		},
 	},
 } as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
index 357bf83e87..4db52b1052 100644
--- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
+++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
@@ -23,8 +23,9 @@ export const paramDef = {
 	properties: {
 		host: { type: 'string' },
 		isSuspended: { type: 'boolean' },
+		isNSFW: { type: 'boolean' },
 	},
-	required: ['host', 'isSuspended'],
+	required: ['host'],
 } as const;
 
 @Injectable()
@@ -44,23 +45,31 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				throw new Error('instance not found');
 			}
 
-			await this.federatedInstanceService.update(instance.id, {
-				isSuspended: ps.isSuspended,
-			});
+			if (ps.isSuspended != null) {
+				await this.federatedInstanceService.update(instance.id, {
+					isSuspended: ps.isSuspended,
+				});
 
-			if (instance.isSuspended !== ps.isSuspended) {
-				if (ps.isSuspended) {
-					this.moderationLogService.log(me, 'suspendRemoteInstance', {
-						id: instance.id,
-						host: instance.host,
-					});
-				} else {
-					this.moderationLogService.log(me, 'unsuspendRemoteInstance', {
-						id: instance.id,
-						host: instance.host,
-					});
+				if (instance.isSuspended !== ps.isSuspended) {
+					if (ps.isSuspended) {
+						this.moderationLogService.log(me, 'suspendRemoteInstance', {
+							id: instance.id,
+							host: instance.host,
+						});
+					} else {
+						this.moderationLogService.log(me, 'unsuspendRemoteInstance', {
+							id: instance.id,
+							host: instance.host,
+						});
+					}
 				}
 			}
+
+			if (ps.isNSFW != null) {
+				await this.federatedInstanceService.update(instance.id, {
+					isNSFW: ps.isNSFW,
+				});
+			}
 		});
 	}
 }
diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts
index c8beefa9c7..e143dcfe89 100644
--- a/packages/backend/src/server/api/endpoints/federation/instances.ts
+++ b/packages/backend/src/server/api/endpoints/federation/instances.ts
@@ -40,6 +40,7 @@ export const paramDef = {
 		federating: { type: 'boolean', nullable: true },
 		subscribing: { type: 'boolean', nullable: true },
 		publishing: { type: 'boolean', nullable: true },
+		nsfw: { type: 'boolean', nullable: true },
 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
 		offset: { type: 'integer', default: 0 },
 		sort: { type: 'string' },
@@ -103,6 +104,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				}
 			}
 
+			if (typeof ps.nsfw === 'boolean') {
+				if (ps.nsfw) {
+					query.andWhere('instance.isNSFW = TRUE');
+				} else {
+					query.andWhere('instance.isNSFW = FALSE');
+				}
+			}
+
 			if (typeof ps.silenced === "boolean") {
 				const meta = await this.metaService.fetch(true);
 
diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue
index db4fff4e37..27f7784007 100644
--- a/packages/frontend/src/pages/about.federation.vue
+++ b/packages/frontend/src/pages/about.federation.vue
@@ -17,6 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<option value="federating">{{ i18n.ts.federating }}</option>
 				<option value="subscribing">{{ i18n.ts.subscribing }}</option>
 				<option value="publishing">{{ i18n.ts.publishing }}</option>
+				<option value="nsfw">NSFW</option>
 				<option value="suspended">{{ i18n.ts.suspended }}</option>
 				<option value="silenced">{{ i18n.ts.silence }}</option>
 				<option value="blocked">{{ i18n.ts.blocked }}</option>
@@ -78,6 +79,7 @@ const pagination = {
 			state === 'blocked' ? { blocked: true } :
 			state === 'silenced' ? { silenced: true } :
 			state === 'notResponding' ? { notResponding: true } :
+			state === 'nsfw' ? { nsfw: true } :
 			{}),
 	})),
 } as Paging;
@@ -87,6 +89,7 @@ function getStatus(instance) {
 	if (instance.isBlocked) return 'Blocked';
 	if (instance.isSilenced) return 'Silenced';
 	if (instance.isNotResponding) return 'Error';
+	if (instance.isNSFW) return 'NSFW';
 	return 'Alive';
 }
 </script>
diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue
index 39285deb13..e09d5181c5 100644
--- a/packages/frontend/src/pages/admin/federation.vue
+++ b/packages/frontend/src/pages/admin/federation.vue
@@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<option value="federating">{{ i18n.ts.federating }}</option>
 							<option value="subscribing">{{ i18n.ts.subscribing }}</option>
 							<option value="publishing">{{ i18n.ts.publishing }}</option>
+							<option value="nsfw">NSFW</option>
 							<option value="suspended">{{ i18n.ts.suspended }}</option>
 							<option value="blocked">{{ i18n.ts.blocked }}</option>
 							<option value="silenced">{{ i18n.ts.silence }}</option>
@@ -86,6 +87,7 @@ const pagination = {
 			state === 'blocked' ? { blocked: true } :
 			state === 'silenced' ? { silenced: true } :
 			state === 'notResponding' ? { notResponding: true } :
+			state === 'nsfw' ? { nsfw: true } :
 			{}),
 	})),
 };
@@ -95,6 +97,7 @@ function getStatus(instance) {
 	if (instance.isBlocked) return 'Blocked';
 	if (instance.isSilenced) return 'Silenced';
 	if (instance.isNotResponding) return 'Error';
+	if (instance.isNSFW) return 'NSFW';
 	return 'Alive';
 }
 
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index 51e6939c2d..668e4e61bf 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -37,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<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="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
+					<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch>
 					<MkButton @click="refreshMetadata"><i class="ph-arrows-counter-clockwise ph-bold ph-lg"></i> Refresh metadata</MkButton>
 				</div>
 			</FormSection>
@@ -149,6 +150,7 @@ let instance = $ref<Misskey.entities.Instance | null>(null);
 let suspended = $ref(false);
 let isBlocked = $ref(false);
 let isSilenced = $ref(false);
+let isNSFW = $ref(false);
 let faviconUrl = $ref<string | null>(null);
 
 const usersPagination = {
@@ -172,6 +174,7 @@ async function fetch(): Promise<void> {
 	suspended = instance.isSuspended;
 	isBlocked = instance.isBlocked;
 	isSilenced = instance.isSilenced;
+	isNSFW = instance.isNSFW;
 	faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
 }
 
@@ -201,6 +204,14 @@ async function toggleSuspend(): Promise<void> {
 	});
 }
 
+async function toggleNSFW(): Promise<void> {
+	if (!instance) throw new Error('No instance?');
+	await os.api('admin/federation/update-instance', {
+		host: instance.host,
+		isNSFW: isNSFW,
+	});
+}
+
 function refreshMetadata(): void {
 	if (!instance) throw new Error('No instance?');
 	os.api('admin/federation/refresh-remote-instance-metadata', {
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 05960a5719..1a4509cabf 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -609,6 +609,7 @@ export type Instance = {
 	faviconUrl: string | null;
 	themeColor: string | null;
 	infoUpdatedAt: DateString | null;
+	isNSFW: boolean;
 };
 
 export type Signin = {