/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ 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'; import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm'; @Injectable() export class QueryService { constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @Inject(DI.renoteMutingsRepository) private renoteMutingsRepository: RenoteMutingsRepository, @Inject(DI.instancesRepository) private readonly instancesRepository: InstancesRepository, @Inject(DI.meta) private meta: MiMeta, private idService: IdService, ) { } public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder { if (sinceId && untilId) { q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); q.orderBy(`${q.alias}.id`, 'DESC'); } else if (sinceId) { q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); q.orderBy(`${q.alias}.id`, 'ASC'); } else if (untilId) { q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); q.orderBy(`${q.alias}.id`, 'DESC'); } else if (sinceDate && untilDate) { q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); q.orderBy(`${q.alias}.id`, 'DESC'); } else if (sinceDate) { q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); q.orderBy(`${q.alias}.id`, 'ASC'); } else if (untilDate) { q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); q.orderBy(`${q.alias}.id`, 'DESC'); } else { q.orderBy(`${q.alias}.id`, 'DESC'); } return q; } // ここでいうBlockedは被Blockedの意 @bindThis public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { // 投稿の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ // 投稿の引用元の作者にブロックされていない return this .andNotBlockingUser(q, 'note.userId', ':meId') .andWhere(new Brackets(qb => this .orNotBlockingUser(qb, 'note.replyUserId', ':meId') .orWhere('note.replyUserId IS NULL'))) .andWhere(new Brackets(qb => this .orNotBlockingUser(qb, 'note.renoteUserId', ':meId') .orWhere('note.renoteUserId IS NULL'))) .setParameters({ meId: me.id }); } @bindThis public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { this.andNotBlockingUser(q, ':meId', 'user.id'); this.andNotBlockingUser(q, 'user.id', ':me.id'); return q.setParameters({ meId: me.id }); } @bindThis public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { return this .andNotMutingThread(q, ':meId', 'note.id') .andWhere(new Brackets(qb => this .orNotMutingThread(qb, ':meId', 'note.threadId') .orWhere('note.threadId IS NULL'))) .setParameters({ meId: me.id }); } @bindThis public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder { // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない return this .andNotMutingUser(q, ':meId', 'note.userId', exclude) .andWhere(new Brackets(qb => this .orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude) .orWhere('note.replyUserId IS NULL'))) .andWhere(new Brackets(qb => this .orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude) .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'))) .setParameters({ meId: me.id }); } @bindThis public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { return this .andNotMutingUser(q, ':meId', 'user.id') .setParameters({ meId: me.id }); } // This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents. // NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads. // For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user. @bindThis public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { // This code must always be synchronized with the checks in Notes.isVisibleForMe. return q.andWhere(new Brackets(qb => { // Public post qb.orWhere('note.visibility = \'public\'') .orWhere('note.visibility = \'home\''); if (me != null) { qb // My post .orWhere(':meId = note.userId') // Reply to me .orWhere(':meId = note.replyUserId') // DM to me .orWhere(':meIdAsList <@ note.visibleUserIds') // Mentions me .orWhere(':meIdAsList <@ note.mentions') // Followers-only post .orWhere(new Brackets(qb => this .andFollowingUser(qb, ':meId', 'note.userId') .andWhere('note.visibility = \'followers\''))); q.setParameters({ meId: me.id, meIdAsList: [me.id] }); } })); } @bindThis public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { return q .andWhere(new Brackets(qb => this .orNotMutingRenote(qb, ':meId', 'note.userId') .orWhere('note.renoteId IS NULL') .orWhere('note.text IS NOT NULL') .orWhere('note.cw IS NOT NULL') .orWhere('note.replyId IS NOT NULL') .orWhere('note.hasPoll = true') .orWhere('note.fileIds != \'{}\''))) .setParameters({ meId: me.id }); } @bindThis public generateExcludedRenotesQueryForNotes(q: Q): Q { return this.andIsNotRenote(q, 'note'); } @bindThis public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): SelectQueryBuilder { const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this .leftJoinInstance(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 (!excludeAuthor) { checkFor('user'); } checkFor('replyUser'); checkFor('renoteUser'); return q; } @bindThis public generateSilencedUserQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { if (!me) { return q.andWhere('user.isSilenced = false'); } 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 }); } /** * 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. */ @bindThis public leftJoinInstance(q: SelectQueryBuilder, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder { // Skip if it's already joined, otherwise we'll get an error if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) { q.leftJoin(relation, alias, condition); } return q; } /** * Adds OR condition that noteProp (note ID) refers to a quote. * The prop should be an expression, not a raw value. */ @bindThis public orIsQuote(q: Q, noteProp: string): Q { return this.addIsQuote(q, noteProp, 'orWhere'); } /** * Adds AND condition that noteProp (note ID) refers to a quote. * The prop should be an expression, not a raw value. */ @bindThis public andIsQuote(q: Q, noteProp: string): Q { return this.addIsQuote(q, noteProp, 'andWhere'); } private addIsQuote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { return q[join](new Brackets(qb => qb .andWhere(`${noteProp}.renoteId IS NOT NULL`) .andWhere(new Brackets(qbb => qbb .orWhere(`${noteProp}.text IS NOT NULL`) .orWhere(`${noteProp}.cw IS NOT NULL`) .orWhere(`${noteProp}.replyId IS NOT NULL`) .orWhere(`${noteProp}.hasPoll = true`) .orWhere(`${noteProp}.fileIds != '{}'`))))); } /** * Adds OR condition that noteProp (note ID) does not refer to a quote. * The prop should be an expression, not a raw value. */ @bindThis public orIsNotQuote(q: Q, noteProp: string): Q { return this.addIsNotQuote(q, noteProp, 'orWhere'); } /** * Adds AND condition that noteProp (note ID) does not refer to a quote. * The prop should be an expression, not a raw value. */ @bindThis public andIsNotQuote(q: Q, noteProp: string): Q { return this.addIsNotQuote(q, noteProp, 'andWhere'); } private addIsNotQuote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { return q[join](new Brackets(qb => qb .orWhere(`${noteProp}.renoteId IS NULL`) .orWhere(new Brackets(qb => qb .andWhere(`${noteProp}.text IS NULL`) .andWhere(`${noteProp}.cw IS NULL`) .andWhere(`${noteProp}.replyId IS NULL`) .andWhere(`${noteProp}.hasPoll = false`) .andWhere(`${noteProp}.fileIds = '{}'`))))); } /** * Adds OR condition that noteProp (note ID) refers to a renote. * The prop should be an expression, not a raw value. */ @bindThis public orIsRenote(q: Q, noteProp: string): Q { return this.addIsRenote(q, noteProp, 'orWhere'); } /** * Adds AND condition that noteProp (note ID) refers to a renote. * The prop should be an expression, not a raw value. */ @bindThis public andIsRenote(q: Q, noteProp: string): Q { return this.addIsRenote(q, noteProp, 'andWhere'); } private addIsRenote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { return q[join](new Brackets(qb => qb .andWhere(`${noteProp}.renoteId IS NOT NULL`) .andWhere(`${noteProp}.text IS NULL`) .andWhere(`${noteProp}.cw IS NULL`) .andWhere(`${noteProp}.replyId IS NULL`) .andWhere(`${noteProp}.hasPoll = false`) .andWhere(`${noteProp}.fileIds = '{}'`))); } /** * Adds OR condition that noteProp (note ID) does not refer to a renote. * The prop should be an expression, not a raw value. */ @bindThis public orIsNotRenote(q: Q, noteProp: string): Q { return this.addIsNotRenote(q, noteProp, 'orWhere'); } /** * Adds AND condition that noteProp (note ID) does not refer to a renote. * The prop should be an expression, not a raw value. */ @bindThis public andIsNotRenote(q: Q, noteProp: string): Q { return this.addIsNotRenote(q, noteProp, 'andWhere'); } private addIsNotRenote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { return q[join](new Brackets(qb => qb .orWhere(`${noteProp}.renoteId IS NULL`) .orWhere(`${noteProp}.text IS NOT NULL`) .orWhere(`${noteProp}.cw IS NOT NULL`) .orWhere(`${noteProp}.replyId IS NOT NULL`) .orWhere(`${noteProp}.hasPoll = true`) .orWhere(`${noteProp}.fileIds != '{}'`))); } /** * Adds OR condition that followerProp (user ID) is following followeeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public orFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere'); } /** * Adds AND condition that followerProp (user ID) is following followeeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public andFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere'); } private addFollowingUser(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q { const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('1') .andWhere(`following.followerId = ${followerProp}`) .andWhere(`following.followeeId = ${followeeProp}`); return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); }; /** * Adds OR condition that followerProp (user ID) is following followeeProp (channel ID). * Both props should be expressions, not raw values. */ @bindThis public orFollowingChannel(q: Q, followerProp: string, followeeProp: string): Q { return this.addFollowingChannel(q, followerProp, followeeProp, 'orWhere'); } /** * Adds AND condition that followerProp (user ID) is following followeeProp (channel ID). * Both props should be expressions, not raw values. */ @bindThis public andFollowingChannel(q: Q, followerProp: string, followeeProp: string): Q { return this.addFollowingChannel(q, followerProp, followeeProp, 'andWhere'); } private addFollowingChannel(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q { const followingQuery = this.channelFollowingsRepository.createQueryBuilder('following') .select('1') .andWhere(`following.followerId = ${followerProp}`) .andWhere(`following.followeeId = ${followeeProp}`); return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); } /** * Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public orNotBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere'); } /** * Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public andNotBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere'); } private excludeBlockingUser(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q { const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') .select('1') .andWhere(`blocking.blockerId = ${blockerProp}`) .andWhere(`blocking.blockeeId = ${blockeeProp}`); return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters()); }; /** * Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public orNotMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude); } /** * Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public andNotMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude); } private excludeMutingUser(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('1') .andWhere(`muting.muterId = ${muterProp}`) .andWhere(`muting.muteeId = ${muteeProp}`); if (exclude) { mutingQuery.andWhere({ muteeId: Not(exclude.id) }); } return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); } /** * Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public orNotMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere'); } /** * Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis public andNotMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere'); } private excludeMutingRenote(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') .select('1') .andWhere(`renote_muting.muterId = ${muterProp}`) .andWhere(`renote_muting.muteeId = ${muteeProp}`); return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); }; /** * Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host). * Both props should be expressions, not raw values. */ @bindThis public orNotMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere'); } /** * Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host). * Both props should be expressions, not raw values. */ @bindThis public andNotMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere'); } private excludeMutingInstance(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') .select('1') .andWhere(`user_profile.userId = ${muterProp}`) .andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`); return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters()); } /** * Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID). * Both props should be expressions, not raw values. */ @bindThis public orNotMutingThread(q: Q, muterProp: string, muteeProp: string): Q { return this.excludeMutingThread(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 andNotMutingThread(q: Q, muterProp: string, muteeProp: string): Q { return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere'); } private excludeMutingThread(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}`); return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters()); } }