diff --git a/UPGRADE_NOTES.md b/UPGRADE_NOTES.md index 47ac649c31..4a1ec87b86 100644 --- a/UPGRADE_NOTES.md +++ b/UPGRADE_NOTES.md @@ -1,6 +1,18 @@ # Upgrade Notes -## 2025.X.X +## 2025.5.2 + +### Mark instance as NSFW + +The "Mark instance as NSFW" has been removed in favor of the new "mandatory CW" / "force CW" system. +Moderators can now apply any Content Warning of their choice to all notes from an instance by populating the "Force content warning" field on that instance's info page. +The new Content Warning applies immediately, is retroactive, and does not federate or "infect" replies in a thread. + +The upgrade will automatically set a content warning of "NSFW" for instances that were formerly marked as NSFW, which displays as `[instance name] is flagged: "NSFW"` to users. +The `notes` table is also cleaned up to remove any leftover "Instance is marked as NSFW" content warnings from posts. +Staff can then remove or modify the new CW as usual. + +## 2025.2.2 ### Authorized Fetch @@ -13,6 +25,8 @@ Do not remove it before migration, or else the setting will reset to default (di ### Hellspawns +**Note: this workaround is no longer needed on Sharkey version 2025.5.2 and later, as "Mark instance as NSFW" has been completely rewritten.** + Sharkey versions before 2024.10 suffered from a bug in the "Mark instance as NSFW" feature. When a user from such an instance boosted a note, the boost would be converted to a hellspawn (pure renote with Content Warning). Hellspawns are buggy and do not properly federate, so it may be desirable to correct any that already exist in the database. diff --git a/locales/index.d.ts b/locales/index.d.ts index d6344405b5..4171a41f17 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9216,6 +9216,14 @@ export interface Locale extends ILocale { * Apply mandatory CW on users */ "write:admin:cw-user": string; + /** + * Apply mandatory CW on notes + */ + "write:admin:cw-note": string; + /** + * Apply mandatory CW on instances + */ + "write:admin:cw-instance": string; /** * Silence users */ @@ -10953,13 +10961,13 @@ export interface Locale extends ILocale { */ "setMandatoryCW": string; /** - * Set remote instance as NSFW + * Set content warning for note */ - "setRemoteInstanceNSFW": string; + "setMandatoryCWForNote": string; /** - * Unset remote instance as NSFW + * Set content warning for instance */ - "unsetRemoteInstanceNSFW": string; + "setMandatoryCWForInstance": string; /** * Rejected reports from remote instance */ @@ -12088,6 +12096,26 @@ export interface Locale extends ILocale { * {name} said something in a muted thread */ "userSaysSomethingInMutedThread": ParameterizedString<"name">; + /** + * {name} has been silenced by {host} staff + */ + "silencedUserSaysSomething": ParameterizedString<"name" | "host">; + /** + * {name} has been silenced by {host} staff + */ + "silencedInstanceSaysSomething": ParameterizedString<"name" | "host">; + /** + * {name} is flagged: "{cw}" + */ + "userIsFlaggedAs": ParameterizedString<"name" | "cw">; + /** + * Note is flagged: "{cw}" + */ + "noteIsFlaggedAs": ParameterizedString<"cw">; + /** + * {name} is flagged: "{cw}" + */ + "instanceIsFlaggedAs": ParameterizedString<"name" | "cw">; /** * Mark all media from user as NSFW */ @@ -13038,9 +13066,25 @@ export interface Locale extends ILocale { */ "mandatoryCW": string; /** - * Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end. + * Applies a content warning to all posts created by this user. The forced warnings will appear like a word mute to distinguish them from the author's own content warnings. */ "mandatoryCWDescription": string; + /** + * Force content warning + */ + "mandatoryCWForNote": string; + /** + * Applies an additional content warning to this post. The new warning will appear like a word mute to distinguish it from the author's own content warning. + */ + "mandatoryCWForNoteDescription": string; + /** + * Force content warning + */ + "mandatoryCWForInstance": string; + /** + * Applies a content warning to all posts originating from this instance. The forced warnings will appear like a word mute to distinguish them from the notes' own content warnings. + */ + "mandatoryCWForInstanceDescription": string; /** * Fetch linked note */ diff --git a/packages/backend/migration/1751077195277-add-note-mandatoryCW.js b/packages/backend/migration/1751077195277-add-note-mandatoryCW.js new file mode 100644 index 0000000000..9ebe27d4dd --- /dev/null +++ b/packages/backend/migration/1751077195277-add-note-mandatoryCW.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddNoteMandatoryCW1751077195277 { + name = 'AddNoteMandatoryCW1751077195277' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "mandatoryCW" text`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "mandatoryCW"`); + } +} diff --git a/packages/backend/migration/1751078046239-add-instance-mandatoryCW.js b/packages/backend/migration/1751078046239-add-instance-mandatoryCW.js new file mode 100644 index 0000000000..8a71b6f5ef --- /dev/null +++ b/packages/backend/migration/1751078046239-add-instance-mandatoryCW.js @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddInstanceMandatoryCW1751078046239 { + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" ADD "mandatoryCW" text`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "mandatoryCW"`); + } +} diff --git a/packages/backend/migration/1751236746539-replace-instance-isNSFW.js b/packages/backend/migration/1751236746539-replace-instance-isNSFW.js new file mode 100644 index 0000000000..632fa98d6b --- /dev/null +++ b/packages/backend/migration/1751236746539-replace-instance-isNSFW.js @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ReplaceInstanceIsNSFW1751236746539 { + name = 'ReplaceInstanceIsNSFW1751236746539' + + async up(queryRunner) { + // Data migration + await queryRunner.query(`UPDATE "instance" SET "mandatoryCW" = 'NSFW' WHERE "isNSFW" = true`); + await queryRunner.query(`UPDATE "note" SET "cw" = null WHERE "cw" = 'Instance is marked as NSFW'`); + + // Schema migration + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isNSFW"`); + } + + async down(queryRunner) { + // Schema migration + await queryRunner.query(`ALTER TABLE "instance" ADD "isNSFW" boolean NOT NULL DEFAULT false`); + + // Data migration + await queryRunner.query(`UPDATE "instance" SET "isNSFW" = true WHERE "mandatoryCW" ILIKE '%NSFW%'`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 6839ba0159..f818a65ff8 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -20,6 +20,7 @@ import { EnvService } from '@/core/EnvService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApLogService } from '@/core/ApLogService.js'; import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; +import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AnnouncementService } from './AnnouncementService.js'; @@ -240,6 +241,7 @@ const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisti const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; const $TimeService: Provider = { provide: 'TimeService', useExisting: TimeService }; const $EnvService: Provider = { provide: 'EnvService', useExisting: EnvService }; +const $NoteVisibilityService: Provider = { provide: 'NoteVisibilityService', useExisting: NoteVisibilityService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; @@ -400,6 +402,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp ReversiService, TimeService, EnvService, + NoteVisibilityService, ChartLoggerService, FederationChart, @@ -556,6 +559,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $ReversiService, $TimeService, $EnvService, + $NoteVisibilityService, $ChartLoggerService, $FederationChart, @@ -713,6 +717,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp ReversiService, TimeService, EnvService, + NoteVisibilityService, FederationChart, NotesChart, @@ -867,6 +872,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $ReversiService, $TimeService, $EnvService, + $NoteVisibilityService, $FederationChart, $NotesChart, diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 40e2af1487..ddb0ddb7d2 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -19,6 +19,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { NotePopulationData, NoteVisibilityService, PopulatedNote } from '@/core/NoteVisibilityService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; type TimelineOptions = { untilId: string | null, @@ -29,7 +31,6 @@ type TimelineOptions = { useDbFallback: boolean, redisTimelines: FanoutTimelineName[], noteFilter?: (note: MiNote) => boolean, - alwaysIncludeMyNotes?: boolean; ignoreAuthorFromBlock?: boolean; ignoreAuthorFromMute?: boolean; ignoreAuthorFromInstanceBlock?: boolean; @@ -37,7 +38,9 @@ type TimelineOptions = { excludeReplies?: boolean; excludeBots?: boolean; excludePureRenotes: boolean; + includeMutedNotes?: boolean; ignoreAuthorFromUserSuspension?: boolean; + ignoreAuthorFromUserSilence?: boolean; dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, }; @@ -54,6 +57,8 @@ export class FanoutTimelineEndpointService { private cacheService: CacheService, private fanoutTimelineService: FanoutTimelineService, private utilityService: UtilityService, + private readonly noteVisibilityService: NoteVisibilityService, + private readonly federatedInstanceService: FederatedInstanceService, ) { } @@ -80,86 +85,67 @@ export class FanoutTimelineEndpointService { const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; if (!shouldFallbackToDb) { - let filter = ps.noteFilter ?? (_note => true); - - if (ps.alwaysIncludeMyNotes && ps.me) { - const me = ps.me; - const parentFilter = filter; - filter = (note) => note.userId === me.id || parentFilter(note); - } + let filter: (note: MiNote, populated: PopulatedNote) => boolean = ps.noteFilter ?? (() => true); if (ps.excludeNoFiles) { const parentFilter = filter; - filter = (note) => note.fileIds.length !== 0 && parentFilter(note); + filter = (note, populated) => note.fileIds.length !== 0 && parentFilter(note, populated); } if (ps.excludeReplies) { const parentFilter = filter; - filter = (note) => !isReply(note, ps.me?.id) && parentFilter(note); + filter = (note, populated) => { + if (note.userId !== ps.me?.id && isReply(note, ps.me?.id)) return false; + return parentFilter(note, populated); + }; } if (ps.excludeBots) { const parentFilter = filter; - filter = (note) => !note.user?.isBot && parentFilter(note); + filter = (note, populated) => !note.user?.isBot && parentFilter(note, populated); } if (ps.excludePureRenotes) { const parentFilter = filter; - filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note); + filter = (note, populated) => (!isRenote(note) || isQuote(note)) && parentFilter(note, populated); } - if (ps.me) { - const me = ps.me; - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - userMutedInstances, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(ps.me.id), - this.cacheService.renoteMutingsCache.fetch(ps.me.id), - this.cacheService.userBlockedCache.fetch(ps.me.id), - this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)), - ]); + { + const me = ps.me ? await this.cacheService.findUserById(ps.me.id) : null; + const data = await this.noteVisibilityService.populateData(me); const parentFilter = filter; - filter = (note) => { - if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; - if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; - if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; - if (isInstanceMuted(note, userMutedInstances)) return false; + filter = (note, populated) => { + const { accessible, silence } = this.noteVisibilityService.checkNoteVisibility(populated, me, { data, filters: { includeSilencedAuthor: ps.ignoreAuthorFromUserSilence } }); + if (!accessible || silence) return false; - return parentFilter(note); + return parentFilter(note, populated); }; } { const parentFilter = filter; - filter = (note) => { + filter = (note, populated) => { if (!ps.ignoreAuthorFromInstanceBlock) { - if (note.userInstance?.isBlocked) return false; + if (note.user?.instance?.isBlocked) return false; } - if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false; - if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false; + if (note.userId !== note.renoteUserId && note.renote?.user?.instance?.isBlocked) return false; + if (note.userId !== note.replyUserId && note.reply?.user?.instance?.isBlocked) return false; - return parentFilter(note); + return parentFilter(note, populated); }; } { const parentFilter = filter; - filter = (note) => { - const noteJoined = note as MiNote & { - renoteUser: MiUser | null; - replyUser: MiUser | null; - }; + filter = (note, populated) => { if (!ps.ignoreAuthorFromUserSuspension) { - if (note.user!.isSuspended) return false; + if (note.user?.isSuspended) return false; } - if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false; - if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false; + if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false; + if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false; - return parentFilter(note); + return parentFilter(note, populated); }; } @@ -204,23 +190,117 @@ export class FanoutTimelineEndpointService { return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); } - private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise { + private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote, populated: PopulatedNote) => boolean, idCompare: (a: string, b: string) => number): Promise { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel') - .leftJoinAndSelect('note.userInstance', 'userInstance') - .leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance') - .leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance'); - const notes = (await query.getMany()).filter(noteFilter); + // Needed for populated note + .leftJoinAndSelect('renote.reply', 'renoteReply') + ; - notes.sort((a, b) => idCompare(a.id, b.id)); + const notes = await query.getMany(); - return notes; + const populatedNotes = await this.populateNotes(notes); + return populatedNotes + .filter(({ note, populated }) => noteFilter(note, populated)) + .sort((a, b) => idCompare(a.id, b.id)) + .map(({ note }) => note); + } + + /** + * Given a sample of notes to return, populates the relations from cache and generates a NotePopulationData hint object. + * This is messy and kinda gross, but it allows us to use the synchronous checkNoteVisibility from within the filter callbacks. + */ + private async populateNotes(notes: MiNote[]): Promise<{ id: string, note: MiNote, populated: PopulatedNote }[]> { + // Manually populate user/instance since it's cacheable and avoids many joins. + // These fields *must* be populated or NoteVisibilityService won't work right! + const populationData = await this.populateUsers(notes); + + // This is async, but it should never await because we populate above. + return await Promise.all(notes.map(async note => ({ + id: note.id, + note: note, + populated: await this.noteVisibilityService.populateNote(note, populationData), + }))); + } + + /** + * This does two things: + * 1. Populates the user/instance relations of every note in the object graph. + * 2. Returns fetched note/user/instance maps for use as hint data for NoteVisibilityService. + */ + private async populateUsers(notes: MiNote[]): Promise { + // Enumerate all related data + const allNotes = new Map(); + const usersToFetch = new Set(); + const instancesToFetch = new Set(); + + for (const note of notes) { + // note + allNotes.set(note.id, note); + usersToFetch.add(note.userId); + if (note.userHost) { + instancesToFetch.add(note.userHost); + } + + // note.reply + if (note.reply) { + allNotes.set(note.reply.id, note.reply); + usersToFetch.add(note.reply.userId); + if (note.reply.userHost) { + instancesToFetch.add(note.reply.userHost); + } + } + + // note.renote + if (note.renote) { + allNotes.set(note.renote.id, note.renote); + usersToFetch.add(note.renote.userId); + if (note.renote.userHost) { + instancesToFetch.add(note.renote.userHost); + } + } + + // note.renote.reply + if (note.renote?.reply) { + allNotes.set(note.renote.reply.id, note.renote.reply); + usersToFetch.add(note.renote.reply.userId); + if (note.renote.reply.userHost) { + instancesToFetch.add(note.renote.reply.userHost); + } + } + } + + // Fetch everything and populate users + const [users, instances] = await Promise.all([ + this.cacheService.getUsers(usersToFetch), + this.federatedInstanceService.federatedInstanceCache.fetchMany(instancesToFetch).then(i => new Map(i)), + ]); + for (const [id, user] of Array.from(users)) { + users.set(id, { + ...user, + instance: (user.host && instances.get(user.host)) || null, + }); + } + + // Assign users back to notes + for (const note of notes) { + note.user = users.get(note.userId) ?? null; + if (note.reply) { + note.reply.user = users.get(note.reply.userId) ?? null; + } + if (note.renote) { + note.renote.user = users.get(note.renote.userId) ?? null; + if (note.renote.reply) { + note.renote.reply.user = users.get(note.renote.reply.userId) ?? null; + } + } + } + + // Optimization: return our accumulated data to avoid duplicate lookups later + return { users, instances, notes: allNotes }; } } diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 34df10f0ff..e549dbc93e 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -5,37 +5,72 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { In } from 'typeorm'; import type { InstancesRepository, MiMeta } from '@/models/_.js'; import type { MiInstance } from '@/models/Instance.js'; -import { MemoryKVCache } from '@/misc/cache.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { Serialized } from '@/types.js'; -import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js'; +import { diffArraysSimple } from '@/misc/diff-arrays.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; @Injectable() export class FederatedInstanceService implements OnApplicationShutdown { - private readonly federatedInstanceCache: MemoryKVCache; + public readonly federatedInstanceCache: QuantumKVCache; constructor( - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + @Inject(DI.meta) + private readonly meta: MiMeta, + private utilityService: UtilityService, private idService: IdService, + private readonly internalEventService: InternalEventService, ) { - this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m - this.redisForSub.on('message', this.onMessage); + this.federatedInstanceCache = new QuantumKVCache(this.internalEventService, 'federatedInstance', { + lifetime: 1000 * 60 * 3, // 3 minutes + fetcher: async key => { + const host = this.utilityService.toPuny(key); + let instance = await this.instancesRepository.findOneBy({ host }); + if (instance == null) { + await this.instancesRepository.createQueryBuilder('instance') + .insert() + .values({ + id: this.idService.gen(), + host, + firstRetrievedAt: new Date(), + isBlocked: this.utilityService.isBlockedHost(host), + isSilenced: this.utilityService.isSilencedHost(host), + isMediaSilenced: this.utilityService.isMediaSilencedHost(host), + isAllowListed: this.utilityService.isAllowListedHost(host), + isBubbled: this.utilityService.isBubbledHost(host), + }) + .orIgnore() + .execute(); + + instance = await this.instancesRepository.findOneByOrFail({ host }); + } + return instance; + }, + bulkFetcher: async keys => { + const hosts = keys.map(key => this.utilityService.toPuny(key)); + const instances = await this.instancesRepository.findBy({ host: In(hosts) }); + return instances.map(i => [i.host, i]); + }, + }); + + this.internalEventService.on('metaUpdated', this.onMetaUpdated); } @bindThis public async fetchOrRegister(host: string): Promise { + return this.federatedInstanceCache.fetch(host); + /* host = this.utilityService.toPuny(host); const cached = this.federatedInstanceCache.get(host); @@ -61,12 +96,15 @@ export class FederatedInstanceService implements OnApplicationShutdown { index = await this.instancesRepository.findOneByOrFail({ host }); } - this.federatedInstanceCache.set(host, index); + await this.federatedInstanceCache.set(host, index); return index; + */ } @bindThis - public async fetch(host: string): Promise { + public async fetch(host: string): Promise { + return this.federatedInstanceCache.fetch(host); + /* host = this.utilityService.toPuny(host); const cached = this.federatedInstanceCache.get(host); @@ -75,29 +113,54 @@ export class FederatedInstanceService implements OnApplicationShutdown { const index = await this.instancesRepository.findOneBy({ host }); if (index == null) { - this.federatedInstanceCache.set(host, null); + await this.federatedInstanceCache.set(host, null); return null; } else { - this.federatedInstanceCache.set(host, index); + await this.federatedInstanceCache.set(host, index); return index; } + */ } @bindThis - public async update(id: MiInstance['id'], data: Partial): Promise { + public async update(id: MiInstance['id'], data: QueryDeepPartialEntity): Promise { const result = await this.instancesRepository.createQueryBuilder().update() .set(data) .where('id = :id', { id }) .returning('*') .execute() .then((response) => { - return response.raw[0]; + return response.raw[0] as MiInstance; }); - this.federatedInstanceCache.set(result.host, result); + await this.federatedInstanceCache.set(result.host, result); + + return result; } - private syncCache(before: Serialized, after: Serialized): void { + /** + * Gets all instances in the allowlist (meta.federationHosts). + */ + @bindThis + public async getAllowList(): Promise { + const allowedHosts = new Set(this.meta.federationHosts); + this.meta.blockedHosts.forEach(h => allowedHosts.delete(h)); + + const instances = await this.federatedInstanceCache.fetchMany(this.meta.federationHosts); + return instances.map(i => i[1]); + } + + /** + * Gets all instances in the denylist (meta.blockedHosts). + */ + @bindThis + public async getDenyList(): Promise { + const instances = await this.federatedInstanceCache.fetchMany(this.meta.blockedHosts); + return instances.map(i => i[1]); + } + + // This gets fired *in each process* so don't do anything to trigger cache notifications! + private syncCache(before: MiMeta | undefined, after: MiMeta): void { const changed = diffArraysSimple(before?.blockedHosts, after.blockedHosts) || diffArraysSimple(before?.silencedHosts, after.silencedHosts) || @@ -112,20 +175,13 @@ export class FederatedInstanceService implements OnApplicationShutdown { } @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - if (type === 'metaUpdated') { - this.syncCache(body.before, body.after); - } - } + private async onMetaUpdated(body: { before?: MiMeta; after: MiMeta; }) { + this.syncCache(body.before, body.after); } @bindThis public dispose(): void { - this.redisForSub.off('message', this.onMessage); + this.internalEventService.off('metaUpdated', this.onMetaUpdated); this.federatedInstanceCache.dispose(); } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index bf7d209fef..ea74d3a84e 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -144,6 +144,7 @@ type Option = { url?: string | null; app?: MiApp | null; processErrors?: string[] | null; + mandatoryCW?: string | null; }; export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] }); @@ -414,14 +415,6 @@ export class NoteCreateService implements OnApplicationShutdown { } } - if (user.host && !data.cw) { - await this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { - if (i.isNSFW && !this.isPureRenote(data)) { - data.cw = 'Instance is marked as NSFW'; - } - }); - } - if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) { throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions'); } @@ -485,6 +478,7 @@ export class NoteCreateService implements OnApplicationShutdown { renoteUserHost: data.renote ? data.renote.userHost : null, userHost: user.host, processErrors: data.processErrors, + mandatoryCW: data.mandatoryCW, }); // should really not happen, but better safe than sorry @@ -994,7 +988,7 @@ export class NoteCreateService implements OnApplicationShutdown { // 自分自身のHTL if (note.userHost == null) { - if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { + if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id) || note.userId === user.id) { this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 5b0d1980c3..9e8bb8b4fd 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -118,7 +118,7 @@ type MinimumUser = { uri: MiUser['uri']; }; -type Option = { +export type Option = { createdAt?: Date | null; name?: string | null; text?: string | null; @@ -141,6 +141,7 @@ type Option = { updatedAt?: Date | null; editcount?: boolean | null; processErrors?: string[] | null; + mandatoryCW?: string | null; }; @Injectable() @@ -224,13 +225,7 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - public async edit(user: MiUser & { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, editid: MiNote['id'], data: Option, silent = false): Promise { + public async edit(user: MiUser, editid: MiNote['id'], data: Option, silent = false): Promise { if (!editid) { throw new UnrecoverableError('edit failed: missing editid'); } @@ -379,8 +374,6 @@ export class NoteEditService implements OnApplicationShutdown { if (data.text === '') { data.text = null; } - } else { - data.text = null; } const maxCwLength = user.host == null @@ -395,8 +388,6 @@ export class NoteEditService implements OnApplicationShutdown { if (data.cw === '') { data.cw = null; } - } else { - data.cw = null; } let tags = data.apHashtags; @@ -443,28 +434,23 @@ export class NoteEditService implements OnApplicationShutdown { } } - if (user.host && !data.cw) { - await this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { - if (i.isNSFW && !this.noteCreateService.isPureRenote(data)) { - data.cw = 'Instance is marked as NSFW'; - } - }); - } - if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) { throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions'); } const update: Partial = {}; - if (data.text !== oldnote.text) { + if (data.text !== undefined && data.text !== oldnote.text) { update.text = data.text; } - if (data.cw !== oldnote.cw) { + if (data.cw !== undefined && data.cw !== oldnote.cw) { update.cw = data.cw; } - if (oldnote.hasPoll !== !!data.poll) { + if (data.poll !== undefined && oldnote.hasPoll !== !!data.poll) { update.hasPoll = !!data.poll; } + if (data.mandatoryCW !== undefined && oldnote.mandatoryCW !== data.mandatoryCW) { + update.mandatoryCW = data.mandatoryCW; + } // TODO deep-compare files const filesChanged = oldnote.fileIds.length || data.files?.length; @@ -526,6 +512,7 @@ export class NoteEditService implements OnApplicationShutdown { renoteUserHost: data.renote ? data.renote.userHost : null, userHost: user.host, reactionAndUserPairCache: oldnote.reactionAndUserPairCache, + mandatoryCW: data.mandatoryCW, }); if (data.uri != null) note.uri = data.uri; diff --git a/packages/backend/src/core/NoteVisibilityService.ts b/packages/backend/src/core/NoteVisibilityService.ts new file mode 100644 index 0000000000..0285847cf5 --- /dev/null +++ b/packages/backend/src/core/NoteVisibilityService.ts @@ -0,0 +1,465 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { CacheService } from '@/core/CacheService.js'; +import type { MiNote } from '@/models/Note.js'; +import type { MiUser } from '@/models/User.js'; +import { bindThis } from '@/decorators.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { IdService } from '@/core/IdService.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import type { MiFollowing, MiInstance, NotesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; + +/** + * Visibility level for a given user towards a given post. + */ +export interface NoteVisibilityResult { + /** + * Whether the user has access to view this post. + */ + accessible: boolean; + + /** + * If the user should be shown only a redacted version of the post. + * (see NoteEntityService.hideNote() for details.) + */ + redact: boolean; + + /** + * If false, the note should be visible by default. (normal case) + * If true, the note should be hidden by default. (Silences, mutes, etc.) + * If "timeline", the note should be hidden in timelines only. (following w/o replies) + */ + silence: boolean; +} + +export interface NoteVisibilityFilters { + /** + * If false, exclude replies to other users unless the "include replies to others in timeline" has been enabled for the note's author. + * If true (default), then replies are treated like any other post. + */ + includeReplies?: boolean; + + /** + * If true, treat the note's author as never being silenced. Does not apply to reply or renote targets, unless they're by the same author. + * If false (default), then silence is enforced for all notes. + */ + includeSilencedAuthor?: boolean; +} + +@Injectable() +export class NoteVisibilityService { + constructor( + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, + + private readonly cacheService: CacheService, + private readonly idService: IdService, + private readonly federatedInstanceService: FederatedInstanceService, + ) {} + + @bindThis + public async checkNoteVisibilityAsync(note: MiNote | Packed<'Note'>, user: string | PopulatedMe, opts?: { filters?: NoteVisibilityFilters, hint?: Partial }): Promise { + if (typeof(user) === 'string') { + user = await this.cacheService.findUserById(user); + } + + const populatedNote = await this.populateNote(note, opts?.hint); + const populatedData = await this.populateData(user, opts?.hint ?? {}); + + return this.checkNoteVisibility(populatedNote, user, { filters: opts?.filters, data: populatedData }); + } + + @bindThis + public async populateNote(note: MiNote | Packed<'Note'>, hint?: NotePopulationData, diveReply = true, diveRenote = true): Promise { + const userPromise = this.getNoteUser(note, hint); + + // noinspection ES6MissingAwait + return await awaitAll({ + id: note.id, + threadId: note.threadId ?? note.id, + createdAt: 'createdAt' in note + ? new Date(note.createdAt) + : this.idService.parse(note.id).date, + userId: note.userId, + userHost: userPromise.then(u => u.host), + user: userPromise, + renoteId: note.renoteId ?? null, + renote: diveRenote ? this.getNoteRenote(note, hint) : null, + replyId: note.replyId ?? null, + reply: diveReply ? this.getNoteReply(note, hint) : null, + hasPoll: 'hasPoll' in note ? note.hasPoll : (note.poll != null), + mentions: note.mentions ?? [], + visibleUserIds: note.visibleUserIds ?? [], + visibility: note.visibility, + text: note.text, + cw: note.cw ?? null, + fileIds: note.fileIds ?? [], + }); + } + + private async getNoteUser(note: MiNote | Packed<'Note'>, hint?: NotePopulationData): Promise { + const user = note.user + ?? hint?.users?.get(note.userId) + ?? await this.cacheService.findUserById(note.userId); + + const instance = user.host + ? ( + user.instance + ?? hint?.instances?.get(user.host) + ?? await this.federatedInstanceService.fetchOrRegister(user.host) + ) : null; + + return { + ...user, + makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? null, + makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? null, + requireSigninToViewContents: user.requireSigninToViewContents ?? false, + instance: instance ? { + ...instance, + host: user.host as string, + } : null, + }; + } + + private async getNoteRenote(note: MiNote | Packed<'Note'>, hint?: NotePopulationData): Promise { + if (!note.renoteId) return null; + + const renote = note.renote + ?? hint?.notes?.get(note.renoteId) + ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId }); + + // Renote needs to include the reply! + // This will dive one more time before landing in getNoteReply, which terminates recursion. + // Based on the logic in NoteEntityService.pack() + return await this.populateNote(renote, hint, true, false); + } + + private async getNoteReply(note: MiNote | Packed<'Note'>, hint?: NotePopulationData): Promise { + if (!note.replyId) return null; + + const reply = note.reply + ?? hint?.notes?.get(note.replyId) + ?? await this.notesRepository.findOneByOrFail({ id: note.replyId }); + + return await this.populateNote(reply, hint, false, false); + } + + @bindThis + public async populateData(user: PopulatedMe, hint?: Partial): Promise { + // noinspection ES6MissingAwait + const [ + userBlockers, + userFollowings, + userMutedThreads, + userMutedNotes, + userMutedUsers, + userMutedUserRenotes, + userMutedInstances, + ] = await Promise.all([ + user ? (hint?.userBlockers ?? this.cacheService.userBlockedCache.fetch(user.id)) : null, + user ? (hint?.userFollowings ?? this.cacheService.userFollowingsCache.fetch(user.id)) : null, + user ? (hint?.userMutedThreads ?? this.cacheService.threadMutingsCache.fetch(user.id)) : null, + user ? (hint?.userMutedNotes ?? this.cacheService.noteMutingsCache.fetch(user.id)) : null, + user ? (hint?.userMutedUsers ?? this.cacheService.userMutingsCache.fetch(user.id)) : null, + user ? (hint?.userMutedUserRenotes ?? this.cacheService.renoteMutingsCache.fetch(user.id)) : null, + user ? (hint?.userMutedInstances ?? this.cacheService.userProfileCache.fetch(user.id).then(p => new Set(p.mutedInstances))) : null, + ]); + + return { + userBlockers, + userFollowings, + userMutedThreads, + userMutedNotes, + userMutedUsers, + userMutedUserRenotes, + userMutedInstances, + }; + } + + @bindThis + public checkNoteVisibility(note: PopulatedNote, user: PopulatedMe, opts: { filters?: NoteVisibilityFilters, data: NoteVisibilityData }): NoteVisibilityResult { + // Copy note since we mutate it below + note = { + ...note, + renote: note.renote ? { + ...note.renote, + renote: note.renote.renote ? { ...note.renote.renote } : null, + reply: note.renote.reply ? { ...note.renote.reply } : null, + } : null, + reply: note.reply ? { + ...note.reply, + renote: note.reply.renote ? { ...note.reply.renote } : null, + reply: note.reply.reply ? { ...note.reply.reply } : null, + } : null, + } as PopulatedNote; + + this.syncVisibility(note); + return this.checkNoteVisibilityFor(note, user, opts); + } + + private checkNoteVisibilityFor(note: PopulatedNote, user: PopulatedMe, opts: { filters?: NoteVisibilityFilters, data: NoteVisibilityData }): NoteVisibilityResult { + const accessible = this.isAccessible(note, user, opts.data); + const redact = !accessible || this.shouldRedact(note, user); + const silence = this.shouldSilence(note, user, opts.data, opts.filters); + + // For boosts (pure renotes), we must recurse and pick the lowest common access level. + if (isPopulatedBoost(note)) { + const boostVisibility = this.checkNoteVisibilityFor(note.renote, user, opts); + return { + accessible: accessible && boostVisibility.accessible, + redact: redact || boostVisibility.redact, + silence: silence || boostVisibility.silence, + }; + } + + return { accessible, redact, silence }; + } + + // Based on NoteEntityService.isVisibleForMe + private isAccessible(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData): boolean { + // We can always view our own notes + if (user?.id === note.userId) return true; + + // We can *never* view blocked notes + if (data.userBlockers?.has(note.userId)) return false; + + if (note.visibility === 'specified') { + return this.isAccessibleDM(note, user); + } else if (note.visibility === 'followers') { + return this.isAccessibleFO(note, user, data); + } else { + return true; + } + } + + private isAccessibleDM(note: PopulatedNote, user: PopulatedMe): boolean { + // Must be logged in to view DM + if (user == null) return false; + + // Can be visible to me + if (note.visibleUserIds.includes(user.id)) return true; + + // Otherwise invisible + return false; + } + + private isAccessibleFO(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData): boolean { + // Must be logged in to view FO + if (user == null) return false; + + // Can be a reply to me + if (note.reply?.userId === user.id) return true; + + // Can mention me + if (note.mentions.includes(user.id)) return true; + + // Can be visible to me + if (note.visibleUserIds.includes(user.id)) return true; + + // Can be followed by me + if (data.userFollowings?.has(note.userId)) return true; + + // Can be two remote users, since we can't verify remote->remote following. + if (note.userHost != null && user.host != null) return true; + + // Otherwise invisible + return false; + } + + // Based on NoteEntityService.treatVisibility + @bindThis + public syncVisibility(note: PopulatedNote | Packed<'Note'>): void { + // Make followers-only + if (note.user.makeNotesFollowersOnlyBefore && note.visibility !== 'specified' && note.visibility !== 'followers') { + const followersOnlyBefore = note.user.makeNotesFollowersOnlyBefore * 1000; + const createdAt = new Date(note.createdAt).valueOf(); + + // I don't understand this logic, but I tried to break it out for readability + const followersOnlyOpt1 = followersOnlyBefore <= 0 && (Date.now() - createdAt > 0 - followersOnlyBefore); + const followersOnlyOpt2 = followersOnlyBefore > 0 && (createdAt < followersOnlyBefore); + if (followersOnlyOpt1 || followersOnlyOpt2) { + note.visibility = 'followers'; + } + } + + // Recurse + if (note.renote) { + this.syncVisibility(note.renote); + } + if (note.reply) { + this.syncVisibility(note.reply); + } + } + + // Based on NoteEntityService.hideNote + private shouldRedact(note: PopulatedNote, user: PopulatedMe): boolean { + // Never redact our own notes + if (user?.id === note.userId) return false; + + // Redact if sign-in required + if (note.user.requireSigninToViewContents && !user) return true; + + // Redact if note has expired + if (note.user.makeNotesHiddenBefore) { + const hiddenBefore = note.user.makeNotesHiddenBefore * 1000; + const createdAt = note.createdAt.valueOf(); + + // I don't understand this logic, but I tried to break it out for readability + const hiddenOpt1 = hiddenBefore <= 0 && (Date.now() - createdAt > 0 - hiddenBefore); + const hiddenOpt2 = hiddenBefore > 0 && (createdAt < hiddenBefore); + if (hiddenOpt1 || hiddenOpt2) return true; + } + + // Otherwise don't redact + return false; + } + + // Based on inconsistent logic from all around the app + private shouldSilence(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData, filters: NoteVisibilityFilters | undefined): boolean { + if (this.shouldSilenceForMute(note, data)) { + return true; + } + + if (this.shouldSilenceForSilence(note, user, data, filters?.includeSilencedAuthor ?? false)) { + return true; + } + + if (!filters?.includeReplies && this.shouldSilenceForFollowWithoutReplies(note, user, data)) { + return true; + } + + return false; + } + + private shouldSilenceForMute(note: PopulatedNote, data: NoteVisibilityData): boolean { + // Silence if we've muted the thread + if (data.userMutedThreads?.has(note.threadId)) return true; + + // Silence if we've muted the note + if (data.userMutedNotes?.has(note.id)) return true; + + // Silence if we've muted the user + if (data.userMutedUsers?.has(note.userId)) return true; + + // Silence if we've muted renotes from the user + if (isPopulatedBoost(note) && data.userMutedUserRenotes?.has(note.userId)) return true; + + // Silence if we've muted the instance + if (note.userHost && data.userMutedInstances?.has(note.userHost)) return true; + + // Otherwise don't silence + return false; + } + + private shouldSilenceForSilence(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData, ignoreSilencedAuthor: boolean): boolean { + // Don't silence if it's us + if (note.userId === user?.id) return false; + + // Don't silence if we're following or ignoring the author + if (!data.userFollowings?.has(note.userId) && !ignoreSilencedAuthor) { + // Silence if user is silenced + if (note.user.isSilenced) return true; + + // Silence if user instance is silenced + if (note.user.instance?.isSilenced) return true; + } + + // Silence if renote is silenced + if (note.renote && note.renote.userId !== note.userId && this.shouldSilenceForSilence(note.renote, user, data, false)) return true; + + // Silence if reply is silenced + if (note.reply && note.reply.userId !== note.userId && this.shouldSilenceForSilence(note.reply, user, data, false)) return true; + + // Otherwise don't silence + return false; + } + + private shouldSilenceForFollowWithoutReplies(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData): boolean { + // Don't silence if it's not a reply + if (!note.reply) return false; + + // Don't silence if it's a self-reply + if (note.reply.userId === note.userId) return false; + + // Don't silence if it's a reply to us + if (note.reply.userId === user?.id) return false; + + // Don't silence if it's our post + if (note.userId === user?.id) return false; + + // Don't silence if we follow w/ replies + if (user && data.userFollowings?.get(user.id)?.withReplies) return false; + + // Silence otherwise + return true; + } +} + +export interface NoteVisibilityData extends NotePopulationData { + userBlockers: Set | null; + userFollowings: Map> | null; + userMutedThreads: Set | null; + userMutedNotes: Set | null; + userMutedUsers: Set | null; + userMutedUserRenotes: Set | null; + userMutedInstances: Set | null; +} + +export interface NotePopulationData { + notes?: Map; + users?: Map; + instances?: Map; +} + +// This represents the *requesting* user! +export type PopulatedMe = Pick | null | undefined; + +export interface PopulatedNote { + id: string; + threadId: string; + userId: string; + userHost: string | null; + user: PopulatedUser; + renoteId: string | null; + renote: PopulatedNote | null; + replyId: string | null; + reply: PopulatedNote | null; + mentions: string[]; + visibleUserIds: string[]; + visibility: 'public' | 'followers' | 'home' | 'specified'; + createdAt: Date; + text: string | null; + cw: string | null; + hasPoll: boolean; + fileIds: string[]; +} + +interface PopulatedUser { + id: string; + host: string | null; + instance: PopulatedInstance | null; + isSilenced: boolean; + requireSigninToViewContents: boolean; + makeNotesHiddenBefore: number | null; + makeNotesFollowersOnlyBefore: number | null; +} + +interface PopulatedInstance { + host: string; + isSilenced: boolean; +} + +function isPopulatedBoost(note: PopulatedNote): note is PopulatedNote & { renote: PopulatedNote } { + return note.renoteId != null + && note.replyId == null + && note.text == null + && note.cw == null + && note.fileIds.length === 0 + && !note.hasPoll; +} diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index e1bfe8d3b9..f5d83d5ea8 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -7,7 +7,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, Not, WhereExpressionBuilder } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import { MiInstance } from '@/models/Instance.js'; import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; @@ -81,6 +80,35 @@ export class QueryService { return q; } + /** + * Exclude replies from the queries, used for timelines. + * withRepliesProp can be specified to additionally allow replies when a given property is true. + * Must match logic NoteVisibilityService.shouldSilenceForFollowWithoutReplies. + */ + @bindThis + public generateExcludedRepliesQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null, withRepliesProp?: string): SelectQueryBuilder { + return q + .andWhere(new Brackets(qb => { + if (withRepliesProp) { + // Allow if query specifies it + qb.orWhere(`${withRepliesProp} = true`); + } + + return this + // Allow if we're following w/ replies + .orFollowingUser(qb, ':meId', 'note.userId', true) + // Allow if it's not a reply + .orWhere('note.replyId IS NULL') // 返信ではない + // Allow if it's a self-reply (user replied to themself) + .orWhere('note.replyUserId = note.userId') + // Allow if it's a reply to me + .orWhere('note.replyUserId = :meId') + // Allow if it's my reply + .orWhere('note.userId = :meId'); + })) + .setParameters({ meId: me?.id ?? null }); + } + // ここでいうBlockedは被Blockedの意 @bindThis public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { @@ -107,38 +135,66 @@ export class QueryService { @bindThis public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + // Muted thread + this.andNotMutingThread(q, ':meId', 'coalesce(note.threadId, note.id)'); + + // Muted note + this.andNotMutingNote(q, ':meId', 'note.id'); + + q.andWhere(new Brackets(qb => qb + .orWhere('note.renoteId IS NULL') + .orWhere(new Brackets(qbb => { + // Renote muted thread + this.andNotMutingThread(qbb, ':meId', 'coalesce(renote.threadId, renote.id)'); + + // Renote muted note + this.andNotMutingNote(qbb, ':meId', 'renote.id'); + })))); + return this - .andNotMutingThread(q, ':meId', 'note.id') - .andWhere(new Brackets(qb => this - .orNotMutingThread(qb, ':meId', 'note.threadId') - .orWhere('note.threadId IS NULL'))) + .leftJoin(q, 'note.renote', 'renote') .setParameters({ meId: me.id }); } @bindThis - public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder { - // 投稿の作者をミュートしていない かつ - // 投稿の返信先の作者をミュートしていない かつ - // 投稿の引用元の作者をミュートしていない - return this - .andNotMutingUser(q, ':meId', 'note.userId', exclude) + public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, excludeAuthor = false): SelectQueryBuilder { + if (!excludeAuthor) { + this + // muted user + .andNotMutingUser(q, ':meId', 'note.userId') + // muted host + .andWhere(new Brackets(qb => { + qb.orWhere('note.userHost IS NULL'); + this.orFollowingUser(qb, ':meId', 'note.userId'); + this.orNotMutingInstance(qb, ':meId', 'note.userHost'); + })); + } + + return q + // muted reply user .andWhere(new Brackets(qb => this - .orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude) + .orNotMutingUser(qb, ':meId', 'note.replyUserId') + .orWhere('note.replyUserId = note.userId') .orWhere('note.replyUserId IS NULL'))) + // muted renote user .andWhere(new Brackets(qb => this - .orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude) + .orNotMutingUser(qb, ':meId', 'note.renoteUserId') + .orWhere('note.renoteUserId = note.userId') .orWhere('note.renoteUserId IS NULL'))) - // TODO exclude should also pass a host to skip these instances - // mute instances - .andWhere(new Brackets(qb => this - .andNotMutingInstance(qb, ':meId', 'note.userHost') - .orWhere('note.userHost IS NULL'))) - .andWhere(new Brackets(qb => this - .orNotMutingInstance(qb, ':meId', 'note.replyUserHost') - .orWhere('note.replyUserHost IS NULL'))) - .andWhere(new Brackets(qb => this - .orNotMutingInstance(qb, ':meId', 'note.renoteUserHost') - .orWhere('note.renoteUserHost IS NULL'))) + // muted reply host + .andWhere(new Brackets(qb => { + qb.orWhere('note.replyUserHost IS NULL'); + qb.orWhere('note.replyUserHost = note.userHost'); + this.orFollowingUser(qb, ':meId', 'note.replyUserId'); + this.orNotMutingInstance(qb, ':meId', 'note.replyUserHost'); + })) + // muted renote host + .andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NULL'); + qb.orWhere('note.renoteUserHost = note.userHost'); + this.orFollowingUser(qb, ':meId', 'note.renoteUserId'); + this.orNotMutingInstance(qb, ':meId', 'note.renoteUserHost'); + })) .setParameters({ meId: me.id }); } @@ -154,7 +210,7 @@ export class QueryService { // For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user. @bindThis public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { - // This code must always be synchronized with the checks in Notes.isVisibleForMe. + // This code must always be synchronized with the checks in NoteEntityService.isVisibleForMe. return q.andWhere(new Brackets(qb => { // Public post qb.orWhere('note.visibility = \'public\'') @@ -204,14 +260,15 @@ export class QueryService { @bindThis public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): SelectQueryBuilder { const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this - .leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`) + .leftJoin(q, `note.${key}Instance`, `${key}Instance`) .andWhere(new Brackets(qb => { qb .orWhere(`"${key}Instance" IS NULL`) // local .orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked - if (excludeAuthor) { - qb.orWhere(`note.userId = note.${key}Id`); // author + if (key !== 'user') { + // Don't re-check self-replies and self-renote targets + qb.orWhere(`note.userId = note.${key}Id`); } })); @@ -225,33 +282,119 @@ export class QueryService { } @bindThis - public generateSilencedUserQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { - if (!me) { - return q.andWhere('user.isSilenced = false'); + public generateSilencedUserQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null, excludeAuthor = false): SelectQueryBuilder { + const checkFor = (key: 'user' | 'replyUser' | 'renoteUser', userKey: 'note.user' | 'reply.user' | 'renote.user') => { + // These are de-duplicated, since most call sites already provide some of them. + this.leftJoin(q, `note.${key}Instance`, `${key}Instance`); // note->instance + this.leftJoin(q, userKey, key); // note->user + + q.andWhere(new Brackets(qb => { + // case 1: user does not exist (note is not reply/renote) + qb.orWhere(`note.${key}Id IS NULL`); + + // case 2: user not silenced AND (instance not silenced OR instance is local) + qb.orWhere(new Brackets(qbb => qbb + .andWhere(`"${key}"."isSilenced" = false`) + .andWhere(new Brackets(qbbb => qbbb + .orWhere(`"${key}Instance"."isSilenced" = false`) + .orWhere(`"note"."${key}Host" IS NULL`))))); + + if (me) { + // case 3: we are the author + qb.orWhere(`note.${key}Id = :meId`); + + // case 4: we are following the user + this.orFollowingUser(qb, ':meId', `note.${key}Id`); + } + + // case 5: user is the same + if (key !== 'user') { + qb.orWhere(`note.${key}Id = note.userId`); + } + })); + }; + + const checkForRenote = (_q: WhereExpressionBuilder, key: 'replyUser' | 'renoteUser', userRel: 'renoteReply.user' | 'renoteRenote.user', userAlias: 'renoteReplyUser' | 'renoteRenoteUser') => { + const instanceAlias = `${userAlias}Instance`; + this.leftJoin(q, `renote.${key}Instance`, instanceAlias); // note->instance + this.leftJoin(q, userRel, userAlias); // note->user + + _q.andWhere(new Brackets(qb => { + // case 1: user does not exist (note is not reply/renote) + qb.orWhere(`renote.${key}Id IS NULL`); + + // case 2: user not silenced AND (instance not silenced OR instance is local) + qb.orWhere(new Brackets(qbb => qbb + .andWhere(`"${userAlias}"."isSilenced" = false`) + .andWhere(new Brackets(qbbb => qbbb + .orWhere(`"${instanceAlias}"."isSilenced" = false`) + .orWhere(`"renote"."${key}Host" IS NULL`))))); + + if (me) { + // case 3: we are the author + qb.orWhere(`renote.${key}Id = :meId`); + + // case 4: we are following the user + this.orFollowingUser(qb, ':meId', `renote.${key}Id`); + } + + // case 5: user is the same + qb.orWhere(`renote.${key}Id = renote.userId`); + })); + }; + + // Set parameters only once + if (me) { + q.setParameters({ meId: me.id }); } - return this - .leftJoinInstance(q, 'note.userInstance', 'userInstance') - .andWhere(new Brackets(qb => this - // case 1: we are following the user - .orFollowingUser(qb, ':meId', 'note.userId') - // case 2: user not silenced AND instance not silenced - .orWhere(new Brackets(qbb => qbb - .andWhere(new Brackets(qbbb => qbbb - .orWhere('"userInstance"."isSilenced" = false') - .orWhere('"userInstance" IS NULL'))) - .andWhere('user.isSilenced = false'))))) - .setParameters({ meId: me.id }); + if (!excludeAuthor) { + checkFor('user', 'note.user'); + } + checkFor('replyUser', 'reply.user'); + checkFor('renoteUser', 'renote.user'); + + // Filter for boosts + this.leftJoin(q, 'renote.reply', 'renoteReply'); + this.leftJoin(q, 'renote.renote', 'renoteRenote'); + q.andWhere(new Brackets(qb => this + .orIsNotRenote(qb, 'note') + .orWhere(new Brackets(qbb => { + checkForRenote(qbb, 'replyUser', 'renoteReply.user', 'renoteReplyUser'); + checkForRenote(qbb, 'renoteUser', 'renoteRenote.user', 'renoteRenoteUser'); + })))); + + return q; } /** - * Left-joins an instance in to the query with a given alias and optional condition. - * These calls are de-duplicated - multiple uses of the same alias are skipped. + * Left-joins a relation into the query with a given alias and optional condition. + * These calls are de-duplicated - multiple uses of the same relation+alias are skipped. */ @bindThis - public leftJoinInstance(q: SelectQueryBuilder, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder { + public leftJoin(q: SelectQueryBuilder, relation: string, alias: string, condition?: string): SelectQueryBuilder { // Skip if it's already joined, otherwise we'll get an error - if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) { + const join = q.expressionMap.joinAttributes.find(j => j.alias.name === alias); + if (join) { + const oldRelation = typeof(join.entityOrProperty) === 'function' + ? join.entityOrProperty.name + : join.entityOrProperty; + + const oldQuery = join.condition + ? `JOIN ${oldRelation} AS ${alias} ON ${join.condition}` + : `JOIN ${oldRelation} AS ${alias}`; + const newQuery = condition + ? `JOIN ${relation} AS ${alias} ON ${oldRelation}` + : `JOIN ${relation} AS ${alias}`; + + if (oldRelation !== relation) { + throw new Error(`Query error: cannot add ${newQuery}: alias already used by ${oldQuery}`); + } + + if (join.condition !== condition) { + throw new Error(`Query error: cannot add ${newQuery}: relation already defined with different condition by ${oldQuery}`); + } + } else { q.leftJoin(relation, alias, condition); } @@ -375,27 +518,33 @@ export class QueryService { /** * Adds OR condition that followerProp (user ID) is following followeeProp (user ID). * Both props should be expressions, not raw values. + * If withReplies is set to a boolean, then this method will only count followings with the matching withReplies value. */ @bindThis - public orFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { - return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere'); + public orFollowingUser(q: Q, followerProp: string, followeeProp: string, withReplies?: boolean): Q { + return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere', withReplies); } /** * Adds AND condition that followerProp (user ID) is following followeeProp (user ID). * Both props should be expressions, not raw values. + * If withReplies is set to a boolean, then this method will only count followings with the matching withReplies value. */ @bindThis - public andFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { - return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere'); + public andFollowingUser(q: Q, followerProp: string, followeeProp: string, withReplies?: boolean): Q { + return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere', withReplies); } - private addFollowingUser(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q { + private addFollowingUser(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere', withReplies?: boolean): Q { const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('1') .andWhere(`following.followerId = ${followerProp}`) .andWhere(`following.followeeId = ${followeeProp}`); + if (withReplies !== undefined) { + followingQuery.andWhere('following.withReplies = :withReplies', { withReplies }); + } + return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); }; @@ -560,14 +709,48 @@ export class QueryService { const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') .select('1') .andWhere(`threadMuted.userId = ${muterProp}`) - .andWhere(`threadMuted.threadId = ${muteeProp}`); + .andWhere(`threadMuted.threadId = ${muteeProp}`) + .andWhere('threadMuted.isPostMute = false'); return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters()); } - // Requirements: user replyUser renoteUser must be joined + /** + * Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID). + * Both props should be expressions, not raw values. + */ @bindThis - public generateSuspendedUserQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): void { + public orNotMutingNote(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingNote(q, muterProp, muteeProp, 'orWhere'); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingNote(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingNote(q, muterProp, muteeProp, 'andWhere'); + } + + private excludeMutingNote(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { + const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') + .select('1') + .andWhere(`threadMuted.userId = ${muterProp}`) + .andWhere(`threadMuted.threadId = ${muteeProp}`) + .andWhere('threadMuted.isPostMute = true'); + + return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters()); + } + + @bindThis + public generateSuspendedUserQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): void { + this.leftJoin(q, 'note.user', 'user'); + this.leftJoin(q, 'note.reply', 'reply'); + this.leftJoin(q, 'note.renote', 'renote'); + this.leftJoin(q, 'reply.user', 'replyUser'); + this.leftJoin(q, 'renote.user', 'renoteUser'); + if (excludeAuthor) { const brakets = (user: string) => new Brackets(qb => qb .where(`note.${user}Id IS NULL`) diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index bb56c6c745..478438b042 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -31,6 +31,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; import { CacheService } from '@/core/CacheService.js'; +import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import type { DataSource } from 'typeorm'; const FALLBACK = '\u2764'; @@ -108,6 +109,7 @@ export class ReactionService { private notificationService: NotificationService, private perUserReactionsChart: PerUserReactionsChart, private readonly cacheService: CacheService, + private readonly noteVisibilityService: NoteVisibilityService, ) { } @@ -122,7 +124,8 @@ export class ReactionService { } // check visibility - if (!await this.noteEntityService.isVisibleForMe(note, user.id, { me: user })) { + const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(note, user); + if (!accessible) { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 60a66d58f7..6e4dd9adca 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -314,6 +314,7 @@ export class SearchService { this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateSuspendedUserQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); @@ -392,6 +393,7 @@ export class SearchService { this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateSuspendedUserQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 74a8b79a89..dd90d147c0 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -127,6 +127,7 @@ function generateDummyNote(override?: Partial): MiNote { renoteUserInstance: null, updatedAt: null, processErrors: [], + mandatoryCW: null, ...override, }; } @@ -400,6 +401,7 @@ export class WebhookTestService { text: note.text, cw: note.cw, userId: note.userId, + userHost: note.userHost ?? null, user: await this.toPackedUserLite(note.user ?? generateDummyUser()), replyId: note.replyId, renoteId: note.renoteId, @@ -408,6 +410,7 @@ export class WebhookTestService { isMutingNote: false, isFavorited: false, isRenoted: false, + bypassSilence: false, visibility: note.visibility, mentions: note.mentions, visibleUserIds: note.visibleUserIds, @@ -450,6 +453,8 @@ export class WebhookTestService { username: user.username, host: user.host, description: 'dummy user', + isSilenced: false, + bypassSilence: false, avatarUrl: user.avatarId == null ? null : user.avatarUrl, avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash, avatarDecorations: user.avatarDecorations.map(it => ({ diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 009d4cbd39..8aaa229466 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -38,6 +38,7 @@ import FederationChart from '@/core/chart/charts/federation.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; import { CacheService } from '@/core/CacheService.js'; +import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -100,6 +101,7 @@ export class ApInboxService { private readonly federationChart: FederationChart, private readonly updateInstanceQueue: UpdateInstanceQueue, private readonly cacheService: CacheService, + private readonly noteVisibilityService: NoteVisibilityService, ) { this.logger = this.apLoggerService.logger; } @@ -367,7 +369,8 @@ export class ApInboxService { const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) }); if (renote == null) return 'announce target is null'; - if (!await this.noteEntityService.isVisibleForMe(renote, actor.id, { me: actor })) { + const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(renote, actor); + if (!accessible) { return 'skip: invalid actor for this activity'; } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index a13abc6369..25ad0852cb 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -34,6 +34,7 @@ import { QueryService } from '@/core/QueryService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { CacheService } from '@/core/CacheService.js'; import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; @@ -75,9 +76,10 @@ export class ApRendererService { private apMfmService: ApMfmService, private mfmService: MfmService, private idService: IdService, - private readonly queryService: QueryService, private utilityService: UtilityService, + private readonly queryService: QueryService, private readonly cacheService: CacheService, + private readonly federatedInstanceService: FederatedInstanceService, ) { } @@ -398,6 +400,8 @@ export class ApRendererService { return ids.map(id => items.find(item => item.id === id)).filter(x => x != null); }; + const instance = author.instance ?? (author.host ? await this.federatedInstanceService.fetch(author.host) : null); + let inReplyTo; let inReplyToNote: MiNote | null; @@ -497,9 +501,15 @@ export class ApRendererService { let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; // Apply mandatory CW, if applicable + if (note.mandatoryCW) { + summary = appendContentWarning(summary, note.mandatoryCW); + } if (author.mandatoryCW) { summary = appendContentWarning(summary, author.mandatoryCW); } + if (instance?.mandatoryCW) { + summary = appendContentWarning(summary, instance.mandatoryCW); + } const { content } = this.apMfmService.getNoteHtml(note, apAppend); diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 8973195f53..51ea4f5ed3 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -75,7 +75,7 @@ export class ApImageService { const shouldBeCached = this.meta.cacheRemoteFiles && (this.meta.cacheRemoteSensitiveFiles || !image.sensitive); await this.federatedInstanceService.fetchOrRegister(actor.host).then(async i => { - if (i.isNSFW) { + if (i.isMediaSilenced) { image.sensitive = true; } }); diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index f1149fe113..1225b6484a 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -63,11 +63,11 @@ export class InstanceEntityService { themeColor: instance.themeColor, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, - isNSFW: instance.isNSFW, rejectReports: instance.rejectReports, rejectQuotes: instance.rejectQuotes, moderationNote: iAmModerator ? instance.moderationNote : null, isBubbled: this.utilityService.isBubbledHost(instance.host), + mandatoryCW: instance.mandatoryCW, }; } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 029a0b6ebb..4e7ac59f41 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -17,8 +17,10 @@ import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { QueryService } from '@/core/QueryService.js'; -import { isPackedPureRenote } from '@/misc/is-renote.js'; import type { Config } from '@/config.js'; +import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; +import type { PopulatedNote } from '@/core/NoteVisibilityService.js'; +import type { NoteVisibilityData } from '@/core/NoteVisibilityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CacheService } from '../CacheService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -101,6 +103,9 @@ export class NoteEntityService implements OnModuleInit { @Inject(DI.noteFavoritesRepository) private noteFavoritesRepository: NoteFavoritesRepository, + // This is public to avoid weaving a whole new service through the Channel class hierarchy. + public readonly noteVisibilityService: NoteVisibilityService, + private readonly queryService: QueryService, //private userEntityService: UserEntityService, //private driveFileEntityService: DriveFileEntityService, @@ -121,6 +126,8 @@ export class NoteEntityService implements OnModuleInit { this.idService = this.moduleRef.get('IdService'); } + // Implementation moved to NoteVisibilityService + /* @bindThis private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] { if (packedNote.visibility === 'public' || packedNote.visibility === 'home') { @@ -136,104 +143,29 @@ export class NoteEntityService implements OnModuleInit { } return packedNote.visibility; } + */ @bindThis - public async hideNotes(notes: Packed<'Note'>[], meId: string | null): Promise { - const myFollowing = meId ? new Map(await this.cacheService.userFollowingsCache.fetch(meId)) : new Map>(); - const myBlockers = meId ? new Set(await this.cacheService.userBlockedCache.fetch(meId)) : new Set(); + public async hideNotes(notes: Packed<'Note'>[], meId: string | null, hint?: Partial): Promise { + const me = meId ? await this.cacheService.findUserById(meId) : null; + const data = await this.noteVisibilityService.populateData(me, hint); - // This shouldn't actually await, but we have to wrap it anyway because hideNote() is async - await Promise.all(notes.map(note => this.hideNote(note, meId, { - myFollowing, - myBlockers, - }))); + for (const note of notes) { + await this.hideNoteAsync(note, me, data); + } } @bindThis - public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: { - myFollowing?: ReadonlyMap> | ReadonlySet, - myBlockers?: ReadonlySet, - }): Promise { - if (meId === packedNote.userId) return; + public async hideNoteAsync(packedNote: Packed<'Note'>, me: string | Pick | null, hint?: Partial): Promise { + const { redact } = await this.noteVisibilityService.checkNoteVisibilityAsync(packedNote, me, { hint }); - // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) - let hide = false; - - if (packedNote.user.requireSigninToViewContents && meId == null) { - hide = true; + if (redact) { + this.redactNoteContents(packedNote); } + } - if (!hide) { - const hiddenBefore = packedNote.user.makeNotesHiddenBefore; - if ((hiddenBefore != null) - && ( - (hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000))) - || (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000)) - ) - ) { - hide = true; - } - } - - // visibility が specified かつ自分が指定されていなかったら非表示 - if (!hide) { - if (packedNote.visibility === 'specified') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else { - // 指定されているかどうか - const specified = packedNote.visibleUserIds!.some(id => meId === id); - - if (!specified) { - hide = true; - } - } - } - } - - // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 - if (!hide) { - if (packedNote.visibility === 'followers') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else if (packedNote.reply && (meId === packedNote.reply.userId)) { - // 自分の投稿に対するリプライ - hide = false; - } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { - // 自分へのメンション - hide = false; - } else if (packedNote.renote && (meId === packedNote.renote.userId)) { - hide = false; - } else { - const isFollowing = hint?.myFollowing - ? hint.myFollowing.has(packedNote.userId) - : (await this.cacheService.userFollowingsCache.fetch(meId)).has(packedNote.userId); - - hide = !isFollowing; - } - } - } - - // If this is a pure renote (boost), then we should *also* check the boosted note's visibility. - // Otherwise we can have empty notes on the timeline, which is not good. - // Notes are packed in depth-first order, so we can safely grab the "isHidden" property to avoid duplicated checks. - // This is pulled out to ensure that we check both the renote *and* the boosted note. - if (packedNote.renote?.isHidden && isPackedPureRenote(packedNote)) { - hide = true; - } - - if (!hide && meId && packedNote.userId !== meId) { - const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId); - const isBlocked = blockers.has(packedNote.userId); - - if (isBlocked) hide = true; - } - - if (hide) { + private redactNoteContents(packedNote: Packed<'Note'>) { + { packedNote.visibleUserIds = undefined; packedNote.fileIds = []; packedNote.files = []; @@ -477,74 +409,83 @@ export class NoteEntityService implements OnModuleInit { return undefined; } + // Implementation moved to NoteVisibilityService + /* @bindThis public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null, hint?: { myFollowing?: ReadonlySet, - myBlocking?: ReadonlySet, myBlockers?: ReadonlySet, - me?: Pick | null, + me?: Pick | null, }): Promise { + const [myFollowings, myBlockers, me] = await Promise.all([ + hint?.myFollowing ?? (meId ? this.cacheService.userFollowingsCache.fetch(meId).then(fs => new Set(fs.keys())) : null), + hint?.myBlockers ?? (meId ? this.cacheService.userBlockedCache.fetch(meId) : null), + hint?.me ?? (meId ? this.cacheService.findUserById(meId) : null), + ]); + + return this.isVisibleForMeSync(note, me, myFollowings, myBlockers); + } + + @bindThis + public isVisibleForMeSync(note: MiNote | Packed<'Note'>, me: Pick | null, myFollowings: ReadonlySet | null, myBlockers: ReadonlySet | null): boolean { + // We can always view our own notes + if (me?.id === note.userId) { + return true; + } + + // We can *never* view blocked notes + if (myBlockers?.has(note.userId)) { + return false; + } + // This code must always be synchronized with the checks in generateVisibilityQuery. // visibility が specified かつ自分が指定されていなかったら非表示 if (note.visibility === 'specified') { - if (meId == null) { + if (me == null) { + return false; + } else if (!note.visibleUserIds) { return false; - } else if (meId === note.userId) { - return true; } else { // 指定されているかどうか - return note.visibleUserIds.some(id => meId === id); + return note.visibleUserIds.includes(me.id); } } // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 if (note.visibility === 'followers') { - if (meId == null) { + if (me == null) { return false; - } else if (meId === note.userId) { - return true; - } else if (note.reply && (meId === note.reply.userId)) { + } else if (note.reply && (me.id === note.reply.userId)) { // 自分の投稿に対するリプライ return true; - } else if (note.mentions && note.mentions.some(id => meId === id)) { + } else if (!note.mentions) { + return false; + } else if (note.mentions.includes(me.id)) { // 自分へのメンション return true; + } else if (!note.visibleUserIds) { + return false; + } else if (note.visibleUserIds.includes(me.id)) { + // Explicitly visible to me + return true; } else { // フォロワーかどうか - const [blocked, following, userHost] = await Promise.all([ - hint?.myBlocking - ? hint.myBlocking.has(note.userId) - : this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)), - hint?.myFollowing - ? hint.myFollowing.has(note.userId) - : this.cacheService.userFollowingsCache.fetch(meId).then(ids => ids.has(note.userId)), - hint?.me !== undefined - ? (hint.me?.host ?? null) - : this.cacheService.findUserById(meId).then(me => me.host), - ]); + const following = myFollowings?.has(note.userId); + const userHost = me.host; - if (blocked) return false; - - /* If we know the following, everyhting is fine. - - But if we do not know the following, it might be that both the - author of the note and the author of the like are remote users, - in which case we can never know the following. Instead we have - to assume that the users are following each other. - */ + // If we know the following, everyhting is fine. + // + // But if we do not know the following, it might be that both the + // author of the note and the author of the like are remote users, + // in which case we can never know the following. Instead we have + // to assume that the users are following each other. return following || (note.userHost != null && userHost != null); } } - if (meId != null) { - const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId); - const isBlocked = blockers.has(note.userId); - - if (isBlocked) return false; - } - return true; } + */ @bindThis public async packAttachedFiles(fileIds: MiNote['fileIds'], packedFiles: Map | null>): Promise[]> { @@ -569,6 +510,7 @@ export class NoteEntityService implements OnModuleInit { detail?: boolean; skipHide?: boolean; withReactionAndUserPairCache?: boolean; + bypassSilence?: boolean; _hint_?: { bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; myReactions: Map; @@ -642,15 +584,19 @@ export class NoteEntityService implements OnModuleInit { .getExists() : false), ]); + const bypassSilence = opts.bypassSilence || note.userId === meId; + const packed: Packed<'Note'> = await awaitAll({ id: note.id, threadId, createdAt: this.idService.parse(note.id).date.toISOString(), updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, + userHost: note.userHost, user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me), text: text, cw: note.cw, + mandatoryCW: note.mandatoryCW, visibility: note.visibility, localOnly: note.localOnly, reactionAcceptance: note.reactionAcceptance, @@ -688,6 +634,7 @@ export class NoteEntityService implements OnModuleInit { isMutingNote: mutedNotes.has(note.id), isFavorited, isRenoted, + bypassSilence, ...(meId && Object.keys(reactions).length > 0 ? { myReaction: this.populateMyReaction({ @@ -706,6 +653,9 @@ export class NoteEntityService implements OnModuleInit { skipHide: opts.skipHide, withReactionAndUserPairCache: opts.withReactionAndUserPairCache, _hint_: options?._hint_, + + // Don't silence target of self-reply, since the outer note will already be silenced. + bypassSilence: bypassSilence || note.userId === note.replyUserId, }) : undefined, renote: note.renoteId ? this.pack(note.renote ?? opts._hint_?.notes.get(note.renoteId) ?? note.renoteId, me, { @@ -713,16 +663,21 @@ export class NoteEntityService implements OnModuleInit { skipHide: opts.skipHide, withReactionAndUserPairCache: opts.withReactionAndUserPairCache, _hint_: options?._hint_, + + // Don't silence target of self-renote, since the outer note will already be silenced. + bypassSilence: bypassSilence || note.userId === note.renoteUserId, }) : undefined, } : {}), }); - this.treatVisibility(packed); + this.noteVisibilityService.syncVisibility(packed); if (!opts.skipHide) { - await this.hideNote(packed, meId, meId == null ? undefined : { - myFollowing: opts._hint_?.userFollowings.get(meId), - myBlockers: opts._hint_?.userBlockers.get(meId), + await this.hideNoteAsync(packed, meId, { + userFollowings: meId ? opts._hint_?.userFollowings.get(meId) : null, + userBlockers: meId ? opts._hint_?.userBlockers.get(meId) : null, + userMutedNotes: opts._hint_?.mutedNotes, + userMutedThreads: opts._hint_?.mutedThreads, }); } @@ -736,79 +691,13 @@ export class NoteEntityService implements OnModuleInit { options?: { detail?: boolean; skipHide?: boolean; + bypassSilence?: boolean; }, ) { if (notes.length === 0) return []; - const targetNotesMap = new Map(); - const targetNotesToFetch : string[] = []; - for (const note of notes) { - if (isPureRenote(note)) { - // we may need to fetch 'my reaction' for renote target. - if (note.renote) { - targetNotesMap.set(note.renote.id, note.renote); - if (note.renote.reply) { - // idem if the renote is also a reply. - targetNotesMap.set(note.renote.reply.id, note.renote.reply); - } - } else if (options?.detail) { - targetNotesToFetch.push(note.renoteId); - } - } else { - if (note.reply) { - // idem for OP of a regular reply. - targetNotesMap.set(note.reply.id, note.reply); - } else if (note.replyId && options?.detail) { - targetNotesToFetch.push(note.replyId); - } - - targetNotesMap.set(note.id, note); - } - } - - // Don't fetch notes that were added by ID and then found inline in another note. - for (let i = targetNotesToFetch.length - 1; i >= 0; i--) { - if (targetNotesMap.has(targetNotesToFetch[i])) { - targetNotesToFetch.splice(i, 1); - } - } - - // Populate any relations that weren't included in the source - if (targetNotesToFetch.length > 0) { - const newNotes = await this.notesRepository.find({ - where: { - id: In(targetNotesToFetch), - }, - relations: { - user: { - userProfile: true, - }, - reply: { - user: { - userProfile: true, - }, - }, - renote: { - user: { - userProfile: true, - }, - reply: { - user: { - userProfile: true, - }, - }, - }, - channel: true, - }, - }); - - for (const note of newNotes) { - targetNotesMap.set(note.id, note); - } - } - - const targetNotes = Array.from(targetNotesMap.values()); - const noteIds = Array.from(targetNotesMap.keys()); + const targetNotes = await this.fetchRequiredNotes(notes, options?.detail ?? false); + const noteIds = Array.from(new Set(targetNotes.map(n => n.id))); const usersMap = new Map(); const allUsers = notes.flatMap(note => [ @@ -915,6 +804,84 @@ export class NoteEntityService implements OnModuleInit { }))); } + // TODO find a way to de-duplicate pack() calls when we have multiple references to the same note. + + private async fetchRequiredNotes(notes: MiNote[], detail: boolean): Promise { + const notesMap = new Map(); + const notesToFetch = new Set(); + + function addNote(note: string | MiNote | null | undefined) { + if (note == null) return; + + if (typeof(note) === 'object') { + notesMap.set(note.id, note); + notesToFetch.delete(note.id); + } else if (detail) { + if (!notesMap.has(note)) { + notesToFetch.add(note); + } + } + } + + // Enumerate 1st-tier dependencies + for (const note of notes) { + // Add note itself + addNote(note); + + // Add renote + if (note.renoteId) { + if (note.renote) { + addNote(note.renote); + addNote(note.renote.reply ?? note.renote.replyId); + addNote(note.renote.renote ?? note.renote.renoteId); + } else { + addNote(note.renoteId); + } + } + + // Add reply + addNote(note.reply ?? note.replyId); + } + + // Populate 1st-tier dependencies + if (notesToFetch.size > 0) { + const newNotes = await this.notesRepository.find({ + where: { + id: In(Array.from(notesToFetch)), + }, + relations: { + reply: true, + renote: { + reply: true, + renote: true, + }, + channel: true, + }, + }); + + for (const note of newNotes) { + addNote(note); + } + + notesToFetch.clear(); + } + + // Extract second-tier dependencies + for (const note of Array.from(notesMap.values())) { + if (isPureRenote(note) && note.renote) { + if (note.renote.reply && !notesMap.has(note.renote.reply.id)) { + notesMap.set(note.renote.reply.id, note.renote.reply); + } + + if (note.renote.renote && !notesMap.has(note.renote.renote.id)) { + notesMap.set(note.renote.renote.id, note.renote.renote); + } + } + } + + return Array.from(notesMap.values()); + } + @bindThis public aggregateNoteEmojis(notes: MiNote[]) { let emojis: { name: string | null; host: string | null; }[] = []; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 227814454d..2abf2ee4f5 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -183,7 +183,7 @@ export class UserEntityService implements OnModuleInit { public isRemoteUser = isRemoteUser; @bindThis - public async getRelation(me: MiUser['id'], target: MiUser['id']): Promise { + public async getRelation(me: MiUser['id'], target: MiUser['id'], hint?: { myFollowings?: Map> }): Promise { const [ following, isFollowed, @@ -197,7 +197,9 @@ export class UserEntityService implements OnModuleInit { memo, mutedInstances, ] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null), + hint?.myFollowings + ? (hint.myFollowings.get(target) ?? null) + : this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null), this.cacheService.userFollowingsCache.fetch(target).then(f => f.has(me)), this.followRequestsRepository.exists({ where: { @@ -248,7 +250,8 @@ export class UserEntityService implements OnModuleInit { } @bindThis - public async getRelations(me: MiUser['id'], targets: MiUser['id'][]): Promise> { + public async getRelations(me: MiUser['id'], targets: MiUser['id'][], hint?: { myFollowings?: Map> }): Promise> { + // noinspection ES6MissingAwait const [ myFollowing, myFollowers, @@ -262,7 +265,7 @@ export class UserEntityService implements OnModuleInit { memos, mutedInstances, ] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me), + hint?.myFollowings ?? this.cacheService.userFollowingsCache.fetch(me), this.cacheService.userFollowersCache.fetch(me), this.followRequestsRepository.createQueryBuilder('f') .select('f.followeeId') @@ -432,6 +435,7 @@ export class UserEntityService implements OnModuleInit { userIdsByUri?: Map, instances?: Map, securityKeyCounts?: Map, + myFollowings?: Map>, }, ): Promise> { const opts = Object.assign({ @@ -479,12 +483,14 @@ export class UserEntityService implements OnModuleInit { ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; + const myFollowings = opts.myFollowings ?? (meId ? await this.cacheService.userFollowingsCache.fetch(meId) : undefined); + let relation: UserRelation | null = null; if (meId && !isMe && isDetailed) { if (opts.userRelations) { relation = opts.userRelations.get(user.id) ?? null; } else { - relation = await this.getRelation(meId, user.id); + relation = await this.getRelation(meId, user.id, { myFollowings }); } } @@ -537,6 +543,8 @@ export class UserEntityService implements OnModuleInit { let fetchPoliciesPromise: Promise | null = null; const fetchPolicies = () => fetchPoliciesPromise ??= this.roleService.getUserPolicies(user); + const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false); + const packed = { id: user.id, name: user.name, @@ -564,7 +572,8 @@ export class UserEntityService implements OnModuleInit { mandatoryCW: user.mandatoryCW, rejectQuotes: user.rejectQuotes, attributionDomains: user.attributionDomains, - isSilenced: user.isSilenced || fetchPolicies().then(r => !r.canPublicNote), + isSilenced: user.isSilenced, + bypassSilence: bypassSilence, speakAsCat: user.speakAsCat ?? false, approved: user.approved, requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true, @@ -578,6 +587,7 @@ export class UserEntityService implements OnModuleInit { faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, isSilenced: instance.isSilenced, + mandatoryCW: instance.mandatoryCW, } : undefined) : undefined, followersCount: followersCount ?? 0, followingCount: followingCount ?? 0, @@ -782,14 +792,20 @@ export class UserEntityService implements OnModuleInit { // -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 - const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts] = await Promise.all([ + const myFollowingsPromise: Promise> | undefined> = meId + ? this.cacheService.userFollowingsCache.fetch(meId) + : Promise.resolve(undefined); + + const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts, myFollowings] = await Promise.all([ // profilesMap this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))), // userMemos isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId }) .then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(), // userRelations - isDetailed && meId ? this.getRelations(meId, _userIds) : new Map(), + meId && isDetailed + ? myFollowingsPromise.then(myFollowings => this.getRelations(meId, _userIds, { myFollowings })) + : new Map(), // pinNotes isDetailed ? this.userNotePiningsRepository.createQueryBuilder('pin') .where('pin.userId IN (:...userIds)', { userIds: _userIds }) @@ -833,6 +849,8 @@ export class UserEntityService implements OnModuleInit { .getRawMany<{ userId: string, userCount: number }>() .then(counts => new Map(counts.map(c => [c.userId, c.userCount]))) : undefined, // .pack will fetch the keys for the requesting user if it's in the _userIds + // myFollowings + myFollowingsPromise, ]); return Promise.all( @@ -849,6 +867,7 @@ export class UserEntityService implements OnModuleInit { userIdsByUri, instances, securityKeyCounts, + myFollowings, }, )), ); diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index be2d3ea98d..be183b4979 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -23,8 +23,17 @@ export const getNoteSummary = (note: Packed<'Note'>): string => { // Append mandatory CW, if applicable let cw = note.cw; + if (note.mandatoryCW) { + cw = appendContentWarning(cw, `Note is flagged: "${note.mandatoryCW}"`); + } if (note.user.mandatoryCW) { - cw = appendContentWarning(cw, note.user.mandatoryCW); + const username = note.user.host + ? `@${note.user.username}@${note.user.host}` + : `@${note.user.username}`; + cw = appendContentWarning(cw, `${username} is flagged: "${note.user.mandatoryCW}"`); + } + if (note.user.instance?.mandatoryCW) { + cw = appendContentWarning(cw, `${note.user.host} is flagged: "${note.user.instance.mandatoryCW}"`); } // 本文 diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index 0cde4b75fc..e52ba65e1c 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -205,11 +205,6 @@ export class MiInstance { }) public infoUpdatedAt: Date | null; - @Column('boolean', { - default: false, - }) - public isNSFW: boolean; - @Column('boolean', { default: false, }) @@ -228,4 +223,13 @@ export class MiInstance { length: 16384, default: '', }) public moderationNote: string; + + /** + * Specifies a Content Warning that should be forcibly applied to all notes from this instance + * If null (default), then no Content Warning is applied. + */ + @Column('text', { + nullable: true, + }) + public mandatoryCW: string | null; } diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index b064e95493..74e3630303 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -228,6 +228,15 @@ export class MiNote { }) public processErrors: string[] | null; + /** + * Specifies a Content Warning that should be forcibly attached to this note. + * Does not replace the user's own CW. + */ + @Column('text', { + nullable: true, + }) + public mandatoryCW: string | null; + //#region Denormalized fields @Column('varchar', { length: 128, nullable: true, diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 0e9ce9334e..f04ac810d1 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -116,11 +116,6 @@ export const packedFederationInstanceSchema = { optional: false, nullable: true, format: 'date-time', }, - isNSFW: { - type: 'boolean', - optional: false, - nullable: false, - }, rejectReports: { type: 'boolean', optional: false, @@ -139,5 +134,9 @@ export const packedFederationInstanceSchema = { type: 'boolean', optional: false, nullable: false, }, + mandatoryCW: { + type: 'string', + optional: false, nullable: true, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index b57458235f..960865facd 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -41,11 +41,19 @@ export const packedNoteSchema = { type: 'string', optional: true, nullable: true, }, + mandatoryCW: { + type: 'string', + optional: true, nullable: true, + }, userId: { type: 'string', optional: false, nullable: false, format: 'id', }, + userHost: { + type: 'string', + optional: false, nullable: true, + }, user: { type: 'object', ref: 'UserLite', @@ -189,6 +197,10 @@ export const packedNoteSchema = { type: 'boolean', optional: false, nullable: false, }, + bypassSilence: { + type: 'boolean', + optional: false, nullable: false, + }, emojis: { type: 'object', optional: true, nullable: false, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 4a2d5780d1..65ef387fb7 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -184,6 +184,10 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: false, }, + bypassSilence: { + type: 'boolean', + nullable: false, optional: false, + }, requireSigninToViewContents: { type: 'boolean', nullable: false, optional: true, @@ -228,6 +232,10 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: false, }, + mandatoryCW: { + type: 'string', + nullable: true, optional: false, + }, }, }, followersCount: { diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 521710bc13..90635906d6 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -34,6 +34,8 @@ export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decor export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js'; export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js'; export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js'; +export * as 'admin/cw-instance' from './endpoints/admin/cw-instance.js'; +export * as 'admin/cw-note' from './endpoints/admin/cw-note.js'; export * as 'admin/cw-user' from './endpoints/admin/cw-user.js'; export * as 'admin/decline-user' from './endpoints/admin/decline-user.js'; export * as 'admin/delete-account' from './endpoints/admin/delete-account.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/cw-instance.ts b/packages/backend/src/server/api/endpoints/admin/cw-instance.ts new file mode 100644 index 0000000000..5e5d83283e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/cw-instance.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:cw-instance', + + res: {}, +} as const; + +export const paramDef = { + type: 'object', + properties: { + host: { type: 'string' }, + cw: { type: 'string', nullable: true }, + }, + required: ['host', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly moderationLogService: ModerationLogService, + private readonly federatedInstanceService: FederatedInstanceService, + ) { + super(meta, paramDef, async (ps, me) => { + const instance = await this.federatedInstanceService.fetchOrRegister(ps.host); + + // Collapse empty strings to null + const newCW = ps.cw?.trim() || null; + const oldCW = instance.mandatoryCW; + + // Skip if there's nothing to do + if (oldCW === newCW) return; + + // This synchronizes caches automatically + await this.federatedInstanceService.update(instance.id, { mandatoryCW: newCW }); + + await this.moderationLogService.log(me, 'setMandatoryCWForInstance', { + newCW, + oldCW, + host: ps.host, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/cw-note.ts b/packages/backend/src/server/api/endpoints/admin/cw-note.ts new file mode 100644 index 0000000000..ba2240b8b2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/cw-note.ts @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelsRepository, DriveFilesRepository, MiNote, NotesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { NoteEditService, Option } from '@/core/NoteEditService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:cw-note', + + res: {}, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + cw: { type: 'string', nullable: true }, + }, + required: ['noteId', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private readonly driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private readonly channelsRepository: ChannelsRepository, + + private readonly noteEditService: NoteEditService, + private readonly moderationLogService: ModerationLogService, + private readonly cacheService: CacheService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.notesRepository.findOneOrFail({ + where: { id: ps.noteId }, + relations: { reply: true, renote: true, channel: true }, + }); + const user = await this.cacheService.findUserById(note.userId); + + // Collapse empty strings to null + const newCW = ps.cw?.trim() || null; + const oldCW = note.mandatoryCW; + + // Skip if there's nothing to do + if (oldCW === newCW) return; + + // TODO remove this after merging hazelnoot/fix-note-edit-logic. + // Until then, we have to ensure that everything is populated just like it would be from notes/edit.ts. + // Otherwise forcing a CW will erase everything else in the note. + // After merging remove all the "createUpdate" stuff and just pass "{ mandatoryCW: newCW }" into noteEditService.edit(). + const update = await this.createUpdate(note, newCW); + await this.noteEditService.edit(user, note.id, update); + + await this.moderationLogService.log(me, 'setMandatoryCWForNote', { + newCW, + oldCW, + noteId: note.id, + noteUserId: user.id, + noteUserUsername: user.username, + noteUserHost: user.host, + }); + }); + } + + // Note: user must be fetched with reply, renote, and channel relations populated + private async createUpdate(note: MiNote, newCW: string | null) { + // This is based on the call to NoteEditService.edit from notes/edit endpoint. + // noinspection ES6MissingAwait + return await awaitAll