completely re-implement note visibility as NoteVisibilityService
This commit is contained in:
parent
d1912362e0
commit
85ca2269e4
27 changed files with 925 additions and 420 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,
|
||||
|
|
|
|||
|
|
@ -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をキャッシュする?
|
||||
|
|
|
|||
420
packages/backend/src/core/NoteVisibilityService.ts
Normal file
420
packages/backend/src/core/NoteVisibilityService.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue