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:
commit
741e612508
125 changed files with 3195 additions and 1338 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
465
packages/backend/src/core/NoteVisibilityService.ts
Normal file
465
packages/backend/src/core/NoteVisibilityService.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue