merge: Expand Mandatory CW feature and fixup block/mute/silence features (resolves #809, #910, #912, #943, #1064, #1142, and #1186) (!1148)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1148

Closes #809, #910, #912, #943, #1064, #1142, and #1186

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Marie 2025-09-25 20:05:46 +02:00
commit 741e612508
125 changed files with 3195 additions and 1338 deletions

View file

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

View file

@ -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<MiNote[]>,
};
@ -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<MiNote[]> {
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote, populated: PopulatedNote) => boolean, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
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<NotePopulationData> {
// Enumerate all related data
const allNotes = new Map<string, MiNote>();
const usersToFetch = new Set<string>();
const instancesToFetch = new Set<string>();
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 };
}
}

View file

@ -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<MiInstance | null>;
public readonly federatedInstanceCache: QuantumKVCache<MiInstance>;
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<MiInstance> {
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<MiInstance | null> {
public async fetch(host: string): Promise<MiInstance> {
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<MiInstance>): Promise<void> {
public async update(id: MiInstance['id'], data: QueryDeepPartialEntity<MiInstance>): Promise<MiInstance> {
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<MiMeta | undefined>, after: Serialized<MiMeta>): void {
/**
* Gets all instances in the allowlist (meta.federationHosts).
*/
@bindThis
public async getAllowList(): Promise<MiInstance[]> {
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<MiInstance[]> {
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<void> {
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();
}

View file

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

View file

@ -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<MiNote> {
public async edit(user: MiUser, editid: MiNote['id'], data: Option, silent = false): Promise<MiNote> {
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<MiNote> = {};
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;

View file

@ -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<NoteVisibilityData> }): Promise<NoteVisibilityResult> {
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<PopulatedNote> {
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<PopulatedUser> {
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<PopulatedNote | null> {
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<PopulatedNote | null> {
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<NoteVisibilityData>): Promise<NoteVisibilityData> {
// 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<string> | null;
userFollowings: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> | null;
userMutedThreads: Set<string> | null;
userMutedNotes: Set<string> | null;
userMutedUsers: Set<string> | null;
userMutedUserRenotes: Set<string> | null;
userMutedInstances: Set<string> | null;
}
export interface NotePopulationData {
notes?: Map<string, MiNote>;
users?: Map<string, MiUser>;
instances?: Map<string, MiInstance>;
}
// This represents the *requesting* user!
export type PopulatedMe = Pick<MiUser, 'id' | 'host'> | 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;
}

View file

@ -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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null, withRepliesProp?: string): SelectQueryBuilder<E> {
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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
@ -107,38 +135,66 @@ export class QueryService {
@bindThis
public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
// 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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
// 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
return this
.andNotMutingUser(q, ':meId', 'note.userId', exclude)
public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, excludeAuthor = false): SelectQueryBuilder<E> {
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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
// 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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
if (!me) {
return q.andWhere('user.isSilenced = false');
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null, excludeAuthor = false): SelectQueryBuilder<E> {
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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
public leftJoin<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string, alias: string, condition?: string): SelectQueryBuilder<E> {
// 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 extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere');
public orFollowingUser<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, withReplies?: boolean): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere', withReplies);
}
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
private addFollowingUser<Q extends WhereExpressionBuilder>(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<any>, excludeAuthor?: boolean): void {
public orNotMutingNote<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingNote(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingNote<Q extends WhereExpressionBuilder>(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<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, 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`)

View file

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

View file

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

View file

@ -127,6 +127,7 @@ function generateDummyNote(override?: Partial<MiNote>): 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 => ({

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void> {
const myFollowing = meId ? new Map(await this.cacheService.userFollowingsCache.fetch(meId)) : new Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>();
const myBlockers = meId ? new Set(await this.cacheService.userBlockedCache.fetch(meId)) : new Set<string>();
public async hideNotes(notes: Packed<'Note'>[], meId: string | null, hint?: Partial<NoteVisibilityData>): Promise<void> {
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<string, Omit<MiFollowing, 'isFollowerHibernated'>> | ReadonlySet<string>,
myBlockers?: ReadonlySet<string>,
}): Promise<void> {
if (meId === packedNote.userId) return;
public async hideNoteAsync(packedNote: Packed<'Note'>, me: string | Pick<MiUser, 'id' | 'host'> | null, hint?: Partial<NoteVisibilityData>): Promise<void> {
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<string>,
myBlocking?: ReadonlySet<string>,
myBlockers?: ReadonlySet<string>,
me?: Pick<MiUser, 'host'> | null,
me?: Pick<MiUser, 'id' | 'host'> | null,
}): Promise<boolean> {
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<MiUser, 'id' | 'host'> | null, myFollowings: ReadonlySet<string> | null, myBlockers: ReadonlySet<string> | 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<MiNote['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
@ -569,6 +510,7 @@ export class NoteEntityService implements OnModuleInit {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
bypassSilence?: boolean;
_hint_?: {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
@ -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<string, MiNote>();
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<string, MiUser | string>();
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<MiNote[]> {
const notesMap = new Map<string, MiNote>();
const notesToFetch = new Set<string>();
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; }[] = [];

View file

@ -183,7 +183,7 @@ export class UserEntityService implements OnModuleInit {
public isRemoteUser = isRemoteUser;
@bindThis
public async getRelation(me: MiUser['id'], target: MiUser['id']): Promise<UserRelation> {
public async getRelation(me: MiUser['id'], target: MiUser['id'], hint?: { myFollowings?: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> }): Promise<UserRelation> {
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<Map<MiUser['id'], UserRelation>> {
public async getRelations(me: MiUser['id'], targets: MiUser['id'][], hint?: { myFollowings?: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> }): Promise<Map<MiUser['id'], UserRelation>> {
// 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<string, string>,
instances?: Map<string, MiInstance | null>,
securityKeyCounts?: Map<string, number>,
myFollowings?: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>,
},
): Promise<Packed<S>> {
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<RolePolicies> | 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<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> | 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,
},
)),
);