completely re-implement note visibility as NoteVisibilityService

This commit is contained in:
Hazelnoot 2025-08-12 23:40:49 -04:00
parent d1912362e0
commit 85ca2269e4
27 changed files with 925 additions and 420 deletions

View file

@ -20,6 +20,7 @@ import { EnvService } from '@/core/EnvService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApLogService } from '@/core/ApLogService.js';
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AnnouncementService } from './AnnouncementService.js';
@ -240,6 +241,7 @@ const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisti
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $TimeService: Provider = { provide: 'TimeService', useExisting: TimeService };
const $EnvService: Provider = { provide: 'EnvService', useExisting: EnvService };
const $NoteVisibilityService: Provider = { provide: 'NoteVisibilityService', useExisting: NoteVisibilityService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@ -400,6 +402,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ReversiService,
TimeService,
EnvService,
NoteVisibilityService,
ChartLoggerService,
FederationChart,
@ -556,6 +559,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$ReversiService,
$TimeService,
$EnvService,
$NoteVisibilityService,
$ChartLoggerService,
$FederationChart,
@ -713,6 +717,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ReversiService,
TimeService,
EnvService,
NoteVisibilityService,
FederationChart,
NotesChart,
@ -867,6 +872,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$ReversiService,
$TimeService,
$EnvService,
$NoteVisibilityService,
$FederationChart,
$NotesChart,

View file

@ -15,10 +15,11 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
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 { NoteVisibilityService, PopulatedNote } from '@/core/NoteVisibilityService.js';
type TimelineOptions = {
untilId: string | null,
@ -56,6 +57,7 @@ export class FanoutTimelineEndpointService {
private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
private utilityService: UtilityService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
}
@ -110,52 +112,14 @@ export class FanoutTimelineEndpointService {
filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note);
}
if (ps.me) {
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
myFollowings,
myThreadMutings,
myNoteMutings,
me,
] = 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(ps.me.id).then(p => new Set(p.mutedInstances)),
this.cacheService.userFollowingsCache.fetch(ps.me.id).then(fs => new Set(fs.keys())),
this.cacheService.threadMutingsCache.fetch(ps.me.id),
this.cacheService.noteMutingsCache.fetch(ps.me.id),
this.cacheService.findUserById(ps.me.id),
]);
{
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;
// Silenced users (when logged in)
if (!ps.ignoreAuthorFromUserSilence && !myFollowings.has(note.userId)) {
if (note.user?.isSilenced || note.user?.instance?.isSilenced) return false;
if (note.reply?.user?.isSilenced || note.reply?.user?.instance?.isSilenced) return false;
if (note.renote?.user?.isSilenced || note.renote?.user?.instance?.isSilenced) return false;
}
// Muted threads / posts
if (!ps.includeMutedNotes) {
if (myThreadMutings.has(note.threadId ?? note.id) || myNoteMutings.has(note.id)) return false;
if (note.replyId && myNoteMutings.has(note.replyId)) return false;
if (note.renote && (myThreadMutings.has(note.renote.threadId ?? note.renote.id) || myNoteMutings.has(note.renote.id))) return false;
}
// Invisible notes
if (!this.noteEntityService.isVisibleForMeSync(note, me, myFollowings, userIdsWhoBlockingMe)) {
return false;
}
const { accessible, silence } = this.noteVisibilityService.checkNoteVisibility(note as PopulatedNote, me, { data, filters: { includeSilencedAuthor: ps.ignoreAuthorFromUserSilence } });
if (!accessible || silence) return false;
return parentFilter(note);
};
@ -191,36 +155,6 @@ export class FanoutTimelineEndpointService {
};
}
{
const parentFilter = filter;
filter = (note) => {
// Silenced users (when logged out)
if (!ps.ignoreAuthorFromUserSilence && !ps.me) {
if (note.user?.isSilenced || note.user?.instance?.isSilenced) return false;
if (note.reply?.user?.isSilenced || note.reply?.user?.instance?.isSilenced) return false;
if (note.renote?.user?.isSilenced || note.renote?.user?.instance?.isSilenced) return false;
}
return parentFilter(note);
};
}
// This one MUST be last!
{
const parentFilter = filter;
filter = (note) => {
// If this is a boost, then first run all checks for the boost target.
if (isPureRenote(note) && note.renote) {
if (!parentFilter(note.renote)) {
return false;
}
}
// Either way, make sure to run the checks for the actual note too!
return parentFilter(note);
};
}
const redisTimeline: MiNote[] = [];
let readFromRedis = 0;
let lastSuccessfulRate = 1; // rateをキャッシュする

View file

@ -0,0 +1,420 @@
/*
* 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 { MiInstance } from '@/models/Instance.js';
import { bindThis } from '@/decorators.js';
import { 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, 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 | PopulatedUser, opts?: { filters?: NoteVisibilityFilters, hint?: Partial<NoteVisibilityData> }): Promise<NoteVisibilityResult> {
if (typeof(user) === 'string') {
user = await this.cacheService.findUserById(user);
}
const populatedNote = await this.populateNote(note);
const populatedData = await this.populateData(user, opts?.hint ?? {});
return this.checkNoteVisibility(populatedNote, user, { filters: opts?.filters, data: populatedData });
}
private async populateNote(note: Packed<'Note'>, dive?: boolean): Promise<Packed<'Note'>>;
private async populateNote(note: MiNote, dive?: boolean): Promise<PopulatedMiNote>;
private async populateNote(note: MiNote | Packed<'Note'>, dive?: boolean): Promise<PopulatedNote>;
private async populateNote(note: MiNote | Packed<'Note'>, dive = true): Promise<PopulatedNote> {
// Packed<'Note'> is already fully loaded
if (isPackedNote(note)) return note;
// noinspection ES6MissingAwait
return await awaitAll({
...note,
user: this.getNoteUser(note),
renote: dive ? this.getNoteRenote(note) : null,
reply: dive ? this.getNoteReply(note) : null,
});
}
private async getNoteUser(note: MiNote): Promise<PopulatedMiNote['user']> {
const user = note.user ?? await this.cacheService.findUserById(note.userId);
return {
...user,
instance: user.instance ?? (user.host ? await this.federatedInstanceService.fetchOrRegister(user.host) : null),
};
}
private async getNoteRenote(note: MiNote): Promise<PopulatedMiNote['renote']> {
if (!note.renoteId) return null;
const renote = note.renote ?? await this.notesRepository.findOneOrFail({
where: { id: note.renoteId },
relations: {
user: {
instance: true,
},
},
});
return await this.populateNote(renote, false);
}
private async getNoteReply(note: MiNote): Promise<PopulatedMiNote['reply']> {
if (!note.replyId) return null;
const reply = note.reply ?? await this.notesRepository.findOneOrFail({
where: { id: note.replyId },
relations: {
user: {
instance: true,
},
},
});
return await this.populateNote(reply, false);
}
@bindThis
public async populateData(user: PopulatedUser, 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: PopulatedUser, opts: { filters?: NoteVisibilityFilters, data: NoteVisibilityData }): NoteVisibilityResult {
// Copy note since we mutate it below
note = {
...note,
renote: note.renote ? { ...note.renote } : null,
reply: note.reply ? { ...note.reply } : null,
} as PopulatedNote;
this.syncVisibility(note);
const accessible = this.isAccessible(note, user, opts.data);
const redact = this.shouldRedact(note, user, opts.data);
const silence = this.shouldSilence(note, user, opts.data, opts.filters);
const baseVisibility = { accessible, redact, silence };
// For boosts (pure renotes), we must recurse and pick the lowest common access level.
if (isPopulatedBoost(note)) {
const boostVisibility = this.checkNoteVisibility(note.renote, user, opts);
return {
accessible: baseVisibility.accessible && boostVisibility.accessible,
redact: baseVisibility.redact || boostVisibility.redact,
silence: baseVisibility.silence || boostVisibility.silence,
};
}
return baseVisibility;
}
// Based on NoteEntityService.isVisibleForMe
private isAccessible(note: PopulatedNote, user: PopulatedUser, 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: PopulatedUser): 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: PopulatedUser, 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): void {
// Make followers-only
if (note.user.makeNotesFollowersOnlyBefore && note.visibility !== 'specified' && note.visibility !== 'followers') {
const followersOnlyBefore = note.user.makeNotesFollowersOnlyBefore * 1000;
const createdAt = 'createdAt' in note
? new Date(note.createdAt).getTime()
: this.idService.parse(note.id).date.getTime();
// 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: PopulatedUser, data: NoteVisibilityData): 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 = 'createdAt' in note
? new Date(note.createdAt).getTime()
: this.idService.parse(note.id).date.getTime();
// 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;
}
// Redact if inaccessible.
// We have to repeat the check in case note visibility changed in treatVisibility!
if (!this.isAccessible(note, user, data)) {
return true;
}
// Otherwise don't redact
return false;
}
// Based on inconsistent logic from all around the app
private shouldSilence(note: PopulatedNote, user: PopulatedUser, 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 ?? note.id)) 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: PopulatedUser, data: NoteVisibilityData, includeSilencedAuthor: boolean): boolean {
// Don't silence if it's us
if (note.userId === user?.id) return false;
// Don't silence if we're following
if (data.userFollowings?.has(note.userId)) return false;
if (!includeSilencedAuthor) {
// 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: PopulatedUser, 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 {
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 type PopulatedUser = Pick<MiUser, 'id' | 'host'> | null | undefined;
export type PopulatedNote = PopulatedMiNote | Packed<'Note'>;
type PopulatedMiNote = MiNote & {
user: MiUser & {
instance: MiInstance | null,
}
renote: PopulatedMiNote | null,
reply: PopulatedMiNote | null,
};
function isPopulatedBoost(note: PopulatedNote): note is PopulatedNote & { renote: PopulatedNote } {
return note.renoteId != null
&& note.replyId == null
&& note.text == null
&& note.cw == null
&& (note.fileIds == null || note.fileIds.length === 0);
}
function isPackedNote(note: MiNote | Packed<'Note'>): note is Packed<'Note'> {
// Kind of a hack: determine whether it's packed by looking for property that doesn't exist in MiNote
return 'isFavorited' in note;
}

View file

@ -128,29 +128,44 @@ export class QueryService {
}
@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 });
}
@ -238,10 +253,10 @@ export class QueryService {
@bindThis
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null, excludeAuthor = false): SelectQueryBuilder<E> {
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => {
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, `note.${key}`, key); // note->user
this.leftJoin(q, userKey, key); // note->user
q.andWhere(new Brackets(qb => {
// case 1: user does not exist (note is not reply/renote)
@ -270,10 +285,10 @@ export class QueryService {
}
if (!excludeAuthor) {
checkFor('user');
checkFor('user', 'note.user');
}
checkFor('replyUser');
checkFor('renoteUser');
checkFor('replyUser', 'reply.user');
checkFor('renoteUser', 'renote.user');
return q;
}

View file

@ -31,6 +31,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
import { CacheService } from '@/core/CacheService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import type { DataSource } from 'typeorm';
const FALLBACK = '\u2764';
@ -108,6 +109,7 @@ export class ReactionService {
private notificationService: NotificationService,
private perUserReactionsChart: PerUserReactionsChart,
private readonly cacheService: CacheService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
}
@ -122,7 +124,8 @@ export class ReactionService {
}
// check visibility
if (!await this.noteEntityService.isVisibleForMe(note, user.id, { me: user })) {
const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(note, user);
if (!accessible) {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
}

View file

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

View file

@ -38,6 +38,7 @@ import FederationChart from '@/core/chart/charts/federation.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
import { CacheService } from '@/core/CacheService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
@ -100,6 +101,7 @@ export class ApInboxService {
private readonly federationChart: FederationChart,
private readonly updateInstanceQueue: UpdateInstanceQueue,
private readonly cacheService: CacheService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
this.logger = this.apLoggerService.logger;
}
@ -367,7 +369,8 @@ export class ApInboxService {
const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) });
if (renote == null) return 'announce target is null';
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id, { me: actor })) {
const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(renote, actor);
if (!accessible) {
return 'skip: invalid actor for this activity';
}

View file

@ -17,8 +17,9 @@ 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 { 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 +102,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 +125,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,24 +142,31 @@ 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) {
this.hideNote(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> {
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 });
if (redact) {
this.redactNoteContents(packedNote);
}
}
@bindThis
public hideNote(packedNote: Packed<'Note'>, me: Pick<MiUser, 'id' | 'host'> | null, data: NoteVisibilityData): void {
// Implementation moved to NoteVisibilityService
/*
if (meId === packedNote.userId) return;
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
@ -232,8 +245,17 @@ export class NoteEntityService implements OnModuleInit {
if (isBlocked) hide = true;
}
*/
const hide = this.noteVisibilityService.checkNoteVisibility(packedNote, me, { data }).redact;
if (hide) {
this.redactNoteContents(packedNote);
}
}
private redactNoteContents(packedNote: Packed<'Note'>) {
{
packedNote.visibleUserIds = undefined;
packedNote.fileIds = [];
packedNote.files = [];
@ -477,6 +499,8 @@ 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>,
@ -493,7 +517,7 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
public isVisibleForMeSync(note: MiNote, me: Pick<MiUser, 'id' | 'host'> | null, myFollowings: ReadonlySet<string> | null, myBlockers: ReadonlySet<string> | null): boolean {
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;
@ -509,6 +533,8 @@ export class NoteEntityService implements OnModuleInit {
if (note.visibility === 'specified') {
if (me == null) {
return false;
} else if (!note.visibleUserIds) {
return false;
} else {
// 指定されているかどうか
return note.visibleUserIds.includes(me.id);
@ -522,9 +548,13 @@ export class NoteEntityService implements OnModuleInit {
} else if (note.reply && (me.id === note.reply.userId)) {
// 自分の投稿に対するリプライ
return true;
} 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;
@ -533,19 +563,19 @@ export class NoteEntityService implements OnModuleInit {
const following = myFollowings?.has(note.userId);
const userHost = me.host;
/* 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);
}
}
return true;
}
*/
@bindThis
public async packAttachedFiles(fileIds: MiNote['fileIds'], packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
@ -649,6 +679,7 @@ export class NoteEntityService implements OnModuleInit {
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,
@ -719,12 +750,14 @@ export class NoteEntityService implements OnModuleInit {
} : {}),
});
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,
});
}