)
+ case 'pre': {
+ if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+ text += '\n```\n';
+ text += getText(node.childNodes[0]);
+ text += '\n```\n';
+ } else {
+ appendChildren(node.childNodes);
+ }
+ break;
+ }
+
+ // inline code ()
+ case 'code': {
+ text += '`';
+ appendChildren(node.childNodes);
+ text += '`';
+ break;
+ }
+
+ case 'blockquote': {
+ const t = getText(node);
+ if (t) {
+ text += '\n> ';
+ text += t.split('\n').join('\n> ');
+ }
+ break;
+ }
+
+ case 'p':
+ case 'h2':
+ case 'h3':
+ case 'h4':
+ case 'h5':
+ case 'h6':
+ {
+ text += '\n\n';
+ appendChildren(node.childNodes);
+ break;
+ }
+
+ // other block elements
+ case 'div':
+ case 'header':
+ case 'footer':
+ case 'article':
+ case 'li':
+ case 'dt':
+ case 'dd':
+ {
+ text += '\n';
+ appendChildren(node.childNodes);
+ break;
+ }
+
+ default: // includes inline elements
+ {
+ appendChildren(node.childNodes);
+ break;
+ }
+ }
+ }
+ }
+
+ public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
+ if (nodes == null) {
+ return null;
+ }
+
+ const { window } = new JSDOM('');
+
+ const doc = window.document;
+
+ function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
+ if (children) {
+ for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
+ }
+ }
+
+ const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+ bold: (node) => {
+ const el = doc.createElement('b');
+ appendChildren(node.children, el);
+ return el;
+ },
+
+ small: (node) => {
+ const el = doc.createElement('small');
+ appendChildren(node.children, el);
+ return el;
+ },
+
+ strike: (node) => {
+ const el = doc.createElement('del');
+ appendChildren(node.children, el);
+ return el;
+ },
+
+ italic: (node) => {
+ const el = doc.createElement('i');
+ appendChildren(node.children, el);
+ return el;
+ },
+
+ fn: (node) => {
+ const el = doc.createElement('i');
+ appendChildren(node.children, el);
+ return el;
+ },
+
+ blockCode: (node) => {
+ const pre = doc.createElement('pre');
+ const inner = doc.createElement('code');
+ inner.textContent = node.props.code;
+ pre.appendChild(inner);
+ return pre;
+ },
+
+ center: (node) => {
+ const el = doc.createElement('div');
+ appendChildren(node.children, el);
+ return el;
+ },
+
+ emojiCode: (node) => {
+ return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+ },
+
+ unicodeEmoji: (node) => {
+ return doc.createTextNode(node.props.emoji);
+ },
+
+ hashtag: (node) => {
+ const a = doc.createElement('a');
+ a.href = `${this.config.url}/tags/${node.props.hashtag}`;
+ a.textContent = `#${node.props.hashtag}`;
+ a.setAttribute('rel', 'tag');
+ return a;
+ },
+
+ inlineCode: (node) => {
+ const el = doc.createElement('code');
+ el.textContent = node.props.code;
+ return el;
+ },
+
+ mathInline: (node) => {
+ const el = doc.createElement('code');
+ el.textContent = node.props.formula;
+ return el;
+ },
+
+ mathBlock: (node) => {
+ const el = doc.createElement('code');
+ el.textContent = node.props.formula;
+ return el;
+ },
+
+ link: (node) => {
+ const a = doc.createElement('a');
+ a.href = node.props.url;
+ appendChildren(node.children, a);
+ return a;
+ },
+
+ mention: (node) => {
+ const a = doc.createElement('a');
+ const { username, host, acct } = node.props;
+ const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
+ a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`;
+ a.className = 'u-url mention';
+ a.textContent = acct;
+ return a;
+ },
+
+ quote: (node) => {
+ const el = doc.createElement('blockquote');
+ appendChildren(node.children, el);
+ return el;
+ },
+
+ text: (node) => {
+ const el = doc.createElement('span');
+ const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
+
+ for (const x of intersperse('br', nodes)) {
+ el.appendChild(x === 'br' ? doc.createElement('br') : x);
+ }
+
+ return el;
+ },
+
+ url: (node) => {
+ const a = doc.createElement('a');
+ a.href = node.props.url;
+ a.textContent = node.props.url;
+ return a;
+ },
+
+ search: (node) => {
+ const a = doc.createElement('a');
+ a.href = `https://www.google.com/search?q=${node.props.query}`;
+ a.textContent = node.props.content;
+ return a;
+ },
+
+ plain: (node) => {
+ const el = doc.createElement('span');
+ appendChildren(node.children, el);
+ return el;
+ },
+ };
+
+ appendChildren(nodes, doc.body);
+
+ return `${doc.body.innerHTML}
`;
+ }
+}
diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts
new file mode 100644
index 000000000..191148ac2
--- /dev/null
+++ b/packages/backend/src/core/ModerationLogService.ts
@@ -0,0 +1,26 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import { ModerationLogsRepository } from '@/models/index.js';
+import type { User } from '@/models/entities/User.js';
+import { IdService } from '@/core/IdService.js';
+
+@Injectable()
+export class ModerationLogService {
+ constructor(
+ @Inject(DI.moderationLogsRepository)
+ private moderationLogsRepository: ModerationLogsRepository,
+
+ private idService: IdService,
+ ) {
+ }
+
+ public async insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record) {
+ await this.moderationLogsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ userId: moderator.id,
+ type: type,
+ info: info ?? {},
+ });
+ }
+}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
new file mode 100644
index 000000000..5acc07fba
--- /dev/null
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -0,0 +1,742 @@
+import * as mfm from 'mfm-js';
+import { Not, In, DataSource } from 'typeorm';
+import { Inject, Injectable } from '@nestjs/common';
+import { extractMentions } from '@/misc/extract-mentions.js';
+import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
+import { extractHashtags } from '@/misc/extract-hashtags.js';
+import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
+import { Note } from '@/models/entities/Note.js';
+import { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
+import type { DriveFile } from '@/models/entities/DriveFile.js';
+import type { App } from '@/models/entities/App.js';
+import { concat } from '@/misc/prelude/array.js';
+import { IdService } from '@/core/IdService.js';
+import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
+import type { IPoll } from '@/models/entities/Poll.js';
+import { Poll } from '@/models/entities/Poll.js';
+import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
+import { checkWordMute } from '@/misc/check-word-mute.js';
+import type { Channel } from '@/models/entities/Channel.js';
+import { normalizeForSearch } from '@/misc/normalize-for-search.js';
+import { Cache } from '@/misc/cache.js';
+import type { UserProfile } from '@/models/entities/UserProfile.js';
+import { RelayService } from '@/core/RelayService.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { DI } from '@/di-symbols.js';
+import { Config } from '@/config.js';
+import NotesChart from '@/core/chart/charts/notes.js';
+import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+import ActiveUsersChart from '@/core/chart/charts/active-users.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { CreateNotificationService } from '@/core/CreateNotificationService.js';
+import { WebhookService } from '@/core/WebhookService.js';
+import { HashtagService } from '@/core/HashtagService.js';
+import { AntennaService } from '@/core/AntennaService.js';
+import { QueueService } from '@/core/QueueService.js';
+import { NoteEntityService } from './entities/NoteEntityService.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { NoteReadService } from './NoteReadService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+import { ResolveUserService } from './remote/ResolveUserService.js';
+import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
+
+const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
+
+type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
+
+class NotificationManager {
+ private notifier: { id: User['id']; };
+ private note: Note;
+ private queue: {
+ target: ILocalUser['id'];
+ reason: NotificationType;
+ }[];
+
+ constructor(
+ private mutingsRepository: MutingsRepository,
+ private createNotificationService: CreateNotificationService,
+ notifier: { id: User['id']; },
+ note: Note,
+ ) {
+ this.notifier = notifier;
+ this.note = note;
+ this.queue = [];
+ }
+
+ public push(notifiee: ILocalUser['id'], reason: NotificationType) {
+ // 自分自身へは通知しない
+ if (this.notifier.id === notifiee) return;
+
+ const exist = this.queue.find(x => x.target === notifiee);
+
+ if (exist) {
+ // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
+ if (reason !== 'mention') {
+ exist.reason = reason;
+ }
+ } else {
+ this.queue.push({
+ reason: reason,
+ target: notifiee,
+ });
+ }
+ }
+
+ public async deliver() {
+ for (const x of this.queue) {
+ // ミュート情報を取得
+ const mentioneeMutes = await this.mutingsRepository.findBy({
+ muterId: x.target,
+ });
+
+ const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId);
+
+ // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
+ if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
+ this.createNotificationService.createNotification(x.target, x.reason, {
+ notifierId: this.notifier.id,
+ noteId: this.note.id,
+ });
+ }
+ }
+ }
+}
+
+type MinimumUser = {
+ id: User['id'];
+ host: User['host'];
+ username: User['username'];
+ uri: User['uri'];
+};
+
+type Option = {
+ createdAt?: Date | null;
+ name?: string | null;
+ text?: string | null;
+ reply?: Note | null;
+ renote?: Note | null;
+ files?: DriveFile[] | null;
+ poll?: IPoll | null;
+ localOnly?: boolean | null;
+ cw?: string | null;
+ visibility?: string;
+ visibleUsers?: MinimumUser[] | null;
+ channel?: Channel | null;
+ apMentions?: MinimumUser[] | null;
+ apHashtags?: string[] | null;
+ apEmojis?: string[] | null;
+ uri?: string | null;
+ url?: string | null;
+ app?: App | null;
+};
+
+@Injectable()
+export class NoteCreateService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
+ @Inject(DI.userProfilesRepository)
+ private userProfilesRepository: UserProfilesRepository,
+
+ @Inject(DI.mutedNotesRepository)
+ private mutedNotesRepository: MutedNotesRepository,
+
+ @Inject(DI.channelsRepository)
+ private channelsRepository: ChannelsRepository,
+
+ @Inject(DI.channelFollowingsRepository)
+ private channelFollowingsRepository: ChannelFollowingsRepository,
+
+ @Inject(DI.noteThreadMutingsRepository)
+ private noteThreadMutingsRepository: NoteThreadMutingsRepository,
+
+ private userEntityService: UserEntityService,
+ private noteEntityService: NoteEntityService,
+ private idService: IdService,
+ private globalEventServie: GlobalEventService,
+ private queueService: QueueService,
+ private noteReadService: NoteReadService,
+ private createNotificationService: CreateNotificationService,
+ private relayService: RelayService,
+ private federatedInstanceService: FederatedInstanceService,
+ private hashtagService: HashtagService,
+ private antennaService: AntennaService,
+ private webhookService: WebhookService,
+ private resolveUserService: ResolveUserService,
+ private apDeliverManagerService: ApDeliverManagerService,
+ private apRendererService: ApRendererService,
+ private notesChart: NotesChart,
+ private perUserNotesChart: PerUserNotesChart,
+ private activeUsersChart: ActiveUsersChart,
+ private instanceChart: InstanceChart,
+ ) {}
+
+ public async create(user: {
+ id: User['id'];
+ username: User['username'];
+ host: User['host'];
+ isSilenced: User['isSilenced'];
+ createdAt: User['createdAt'];
+ }, data: Option, silent = false): Promise {
+ // チャンネル外にリプライしたら対象のスコープに合わせる
+ // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
+ if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
+ if (data.reply.channelId) {
+ data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
+ } else {
+ data.channel = null;
+ }
+ }
+
+ // チャンネル内にリプライしたら対象のスコープに合わせる
+ // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
+ if (data.reply && (data.channel == null) && data.reply.channelId) {
+ data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
+ }
+
+ if (data.createdAt == null) data.createdAt = new Date();
+ if (data.visibility == null) data.visibility = 'public';
+ if (data.localOnly == null) data.localOnly = false;
+ if (data.channel != null) data.visibility = 'public';
+ if (data.channel != null) data.visibleUsers = [];
+ if (data.channel != null) data.localOnly = true;
+
+ // サイレンス
+ if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
+ data.visibility = 'home';
+ }
+
+ // Renote対象が「ホームまたは全体」以外の公開範囲ならreject
+ if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
+ throw new Error('Renote target is not public or home');
+ }
+
+ // Renote対象がpublicではないならhomeにする
+ if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
+ data.visibility = 'home';
+ }
+
+ // Renote対象がfollowersならfollowersにする
+ if (data.renote && data.renote.visibility === 'followers') {
+ data.visibility = 'followers';
+ }
+
+ // 返信対象がpublicではないならhomeにする
+ if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
+ data.visibility = 'home';
+ }
+
+ // ローカルのみをRenoteしたらローカルのみにする
+ if (data.renote && data.renote.localOnly && data.channel == null) {
+ data.localOnly = true;
+ }
+
+ // ローカルのみにリプライしたらローカルのみにする
+ if (data.reply && data.reply.localOnly && data.channel == null) {
+ data.localOnly = true;
+ }
+
+ if (data.text) {
+ data.text = data.text.trim();
+ } else {
+ data.text = null;
+ }
+
+ let tags = data.apHashtags;
+ let emojis = data.apEmojis;
+ let mentionedUsers = data.apMentions;
+
+ // Parse MFM if needed
+ if (!tags || !emojis || !mentionedUsers) {
+ const tokens = data.text ? mfm.parse(data.text)! : [];
+ const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
+ const choiceTokens = data.poll && data.poll.choices
+ ? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
+ : [];
+
+ const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
+
+ tags = data.apHashtags ?? extractHashtags(combinedTokens);
+
+ emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
+
+ mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
+ }
+
+ tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32);
+
+ if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
+ mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
+ }
+
+ if (data.visibility === 'specified') {
+ if (data.visibleUsers == null) throw new Error('invalid param');
+
+ for (const u of data.visibleUsers) {
+ if (!mentionedUsers.some(x => x.id === u.id)) {
+ mentionedUsers.push(u);
+ }
+ }
+
+ if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) {
+ data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
+ }
+ }
+
+ const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
+
+ setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!));
+
+ return note;
+ }
+
+ private async insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
+ const insert = new Note({
+ id: this.idService.genId(data.createdAt!),
+ createdAt: data.createdAt!,
+ fileIds: data.files ? data.files.map(file => file.id) : [],
+ replyId: data.reply ? data.reply.id : null,
+ renoteId: data.renote ? data.renote.id : null,
+ channelId: data.channel ? data.channel.id : null,
+ threadId: data.reply
+ ? data.reply.threadId
+ ? data.reply.threadId
+ : data.reply.id
+ : null,
+ name: data.name,
+ text: data.text,
+ hasPoll: data.poll != null,
+ cw: data.cw == null ? null : data.cw,
+ tags: tags.map(tag => normalizeForSearch(tag)),
+ emojis,
+ userId: user.id,
+ localOnly: data.localOnly!,
+ visibility: data.visibility as any,
+ visibleUserIds: data.visibility === 'specified'
+ ? data.visibleUsers
+ ? data.visibleUsers.map(u => u.id)
+ : []
+ : [],
+
+ attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
+
+ // 以下非正規化データ
+ replyUserId: data.reply ? data.reply.userId : null,
+ replyUserHost: data.reply ? data.reply.userHost : null,
+ renoteUserId: data.renote ? data.renote.userId : null,
+ renoteUserHost: data.renote ? data.renote.userHost : null,
+ userHost: user.host,
+ });
+
+ if (data.uri != null) insert.uri = data.uri;
+ if (data.url != null) insert.url = data.url;
+
+ // Append mentions data
+ if (mentionedUsers.length > 0) {
+ insert.mentions = mentionedUsers.map(u => u.id);
+ const profiles = await this.userProfilesRepository.findBy({ userId: In(insert.mentions) });
+ insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => {
+ const profile = profiles.find(p => p.userId === u.id);
+ const url = profile != null ? profile.url : null;
+ return {
+ uri: u.uri,
+ url: url == null ? undefined : url,
+ username: u.username,
+ host: u.host,
+ } as IMentionedRemoteUsers[0];
+ }));
+ }
+
+ // 投稿を作成
+ try {
+ if (insert.hasPoll) {
+ // Start transaction
+ await this.db.transaction(async transactionalEntityManager => {
+ await transactionalEntityManager.insert(Note, insert);
+
+ const poll = new Poll({
+ noteId: insert.id,
+ choices: data.poll!.choices,
+ expiresAt: data.poll!.expiresAt,
+ multiple: data.poll!.multiple,
+ votes: new Array(data.poll!.choices.length).fill(0),
+ noteVisibility: insert.visibility,
+ userId: user.id,
+ userHost: user.host,
+ });
+
+ await transactionalEntityManager.insert(Poll, poll);
+ });
+ } else {
+ await this.notesRepository.insert(insert);
+ }
+
+ return insert;
+ } catch (e) {
+ // duplicate key error
+ if (isDuplicateKeyValueError(e)) {
+ const err = new Error('Duplicated note');
+ err.name = 'duplicated';
+ throw err;
+ }
+
+ console.error(e);
+
+ throw e;
+ }
+ }
+
+ private async postNoteCreated(note: Note, user: {
+ id: User['id'];
+ username: User['username'];
+ host: User['host'];
+ isSilenced: User['isSilenced'];
+ createdAt: User['createdAt'];
+ }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
+ // 統計を更新
+ this.notesChart.update(note, true);
+ this.perUserNotesChart.update(user, note, true);
+
+ // Register host
+ if (this.userEntityService.isRemoteUser(user)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
+ this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
+ this.instanceChart.updateNote(i.host, note, true);
+ });
+ }
+
+ // ハッシュタグ更新
+ if (data.visibility === 'public' || data.visibility === 'home') {
+ this.hashtagService.updateHashtags(user, tags);
+ }
+
+ // Increment notes count (user)
+ this.incNotesCountOfUser(user);
+
+ // Word mute
+ mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({
+ where: {
+ enableWordMute: true,
+ },
+ select: ['userId', 'mutedWords'],
+ })).then(us => {
+ for (const u of us) {
+ checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
+ if (shouldMute) {
+ this.mutedNotesRepository.insert({
+ id: this.idService.genId(),
+ userId: u.userId,
+ noteId: note.id,
+ reason: 'word',
+ });
+ }
+ });
+ }
+ });
+
+ // Antenna
+ for (const antenna of (await this.antennaService.getAntennas())) {
+ this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
+ if (hit) {
+ this.antennaService.addNoteToAntenna(antenna, note, user);
+ }
+ });
+ }
+
+ // Channel
+ if (note.channelId) {
+ this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => {
+ for (const following of followings) {
+ this.noteReadService.insertNoteUnread(following.followerId, note, {
+ isSpecified: false,
+ isMentioned: false,
+ });
+ }
+ });
+ }
+
+ if (data.reply) {
+ this.saveReply(data.reply, note);
+ }
+
+ // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
+ if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
+ this.incRenoteCount(data.renote);
+ }
+
+ if (data.poll && data.poll.expiresAt) {
+ const delay = data.poll.expiresAt.getTime() - Date.now();
+ this.queueService.endedPollNotificationQueue.add({
+ noteId: note.id,
+ }, {
+ delay,
+ removeOnComplete: true,
+ });
+ }
+
+ if (!silent) {
+ if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
+
+ // 未読通知を作成
+ if (data.visibility === 'specified') {
+ if (data.visibleUsers == null) throw new Error('invalid param');
+
+ for (const u of data.visibleUsers) {
+ // ローカルユーザーのみ
+ if (!this.userEntityService.isLocalUser(u)) continue;
+
+ this.noteReadService.insertNoteUnread(u.id, note, {
+ isSpecified: true,
+ isMentioned: false,
+ });
+ }
+ } else {
+ for (const u of mentionedUsers) {
+ // ローカルユーザーのみ
+ if (!this.userEntityService.isLocalUser(u)) continue;
+
+ this.noteReadService.insertNoteUnread(u.id, note, {
+ isSpecified: false,
+ isMentioned: true,
+ });
+ }
+ }
+
+ // Pack the note
+ const noteObj = await this.noteEntityService.pack(note);
+
+ this.globalEventServie.publishNotesStream(noteObj);
+
+ this.webhookService.getActiveWebhooks().then(webhooks => {
+ webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'note', {
+ note: noteObj,
+ });
+ }
+ });
+
+ const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note);
+ const nmRelatedPromises = [];
+
+ await this.createMentionedEvents(mentionedUsers, note, nm);
+
+ // If has in reply to note
+ if (data.reply) {
+ // 通知
+ if (data.reply.userHost === null) {
+ const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
+ userId: data.reply.userId,
+ threadId: data.reply.threadId ?? data.reply.id,
+ });
+
+ if (!threadMuted) {
+ nm.push(data.reply.userId, 'reply');
+ this.globalEventServie.publishMainStream(data.reply.userId, 'reply', noteObj);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'reply', {
+ note: noteObj,
+ });
+ }
+ }
+ }
+ }
+
+ // If it is renote
+ if (data.renote) {
+ const type = data.text ? 'quote' : 'renote';
+
+ // Notify
+ if (data.renote.userHost === null) {
+ nm.push(data.renote.userId, type);
+ }
+
+ // Publish event
+ if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
+ this.globalEventServie.publishMainStream(data.renote.userId, 'renote', noteObj);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'renote', {
+ note: noteObj,
+ });
+ }
+ }
+ }
+
+ Promise.all(nmRelatedPromises).then(() => {
+ nm.deliver();
+ });
+
+ //#region AP deliver
+ if (this.userEntityService.isLocalUser(user)) {
+ (async () => {
+ const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
+ const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
+
+ // メンションされたリモートユーザーに配送
+ for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) {
+ dm.addDirectRecipe(u as IRemoteUser);
+ }
+
+ // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
+ if (data.reply && data.reply.userHost !== null) {
+ const u = await this.usersRepository.findOneBy({ id: data.reply.userId });
+ if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
+ }
+
+ // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
+ if (data.renote && data.renote.userHost !== null) {
+ const u = await this.usersRepository.findOneBy({ id: data.renote.userId });
+ if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
+ }
+
+ // フォロワーに配送
+ if (['public', 'home', 'followers'].includes(note.visibility)) {
+ dm.addFollowersRecipe();
+ }
+
+ if (['public'].includes(note.visibility)) {
+ this.relayService.deliverToRelays(user, noteActivity);
+ }
+
+ dm.execute();
+ })();
+ }
+ //#endregion
+ }
+
+ if (data.channel) {
+ this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
+ this.channelsRepository.update(data.channel.id, {
+ lastNotedAt: new Date(),
+ });
+
+ this.notesRepository.countBy({
+ userId: user.id,
+ channelId: data.channel.id,
+ }).then(count => {
+ // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
+ // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
+ if (count === 1) {
+ this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1);
+ }
+ });
+ }
+
+ // Register to search database
+ this.index(note);
+ }
+
+ private incRenoteCount(renote: Note) {
+ this.notesRepository.createQueryBuilder().update()
+ .set({
+ renoteCount: () => '"renoteCount" + 1',
+ score: () => '"score" + 1',
+ })
+ .where('id = :id', { id: renote.id })
+ .execute();
+ }
+
+ private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) {
+ for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
+ const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
+ userId: u.id,
+ threadId: note.threadId ?? note.id,
+ });
+
+ if (threadMuted) {
+ continue;
+ }
+
+ const detailPackedNote = await this.noteEntityService.pack(note, u, {
+ detail: true,
+ });
+
+ this.globalEventServie.publishMainStream(u.id, 'mention', detailPackedNote);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'mention', {
+ note: detailPackedNote,
+ });
+ }
+
+ // Create notification
+ nm.push(u.id, 'mention');
+ }
+ }
+
+ private saveReply(reply: Note, note: Note) {
+ this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
+ }
+
+ private async renderNoteOrRenoteActivity(data: Option, note: Note) {
+ if (data.localOnly) return null;
+
+ const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)
+ ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
+ : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);
+
+ return this.apRendererService.renderActivity(content);
+ }
+
+ private index(note: Note) {
+ if (note.text == null || this.config.elasticsearch == null) return;
+ /*
+ es!.index({
+ index: this.config.elasticsearch.index ?? 'misskey_note',
+ id: note.id.toString(),
+ body: {
+ text: normalizeForSearch(note.text),
+ userId: note.userId,
+ userHost: note.userHost,
+ },
+ });*/
+ }
+
+ private incNotesCountOfUser(user: { id: User['id']; }) {
+ this.usersRepository.createQueryBuilder().update()
+ .set({
+ updatedAt: new Date(),
+ notesCount: () => '"notesCount" + 1',
+ })
+ .where('id = :id', { id: user.id })
+ .execute();
+ }
+
+ private async extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise {
+ if (tokens == null) return [];
+
+ const mentions = extractMentions(tokens);
+ let mentionedUsers = (await Promise.all(mentions.map(m =>
+ this.resolveUserService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
+ ))).filter(x => x != null) as User[];
+
+ // Drop duplicate users
+ mentionedUsers = mentionedUsers.filter((u, i, self) =>
+ i === self.findIndex(u2 => u.id === u2.id),
+ );
+
+ return mentionedUsers;
+ }
+}
diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts
new file mode 100644
index 000000000..6365286f8
--- /dev/null
+++ b/packages/backend/src/core/NoteDeleteService.ts
@@ -0,0 +1,168 @@
+import { Brackets, In } from 'typeorm';
+import { Injectable, Inject } from '@nestjs/common';
+import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
+import type { Note, IMentionedRemoteUsers } from '@/models/entities/Note.js';
+import { InstancesRepository, NotesRepository, UsersRepository } from '@/models/index.js';
+import { RelayService } from '@/core/RelayService.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { DI } from '@/di-symbols.js';
+import { Config } from '@/config.js';
+import NotesChart from '@/core/chart/charts/notes.js';
+import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+
+@Injectable()
+export class NoteDeleteService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
+ private userEntityService: UserEntityService,
+ private globalEventServie: GlobalEventService,
+ private relayService: RelayService,
+ private federatedInstanceService: FederatedInstanceService,
+ private apRendererService: ApRendererService,
+ private apDeliverManagerService: ApDeliverManagerService,
+ private notesChart: NotesChart,
+ private perUserNotesChart: PerUserNotesChart,
+ private instanceChart: InstanceChart,
+ ) {}
+
+ /**
+ * 投稿を削除します。
+ * @param user 投稿者
+ * @param note 投稿
+ */
+ async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) {
+ const deletedAt = new Date();
+
+ // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
+ if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
+ this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1);
+ this.notesRepository.decrement({ id: note.renoteId }, 'score', 1);
+ }
+
+ if (note.replyId) {
+ await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
+ }
+
+ if (!quiet) {
+ this.globalEventServie.publishNoteStream(note.id, 'deleted', {
+ deletedAt: deletedAt,
+ });
+
+ //#region ローカルの投稿なら削除アクティビティを配送
+ if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
+ let renote: Note | null = null;
+
+ // if deletd note is renote
+ if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
+ renote = await this.notesRepository.findOneBy({
+ id: note.renoteId,
+ });
+ }
+
+ const content = this.apRendererService.renderActivity(renote
+ ? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
+ : this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
+
+ this.deliverToConcerned(user, note, content);
+ }
+
+ // also deliever delete activity to cascaded notes
+ const cascadingNotes = (await this.findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
+ for (const cascadingNote of cascadingNotes) {
+ if (!cascadingNote.user) continue;
+ if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
+ this.deliverToConcerned(cascadingNote.user, cascadingNote, content);
+ }
+ //#endregion
+
+ // 統計を更新
+ this.notesChart.update(note, false);
+ this.perUserNotesChart.update(user, note, false);
+
+ if (this.userEntityService.isRemoteUser(user)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
+ this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
+ this.instanceChart.updateNote(i.host, note, false);
+ });
+ }
+ }
+
+ await this.notesRepository.delete({
+ id: note.id,
+ userId: user.id,
+ });
+ }
+
+ private async findCascadingNotes(note: Note) {
+ const cascadingNotes: Note[] = [];
+
+ const recursive = async (noteId: string) => {
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.replyId = :noteId', { noteId })
+ .orWhere(new Brackets(q => {
+ q.where('note.renoteId = :noteId', { noteId })
+ .andWhere('note.text IS NOT NULL');
+ }))
+ .leftJoinAndSelect('note.user', 'user');
+ const replies = await query.getMany();
+ for (const reply of replies) {
+ cascadingNotes.push(reply);
+ await recursive(reply.id);
+ }
+ };
+ await recursive(note.id);
+
+ return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
+ }
+
+ private async getMentionedRemoteUsers(note: Note) {
+ const where = [] as any[];
+
+ // mention / reply / dm
+ const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
+ if (uris.length > 0) {
+ where.push(
+ { uri: In(uris) },
+ );
+ }
+
+ // renote / quote
+ if (note.renoteUserId) {
+ where.push({
+ id: note.renoteUserId,
+ });
+ }
+
+ if (where.length === 0) return [];
+
+ return await this.usersRepository.find({
+ where,
+ }) as IRemoteUser[];
+ }
+
+ private async deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) {
+ this.apDeliverManagerService.deliverToFollowers(user, content);
+ this.relayService.deliverToRelays(user, content);
+ const remoteUsers = await this.getMentionedRemoteUsers(note);
+ for (const remoteUser of remoteUsers) {
+ this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
+ }
+ }
+}
diff --git a/packages/backend/src/core/NotePiningService.ts b/packages/backend/src/core/NotePiningService.ts
new file mode 100644
index 000000000..576e90bd4
--- /dev/null
+++ b/packages/backend/src/core/NotePiningService.ts
@@ -0,0 +1,117 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import { UsersRepository } from '@/models/index.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import type { User } from '@/models/entities/User.js';
+import type { Note } from '@/models/entities/Note.js';
+import { IdService } from '@/core/IdService.js';
+import type { UserNotePining } from '@/models/entities/UserNotePining.js';
+import { RelayService } from '@/core/RelayService.js';
+import { Config } from '@/config.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+
+@Injectable()
+export class NotePiningService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.userNotePiningsRepository)
+ private userNotePiningsRepository: UserNotePiningsRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private relayService: RelayService,
+ private apDeliverManagerService: ApDeliverManagerService,
+ private apRendererService: ApRendererService,
+ ) {
+ }
+
+ /**
+ * 指定した投稿をピン留めします
+ * @param user
+ * @param noteId
+ */
+ public async addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
+ // Fetch pinee
+ const note = await this.notesRepository.findOneBy({
+ id: noteId,
+ userId: user.id,
+ });
+
+ if (note == null) {
+ throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
+ }
+
+ const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id });
+
+ if (pinings.length >= 5) {
+ throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
+ }
+
+ if (pinings.some(pining => pining.noteId === note.id)) {
+ throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
+ }
+
+ await this.userNotePiningsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ userId: user.id,
+ noteId: note.id,
+ } as UserNotePining);
+
+ // Deliver to remote followers
+ if (this.userEntityService.isLocalUser(user)) {
+ this.deliverPinnedChange(user.id, note.id, true);
+ }
+ }
+
+ /**
+ * 指定した投稿のピン留めを解除します
+ * @param user
+ * @param noteId
+ */
+ public async removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
+ // Fetch unpinee
+ const note = await this.notesRepository.findOneBy({
+ id: noteId,
+ userId: user.id,
+ });
+
+ if (note == null) {
+ throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
+ }
+
+ this.userNotePiningsRepository.delete({
+ userId: user.id,
+ noteId: note.id,
+ });
+
+ // Deliver to remote followers
+ if (this.userEntityService.isLocalUser(user)) {
+ this.deliverPinnedChange(user.id, noteId, false);
+ }
+ }
+
+ public async deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) {
+ const user = await this.usersRepository.findOneBy({ id: userId });
+ if (user == null) throw new Error('user not found');
+
+ if (!this.userEntityService.isLocalUser(user)) return;
+
+ const target = `${this.config.url}/users/${user.id}/collections/featured`;
+ const item = `${this.config.url}/notes/${noteId}`;
+ const content = this.apRendererService.renderActivity(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item));
+
+ this.apDeliverManagerService.deliverToFollowers(user, content);
+ this.relayService.deliverToRelays(user, content);
+ }
+}
diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts
new file mode 100644
index 000000000..b1572c631
--- /dev/null
+++ b/packages/backend/src/core/NoteReadService.ts
@@ -0,0 +1,214 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { In, IsNull, Not } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { User } from '@/models/entities/User.js';
+import type { Channel } from '@/models/entities/Channel.js';
+import type { Packed } from '@/misc/schema.js';
+import type { Note } from '@/models/entities/Note.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { NotificationService } from './NotificationService.js';
+import { AntennaService } from './AntennaService.js';
+
+@Injectable()
+export class NoteReadService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.noteUnreadsRepository)
+ private noteUnreadsRepository: NoteUnreadsRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+
+ @Inject(DI.noteThreadMutingsRepository)
+ private noteThreadMutingsRepository: NoteThreadMutingsRepository,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ @Inject(DI.channelFollowingsRepository)
+ private channelFollowingsRepository: ChannelFollowingsRepository,
+
+ @Inject(DI.antennaNotesRepository)
+ private antennaNotesRepository: AntennaNotesRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private globalEventServie: GlobalEventService,
+ private notificationService: NotificationService,
+ private antennaService: AntennaService,
+ ) {
+ }
+
+ public async insertNoteUnread(userId: User['id'], note: Note, params: {
+ // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
+ isSpecified: boolean;
+ isMentioned: boolean;
+ }): Promise {
+ //#region ミュートしているなら無視
+ // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
+ const mute = await this.mutingsRepository.findBy({
+ muterId: userId,
+ });
+ if (mute.map(m => m.muteeId).includes(note.userId)) return;
+ //#endregion
+
+ // スレッドミュート
+ const threadMute = await this.noteThreadMutingsRepository.findOneBy({
+ userId: userId,
+ threadId: note.threadId ?? note.id,
+ });
+ if (threadMute) return;
+
+ const unread = {
+ id: this.idService.genId(),
+ noteId: note.id,
+ userId: userId,
+ isSpecified: params.isSpecified,
+ isMentioned: params.isMentioned,
+ noteChannelId: note.channelId,
+ noteUserId: note.userId,
+ };
+
+ await this.noteUnreadsRepository.insert(unread);
+
+ // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
+ setTimeout(async () => {
+ const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
+
+ if (exist == null) return;
+
+ if (params.isMentioned) {
+ this.globalEventServie.publishMainStream(userId, 'unreadMention', note.id);
+ }
+ if (params.isSpecified) {
+ this.globalEventServie.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
+ }
+ if (note.channelId) {
+ this.globalEventServie.publishMainStream(userId, 'unreadChannel', note.id);
+ }
+ }, 2000);
+ }
+
+ public async read(
+ userId: User['id'],
+ notes: (Note | Packed<'Note'>)[],
+ info?: {
+ following: Set;
+ followingChannels: Set;
+ },
+ ): Promise {
+ const following = info?.following ? info.following : new Set((await this.followingsRepository.find({
+ where: {
+ followerId: userId,
+ },
+ select: ['followeeId'],
+ })).map(x => x.followeeId));
+ const followingChannels = info?.followingChannels ? info.followingChannels : new Set((await this.channelFollowingsRepository.find({
+ where: {
+ followerId: userId,
+ },
+ select: ['followeeId'],
+ })).map(x => x.followeeId));
+
+ const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
+ const readMentions: (Note | Packed<'Note'>)[] = [];
+ const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
+ const readChannelNotes: (Note | Packed<'Note'>)[] = [];
+ const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
+
+ for (const note of notes) {
+ if (note.mentions && note.mentions.includes(userId)) {
+ readMentions.push(note);
+ } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
+ readSpecifiedNotes.push(note);
+ }
+
+ if (note.channelId && followingChannels.has(note.channelId)) {
+ readChannelNotes.push(note);
+ }
+
+ if (note.user != null) { // たぶんnullになることは無いはずだけど一応
+ for (const antenna of myAntennas) {
+ if (await this.antennaService.checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) {
+ readAntennaNotes.push(note);
+ }
+ }
+ }
+ }
+
+ if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
+ // Remove the record
+ await this.noteUnreadsRepository.delete({
+ userId: userId,
+ noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
+ });
+
+ // TODO: ↓まとめてクエリしたい
+
+ this.noteUnreadsRepository.countBy({
+ userId: userId,
+ isMentioned: true,
+ }).then(mentionsCount => {
+ if (mentionsCount === 0) {
+ // 全て既読になったイベントを発行
+ this.globalEventServie.publishMainStream(userId, 'readAllUnreadMentions');
+ }
+ });
+
+ this.noteUnreadsRepository.countBy({
+ userId: userId,
+ isSpecified: true,
+ }).then(specifiedCount => {
+ if (specifiedCount === 0) {
+ // 全て既読になったイベントを発行
+ this.globalEventServie.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
+ }
+ });
+
+ this.noteUnreadsRepository.countBy({
+ userId: userId,
+ noteChannelId: Not(IsNull()),
+ }).then(channelNoteCount => {
+ if (channelNoteCount === 0) {
+ // 全て既読になったイベントを発行
+ this.globalEventServie.publishMainStream(userId, 'readAllChannels');
+ }
+ });
+
+ this.notificationService.readNotificationByQuery(userId, {
+ noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
+ });
+ }
+
+ if (readAntennaNotes.length > 0) {
+ await this.antennaNotesRepository.update({
+ antennaId: In(myAntennas.map(a => a.id)),
+ noteId: In(readAntennaNotes.map(n => n.id)),
+ }, {
+ read: true,
+ });
+
+ // TODO: まとめてクエリしたい
+ for (const antenna of myAntennas) {
+ const count = await this.antennaNotesRepository.countBy({
+ antennaId: antenna.id,
+ read: false,
+ });
+
+ if (count === 0) {
+ this.globalEventServie.publishMainStream(userId, 'readAntenna', antenna);
+ }
+ }
+
+ this.userEntityService.getHasUnreadAntenna(userId).then(unread => {
+ if (!unread) {
+ this.globalEventServie.publishMainStream(userId, 'readAllAntennas');
+ }
+ });
+ }
+ }
+}
diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts
new file mode 100644
index 000000000..ca9e60889
--- /dev/null
+++ b/packages/backend/src/core/NotificationService.ts
@@ -0,0 +1,67 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { In } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import { NotificationsRepository } from '@/models/index.js';
+import type { UsersRepository } from '@/models/index.js';
+import type { User } from '@/models/entities/User.js';
+import type { Notification } from '@/models/entities/Notification.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { GlobalEventService } from './GlobalEventService.js';
+import { PushNotificationService } from './PushNotificationService.js';
+
+@Injectable()
+export class NotificationService {
+ constructor(
+ @Inject(DI.notificationsRepository)
+ private notificationsRepository: NotificationsRepository,
+
+ private userEntityService: UserEntityService,
+ private globalEventService: GlobalEventService,
+ private pushNotificationService: PushNotificationService,
+ ) {
+ }
+
+ public async readNotification(
+ userId: User['id'],
+ notificationIds: Notification['id'][],
+ ) {
+ if (notificationIds.length === 0) return;
+
+ // Update documents
+ const result = await this.notificationsRepository.update({
+ notifieeId: userId,
+ id: In(notificationIds),
+ isRead: false,
+ }, {
+ isRead: true,
+ });
+
+ if (result.affected === 0) return;
+
+ if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId);
+ else return this.postReadNotifications(userId, notificationIds);
+ }
+
+ public async readNotificationByQuery(
+ userId: User['id'],
+ query: Record,
+ ) {
+ const notificationIds = await this.notificationsRepository.findBy({
+ ...query,
+ notifieeId: userId,
+ isRead: false,
+ }).then(notifications => notifications.map(notification => notification.id));
+
+ return this.readNotification(userId, notificationIds);
+ }
+
+ private postReadAllNotifications(userId: User['id']) {
+ this.globalEventService.publishMainStream(userId, 'readAllNotifications');
+ return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
+ }
+
+ private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) {
+ this.globalEventService.publishMainStream(userId, 'readNotifications', notificationIds);
+ return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds });
+ }
+}
diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts
new file mode 100644
index 000000000..8bc94c8a8
--- /dev/null
+++ b/packages/backend/src/core/PollService.ts
@@ -0,0 +1,115 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Not } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import { NotesRepository, UsersRepository, BlockingsRepository } from '@/models/index.js';
+import type { Note } from '@/models/entities/Note.js';
+import { RelayService } from '@/core/RelayService.js';
+import type { CacheableUser } from '@/models/entities/User.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { CreateNotificationService } from '@/core/CreateNotificationService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
+
+@Injectable()
+export class PollService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.pollsRepository)
+ private pollsRepository: PollsRepository,
+
+ @Inject(DI.pollVotesRepository)
+ private pollVotesRepository: PollVotesRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private relayService: RelayService,
+ private globalEventServie: GlobalEventService,
+ private createNotificationService: CreateNotificationService,
+ private apRendererService: ApRendererService,
+ private apDeliverManagerService: ApDeliverManagerService,
+ ) {
+ }
+
+ public async vote(user: CacheableUser, note: Note, choice: number) {
+ const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
+
+ if (poll == null) throw new Error('poll not found');
+
+ // Check whether is valid choice
+ if (poll.choices[choice] == null) throw new Error('invalid choice param');
+
+ // Check blocking
+ if (note.userId !== user.id) {
+ const block = await this.blockingsRepository.findOneBy({
+ blockerId: note.userId,
+ blockeeId: user.id,
+ });
+ if (block) {
+ throw new Error('blocked');
+ }
+ }
+
+ // if already voted
+ const exist = await this.pollVotesRepository.findBy({
+ noteId: note.id,
+ userId: user.id,
+ });
+
+ if (poll.multiple) {
+ if (exist.some(x => x.choice === choice)) {
+ throw new Error('already voted');
+ }
+ } else if (exist.length !== 0) {
+ throw new Error('already voted');
+ }
+
+ // Create vote
+ await this.pollVotesRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ noteId: note.id,
+ userId: user.id,
+ choice: choice,
+ });
+
+ // Increment votes count
+ const index = choice + 1; // In SQL, array index is 1 based
+ await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
+
+ this.globalEventServie.publishNoteStream(note.id, 'pollVoted', {
+ choice: choice,
+ userId: user.id,
+ });
+
+ // Notify
+ this.createNotificationService.createNotification(note.userId, 'pollVote', {
+ notifierId: user.id,
+ noteId: note.id,
+ choice: choice,
+ });
+ }
+
+ public async deliverQuestionUpdate(noteId: Note['id']) {
+ const note = await this.notesRepository.findOneBy({ id: noteId });
+ if (note == null) throw new Error('note not found');
+
+ const user = await this.usersRepository.findOneBy({ id: note.userId });
+ if (user == null) throw new Error('note not found');
+
+ if (this.userEntityService.isLocalUser(user)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user));
+ this.apDeliverManagerService.deliverToFollowers(user, content);
+ this.relayService.deliverToRelays(user, content);
+ }
+ }
+}
diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts
new file mode 100644
index 000000000..40ccc8226
--- /dev/null
+++ b/packages/backend/src/core/ProxyAccountService.ts
@@ -0,0 +1,22 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { UsersRepository } from '@/models/index.js';
+import type { ILocalUser, User } from '@/models/entities/User.js';
+import { DI } from '@/di-symbols.js';
+import { MetaService } from './MetaService.js';
+
+@Injectable()
+export class ProxyAccountService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ private metaService: MetaService,
+ ) {
+ }
+
+ public async fetch(): Promise {
+ const meta = await this.metaService.fetch();
+ if (meta.proxyAccountId == null) return null;
+ return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser;
+ }
+}
diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts
new file mode 100644
index 000000000..31d29bed9
--- /dev/null
+++ b/packages/backend/src/core/PushNotificationService.ts
@@ -0,0 +1,101 @@
+import { Inject, Injectable } from '@nestjs/common';
+import push from 'web-push';
+import { DI } from '@/di-symbols.js';
+import { Config } from '@/config.js';
+import type { Packed } from '@/misc/schema';
+import { getNoteSummary } from '@/misc/get-note-summary.js';
+import { SwSubscriptionsRepository } from '@/models/index.js';
+import { MetaService } from './MetaService.js';
+
+// Defined also packages/sw/types.ts#L14-L21
+type pushNotificationsTypes = {
+ 'notification': Packed<'Notification'>;
+ 'unreadMessagingMessage': Packed<'MessagingMessage'>;
+ 'readNotifications': { notificationIds: string[] };
+ 'readAllNotifications': undefined;
+ 'readAllMessagingMessages': undefined;
+ 'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
+};
+
+// プッシュメッセージサーバーには文字数制限があるため、内容を削減します
+function truncateNotification(notification: Packed<'Notification'>): any {
+ if (notification.note) {
+ return {
+ ...notification,
+ note: {
+ ...notification.note,
+ // textをgetNoteSummaryしたものに置き換える
+ text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
+
+ cw: undefined,
+ reply: undefined,
+ renote: undefined,
+ user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
+ },
+ };
+ }
+
+ return notification;
+}
+
+@Injectable()
+export class PushNotificationService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.swSubscriptionsRepository)
+ private swSubscriptionsRepository: SwSubscriptionsRepository,
+
+ private metaService: MetaService,
+ ) {
+ }
+
+ public async pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]) {
+ const meta = await this.metaService.fetch();
+
+ if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
+
+ // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
+ push.setVapidDetails(this.config.url,
+ meta.swPublicKey,
+ meta.swPrivateKey);
+
+ // Fetch
+ const subscriptions = await this.swSubscriptionsRepository.findBy({
+ userId: userId,
+ });
+
+ for (const subscription of subscriptions) {
+ const pushSubscription = {
+ endpoint: subscription.endpoint,
+ keys: {
+ auth: subscription.auth,
+ p256dh: subscription.publickey,
+ },
+ };
+
+ push.sendNotification(pushSubscription, JSON.stringify({
+ type,
+ body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body,
+ userId,
+ dateTime: (new Date()).getTime(),
+ }), {
+ proxy: this.config.proxy,
+ }).catch((err: any) => {
+ //swLogger.info(err.statusCode);
+ //swLogger.info(err.headers);
+ //swLogger.info(err.body);
+
+ if (err.statusCode === 410) {
+ this.swSubscriptionsRepository.delete({
+ userId: userId,
+ endpoint: subscription.endpoint,
+ auth: subscription.auth,
+ publickey: subscription.publickey,
+ });
+ }
+ });
+ }
+ }
+}
diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts
new file mode 100644
index 000000000..1613f70c8
--- /dev/null
+++ b/packages/backend/src/core/QueryService.ts
@@ -0,0 +1,262 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { User } from '@/models/entities/User.js';
+import type { SelectQueryBuilder } 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.mutedNotesRepository)
+ private mutedNotesRepository: MutedNotesRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.noteThreadMutingsRepository)
+ private noteThreadMutingsRepository: NoteThreadMutingsRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+ ) {
+ }
+
+ public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): 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}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
+ q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
+ q.orderBy(`${q.alias}.createdAt`, 'DESC');
+ } else if (sinceDate) {
+ q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
+ q.orderBy(`${q.alias}.createdAt`, 'ASC');
+ } else if (untilDate) {
+ q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
+ q.orderBy(`${q.alias}.createdAt`, 'DESC');
+ } else {
+ q.orderBy(`${q.alias}.id`, 'DESC');
+ }
+ return q;
+ }
+
+ // ここでいうBlockedは被Blockedの意
+ public generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void {
+ const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
+ .select('blocking.blockerId')
+ .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
+
+ // 投稿の作者にブロックされていない かつ
+ // 投稿の返信先の作者にブロックされていない かつ
+ // 投稿の引用元の作者にブロックされていない
+ q
+ .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
+ .andWhere(new Brackets(qb => { qb
+ .where('note.replyUserId IS NULL')
+ .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
+ }))
+ .andWhere(new Brackets(qb => { qb
+ .where('note.renoteUserId IS NULL')
+ .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
+ }));
+
+ q.setParameters(blockingQuery.getParameters());
+ }
+
+ public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }): void {
+ const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
+ .select('blocking.blockeeId')
+ .where('blocking.blockerId = :blockerId', { blockerId: me.id });
+
+ const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
+ .select('blocking.blockerId')
+ .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
+
+ q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
+ q.setParameters(blockingQuery.getParameters());
+
+ q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
+ q.setParameters(blockedQuery.getParameters());
+ }
+
+ public generateChannelQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null): void {
+ if (me == null) {
+ q.andWhere('note.channelId IS NULL');
+ } else {
+ q.leftJoinAndSelect('note.channel', 'channel');
+
+ const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
+ .select('channelFollowing.followeeId')
+ .where('channelFollowing.followerId = :followerId', { followerId: me.id });
+
+ q.andWhere(new Brackets(qb => { qb
+ // チャンネルのノートではない
+ .where('note.channelId IS NULL')
+ // または自分がフォローしているチャンネルのノート
+ .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
+ }));
+
+ q.setParameters(channelFollowingQuery.getParameters());
+ }
+ }
+
+ public generateMutedNoteQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void {
+ const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
+ .select('muted.noteId')
+ .where('muted.userId = :userId', { userId: me.id });
+
+ q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
+
+ q.setParameters(mutedQuery.getParameters());
+ }
+
+ public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void {
+ const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
+ .select('threadMuted.threadId')
+ .where('threadMuted.userId = :userId', { userId: me.id });
+
+ q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
+ q.andWhere(new Brackets(qb => { qb
+ .where('note.threadId IS NULL')
+ .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
+ }));
+
+ q.setParameters(mutedQuery.getParameters());
+ }
+
+ public generateMutedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }, exclude?: User): void {
+ const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
+ .select('muting.muteeId')
+ .where('muting.muterId = :muterId', { muterId: me.id });
+
+ if (exclude) {
+ mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
+ }
+
+ const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
+ .select('user_profile.mutedInstances')
+ .where('user_profile.userId = :muterId', { muterId: me.id });
+
+ // 投稿の作者をミュートしていない かつ
+ // 投稿の返信先の作者をミュートしていない かつ
+ // 投稿の引用元の作者をミュートしていない
+ q
+ .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
+ .andWhere(new Brackets(qb => { qb
+ .where('note.replyUserId IS NULL')
+ .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
+ }))
+ .andWhere(new Brackets(qb => { qb
+ .where('note.renoteUserId IS NULL')
+ .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
+ }))
+ // mute instances
+ .andWhere(new Brackets(qb => { qb
+ .andWhere('note.userHost IS NULL')
+ .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
+ }))
+ .andWhere(new Brackets(qb => { qb
+ .where('note.replyUserHost IS NULL')
+ .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
+ }))
+ .andWhere(new Brackets(qb => { qb
+ .where('note.renoteUserHost IS NULL')
+ .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
+ }));
+
+ q.setParameters(mutingQuery.getParameters());
+ q.setParameters(mutingInstanceQuery.getParameters());
+ }
+
+ public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }): void {
+ const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
+ .select('muting.muteeId')
+ .where('muting.muterId = :muterId', { muterId: me.id });
+
+ q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
+
+ q.setParameters(mutingQuery.getParameters());
+ }
+
+ public generateRepliesQuery(q: SelectQueryBuilder, me?: Pick | null): void {
+ if (me == null) {
+ q.andWhere(new Brackets(qb => { qb
+ .where('note.replyId IS NULL') // 返信ではない
+ .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
+ .where('note.replyId IS NOT NULL')
+ .andWhere('note.replyUserId = note.userId');
+ }));
+ }));
+ } else if (!me.showTimelineReplies) {
+ q.andWhere(new Brackets(qb => { qb
+ .where('note.replyId IS NULL') // 返信ではない
+ .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
+ .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
+ .where('note.replyId IS NOT NULL')
+ .andWhere('note.userId = :meId', { meId: me.id });
+ }))
+ .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
+ .where('note.replyId IS NOT NULL')
+ .andWhere('note.replyUserId = note.userId');
+ }));
+ }));
+ }
+ }
+
+ public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null): void {
+ // This code must always be synchronized with the checks in Notes.isVisibleForMe.
+ if (me == null) {
+ q.andWhere(new Brackets(qb => { qb
+ .where('note.visibility = \'public\'')
+ .orWhere('note.visibility = \'home\'');
+ }));
+ } else {
+ const followingQuery = this.followingsRepository.createQueryBuilder('following')
+ .select('following.followeeId')
+ .where('following.followerId = :meId');
+
+ q.andWhere(new Brackets(qb => { qb
+ // 公開投稿である
+ .where(new Brackets(qb => { qb
+ .where('note.visibility = \'public\'')
+ .orWhere('note.visibility = \'home\'');
+ }))
+ // または 自分自身
+ .orWhere('note.userId = :meId')
+ // または 自分宛て
+ .orWhere(':meId = ANY(note.visibleUserIds)')
+ .orWhere(':meId = ANY(note.mentions)')
+ .orWhere(new Brackets(qb => { qb
+ // または フォロワー宛ての投稿であり、
+ .where('note.visibility = \'followers\'')
+ .andWhere(new Brackets(qb => { qb
+ // 自分がフォロワーである
+ .where(`note.userId IN (${ followingQuery.getQuery() })`)
+ // または 自分の投稿へのリプライ
+ .orWhere('note.replyUserId = :meId');
+ }));
+ }));
+ }));
+
+ q.setParameters({ meId: me.id });
+ }
+ }
+}
+
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
new file mode 100644
index 000000000..7e771c100
--- /dev/null
+++ b/packages/backend/src/core/QueueService.ts
@@ -0,0 +1,242 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { v4 as uuid } from 'uuid';
+import type { IActivity } from '@/core/remote/activitypub/type.js';
+import type { DriveFile } from '@/models/entities/DriveFile.js';
+import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
+import { Config } from '@/config.js';
+import { DI } from '@/di-symbols.js';
+import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './queue/QueueModule.js';
+import type { ThinUser } from '../queue/types.js';
+import type httpSignature from '@peertube/http-signature';
+
+@Injectable()
+export class QueueService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject('queue:system') public systemQueue: SystemQueue,
+ @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+ @Inject('queue:deliver') public deliverQueue: DeliverQueue,
+ @Inject('queue:inbox') public inboxQueue: InboxQueue,
+ @Inject('queue:db') public dbQueue: DbQueue,
+ @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
+ @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
+ ) {}
+
+ public deliver(user: ThinUser, content: IActivity, to: string | null) {
+ if (content == null) return null;
+ if (to == null) return null;
+
+ const data = {
+ user: {
+ id: user.id,
+ },
+ content,
+ to,
+ };
+
+ return this.deliverQueue.add(data, {
+ attempts: this.config.deliverJobMaxAttempts ?? 12,
+ timeout: 1 * 60 * 1000, // 1min
+ backoff: {
+ type: 'apBackoff',
+ },
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
+ const data = {
+ activity: activity,
+ signature,
+ };
+
+ return this.inboxQueue.add(data, {
+ attempts: this.config.inboxJobMaxAttempts ?? 8,
+ timeout: 5 * 60 * 1000, // 5min
+ backoff: {
+ type: 'apBackoff',
+ },
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createDeleteDriveFilesJob(user: ThinUser) {
+ return this.dbQueue.add('deleteDriveFiles', {
+ user: user,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createExportCustomEmojisJob(user: ThinUser) {
+ return this.dbQueue.add('exportCustomEmojis', {
+ user: user,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createExportNotesJob(user: ThinUser) {
+ return this.dbQueue.add('exportNotes', {
+ user: user,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
+ return this.dbQueue.add('exportFollowing', {
+ user: user,
+ excludeMuting,
+ excludeInactive,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createExportMuteJob(user: ThinUser) {
+ return this.dbQueue.add('exportMuting', {
+ user: user,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createExportBlockingJob(user: ThinUser) {
+ return this.dbQueue.add('exportBlocking', {
+ user: user,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createExportUserListsJob(user: ThinUser) {
+ return this.dbQueue.add('exportUserLists', {
+ user: user,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
+ return this.dbQueue.add('importFollowing', {
+ user: user,
+ fileId: fileId,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
+ return this.dbQueue.add('importMuting', {
+ user: user,
+ fileId: fileId,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
+ return this.dbQueue.add('importBlocking', {
+ user: user,
+ fileId: fileId,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
+ return this.dbQueue.add('importUserLists', {
+ user: user,
+ fileId: fileId,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
+ return this.dbQueue.add('importCustomEmojis', {
+ user: user,
+ fileId: fileId,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
+ return this.dbQueue.add('deleteAccount', {
+ user: user,
+ soft: opts.soft,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createDeleteObjectStorageFileJob(key: string) {
+ return this.objectStorageQueue.add('deleteFile', {
+ key: key,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public createCleanRemoteFilesJob() {
+ return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) {
+ const data = {
+ type,
+ content,
+ webhookId: webhook.id,
+ userId: webhook.userId,
+ to: webhook.url,
+ secret: webhook.secret,
+ createdAt: Date.now(),
+ eventId: uuid(),
+ };
+
+ return this.webhookDeliverQueue.add(data, {
+ attempts: 4,
+ timeout: 1 * 60 * 1000, // 1min
+ backoff: {
+ type: 'apBackoff',
+ },
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ public destroy() {
+ this.deliverQueue.once('cleaned', (jobs, status) => {
+ //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
+ });
+ this.deliverQueue.clean(0, 'delayed');
+
+ this.inboxQueue.once('cleaned', (jobs, status) => {
+ //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
+ });
+ this.inboxQueue.clean(0, 'delayed');
+ }
+}
diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts
new file mode 100644
index 000000000..300645657
--- /dev/null
+++ b/packages/backend/src/core/ReactionService.ts
@@ -0,0 +1,340 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { IsNull } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import type { IRemoteUser, User } from '@/models/entities/User.js';
+import type { Note } from '@/models/entities/Note.js';
+import { IdService } from '@/core/IdService.js';
+import type { NoteReaction } from '@/models/entities/NoteReaction.js';
+import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { CreateNotificationService } from '@/core/CreateNotificationService.js';
+import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
+import { emojiRegex } from '@/misc/emoji-regex.js';
+import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
+import { NoteEntityService } from './entities/NoteEntityService.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+import { MetaService } from './MetaService.js';
+import { UtilityService } from './UtilityService.js';
+
+const legacies: Record = {
+ 'like': '👍',
+ 'love': '❤', // ここに記述する場合は異体字セレクタを入れない
+ 'laugh': '😆',
+ 'hmm': '🤔',
+ 'surprise': '😮',
+ 'congrats': '🎉',
+ 'angry': '💢',
+ 'confused': '😥',
+ 'rip': '😇',
+ 'pudding': '🍮',
+ 'star': '⭐',
+};
+
+type DecodedReaction = {
+ /**
+ * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.')
+ */
+ reaction: string;
+
+ /**
+ * name (カスタム絵文字の場合name, Emojiクエリに使う)
+ */
+ name?: string;
+
+ /**
+ * host (カスタム絵文字の場合host, Emojiクエリに使う)
+ */
+ host?: string | null;
+};
+
+@Injectable()
+export class ReactionService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.noteReactionsRepository)
+ private noteReactionsRepository: NoteReactionsRepository,
+
+ @Inject(DI.emojisRepository)
+ private emojisRepository: EmojisRepository,
+
+ private utilityService: UtilityService,
+ private metaService: MetaService,
+ private userEntityService: UserEntityService,
+ private noteEntityService: NoteEntityService,
+ private idService: IdService,
+ private globalEventServie: GlobalEventService,
+ private apRendererService: ApRendererService,
+ private apDeliverManagerService: ApDeliverManagerService,
+ private createNotificationService: CreateNotificationService,
+ private perUserReactionsChart: PerUserReactionsChart,
+ ) {
+ }
+
+ public async create(user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) {
+ // Check blocking
+ if (note.userId !== user.id) {
+ const block = await this.blockingsRepository.findOneBy({
+ blockerId: note.userId,
+ blockeeId: user.id,
+ });
+ if (block) {
+ throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
+ }
+ }
+
+ // check visibility
+ if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
+ throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
+ }
+
+ // TODO: cache
+ reaction = await this.toDbReaction(reaction, user.host);
+
+ const record: NoteReaction = {
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ noteId: note.id,
+ userId: user.id,
+ reaction,
+ };
+
+ // Create reaction
+ try {
+ await this.noteReactionsRepository.insert(record);
+ } catch (e) {
+ if (isDuplicateKeyValueError(e)) {
+ const exists = await this.noteReactionsRepository.findOneByOrFail({
+ noteId: note.id,
+ userId: user.id,
+ });
+
+ if (exists.reaction !== reaction) {
+ // 別のリアクションがすでにされていたら置き換える
+ await this.delete(user, note);
+ await this.noteReactionsRepository.insert(record);
+ } else {
+ // 同じリアクションがすでにされていたらエラー
+ throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
+ }
+ } else {
+ throw e;
+ }
+ }
+
+ // Increment reactions count
+ const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
+ await this.notesRepository.createQueryBuilder().update()
+ .set({
+ reactions: () => sql,
+ score: () => '"score" + 1',
+ })
+ .where('id = :id', { id: note.id })
+ .execute();
+
+ this.perUserReactionsChart.update(user, note);
+
+ // カスタム絵文字リアクションだったら絵文字情報も送る
+ const decodedReaction = this.decodeReaction(reaction);
+
+ const emoji = await this.emojisRepository.findOne({
+ where: {
+ name: decodedReaction.name,
+ host: decodedReaction.host ?? IsNull(),
+ },
+ select: ['name', 'host', 'originalUrl', 'publicUrl'],
+ });
+
+ this.globalEventServie.publishNoteStream(note.id, 'reacted', {
+ reaction: decodedReaction.reaction,
+ emoji: emoji != null ? {
+ name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
+ url: emoji.publicUrl ?? emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため
+ } : null,
+ userId: user.id,
+ });
+
+ // リアクションされたユーザーがローカルユーザーなら通知を作成
+ if (note.userHost === null) {
+ this.createNotificationService.createNotification(note.userId, 'reaction', {
+ notifierId: user.id,
+ noteId: note.id,
+ reaction: reaction,
+ });
+ }
+
+ //#region 配信
+ if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
+ const content = this.apRendererService.renderActivity(await this.apRendererService.renderLike(record, note));
+ const dm = this.apDeliverManagerService.createDeliverManager(user, content);
+ if (note.userHost !== null) {
+ const reactee = await this.usersRepository.findOneBy({ id: note.userId });
+ dm.addDirectRecipe(reactee as IRemoteUser);
+ }
+
+ if (['public', 'home', 'followers'].includes(note.visibility)) {
+ dm.addFollowersRecipe();
+ } else if (note.visibility === 'specified') {
+ const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id })));
+ for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) {
+ dm.addDirectRecipe(u as IRemoteUser);
+ }
+ }
+
+ dm.execute();
+ }
+ //#endregion
+ }
+
+ public async delete(user: { id: User['id']; host: User['host']; }, note: Note) {
+ // if already unreacted
+ const exist = await this.noteReactionsRepository.findOneBy({
+ noteId: note.id,
+ userId: user.id,
+ });
+
+ if (exist == null) {
+ throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
+ }
+
+ // Delete reaction
+ const result = await this.noteReactionsRepository.delete(exist.id);
+
+ if (result.affected !== 1) {
+ throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
+ }
+
+ // Decrement reactions count
+ const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
+ await this.notesRepository.createQueryBuilder().update()
+ .set({
+ reactions: () => sql,
+ })
+ .where('id = :id', { id: note.id })
+ .execute();
+
+ this.notesRepository.decrement({ id: note.id }, 'score', 1);
+
+ this.globalEventServie.publishNoteStream(note.id, 'unreacted', {
+ reaction: this.decodeReaction(exist.reaction).reaction,
+ userId: user.id,
+ });
+
+ //#region 配信
+ if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
+ const dm = this.apDeliverManagerService.createDeliverManager(user, content);
+ if (note.userHost !== null) {
+ const reactee = await this.usersRepository.findOneBy({ id: note.userId });
+ dm.addDirectRecipe(reactee as IRemoteUser);
+ }
+ dm.addFollowersRecipe();
+ dm.execute();
+ }
+ //#endregion
+ }
+
+ public async getFallbackReaction(): Promise {
+ const meta = await this.metaService.fetch();
+ return meta.useStarForReactionFallback ? '⭐' : '👍';
+ }
+
+ public convertLegacyReactions(reactions: Record) {
+ const _reactions = {} as Record;
+
+ for (const reaction of Object.keys(reactions)) {
+ if (reactions[reaction] <= 0) continue;
+
+ if (Object.keys(legacies).includes(reaction)) {
+ if (_reactions[legacies[reaction]]) {
+ _reactions[legacies[reaction]] += reactions[reaction];
+ } else {
+ _reactions[legacies[reaction]] = reactions[reaction];
+ }
+ } else {
+ if (_reactions[reaction]) {
+ _reactions[reaction] += reactions[reaction];
+ } else {
+ _reactions[reaction] = reactions[reaction];
+ }
+ }
+ }
+
+ const _reactions2 = {} as Record;
+
+ for (const reaction of Object.keys(_reactions)) {
+ _reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
+ }
+
+ return _reactions2;
+ }
+
+ public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise {
+ if (reaction == null) return await this.getFallbackReaction();
+
+ reacterHost = this.utilityService.toPunyNullable(reacterHost);
+
+ // 文字列タイプのリアクションを絵文字に変換
+ if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
+
+ // Unicode絵文字
+ const match = emojiRegex.exec(reaction);
+ if (match) {
+ // 合字を含む1つの絵文字
+ const unicode = match[0];
+
+ // 異体字セレクタ除去
+ return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
+ }
+
+ const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
+ if (custom) {
+ const name = custom[1];
+ const emoji = await this.emojisRepository.findOneBy({
+ host: reacterHost ?? IsNull(),
+ name,
+ });
+
+ if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
+ }
+
+ return await this.getFallbackReaction();
+ }
+
+ public decodeReaction(str: string): DecodedReaction {
+ const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/);
+
+ if (custom) {
+ const name = custom[1];
+ const host = custom[2] ?? null;
+
+ return {
+ reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする
+ name,
+ host,
+ };
+ }
+
+ return {
+ reaction: str,
+ name: undefined,
+ host: undefined,
+ };
+ }
+
+ public convertLegacyReaction(reaction: string): string {
+ reaction = this.decodeReaction(reaction).reaction;
+ if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
+ return reaction;
+ }
+}
diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts
new file mode 100644
index 000000000..688ea03d3
--- /dev/null
+++ b/packages/backend/src/core/RelayService.ts
@@ -0,0 +1,119 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { IsNull } from 'typeorm';
+import type { ILocalUser, User } from '@/models/entities/User.js';
+import { RelaysRepository, UsersRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import { Cache } from '@/misc/cache.js';
+import type { Relay } from '@/models/entities/Relay.js';
+import { QueueService } from '@/core/QueueService.js';
+import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
+import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js';
+import { DI } from '@/di-symbols.js';
+
+const ACTOR_USERNAME = 'relay.actor' as const;
+
+@Injectable()
+export class RelayService {
+ private relaysCache: Cache;
+
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.relaysRepository)
+ private relaysRepository: RelaysRepository,
+
+ private idService: IdService,
+ private queueService: QueueService,
+ private createSystemUserService: CreateSystemUserService,
+ private apRendererService: ApRendererService,
+ ) {
+ this.relaysCache = new Cache(1000 * 60 * 10);
+ }
+
+ private async getRelayActor(): Promise {
+ const user = await this.usersRepository.findOneBy({
+ host: IsNull(),
+ username: ACTOR_USERNAME,
+ });
+
+ if (user) return user as ILocalUser;
+
+ const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
+ return created as ILocalUser;
+ }
+
+ public async addRelay(inbox: string): Promise {
+ const relay = await this.relaysRepository.insert({
+ id: this.idService.genId(),
+ inbox,
+ status: 'requesting',
+ }).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0]));
+
+ const relayActor = await this.getRelayActor();
+ const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);
+ const activity = this.apRendererService.renderActivity(follow);
+ this.queueService.deliver(relayActor, activity, relay.inbox);
+
+ return relay;
+ }
+
+ public async removeRelay(inbox: string): Promise {
+ const relay = await this.relaysRepository.findOneBy({
+ inbox,
+ });
+
+ if (relay == null) {
+ throw new Error('relay not found');
+ }
+
+ const relayActor = await this.getRelayActor();
+ const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
+ const undo = this.apRendererService.renderUndo(follow, relayActor);
+ const activity = this.apRendererService.renderActivity(undo);
+ this.queueService.deliver(relayActor, activity, relay.inbox);
+
+ await this.relaysRepository.delete(relay.id);
+ }
+
+ public async listRelay(): Promise {
+ const relays = await this.relaysRepository.find();
+ return relays;
+ }
+
+ public async relayAccepted(id: string): Promise {
+ const result = await this.relaysRepository.update(id, {
+ status: 'accepted',
+ });
+
+ return JSON.stringify(result);
+ }
+
+ public async relayRejected(id: string): Promise {
+ const result = await this.relaysRepository.update(id, {
+ status: 'rejected',
+ });
+
+ return JSON.stringify(result);
+ }
+
+ public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise {
+ if (activity == null) return;
+
+ const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({
+ status: 'accepted',
+ }));
+ if (relays.length === 0) return;
+
+ // TODO
+ //const copy = structuredClone(activity);
+ const copy = JSON.parse(JSON.stringify(activity));
+ if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
+
+ const signed = await this.apRendererService.attachLdSignature(copy, user);
+
+ for (const relay of relays) {
+ this.queueService.deliver(user, signed, relay.inbox);
+ }
+ }
+}
diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts
new file mode 100644
index 000000000..9549e1999
--- /dev/null
+++ b/packages/backend/src/core/S3Service.ts
@@ -0,0 +1,38 @@
+import { URL } from 'node:url';
+import { Inject, Injectable } from '@nestjs/common';
+import S3 from 'aws-sdk/clients/s3.js';
+import { DI } from '@/di-symbols.js';
+import { Config } from '@/config.js';
+import type { Meta } from '@/models/entities/Meta.js';
+import { HttpRequestService } from './HttpRequestService.js';
+
+@Injectable()
+export class S3Service {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ private httpRequestService: HttpRequestService,
+ ) {
+ }
+
+ public getS3(meta: Meta) {
+ const u = meta.objectStorageEndpoint != null
+ ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}`
+ : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`;
+
+ return new S3({
+ endpoint: meta.objectStorageEndpoint ?? undefined,
+ accessKeyId: meta.objectStorageAccessKey!,
+ secretAccessKey: meta.objectStorageSecretKey!,
+ region: meta.objectStorageRegion ?? undefined,
+ sslEnabled: meta.objectStorageUseSSL,
+ s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted
+ ? false
+ : meta.objectStorageS3ForcePathStyle,
+ httpOptions: {
+ agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
+ },
+ });
+ }
+}
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
new file mode 100644
index 000000000..a876668b9
--- /dev/null
+++ b/packages/backend/src/core/SignupService.ts
@@ -0,0 +1,141 @@
+import { generateKeyPair } from 'node:crypto';
+import { Inject, Injectable } from '@nestjs/common';
+import bcrypt from 'bcryptjs';
+import { DataSource, IsNull } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import { UsedUsernamesRepository } from '@/models/index.js';
+import { Config } from '@/config.js';
+import { User } from '@/models/entities/User.js';
+import { UserProfile } from '@/models/entities/UserProfile.js';
+import { IdService } from '@/core/IdService.js';
+import { UserKeypair } from '@/models/entities/UserKeypair.js';
+import { UsedUsername } from '@/models/entities/UsedUsername.js';
+import generateUserToken from '@/misc/generate-native-user-token.js';
+import UsersChart from './chart/charts/users.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { UtilityService } from './UtilityService.js';
+
+@Injectable()
+export class SignupService {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.usedUsernamesRepository)
+ private usedUsernamesRepository: UsedUsernamesRepository,
+
+ private utilityService: UtilityService,
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private usersChart: UsersChart,
+ ) {
+ }
+
+ public async signup(opts: {
+ username: User['username'];
+ password?: string | null;
+ passwordHash?: UserProfile['password'] | null;
+ host?: string | null;
+ }) {
+ const { username, password, passwordHash, host } = opts;
+ let hash = passwordHash;
+
+ // Validate username
+ if (!this.userEntityService.validateLocalUsername(username)) {
+ throw new Error('INVALID_USERNAME');
+ }
+
+ if (password != null && passwordHash == null) {
+ // Validate password
+ if (!this.userEntityService.validatePassword(password)) {
+ throw new Error('INVALID_PASSWORD');
+ }
+
+ // Generate hash of password
+ const salt = await bcrypt.genSalt(8);
+ hash = await bcrypt.hash(password, salt);
+ }
+
+ // Generate secret
+ const secret = generateUserToken();
+
+ // Check username duplication
+ if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
+ throw new Error('DUPLICATED_USERNAME');
+ }
+
+ // Check deleted username duplication
+ if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) {
+ throw new Error('USED_USERNAME');
+ }
+
+ const keyPair = await new Promise((res, rej) =>
+ generateKeyPair('rsa', {
+ modulusLength: 4096,
+ publicKeyEncoding: {
+ type: 'spki',
+ format: 'pem',
+ },
+ privateKeyEncoding: {
+ type: 'pkcs8',
+ format: 'pem',
+ cipher: undefined,
+ passphrase: undefined,
+ },
+ } as any, (err, publicKey, privateKey) =>
+ err ? rej(err) : res([publicKey, privateKey]),
+ ));
+
+ let account!: User;
+
+ // Start transaction
+ await this.db.transaction(async transactionalEntityManager => {
+ const exist = await transactionalEntityManager.findOneBy(User, {
+ usernameLower: username.toLowerCase(),
+ host: IsNull(),
+ });
+
+ if (exist) throw new Error(' the username is already used');
+
+ account = await transactionalEntityManager.save(new User({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ username: username,
+ usernameLower: username.toLowerCase(),
+ host: this.utilityService.toPunyNullable(host),
+ token: secret,
+ isAdmin: (await this.usersRepository.countBy({
+ host: IsNull(),
+ })) === 0,
+ }));
+
+ await transactionalEntityManager.save(new UserKeypair({
+ publicKey: keyPair[0],
+ privateKey: keyPair[1],
+ userId: account.id,
+ }));
+
+ await transactionalEntityManager.save(new UserProfile({
+ userId: account.id,
+ autoAcceptFollowed: true,
+ password: hash,
+ }));
+
+ await transactionalEntityManager.save(new UsedUsername({
+ createdAt: new Date(),
+ username: username.toLowerCase(),
+ }));
+ });
+
+ this.usersChart.update(account, true);
+
+ return { account, secret };
+ }
+}
+
diff --git a/packages/backend/src/core/TwoFactorAuthenticationService.ts b/packages/backend/src/core/TwoFactorAuthenticationService.ts
new file mode 100644
index 000000000..be31534c0
--- /dev/null
+++ b/packages/backend/src/core/TwoFactorAuthenticationService.ts
@@ -0,0 +1,439 @@
+import * as crypto from 'node:crypto';
+import { Inject, Injectable } from '@nestjs/common';
+import * as jsrsasign from 'jsrsasign';
+import { DI } from '@/di-symbols.js';
+import { UsersRepository } from '@/models/index.js';
+import { Config } from '@/config.js';
+
+const ECC_PRELUDE = Buffer.from([0x04]);
+const NULL_BYTE = Buffer.from([0]);
+const PEM_PRELUDE = Buffer.from(
+ '3059301306072a8648ce3d020106082a8648ce3d030107034200',
+ 'hex',
+);
+
+// Android Safetynet attestations are signed with this cert:
+const GSR2 = `-----BEGIN CERTIFICATE-----
+MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
+A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
+Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
+MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
+A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
+hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
+v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
+eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
+tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
+C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
+zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
+mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
+V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
+bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
+3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
+J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
+291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
+ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
+AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
+TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
+-----END CERTIFICATE-----\n`;
+
+function base64URLDecode(source: string) {
+ return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
+}
+
+function getCertSubject(certificate: string) {
+ const subjectCert = new jsrsasign.X509();
+ subjectCert.readCertPEM(certificate);
+
+ const subjectString = subjectCert.getSubjectString();
+ const subjectFields = subjectString.slice(1).split('/');
+
+ const fields = {} as Record;
+ for (const field of subjectFields) {
+ const eqIndex = field.indexOf('=');
+ fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
+ }
+
+ return fields;
+}
+
+function verifyCertificateChain(certificates: string[]) {
+ let valid = true;
+
+ for (let i = 0; i < certificates.length; i++) {
+ const Cert = certificates[i];
+ const certificate = new jsrsasign.X509();
+ certificate.readCertPEM(Cert);
+
+ const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
+
+ const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
+ const algorithm = certificate.getSignatureAlgorithmField();
+ const signatureHex = certificate.getSignatureValueHex();
+
+ // Verify against CA
+ const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm });
+ Signature.init(CACert);
+ Signature.updateHex(certStruct);
+ valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate
+ }
+
+ return valid;
+}
+
+function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
+ if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) {
+ pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
+ type = 'PUBLIC KEY';
+ }
+ const cert = pemBuffer.toString('base64');
+
+ const keyParts = [];
+ const max = Math.ceil(cert.length / 64);
+ let start = 0;
+ for (let i = 0; i < max; i++) {
+ keyParts.push(cert.substring(start, start + 64));
+ start += 64;
+ }
+
+ return (
+ `-----BEGIN ${type}-----\n` +
+ keyParts.join('\n') +
+ `\n-----END ${type}-----\n`
+ );
+}
+
+@Injectable()
+export class TwoFactorAuthenticationService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+ ) {
+ }
+
+ public hash(data: Buffer) {
+ return crypto
+ .createHash('sha256')
+ .update(data)
+ .digest();
+ }
+
+ public verifySignin({
+ publicKey,
+ authenticatorData,
+ clientDataJSON,
+ clientData,
+ signature,
+ challenge,
+ }: {
+ publicKey: Buffer,
+ authenticatorData: Buffer,
+ clientDataJSON: Buffer,
+ clientData: any,
+ signature: Buffer,
+ challenge: string
+ }) {
+ if (clientData.type !== 'webauthn.get') {
+ throw new Error('type is not webauthn.get');
+ }
+
+ if (this.hash(clientData.challenge).toString('hex') !== challenge) {
+ throw new Error('challenge mismatch');
+ }
+ if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
+ throw new Error('origin mismatch');
+ }
+
+ const verificationData = Buffer.concat(
+ [authenticatorData, this.hash(clientDataJSON)],
+ 32 + authenticatorData.length,
+ );
+
+ return crypto
+ .createVerify('SHA256')
+ .update(verificationData)
+ .verify(PEMString(publicKey), signature);
+ }
+
+ public getProcedures() {
+ return {
+ none: {
+ verify({ publicKey }: { publicKey: Map }) {
+ const negTwo = publicKey.get(-2);
+
+ if (!negTwo || negTwo.length !== 32) {
+ throw new Error('invalid or no -2 key given');
+ }
+ const negThree = publicKey.get(-3);
+ if (!negThree || negThree.length !== 32) {
+ throw new Error('invalid or no -3 key given');
+ }
+
+ const publicKeyU2F = Buffer.concat(
+ [ECC_PRELUDE, negTwo, negThree],
+ 1 + 32 + 32,
+ );
+
+ return {
+ publicKey: publicKeyU2F,
+ valid: true,
+ };
+ },
+ },
+ 'android-key': {
+ verify({
+ attStmt,
+ authenticatorData,
+ clientDataHash,
+ publicKey,
+ rpIdHash,
+ credentialId,
+ }: {
+ attStmt: any,
+ authenticatorData: Buffer,
+ clientDataHash: Buffer,
+ publicKey: Map;
+ rpIdHash: Buffer,
+ credentialId: Buffer,
+ }) {
+ if (attStmt.alg !== -7) {
+ throw new Error('alg mismatch');
+ }
+
+ const verificationData = Buffer.concat([
+ authenticatorData,
+ clientDataHash,
+ ]);
+
+ const attCert: Buffer = attStmt.x5c[0];
+
+ const negTwo = publicKey.get(-2);
+
+ if (!negTwo || negTwo.length !== 32) {
+ throw new Error('invalid or no -2 key given');
+ }
+ const negThree = publicKey.get(-3);
+ if (!negThree || negThree.length !== 32) {
+ throw new Error('invalid or no -3 key given');
+ }
+
+ const publicKeyData = Buffer.concat(
+ [ECC_PRELUDE, negTwo, negThree],
+ 1 + 32 + 32,
+ );
+
+ if (!attCert.equals(publicKeyData)) {
+ throw new Error('public key mismatch');
+ }
+
+ const isValid = crypto
+ .createVerify('SHA256')
+ .update(verificationData)
+ .verify(PEMString(attCert), attStmt.sig);
+
+ // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
+
+ return {
+ valid: isValid,
+ publicKey: publicKeyData,
+ };
+ },
+ },
+ // what a stupid attestation
+ 'android-safetynet': {
+ verify: ({
+ attStmt,
+ authenticatorData,
+ clientDataHash,
+ publicKey,
+ rpIdHash,
+ credentialId,
+ }: {
+ attStmt: any,
+ authenticatorData: Buffer,
+ clientDataHash: Buffer,
+ publicKey: Map;
+ rpIdHash: Buffer,
+ credentialId: Buffer,
+ }) => {
+ const verificationData = this.hash(
+ Buffer.concat([authenticatorData, clientDataHash]),
+ );
+
+ const jwsParts = attStmt.response.toString('utf-8').split('.');
+
+ const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
+ const response = JSON.parse(
+ base64URLDecode(jwsParts[1]).toString('utf-8'),
+ );
+ const signature = jwsParts[2];
+
+ if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
+ throw new Error('invalid nonce');
+ }
+
+ const certificateChain = header.x5c
+ .map((key: any) => PEMString(key))
+ .concat([GSR2]);
+
+ if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') {
+ throw new Error('invalid common name');
+ }
+
+ if (!verifyCertificateChain(certificateChain)) {
+ throw new Error('Invalid certificate chain!');
+ }
+
+ const signatureBase = Buffer.from(
+ jwsParts[0] + '.' + jwsParts[1],
+ 'utf-8',
+ );
+
+ const valid = crypto
+ .createVerify('sha256')
+ .update(signatureBase)
+ .verify(certificateChain[0], base64URLDecode(signature));
+
+ const negTwo = publicKey.get(-2);
+
+ if (!negTwo || negTwo.length !== 32) {
+ throw new Error('invalid or no -2 key given');
+ }
+ const negThree = publicKey.get(-3);
+ if (!negThree || negThree.length !== 32) {
+ throw new Error('invalid or no -3 key given');
+ }
+
+ const publicKeyData = Buffer.concat(
+ [ECC_PRELUDE, negTwo, negThree],
+ 1 + 32 + 32,
+ );
+ return {
+ valid,
+ publicKey: publicKeyData,
+ };
+ },
+ },
+ packed: {
+ verify({
+ attStmt,
+ authenticatorData,
+ clientDataHash,
+ publicKey,
+ rpIdHash,
+ credentialId,
+ }: {
+ attStmt: any,
+ authenticatorData: Buffer,
+ clientDataHash: Buffer,
+ publicKey: Map;
+ rpIdHash: Buffer,
+ credentialId: Buffer,
+ }) {
+ const verificationData = Buffer.concat([
+ authenticatorData,
+ clientDataHash,
+ ]);
+
+ if (attStmt.x5c) {
+ const attCert = attStmt.x5c[0];
+
+ const validSignature = crypto
+ .createVerify('SHA256')
+ .update(verificationData)
+ .verify(PEMString(attCert), attStmt.sig);
+
+ const negTwo = publicKey.get(-2);
+
+ if (!negTwo || negTwo.length !== 32) {
+ throw new Error('invalid or no -2 key given');
+ }
+ const negThree = publicKey.get(-3);
+ if (!negThree || negThree.length !== 32) {
+ throw new Error('invalid or no -3 key given');
+ }
+
+ const publicKeyData = Buffer.concat(
+ [ECC_PRELUDE, negTwo, negThree],
+ 1 + 32 + 32,
+ );
+
+ return {
+ valid: validSignature,
+ publicKey: publicKeyData,
+ };
+ } else if (attStmt.ecdaaKeyId) {
+ // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
+ throw new Error('ECDAA-Verify is not supported');
+ } else {
+ if (attStmt.alg !== -7) throw new Error('alg mismatch');
+
+ throw new Error('self attestation is not supported');
+ }
+ },
+ },
+
+ 'fido-u2f': {
+ verify({
+ attStmt,
+ authenticatorData,
+ clientDataHash,
+ publicKey,
+ rpIdHash,
+ credentialId,
+ }: {
+ attStmt: any,
+ authenticatorData: Buffer,
+ clientDataHash: Buffer,
+ publicKey: Map,
+ rpIdHash: Buffer,
+ credentialId: Buffer
+ }) {
+ const x5c: Buffer[] = attStmt.x5c;
+ if (x5c.length !== 1) {
+ throw new Error('x5c length does not match expectation');
+ }
+
+ const attCert = x5c[0];
+
+ // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
+
+ const negTwo: Buffer = publicKey.get(-2);
+
+ if (!negTwo || negTwo.length !== 32) {
+ throw new Error('invalid or no -2 key given');
+ }
+ const negThree: Buffer = publicKey.get(-3);
+ if (!negThree || negThree.length !== 32) {
+ throw new Error('invalid or no -3 key given');
+ }
+
+ const publicKeyU2F = Buffer.concat(
+ [ECC_PRELUDE, negTwo, negThree],
+ 1 + 32 + 32,
+ );
+
+ const verificationData = Buffer.concat([
+ NULL_BYTE,
+ rpIdHash,
+ clientDataHash,
+ credentialId,
+ publicKeyU2F,
+ ]);
+
+ const validSignature = crypto
+ .createVerify('SHA256')
+ .update(verificationData)
+ .verify(PEMString(attCert), attStmt.sig);
+
+ return {
+ valid: validSignature,
+ publicKey: publicKeyU2F,
+ };
+ },
+ },
+ };
+ }
+}
diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts
new file mode 100644
index 000000000..9efb021f6
--- /dev/null
+++ b/packages/backend/src/core/UserBlockingService.ts
@@ -0,0 +1,199 @@
+
+import { Inject, Injectable } from '@nestjs/common';
+import { IdService } from '@/core/IdService.js';
+import type { CacheableUser, User } from '@/models/entities/User.js';
+import type { Blocking } from '@/models/entities/Blocking.js';
+import { QueueService } from '@/core/QueueService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { WebhookService } from './WebhookService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+
+@Injectable()
+export class UserBlockingService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ @Inject(DI.followRequestsRepository)
+ private followRequestsRepository: FollowRequestsRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private queueService: QueueService,
+ private globalEventServie: GlobalEventService,
+ private webhookService: WebhookService,
+ private apRendererService: ApRendererService,
+ private perUserFollowingChart: PerUserFollowingChart,
+ ) {
+ }
+
+ public async block(blocker: User, blockee: User) {
+ await Promise.all([
+ this.cancelRequest(blocker, blockee),
+ this.cancelRequest(blockee, blocker),
+ this.unFollow(blocker, blockee),
+ this.unFollow(blockee, blocker),
+ this.removeFromList(blockee, blocker),
+ ]);
+
+ const blocking = {
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ blocker,
+ blockerId: blocker.id,
+ blockee,
+ blockeeId: blockee.id,
+ } as Blocking;
+
+ await this.blockingsRepository.insert(blocking);
+
+ if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking));
+ this.queueService.deliver(blocker, content, blockee.inbox);
+ }
+ }
+
+ private async cancelRequest(follower: User, followee: User) {
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (request == null) {
+ return;
+ }
+
+ await this.followRequestsRepository.delete({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (this.userEntityService.isLocalUser(followee)) {
+ this.userEntityService.pack(followee, followee, {
+ detail: true,
+ }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
+ }
+
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.userEntityService.pack(followee, follower, {
+ detail: true,
+ }).then(async packed => {
+ this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
+ this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'unfollow', {
+ user: packed,
+ });
+ }
+ });
+ }
+
+ // リモートにフォローリクエストをしていたらUndoFollow送信
+ if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+
+ // リモートからフォローリクエストを受けていたらReject送信
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+ }
+
+ private async unFollow(follower: User, followee: User) {
+ const following = await this.followingsRepository.findOneBy({
+ followerId: follower.id,
+ followeeId: followee.id,
+ });
+
+ if (following == null) {
+ return;
+ }
+
+ await Promise.all([
+ this.followingsRepository.delete(following.id),
+ this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
+ this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
+ this.perUserFollowingChart.update(follower, followee, false),
+ ]);
+
+ // Publish unfollow event
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.userEntityService.pack(followee, follower, {
+ detail: true,
+ }).then(async packed => {
+ this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
+ this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'unfollow', {
+ user: packed,
+ });
+ }
+ });
+ }
+
+ // リモートにフォローをしていたらUndoFollow送信
+ if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+ }
+
+ private async removeFromList(listOwner: User, user: User) {
+ const userLists = await this.userListsRepository.findBy({
+ userId: listOwner.id,
+ });
+
+ for (const userList of userLists) {
+ await this.userListJoiningsRepository.delete({
+ userListId: userList.id,
+ userId: user.id,
+ });
+ }
+ }
+
+ public async unblock(blocker: CacheableUser, blockee: CacheableUser) {
+ const blocking = await this.blockingsRepository.findOneBy({
+ blockerId: blocker.id,
+ blockeeId: blockee.id,
+ });
+
+ if (blocking == null) {
+ logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした');
+ return;
+ }
+
+ // Since we already have the blocker and blockee, we do not need to fetch
+ // them in the query above and can just manually insert them here.
+ blocking.blocker = blocker;
+ blocking.blockee = blockee;
+
+ await this.blockingsRepository.delete(blocking.id);
+
+ // deliver if remote bloking
+ if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker));
+ this.queueService.deliver(blocker, content, blockee.inbox);
+ }
+ }
+}
diff --git a/packages/backend/src/core/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts
new file mode 100644
index 000000000..8212abf7b
--- /dev/null
+++ b/packages/backend/src/core/UserCacheService.ts
@@ -0,0 +1,74 @@
+import { Inject, Injectable } from '@nestjs/common';
+import Redis from 'ioredis';
+import { UsersRepository } from '@/models/index.js';
+import { Cache } from '@/misc/cache.js';
+import type { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/User.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import type { OnApplicationShutdown } from '@nestjs/common';
+
+@Injectable()
+export class UserCacheService implements OnApplicationShutdown {
+ public userByIdCache: Cache;
+ public localUserByNativeTokenCache: Cache;
+ public localUserByIdCache: Cache;
+ public uriPersonCache: Cache;
+
+ constructor(
+ @Inject(DI.redisSubscriber)
+ private redisSubscriber: Redis.Redis,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ private userEntityService: UserEntityService,
+ ) {
+ this.onMessage = this.onMessage.bind(this);
+
+ this.userByIdCache = new Cache(Infinity);
+ this.localUserByNativeTokenCache = new Cache(Infinity);
+ this.localUserByIdCache = new Cache(Infinity);
+ this.uriPersonCache = new Cache(Infinity);
+
+ this.redisSubscriber.on('message', this.onMessage);
+ }
+
+ private async onMessage(_, data) {
+ const obj = JSON.parse(data);
+
+ if (obj.channel === 'internal') {
+ const { type, body } = obj.message;
+ switch (type) {
+ case 'userChangeSuspendedState':
+ case 'userChangeSilencedState':
+ case 'userChangeModeratorState':
+ case 'remoteUserUpdated': {
+ const user = await this.usersRepository.findOneByOrFail({ id: body.id });
+ this.userByIdCache.set(user.id, user);
+ for (const [k, v] of this.uriPersonCache.cache.entries()) {
+ if (v.value?.id === user.id) {
+ this.uriPersonCache.set(k, user);
+ }
+ }
+ if (this.userEntityService.isLocalUser(user)) {
+ this.localUserByNativeTokenCache.set(user.token, user);
+ this.localUserByIdCache.set(user.id, user);
+ }
+ break;
+ }
+ case 'userTokenRegenerated': {
+ const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as ILocalUser;
+ this.localUserByNativeTokenCache.delete(body.oldToken);
+ this.localUserByNativeTokenCache.set(body.newToken, user);
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ }
+
+ public onApplicationShutdown(signal?: string | undefined) {
+ this.redisSubscriber.off('message', this.onMessage);
+ }
+}
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
new file mode 100644
index 000000000..ff86d4343
--- /dev/null
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -0,0 +1,574 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { CacheableUser, ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { QueueService } from '@/core/QueueService.js';
+import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { IdService } from '@/core/IdService.js';
+import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
+import type { Packed } from '@/misc/schema.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { WebhookService } from '@/core/WebhookService.js';
+import { CreateNotificationService } from '@/core/CreateNotificationService.js';
+import { DI } from '@/di-symbols.js';
+import Logger from '../logger.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+
+const logger = new Logger('following/create');
+
+type Local = ILocalUser | {
+ id: ILocalUser['id'];
+ host: ILocalUser['host'];
+ uri: ILocalUser['uri']
+};
+type Remote = IRemoteUser | {
+ id: IRemoteUser['id'];
+ host: IRemoteUser['host'];
+ uri: IRemoteUser['uri'];
+ inbox: IRemoteUser['inbox'];
+};
+type Both = Local | Remote;
+
+@Injectable()
+export class UserFollowingService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.userProfilesRepository)
+ private userProfilesRepository: UserProfilesRepository,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ @Inject(DI.followRequestsRepository)
+ private followRequestsRepository: FollowRequestsRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private queueService: QueueService,
+ private globalEventServie: GlobalEventService,
+ private createNotificationService: CreateNotificationService,
+ private federatedInstanceService: FederatedInstanceService,
+ private webhookService: WebhookService,
+ private apRendererService: ApRendererService,
+ private perUserFollowingChart: PerUserFollowingChart,
+ private instanceChart: InstanceChart,
+ ) {
+ }
+
+ public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise {
+ const [follower, followee] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: _follower.id }),
+ this.usersRepository.findOneByOrFail({ id: _followee.id }),
+ ]);
+
+ // check blocking
+ const [blocking, blocked] = await Promise.all([
+ this.blockingsRepository.findOneBy({
+ blockerId: follower.id,
+ blockeeId: followee.id,
+ }),
+ this.blockingsRepository.findOneBy({
+ blockerId: followee.id,
+ blockeeId: follower.id,
+ }),
+ ]);
+
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) {
+ // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ return;
+ } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) {
+ // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
+ await this.blockingsRepository.delete(blocking.id);
+ } else {
+ // それ以外は単純に例外
+ if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
+ if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
+ }
+
+ const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
+
+ // フォロー対象が鍵アカウントである or
+ // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
+ // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
+ // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
+ if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
+ let autoAccept = false;
+
+ // 鍵アカウントであっても、既にフォローされていた場合はスルー
+ const following = await this.followingsRepository.findOneBy({
+ followerId: follower.id,
+ followeeId: followee.id,
+ });
+ if (following) {
+ autoAccept = true;
+ }
+
+ // フォローしているユーザーは自動承認オプション
+ if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
+ const followed = await this.followingsRepository.findOneBy({
+ followerId: followee.id,
+ followeeId: follower.id,
+ });
+
+ if (followed) autoAccept = true;
+ }
+
+ if (!autoAccept) {
+ await this.createFollowRequest(follower, followee, requestId);
+ return;
+ }
+ }
+
+ await this.insertFollowingDoc(followee, follower);
+
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+ }
+
+ private async insertFollowingDoc(
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
+ },
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
+ },
+ ): Promise {
+ if (follower.id === followee.id) return;
+
+ let alreadyFollowed = false as boolean;
+
+ await this.followingsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ followerId: follower.id,
+ followeeId: followee.id,
+
+ // 非正規化
+ followerHost: follower.host,
+ followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null,
+ followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null,
+ followeeHost: followee.host,
+ followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null,
+ followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null,
+ }).catch(err => {
+ if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
+ alreadyFollowed = true;
+ } else {
+ throw err;
+ }
+ });
+
+ const req = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (req) {
+ await this.followRequestsRepository.delete({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ // 通知を作成
+ this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', {
+ notifierId: followee.id,
+ });
+ }
+
+ if (alreadyFollowed) return;
+
+ //#region Increment counts
+ await Promise.all([
+ this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
+ this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
+ ]);
+ //#endregion
+
+ //#region Update instance stats
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
+ this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
+ this.instanceChart.updateFollowing(i.host, true);
+ });
+ } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
+ this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
+ this.instanceChart.updateFollowers(i.host, true);
+ });
+ }
+ //#endregion
+
+ this.perUserFollowingChart.update(follower, followee, true);
+
+ // Publish follow event
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.userEntityService.pack(followee.id, follower, {
+ detail: true,
+ }).then(async packed => {
+ this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
+ this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'follow', {
+ user: packed,
+ });
+ }
+ });
+ }
+
+ // Publish followed event
+ if (this.userEntityService.isLocalUser(followee)) {
+ this.userEntityService.pack(follower.id, followee).then(async packed => {
+ this.globalEventServie.publishMainStream(followee.id, 'followed', packed);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'followed', {
+ user: packed,
+ });
+ }
+ });
+
+ // 通知を作成
+ this.createNotificationService.createNotification(followee.id, 'follow', {
+ notifierId: follower.id,
+ });
+ }
+ }
+
+ public async unfollow(
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ silent = false,
+ ): Promise {
+ const following = await this.followingsRepository.findOneBy({
+ followerId: follower.id,
+ followeeId: followee.id,
+ });
+
+ if (following == null) {
+ logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
+ return;
+ }
+
+ await this.followingsRepository.delete(following.id);
+
+ this.decrementFollowing(follower, followee);
+
+ // Publish unfollow event
+ if (!silent && this.userEntityService.isLocalUser(follower)) {
+ this.userEntityService.pack(followee.id, follower, {
+ detail: true,
+ }).then(async packed => {
+ this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
+ this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'unfollow', {
+ user: packed,
+ });
+ }
+ });
+ }
+
+ if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+
+ if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
+ // local user has null host
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+ }
+
+ private async decrementFollowing(
+ follower: {id: User['id']; host: User['host']; },
+ followee: { id: User['id']; host: User['host']; },
+ ): Promise {
+ //#region Decrement following / followers counts
+ await Promise.all([
+ this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
+ this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
+ ]);
+ //#endregion
+
+ //#region Update instance stats
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
+ this.instanceChart.updateFollowing(i.host, false);
+ });
+ } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
+ this.instanceChart.updateFollowers(i.host, false);
+ });
+ }
+ //#endregion
+
+ this.perUserFollowingChart.update(follower, followee, false);
+ }
+
+ public async createFollowRequest(
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ requestId?: string,
+ ): Promise {
+ if (follower.id === followee.id) return;
+
+ // check blocking
+ const [blocking, blocked] = await Promise.all([
+ this.blockingsRepository.findOneBy({
+ blockerId: follower.id,
+ blockeeId: followee.id,
+ }),
+ this.blockingsRepository.findOneBy({
+ blockerId: followee.id,
+ blockeeId: follower.id,
+ }),
+ ]);
+
+ if (blocking != null) throw new Error('blocking');
+ if (blocked != null) throw new Error('blocked');
+
+ const followRequest = await this.followRequestsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ followerId: follower.id,
+ followeeId: followee.id,
+ requestId,
+
+ // 非正規化
+ followerHost: follower.host,
+ followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined,
+ followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined,
+ followeeHost: followee.host,
+ followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined,
+ followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined,
+ }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0]));
+
+ // Publish receiveRequest event
+ if (this.userEntityService.isLocalUser(followee)) {
+ this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed));
+
+ this.userEntityService.pack(followee.id, followee, {
+ detail: true,
+ }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
+
+ // 通知を作成
+ this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', {
+ notifierId: follower.id,
+ followRequestId: followRequest.id,
+ });
+ }
+
+ if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee));
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+ }
+
+ public async cancelFollowRequest(
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']
+ },
+ follower: {
+ id: User['id']; host: User['host']; uri: User['host']
+ },
+ ): Promise {
+ if (this.userEntityService.isRemoteUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+
+ if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
+ this.queueService.deliver(follower, content, followee.inbox);
+ }
+ }
+
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (request == null) {
+ throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
+ }
+
+ await this.followRequestsRepository.delete({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ this.userEntityService.pack(followee.id, followee, {
+ detail: true,
+ }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
+ }
+
+ public async acceptFollowRequest(
+ followee: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ follower: CacheableUser,
+ ): Promise {
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (request == null) {
+ throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.');
+ }
+
+ await this.insertFollowingDoc(followee, follower);
+
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+
+ this.userEntityService.pack(followee.id, followee, {
+ detail: true,
+ }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
+ }
+
+ public async acceptAllFollowRequests(
+ user: {
+ id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
+ },
+ ): Promise {
+ const requests = await this.followRequestsRepository.findBy({
+ followeeId: user.id,
+ });
+
+ for (const request of requests) {
+ const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
+ this.acceptFollowRequest(user, follower);
+ }
+ }
+
+ /**
+ * API following/request/reject
+ */
+ public async rejectFollowRequest(user: Local, follower: Both): Promise {
+ if (this.userEntityService.isRemoteUser(follower)) {
+ this.deliverReject(user, follower);
+ }
+
+ await this.removeFollowRequest(user, follower);
+
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.publishUnfollow(user, follower);
+ }
+ }
+
+ /**
+ * API following/reject
+ */
+ public async rejectFollow(user: Local, follower: Both): Promise {
+ if (this.userEntityService.isRemoteUser(follower)) {
+ this.deliverReject(user, follower);
+ }
+
+ await this.removeFollow(user, follower);
+
+ if (this.userEntityService.isLocalUser(follower)) {
+ this.publishUnfollow(user, follower);
+ }
+ }
+
+ /**
+ * AP Reject/Follow
+ */
+ public async remoteReject(actor: Remote, follower: Local): Promise {
+ await this.removeFollowRequest(actor, follower);
+ await this.removeFollow(actor, follower);
+ this.publishUnfollow(actor, follower);
+ }
+
+ /**
+ * Remove follow request record
+ */
+ private async removeFollowRequest(followee: Both, follower: Both): Promise {
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (!request) return;
+
+ await this.followRequestsRepository.delete(request.id);
+ }
+
+ /**
+ * Remove follow record
+ */
+ private async removeFollow(followee: Both, follower: Both): Promise {
+ const following = await this.followingsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ if (!following) return;
+
+ await this.followingsRepository.delete(following.id);
+ this.decrementFollowing(follower, followee);
+ }
+
+ /**
+ * Deliver Reject to remote
+ */
+ private async deliverReject(followee: Local, follower: Remote): Promise {
+ const request = await this.followRequestsRepository.findOneBy({
+ followeeId: followee.id,
+ followerId: follower.id,
+ });
+
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
+ this.queueService.deliver(followee, content, follower.inbox);
+ }
+
+ /**
+ * Publish unfollow to local
+ */
+ private async publishUnfollow(followee: Both, follower: Local): Promise {
+ const packedFollowee = await this.userEntityService.pack(followee.id, follower, {
+ detail: true,
+ });
+
+ this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee);
+ this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee);
+
+ const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
+ for (const webhook of webhooks) {
+ this.queueService.webhookDeliver(webhook, 'unfollow', {
+ user: packedFollowee,
+ });
+ }
+ }
+}
diff --git a/packages/backend/src/core/UserKeypairStoreService.ts b/packages/backend/src/core/UserKeypairStoreService.ts
new file mode 100644
index 000000000..e53f37b71
--- /dev/null
+++ b/packages/backend/src/core/UserKeypairStoreService.ts
@@ -0,0 +1,22 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { User } from '@/models/entities/User.js';
+import { UserKeypairsRepository } from '@/models/index.js';
+import { Cache } from '@/misc/cache.js';
+import type { UserKeypair } from '@/models/entities/UserKeypair.js';
+import { DI } from '@/di-symbols.js';
+
+@Injectable()
+export class UserKeypairStoreService {
+ private cache: Cache;
+
+ constructor(
+ @Inject(DI.userKeypairsRepository)
+ private userKeypairsRepository: UserKeypairsRepository,
+ ) {
+ this.cache = new Cache(Infinity);
+ }
+
+ public async getUserKeypair(userId: User['id']): Promise {
+ return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId }));
+ }
+}
diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts
new file mode 100644
index 000000000..03113f042
--- /dev/null
+++ b/packages/backend/src/core/UserListService.ts
@@ -0,0 +1,48 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
+import type { User } from '@/models/entities/User.js';
+import type { UserList } from '@/models/entities/UserList.js';
+import type { UserListJoining } from '@/models/entities/UserListJoining.js';
+import { IdService } from '@/core/IdService.js';
+import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+import { ProxyAccountService } from './ProxyAccountService.js';
+
+@Injectable()
+export class UserListService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ private userFollowingService: UserFollowingService,
+ private globalEventServie: GlobalEventService,
+ private proxyAccountService: ProxyAccountService,
+ ) {
+ }
+
+ public async push(target: User, list: UserList) {
+ await this.userListJoiningsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ userId: target.id,
+ userListId: list.id,
+ } as UserListJoining);
+
+ this.globalEventServie.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
+
+ // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
+ if (this.userEntityService.isRemoteUser(target)) {
+ const proxy = await this.proxyAccountService.fetch();
+ if (proxy) {
+ this.userFollowingService.follow(proxy, target);
+ }
+ }
+ }
+}
diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts
new file mode 100644
index 000000000..9146360df
--- /dev/null
+++ b/packages/backend/src/core/UserMutingService.ts
@@ -0,0 +1,32 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { UsersRepository, MutingsRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import { QueueService } from '@/core/QueueService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import type { User } from '@/models/entities/User.js';
+import { DI } from '@/di-symbols.js';
+
+@Injectable()
+export class UserMutingService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+
+ private idService: IdService,
+ private queueService: QueueService,
+ private globalEventServie: GlobalEventService,
+ ) {
+ }
+
+ public async mute(user: User, target: User): Promise {
+ await this.mutingsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ muterId: user.id,
+ muteeId: target.id,
+ });
+ }
+}
diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts
new file mode 100644
index 000000000..068341cb2
--- /dev/null
+++ b/packages/backend/src/core/UserSuspendService.ts
@@ -0,0 +1,88 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Not, IsNull } from 'typeorm';
+import { FollowingsRepository, UsersRepository } from '@/models/index.js';
+import type { User } from '@/models/entities/User.js';
+import { QueueService } from '@/core/QueueService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { DI } from '@/di-symbols.js';
+import { Config } from '@/config.js';
+import { ApRendererService } from './remote/activitypub/ApRendererService.js';
+import { UserEntityService } from './entities/UserEntityService.js';
+
+@Injectable()
+export class UserSuspendService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ private userEntityService: UserEntityService,
+ private queueService: QueueService,
+ private globalEventService: GlobalEventService,
+ private apRendererService: ApRendererService,
+ ) {
+ }
+
+ public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise {
+ this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
+
+ if (this.userEntityService.isLocalUser(user)) {
+ // 知り得る全SharedInboxにDelete配信
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user));
+
+ const queue: string[] = [];
+
+ const followings = await this.followingsRepository.find({
+ where: [
+ { followerSharedInbox: Not(IsNull()) },
+ { followeeSharedInbox: Not(IsNull()) },
+ ],
+ select: ['followerSharedInbox', 'followeeSharedInbox'],
+ });
+
+ const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
+
+ for (const inbox of inboxes) {
+ if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
+ }
+
+ for (const inbox of queue) {
+ this.queueService.deliver(user, content, inbox);
+ }
+ }
+ }
+
+ public async doPostUnsuspend(user: User): Promise {
+ this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
+
+ if (this.userEntityService.isLocalUser(user)) {
+ // 知り得る全SharedInboxにUndo Delete配信
+ const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user));
+
+ const queue: string[] = [];
+
+ const followings = await this.followingsRepository.find({
+ where: [
+ { followerSharedInbox: Not(IsNull()) },
+ { followeeSharedInbox: Not(IsNull()) },
+ ],
+ select: ['followerSharedInbox', 'followeeSharedInbox'],
+ });
+
+ const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
+
+ for (const inbox of inboxes) {
+ if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
+ }
+
+ for (const inbox of queue) {
+ this.queueService.deliver(user as any, content, inbox);
+ }
+ }
+ }
+}
diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts
new file mode 100644
index 000000000..ba03dfc06
--- /dev/null
+++ b/packages/backend/src/core/UtilityService.ts
@@ -0,0 +1,37 @@
+import { URL } from 'node:url';
+import { toASCII } from 'punycode';
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import { Config } from '@/config.js';
+
+@Injectable()
+export class UtilityService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+ ) {
+ }
+
+ public getFullApAccount(username: string, host: string | null): string {
+ return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`;
+ }
+
+ public isSelfHost(host: string | null): boolean {
+ if (host == null) return true;
+ return this.toPuny(this.config.host) === this.toPuny(host);
+ }
+
+ public extractDbHost(uri: string): string {
+ const url = new URL(uri);
+ return this.toPuny(url.hostname);
+ }
+
+ public toPuny(host: string): string {
+ return toASCII(host.toLowerCase());
+ }
+
+ public toPunyNullable(host: string | null | undefined): string | null {
+ if (host == null) return null;
+ return toASCII(host.toLowerCase());
+ }
+}
diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts
new file mode 100644
index 000000000..70b9664c7
--- /dev/null
+++ b/packages/backend/src/core/VideoProcessingService.ts
@@ -0,0 +1,44 @@
+import { Inject, Injectable } from '@nestjs/common';
+import FFmpeg from 'fluent-ffmpeg';
+import { DI } from '@/di-symbols.js';
+import { Config } from '@/config.js';
+import { ImageProcessingService } from '@/core/ImageProcessingService.js';
+import type { IImage } from '@/core/ImageProcessingService.js';
+import { createTempDir } from '@/misc/create-temp.js';
+
+@Injectable()
+export class VideoProcessingService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ private imageProcessingService: ImageProcessingService,
+ ) {
+ }
+
+ public async generateVideoThumbnail(source: string): Promise {
+ const [dir, cleanup] = await createTempDir();
+
+ try {
+ await new Promise((res, rej) => {
+ FFmpeg({
+ source,
+ })
+ .on('end', res)
+ .on('error', rej)
+ .screenshot({
+ folder: dir,
+ filename: 'out.png', // must have .png extension
+ count: 1,
+ timestamps: ['5%'],
+ });
+ });
+
+ // JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる)
+ return await this.imageProcessingService.convertToJpeg(`${dir}/out.png`, 498, 280);
+ } finally {
+ cleanup();
+ }
+ }
+}
+
diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts
new file mode 100644
index 000000000..1d74290dd
--- /dev/null
+++ b/packages/backend/src/core/WebhookService.ts
@@ -0,0 +1,70 @@
+import { Inject, Injectable } from '@nestjs/common';
+import Redis from 'ioredis';
+import { WebhooksRepository } from '@/models/index.js';
+import type { Webhook } from '@/models/entities/Webhook.js';
+import { DI } from '@/di-symbols.js';
+import type { OnApplicationShutdown } from '@nestjs/common';
+
+@Injectable()
+export class WebhookService implements OnApplicationShutdown {
+ private webhooksFetched = false;
+ private webhooks: Webhook[] = [];
+
+ constructor(
+ @Inject(DI.redisSubscriber)
+ private redisSubscriber: Redis.Redis,
+
+ @Inject(DI.webhooksRepository)
+ private webhooksRepository: WebhooksRepository,
+ ) {
+ this.onMessage = this.onMessage.bind(this);
+ this.redisSubscriber.on('message', this.onMessage);
+ }
+
+ public async getActiveWebhooks() {
+ if (!this.webhooksFetched) {
+ this.webhooks = await this.webhooksRepository.findBy({
+ active: true,
+ });
+ this.webhooksFetched = true;
+ }
+
+ return this.webhooks;
+ }
+
+ private async onMessage(_, data) {
+ const obj = JSON.parse(data);
+
+ if (obj.channel === 'internal') {
+ const { type, body } = obj.message;
+ switch (type) {
+ case 'webhookCreated':
+ if (body.active) {
+ this.webhooks.push(body);
+ }
+ break;
+ case 'webhookUpdated':
+ if (body.active) {
+ const i = this.webhooks.findIndex(a => a.id === body.id);
+ if (i > -1) {
+ this.webhooks[i] = body;
+ } else {
+ this.webhooks.push(body);
+ }
+ } else {
+ this.webhooks = this.webhooks.filter(a => a.id !== body.id);
+ }
+ break;
+ case 'webhookDeleted':
+ this.webhooks = this.webhooks.filter(a => a.id !== body.id);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ public onApplicationShutdown(signal?: string | undefined) {
+ this.redisSubscriber.off('message', this.onMessage);
+ }
+}
diff --git a/packages/backend/src/core/chart/ChartLoggerService.ts b/packages/backend/src/core/chart/ChartLoggerService.ts
new file mode 100644
index 000000000..544a006ac
--- /dev/null
+++ b/packages/backend/src/core/chart/ChartLoggerService.ts
@@ -0,0 +1,14 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type Logger from '@/logger.js';
+import { LoggerService } from '@/core/LoggerService.js';
+
+@Injectable()
+export class ChartLoggerService {
+ public logger: Logger;
+
+ constructor(
+ private loggerService: LoggerService,
+ ) {
+ this.logger = this.loggerService.getLogger('chart', 'white', process.env.NODE_ENV !== 'test');
+ }
+}
diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts
new file mode 100644
index 000000000..6476cd684
--- /dev/null
+++ b/packages/backend/src/core/chart/ChartManagementService.ts
@@ -0,0 +1,67 @@
+import { Injectable, Inject } from '@nestjs/common';
+
+import FederationChart from './charts/federation.js';
+import NotesChart from './charts/notes.js';
+import UsersChart from './charts/users.js';
+import ActiveUsersChart from './charts/active-users.js';
+import InstanceChart from './charts/instance.js';
+import PerUserNotesChart from './charts/per-user-notes.js';
+import DriveChart from './charts/drive.js';
+import PerUserReactionsChart from './charts/per-user-reactions.js';
+import HashtagChart from './charts/hashtag.js';
+import PerUserFollowingChart from './charts/per-user-following.js';
+import PerUserDriveChart from './charts/per-user-drive.js';
+import ApRequestChart from './charts/ap-request.js';
+import type { OnApplicationShutdown } from '@nestjs/common';
+
+@Injectable()
+export class ChartManagementService implements OnApplicationShutdown {
+ private charts;
+ private saveIntervalId: NodeJS.Timer;
+
+ constructor(
+ private federationChart: FederationChart,
+ private notesChart: NotesChart,
+ private usersChart: UsersChart,
+ private activeUsersChart: ActiveUsersChart,
+ private instanceChart: InstanceChart,
+ private perUserNotesChart: PerUserNotesChart,
+ private driveChart: DriveChart,
+ private perUserReactionsChart: PerUserReactionsChart,
+ private hashtagChart: HashtagChart,
+ private perUserFollowingChart: PerUserFollowingChart,
+ private perUserDriveChart: PerUserDriveChart,
+ private apRequestChart: ApRequestChart,
+ ) {
+ this.charts = [
+ this.federationChart,
+ this.notesChart,
+ this.usersChart,
+ this.activeUsersChart,
+ this.instanceChart,
+ this.perUserNotesChart,
+ this.driveChart,
+ this.perUserReactionsChart,
+ this.hashtagChart,
+ this.perUserFollowingChart,
+ this.perUserDriveChart,
+ this.apRequestChart,
+ ];
+ }
+
+ public async run() {
+ // 20分おきにメモリ情報をDBに書き込み
+ this.saveIntervalId = setInterval(() => {
+ for (const chart of this.charts) {
+ chart.save();
+ }
+ }, 1000 * 60 * 20);
+ }
+
+ async onApplicationShutdown(signal: string): Promise {
+ clearInterval(this.saveIntervalId);
+ await Promise.all(
+ this.charts.map(chart => chart.save()),
+ );
+ }
+}
diff --git a/packages/backend/src/services/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts
similarity index 68%
rename from packages/backend/src/services/chart/charts/active-users.ts
rename to packages/backend/src/core/chart/charts/active-users.ts
index d952ea53b..40c60910e 100644
--- a/packages/backend/src/services/chart/charts/active-users.ts
+++ b/packages/backend/src/core/chart/charts/active-users.ts
@@ -1,7 +1,12 @@
-import Chart, { KVs } from '../core.js';
-import { User } from '@/models/entities/user.js';
-import { Users } from '@/models/index.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { AppLockService } from '@/core/AppLockService.js';
+import type { User } from '@/models/entities/User.js';
+import { DI } from '@/di-symbols.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/active-users.js';
+import type { KVs } from '../core.js';
const week = 1000 * 60 * 60 * 24 * 7;
const month = 1000 * 60 * 60 * 24 * 30;
@@ -11,9 +16,16 @@ const year = 1000 * 60 * 60 * 24 * 365;
* アクティブユーザーに関するチャート
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class ActiveUsersChart extends Chart {
- constructor() {
- super(name, schema);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/services/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts
similarity index 54%
rename from packages/backend/src/services/chart/charts/ap-request.ts
rename to packages/backend/src/core/chart/charts/ap-request.ts
index e9e42ade7..4b91fbbf1 100644
--- a/packages/backend/src/services/chart/charts/ap-request.ts
+++ b/packages/backend/src/core/chart/charts/ap-request.ts
@@ -1,13 +1,26 @@
-import Chart, { KVs } from '../core.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/ap-request.js';
+import type { KVs } from '../core.js';
/**
* Chart about ActivityPub requests
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class ApRequestChart extends Chart {
- constructor() {
- super(name, schema);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/services/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts
similarity index 58%
rename from packages/backend/src/services/chart/charts/drive.ts
rename to packages/backend/src/core/chart/charts/drive.ts
index 0eeba90dd..494dfbbe5 100644
--- a/packages/backend/src/services/chart/charts/drive.ts
+++ b/packages/backend/src/core/chart/charts/drive.ts
@@ -1,16 +1,27 @@
-import Chart, { KVs } from '../core.js';
-import { DriveFiles } from '@/models/index.js';
-import { Not, IsNull } from 'typeorm';
-import { DriveFile } from '@/models/entities/drive-file.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { Not, IsNull, DataSource } from 'typeorm';
+import type { DriveFile } from '@/models/entities/DriveFile.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/drive.js';
+import type { KVs } from '../core.js';
/**
* ドライブに関するチャート
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class DriveChart extends Chart {
- constructor() {
- super(name, schema);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/services/chart/charts/entities/active-users.ts b/packages/backend/src/core/chart/charts/entities/active-users.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/active-users.ts
rename to packages/backend/src/core/chart/charts/entities/active-users.ts
diff --git a/packages/backend/src/services/chart/charts/entities/ap-request.ts b/packages/backend/src/core/chart/charts/entities/ap-request.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/ap-request.ts
rename to packages/backend/src/core/chart/charts/entities/ap-request.ts
diff --git a/packages/backend/src/services/chart/charts/entities/drive.ts b/packages/backend/src/core/chart/charts/entities/drive.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/drive.ts
rename to packages/backend/src/core/chart/charts/entities/drive.ts
diff --git a/packages/backend/src/services/chart/charts/entities/federation.ts b/packages/backend/src/core/chart/charts/entities/federation.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/federation.ts
rename to packages/backend/src/core/chart/charts/entities/federation.ts
diff --git a/packages/backend/src/services/chart/charts/entities/hashtag.ts b/packages/backend/src/core/chart/charts/entities/hashtag.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/hashtag.ts
rename to packages/backend/src/core/chart/charts/entities/hashtag.ts
diff --git a/packages/backend/src/services/chart/charts/entities/instance.ts b/packages/backend/src/core/chart/charts/entities/instance.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/instance.ts
rename to packages/backend/src/core/chart/charts/entities/instance.ts
diff --git a/packages/backend/src/services/chart/charts/entities/notes.ts b/packages/backend/src/core/chart/charts/entities/notes.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/notes.ts
rename to packages/backend/src/core/chart/charts/entities/notes.ts
diff --git a/packages/backend/src/services/chart/charts/entities/per-user-drive.ts b/packages/backend/src/core/chart/charts/entities/per-user-drive.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/per-user-drive.ts
rename to packages/backend/src/core/chart/charts/entities/per-user-drive.ts
diff --git a/packages/backend/src/services/chart/charts/entities/per-user-following.ts b/packages/backend/src/core/chart/charts/entities/per-user-following.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/per-user-following.ts
rename to packages/backend/src/core/chart/charts/entities/per-user-following.ts
diff --git a/packages/backend/src/services/chart/charts/entities/per-user-notes.ts b/packages/backend/src/core/chart/charts/entities/per-user-notes.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/per-user-notes.ts
rename to packages/backend/src/core/chart/charts/entities/per-user-notes.ts
diff --git a/packages/backend/src/services/chart/charts/entities/per-user-reactions.ts b/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/per-user-reactions.ts
rename to packages/backend/src/core/chart/charts/entities/per-user-reactions.ts
diff --git a/packages/backend/src/services/chart/charts/entities/test-grouped.ts b/packages/backend/src/core/chart/charts/entities/test-grouped.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/test-grouped.ts
rename to packages/backend/src/core/chart/charts/entities/test-grouped.ts
diff --git a/packages/backend/src/services/chart/charts/entities/test-intersection.ts b/packages/backend/src/core/chart/charts/entities/test-intersection.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/test-intersection.ts
rename to packages/backend/src/core/chart/charts/entities/test-intersection.ts
diff --git a/packages/backend/src/services/chart/charts/entities/test-unique.ts b/packages/backend/src/core/chart/charts/entities/test-unique.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/test-unique.ts
rename to packages/backend/src/core/chart/charts/entities/test-unique.ts
diff --git a/packages/backend/src/services/chart/charts/entities/test.ts b/packages/backend/src/core/chart/charts/entities/test.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/test.ts
rename to packages/backend/src/core/chart/charts/entities/test.ts
diff --git a/packages/backend/src/services/chart/charts/entities/users.ts b/packages/backend/src/core/chart/charts/entities/users.ts
similarity index 100%
rename from packages/backend/src/services/chart/charts/entities/users.ts
rename to packages/backend/src/core/chart/charts/entities/users.ts
diff --git a/packages/backend/src/services/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts
similarity index 50%
rename from packages/backend/src/services/chart/charts/federation.ts
rename to packages/backend/src/core/chart/charts/federation.ts
index 10221ee1e..4366d4cce 100644
--- a/packages/backend/src/services/chart/charts/federation.ts
+++ b/packages/backend/src/core/chart/charts/federation.ts
@@ -1,15 +1,35 @@
-import Chart, { KVs } from '../core.js';
-import { Followings, Instances } from '@/models/index.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { FollowingsRepository, InstancesRepository } from '@/models/index.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { MetaService } from '@/core/MetaService.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/federation.js';
-import { fetchMeta } from '@/misc/fetch-meta.js';
+import type { KVs } from '../core.js';
/**
* フェデレーションに関するチャート
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class FederationChart extends Chart {
- constructor() {
- super(name, schema);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
+ private metaService: MetaService,
+ private appLockService: AppLockService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
@@ -18,62 +38,62 @@ export default class FederationChart extends Chart {
}
protected async tickMinor(): Promise>> {
- const meta = await fetchMeta();
+ const meta = await this.metaService.fetch();
- const suspendedInstancesQuery = Instances.createQueryBuilder('instance')
+ const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance')
.select('instance.host')
.where('instance.isSuspended = true');
- const pubsubSubQuery = Followings.createQueryBuilder('f')
+ const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
.select('f.followerHost')
.where('f.followerHost IS NOT NULL');
- const subInstancesQuery = Followings.createQueryBuilder('f')
+ const subInstancesQuery = this.followingsRepository.createQueryBuilder('f')
.select('f.followeeHost')
.where('f.followeeHost IS NOT NULL');
- const pubInstancesQuery = Followings.createQueryBuilder('f')
+ const pubInstancesQuery = this.followingsRepository.createQueryBuilder('f')
.select('f.followerHost')
.where('f.followerHost IS NOT NULL');
const [sub, pub, pubsub, subActive, pubActive] = await Promise.all([
- Followings.createQueryBuilder('following')
+ this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL')
- .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followeeHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
+ .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts })
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.getRawOne()
.then(x => parseInt(x.count, 10)),
- Followings.createQueryBuilder('following')
+ this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followerHost)')
.where('following.followerHost IS NOT NULL')
- .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followerHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
+ .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT IN (:...blocked)', { blocked: meta.blockedHosts })
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.getRawOne()
.then(x => parseInt(x.count, 10)),
- Followings.createQueryBuilder('following')
+ this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL')
- .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followeeHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
+ .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts })
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
.setParameters(pubsubSubQuery.getParameters())
.getRawOne()
.then(x => parseInt(x.count, 10)),
- Instances.createQueryBuilder('instance')
+ this.instancesRepository.createQueryBuilder('instance')
.select('COUNT(instance.id)')
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
- .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
- .andWhere(`instance.isSuspended = false`)
- .andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
+ .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts })
+ .andWhere('instance.isSuspended = false')
+ .andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
.getRawOne()
.then(x => parseInt(x.count, 10)),
- Instances.createQueryBuilder('instance')
+ this.instancesRepository.createQueryBuilder('instance')
.select('COUNT(instance.id)')
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
- .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
- .andWhere(`instance.isSuspended = false`)
- .andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
+ .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts })
+ .andWhere('instance.isSuspended = false')
+ .andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
.getRawOne()
.then(x => parseInt(x.count, 10)),
]);
diff --git a/packages/backend/src/core/chart/charts/hashtag.ts b/packages/backend/src/core/chart/charts/hashtag.ts
new file mode 100644
index 000000000..8b8c795cf
--- /dev/null
+++ b/packages/backend/src/core/chart/charts/hashtag.ts
@@ -0,0 +1,43 @@
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import type { User } from '@/models/entities/User.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
+import { name, schema } from './entities/hashtag.js';
+import type { KVs } from '../core.js';
+
+/**
+ * ハッシュタグに関するチャート
+ */
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class HashtagChart extends Chart {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private userEntityService: UserEntityService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ }
+
+ protected async tickMajor(): Promise>> {
+ return {};
+ }
+
+ protected async tickMinor(): Promise>> {
+ return {};
+ }
+
+ public async update(hashtag: string, user: { id: User['id'], host: User['host'] }): Promise {
+ await this.commit({
+ 'local.users': this.userEntityService.isLocalUser(user) ? [user.id] : [],
+ 'remote.users': this.userEntityService.isLocalUser(user) ? [] : [user.id],
+ }, hashtag);
+ }
+}
diff --git a/packages/backend/src/services/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts
similarity index 59%
rename from packages/backend/src/services/chart/charts/instance.ts
rename to packages/backend/src/core/chart/charts/instance.ts
index fe29ba522..be70bc79c 100644
--- a/packages/backend/src/services/chart/charts/instance.ts
+++ b/packages/backend/src/core/chart/charts/instance.ts
@@ -1,17 +1,43 @@
-import Chart, { KVs } from '../core.js';
-import { DriveFiles, Followings, Users, Notes } from '@/models/index.js';
-import { DriveFile } from '@/models/entities/drive-file.js';
-import { Note } from '@/models/entities/note.js';
-import { toPuny } from '@/misc/convert-host.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
+import type { DriveFile } from '@/models/entities/DriveFile.js';
+import type { Note } from '@/models/entities/Note.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { UtilityService } from '@/core/UtilityService.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/instance.js';
+import type { KVs } from '../core.js';
/**
* インスタンスごとのチャート
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class InstanceChart extends Chart {
- constructor() {
- super(name, schema, true);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ private utilityService: UtilityService,
+ private appLockService: AppLockService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
@@ -22,11 +48,11 @@ export default class InstanceChart extends Chart {
followersCount,
driveFiles,
] = await Promise.all([
- Notes.countBy({ userHost: group }),
- Users.countBy({ host: group }),
- Followings.countBy({ followerHost: group }),
- Followings.countBy({ followeeHost: group }),
- DriveFiles.countBy({ userHost: group }),
+ this.notesRepository.countBy({ userHost: group }),
+ this.usersRepository.countBy({ host: group }),
+ this.followingsRepository.countBy({ followerHost: group }),
+ this.followingsRepository.countBy({ followeeHost: group }),
+ this.driveFilesRepository.countBy({ userHost: group }),
]);
return {
@@ -45,21 +71,21 @@ export default class InstanceChart extends Chart {
public async requestReceived(host: string): Promise {
await this.commit({
'requests.received': 1,
- }, toPuny(host));
+ }, this.utilityService.toPuny(host));
}
public async requestSent(host: string, isSucceeded: boolean): Promise {
await this.commit({
'requests.succeeded': isSucceeded ? 1 : 0,
'requests.failed': isSucceeded ? 0 : 1,
- }, toPuny(host));
+ }, this.utilityService.toPuny(host));
}
public async newUser(host: string): Promise {
await this.commit({
'users.total': 1,
'users.inc': 1,
- }, toPuny(host));
+ }, this.utilityService.toPuny(host));
}
public async updateNote(host: string, note: Note, isAdditional: boolean): Promise {
@@ -71,7 +97,7 @@ export default class InstanceChart extends Chart {
'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0,
'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0,
'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0,
- }, toPuny(host));
+ }, this.utilityService.toPuny(host));
}
public async updateFollowing(host: string, isAdditional: boolean): Promise {
@@ -79,7 +105,7 @@ export default class InstanceChart extends Chart {
'following.total': isAdditional ? 1 : -1,
'following.inc': isAdditional ? 1 : 0,
'following.dec': isAdditional ? 0 : 1,
- }, toPuny(host));
+ }, this.utilityService.toPuny(host));
}
public async updateFollowers(host: string, isAdditional: boolean): Promise {
@@ -87,7 +113,7 @@ export default class InstanceChart extends Chart {
'followers.total': isAdditional ? 1 : -1,
'followers.inc': isAdditional ? 1 : 0,
'followers.dec': isAdditional ? 0 : 1,
- }, toPuny(host));
+ }, this.utilityService.toPuny(host));
}
public async updateDrive(file: DriveFile, isAdditional: boolean): Promise {
diff --git a/packages/backend/src/services/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts
similarity index 56%
rename from packages/backend/src/services/chart/charts/notes.ts
rename to packages/backend/src/core/chart/charts/notes.ts
index bb14b62f3..e1bfeabf9 100644
--- a/packages/backend/src/services/chart/charts/notes.ts
+++ b/packages/backend/src/core/chart/charts/notes.ts
@@ -1,22 +1,37 @@
-import Chart, { KVs } from '../core.js';
-import { Notes } from '@/models/index.js';
-import { Not, IsNull } from 'typeorm';
-import { Note } from '@/models/entities/note.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { Not, IsNull, DataSource } from 'typeorm';
+import { NotesRepository } from '@/models/index.js';
+import type { Note } from '@/models/entities/Note.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/notes.js';
+import type { KVs } from '../core.js';
/**
* ノートに関するチャート
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class NotesChart extends Chart {
- constructor() {
- super(name, schema);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ private appLockService: AppLockService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
const [localCount, remoteCount] = await Promise.all([
- Notes.countBy({ userHost: IsNull() }),
- Notes.countBy({ userHost: Not(IsNull()) }),
+ this.notesRepository.countBy({ userHost: IsNull() }),
+ this.notesRepository.countBy({ userHost: Not(IsNull()) }),
]);
return {
diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts
new file mode 100644
index 000000000..752203daa
--- /dev/null
+++ b/packages/backend/src/core/chart/charts/per-user-drive.ts
@@ -0,0 +1,60 @@
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { DriveFilesRepository } from '@/models/index.js';
+import type { DriveFile } from '@/models/entities/DriveFile.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
+import { name, schema } from './entities/per-user-drive.js';
+import type { KVs } from '../core.js';
+
+/**
+ * ユーザーごとのドライブに関するチャート
+ */
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class PerUserDriveChart extends Chart {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ private appLockService: AppLockService,
+ private driveFileEntityService: DriveFileEntityService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ }
+
+ protected async tickMajor(group: string): Promise>> {
+ const [count, size] = await Promise.all([
+ this.driveFilesRepository.countBy({ userId: group }),
+ this.driveFileEntityService.calcDriveUsageOf(group),
+ ]);
+
+ return {
+ 'totalCount': count,
+ 'totalSize': size,
+ };
+ }
+
+ protected async tickMinor(): Promise>> {
+ return {};
+ }
+
+ public async update(file: DriveFile, isAdditional: boolean): Promise {
+ const fileSizeKb = file.size / 1000;
+ await this.commit({
+ 'totalCount': isAdditional ? 1 : -1,
+ 'totalSize': isAdditional ? fileSizeKb : -fileSizeKb,
+ 'incCount': isAdditional ? 1 : 0,
+ 'incSize': isAdditional ? fileSizeKb : 0,
+ 'decCount': isAdditional ? 0 : 1,
+ 'decSize': isAdditional ? 0 : fileSizeKb,
+ }, file.userId);
+ }
+}
diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts
new file mode 100644
index 000000000..48bf3d7c6
--- /dev/null
+++ b/packages/backend/src/core/chart/charts/per-user-following.ts
@@ -0,0 +1,73 @@
+import { Injectable, Inject } from '@nestjs/common';
+import { Not, IsNull, DataSource } from 'typeorm';
+import type { User } from '@/models/entities/User.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { FollowingsRepository } from '@/models/index.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
+import { name, schema } from './entities/per-user-following.js';
+import type { KVs } from '../core.js';
+
+/**
+ * ユーザーごとのフォローに関するチャート
+ */
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class PerUserFollowingChart extends Chart {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ private appLockService: AppLockService,
+ private userEntityService: UserEntityService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ }
+
+ protected async tickMajor(group: string): Promise>> {
+ const [
+ localFollowingsCount,
+ localFollowersCount,
+ remoteFollowingsCount,
+ remoteFollowersCount,
+ ] = await Promise.all([
+ this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }),
+ this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }),
+ this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }),
+ this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }),
+ ]);
+
+ return {
+ 'local.followings.total': localFollowingsCount,
+ 'local.followers.total': localFollowersCount,
+ 'remote.followings.total': remoteFollowingsCount,
+ 'remote.followers.total': remoteFollowersCount,
+ };
+ }
+
+ protected async tickMinor(): Promise>> {
+ return {};
+ }
+
+ public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean): Promise {
+ const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote';
+ const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote';
+
+ this.commit({
+ [`${prefixFollower}.followings.total`]: isFollow ? 1 : -1,
+ [`${prefixFollower}.followings.inc`]: isFollow ? 1 : 0,
+ [`${prefixFollower}.followings.dec`]: isFollow ? 0 : 1,
+ }, follower.id);
+ this.commit({
+ [`${prefixFollowee}.followers.total`]: isFollow ? 1 : -1,
+ [`${prefixFollowee}.followers.inc`]: isFollow ? 1 : 0,
+ [`${prefixFollowee}.followers.dec`]: isFollow ? 0 : 1,
+ }, followee.id);
+ }
+}
diff --git a/packages/backend/src/services/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts
similarity index 54%
rename from packages/backend/src/services/chart/charts/per-user-notes.ts
rename to packages/backend/src/core/chart/charts/per-user-notes.ts
index b9191dd08..ffe52dcd5 100644
--- a/packages/backend/src/services/chart/charts/per-user-notes.ts
+++ b/packages/backend/src/core/chart/charts/per-user-notes.ts
@@ -1,21 +1,37 @@
-import Chart, { KVs } from '../core.js';
-import { User } from '@/models/entities/user.js';
-import { Notes } from '@/models/index.js';
-import { Note } from '@/models/entities/note.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import type { User } from '@/models/entities/User.js';
+import type { Note } from '@/models/entities/Note.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { NotesRepository } from '@/models/index.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-notes.js';
+import type { KVs } from '../core.js';
/**
* ユーザーごとのノートに関するチャート
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class PerUserNotesChart extends Chart {
- constructor() {
- super(name, schema, true);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ private appLockService: AppLockService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
const [count] = await Promise.all([
- Notes.countBy({ userId: group }),
+ this.notesRepository.countBy({ userId: group }),
]);
return {
diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts
new file mode 100644
index 000000000..416021972
--- /dev/null
+++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts
@@ -0,0 +1,44 @@
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import type { User } from '@/models/entities/User.js';
+import type { Note } from '@/models/entities/Note.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
+import { name, schema } from './entities/per-user-reactions.js';
+import type { KVs } from '../core.js';
+
+/**
+ * ユーザーごとのリアクションに関するチャート
+ */
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class PerUserReactionsChart extends Chart {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private userEntityService: UserEntityService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ }
+
+ protected async tickMajor(group: string): Promise>> {
+ return {};
+ }
+
+ protected async tickMinor(): Promise>> {
+ return {};
+ }
+
+ public async update(user: { id: User['id'], host: User['host'] }, note: Note): Promise {
+ const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
+ this.commit({
+ [`${prefix}.count`]: 1,
+ }, note.userId);
+ }
+}
diff --git a/packages/backend/src/services/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts
similarity index 56%
rename from packages/backend/src/services/chart/charts/test-grouped.ts
rename to packages/backend/src/core/chart/charts/test-grouped.ts
index d01c9fcbd..500e85f9f 100644
--- a/packages/backend/src/services/chart/charts/test-grouped.ts
+++ b/packages/backend/src/core/chart/charts/test-grouped.ts
@@ -1,15 +1,28 @@
-import Chart, { KVs } from '../core.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import Logger from '@/logger.js';
+import Chart from '../core.js';
import { name, schema } from './entities/test-grouped.js';
+import type { KVs } from '../core.js';
/**
* For testing
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class TestGroupedChart extends Chart {
private total = {} as Record;
- constructor() {
- super(name, schema, true);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private logger: Logger,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
diff --git a/packages/backend/src/services/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts
similarity index 52%
rename from packages/backend/src/services/chart/charts/test-intersection.ts
rename to packages/backend/src/core/chart/charts/test-intersection.ts
index 88b5a715c..ff63e9976 100644
--- a/packages/backend/src/services/chart/charts/test-intersection.ts
+++ b/packages/backend/src/core/chart/charts/test-intersection.ts
@@ -1,13 +1,26 @@
-import Chart, { KVs } from '../core.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import Logger from '@/logger.js';
+import Chart from '../core.js';
import { name, schema } from './entities/test-intersection.js';
+import type { KVs } from '../core.js';
/**
* For testing
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class TestIntersectionChart extends Chart {
- constructor() {
- super(name, schema);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private logger: Logger,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts
new file mode 100644
index 000000000..3be4b0df2
--- /dev/null
+++ b/packages/backend/src/core/chart/charts/test-unique.ts
@@ -0,0 +1,39 @@
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import Logger from '@/logger.js';
+import Chart from '../core.js';
+import { name, schema } from './entities/test-unique.js';
+import type { KVs } from '../core.js';
+
+/**
+ * For testing
+ */
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class TestUniqueChart extends Chart {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private logger: Logger,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema);
+ }
+
+ protected async tickMajor(): Promise>> {
+ return {};
+ }
+
+ protected async tickMinor(): Promise>> {
+ return {};
+ }
+
+ public async uniqueIncrement(key: string): Promise {
+ await this.commit({
+ foo: [key],
+ });
+ }
+}
diff --git a/packages/backend/src/services/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts
similarity index 58%
rename from packages/backend/src/services/chart/charts/test.ts
rename to packages/backend/src/core/chart/charts/test.ts
index adb2b18c8..89f64c4c1 100644
--- a/packages/backend/src/services/chart/charts/test.ts
+++ b/packages/backend/src/core/chart/charts/test.ts
@@ -1,15 +1,28 @@
-import Chart, { KVs } from '../core.js';
+import { Injectable, Inject } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import Logger from '@/logger.js';
+import Chart from '../core.js';
import { name, schema } from './entities/test.js';
+import type { KVs } from '../core.js';
/**
* For testing
*/
// eslint-disable-next-line import/no-default-export
+@Injectable()
export default class TestChart extends Chart {
public total = 0; // publicにするのはテストのため
- constructor() {
- super(name, schema);
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ private appLockService: AppLockService,
+ private logger: Logger,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts
new file mode 100644
index 000000000..b3187997c
--- /dev/null
+++ b/packages/backend/src/core/chart/charts/users.ts
@@ -0,0 +1,58 @@
+import { Injectable, Inject } from '@nestjs/common';
+import { Not, IsNull, DataSource } from 'typeorm';
+import type { User } from '@/models/entities/User.js';
+import { AppLockService } from '@/core/AppLockService.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { UsersRepository } from '@/models/index.js';
+import Chart from '../core.js';
+import { ChartLoggerService } from '../ChartLoggerService.js';
+import { name, schema } from './entities/users.js';
+import type { KVs } from '../core.js';
+
+/**
+ * ユーザー数に関するチャート
+ */
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class UsersChart extends Chart {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ private appLockService: AppLockService,
+ private userEntityService: UserEntityService,
+ private chartLoggerService: ChartLoggerService,
+ ) {
+ super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
+ }
+
+ protected async tickMajor(): Promise>> {
+ const [localCount, remoteCount] = await Promise.all([
+ this.usersRepository.countBy({ host: IsNull() }),
+ this.usersRepository.countBy({ host: Not(IsNull()) }),
+ ]);
+
+ return {
+ 'local.total': localCount,
+ 'remote.total': remoteCount,
+ };
+ }
+
+ protected async tickMinor(): Promise>> {
+ return {};
+ }
+
+ public async update(user: { id: User['id'], host: User['host'] }, isAdditional: boolean): Promise {
+ const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
+
+ await this.commit({
+ [`${prefix}.total`]: isAdditional ? 1 : -1,
+ [`${prefix}.inc`]: isAdditional ? 1 : 0,
+ [`${prefix}.dec`]: isAdditional ? 0 : 1,
+ });
+ }
+}
diff --git a/packages/backend/src/services/chart/core.ts b/packages/backend/src/core/chart/core.ts
similarity index 92%
rename from packages/backend/src/services/chart/core.ts
rename to packages/backend/src/core/chart/core.ts
index 2960bac8f..cf5aa4888 100644
--- a/packages/backend/src/services/chart/core.ts
+++ b/packages/backend/src/core/chart/core.ts
@@ -5,13 +5,10 @@
*/
import * as nestedProperty from 'nested-property';
-import Logger from '../logger.js';
-import { EntitySchema, Repository, LessThan, Between } from 'typeorm';
-import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time.js';
-import { getChartInsertLock } from '@/misc/app-lock.js';
-import { db } from '@/db/postgre.js';
-
-const logger = new Logger('chart', 'white', process.env.NODE_ENV !== 'test');
+import { EntitySchema, LessThan, Between } from 'typeorm';
+import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc/prelude/time.js';
+import type Logger from '@/logger.js';
+import type { Repository, DataSource } from 'typeorm';
const columnPrefix = '___' as const;
const uniqueTempColumnPrefix = 'unique_temp___' as const;
@@ -112,6 +109,8 @@ export function getJsonSchema(schema: S): ToJsonSchema {
+ private logger: Logger;
+
public schema: T;
private name: string;
@@ -230,9 +229,20 @@ export default abstract class Chart {
};
}
- constructor(name: string, schema: T, grouped = false) {
+ private lock: (key: string) => Promise<() => void>;
+
+ constructor(
+ db: DataSource,
+ lock: (key: string) => Promise<() => void>,
+ logger: Logger,
+ name: string,
+ schema: T,
+ grouped = false,
+ ) {
this.name = name;
this.schema = schema;
+ this.lock = lock;
+ this.logger = logger;
const { hour, day } = Chart.schemaToEntity(name, schema, grouped);
this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour);
@@ -242,7 +252,7 @@ export default abstract class Chart {
private convertRawRecord(x: RawRecord): KVs {
const kvs = {} as Record;
for (const k of Object.keys(x).filter((k) => k.startsWith(columnPrefix)) as (keyof Columns)[]) {
- kvs[(k as string).substr(columnPrefix.length).split(columnDot).join('.')] = x[k];
+ kvs[(k as string).substr(columnPrefix.length).split(columnDot).join('.')] = x[k] as unknown as number;
}
return kvs as KVs;
}
@@ -323,13 +333,13 @@ export default abstract class Chart {
// 初期ログデータを作成
data = this.getNewLog(null);
- logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`);
+ this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`);
}
const date = Chart.dateToTimestamp(current);
const lockKey = group ? `${this.name}:${date}:${span}:${group}` : `${this.name}:${date}:${span}`;
- const unlock = await getChartInsertLock(lockKey);
+ const unlock = await this.lock(lockKey);
try {
// ロック内でもう1回チェックする
const currentLog = await repository.findOneBy({
@@ -353,7 +363,7 @@ export default abstract class Chart {
...columns,
}).then(x => repository.findOneByOrFail(x.identifiers[0])) as RawRecord;
- logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`);
+ this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`);
return log;
} finally {
@@ -372,7 +382,7 @@ export default abstract class Chart {
public async save(): Promise {
if (this.buffer.length === 0) {
- logger.info(`${this.name}: Write skipped`);
+ this.logger.info(`${this.name}: Write skipped`);
return;
}
@@ -403,16 +413,16 @@ export default abstract class Chart {
const queryForDay: Record, number | (() => string)> = {} as any;
for (const [k, v] of Object.entries(finalDiffs)) {
if (typeof v === 'number') {
- const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns;
+ const name = columnPrefix + k.replaceAll('.', columnDot) as string & keyof Columns;
if (v > 0) queryForHour[name] = () => `"${name}" + ${v}`;
if (v < 0) queryForHour[name] = () => `"${name}" - ${Math.abs(v)}`;
if (v > 0) queryForDay[name] = () => `"${name}" + ${v}`;
if (v < 0) queryForDay[name] = () => `"${name}" - ${Math.abs(v)}`;
} else if (Array.isArray(v) && v.length > 0) { // ユニークインクリメント
- const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique;
+ const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as string & keyof TempColumnsForUnique;
// TODO: item をSQLエスケープ
- const itemsForHour = v.filter(item => !logHour[tempColumnName].includes(item)).map(item => `"${item}"`);
- const itemsForDay = v.filter(item => !logDay[tempColumnName].includes(item)).map(item => `"${item}"`);
+ const itemsForHour = v.filter(item => !(logHour[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`);
+ const itemsForDay = v.filter(item => !(logDay[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`);
if (itemsForHour.length > 0) queryForHour[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForHour.join(',')}}'::varchar[])`;
if (itemsForDay.length > 0) queryForDay[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForDay.join(',')}}'::varchar[])`;
}
@@ -423,8 +433,8 @@ export default abstract class Chart