[${(new Date()).toString()}] ${text.replace(/\n/g,'
')}
)
- case 'pre': {
- if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+ case 'PRE': {
+ if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.HTMLElement) && node.childNodes[0].tagName === 'CODE') {
text += '\n```\n';
text += getText(node.childNodes[0]);
text += '\n```\n';
+ } else if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.TextNode) && node.childNodes[0].textContent.startsWith('') && node.childNodes[0].textContent.endsWith('')) {
+ text += '\n```\n';
+ text += node.childNodes[0].textContent.slice(6, -7);
+ text += '\n```\n';
} else {
- appendChildren(node.childNodes);
+ analyzeChildren(node.childNodes);
}
break;
}
// inline code ()
- case 'code': {
+ case 'CODE': {
text += '`';
- appendChildren(node.childNodes);
+ analyzeChildren(node.childNodes);
text += '`';
break;
}
- case 'blockquote': {
+ case 'BLOCKQUOTE': {
const t = getText(node);
if (t) {
text += '\n> ';
@@ -235,33 +236,33 @@ export class MfmService {
break;
}
- case 'p':
- case 'h2':
- case 'h3':
- case 'h4':
- case 'h5':
- case 'h6': {
+ case 'P':
+ case 'H2':
+ case 'H3':
+ case 'H4':
+ case 'H5':
+ case 'H6': {
text += '\n\n';
- appendChildren(node.childNodes);
+ analyzeChildren(node.childNodes);
break;
}
// other block elements
- case 'div':
- case 'header':
- case 'footer':
- case 'article':
- case 'li':
- case 'dt':
- case 'dd': {
+ case 'DIV':
+ case 'HEADER':
+ case 'FOOTER':
+ case 'ARTICLE':
+ case 'LI':
+ case 'DT':
+ case 'DD': {
text += '\n';
- appendChildren(node.childNodes);
+ analyzeChildren(node.childNodes);
break;
}
default: // includes inline elements
{
- appendChildren(node.childNodes);
+ analyzeChildren(node.childNodes);
break;
}
}
@@ -269,52 +270,35 @@ export class MfmService {
}
@bindThis
- public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
+ public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], extraHtml: string | null = null) {
if (nodes == null) {
return null;
}
- const { happyDOM, window } = new Window();
-
- const doc = window.document;
-
- const body = doc.createElement('p');
-
- 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);
- }
+ function toHtml(children?: mfm.MfmNode[]): string {
+ if (children == null) return '';
+ return children.map(x => handlers[x.type](x)).join('');
}
function fnDefault(node: mfm.MfmFn) {
- const el = doc.createElement('i');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}`;
}
- const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+ const handlers = {
bold: (node) => {
- const el = doc.createElement('b');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}`;
},
small: (node) => {
- const el = doc.createElement('small');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}`;
},
strike: (node) => {
- const el = doc.createElement('del');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}`;
},
italic: (node) => {
- const el = doc.createElement('i');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}`;
},
fn: (node) => {
@@ -323,10 +307,7 @@ export class MfmService {
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
try {
const date = new Date(parseInt(text, 10) * 1000);
- const el = doc.createElement('time');
- el.setAttribute('datetime', date.toISOString());
- el.textContent = date.toISOString();
- return el;
+ return ``;
} catch (err) {
return fnDefault(node);
}
@@ -336,21 +317,9 @@ export class MfmService {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
- const rubyEl = doc.createElement('ruby');
- const rtEl = doc.createElement('rt');
- // ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
- const rpStartEl = doc.createElement('rp');
- rpStartEl.appendChild(doc.createTextNode('('));
- const rpEndEl = doc.createElement('rp');
- rpEndEl.appendChild(doc.createTextNode(')'));
-
- rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
- rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
- rubyEl.appendChild(rpStartEl);
- rubyEl.appendChild(rtEl);
- rubyEl.appendChild(rpEndEl);
- return rubyEl;
+ // ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
+ return `${escapeHtml(text.split(' ')[0])}`;
} else {
const rt = node.children.at(-1);
@@ -359,21 +328,9 @@ export class MfmService {
}
const text = rt.type === 'text' ? rt.props.text : '';
- const rubyEl = doc.createElement('ruby');
- const rtEl = doc.createElement('rt');
- // ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
- const rpStartEl = doc.createElement('rp');
- rpStartEl.appendChild(doc.createTextNode('('));
- const rpEndEl = doc.createElement('rp');
- rpEndEl.appendChild(doc.createTextNode(')'));
-
- appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
- rtEl.appendChild(doc.createTextNode(text.trim()));
- rubyEl.appendChild(rpStartEl);
- rubyEl.appendChild(rtEl);
- rubyEl.appendChild(rpEndEl);
- return rubyEl;
+ // ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
+ return `${toHtml(node.children.slice(0, node.children.length - 1))}`;
}
}
@@ -384,125 +341,98 @@ export class MfmService {
},
blockCode: (node) => {
- const pre = doc.createElement('pre');
- const inner = doc.createElement('code');
- inner.textContent = node.props.code;
- pre.appendChild(inner);
- return pre;
+ return `${escapeHtml(node.props.code)}
`;
},
center: (node) => {
- const el = doc.createElement('div');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}`;
},
emojiCode: (node) => {
- return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+ return `\u200B:${escapeHtml(node.props.name)}:\u200B`;
},
unicodeEmoji: (node) => {
- return doc.createTextNode(node.props.emoji);
+ return node.props.emoji;
},
hashtag: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
- a.textContent = `#${node.props.hashtag}`;
- a.setAttribute('rel', 'tag');
- return a;
+ return `#${escapeHtml(node.props.hashtag)}`;
},
inlineCode: (node) => {
- const el = doc.createElement('code');
- el.textContent = node.props.code;
- return el;
+ return `${escapeHtml(node.props.code)}`;
},
mathInline: (node) => {
- const el = doc.createElement('code');
- el.textContent = node.props.formula;
- return el;
+ return `${escapeHtml(node.props.formula)}`;
},
mathBlock: (node) => {
- const el = doc.createElement('code');
- el.textContent = node.props.formula;
- return el;
+ return `${escapeHtml(node.props.formula)}
`;
},
link: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', node.props.url);
- appendChildren(node.children, a);
- return a;
+ try {
+ const url = new URL(node.props.url);
+ return `${toHtml(node.children)}`;
+ } catch (err) {
+ return `[${toHtml(node.children)}](${escapeHtml(node.props.url)})`;
+ }
},
mention: (node) => {
- const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
- a.setAttribute('href', remoteUserInfo
+ const href = remoteUserInfo
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
- : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
- a.className = 'u-url mention';
- a.textContent = acct;
- return a;
+ : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`;
+ try {
+ const url = new URL(href);
+ return `${escapeHtml(acct)}`;
+ } catch (err) {
+ return escapeHtml(acct);
+ }
},
quote: (node) => {
- const el = doc.createElement('blockquote');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}
`;
},
text: (node) => {
if (!node.props.text.match(/[\r\n]/)) {
- return doc.createTextNode(node.props.text);
+ return escapeHtml(node.props.text);
}
- const el = doc.createElement('span');
- const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
+ let html = '';
- for (const x of intersperse('br', nodes)) {
- el.appendChild(x === 'br' ? doc.createElement('br') : x);
+ const lines = node.props.text.split(/\r\n|\r|\n/).map(x => escapeHtml(x));
+
+ for (const x of intersperse('br', lines)) {
+ html += x === 'br' ? '
' : x;
}
- return el;
+ return html;
},
url: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', node.props.url);
- a.textContent = node.props.url;
- return a;
+ try {
+ const url = new URL(node.props.url);
+ return `${escapeHtml(node.props.url)}`;
+ } catch (err) {
+ return escapeHtml(node.props.url);
+ }
},
search: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
- a.textContent = node.props.content;
- return a;
+ return `${escapeHtml(node.props.content)}`;
},
plain: (node) => {
- const el = doc.createElement('span');
- appendChildren(node.children, el);
- return el;
+ return `${toHtml(node.children)}`;
},
- };
+ } satisfies { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => string } as { [K in mfm.MfmNode['type']]: (node: mfm.MfmNode) => string };
- appendChildren(nodes, body);
-
- for (const additionalAppender of additionalAppenders) {
- additionalAppender(doc, body);
- }
-
- // Remove the unnecessary namespace
- const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*/, '
');
-
- happyDOM.close().catch(err => {});
-
- return serialized;
+ return `${toHtml(nodes)}${extraHtml ?? ''}`;
}
}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 1eefcfa054..748f2cbad9 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
-import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@@ -56,6 +56,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js';
+import { isQuote, isRenote } from '@/misc/is-renote.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@@ -192,6 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
@@ -221,6 +228,167 @@ export class NoteCreateService implements OnApplicationShutdown {
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
}
+ @bindThis
+ public async fetchAndCreate(user: {
+ id: MiUser['id'];
+ username: MiUser['username'];
+ host: MiUser['host'];
+ isBot: MiUser['isBot'];
+ isCat: MiUser['isCat'];
+ }, data: {
+ createdAt: Date;
+ replyId: MiNote['id'] | null;
+ renoteId: MiNote['id'] | null;
+ fileIds: MiDriveFile['id'][];
+ text: string | null;
+ cw: string | null;
+ visibility: string;
+ visibleUserIds: MiUser['id'][];
+ channelId: MiChannel['id'] | null;
+ localOnly: boolean;
+ reactionAcceptance: MiNote['reactionAcceptance'];
+ poll: IPoll | null;
+ apMentions?: MinimumUser[] | null;
+ apHashtags?: string[] | null;
+ apEmojis?: string[] | null;
+ }): Promise {
+ const visibleUsers = data.visibleUserIds.length > 0 ? await this.usersRepository.findBy({
+ id: In(data.visibleUserIds),
+ }) : [];
+
+ let files: MiDriveFile[] = [];
+ if (data.fileIds.length > 0) {
+ files = await this.driveFilesRepository.createQueryBuilder('file')
+ .where('file.userId = :userId AND file.id IN (:...fileIds)', {
+ userId: user.id,
+ fileIds: data.fileIds,
+ })
+ .orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
+ .setParameters({ fileIds: data.fileIds })
+ .getMany();
+
+ if (files.length !== data.fileIds.length) {
+ throw new IdentifiableError('801c046c-5bf5-4234-ad2b-e78fc20a2ac7', 'No such file');
+ }
+ }
+
+ let renote: MiNote | null = null;
+ if (data.renoteId != null) {
+ // Fetch renote to note
+ renote = await this.notesRepository.findOne({
+ where: { id: data.renoteId },
+ relations: ['user', 'renote', 'reply'],
+ });
+
+ if (renote == null) {
+ throw new IdentifiableError('53983c56-e163-45a6-942f-4ddc485d4290', 'No such renote target');
+ } else if (isRenote(renote) && !isQuote(renote)) {
+ throw new IdentifiableError('bde24c37-121f-4e7d-980d-cec52f599f02', 'Cannot renote pure renote');
+ }
+
+ // Check blocking
+ if (renote.userId !== user.id) {
+ const blockExist = await this.blockingsRepository.exists({
+ where: {
+ blockerId: renote.userId,
+ blockeeId: user.id,
+ },
+ });
+ if (blockExist) {
+ throw new IdentifiableError('2b4fe776-4414-4a2d-ae39-f3418b8fd4d3', 'You have been blocked by the user');
+ }
+ }
+
+ if (renote.visibility === 'followers' && renote.userId !== user.id) {
+ // 他人のfollowers noteはreject
+ throw new IdentifiableError('90b9d6f0-893a-4fef-b0f1-e9a33989f71a', 'Renote target visibility');
+ } else if (renote.visibility === 'specified') {
+ // specified / direct noteはreject
+ throw new IdentifiableError('48d7a997-da5c-4716-b3c3-92db3f37bf7d', 'Renote target visibility');
+ }
+
+ if (renote.channelId && renote.channelId !== data.channelId) {
+ // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
+ // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
+ const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
+ if (renoteChannel == null) {
+ // リノートしたいノートが書き込まれているチャンネルが無い
+ throw new IdentifiableError('b060f9a6-8909-4080-9e0b-94d9fa6f6a77', 'No such channel');
+ } else if (!renoteChannel.allowRenoteToExternal) {
+ // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
+ throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external');
+ }
+ }
+ }
+
+ let reply: MiNote | null = null;
+ if (data.replyId != null) {
+ // Fetch reply
+ reply = await this.notesRepository.findOne({
+ where: { id: data.replyId },
+ relations: ['user'],
+ });
+
+ if (reply == null) {
+ throw new IdentifiableError('60142edb-1519-408e-926d-4f108d27bee0', 'No such reply target');
+ } else if (isRenote(reply) && !isQuote(reply)) {
+ throw new IdentifiableError('f089e4e2-c0e7-4f60-8a23-e5a6bf786b36', 'Cannot reply to pure renote');
+ } else if (!await this.noteEntityService.isVisibleForMe(reply, user.id)) {
+ throw new IdentifiableError('11cd37b3-a411-4f77-8633-c580ce6a8dce', 'No such reply target');
+ } else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
+ throw new IdentifiableError('ced780a1-2012-4caf-bc7e-a95a291294cb', 'Cannot reply to specified note with different visibility');
+ }
+
+ // Check blocking
+ if (reply.userId !== user.id) {
+ const blockExist = await this.blockingsRepository.exists({
+ where: {
+ blockerId: reply.userId,
+ blockeeId: user.id,
+ },
+ });
+ if (blockExist) {
+ throw new IdentifiableError('b0df6025-f2e8-44b4-a26a-17ad99104612', 'You have been blocked by the user');
+ }
+ }
+ }
+
+ if (data.poll) {
+ if (data.poll.expiresAt != null) {
+ if (data.poll.expiresAt.getTime() < Date.now()) {
+ throw new IdentifiableError('0c11c11e-0c8d-48e7-822c-76ccef660068', 'Poll expiration must be future time');
+ }
+ }
+ }
+
+ let channel: MiChannel | null = null;
+ if (data.channelId != null) {
+ channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false });
+
+ if (channel == null) {
+ throw new IdentifiableError('bfa3905b-25f5-4894-b430-da331a490e4b', 'No such channel');
+ }
+ }
+
+ return this.create(user, {
+ createdAt: data.createdAt,
+ files: files,
+ poll: data.poll,
+ text: data.text,
+ reply,
+ renote,
+ cw: data.cw,
+ localOnly: data.localOnly,
+ reactionAcceptance: data.reactionAcceptance,
+ visibility: data.visibility,
+ visibleUsers,
+ channel,
+ apMentions: data.apMentions,
+ apHashtags: data.apHashtags,
+ apEmojis: data.apEmojis,
+ });
+ }
+
@bindThis
public async create(user: {
id: MiUser['id'];
@@ -436,6 +604,7 @@ export class NoteCreateService implements OnApplicationShutdown {
replyUserHost: data.reply ? data.reply.userHost : null,
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
+ renoteChannelId: data.renote ? data.renote.channelId : null,
userHost: user.host,
});
diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts
index c43be96efa..a346ff7618 100644
--- a/packages/backend/src/core/NoteDraftService.ts
+++ b/packages/backend/src/core/NoteDraftService.ts
@@ -5,32 +5,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
-import type { noteVisibilities, noteReactionAcceptances } from '@/types.js';
import { DI } from '@/di-symbols.js';
import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
-import { IPoll } from '@/models/Poll.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRenote, isQuote } from '@/misc/is-renote.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { QueueService } from '@/core/QueueService.js';
-export type NoteDraftOptions = {
- replyId?: MiNote['id'] | null;
- renoteId?: MiNote['id'] | null;
- text?: string | null;
- cw?: string | null;
- localOnly?: boolean | null;
- reactionAcceptance?: typeof noteReactionAcceptances[number];
- visibility?: typeof noteVisibilities[number];
- fileIds?: MiDriveFile['id'][];
- visibleUserIds?: MiUser['id'][];
- hashtag?: string;
- channelId?: MiChannel['id'] | null;
- poll?: (IPoll & { expiredAfter?: number | null }) | null;
-};
+export type NoteDraftOptions = Omit;
@Injectable()
export class NoteDraftService {
@@ -56,6 +42,7 @@ export class NoteDraftService {
private roleService: RoleService,
private idService: IdService,
private noteEntityService: NoteEntityService,
+ private queueService: QueueService,
) {
}
@@ -72,36 +59,43 @@ export class NoteDraftService {
@bindThis
public async create(me: MiLocalUser, data: NoteDraftOptions): Promise {
//#region check draft limit
+ const policies = await this.roleService.getUserPolicies(me.id);
const currentCount = await this.noteDraftsRepository.countBy({
userId: me.id,
});
- if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) {
+ if (currentCount >= policies.noteDraftLimit) {
throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts');
}
+
+ if (data.isActuallyScheduled) {
+ const currentScheduledCount = await this.noteDraftsRepository.countBy({
+ userId: me.id,
+ isActuallyScheduled: true,
+ });
+ if (currentScheduledCount >= policies.scheduledNoteLimit) {
+ throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes');
+ }
+ }
//#endregion
- if (data.poll) {
- if (typeof data.poll.expiresAt === 'number') {
- if (data.poll.expiresAt < Date.now()) {
- throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
- }
- } else if (typeof data.poll.expiredAfter === 'number') {
- data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
- }
+ await this.validate(me, data);
+
+ const draft = await this.noteDraftsRepository.insertOne({
+ ...data,
+ id: this.idService.gen(),
+ userId: me.id,
+ });
+
+ if (draft.scheduledAt && draft.isActuallyScheduled) {
+ this.schedule(draft);
}
- const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data);
-
- appliedDraft.id = this.idService.gen();
- appliedDraft.userId = me.id;
- const draft = this.noteDraftsRepository.save(appliedDraft);
-
return draft;
}
@bindThis
- public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise {
+ public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial): Promise {
const draft = await this.noteDraftsRepository.findOneBy({
id: draftId,
userId: me.id,
@@ -111,19 +105,36 @@ export class NoteDraftService {
throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
}
- if (data.poll) {
- if (typeof data.poll.expiresAt === 'number') {
- if (data.poll.expiresAt < Date.now()) {
- throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
- }
- } else if (typeof data.poll.expiredAfter === 'number') {
- data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
+ //#region check draft limit
+ const policies = await this.roleService.getUserPolicies(me.id);
+
+ if (!draft.isActuallyScheduled && data.isActuallyScheduled) {
+ const currentScheduledCount = await this.noteDraftsRepository.countBy({
+ userId: me.id,
+ isActuallyScheduled: true,
+ });
+ if (currentScheduledCount >= policies.scheduledNoteLimit) {
+ throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes');
}
}
+ //#endregion
- const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data);
+ await this.validate(me, data);
- return await this.noteDraftsRepository.save(appliedDraft);
+ const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update()
+ .set(data)
+ .where('id = :id', { id: draftId })
+ .returning('*')
+ .execute()
+ .then((response) => response.raw[0]);
+
+ this.clearSchedule(draftId).then(() => {
+ if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) {
+ this.schedule(updatedDraft);
+ }
+ });
+
+ return updatedDraft;
}
@bindThis
@@ -138,6 +149,8 @@ export class NoteDraftService {
}
await this.noteDraftsRepository.delete(draft.id);
+
+ this.clearSchedule(draftId);
}
@bindThis
@@ -154,27 +167,28 @@ export class NoteDraftService {
return draft;
}
- // 関連エンティティを取得し紐づける部分を共通化する
@bindThis
- public async checkAndSetDraftNoteOptions(
+ public async validate(
me: MiLocalUser,
- draft: MiNoteDraft,
- data: NoteDraftOptions,
- ): Promise {
- data.visibility ??= 'public';
- data.localOnly ??= false;
- if (data.reactionAcceptance === undefined) data.reactionAcceptance = null;
- if (data.channelId != null) {
- data.visibility = 'public';
- data.visibleUserIds = [];
- data.localOnly = true;
+ data: Partial,
+ ): Promise {
+ if (data.isActuallyScheduled) {
+ if (data.scheduledAt == null) {
+ throw new IdentifiableError('94a89a43-3591-400a-9c17-dd166e71fdfa', 'scheduledAt is required when isActuallyScheduled is true');
+ } else if (data.scheduledAt.getTime() < Date.now()) {
+ throw new IdentifiableError('b34d0c1b-996f-4e34-a428-c636d98df457', 'scheduledAt must be in the future');
+ }
}
- let appliedDraft = draft;
+ if (data.pollExpiresAt != null) {
+ if (data.pollExpiresAt.getTime() < Date.now()) {
+ throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
+ }
+ }
//#region visibleUsers
let visibleUsers: MiUser[] = [];
- if (data.visibleUserIds != null) {
+ if (data.visibleUserIds != null && data.visibleUserIds.length > 0) {
visibleUsers = await this.usersRepository.findBy({
id: In(data.visibleUserIds),
});
@@ -184,7 +198,7 @@ export class NoteDraftService {
//#region files
let files: MiDriveFile[] = [];
const fileIds = data.fileIds ?? null;
- if (fileIds != null) {
+ if (fileIds != null && fileIds.length > 0) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
@@ -288,27 +302,38 @@ export class NoteDraftService {
}
}
//#endregion
+ }
- appliedDraft = {
- ...appliedDraft,
- visibility: data.visibility,
- cw: data.cw ?? null,
- fileIds: fileIds ?? [],
- replyId: data.replyId ?? null,
- renoteId: data.renoteId ?? null,
- channelId: data.channelId ?? null,
- text: data.text ?? null,
- hashtag: data.hashtag ?? null,
- hasPoll: data.poll != null,
- pollChoices: data.poll ? data.poll.choices : [],
- pollMultiple: data.poll ? data.poll.multiple : false,
- pollExpiresAt: data.poll ? data.poll.expiresAt : null,
- pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null,
- visibleUserIds: data.visibleUserIds ?? [],
- localOnly: data.localOnly,
- reactionAcceptance: data.reactionAcceptance,
- } satisfies MiNoteDraft;
+ @bindThis
+ public async schedule(draft: MiNoteDraft): Promise {
+ if (!draft.isActuallyScheduled) return;
+ if (draft.scheduledAt == null) return;
+ if (draft.scheduledAt.getTime() <= Date.now()) return;
- return appliedDraft;
+ const delay = draft.scheduledAt.getTime() - Date.now();
+ this.queueService.postScheduledNoteQueue.add(draft.id, {
+ noteDraftId: draft.id,
+ }, {
+ delay,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
+ });
+ }
+
+ @bindThis
+ public async clearSchedule(draftId: MiNoteDraft['id']): Promise {
+ // TODO: 線形探索なのをどうにかする
+ const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']);
+ for (const job of jobs) {
+ if (job.data.noteDraftId === draftId) {
+ await job.remove();
+ }
+ }
}
}
diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts
index eeade4569b..310ffec7ce 100644
--- a/packages/backend/src/core/NotificationService.ts
+++ b/packages/backend/src/core/NotificationService.ts
@@ -202,7 +202,7 @@ export class NotificationService implements OnApplicationShutdown {
}
// TODO
- //const locales = await import('../../../../locales/index.js');
+ //const locales = await import('i18n');
// TODO: locale ファイルをクライアント用とサーバー用で分けたい
@@ -271,7 +271,7 @@ export class NotificationService implements OnApplicationShutdown {
let untilTime = untilId ? this.toXListId(untilId) : null;
let notifications: MiNotification[];
- for (;;) {
+ for (; ;) {
let notificationsRes: [id: string, fields: string[]][];
// sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照
diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts
new file mode 100644
index 0000000000..4abeb30fce
--- /dev/null
+++ b/packages/backend/src/core/PageService.ts
@@ -0,0 +1,223 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DataSource, In, Not } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import {
+ type NotesRepository,
+ MiPage,
+ type PagesRepository,
+ MiDriveFile,
+ type UsersRepository,
+ MiNote,
+} from '@/models/_.js';
+import { bindThis } from '@/decorators.js';
+import { RoleService } from '@/core/RoleService.js';
+import { IdService } from '@/core/IdService.js';
+import type { MiUser } from '@/models/User.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+
+export interface PageBody {
+ title: string;
+ name: string;
+ summary: string | null;
+ content: Array>;
+ variables: Array>;
+ script: string;
+ eyeCatchingImage?: MiDriveFile | null;
+ font: 'serif' | 'sans-serif';
+ alignCenter: boolean;
+ hideTitleWhenPinned: boolean;
+}
+
+@Injectable()
+export class PageService {
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.pagesRepository)
+ private pagesRepository: PagesRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ private roleService: RoleService,
+ private moderationLogService: ModerationLogService,
+ private idService: IdService,
+ ) {
+ }
+
+ @bindThis
+ public async create(
+ me: MiUser,
+ body: PageBody,
+ ): Promise {
+ await this.pagesRepository.findBy({
+ userId: me.id,
+ name: body.name,
+ }).then(result => {
+ if (result.length > 0) {
+ throw new IdentifiableError('1a79e38e-3d83-4423-845b-a9d83ff93b61');
+ }
+ });
+
+ const page = await this.pagesRepository.insertOne(new MiPage({
+ id: this.idService.gen(),
+ updatedAt: new Date(),
+ title: body.title,
+ name: body.name,
+ summary: body.summary,
+ content: body.content,
+ variables: body.variables,
+ script: body.script,
+ eyeCatchingImageId: body.eyeCatchingImage ? body.eyeCatchingImage.id : null,
+ userId: me.id,
+ visibility: 'public',
+ alignCenter: body.alignCenter,
+ hideTitleWhenPinned: body.hideTitleWhenPinned,
+ font: body.font,
+ }));
+
+ const referencedNotes = this.collectReferencedNotes(page.content);
+ if (referencedNotes.length > 0) {
+ await this.notesRepository.increment({ id: In(referencedNotes) }, 'pageCount', 1);
+ }
+
+ return page;
+ }
+
+ @bindThis
+ public async update(
+ me: MiUser,
+ pageId: MiPage['id'],
+ body: Partial,
+ ): Promise {
+ await this.db.transaction(async (transaction) => {
+ const page = await transaction.findOne(MiPage, {
+ where: {
+ id: pageId,
+ },
+ lock: { mode: 'for_no_key_update' },
+ });
+
+ if (page == null) {
+ throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
+ }
+ if (page.userId !== me.id) {
+ throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
+ }
+
+ if (body.name != null) {
+ await transaction.findBy(MiPage, {
+ id: Not(pageId),
+ userId: me.id,
+ name: body.name,
+ }).then(result => {
+ if (result.length > 0) {
+ throw new IdentifiableError('d05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4');
+ }
+ });
+ }
+
+ await transaction.update(MiPage, page.id, {
+ updatedAt: new Date(),
+ title: body.title,
+ name: body.name,
+ summary: body.summary === undefined ? page.summary : body.summary,
+ content: body.content,
+ variables: body.variables,
+ script: body.script,
+ alignCenter: body.alignCenter,
+ hideTitleWhenPinned: body.hideTitleWhenPinned,
+ font: body.font,
+ eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
+ });
+
+ console.log('page.content', page.content);
+
+ if (body.content != null) {
+ const beforeReferencedNotes = this.collectReferencedNotes(page.content);
+ const afterReferencedNotes = this.collectReferencedNotes(body.content);
+
+ const removedNotes = beforeReferencedNotes.filter(noteId => !afterReferencedNotes.includes(noteId));
+ const addedNotes = afterReferencedNotes.filter(noteId => !beforeReferencedNotes.includes(noteId));
+
+ if (removedNotes.length > 0) {
+ await transaction.decrement(MiNote, { id: In(removedNotes) }, 'pageCount', 1);
+ }
+ if (addedNotes.length > 0) {
+ await transaction.increment(MiNote, { id: In(addedNotes) }, 'pageCount', 1);
+ }
+ }
+ });
+ }
+
+ @bindThis
+ public async delete(me: MiUser, pageId: MiPage['id']): Promise {
+ await this.db.transaction(async (transaction) => {
+ const page = await transaction.findOne(MiPage, {
+ where: {
+ id: pageId,
+ },
+ lock: { mode: 'pessimistic_write' }, // same lock level as DELETE
+ });
+
+ if (page == null) {
+ throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
+ }
+
+ if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
+ throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
+ }
+
+ await transaction.delete(MiPage, page.id);
+
+ if (page.userId !== me.id) {
+ const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
+ this.moderationLogService.log(me, 'deletePage', {
+ pageId: page.id,
+ pageUserId: page.userId,
+ pageUserUsername: user.username,
+ page,
+ });
+ }
+
+ const referencedNotes = this.collectReferencedNotes(page.content);
+ if (referencedNotes.length > 0) {
+ await transaction.decrement(MiNote, { id: In(referencedNotes) }, 'pageCount', 1);
+ }
+ });
+ }
+
+ collectReferencedNotes(content: MiPage['content']): string[] {
+ const referencingNotes = new Set();
+ const recursiveCollect = (content: unknown[]) => {
+ for (const contentElement of content) {
+ if (typeof contentElement === 'object'
+ && contentElement !== null
+ && 'type' in contentElement) {
+ if (contentElement.type === 'note'
+ && 'note' in contentElement
+ && typeof contentElement.note === 'string') {
+ referencingNotes.add(contentElement.note);
+ }
+ if (contentElement.type === 'section'
+ && 'children' in contentElement
+ && Array.isArray(contentElement.children)) {
+ recursiveCollect(contentElement.children);
+ }
+ }
+ }
+ };
+ recursiveCollect(content);
+ return [...referencingNotes];
+ }
+}
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index b10b8e5899..ecd96261e0 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -16,11 +16,13 @@ import {
RelationshipJobData,
UserWebhookDeliverJobData,
SystemWebhookDeliverJobData,
+ PostScheduledNoteJobData,
} from '../queue/types.js';
import type { Provider } from '@nestjs/common';
export type SystemQueue = Bull.Queue>;
export type EndedPollNotificationQueue = Bull.Queue;
+export type PostScheduledNoteQueue = Bull.Queue;
export type DeliverQueue = Bull.Queue;
export type InboxQueue = Bull.Queue;
export type DbQueue = Bull.Queue;
@@ -41,6 +43,12 @@ const $endedPollNotification: Provider = {
inject: [DI.config],
};
+const $postScheduledNote: Provider = {
+ provide: 'queue:postScheduledNote',
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
+ inject: [DI.config],
+};
+
const $deliver: Provider = {
provide: 'queue:deliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
@@ -89,6 +97,7 @@ const $systemWebhookDeliver: Provider = {
providers: [
$system,
$endedPollNotification,
+ $postScheduledNote,
$deliver,
$inbox,
$db,
@@ -100,6 +109,7 @@ const $systemWebhookDeliver: Provider = {
exports: [
$system,
$endedPollNotification,
+ $postScheduledNote,
$deliver,
$inbox,
$db,
@@ -113,6 +123,7 @@ export class QueueModule implements OnApplicationShutdown {
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+ @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@@ -129,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown {
await Promise.all([
this.systemQueue.close(),
this.endedPollNotificationQueue.close(),
+ this.postScheduledNoteQueue.close(),
this.deliverQueue.close(),
this.inboxQueue.close(),
this.dbQueue.close(),
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 0f225a8242..42782167bb 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -31,6 +31,7 @@ import type {
DbQueue,
DeliverQueue,
EndedPollNotificationQueue,
+ PostScheduledNoteQueue,
InboxQueue,
ObjectStorageQueue,
RelationshipQueue,
@@ -44,6 +45,7 @@ import type * as Bull from 'bullmq';
export const QUEUE_TYPES = [
'system',
'endedPollNotification',
+ 'postScheduledNote',
'deliver',
'inbox',
'db',
@@ -92,6 +94,7 @@ export class QueueService {
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+ @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@@ -717,6 +720,7 @@ export class QueueService {
switch (type) {
case 'system': return this.systemQueue;
case 'endedPollNotification': return this.endedPollNotificationQueue;
+ case 'postScheduledNote': return this.postScheduledNoteQueue;
case 'deliver': return this.deliverQueue;
case 'inbox': return this.inboxQueue;
case 'db': return this.dbQueue;
@@ -756,8 +760,8 @@ export class QueueService {
@bindThis
public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
const queue = this.getQueue(queueType);
- const job: Bull.Job | null = await queue.getJob(jobId);
- if (job) {
+ const job = await queue.getJob(jobId);
+ if (job != null) {
if (job.finishedOn != null) {
await job.retry();
} else {
@@ -769,8 +773,8 @@ export class QueueService {
@bindThis
public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
const queue = this.getQueue(queueType);
- const job: Bull.Job | null = await queue.getJob(jobId);
- if (job) {
+ const job = await queue.getJob(jobId);
+ if (job != null) {
await job.remove();
}
}
@@ -803,8 +807,8 @@ export class QueueService {
@bindThis
public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
const queue = this.getQueue(queueType);
- const job: Bull.Job | null = await queue.getJob(jobId);
- if (job) {
+ const job = await queue.getJob(jobId);
+ if (job != null) {
return this.packJobData(job);
} else {
throw new Error(`Job not found: ${jobId}`);
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 3df7ee69ee..f2f7480dfa 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -31,6 +31,7 @@ import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { NotificationService } from '@/core/NotificationService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
+// misskey-js の rolePolicies と同期すべし
export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
@@ -68,6 +69,7 @@ export type RolePolicies = {
chatAvailability: 'available' | 'readonly' | 'unavailable';
uploadableFileTypes: string[];
noteDraftLimit: number;
+ scheduledNoteLimit: number;
watermarkAvailable: boolean;
};
@@ -100,20 +102,21 @@ export const DEFAULT_POLICIES: RolePolicies = {
userEachUserListsLimit: 50,
rateLimitFactor: 1,
avatarDecorationLimit: 1,
- canImportAntennas: true,
- canImportBlocking: true,
- canImportFollowing: true,
- canImportMuting: true,
- canImportUserLists: true,
+ canImportAntennas: false,
+ canImportBlocking: false,
+ canImportFollowing: false,
+ canImportMuting: false,
+ canImportUserLists: false,
chatAvailability: 'available',
uploadableFileTypes: [
- 'text/plain',
+ 'text/*',
'application/json',
'image/*',
'video/*',
'audio/*',
],
noteDraftLimit: 10,
+ scheduledNoteLimit: 1,
watermarkAvailable: true,
};
@@ -438,6 +441,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return [...set];
}),
noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
+ scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)),
watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
};
}
diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts
index 67ec6cc7b0..21ea9b9983 100644
--- a/packages/backend/src/core/UtilityService.ts
+++ b/packages/backend/src/core/UtilityService.ts
@@ -133,6 +133,7 @@ export class UtilityService {
@bindThis
public isFederationAllowedHost(host: string): boolean {
+ if (this.isSelfHost(host)) return true;
if (this.meta.federation === 'none') return false;
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;
if (this.isBlockedHost(this.meta.blockedHosts, host)) return false;
diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts
index 372e1e2ab7..31c8d67c60 100644
--- a/packages/backend/src/core/WebAuthnService.ts
+++ b/packages/backend/src/core/WebAuthnService.ts
@@ -66,7 +66,6 @@ export class WebAuthnService {
userID: isoUint8Array.fromUTF8String(userId),
userName: userName,
userDisplayName: userDisplayName,
- attestationType: 'indirect',
excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{
id: key.id,
transports: key.transports ?? undefined,
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 9cf985b688..b112912b1b 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -85,6 +85,7 @@ function generateDummyNote(override?: Partial): MiNote {
renoteCount: 10,
repliesCount: 5,
clippedCount: 0,
+ pageCount: 0,
reactions: {},
visibility: 'public',
uri: null,
@@ -105,6 +106,7 @@ function generateDummyNote(override?: Partial): MiNote {
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
+ renoteChannelId: null,
...override,
};
}
@@ -243,7 +245,6 @@ export class WebhookTestService {
case 'reaction':
return;
default: {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveAssertion: never = params.type;
return;
}
@@ -326,7 +327,6 @@ export class WebhookTestService {
break;
}
default: {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveAssertion: never = params.type;
return;
}
@@ -411,7 +411,7 @@ export class WebhookTestService {
name: user.name,
username: user.username,
host: user.host,
- avatarUrl: user.avatarId == null ? null : user.avatarUrl,
+ avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? '',
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({
id: it.id,
diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts
index e88f60b806..81637580e3 100644
--- a/packages/backend/src/core/activitypub/ApInboxService.ts
+++ b/packages/backend/src/core/activitypub/ApInboxService.ts
@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@@ -14,8 +15,8 @@ import { NotePiningService } from '@/core/NotePiningService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
+import { acquireApObjectLock } from '@/misc/distributed-lock.js';
import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
-import { AppLockService } from '@/core/AppLockService.js';
import type Logger from '@/logger.js';
import { IdService } from '@/core/IdService.js';
import { StatusError } from '@/misc/status-error.js';
@@ -48,8 +49,8 @@ export class ApInboxService {
@Inject(DI.config)
private config: Config,
- @Inject(DI.meta)
- private meta: MiMeta,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -76,7 +77,6 @@ export class ApInboxService {
private userBlockingService: UserBlockingService,
private noteCreateService: NoteCreateService,
private noteDeleteService: NoteDeleteService,
- private appLockService: AppLockService,
private apResolverService: ApResolverService,
private apDbResolverService: ApDbResolverService,
private apLoggerService: ApLoggerService,
@@ -311,7 +311,7 @@ export class ApInboxService {
// アナウンス先が許可されているかチェック
if (!this.utilityService.isFederationAllowedUri(uri)) return;
- const unlock = await this.appLockService.getApLock(uri);
+ const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
// 既に同じURIを持つものが登録されていないかチェック
@@ -438,7 +438,7 @@ export class ApInboxService {
}
}
- const unlock = await this.appLockService.getApLock(uri);
+ const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
const exist = await this.apNoteService.fetchNote(note);
@@ -522,7 +522,7 @@ export class ApInboxService {
private async deleteNote(actor: MiRemoteUser, uri: string): Promise {
this.logger.info(`Deleting the Note: ${uri}`);
- const unlock = await this.appLockService.getApLock(uri);
+ const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
const note = await this.apDbResolverService.getNoteFromApId(uri);
diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts
index f4c07e472c..a928ed5ccf 100644
--- a/packages/backend/src/core/activitypub/ApMfmService.ts
+++ b/packages/backend/src/core/activitypub/ApMfmService.ts
@@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common';
import * as mfm from 'mfm-js';
-import { MfmService, Appender } from '@/core/MfmService.js';
+import { MfmService } from '@/core/MfmService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { extractApHashtagObjects } from './models/tag.js';
@@ -25,17 +25,17 @@ export class ApMfmService {
}
@bindThis
- public getNoteHtml(note: Pick, additionalAppender: Appender[] = []) {
+ public getNoteHtml(note: Pick, extraHtml: string | null = null) {
let noMisskeyContent = false;
const srcMfm = (note.text ?? '');
const parsed = mfm.parse(srcMfm);
- if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
+ if (extraHtml == null && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
noMisskeyContent = true;
}
- const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender);
+ const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), extraHtml);
return {
content,
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 55521d6e3a..4570977c5d 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
import type { MiPoll } from '@/models/Poll.js';
import type { MiPollVote } from '@/models/PollVote.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
-import { MfmService, type Appender } from '@/core/MfmService.js';
+import { MfmService } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js';
@@ -28,6 +28,7 @@ import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { escapeHtml } from '@/misc/escape-html.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@@ -384,7 +385,7 @@ export class ApRendererService {
inReplyTo = null;
}
- let quote;
+ let quote: string | undefined;
if (note.renoteId) {
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
@@ -430,29 +431,18 @@ export class ApRendererService {
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
}
- const apAppend: Appender[] = [];
+ let extraHtml: string | null = null;
- if (quote) {
+ if (quote != null) {
// Append quote link as `
RE: ...`
- // the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
+ // the class name `quote-inline` is used in non-misskey clients for styling quote notes.
// For compatibility, the span part should be kept as possible.
- apAppend.push((doc, body) => {
- body.appendChild(doc.createElement('br'));
- body.appendChild(doc.createElement('br'));
- const span = doc.createElement('span');
- span.className = 'quote-inline';
- span.appendChild(doc.createTextNode('RE: '));
- const link = doc.createElement('a');
- link.setAttribute('href', quote);
- link.textContent = quote;
- span.appendChild(link);
- body.appendChild(span);
- });
+ extraHtml = `
RE: ${escapeHtml(quote)}`;
}
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
- const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend);
+ const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, extraHtml);
const emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts
index 61d328ccac..49298a1d22 100644
--- a/packages/backend/src/core/activitypub/ApRequestService.ts
+++ b/packages/backend/src/core/activitypub/ApRequestService.ts
@@ -6,7 +6,7 @@
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
-import { Window } from 'happy-dom';
+import * as htmlParser from 'node-html-parser';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
@@ -215,29 +215,9 @@ export class ApRequestService {
_followAlternate === true
) {
const html = await res.text();
- const { window, happyDOM } = new Window({
- settings: {
- disableJavaScriptEvaluation: true,
- disableJavaScriptFileLoading: true,
- disableCSSFileLoading: true,
- disableComputedStyleRendering: true,
- handleDisabledFileLoadingAsSuccess: true,
- navigation: {
- disableMainFrameNavigation: true,
- disableChildFrameNavigation: true,
- disableChildPageNavigation: true,
- disableFallbackToSetURL: true,
- },
- timer: {
- maxTimeout: 0,
- maxIntervalTime: 0,
- maxIntervalIterations: 0,
- },
- },
- });
- const document = window.document;
+
try {
- document.documentElement.innerHTML = html;
+ const document = htmlParser.parse(html);
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
if (alternate) {
@@ -248,8 +228,6 @@ export class ApRequestService {
}
} catch (e) {
// something went wrong parsing the HTML, ignore the whole thing
- } finally {
- happyDOM.close().catch(err => {});
}
}
//#endregion
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 8abacd293f..214d32f67f 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -5,14 +5,15 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { MiRemoteUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
+import { acquireApObjectLock } from '@/misc/distributed-lock.js';
import { toArray, toSingle, unique } from '@/misc/prelude/array.js';
import type { MiEmoji } from '@/models/Emoji.js';
-import { AppLockService } from '@/core/AppLockService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import type Logger from '@/logger.js';
@@ -48,6 +49,9 @@ export class ApNoteService {
@Inject(DI.meta)
private meta: MiMeta,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@@ -67,7 +71,6 @@ export class ApNoteService {
private apMentionService: ApMentionService,
private apImageService: ApImageService,
private apQuestionService: ApQuestionService,
- private appLockService: AppLockService,
private pollService: PollService,
private noteCreateService: NoteCreateService,
private apDbResolverService: ApDbResolverService,
@@ -354,7 +357,7 @@ export class ApNoteService {
throw new StatusError('blocked host', 451);
}
- const unlock = await this.appLockService.getApLock(uri);
+ const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
//#region このサーバーに既に登録されていたらそれを返す
diff --git a/packages/backend/src/core/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts
index 05905f3782..7b9840af87 100644
--- a/packages/backend/src/core/chart/charts/active-users.ts
+++ b/packages/backend/src/core/chart/charts/active-users.ts
@@ -5,11 +5,12 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import { AppLockService } from '@/core/AppLockService.js';
+import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/active-users.js';
@@ -28,11 +29,13 @@ export default class ActiveUsersChart extends Chart { // eslint-d
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
private chartLoggerService: ChartLoggerService,
private idService: IdService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts
index 04e771a95b..ed790de7b5 100644
--- a/packages/backend/src/core/chart/charts/ap-request.ts
+++ b/packages/backend/src/core/chart/charts/ap-request.ts
@@ -5,9 +5,10 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import { AppLockService } from '@/core/AppLockService.js';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/ap-request.js';
@@ -22,10 +23,12 @@ export default class ApRequestChart extends Chart { // eslint-dis
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts
index 613e074a9f..782873809a 100644
--- a/packages/backend/src/core/chart/charts/drive.ts
+++ b/packages/backend/src/core/chart/charts/drive.ts
@@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { MiDriveFile } from '@/models/DriveFile.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/drive.js';
@@ -23,10 +24,12 @@ export default class DriveChart extends Chart { // eslint-disable
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts
index c9b43cc66d..b7a7f640b8 100644
--- a/packages/backend/src/core/chart/charts/federation.ts
+++ b/packages/backend/src/core/chart/charts/federation.ts
@@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/federation.js';
@@ -26,16 +27,18 @@ export default class FederationChart extends Chart { // eslint-di
@Inject(DI.meta)
private meta: MiMeta,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
- private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts
index 97f3bc6f2b..b1657e0a0b 100644
--- a/packages/backend/src/core/chart/charts/instance.ts
+++ b/packages/backend/src/core/chart/charts/instance.ts
@@ -5,13 +5,14 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/instance.js';
@@ -26,6 +27,9 @@ export default class InstanceChart extends Chart { // eslint-disa
@Inject(DI.db)
private db: DataSource,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -39,10 +43,9 @@ export default class InstanceChart extends Chart { // eslint-disa
private followingsRepository: FollowingsRepository,
private utilityService: UtilityService,
- private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts
index f763b5fffa..aa64e2329a 100644
--- a/packages/backend/src/core/chart/charts/notes.ts
+++ b/packages/backend/src/core/chart/charts/notes.ts
@@ -5,11 +5,12 @@
import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { NotesRepository } from '@/models/_.js';
import type { MiNote } from '@/models/Note.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/notes.js';
@@ -24,13 +25,15 @@ export default class NotesChart extends Chart { // eslint-disable
@Inject(DI.db)
private db: DataSource,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
- private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts
index 404964d8b7..f7e92aecea 100644
--- a/packages/backend/src/core/chart/charts/per-user-drive.ts
+++ b/packages/backend/src/core/chart/charts/per-user-drive.ts
@@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { DriveFilesRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-drive.js';
@@ -25,14 +26,16 @@ export default class PerUserDriveChart extends Chart { // eslint-
@Inject(DI.db)
private db: DataSource,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@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);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts
index 588ac638de..ea431a5131 100644
--- a/packages/backend/src/core/chart/charts/per-user-following.ts
+++ b/packages/backend/src/core/chart/charts/per-user-following.ts
@@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { FollowingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-following.js';
@@ -25,14 +26,16 @@ export default class PerUserFollowingChart extends Chart { // esl
@Inject(DI.db)
private db: DataSource,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@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);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts
index e4900772bb..824d60042d 100644
--- a/packages/backend/src/core/chart/charts/per-user-notes.ts
+++ b/packages/backend/src/core/chart/charts/per-user-notes.ts
@@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import type { NotesRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-notes.js';
@@ -25,13 +26,15 @@ export default class PerUserNotesChart extends Chart { // eslint-
@Inject(DI.db)
private db: DataSource,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
- private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/per-user-pv.ts b/packages/backend/src/core/chart/charts/per-user-pv.ts
index 31708fefa8..b3e1b2cea1 100644
--- a/packages/backend/src/core/chart/charts/per-user-pv.ts
+++ b/packages/backend/src/core/chart/charts/per-user-pv.ts
@@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-pv.js';
@@ -23,10 +24,12 @@ export default class PerUserPvChart extends Chart { // eslint-dis
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts
index c29c4d2870..7bc1d9e7fa 100644
--- a/packages/backend/src/core/chart/charts/per-user-reactions.ts
+++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts
@@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-reactions.js';
@@ -25,11 +26,13 @@ export default class PerUserReactionsChart extends Chart { // esl
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
private userEntityService: UserEntityService,
private chartLoggerService: ChartLoggerService,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts
index 7a2844f4ed..8dd1a5d996 100644
--- a/packages/backend/src/core/chart/charts/test-grouped.ts
+++ b/packages/backend/src/core/chart/charts/test-grouped.ts
@@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import { AppLockService } from '@/core/AppLockService.js';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { name, schema } from './entities/test-grouped.js';
import type { KVs } from '../core.js';
@@ -24,10 +25,12 @@ export default class TestGroupedChart extends Chart { // eslint-d
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
logger: Logger,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema, true);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema, true);
}
protected async tickMajor(group: string): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts
index b8d0556c9f..23b8649cce 100644
--- a/packages/backend/src/core/chart/charts/test-intersection.ts
+++ b/packages/backend/src/core/chart/charts/test-intersection.ts
@@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import { AppLockService } from '@/core/AppLockService.js';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { name, schema } from './entities/test-intersection.js';
import type { KVs } from '../core.js';
@@ -22,10 +23,12 @@ export default class TestIntersectionChart extends Chart { // esl
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
logger: Logger,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, 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
index f94e008059..b84dd419ba 100644
--- a/packages/backend/src/core/chart/charts/test-unique.ts
+++ b/packages/backend/src/core/chart/charts/test-unique.ts
@@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import { AppLockService } from '@/core/AppLockService.js';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { name, schema } from './entities/test-unique.js';
import type { KVs } from '../core.js';
@@ -22,10 +23,12 @@ export default class TestUniqueChart extends Chart { // eslint-di
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
logger: Logger,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts
index a90dc8f99b..0e95ce9239 100644
--- a/packages/backend/src/core/chart/charts/test.ts
+++ b/packages/backend/src/core/chart/charts/test.ts
@@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import { AppLockService } from '@/core/AppLockService.js';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { name, schema } from './entities/test.js';
import type { KVs } from '../core.js';
@@ -24,10 +25,12 @@ export default class TestChart extends Chart { // eslint-disable-
@Inject(DI.db)
private db: DataSource,
- private appLockService: AppLockService,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
logger: Logger,
) {
- super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema);
+ super(db, (k) => acquireChartInsertLock(redisClient, 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
index d148fc629b..4471c1df23 100644
--- a/packages/backend/src/core/chart/charts/users.ts
+++ b/packages/backend/src/core/chart/charts/users.ts
@@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm';
+import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js';
-import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
+import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/users.js';
@@ -25,14 +26,16 @@ export default class UsersChart extends Chart { // eslint-disable
@Inject(DI.db)
private db: DataSource,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@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);
+ super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise>> {
diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts
index 1ba7ca8e57..e26cddd281 100644
--- a/packages/backend/src/core/entities/ChannelEntityService.ts
+++ b/packages/backend/src/core/entities/ChannelEntityService.ts
@@ -4,36 +4,40 @@
*/
import { Inject, Injectable } from '@nestjs/common';
+import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js';
+import type {
+ ChannelFavoritesRepository,
+ ChannelFollowingsRepository, ChannelMutingRepository,
+ ChannelsRepository,
+ DriveFilesRepository,
+ MiDriveFile,
+ MiNote,
+ NotesRepository,
+} from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
-import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiChannel } from '@/models/Channel.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { DriveFileEntityService } from './DriveFileEntityService.js';
import { NoteEntityService } from './NoteEntityService.js';
-import { In } from 'typeorm';
@Injectable()
export class ChannelEntityService {
constructor(
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
-
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
-
@Inject(DI.channelFavoritesRepository)
private channelFavoritesRepository: ChannelFavoritesRepository,
-
+ @Inject(DI.channelMutingRepository)
+ private channelMutingRepository: ChannelMutingRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
-
private noteEntityService: NoteEntityService,
private driveFileEntityService: DriveFileEntityService,
private idService: IdService,
@@ -45,31 +49,59 @@ export class ChannelEntityService {
src: MiChannel['id'] | MiChannel,
me?: { id: MiUser['id'] } | null | undefined,
detailed?: boolean,
+ opts?: {
+ bannerFiles?: Map;
+ followings?: Set;
+ favorites?: Set;
+ muting?: Set;
+ pinnedNotes?: Map;
+ },
): Promise> {
const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src });
- const meId = me ? me.id : null;
- const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
+ let bannerFile: MiDriveFile | null = null;
+ if (channel.bannerId) {
+ bannerFile = opts?.bannerFiles?.get(channel.bannerId)
+ ?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId });
+ }
- const isFollowing = meId ? await this.channelFollowingsRepository.exists({
- where: {
- followerId: meId,
- followeeId: channel.id,
- },
- }) : false;
+ let isFollowing = false;
+ let isFavorited = false;
+ let isMuting = false;
+ if (me) {
+ isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({
+ where: {
+ followerId: me.id,
+ followeeId: channel.id,
+ },
+ });
- const isFavorited = meId ? await this.channelFavoritesRepository.exists({
- where: {
- userId: meId,
- channelId: channel.id,
- },
- }) : false;
+ isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({
+ where: {
+ userId: me.id,
+ channelId: channel.id,
+ },
+ });
- const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({
- where: {
- id: In(channel.pinnedNoteIds),
- },
- }) : [];
+ isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({
+ where: {
+ userId: me.id,
+ channelId: channel.id,
+ },
+ });
+ }
+
+ const pinnedNotes = Array.of();
+ if (channel.pinnedNoteIds.length > 0) {
+ pinnedNotes.push(
+ ...(
+ opts?.pinnedNotes
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ ? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null)
+ : await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) })
+ ),
+ );
+ }
return {
id: channel.id,
@@ -78,7 +110,8 @@ export class ChannelEntityService {
name: channel.name,
description: channel.description,
userId: channel.userId,
- bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
+ bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null,
+ bannerId: channel.bannerId,
pinnedNoteIds: channel.pinnedNoteIds,
color: channel.color,
isArchived: channel.isArchived,
@@ -90,6 +123,7 @@ export class ChannelEntityService {
...(me ? {
isFollowing,
isFavorited,
+ isMuting,
hasUnreadNote: false, // 後方互換性のため
} : {}),
@@ -98,5 +132,72 @@ export class ChannelEntityService {
} : {}),
};
}
+
+ @bindThis
+ public async packMany(
+ src: MiChannel['id'][] | MiChannel[],
+ me?: { id: MiUser['id'] } | null | undefined,
+ detailed?: boolean,
+ ): Promise[]> {
+ // IDのみの要素がある場合、DBからオブジェクトを取得して補う
+ const channels = src.filter(it => typeof it === 'object') as MiChannel[];
+ channels.push(
+ ...(await this.channelsRepository.find({
+ where: {
+ id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]),
+ },
+ })),
+ );
+ channels.sort((a, b) => a.id.localeCompare(b.id));
+
+ const bannerFiles = await this.driveFilesRepository
+ .findBy({
+ id: In(channels.map(it => it.bannerId).filter(it => it != null)),
+ })
+ .then(it => new Map(it.map(it => [it.id, it])));
+
+ const followings = me
+ ? await this.channelFollowingsRepository
+ .findBy({
+ followerId: me.id,
+ followeeId: In(channels.map(it => it.id)),
+ })
+ .then(it => new Set(it.map(it => it.followeeId)))
+ : new Set();
+
+ const favorites = me
+ ? await this.channelFavoritesRepository
+ .findBy({
+ userId: me.id,
+ channelId: In(channels.map(it => it.id)),
+ })
+ .then(it => new Set(it.map(it => it.channelId)))
+ : new Set();
+
+ const muting = me
+ ? await this.channelMutingRepository
+ .findBy({
+ userId: me.id,
+ channelId: In(channels.map(it => it.id)),
+ })
+ .then(it => new Set(it.map(it => it.channelId)))
+ : new Set();
+
+ const pinnedNotes = await this.notesRepository
+ .find({
+ where: {
+ id: In(channels.flatMap(it => it.pinnedNoteIds)),
+ },
+ })
+ .then(it => new Map(it.map(it => [it.id, it])));
+
+ return Promise.all(channels.map(it => this.pack(it, me, detailed, {
+ bannerFiles,
+ followings,
+ favorites,
+ muting,
+ pinnedNotes,
+ })));
+ }
}
diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts
index 6bce2413fd..cfa983e766 100644
--- a/packages/backend/src/core/entities/ChatEntityService.ts
+++ b/packages/backend/src/core/entities/ChatEntityService.ts
@@ -54,12 +54,13 @@ export class ChatEntityService {
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
- const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+ // userは削除されている可能性があるのでnull許容
+ const reactions: { user: Packed<'UserLite'> | null; reaction: string; }[] = [];
for (const record of message.reactions) {
const [userId, reaction] = record.split('/');
reactions.push({
- user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+ user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId).catch(() => null),
reaction,
});
}
@@ -76,7 +77,7 @@ export class ChatEntityService {
toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined,
fileId: message.fileId,
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
- reactions,
+ reactions: reactions.filter((r): r is { user: Packed<'UserLite'>; reaction: string; } => r.user != null),
};
}
@@ -108,6 +109,7 @@ export class ChatEntityService {
}
}
+ // TODO: packedUsersに削除されたユーザーもnullとして含める
const [packedUsers, packedFiles, packedRooms] = await Promise.all([
this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u]))),
@@ -183,12 +185,13 @@ export class ChatEntityService {
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
- const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+ // userは削除されている可能性があるのでnull許容
+ const reactions: { user: Packed<'UserLite'> | null; reaction: string; }[] = [];
for (const record of message.reactions) {
const [userId, reaction] = record.split('/');
reactions.push({
- user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+ user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId).catch(() => null),
reaction,
});
}
@@ -202,7 +205,7 @@ export class ChatEntityService {
toRoomId: message.toRoomId!,
fileId: message.fileId,
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
- reactions,
+ reactions: reactions.filter((r): r is { user: Packed<'UserLite'>; reaction: string; } => r.user != null),
};
}
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index 02783dc450..2da614a120 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -109,6 +109,7 @@ export class MetaEntityService {
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
defaultLightTheme,
defaultDarkTheme,
+ clientOptions: instance.clientOptions,
ads: ads.map(ad => ({
id: ad.id,
url: ad.url,
@@ -116,6 +117,7 @@ export class MetaEntityService {
ratio: ad.ratio,
imageUrl: ad.imageUrl,
dayOfWeek: ad.dayOfWeek,
+ isSensitive: ad.isSensitive ? true : undefined,
})),
notesPerOneAd: instance.notesPerOneAd,
enableEmail: instance.enableEmail,
diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts
index 3ef8cdaa12..71e41a588d 100644
--- a/packages/backend/src/core/entities/NoteDraftEntityService.ts
+++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts
@@ -105,6 +105,8 @@ export class NoteDraftEntityService implements OnModuleInit {
const packed: Packed<'NoteDraft'> = await awaitAll({
id: noteDraft.id,
createdAt: this.idService.parse(noteDraft.id).date.toISOString(),
+ scheduledAt: noteDraft.scheduledAt?.getTime() ?? null,
+ isActuallyScheduled: noteDraft.isActuallyScheduled,
userId: noteDraft.userId,
user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me),
text: text,
@@ -112,13 +114,13 @@ export class NoteDraftEntityService implements OnModuleInit {
visibility: noteDraft.visibility,
localOnly: noteDraft.localOnly,
reactionAcceptance: noteDraft.reactionAcceptance,
- visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined,
- hashtag: noteDraft.hashtag ?? undefined,
+ visibleUserIds: noteDraft.visibleUserIds,
+ hashtag: noteDraft.hashtag,
fileIds: noteDraft.fileIds,
files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds),
replyId: noteDraft.replyId,
renoteId: noteDraft.renoteId,
- channelId: noteDraft.channelId ?? undefined,
+ channelId: noteDraft.channelId,
channel: channel ? {
id: channel.id,
name: channel.name,
@@ -127,6 +129,12 @@ export class NoteDraftEntityService implements OnModuleInit {
allowRenoteToExternal: channel.allowRenoteToExternal,
userId: channel.userId,
} : undefined,
+ poll: noteDraft.hasPoll ? {
+ choices: noteDraft.pollChoices,
+ multiple: noteDraft.pollMultiple,
+ expiresAt: noteDraft.pollExpiresAt?.toISOString(),
+ expiredAfter: noteDraft.pollExpiredAfter,
+ } : null,
...(opts.detail ? {
reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, {
@@ -138,13 +146,6 @@ export class NoteDraftEntityService implements OnModuleInit {
detail: true,
skipHide: opts.skipHide,
})) : undefined,
-
- poll: noteDraft.hasPoll ? {
- choices: noteDraft.pollChoices,
- multiple: noteDraft.pollMultiple,
- expiresAt: noteDraft.pollExpiresAt?.toISOString(),
- expiredAfter: noteDraft.pollExpiredAfter,
- } : undefined,
} : {} ),
});
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index 6871ba2c72..e7847ba74e 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -15,6 +15,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepos
import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
+import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
@@ -116,12 +117,7 @@ export class NoteEntityService implements OnModuleInit {
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
- if ((followersOnlyBefore != null)
- && (
- (followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
- || (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
- )
- ) {
+ if (shouldHideNoteByTime(followersOnlyBefore, packedNote.createdAt)) {
packedNote.visibility = 'followers';
}
}
@@ -141,12 +137,7 @@ export class NoteEntityService implements OnModuleInit {
if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
- if ((hiddenBefore != null)
- && (
- (hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
- || (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
- )
- ) {
+ if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
hide = true;
}
}
diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts
index 46ec13704c..54ce4d472a 100644
--- a/packages/backend/src/core/entities/NoteReactionEntityService.ts
+++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts
@@ -49,15 +49,12 @@ export class NoteReactionEntityService implements OnModuleInit {
public async pack(
src: MiNoteReaction['id'] | MiNoteReaction,
me?: { id: MiUser['id'] } | null | undefined,
- options?: {
- withNote: boolean;
- },
+ options?: object,
hints?: {
packedUser?: Packed<'UserLite'>
},
): Promise> {
const opts = Object.assign({
- withNote: false,
}, options);
const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src });
@@ -67,9 +64,6 @@ export class NoteReactionEntityService implements OnModuleInit {
createdAt: this.idService.parse(reaction.id).date.toISOString(),
user: hints?.packedUser ?? await this.userEntityService.pack(reaction.user ?? reaction.userId, me),
type: this.reactionService.convertLegacyReaction(reaction.reaction),
- ...(opts.withNote ? {
- note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me),
- } : {}),
};
}
@@ -77,16 +71,50 @@ export class NoteReactionEntityService implements OnModuleInit {
public async packMany(
reactions: MiNoteReaction[],
me?: { id: MiUser['id'] } | null | undefined,
- options?: {
- withNote: boolean;
- },
+ options?: object,
): Promise[]> {
const opts = Object.assign({
- withNote: false,
}, options);
const _users = reactions.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
}
+
+ @bindThis
+ public async packWithNote(
+ src: MiNoteReaction['id'] | MiNoteReaction,
+ me?: { id: MiUser['id'] } | null | undefined,
+ options?: object,
+ hints?: {
+ packedUser?: Packed<'UserLite'>
+ },
+ ): Promise> {
+ const opts = Object.assign({
+ }, options);
+
+ const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src });
+
+ return {
+ id: reaction.id,
+ createdAt: this.idService.parse(reaction.id).date.toISOString(),
+ user: hints?.packedUser ?? await this.userEntityService.pack(reaction.user ?? reaction.userId, me),
+ type: this.reactionService.convertLegacyReaction(reaction.reaction),
+ note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me),
+ };
+ }
+
+ @bindThis
+ public async packManyWithNote(
+ reactions: MiNoteReaction[],
+ me?: { id: MiUser['id'] } | null | undefined,
+ options?: object,
+ ): Promise[]> {
+ const opts = Object.assign({
+ }, options);
+ const _users = reactions.map(({ user, userId }) => user ?? userId);
+ const _userMap = await this.userEntityService.packMany(_users, me)
+ .then(users => new Map(users.map(u => [u.id, u])));
+ return Promise.all(reactions.map(reaction => this.packWithNote(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
+ }
}
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index e91fb9eb51..0e96237d32 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -21,7 +21,18 @@ import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
-const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]);
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set([
+ 'note',
+ 'mention',
+ 'reply',
+ 'renote',
+ 'renote:grouped',
+ 'quote',
+ 'reaction',
+ 'reaction:grouped',
+ 'pollEnded',
+ 'scheduledNotePosted',
+] as (typeof groupedNotificationTypes[number])[]);
@Injectable()
export class NotificationEntityService implements OnModuleInit {
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index d4769d24d4..ac5b855096 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -471,8 +471,8 @@ export class UserEntityService implements OnModuleInit {
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
null;
- const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : null;
- const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : null;
+ const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : undefined;
+ const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : undefined;
const unreadAnnouncements = isMe && isDetailed ?
(await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({
createdAt: this.idService.parse(announcement.id).date.toISOString(),
@@ -481,6 +481,7 @@ export class UserEntityService implements OnModuleInit {
const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null;
+ // TODO: 例えば avatarUrl: true など間違った型を設定しても型エラーにならないのをどうにかする(ジェネリクス使わない方法で実装するしかなさそう?)
const packed = {
id: user.id,
name: user.name,
@@ -511,8 +512,8 @@ export class UserEntityService implements OnModuleInit {
} : undefined) : undefined,
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user),
- // パフォーマンス上の理由でローカルユーザーのみ
- badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
+ // パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得
+ badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
.filter((r) => r.isPublic || iAmModerator)
.sort((a, b) => b.displayOrder - a.displayOrder)
.map((r) => ({
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index c915133453..b9ca76233c 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -70,6 +70,7 @@ export const DI = {
channelsRepository: Symbol('channelsRepository'),
channelFollowingsRepository: Symbol('channelFollowingsRepository'),
channelFavoritesRepository: Symbol('channelFavoritesRepository'),
+ channelMutingRepository: Symbol('channelMutingRepository'),
registryItemsRepository: Symbol('registryItemsRepository'),
webhooksRepository: Symbol('webhooksRepository'),
systemWebhooksRepository: Symbol('systemWebhooksRepository'),
diff --git a/packages/backend/src/misc/distributed-lock.ts b/packages/backend/src/misc/distributed-lock.ts
new file mode 100644
index 0000000000..93bd741f62
--- /dev/null
+++ b/packages/backend/src/misc/distributed-lock.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Redis from 'ioredis';
+
+export async function acquireDistributedLock(
+ redis: Redis.Redis,
+ name: string,
+ timeout: number,
+ maxRetries: number,
+ retryInterval: number,
+): Promise<() => Promise> {
+ const lockKey = `lock:${name}`;
+ const identifier = Math.random().toString(36).slice(2);
+
+ let retries = 0;
+ while (retries < maxRetries) {
+ const result = await redis.set(lockKey, identifier, 'PX', timeout, 'NX');
+ if (result === 'OK') {
+ return async () => {
+ const currentIdentifier = await redis.get(lockKey);
+ if (currentIdentifier === identifier) {
+ await redis.del(lockKey);
+ }
+ };
+ }
+
+ await new Promise(resolve => setTimeout(resolve, retryInterval));
+ retries++;
+ }
+
+ throw new Error(`Failed to acquire lock ${name}`);
+}
+
+export function acquireApObjectLock(
+ redis: Redis.Redis,
+ uri: string,
+): Promise<() => Promise> {
+ return acquireDistributedLock(redis, `ap-object:${uri}`, 30 * 1000, 50, 100);
+}
+
+export function acquireChartInsertLock(
+ redis: Redis.Redis,
+ name: string,
+): Promise<() => Promise> {
+ return acquireDistributedLock(redis, `chart-insert:${name}`, 30 * 1000, 50, 500);
+}
diff --git a/packages/backend/src/misc/escape-html.ts b/packages/backend/src/misc/escape-html.ts
new file mode 100644
index 0000000000..819aeeed52
--- /dev/null
+++ b/packages/backend/src/misc/escape-html.ts
@@ -0,0 +1,13 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/packages/backend/src/misc/is-channel-related.ts b/packages/backend/src/misc/is-channel-related.ts
new file mode 100644
index 0000000000..fef736dad6
--- /dev/null
+++ b/packages/backend/src/misc/is-channel-related.ts
@@ -0,0 +1,31 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { MiNote } from '@/models/Note.js';
+import { Packed } from '@/misc/json-schema.js';
+
+/**
+ * {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。
+ * 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。
+ *
+ * @param note 確認対象のノート
+ * @param channelIds 確認対象のチャンネルID一覧
+ * @param ignoreAuthor trueの場合、ノートの所属チャンネルが{@link channelIds}に含まれていても無視します(デフォルトはfalse)
+ */
+export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set, ignoreAuthor = false): boolean {
+ // ノートの所属チャンネルが確認対象のチャンネルID一覧に含まれている場合
+ if (!ignoreAuthor && note.channelId && channelIds.has(note.channelId)) {
+ return true;
+ }
+
+ const renoteChannelId = note.renote?.channelId;
+ if (renoteChannelId != null && renoteChannelId !== note.channelId && channelIds.has(renoteChannelId)) {
+ return true;
+ }
+
+ // NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので)
+
+ return false;
+}
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index ed47edff9b..ed7d5bfc3a 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -22,7 +22,7 @@ import { packedFollowingSchema } from '@/models/json-schema/following.js';
import { packedMutingSchema } from '@/models/json-schema/muting.js';
import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js';
import { packedBlockingSchema } from '@/models/json-schema/blocking.js';
-import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js';
+import { packedNoteReactionSchema, packedNoteReactionWithNoteSchema } from '@/models/json-schema/note-reaction.js';
import { packedHashtagSchema } from '@/models/json-schema/hashtag.js';
import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js';
import { packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js';
@@ -65,6 +65,7 @@ import {
packedMetaDetailedSchema,
packedMetaLiteSchema,
} from '@/models/json-schema/meta.js';
+import { packedUserWebhookSchema } from '@/models/json-schema/user-webhook.js';
import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js';
import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js';
import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessageLiteForRoomSchema, packedChatMessageLiteFor1on1Schema } from '@/models/json-schema/chat-message.js';
@@ -92,6 +93,7 @@ export const refs = {
Note: packedNoteSchema,
NoteDraft: packedNoteDraftSchema,
NoteReaction: packedNoteReactionSchema,
+ NoteReactionWithNote: packedNoteReactionWithNoteSchema,
NoteFavorite: packedNoteFavoriteSchema,
Notification: packedNotificationSchema,
DriveFile: packedDriveFileSchema,
@@ -133,6 +135,7 @@ export const refs = {
MetaLite: packedMetaLiteSchema,
MetaDetailedOnly: packedMetaDetailedOnlySchema,
MetaDetailed: packedMetaDetailedSchema,
+ UserWebhook: packedUserWebhookSchema,
SystemWebhook: packedSystemWebhookSchema,
AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema,
ChatMessage: packedChatMessageSchema,
diff --git a/packages/backend/src/misc/json-stringify-html-safe.ts b/packages/backend/src/misc/json-stringify-html-safe.ts
new file mode 100644
index 0000000000..aac12d57db
--- /dev/null
+++ b/packages/backend/src/misc/json-stringify-html-safe.ts
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const ESCAPE_LOOKUP = {
+ '&': '\\u0026',
+ '>': '\\u003e',
+ '<': '\\u003c',
+ '\u2028': '\\u2028',
+ '\u2029': '\\u2029',
+} as Record;
+
+const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
+
+export function htmlSafeJsonStringify(obj: any): string {
+ return JSON.stringify(obj).replace(ESCAPE_REGEX, x => ESCAPE_LOOKUP[x]);
+}
diff --git a/packages/backend/src/misc/should-hide-note-by-time.ts b/packages/backend/src/misc/should-hide-note-by-time.ts
new file mode 100644
index 0000000000..ea1951e66c
--- /dev/null
+++ b/packages/backend/src/misc/should-hide-note-by-time.ts
@@ -0,0 +1,29 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * ノートが指定された時間条件に基づいて非表示対象かどうかを判定する
+ * @param hiddenBefore 非表示条件(負の値: 作成からの経過秒数、正の値: UNIXタイムスタンプ秒、null: 判定しない)
+ * @param createdAt ノートの作成日時(ISO 8601形式の文字列 または Date オブジェクト)
+ * @returns 非表示にすべき場合は true
+ */
+export function shouldHideNoteByTime(hiddenBefore: number | null | undefined, createdAt: string | Date): boolean {
+ if (hiddenBefore == null) {
+ return false;
+ }
+
+ const createdAtTime = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime();
+
+ if (hiddenBefore <= 0) {
+ // 負の値: 作成からの経過時間(秒)で判定
+ const elapsedSeconds = (Date.now() - createdAtTime) / 1000;
+ const hideAfterSeconds = Math.abs(hiddenBefore);
+ return elapsedSeconds >= hideAfterSeconds;
+ } else {
+ // 正の値: 絶対的なタイムスタンプ(秒)で判定
+ const createdAtSeconds = createdAtTime / 1000;
+ return createdAtSeconds <= hiddenBefore;
+ }
+}
diff --git a/packages/backend/src/models/Ad.ts b/packages/backend/src/models/Ad.ts
index 108e991c70..0d402fcbe8 100644
--- a/packages/backend/src/models/Ad.ts
+++ b/packages/backend/src/models/Ad.ts
@@ -54,10 +54,17 @@ export class MiAd {
length: 8192, nullable: false,
})
public memo: string;
+
@Column('integer', {
default: 0, nullable: false,
})
public dayOfWeek: number;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public isSensitive: boolean;
+
constructor(data: Partial) {
if (data == null) return;
diff --git a/packages/backend/src/models/ChannelMuting.ts b/packages/backend/src/models/ChannelMuting.ts
new file mode 100644
index 0000000000..11ac7e5cef
--- /dev/null
+++ b/packages/backend/src/models/ChannelMuting.ts
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiChannel } from './Channel.js';
+
+@Entity('channel_muting')
+@Index(['userId', 'channelId'], {})
+export class MiChannelMuting {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public userId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: MiUser | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public channelId: MiChannel['id'];
+
+ @ManyToOne(type => MiChannel, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public channel: MiChannel | null;
+
+ @Index()
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public expiresAt: Date | null;
+}
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index 1fc50cbd07..205c9eeb89 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -716,6 +716,16 @@ export class MiMeta {
default: 90, // days
})
public remoteNotesCleaningExpiryDaysForEachNotes: number;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public showRoleBadgesOfRemoteUsers: boolean;
+
+ @Column('jsonb', {
+ default: { },
+ })
+ public clientOptions: Record;
}
export type SoftwareSuspension = {
diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts
index ff46615729..23e5960b60 100644
--- a/packages/backend/src/models/Note.ts
+++ b/packages/backend/src/models/Note.ts
@@ -114,6 +114,13 @@ export class MiNote {
})
public clippedCount: number;
+ // The number of note page blocks referencing this note.
+ // This column is used by Remote Note Cleaning and manually updated rather than automatically with triggers.
+ @Column('smallint', {
+ default: 0,
+ })
+ public pageCount: number;
+
@Column('jsonb', {
default: {},
})
@@ -241,6 +248,14 @@ export class MiNote {
})
public renoteUserHost: string | null;
+ @Column({
+ ...id(),
+ nullable: true,
+ comment: '[Denormalized]',
+ })
+ public renoteChannelId: MiChannel['id'] | null;
+ //#endregion
+
constructor(data: Partial) {
if (data == null) return;
diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts
index 6483748bc2..f078e8c21b 100644
--- a/packages/backend/src/models/NoteDraft.ts
+++ b/packages/backend/src/models/NoteDraft.ts
@@ -126,7 +126,7 @@ export class MiNoteDraft {
@JoinColumn()
public channel: MiChannel | null;
- // 以下、Pollについて追加
+ //#region 以下、Pollについて追加
@Column('boolean', {
default: false,
@@ -151,13 +151,18 @@ export class MiNoteDraft {
})
public pollExpiredAfter: number | null;
- // ここまで追加
+ //#endregion
- constructor(data: Partial) {
- if (data == null) return;
+ // 予約日時
+ // これがあるだけでは実際に予約されているかどうかはわからない
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public scheduledAt: Date | null;
- for (const [k, v] of Object.entries(data)) {
- (this as any)[k] = v;
- }
- }
+ // scheduledAtに基づいて実際にスケジュールされているか
+ @Column('boolean', {
+ default: false,
+ })
+ public isActuallyScheduled: boolean;
}
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index 5764a307b0..7fa17e20fa 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -9,7 +9,9 @@ import { MiNote } from './Note.js';
import { MiAccessToken } from './AccessToken.js';
import { MiRole } from './Role.js';
import { MiDriveFile } from './DriveFile.js';
+import { MiNoteDraft } from './NoteDraft.js';
+// misskey-js の notificationTypes と同期すべし
export type MiNotification = {
type: 'note';
id: string;
@@ -59,6 +61,16 @@ export type MiNotification = {
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
+} | {
+ type: 'scheduledNotePosted';
+ id: string;
+ createdAt: string;
+ noteId: MiNote['id'];
+} | {
+ type: 'scheduledNotePostFailed';
+ id: string;
+ createdAt: string;
+ noteDraftId: MiNoteDraft['id'];
} | {
type: 'receiveFollowRequest';
id: string;
diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts
index 0b59e7a92c..d46f6e9d16 100644
--- a/packages/backend/src/models/Page.ts
+++ b/packages/backend/src/models/Page.ts
@@ -47,7 +47,7 @@ export class MiPage {
@Column('varchar', {
length: 32,
})
- public font: string;
+ public font: 'serif' | 'sans-serif';
@Index()
@Column({
@@ -69,7 +69,7 @@ export class MiPage {
public eyeCatchingImageId: MiDriveFile['id'] | null;
@ManyToOne(type => MiDriveFile, {
- onDelete: 'CASCADE',
+ onDelete: 'SET NULL',
})
@JoinColumn()
public eyeCatchingImage: MiDriveFile | null;
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 146dbbc3b8..e3db6f8838 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -21,6 +21,7 @@ import {
MiChannel,
MiChannelFavorite,
MiChannelFollowing,
+ MiChannelMuting,
MiClip,
MiClipFavorite,
MiClipNote,
@@ -429,6 +430,12 @@ const $channelFavoritesRepository: Provider = {
inject: [DI.db],
};
+const $channelMutingRepository: Provider = {
+ provide: DI.channelMutingRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiChannelMuting).extend(miRepository as MiRepository),
+ inject: [DI.db],
+};
+
const $registryItemsRepository: Provider = {
provide: DI.registryItemsRepository,
useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository),
@@ -597,6 +604,7 @@ const $reversiGamesRepository: Provider = {
$channelsRepository,
$channelFollowingsRepository,
$channelFavoritesRepository,
+ $channelMutingRepository,
$registryItemsRepository,
$webhooksRepository,
$systemWebhooksRepository,
@@ -674,6 +682,7 @@ const $reversiGamesRepository: Provider = {
$channelsRepository,
$channelFollowingsRepository,
$channelFavoritesRepository,
+ $channelMutingRepository,
$registryItemsRepository,
$webhooksRepository,
$systemWebhooksRepository,
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index 84b5cbed0a..c4528e3a77 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -5,18 +5,9 @@
import {
FindOneOptions,
- InsertQueryBuilder,
ObjectLiteral,
- QueryRunner,
Repository,
- SelectQueryBuilder,
} from 'typeorm';
-import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
-import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
-import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
-import {
- RawSqlResultsToEntityTransformer,
-} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAccessToken } from '@/models/AccessToken.js';
@@ -32,6 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiChannel } from '@/models/Channel.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
+import { MiChannelMuting } from "@/models/ChannelMuting.js";
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
@@ -95,66 +87,12 @@ import { MiWebhook } from '@/models/Webhook.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
export interface MiRepository {
- createTableColumnNames(this: Repository & MiRepository): string[];
-
insertOne(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>): Promise;
-
- insertOneImpl(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>, queryRunner?: QueryRunner): Promise;
-
- selectAliasColumnNames(this: Repository & MiRepository, queryBuilder: InsertQueryBuilder, builder: SelectQueryBuilder): void;
}
export const miRepository = {
- createTableColumnNames() {
- return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName);
- },
async insertOne(entity, findOptions?) {
- const opt = this.manager.connection.options as PostgresConnectionOptions;
- if (opt.replication) {
- const queryRunner = this.manager.connection.createQueryRunner('master');
- try {
- return this.insertOneImpl(entity, findOptions, queryRunner);
- } finally {
- await queryRunner.release();
- }
- } else {
- return this.insertOneImpl(entity, findOptions);
- }
- },
- async insertOneImpl(entity, findOptions?, queryRunner?) {
- // ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ----
-
- const queryBuilder = this.createQueryBuilder().insert().values(entity);
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const mainAlias = queryBuilder.expressionMap.mainAlias!;
- const name = mainAlias.name;
- mainAlias.name = 't';
- const columnNames = this.createTableColumnNames();
- queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2));
-
- // ---- 共通テーブル式(CTE)から結果を取得 ----
- const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames });
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- builder.expressionMap.mainAlias!.tablePath = 'cte';
- this.selectAliasColumnNames(queryBuilder, builder);
- if (findOptions) {
- builder.setFindOptions(findOptions);
- }
- const raw = await builder.execute();
- mainAlias.name = name;
- const relationId = await new RelationIdLoader(builder.connection, this.queryRunner, builder.expressionMap.relationIdAttributes).load(raw);
- const relationCount = await new RelationCountLoader(builder.connection, this.queryRunner, builder.expressionMap.relationCountAttributes).load(raw);
- const result = new RawSqlResultsToEntityTransformer(builder.expressionMap, builder.connection.driver, relationId, relationCount, this.queryRunner).transform(raw, mainAlias);
- return result[0];
- },
- selectAliasColumnNames(queryBuilder, builder) {
- let selectOrAddSelect = (selection: string, selectionAliasName?: string) => {
- selectOrAddSelect = (selection, selectionAliasName) => builder.addSelect(selection, selectionAliasName);
- return builder.select(selection, selectionAliasName);
- };
- for (const columnName of this.createTableColumnNames()) {
- selectOrAddSelect(`${builder.alias}.${columnName}`, `${builder.alias}_${columnName}`);
- }
+ return await this.insert(entity).then(x => this.findOneOrFail({ where: x.identifiers[0], ...findOptions }));
},
} satisfies MiRepository;
@@ -172,6 +110,7 @@ export {
MiBlocking,
MiChannelFollowing,
MiChannelFavorite,
+ MiChannelMuting,
MiClip,
MiClipNote,
MiClipFavorite,
@@ -251,6 +190,7 @@ export type AuthSessionsRepository = Repository & MiRepository & MiRepository;
export type ChannelFollowingsRepository = Repository & MiRepository;
export type ChannelFavoritesRepository = Repository & MiRepository;
+export type ChannelMutingRepository = Repository & MiRepository;
export type ClipsRepository = Repository & MiRepository;
export type ClipNotesRepository = Repository & MiRepository;
export type ClipFavoritesRepository = Repository & MiRepository;
diff --git a/packages/backend/src/models/json-schema/ad.ts b/packages/backend/src/models/json-schema/ad.ts
index b01b39a38b..d88ac23894 100644
--- a/packages/backend/src/models/json-schema/ad.ts
+++ b/packages/backend/src/models/json-schema/ad.ts
@@ -60,5 +60,10 @@ export const packedAdSchema = {
optional: false,
nullable: false,
},
+ isSensitive: {
+ type: 'boolean',
+ optional: false,
+ nullable: false,
+ },
},
} as const;
diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts
index d233f7858d..e7613290d1 100644
--- a/packages/backend/src/models/json-schema/channel.ts
+++ b/packages/backend/src/models/json-schema/channel.ts
@@ -40,6 +40,11 @@ export const packedChannelSchema = {
format: 'url',
nullable: true, optional: false,
},
+ bannerId: {
+ type: 'string',
+ nullable: true, optional: false,
+ format: 'id',
+ },
pinnedNoteIds: {
type: 'array',
nullable: false, optional: false,
@@ -80,6 +85,10 @@ export const packedChannelSchema = {
type: 'boolean',
optional: true, nullable: false,
},
+ isMuting: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
pinnedNotes: {
type: 'array',
optional: true, nullable: false,
diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts
index 2cd7620af0..a0e7d490b3 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -71,6 +71,10 @@ export const packedMetaLiteSchema = {
type: 'string',
optional: false, nullable: true,
},
+ clientOptions: {
+ type: 'object',
+ optional: false, nullable: false,
+ },
disableRegistration: {
type: 'boolean',
optional: false, nullable: false,
@@ -191,6 +195,10 @@ export const packedMetaLiteSchema = {
type: 'integer',
optional: false, nullable: false,
},
+ isSensitive: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
},
},
},
diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts
index 504b263a6d..8144ac7b3b 100644
--- a/packages/backend/src/models/json-schema/note-draft.ts
+++ b/packages/backend/src/models/json-schema/note-draft.ts
@@ -23,7 +23,7 @@ export const packedNoteDraftSchema = {
},
cw: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
userId: {
type: 'string',
@@ -37,27 +37,23 @@ export const packedNoteDraftSchema = {
},
replyId: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
format: 'id',
- example: 'xxxxxxxxxx',
},
renoteId: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
format: 'id',
- example: 'xxxxxxxxxx',
},
reply: {
type: 'object',
optional: true, nullable: true,
ref: 'Note',
- description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.',
},
renote: {
type: 'object',
optional: true, nullable: true,
ref: 'Note',
- description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.',
},
visibility: {
type: 'string',
@@ -66,7 +62,7 @@ export const packedNoteDraftSchema = {
},
visibleUserIds: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
@@ -75,7 +71,7 @@ export const packedNoteDraftSchema = {
},
fileIds: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
@@ -93,11 +89,11 @@ export const packedNoteDraftSchema = {
},
hashtag: {
type: 'string',
- optional: true, nullable: false,
+ optional: false, nullable: true,
},
poll: {
type: 'object',
- optional: true, nullable: true,
+ optional: false, nullable: true,
properties: {
expiresAt: {
type: 'string',
@@ -124,9 +120,8 @@ export const packedNoteDraftSchema = {
},
channelId: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
format: 'id',
- example: 'xxxxxxxxxx',
},
channel: {
type: 'object',
@@ -160,12 +155,20 @@ export const packedNoteDraftSchema = {
},
localOnly: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
reactionAcceptance: {
type: 'string',
optional: false, nullable: true,
enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null],
},
+ scheduledAt: {
+ type: 'number',
+ optional: false, nullable: true,
+ },
+ isActuallyScheduled: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
},
} as const;
diff --git a/packages/backend/src/models/json-schema/note-reaction.ts b/packages/backend/src/models/json-schema/note-reaction.ts
index 95658ace1f..04c9f34232 100644
--- a/packages/backend/src/models/json-schema/note-reaction.ts
+++ b/packages/backend/src/models/json-schema/note-reaction.ts
@@ -10,7 +10,6 @@ export const packedNoteReactionSchema = {
type: 'string',
optional: false, nullable: false,
format: 'id',
- example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string',
@@ -28,3 +27,33 @@ export const packedNoteReactionSchema = {
},
},
} as const;
+
+export const packedNoteReactionWithNoteSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ createdAt: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'date-time',
+ },
+ user: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserLite',
+ },
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ note: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'Note',
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index 6de120c8d7..30e9c9327a 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -207,6 +207,36 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
},
},
+ }, {
+ type: 'object',
+ properties: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: ['scheduledNotePosted'],
+ },
+ note: {
+ type: 'object',
+ ref: 'Note',
+ optional: false, nullable: false,
+ },
+ },
+ }, {
+ type: 'object',
+ properties: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: ['scheduledNotePostFailed'],
+ },
+ noteDraft: {
+ type: 'object',
+ ref: 'NoteDraft',
+ optional: false, nullable: false,
+ },
+ },
}, {
type: 'object',
properties: {
diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts
index 748d6f1245..8f6d5c675d 100644
--- a/packages/backend/src/models/json-schema/page.ts
+++ b/packages/backend/src/models/json-schema/page.ts
@@ -174,6 +174,7 @@ export const packedPageSchema = {
font: {
type: 'string',
optional: false, nullable: false,
+ enum: ['serif', 'sans-serif'],
},
script: {
type: 'string',
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 0b9234cb81..b9000152d4 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -317,6 +317,10 @@ export const packedRolePoliciesSchema = {
type: 'integer',
optional: false, nullable: false,
},
+ scheduledNoteLimit: {
+ type: 'integer',
+ optional: false, nullable: false,
+ },
watermarkAvailable: {
type: 'boolean',
optional: false, nullable: false,
diff --git a/packages/backend/src/models/json-schema/user-webhook.ts b/packages/backend/src/models/json-schema/user-webhook.ts
new file mode 100644
index 0000000000..8ea0991716
--- /dev/null
+++ b/packages/backend/src/models/json-schema/user-webhook.ts
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { webhookEventTypes } from '@/models/Webhook.js';
+
+export const packedUserWebhookSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ format: 'id',
+ optional: false, nullable: false,
+ },
+ userId: {
+ type: 'string',
+ format: 'id',
+ optional: false, nullable: false,
+ },
+ name: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ on: {
+ type: 'array',
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: webhookEventTypes,
+ },
+ },
+ url: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ secret: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ active: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ latestSentAt: {
+ type: 'string',
+ format: 'date-time',
+ optional: false, nullable: true,
+ },
+ latestStatus: {
+ type: 'integer',
+ optional: false, nullable: true,
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 2b5f706ff9..b5fd38a7d7 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -65,7 +65,7 @@ export const packedUserLiteSchema = {
avatarUrl: {
type: 'string',
format: 'url',
- nullable: true, optional: false,
+ nullable: false, optional: false,
},
avatarBlurhash: {
type: 'string',
@@ -465,11 +465,11 @@ export const packedMeDetailedOnlySchema = {
},
isModerator: {
type: 'boolean',
- nullable: true, optional: false,
+ nullable: false, optional: false,
},
isAdmin: {
type: 'boolean',
- nullable: true, optional: false,
+ nullable: false, optional: false,
},
injectFeaturedNote: {
type: 'boolean',
@@ -591,7 +591,7 @@ export const packedMeDetailedOnlySchema = {
},
mutedInstances: {
type: 'array',
- nullable: true, optional: false,
+ nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
@@ -609,6 +609,8 @@ export const packedMeDetailedOnlySchema = {
quote: { optional: true, ...notificationRecieveConfig },
reaction: { optional: true, ...notificationRecieveConfig },
pollEnded: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig },
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index f6cbbbe64c..3dcd3f0965 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -6,7 +6,6 @@
// https://github.com/typeorm/typeorm/issues/2400
import pg from 'pg';
import { DataSource, Logger, type QueryRunner } from 'typeorm';
-import * as highlight from 'cli-highlight';
import { entities as charts } from '@/core/chart/entities.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
@@ -25,6 +24,7 @@ import { MiAuthSession } from '@/models/AuthSession.js';
import { MiBlocking } from '@/models/Blocking.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
+import { MiChannelMuting } from '@/models/ChannelMuting.js';
import { MiClip } from '@/models/Clip.js';
import { MiClipNote } from '@/models/ClipNote.js';
import { MiClipFavorite } from '@/models/ClipFavorite.js';
@@ -100,12 +100,6 @@ export type LoggerProps = {
printReplicationMode?: boolean,
};
-function highlightSql(sql: string) {
- return highlight.highlight(sql, {
- language: 'sql', ignoreIllegals: true,
- });
-}
-
function truncateSql(sql: string) {
return sql.length > 100 ? `${sql.substring(0, 100)}...` : sql;
}
@@ -131,7 +125,7 @@ class MyCustomLogger implements Logger {
modded = truncateSql(modded);
}
- return highlightSql(modded);
+ return modded;
}
@bindThis
@@ -239,6 +233,7 @@ export const entities = [
MiChannel,
MiChannelFollowing,
MiChannelFavorite,
+ MiChannelMuting,
MiRegistryItem,
MiAd,
MiPasswordResetRequest,
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index e01414cd53..e64882c4df 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
+import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
@@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
UserWebhookDeliverProcessorService,
SystemWebhookDeliverProcessorService,
EndedPollNotificationProcessorService,
+ PostScheduledNoteProcessorService,
DeliverProcessorService,
InboxProcessorService,
AggregateRetentionProcessorService,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index 7b64182754..2b3b3fc0ad 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -5,7 +5,6 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Bull from 'bullmq';
-import * as Sentry from '@sentry/node';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
@@ -14,6 +13,7 @@ import { CheckModeratorsActivityProcessorService } from '@/queue/processors/Chec
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
+import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
@@ -85,6 +85,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private relationshipQueueWorker: Bull.Worker;
private objectStorageQueueWorker: Bull.Worker;
private endedPollNotificationQueueWorker: Bull.Worker;
+ private postScheduledNoteQueueWorker: Bull.Worker;
constructor(
@Inject(DI.config)
@@ -94,6 +95,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
+ private postScheduledNoteProcessorService: PostScheduledNoteProcessorService,
private deliverProcessorService: DeliverProcessorService,
private inboxProcessorService: InboxProcessorService,
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
@@ -154,6 +156,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
}
+ let Sentry: typeof import('@sentry/node') | undefined;
+ if (this.config.sentryForBackend) {
+ import('@sentry/node').then((mod) => {
+ Sentry = mod;
+ });
+ }
+
//#region system
{
const processer = (job: Bull.Job) => {
@@ -172,7 +181,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job));
} else {
return processer(job);
@@ -189,7 +198,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err: Error) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -229,7 +238,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job));
} else {
return processer(job);
@@ -246,7 +255,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -261,7 +270,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region deliver
{
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job));
} else {
return this.deliverProcessorService.process(job);
@@ -286,7 +295,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -301,7 +310,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region inbox
{
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job));
} else {
return this.inboxProcessorService.process(job);
@@ -326,7 +335,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -341,7 +350,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region user-webhook deliver
{
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job));
} else {
return this.userWebhookDeliverProcessorService.process(job);
@@ -366,7 +375,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -381,7 +390,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region system-webhook deliver
{
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job));
} else {
return this.systemWebhookDeliverProcessorService.process(job);
@@ -406,7 +415,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -431,7 +440,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job));
} else {
return processer(job);
@@ -453,7 +462,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -476,7 +485,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job));
} else {
return processer(job);
@@ -494,7 +503,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
- if (config.sentryForBackend) {
+ if (Sentry != null) {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
@@ -509,7 +518,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region ended poll notification
{
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
- if (this.config.sentryForBackend) {
+ if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job));
} else {
return this.endedPollNotificationProcessorService.process(job);
@@ -520,6 +529,21 @@ export class QueueProcessorService implements OnApplicationShutdown {
});
}
//#endregion
+
+ //#region post scheduled note
+ {
+ this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
+ if (Sentry != null) {
+ return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
+ } else {
+ return this.postScheduledNoteProcessorService.process(job);
+ }
+ }, {
+ ...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
+ autorun: false,
+ });
+ }
+ //#endregion
}
@bindThis
@@ -534,6 +558,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.run(),
this.objectStorageQueueWorker.run(),
this.endedPollNotificationQueueWorker.run(),
+ this.postScheduledNoteQueueWorker.run(),
]);
}
@@ -549,6 +574,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.close(),
this.objectStorageQueueWorker.close(),
this.endedPollNotificationQueueWorker.close(),
+ this.postScheduledNoteQueueWorker.close(),
]);
}
diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts
index 7e146a7e03..625204b7ad 100644
--- a/packages/backend/src/queue/const.ts
+++ b/packages/backend/src/queue/const.ts
@@ -12,6 +12,7 @@ export const QUEUE = {
INBOX: 'inbox',
SYSTEM: 'system',
ENDED_POLL_NOTIFICATION: 'endedPollNotification',
+ POST_SCHEDULED_NOTE: 'postScheduledNote',
DB: 'db',
RELATIONSHIP: 'relationship',
OBJECT_STORAGE: 'objectStorage',
diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts
index 448fc9c763..e898e6dd48 100644
--- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts
+++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts
@@ -4,14 +4,13 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MutingsRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { UserMutingService } from '@/core/UserMutingService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type * as Bull from 'bullmq';
@Injectable()
export class CheckExpiredMutingsProcessorService {
@@ -22,6 +21,7 @@ export class CheckExpiredMutingsProcessorService {
private mutingsRepository: MutingsRepository,
private userMutingService: UserMutingService,
+ private channelMutingService: ChannelMutingService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings');
@@ -41,6 +41,8 @@ export class CheckExpiredMutingsProcessorService {
await this.userMutingService.unmute(expired);
}
+ await this.channelMutingService.eraseExpiredMutings();
+
this.logger.succ('All expired mutings checked.');
}
}
diff --git a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts
index da3bb804c2..bc99dea000 100644
--- a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts
+++ b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts
@@ -5,6 +5,7 @@
import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable } from '@nestjs/common';
+import { DataSource, IsNull, LessThan, QueryFailedError, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
@@ -24,19 +25,43 @@ export class CleanRemoteNotesProcessorService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
+ @Inject(DI.db)
+ private db: DataSource,
+
private idService: IdService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-notes');
}
+ @bindThis
+ private computeProgress(minId: string, maxId: string, cursorLeft: string) {
+ const minTs = this.idService.parse(minId).date.getTime();
+ const maxTs = this.idService.parse(maxId).date.getTime();
+ const cursorTs = this.idService.parse(cursorLeft).date.getTime();
+
+ return ((cursorTs - minTs) / (maxTs - minTs)) * 100;
+ }
+
@bindThis
public async process(job: Bull.Job>): Promise<{
deletedCount: number;
oldest: number | null;
newest: number | null;
- skipped?: boolean;
+ skipped: boolean;
+ transientErrors: number;
}> {
+ const getConfig = () => {
+ return {
+ enabled: this.meta.enableRemoteNotesCleaning,
+ maxDuration: this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000, // Convert minutes to milliseconds
+ // The date limit for the newest note to be considered for deletion.
+ // All notes newer than this limit will always be retained.
+ newestLimit: this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes)),
+ };
+ };
+
+ const initialConfig = getConfig();
if (!this.meta.enableRemoteNotesCleaning) {
this.logger.info('Remote notes cleaning is disabled, skipping...');
return {
@@ -44,20 +69,15 @@ export class CleanRemoteNotesProcessorService {
oldest: null,
newest: null,
skipped: true,
+ transientErrors: 0,
};
}
this.logger.info('cleaning remote notes...');
- const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
const startAt = Date.now();
- const MAX_NOTE_COUNT_PER_QUERY = 50;
-
- //#retion queries
- // We use string literals instead of query builder for several reasons:
- // - for removeCondition, we need to use it in having clause, which is not supported by Brackets.
- // - for recursive part, we need to preserve the order of columns, but typeorm query builder does not guarantee the order of columns in the result query
+ //#region queries
// The condition for removing the notes.
// The note must be:
@@ -66,56 +86,95 @@ export class CleanRemoteNotesProcessorService {
// - not have clipped
// - not have pinned on the user profile
// - not has been favorite by any user
- const removeCondition = 'note.id < :newestLimit'
- + ' AND note."clippedCount" = 0'
- + ' AND note."userHost" IS NOT NULL'
- // using both userId and noteId instead of just noteId to use index on user_note_pining table.
- // This is safe because notes are only pinned by the user who created them.
- + ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
- // We cannot use userId trick because users can favorite notes from other users.
- + ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
- ;
+ const removalCriteria = [
+ 'note."id" < :newestLimit',
+ 'note."clippedCount" = 0',
+ 'note."pageCount" = 0',
+ 'note."userHost" IS NOT NULL',
+ 'NOT EXISTS (SELECT 1 FROM user_note_pining WHERE "noteId" = note."id")',
+ 'NOT EXISTS (SELECT 1 FROM note_favorite WHERE "noteId" = note."id")',
+ 'NOT EXISTS (SELECT 1 FROM note_reaction INNER JOIN "user" ON note_reaction."userId" = "user".id WHERE note_reaction."noteId" = note."id" AND "user"."host" IS NULL)',
+ ].join(' AND ');
- // The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes
- const initiatorQuery = this.notesRepository.createQueryBuilder('note')
+ const minId = (await this.notesRepository.createQueryBuilder('note')
+ .select('MIN(note.id)', 'minId')
+ .where({
+ id: LessThan(initialConfig.newestLimit),
+ userHost: Not(IsNull()),
+ replyId: IsNull(),
+ renoteId: IsNull(),
+ })
+ .getRawOne<{ minId?: MiNote['id'] }>())?.minId;
+
+ if (!minId) {
+ this.logger.info('No notes can possibly be deleted, skipping...');
+ return {
+ deletedCount: 0,
+ oldest: null,
+ newest: null,
+ skipped: false,
+ transientErrors: 0,
+ };
+ }
+
+ // start with a conservative limit and adjust it based on the query duration
+ const minimumLimit = 10;
+ let currentLimit = 100;
+ let cursorLeft = '0';
+
+ const candidateNotesCteName = 'candidate_notes';
+
+ // tree walk down all root notes, short-circuit when the first unremovable note is found
+ const candidateNotesQueryBase = this.notesRepository.createQueryBuilder('note')
+ .select('note."id"', 'id')
+ .addSelect('note."replyId"', 'replyId')
+ .addSelect('note."renoteId"', 'renoteId')
+ .addSelect('note."id"', 'rootId')
+ .addSelect('TRUE', 'isRemovable')
+ .addSelect('TRUE', 'isBase')
+ .where('note."id" > :cursorLeft')
+ .andWhere(removalCriteria)
+ .andWhere({ replyId: IsNull(), renoteId: IsNull() });
+
+ const candidateNotesQueryInductive = this.notesRepository.createQueryBuilder('note')
.select('note.id', 'id')
- .where(removeCondition)
- .andWhere('note.id > :cursor')
- .orderBy('note.id', 'ASC')
- .limit(MAX_NOTE_COUNT_PER_QUERY);
+ .addSelect('note."replyId"', 'replyId')
+ .addSelect('note."renoteId"', 'renoteId')
+ .addSelect('parent."rootId"', 'rootId')
+ .addSelect(removalCriteria, 'isRemovable')
+ .addSelect('FALSE', 'isBase')
+ .innerJoin(candidateNotesCteName, 'parent', 'parent."id" = note."replyId" OR parent."id" = note."renoteId"')
+ .where('parent."isRemovable" = TRUE');
- // The union query queries the related notes and replies related to the initiator query
- const unionQuery = `
- SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
- FROM "note" "note"
- INNER JOIN "related_notes" "rn"
- ON "note"."replyId" = rn.id
- OR "note"."renoteId" = rn.id
- OR "note"."id" = rn."replyId"
- OR "note"."id" = rn."renoteId"
- `;
-
- const selectRelatedNotesFromInitiatorIdsQuery = `
- SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
- FROM "note" "note" WHERE "note"."id" IN (:...initiatorIds)
- `;
-
- const recursiveQuery = `(${selectRelatedNotesFromInitiatorIdsQuery}) UNION (${unionQuery})`;
-
- const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
- .select('rn."initiatorId"')
- .innerJoin('related_notes', 'rn', 'note.id = rn.id')
- .groupBy('rn."initiatorId"')
- .having(`bool_and(${removeCondition})`);
-
- const notesQuery = this.notesRepository.createQueryBuilder('note')
- .addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
- .select('note.id', 'id')
- .addSelect('rn."initiatorId"')
- .innerJoin('related_notes', 'rn', 'note.id = rn.id')
- .where(`rn."initiatorId" IN (${removableInitiatorNotesQuery.getQuery()})`)
- .distinctOn(['note.id']);
- //#endregion
+ // A note tree can be deleted if there are no unremovable rows with the same rootId.
+ //
+ // `candidate_notes` will have the following structure after recursive query (some columns omitted):
+ // After performing a LEFT JOIN with `candidate_notes` as `unremovable`,
+ // the note tree containing unremovable notes will be anti-joined.
+ // For removable rows, the `unremovable` columns will have `NULL` values.
+ // | id | rootId | isRemovable |
+ // |-----|--------|-------------|
+ // | aaa | aaa | TRUE |
+ // | bbb | aaa | FALSE |
+ // | ccc | aaa | FALSE |
+ // | ddd | ddd | TRUE |
+ // | eee | ddd | TRUE |
+ // | fff | fff | TRUE |
+ // | ggg | ggg | FALSE |
+ //
+ const candidateNotesQuery = ({ limit }: { limit: number }) => this.db.createQueryBuilder()
+ .select(`"${candidateNotesCteName}"."id"`, 'id')
+ .addSelect('unremovable."id" IS NULL', 'isRemovable')
+ .addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase')
+ .addCommonTableExpression(
+ `((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(limit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
+ candidateNotesCteName,
+ { recursive: true },
+ )
+ .from(candidateNotesCteName, candidateNotesCteName)
+ .leftJoin(candidateNotesCteName, 'unremovable', `unremovable."rootId" = "${candidateNotesCteName}"."rootId" AND unremovable."isRemovable" = FALSE`)
+ .groupBy(`"${candidateNotesCteName}"."id"`)
+ .addGroupBy('unremovable."id" IS NULL');
const stats = {
deletedCount: 0,
@@ -123,74 +182,137 @@ export class CleanRemoteNotesProcessorService {
newest: null as number | null,
};
- // The date limit for the newest note to be considered for deletion.
- // All notes newer than this limit will always be retained.
- const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
-
- let cursor = '0'; // oldest note ID to start from
-
- while (true) {
+ let lowThroughputWarned = false;
+ let transientErrors = 0;
+ for (;;) {
+ const { enabled, maxDuration, newestLimit } = getConfig();
+ if (!enabled) {
+ this.logger.info('Remote notes cleaning is disabled, processing stopped...');
+ break;
+ }
//#region check time
const batchBeginAt = Date.now();
const elapsed = batchBeginAt - startAt;
+ const progress = this.computeProgress(minId, newestLimit, cursorLeft > minId ? cursorLeft : minId);
+
if (elapsed >= maxDuration) {
- this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
- job.log('Reached maximum duration, stopping cleaning.');
+ job.log(`Reached maximum duration of ${maxDuration}ms, stopping... (last cursor: ${cursorLeft}, final progress ${progress}%)`);
job.updateProgress(100);
break;
}
- job.updateProgress((elapsed / maxDuration) * 100);
+ const wallClockUsage = elapsed / maxDuration;
+ if (wallClockUsage > 0.5 && progress < 50 && !lowThroughputWarned) {
+ const msg = `Not projected to finish in time! (wall clock usage ${wallClockUsage * 100}% at ${progress}%, current limit ${currentLimit})`;
+ this.logger.warn(msg);
+ job.log(msg);
+ lowThroughputWarned = true;
+ }
+ job.updateProgress(progress);
//#endregion
- // First, we fetch the initiator notes that are older than the newestLimit.
- const initiatorNotes: { id: MiNote['id'] }[] = await initiatorQuery.setParameters({ cursor, newestLimit }).getRawMany();
+ const queryBegin = performance.now();
+ let noteIds = null;
- // update the cursor to the newest initiatorId found in the fetched notes.
- const newCursor = initiatorNotes.reduce((max, note) => note.id > max ? note.id : max, cursor);
+ try {
+ noteIds = await candidateNotesQuery({ limit: currentLimit }).setParameters(
+ { newestLimit, cursorLeft },
+ ).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>();
+ } catch (e) {
+ if (e instanceof QueryFailedError && e.driverError?.code === '57014') {
+ // Statement timeout (maybe suddenly hit a large note tree), if possible, reduce the limit and try again
+ // if not possible, skip the current batch of notes and find the next root note
+ if (currentLimit <= minimumLimit) {
+ job.log('Local note tree complexity is too high, finding next root note...');
- if (initiatorNotes.length === 0 || cursor === newCursor || newCursor >= newestLimit) {
- // If no notes were found or the cursor did not change, we can stop.
- job.log('No more notes to clean. (no initiator notes found or cursor did not change.)');
+ const idWindow = await this.notesRepository.createQueryBuilder('note')
+ .select('id')
+ .where('note.id > :cursorLeft')
+ .andWhere(removalCriteria)
+ .andWhere({ replyId: IsNull(), renoteId: IsNull() })
+ .orderBy('note.id', 'ASC')
+ .limit(minimumLimit + 1)
+ .setParameters({ cursorLeft, newestLimit })
+ .getRawMany<{ id?: MiNote['id'] }>();
+
+ job.log(`Skipped note IDs: ${idWindow.slice(0, minimumLimit).map(id => id.id).join(', ')}`);
+
+ const lastId = idWindow.at(minimumLimit)?.id;
+
+ if (!lastId) {
+ job.log('No more notes to clean.');
+ break;
+ }
+
+ cursorLeft = lastId;
+ continue;
+ }
+ currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25));
+ continue;
+ }
+ throw e;
+ }
+
+ if (noteIds.length === 0) {
+ job.log('No more notes to clean.');
break;
}
- const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.setParameters({
- initiatorIds: initiatorNotes.map(note => note.id),
- newestLimit,
- }).getRawMany();
+ const queryDuration = performance.now() - queryBegin;
+ // try to adjust such that each query takes about 1~5 seconds and reasonable NodeJS heap so the task stays responsive
+ // this should not oscillate..
+ if (queryDuration > 5000 || noteIds.length > 5000) {
+ currentLimit = Math.floor(currentLimit * 0.5);
+ } else if (queryDuration < 1000 && noteIds.length < 1000) {
+ currentLimit = Math.floor(currentLimit * 1.5);
+ }
+ // clamp to a sane range
+ currentLimit = Math.min(Math.max(currentLimit, minimumLimit), 5000);
- cursor = newCursor;
+ const deletableNoteIds = noteIds.filter(result => result.isRemovable).map(result => result.id);
+ if (deletableNoteIds.length > 0) {
+ try {
+ await this.notesRepository.delete(deletableNoteIds);
- if (notes.length > 0) {
- await this.notesRepository.delete(notes.map(note => note.id));
-
- for (const { id } of notes) {
- const t = this.idService.parse(id).date.getTime();
- if (stats.oldest === null || t < stats.oldest) {
- stats.oldest = t;
+ for (const id of deletableNoteIds) {
+ const t = this.idService.parse(id).date.getTime();
+ if (stats.oldest === null || t < stats.oldest) {
+ stats.oldest = t;
+ }
+ if (stats.newest === null || t > stats.newest) {
+ stats.newest = t;
+ }
}
- if (stats.newest === null || t > stats.newest) {
- stats.newest = t;
+
+ stats.deletedCount += deletableNoteIds.length;
+ } catch (e) {
+ // check for integrity violation errors (class 23) that might have occurred between the check and the delete
+ // we can safely continue to the next batch
+ if (e instanceof QueryFailedError && e.driverError?.code?.startsWith('23')) {
+ transientErrors++;
+ job.log(`Error deleting notes: ${e} (transient race condition?)`);
+ } else {
+ throw e;
}
}
-
- stats.deletedCount += notes.length;
}
- job.log(`Deleted ${notes.length} from ${initiatorNotes.length} initiators; ${Date.now() - batchBeginAt}ms`);
+ cursorLeft = noteIds.filter(result => result.isBase).reduce((max, { id }) => id > max ? id : max, cursorLeft);
- if (initiatorNotes.length < MAX_NOTE_COUNT_PER_QUERY) {
- // If we fetched less than the maximum, it means there are no more notes to process.
- job.log(`No more notes to clean. (fewer than MAX_NOTE_COUNT_PER_QUERY =${MAX_NOTE_COUNT_PER_QUERY}.)`);
- break;
+ job.log(`Deleted ${noteIds.length} notes; ${Date.now() - batchBeginAt}ms`);
+
+ if (process.env.NODE_ENV !== 'test') {
+ await setTimeout(Math.min(1000 * 5, queryDuration)); // Wait a moment to avoid overwhelming the db
}
+ };
- await setTimeout(1000 * 5); // Wait a moment to avoid overwhelming the db
+ if (transientErrors > 0) {
+ const msg = `${transientErrors} transient errors occurred while cleaning remote notes. You may need a second pass to complete the cleaning.`;
+ this.logger.warn(msg);
+ job.log(msg);
}
-
this.logger.succ('cleaning of remote notes completed.');
return {
@@ -198,6 +320,7 @@ export class CleanRemoteNotesProcessorService {
oldest: stats.oldest,
newest: stats.newest,
skipped: false,
+ transientErrors,
};
}
}
diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
index 14a53e0c42..b643c2a6d0 100644
--- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
@@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { DriveFilesRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
@@ -14,6 +14,7 @@ import type { MiNote } from '@/models/Note.js';
import { EmailService } from '@/core/EmailService.js';
import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js';
+import { PageService } from '@/core/PageService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbUserDeleteJobData } from '../types.js';
@@ -35,7 +36,11 @@ export class DeleteAccountProcessorService {
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
+ @Inject(DI.pagesRepository)
+ private pagesRepository: PagesRepository,
+
private driveService: DriveService,
+ private pageService: PageService,
private emailService: EmailService,
private queueLoggerService: QueueLoggerService,
private searchService: SearchService,
@@ -112,6 +117,28 @@ export class DeleteAccountProcessorService {
this.logger.succ('All of files deleted');
}
+ {
+ // delete pages. Necessary for decrementing pageCount of notes.
+ while (true) {
+ const pages = await this.pagesRepository.find({
+ where: {
+ userId: user.id,
+ },
+ take: 100,
+ order: {
+ id: 1,
+ },
+ });
+
+ if (pages.length === 0) {
+ break;
+ }
+ for (const page of pages) {
+ await this.pageService.delete(user, page.id);
+ }
+ }
+ }
+
{ // Send email notification
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.email && profile.emailVerified) {
diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts
index 486dc4c01f..be7d4e9e21 100644
--- a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts
@@ -5,21 +5,20 @@
import * as fs from 'node:fs';
import { Writable } from 'node:stream';
-import { Inject, Injectable, StreamableFile } from '@nestjs/common';
-import { MoreThan } from 'typeorm';
+import { Inject, Injectable } from '@nestjs/common';
import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js';
-import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js';
+import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, PollsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import type { MiPoll } from '@/models/Poll.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
-import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
-import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@@ -43,6 +42,7 @@ export class ExportClipsProcessorService {
private driveService: DriveService,
private queueLoggerService: QueueLoggerService,
+ private queryService: QueryService,
private idService: IdService,
private notificationService: NotificationService,
) {
@@ -100,16 +100,16 @@ export class ExportClipsProcessorService {
});
while (true) {
- const clips = await this.clipsRepository.find({
- where: {
- userId: user.id,
- ...(cursor ? { id: MoreThan(cursor) } : {}),
- },
- take: 100,
- order: {
- id: 1,
- },
- });
+ const query = this.clipsRepository.createQueryBuilder('clip')
+ .where('clip.userId = :userId', { userId: user.id })
+ .orderBy('clip.id', 'ASC')
+ .take(100);
+
+ if (cursor) {
+ query.andWhere('clip.id > :cursor', { cursor });
+ }
+
+ const clips = await query.getMany();
if (clips.length === 0) {
job.updateProgress(100);
@@ -124,7 +124,7 @@ export class ExportClipsProcessorService {
const isFirst = exportedClipsCount === 0;
await writer.write(isFirst ? content : ',\n' + content);
- await this.processClipNotes(writer, clip.id);
+ await this.processClipNotes(writer, clip.id, user.id);
await writer.write(']}');
exportedClipsCount++;
@@ -134,22 +134,25 @@ export class ExportClipsProcessorService {
}
}
- async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise {
+ async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string, userId: string): Promise {
let exportedClipNotesCount = 0;
let cursor: MiClipNote['id'] | null = null;
while (true) {
- const clipNotes = await this.clipNotesRepository.find({
- where: {
- clipId,
- ...(cursor ? { id: MoreThan(cursor) } : {}),
- },
- take: 100,
- order: {
- id: 1,
- },
- relations: ['note', 'note.user'],
- }) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
+ const query = this.clipNotesRepository.createQueryBuilder('clipNote')
+ .leftJoinAndSelect('clipNote.note', 'note')
+ .leftJoinAndSelect('note.user', 'user')
+ .where('clipNote.clipId = :clipId', { clipId })
+ .orderBy('clipNote.id', 'ASC')
+ .take(100);
+
+ if (cursor) {
+ query.andWhere('clipNote.id > :cursor', { cursor });
+ }
+
+ this.queryService.generateVisibilityQuery(query, { id: userId });
+
+ const clipNotes = await query.getMany() as (MiClipNote & { note: MiNote & { user: MiUser } })[];
if (clipNotes.length === 0) {
break;
@@ -158,6 +161,11 @@ export class ExportClipsProcessorService {
cursor = clipNotes.at(-1)?.id ?? null;
for (const clipNote of clipNotes) {
+ const noteCreatedAt = this.idService.parse(clipNote.note.id).date;
+ if (shouldHideNoteByTime(clipNote.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
+ continue;
+ }
+
let poll: MiPoll | undefined;
if (clipNote.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });
diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
index 7918c8ccb5..87a8ded307 100644
--- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
@@ -5,7 +5,6 @@
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
-import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js';
import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js';
@@ -17,6 +16,8 @@ import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService {
private driveService: DriveService,
private queueLoggerService: QueueLoggerService,
+ private queryService: QueryService,
private idService: IdService,
private notificationService: NotificationService,
) {
@@ -83,17 +85,20 @@ export class ExportFavoritesProcessorService {
});
while (true) {
- const favorites = await this.noteFavoritesRepository.find({
- where: {
- userId: user.id,
- ...(cursor ? { id: MoreThan(cursor) } : {}),
- },
- take: 100,
- order: {
- id: 1,
- },
- relations: ['note', 'note.user'],
- }) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
+ const query = this.noteFavoritesRepository.createQueryBuilder('favorite')
+ .leftJoinAndSelect('favorite.note', 'note')
+ .leftJoinAndSelect('note.user', 'user')
+ .where('favorite.userId = :userId', { userId: user.id })
+ .orderBy('favorite.id', 'ASC')
+ .take(100);
+
+ if (cursor) {
+ query.andWhere('favorite.id > :cursor', { cursor });
+ }
+
+ this.queryService.generateVisibilityQuery(query, { id: user.id });
+
+ const favorites = await query.getMany() as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
if (favorites.length === 0) {
job.updateProgress(100);
@@ -103,6 +108,11 @@ export class ExportFavoritesProcessorService {
cursor = favorites.at(-1)?.id ?? null;
for (const favorite of favorites) {
+ const noteCreatedAt = this.idService.parse(favorite.note.id).date;
+ if (shouldHideNoteByTime(favorite.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
+ continue;
+ }
+
let poll: MiPoll | undefined;
if (favorite.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id });
diff --git a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts
new file mode 100644
index 0000000000..d0eaeee090
--- /dev/null
+++ b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts
@@ -0,0 +1,72 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { NoteDraftsRepository } from '@/models/_.js';
+import type Logger from '@/logger.js';
+import { NotificationService } from '@/core/NotificationService.js';
+import { bindThis } from '@/decorators.js';
+import { NoteCreateService } from '@/core/NoteCreateService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type * as Bull from 'bullmq';
+import type { PostScheduledNoteJobData } from '../types.js';
+
+@Injectable()
+export class PostScheduledNoteProcessorService {
+ private logger: Logger;
+
+ constructor(
+ @Inject(DI.noteDraftsRepository)
+ private noteDraftsRepository: NoteDraftsRepository,
+
+ private noteCreateService: NoteCreateService,
+ private notificationService: NotificationService,
+ private queueLoggerService: QueueLoggerService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('post-scheduled-note');
+ }
+
+ @bindThis
+ public async process(job: Bull.Job): Promise {
+ const draft = await this.noteDraftsRepository.findOne({ where: { id: job.data.noteDraftId }, relations: ['user'] });
+ if (draft == null || draft.user == null || draft.scheduledAt == null || !draft.isActuallyScheduled) {
+ return;
+ }
+
+ try {
+ const note = await this.noteCreateService.fetchAndCreate(draft.user, {
+ createdAt: new Date(),
+ fileIds: draft.fileIds,
+ poll: draft.hasPoll ? {
+ choices: draft.pollChoices,
+ multiple: draft.pollMultiple,
+ expiresAt: draft.pollExpiredAfter ? new Date(Date.now() + draft.pollExpiredAfter) : draft.pollExpiresAt ? new Date(draft.pollExpiresAt) : null,
+ } : null,
+ text: draft.text ?? null,
+ replyId: draft.replyId,
+ renoteId: draft.renoteId,
+ cw: draft.cw,
+ localOnly: draft.localOnly,
+ reactionAcceptance: draft.reactionAcceptance,
+ visibility: draft.visibility,
+ visibleUserIds: draft.visibleUserIds,
+ channelId: draft.channelId,
+ });
+
+ // await不要
+ this.noteDraftsRepository.remove(draft);
+
+ // await不要
+ this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', {
+ noteId: note.id,
+ });
+ } catch (err) {
+ this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', {
+ noteDraftId: draft.id,
+ });
+ }
+ }
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 757daea88b..1cb2b93918 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -109,6 +109,10 @@ export type EndedPollNotificationJobData = {
noteId: MiNote['id'];
};
+export type PostScheduledNoteJobData = {
+ noteDraftId: string;
+};
+
export type SystemWebhookDeliverJobData = {
type: T;
content: SystemWebhookPayload;
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 0223650329..111421472d 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -25,6 +25,7 @@ import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
+import { HtmlTemplateService } from './web/HtmlTemplateService.js';
import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
@@ -58,6 +59,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
providers: [
ClientServerService,
ClientLoggerService,
+ HtmlTemplateService,
FeedService,
HealthServerService,
UrlPreviewService,
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 23c085ee27..4e05322b12 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
@bindThis
public async launch(): Promise {
const fastify = Fastify({
- trustProxy: true,
+ trustProxy: this.config.trustProxy ?? false,
logger: false,
});
this.#fastify = fastify;
@@ -238,30 +238,6 @@ export class ServerService implements OnApplicationShutdown {
}
});
- fastify.get<{ Params: { code: string } }>('/verify-email/:code', async (request, reply) => {
- const profile = await this.userProfilesRepository.findOneBy({
- emailVerifyCode: request.params.code,
- });
-
- if (profile != null) {
- await this.userProfilesRepository.update({ userId: profile.userId }, {
- emailVerified: true,
- emailVerifyCode: null,
- });
-
- this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, {
- schema: 'MeDetailed',
- includeSecrets: true,
- }));
-
- reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。');
- return;
- } else {
- reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください');
- return;
- }
- });
-
fastify.register(this.clientServerService.createServer);
this.streamingApiServerService.attach(fastify.server);
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 7a4af407a3..27c79ab438 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as stream from 'node:stream/promises';
import { Inject, Injectable } from '@nestjs/common';
-import * as Sentry from '@sentry/node';
import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
@@ -37,6 +36,7 @@ export class ApiCallService implements OnApplicationShutdown {
private logger: Logger;
private userIpHistories: Map>;
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
+ private Sentry: typeof import('@sentry/node') | null = null;
constructor(
@Inject(DI.meta)
@@ -59,6 +59,12 @@ export class ApiCallService implements OnApplicationShutdown {
this.userIpHistoriesClearIntervalId = setInterval(() => {
this.userIpHistories.clear();
}, 1000 * 60 * 60);
+
+ if (this.config.sentryForBackend) {
+ import('@sentry/node').then((Sentry) => {
+ this.Sentry = Sentry;
+ });
+ }
}
#sendApiError(reply: FastifyReply, err: ApiError): void {
@@ -120,8 +126,8 @@ export class ApiCallService implements OnApplicationShutdown {
},
});
- if (this.config.sentryForBackend) {
- Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
+ if (this.Sentry != null) {
+ this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error',
user: {
id: userId,
@@ -432,8 +438,8 @@ export class ApiCallService implements OnApplicationShutdown {
}
// API invoking
- if (this.config.sentryForBackend) {
- return await Sentry.startSpan({
+ if (this.Sentry != null) {
+ return await this.Sentry.startSpan({
name: 'API: ' + ep.name,
}, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 32818003ad..57d74ef2b1 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -176,6 +176,17 @@ export class ApiServerService {
}
});
+ fastify.all('/clear-browser-cache', (request, reply) => {
+ if (['GET', 'POST'].includes(request.method)) {
+ reply.header('Clear-Site-Data', '"cache", "prefetchCache", "prerenderCache", "executionContexts"');
+ reply.code(204);
+ reply.send();
+ } else {
+ reply.code(405);
+ reply.send();
+ }
+ });
+
// Make sure any unknown path under /api returns HTTP 404 Not Found,
// because otherwise ClientServerService will return the base client HTML
// page with HTTP 200.
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index 2a4e1fc574..21f2f0b7e2 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -15,6 +15,7 @@ import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
@@ -39,6 +40,7 @@ export class StreamingApiServerService {
private notificationService: NotificationService,
private usersService: UserService,
private channelFollowingService: ChannelFollowingService,
+ private channelMutingService: ChannelMutingService,
) {
}
@@ -97,6 +99,7 @@ export class StreamingApiServerService {
this.notificationService,
this.cacheService,
this.channelFollowingService,
+ this.channelMutingService,
user, app,
);
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index c0c43dd5c9..9aecc0f0fd 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -143,6 +143,9 @@ export * as 'channels/timeline' from './endpoints/channels/timeline.js';
export * as 'channels/unfavorite' from './endpoints/channels/unfavorite.js';
export * as 'channels/unfollow' from './endpoints/channels/unfollow.js';
export * as 'channels/update' from './endpoints/channels/update.js';
+export * as 'channels/mute/create' from './endpoints/channels/mute/create.js';
+export * as 'channels/mute/delete' from './endpoints/channels/mute/delete.js';
+export * as 'channels/mute/list' from './endpoints/channels/mute/list.js';
export * as 'charts/active-users' from './endpoints/charts/active-users.js';
export * as 'charts/ap-request' from './endpoints/charts/ap-request.js';
export * as 'charts/drive' from './endpoints/charts/drive.js';
@@ -412,6 +415,7 @@ export * as 'users/search' from './endpoints/users/search.js';
export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js';
export * as 'users/show' from './endpoints/users/show.js';
export * as 'users/update-memo' from './endpoints/users/update-memo.js';
+export * as 'verify-email' from './endpoints/verify-email.js';
export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js';
export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js';
export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
index 06047b58a6..6606202118 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
@@ -34,13 +34,22 @@ export const meta = {
res: {
type: 'object',
optional: false, nullable: false,
- ref: 'MeDetailed',
- properties: {
- token: {
- type: 'string',
- optional: false, nullable: false,
+ allOf: [
+ {
+ type: 'object',
+ ref: 'MeDetailed',
},
- },
+ {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ token: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
+ }
+ ],
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts
index 955154f4fb..01697ae185 100644
--- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts
@@ -36,6 +36,7 @@ export const paramDef = {
startsAt: { type: 'integer' },
imageUrl: { type: 'string', minLength: 1 },
dayOfWeek: { type: 'integer' },
+ isSensitive: { type: 'boolean' },
},
required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'],
} as const;
@@ -55,6 +56,7 @@ export default class extends Endpoint { // eslint-
expiresAt: new Date(ps.expiresAt),
startsAt: new Date(ps.startsAt),
dayOfWeek: ps.dayOfWeek,
+ isSensitive: ps.isSensitive,
url: ps.url,
imageUrl: ps.imageUrl,
priority: ps.priority,
@@ -73,6 +75,7 @@ export default class extends Endpoint { // eslint-
expiresAt: ad.expiresAt.toISOString(),
startsAt: ad.startsAt.toISOString(),
dayOfWeek: ad.dayOfWeek,
+ isSensitive: ad.isSensitive,
url: ad.url,
imageUrl: ad.imageUrl,
priority: ad.priority,
diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts
index 4f897d98e4..f67cad5bd2 100644
--- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts
+++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts
@@ -63,6 +63,7 @@ export default class extends Endpoint { // eslint-
expiresAt: ad.expiresAt.toISOString(),
startsAt: ad.startsAt.toISOString(),
dayOfWeek: ad.dayOfWeek,
+ isSensitive: ad.isSensitive,
url: ad.url,
imageUrl: ad.imageUrl,
memo: ad.memo,
diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts
index 4e3d731aca..a3d9aaddc6 100644
--- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts
@@ -39,6 +39,7 @@ export const paramDef = {
expiresAt: { type: 'integer' },
startsAt: { type: 'integer' },
dayOfWeek: { type: 'integer' },
+ isSensitive: { type: 'boolean' },
},
required: ['id'],
} as const;
@@ -66,6 +67,7 @@ export default class extends Endpoint { // eslint-
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined,
startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined,
dayOfWeek: ps.dayOfWeek,
+ isSensitive: ps.isSensitive,
});
const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id });
diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts
index 81a788de2b..804bd5d9b9 100644
--- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts
+++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts
@@ -49,6 +49,34 @@ export const meta = {
type: 'string',
optional: false, nullable: false,
},
+ icon: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ display: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ isActive: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ forExistingUsers: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ silence: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ needConfirmationToRead: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ userId: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
imageUrl: {
type: 'string',
optional: false, nullable: true,
diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
index b84a5c73f9..e7a70d0762 100644
--- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
+++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
@@ -157,6 +157,22 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
+ maybeSensitive: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ maybePorn: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ requestIp: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ requestHeaders: {
+ type: 'object',
+ optional: false, nullable: true,
+ },
},
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 4d3f6d6cd8..2c7f793584 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -223,10 +223,12 @@ export const meta = {
sensitiveMediaDetection: {
type: 'string',
optional: false, nullable: false,
+ enum: ['none', 'all', 'local', 'remote'],
},
sensitiveMediaDetectionSensitivity: {
type: 'string',
optional: false, nullable: false,
+ enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'],
},
setSensitiveFlagAutomatically: {
type: 'boolean',
@@ -425,6 +427,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
+ clientOptions: {
+ type: 'object',
+ optional: false, nullable: false,
+ },
description: {
type: 'string',
optional: false, nullable: true,
@@ -469,6 +475,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
+ feedbackUrl: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
summalyProxy: {
type: 'string',
optional: false, nullable: true,
@@ -583,6 +593,10 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
+ showRoleBadgesOfRemoteUsers: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
},
},
} as const;
@@ -650,6 +664,7 @@ export default class extends Endpoint { // eslint-
logoImageUrl: instance.logoImageUrl,
defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme,
+ clientOptions: instance.clientOptions,
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
@@ -737,6 +752,7 @@ export default class extends Endpoint { // eslint-
enableRemoteNotesCleaning: instance.enableRemoteNotesCleaning,
remoteNotesCleaningExpiryDaysForEachNotes: instance.remoteNotesCleaningExpiryDaysForEachNotes,
remoteNotesCleaningMaxProcessingDurationInMinutes: instance.remoteNotesCleaningMaxProcessingDurationInMinutes,
+ showRoleBadgesOfRemoteUsers: instance.showRoleBadgesOfRemoteUsers,
};
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
index d7f9e4eaa3..b69699c338 100644
--- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
+++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
+import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, PostScheduledNoteQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
export const meta = {
tags: ['admin'],
@@ -49,6 +49,7 @@ export default class extends Endpoint { // eslint-
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+ @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 1ba6853dbe..2fd7ab8ca2 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -103,6 +103,8 @@ export const meta = {
quote: { optional: true, ...notificationRecieveConfig },
reaction: { optional: true, ...notificationRecieveConfig },
pollEnded: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig },
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 08cea23119..b3c2cecc67 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -67,6 +67,7 @@ export const paramDef = {
description: { type: 'string', nullable: true },
defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true },
+ clientOptions: { type: 'object', nullable: false },
cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' },
@@ -208,6 +209,7 @@ export const paramDef = {
enableRemoteNotesCleaning: { type: 'boolean' },
remoteNotesCleaningExpiryDaysForEachNotes: { type: 'number' },
remoteNotesCleaningMaxProcessingDurationInMinutes: { type: 'number' },
+ showRoleBadgesOfRemoteUsers: { type: 'boolean' },
},
required: [],
} as const;
@@ -326,6 +328,10 @@ export default class extends Endpoint { // eslint-
set.defaultDarkTheme = ps.defaultDarkTheme;
}
+ if (ps.clientOptions !== undefined) {
+ set.clientOptions = ps.clientOptions;
+ }
+
if (ps.cacheRemoteFiles !== undefined) {
set.cacheRemoteFiles = ps.cacheRemoteFiles;
}
@@ -738,6 +744,10 @@ export default class extends Endpoint { // eslint-
set.remoteNotesCleaningMaxProcessingDurationInMinutes = ps.remoteNotesCleaningMaxProcessingDurationInMinutes;
}
+ if (ps.showRoleBadgesOfRemoteUsers !== undefined) {
+ set.showRoleBadgesOfRemoteUsers = ps.showRoleBadgesOfRemoteUsers;
+ }
+
const before = await this.metaService.fetch(true);
await this.metaService.update(set);
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index b2d9cea03c..c59479d370 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
+import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
@@ -14,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -69,6 +71,7 @@ export default class extends Endpoint { // eslint-
private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService,
+ private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -108,6 +111,21 @@ export default class extends Endpoint { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
+ // -- ミュートされたチャンネル対策
+ const mutingChannelIds = await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id));
+ if (mutingChannelIds.length > 0) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.channelId IS NULL');
+ qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }));
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteChannelId IS NULL');
+ qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }));
+ }
+
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts
index 4afed7dc5c..fe48e7497a 100644
--- a/packages/backend/src/server/api/endpoints/ap/show.ts
+++ b/packages/backend/src/server/api/endpoints/ap/show.ts
@@ -18,9 +18,9 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
-import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
+import { ApiError } from '../../error.js';
export const meta = {
tags: ['federation'],
diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts
index e3a6d2d670..8d49b6fd0f 100644
--- a/packages/backend/src/server/api/endpoints/channels/create.ts
+++ b/packages/backend/src/server/api/endpoints/channels/create.ts
@@ -46,7 +46,7 @@ export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 128 },
- description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
+ description: { type: 'string', nullable: true, maxLength: 2048 },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
color: { type: 'string', minLength: 1, maxLength: 16 },
isSensitive: { type: 'boolean', nullable: true },
diff --git a/packages/backend/src/server/api/endpoints/channels/mute/create.ts b/packages/backend/src/server/api/endpoints/channels/mute/create.ts
new file mode 100644
index 0000000000..26ce707c7a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/channels/mute/create.ts
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { ChannelsRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
+
+export const meta = {
+ tags: ['channels', 'mute'],
+
+ requireCredential: true,
+ prohibitMoved: true,
+
+ kind: 'write:channels',
+
+ errors: {
+ noSuchChannel: {
+ message: 'No such Channel.',
+ code: 'NO_SUCH_CHANNEL',
+ id: '7174361e-d58f-31d6-2e7c-6fb830786a3f',
+ },
+
+ alreadyMuting: {
+ message: 'You are already muting that user.',
+ code: 'ALREADY_MUTING_CHANNEL',
+ id: '5a251978-769a-da44-3e89-3931e43bb592',
+ },
+
+ expiresAtIsPast: {
+ message: 'Cannot set past date to "expiresAt".',
+ code: 'EXPIRES_AT_IS_PAST',
+ id: '42b32236-df2c-a45f-fdbf-def67268f749',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ channelId: { type: 'string', format: 'misskey:id' },
+ expiresAt: {
+ type: 'integer',
+ nullable: true,
+ description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.',
+ },
+ },
+ required: ['channelId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.channelsRepository)
+ private channelsRepository: ChannelsRepository,
+ private channelMutingService: ChannelMutingService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ // Check if exists the channel
+ const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
+ if (!targetChannel) {
+ throw new ApiError(meta.errors.noSuchChannel);
+ }
+
+ // Check if already muting
+ const exist = await this.channelMutingService.isMuted({
+ requestUserId: me.id,
+ targetChannelId: targetChannel.id,
+ });
+ if (exist) {
+ throw new ApiError(meta.errors.alreadyMuting);
+ }
+
+ // Check if expiresAt is past
+ if (ps.expiresAt && ps.expiresAt <= Date.now()) {
+ throw new ApiError(meta.errors.expiresAtIsPast);
+ }
+
+ await this.channelMutingService.mute({
+ requestUserId: me.id,
+ targetChannelId: targetChannel.id,
+ expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/channels/mute/delete.ts b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts
new file mode 100644
index 0000000000..79abeebe99
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { ChannelsRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['channels', 'mute'],
+
+ requireCredential: true,
+ prohibitMoved: true,
+
+ kind: 'write:channels',
+
+ errors: {
+ noSuchChannel: {
+ message: 'No such Channel.',
+ code: 'NO_SUCH_CHANNEL',
+ id: 'e7998769-6e94-d9c2-6b8f-94a527314aba',
+ },
+
+ notMuting: {
+ message: 'You are not muting that channel.',
+ code: 'NOT_MUTING_CHANNEL',
+ id: '14d55962-6ea8-d990-1333-d6bef78dc2ab',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ channelId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['channelId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.channelsRepository)
+ private channelsRepository: ChannelsRepository,
+ private channelMutingService: ChannelMutingService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ // Check if exists the channel
+ const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
+ if (!targetChannel) {
+ throw new ApiError(meta.errors.noSuchChannel);
+ }
+
+ // Check muting
+ const exist = await this.channelMutingService.isMuted({
+ requestUserId: me.id,
+ targetChannelId: targetChannel.id,
+ });
+ if (!exist) {
+ throw new ApiError(meta.errors.notMuting);
+ }
+
+ await this.channelMutingService.unmute({
+ requestUserId: me.id,
+ targetChannelId: targetChannel.id,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/channels/mute/list.ts b/packages/backend/src/server/api/endpoints/channels/mute/list.ts
new file mode 100644
index 0000000000..74338eea38
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/channels/mute/list.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
+import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
+
+export const meta = {
+ tags: ['channels', 'mute'],
+
+ requireCredential: true,
+ prohibitMoved: true,
+
+ kind: 'read:channels',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'Channel',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {},
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ private channelMutingService: ChannelMutingService,
+ private channelEntityService: ChannelEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const mutings = await this.channelMutingService.list({
+ requestUserId: me.id,
+ });
+ return await this.channelEntityService.packMany(mutings, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 46b050d4b4..4f56bc2110 100644
--- a/packages/backend/src/server/api/endpoints/channels/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts
@@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { MiLocalUser } from '@/models/User.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -70,6 +71,7 @@ export default class extends Endpoint { // eslint-
private queryService: QueryService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private activeUsersChart: ActiveUsersChart,
+ private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -98,6 +100,7 @@ export default class extends Endpoint { // eslint-
useDbFallback: true,
redisTimelines: [`channelTimeline:${channel.id}`],
excludePureRenotes: false,
+ ignoreAuthorChannelFromMute: true,
dbFallback: async (untilId, sinceId, limit) => {
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
},
@@ -122,6 +125,16 @@ export default class extends Endpoint { // eslint-
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBaseNoteFilteringQuery(query, me);
+
+ if (me) {
+ const mutingChannelIds = await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id).filter(x => x !== ps.channelId));
+ if (mutingChannelIds.length > 0) {
+ query.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ query.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }
+ }
//#endregion
return await query.limit(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts
index dba2938b39..5ec55896e4 100644
--- a/packages/backend/src/server/api/endpoints/channels/update.ts
+++ b/packages/backend/src/server/api/endpoints/channels/update.ts
@@ -50,7 +50,7 @@ export const paramDef = {
properties: {
channelId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 128 },
- description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
+ description: { type: 'string', nullable: true, maxLength: 2048 },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
isArchived: { type: 'boolean', nullable: true },
pinnedNoteIds: {
diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts
index 2e4a3ff820..af20ea9f8d 100644
--- a/packages/backend/src/server/api/endpoints/clips/list.ts
+++ b/packages/backend/src/server/api/endpoints/clips/list.ts
@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueryService } from '@/core/QueryService.js';
import type { ClipsRepository } from '@/models/_.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { DI } from '@/di-symbols.js';
@@ -29,7 +30,13 @@ export const meta = {
export const paramDef = {
type: 'object',
- properties: {},
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ sinceDate: { type: 'integer' },
+ untilDate: { type: 'integer' },
+ },
required: [],
} as const;
@@ -39,12 +46,14 @@ export default class extends Endpoint { // eslint-
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
+ private queryService: QueryService,
private clipEntityService: ClipEntityService,
) {
super(meta, paramDef, async (ps, me) => {
- const clips = await this.clipsRepository.findBy({
- userId: me.id,
- });
+ const query = this.queryService.makePaginationQuery(this.clipsRepository.createQueryBuilder('clip'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere('clip.userId = :userId', { userId: me.id });
+
+ const clips = await query.limit(ps.limit).getMany();
return await this.clipEntityService.packMany(clips, me);
});
diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts
index e378669f0a..8696c6f6e8 100644
--- a/packages/backend/src/server/api/endpoints/flash/update.ts
+++ b/packages/backend/src/server/api/endpoints/flash/update.ts
@@ -73,8 +73,8 @@ export default class extends Endpoint { // eslint-
updatedAt: new Date(),
...Object.fromEntries(
Object.entries(ps).filter(
- ([key, val]) => (key !== 'flashId') && Object.hasOwn(paramDef.properties, key)
- )
+ ([key, val]) => (key !== 'flashId') && Object.hasOwn(paramDef.properties, key),
+ ),
),
});
});
diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts
index 055b5cc061..523d81ac73 100644
--- a/packages/backend/src/server/api/endpoints/i/apps.ts
+++ b/packages/backend/src/server/api/endpoints/i/apps.ts
@@ -46,6 +46,14 @@ export const meta = {
type: 'string',
},
},
+ iconUrl: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ description: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
},
},
},
@@ -88,6 +96,8 @@ export default class extends Endpoint { // eslint-
createdAt: this.idService.parse(token.id).date.toISOString(),
lastUsedAt: token.lastUsedAt?.toISOString(),
permission: token.app ? token.app.permission : token.permission,
+ iconUrl: token.iconUrl,
+ description: token.description ?? token.app?.description ?? null,
})));
});
}
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 082d97f5d4..9971a1ea4d 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -7,7 +7,7 @@ import RE2 from 're2';
import * as mfm from 'mfm-js';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
-import { JSDOM } from 'jsdom';
+import * as htmlParser from 'node-html-parser';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js';
@@ -209,6 +209,8 @@ export const paramDef = {
quote: notificationRecieveConfig,
reaction: notificationRecieveConfig,
pollEnded: notificationRecieveConfig,
+ scheduledNotePosted: notificationRecieveConfig,
+ scheduledNotePostFailed: notificationRecieveConfig,
receiveFollowRequest: notificationRecieveConfig,
followRequestAccepted: notificationRecieveConfig,
roleAssigned: notificationRecieveConfig,
@@ -293,8 +295,20 @@ export default class extends Endpoint { // eslint-
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
- // TODO: ちゃんと数える
- const length = JSON.stringify(mutedWords).length;
+ const count = (arr: (string[] | string)[]) => {
+ let length = 0;
+ for (const item of arr) {
+ if (typeof item === 'string') {
+ length += item.length;
+ } else if (Array.isArray(item)) {
+ for (const subItem of item) {
+ length += subItem.length;
+ }
+ }
+ }
+ return length;
+ };
+ const length = count(mutedWords);
if (length > limit) {
throw new ApiError(meta.errors.tooManyMutedWords);
}
@@ -555,16 +569,15 @@ export default class extends Endpoint { // eslint-
try {
const html = await this.httpRequestService.getHtml(url);
- const { window } = new JSDOM(html);
- const doc: Document = window.document;
+ const doc = htmlParser.parse(html);
const myLink = `${this.config.url}/@${user.username}`;
const aEls = Array.from(doc.getElementsByTagName('a'));
const linkEls = Array.from(doc.getElementsByTagName('link'));
- const includesMyLink = aEls.some(a => a.href === myLink);
- const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink);
+ const includesMyLink = aEls.some(a => a.attributes.href === myLink);
+ const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.attributes.rel?.split(/\s+/).includes('me') && link.attributes.href === myLink);
if (includesMyLink || includesRelMeLinks) {
await this.userProfilesRepository.createQueryBuilder('profile').update()
@@ -574,8 +587,6 @@ export default class extends Endpoint { // eslint-
})
.execute();
}
-
- window.close();
} catch (err) {
// なにもしない
}
diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts
index 394c178f2a..8a3ba9e026 100644
--- a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts
+++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts
@@ -21,29 +21,7 @@ export const meta = {
type: 'array',
items: {
type: 'object',
- properties: {
- id: {
- type: 'string',
- format: 'misskey:id',
- },
- userId: {
- type: 'string',
- format: 'misskey:id',
- },
- name: { type: 'string' },
- on: {
- type: 'array',
- items: {
- type: 'string',
- enum: webhookEventTypes,
- },
- },
- url: { type: 'string' },
- secret: { type: 'string' },
- active: { type: 'boolean' },
- latestSentAt: { type: 'string', format: 'date-time', nullable: true },
- latestStatus: { type: 'integer', nullable: true },
- },
+ ref: 'UserWebhook',
},
},
} as const;
@@ -65,19 +43,17 @@ export default class extends Endpoint { // eslint-
userId: me.id,
});
- return webhooks.map(webhook => (
- {
- id: webhook.id,
- userId: webhook.userId,
- name: webhook.name,
- on: webhook.on,
- url: webhook.url,
- secret: webhook.secret,
- active: webhook.active,
- latestSentAt: webhook.latestSentAt ? webhook.latestSentAt.toISOString() : null,
- latestStatus: webhook.latestStatus,
- }
- ));
+ return webhooks.map(webhook => ({
+ id: webhook.id,
+ userId: webhook.userId,
+ name: webhook.name,
+ on: webhook.on,
+ url: webhook.url,
+ secret: webhook.secret,
+ active: webhook.active,
+ latestSentAt: webhook.latestSentAt ? webhook.latestSentAt.toISOString() : null,
+ latestStatus: webhook.latestStatus,
+ }));
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts
index 4a0c09ff0c..1c19081c98 100644
--- a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts
+++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts
@@ -28,29 +28,7 @@ export const meta = {
res: {
type: 'object',
- properties: {
- id: {
- type: 'string',
- format: 'misskey:id',
- },
- userId: {
- type: 'string',
- format: 'misskey:id',
- },
- name: { type: 'string' },
- on: {
- type: 'array',
- items: {
- type: 'string',
- enum: webhookEventTypes,
- },
- },
- url: { type: 'string' },
- secret: { type: 'string' },
- active: { type: 'boolean' },
- latestSentAt: { type: 'string', format: 'date-time', nullable: true },
- latestStatus: { type: 'integer', nullable: true },
- },
+ ref: 'UserWebhook',
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 7caea8eedc..e48aa69d0f 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -6,17 +6,10 @@
import ms from 'ms';
import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { MiUser } from '@/models/User.js';
-import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js';
-import type { MiDriveFile } from '@/models/DriveFile.js';
-import type { MiNote } from '@/models/Note.js';
-import type { MiChannel } from '@/models/Channel.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
-import { DI } from '@/di-symbols.js';
-import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
@@ -223,168 +216,28 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
-
- @Inject(DI.notesRepository)
- private notesRepository: NotesRepository,
-
- @Inject(DI.blockingsRepository)
- private blockingsRepository: BlockingsRepository,
-
- @Inject(DI.driveFilesRepository)
- private driveFilesRepository: DriveFilesRepository,
-
- @Inject(DI.channelsRepository)
- private channelsRepository: ChannelsRepository,
-
private noteEntityService: NoteEntityService,
private noteCreateService: NoteCreateService,
) {
super(meta, paramDef, async (ps, me) => {
- let visibleUsers: MiUser[] = [];
- if (ps.visibleUserIds) {
- visibleUsers = await this.usersRepository.findBy({
- id: In(ps.visibleUserIds),
- });
- }
-
- let files: MiDriveFile[] = [];
- const fileIds = ps.fileIds ?? ps.mediaIds ?? null;
- if (fileIds != null) {
- files = await this.driveFilesRepository.createQueryBuilder('file')
- .where('file.userId = :userId AND file.id IN (:...fileIds)', {
- userId: me.id,
- fileIds,
- })
- .orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
- .setParameters({ fileIds })
- .getMany();
-
- if (files.length !== fileIds.length) {
- throw new ApiError(meta.errors.noSuchFile);
- }
- }
-
- let renote: MiNote | null = null;
- if (ps.renoteId != null) {
- // Fetch renote to note
- renote = await this.notesRepository.findOne({
- where: { id: ps.renoteId },
- relations: ['user', 'renote', 'reply'],
- });
-
- if (renote == null) {
- throw new ApiError(meta.errors.noSuchRenoteTarget);
- } else if (isRenote(renote) && !isQuote(renote)) {
- throw new ApiError(meta.errors.cannotReRenote);
- }
-
- // Check blocking
- if (renote.userId !== me.id) {
- const blockExist = await this.blockingsRepository.exists({
- where: {
- blockerId: renote.userId,
- blockeeId: me.id,
- },
- });
- if (blockExist) {
- throw new ApiError(meta.errors.youHaveBeenBlocked);
- }
- }
-
- if (renote.visibility === 'followers' && renote.userId !== me.id) {
- // 他人のfollowers noteはreject
- throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
- } else if (renote.visibility === 'specified') {
- // specified / direct noteはreject
- throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
- }
-
- if (renote.channelId && renote.channelId !== ps.channelId) {
- // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
- // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
- const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
- if (renoteChannel == null) {
- // リノートしたいノートが書き込まれているチャンネルが無い
- throw new ApiError(meta.errors.noSuchChannel);
- } else if (!renoteChannel.allowRenoteToExternal) {
- // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
- throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
- }
- }
- }
-
- let reply: MiNote | null = null;
- if (ps.replyId != null) {
- // Fetch reply
- reply = await this.notesRepository.findOne({
- where: { id: ps.replyId },
- relations: ['user'],
- });
-
- if (reply == null) {
- throw new ApiError(meta.errors.noSuchReplyTarget);
- } else if (isRenote(reply) && !isQuote(reply)) {
- throw new ApiError(meta.errors.cannotReplyToPureRenote);
- } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
- throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
- } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
- throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
- }
-
- // Check blocking
- if (reply.userId !== me.id) {
- const blockExist = await this.blockingsRepository.exists({
- where: {
- blockerId: reply.userId,
- blockeeId: me.id,
- },
- });
- if (blockExist) {
- throw new ApiError(meta.errors.youHaveBeenBlocked);
- }
- }
- }
-
- if (ps.poll) {
- if (typeof ps.poll.expiresAt === 'number') {
- if (ps.poll.expiresAt < Date.now()) {
- throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
- }
- } else if (typeof ps.poll.expiredAfter === 'number') {
- ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
- }
- }
-
- let channel: MiChannel | null = null;
- if (ps.channelId != null) {
- channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
-
- if (channel == null) {
- throw new ApiError(meta.errors.noSuchChannel);
- }
- }
-
- // 投稿を作成
try {
- const note = await this.noteCreateService.create(me, {
+ const note = await this.noteCreateService.fetchAndCreate(me, {
createdAt: new Date(),
- files: files,
+ fileIds: ps.fileIds ?? ps.mediaIds ?? [],
poll: ps.poll ? {
choices: ps.poll.choices,
multiple: ps.poll.multiple ?? false,
- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
- } : undefined,
- text: ps.text ?? undefined,
- reply,
- renote,
- cw: ps.cw,
+ expiresAt: ps.poll.expiredAfter ? new Date(Date.now() + ps.poll.expiredAfter) : ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
+ } : null,
+ text: ps.text ?? null,
+ replyId: ps.replyId ?? null,
+ renoteId: ps.renoteId ?? null,
+ cw: ps.cw ?? null,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
- visibleUsers,
- channel,
+ visibleUserIds: ps.visibleUserIds ?? [],
+ channelId: ps.channelId ?? null,
apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
@@ -393,16 +246,46 @@ export default class extends Endpoint { // eslint-
return {
createdNote: await this.noteEntityService.pack(note, me),
};
- } catch (e) {
+ } catch (err) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
- if (e instanceof IdentifiableError) {
- if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
+ if (err instanceof IdentifiableError) {
+ if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
throw new ApiError(meta.errors.containsProhibitedWords);
- } else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
+ } else if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
+ } else if (err.id === '801c046c-5bf5-4234-ad2b-e78fc20a2ac7') {
+ throw new ApiError(meta.errors.noSuchFile);
+ } else if (err.id === '53983c56-e163-45a6-942f-4ddc485d4290') {
+ throw new ApiError(meta.errors.noSuchRenoteTarget);
+ } else if (err.id === 'bde24c37-121f-4e7d-980d-cec52f599f02') {
+ throw new ApiError(meta.errors.cannotReRenote);
+ } else if (err.id === '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3') {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ } else if (err.id === '90b9d6f0-893a-4fef-b0f1-e9a33989f71a') {
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ } else if (err.id === '48d7a997-da5c-4716-b3c3-92db3f37bf7d') {
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ } else if (err.id === 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77') {
+ throw new ApiError(meta.errors.noSuchChannel);
+ } else if (err.id === '7e435f4a-780d-4cfc-a15a-42519bd6fb67') {
+ throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
+ } else if (err.id === '60142edb-1519-408e-926d-4f108d27bee0') {
+ throw new ApiError(meta.errors.noSuchReplyTarget);
+ } else if (err.id === 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36') {
+ throw new ApiError(meta.errors.cannotReplyToPureRenote);
+ } else if (err.id === '11cd37b3-a411-4f77-8633-c580ce6a8dce') {
+ throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
+ } else if (err.id === 'ced780a1-2012-4caf-bc7e-a95a291294cb') {
+ throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
+ } else if (err.id === 'b0df6025-f2e8-44b4-a26a-17ad99104612') {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ } else if (err.id === '0c11c11e-0c8d-48e7-822c-76ccef660068') {
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+ } else if (err.id === 'bfa3905b-25f5-4894-b430-da331a490e4b') {
+ throw new ApiError(meta.errors.noSuchChannel);
}
}
- throw e;
+ throw err;
}
});
}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
index 1c28ec22d0..efb5ee01d1 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
@@ -124,11 +124,29 @@ export const meta = {
id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8',
},
+ tooManyScheduledNotes: {
+ message: 'You cannot create scheduled notes any more.',
+ code: 'TOO_MANY_SCHEDULED_NOTES',
+ id: '22ae69eb-09e3-4541-a850-773cfa45e693',
+ },
+
cannotRenoteToExternal: {
message: 'Cannot Renote to External.',
code: 'CANNOT_RENOTE_TO_EXTERNAL',
id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7',
},
+
+ scheduledAtRequired: {
+ message: 'scheduledAt is required when isActuallyScheduled is true.',
+ code: 'SCHEDULED_AT_REQUIRED',
+ id: '15e28a55-e74c-4d65-89b7-8880cdaaa87d',
+ },
+
+ scheduledAtMustBeInFuture: {
+ message: 'scheduledAt must be in the future.',
+ code: 'SCHEDULED_AT_MUST_BE_IN_FUTURE',
+ id: 'e4bed6c9-017e-4934-aed0-01c22cc60ec1',
+ },
},
limit: {
@@ -162,7 +180,7 @@ export const paramDef = {
fileIds: {
type: 'array',
uniqueItems: true,
- minItems: 1,
+ minItems: 0,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
@@ -183,6 +201,8 @@ export const paramDef = {
},
required: ['choices'],
},
+ scheduledAt: { type: 'integer', nullable: true },
+ isActuallyScheduled: { type: 'boolean', default: false },
},
required: [],
} as const;
@@ -195,23 +215,24 @@ export default class extends Endpoint { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const draft = await this.noteDraftService.create(me, {
- fileIds: ps.fileIds,
- poll: ps.poll ? {
- choices: ps.poll.choices,
- multiple: ps.poll.multiple ?? false,
- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
- expiredAfter: ps.poll.expiredAfter ?? null,
- } : undefined,
+ fileIds: ps.fileIds ?? [],
+ pollChoices: ps.poll?.choices ?? [],
+ pollMultiple: ps.poll?.multiple ?? false,
+ pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
+ pollExpiredAfter: ps.poll?.expiredAfter ?? null,
+ hasPoll: ps.poll != null,
text: ps.text ?? null,
- replyId: ps.replyId ?? undefined,
- renoteId: ps.renoteId ?? undefined,
+ replyId: ps.replyId ?? null,
+ renoteId: ps.renoteId ?? null,
cw: ps.cw ?? null,
- ...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
+ hashtag: ps.hashtag ?? null,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
visibleUserIds: ps.visibleUserIds ?? [],
- channelId: ps.channelId ?? undefined,
+ channelId: ps.channelId ?? null,
+ scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
+ isActuallyScheduled: ps.isActuallyScheduled,
}).catch((err) => {
if (err instanceof IdentifiableError) {
switch (err.id) {
@@ -241,6 +262,12 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
case '215dbc76-336c-4d2a-9605-95766ba7dab0':
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
+ case 'c3275f19-4558-4c59-83e1-4f684b5fab66':
+ throw new ApiError(meta.errors.tooManyScheduledNotes);
+ case '94a89a43-3591-400a-9c17-dd166e71fdfa':
+ throw new ApiError(meta.errors.scheduledAtRequired);
+ case 'b34d0c1b-996f-4e34-a428-c636d98df457':
+ throw new ApiError(meta.errors.scheduledAtMustBeInFuture);
default:
throw err;
}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
index f24f9b8fb2..0774f09228 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
@@ -41,6 +41,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
+ scheduled: { type: 'boolean', nullable: true },
},
required: [],
} as const;
@@ -58,6 +59,12 @@ export default class extends Endpoint { // eslint-
const query = this.queryService.makePaginationQuery(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('drafts.userId = :meId', { meId: me.id });
+ if (ps.scheduled === true) {
+ query.andWhere('drafts.isActuallyScheduled = true');
+ } else if (ps.scheduled === false) {
+ query.andWhere('drafts.isActuallyScheduled = false');
+ }
+
const drafts = await query
.limit(ps.limit)
.getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
index ee221fb765..2900e0cb0d 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
@@ -159,6 +159,24 @@ export const meta = {
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
id: '215dbc76-336c-4d2a-9605-95766ba7dab0',
},
+
+ tooManyScheduledNotes: {
+ message: 'You cannot create scheduled notes any more.',
+ code: 'TOO_MANY_SCHEDULED_NOTES',
+ id: '02f5df79-08ae-4a33-8524-f1503c8f6212',
+ },
+
+ scheduledAtRequired: {
+ message: 'scheduledAt is required when isActuallyScheduled is true.',
+ code: 'SCHEDULED_AT_REQUIRED',
+ id: 'fe9737d5-cc41-498c-af9d-149207307530',
+ },
+
+ scheduledAtMustBeInFuture: {
+ message: 'scheduledAt must be in the future.',
+ code: 'SCHEDULED_AT_MUST_BE_IN_FUTURE',
+ id: 'ed1a6673-d0d1-4364-aaae-9bf3f139cbc5',
+ },
},
limit: {
@@ -171,14 +189,14 @@ export const paramDef = {
type: 'object',
properties: {
draftId: { type: 'string', nullable: false, format: 'misskey:id' },
- visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
+ visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'] },
visibleUserIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
hashtag: { type: 'string', nullable: true, maxLength: 200 },
- localOnly: { type: 'boolean', default: false },
- reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
+ localOnly: { type: 'boolean' },
+ reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'] },
replyId: { type: 'string', format: 'misskey:id', nullable: true },
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
channelId: { type: 'string', format: 'misskey:id', nullable: true },
@@ -194,7 +212,7 @@ export const paramDef = {
fileIds: {
type: 'array',
uniqueItems: true,
- minItems: 1,
+ minItems: 0,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
@@ -215,6 +233,8 @@ export const paramDef = {
},
required: ['choices'],
},
+ scheduledAt: { type: 'integer', nullable: true },
+ isActuallyScheduled: { type: 'boolean' },
},
required: ['draftId'],
} as const;
@@ -228,22 +248,22 @@ export default class extends Endpoint { // eslint-
super(meta, paramDef, async (ps, me) => {
const draft = await this.noteDraftService.update(me, ps.draftId, {
fileIds: ps.fileIds,
- poll: ps.poll ? {
- choices: ps.poll.choices,
- multiple: ps.poll.multiple ?? false,
- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
- expiredAfter: ps.poll.expiredAfter ?? null,
- } : undefined,
- text: ps.text ?? null,
- replyId: ps.replyId ?? undefined,
- renoteId: ps.renoteId ?? undefined,
- cw: ps.cw ?? null,
- ...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
+ pollChoices: ps.poll?.choices,
+ pollMultiple: ps.poll?.multiple,
+ pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
+ pollExpiredAfter: ps.poll?.expiredAfter,
+ text: ps.text,
+ replyId: ps.replyId,
+ renoteId: ps.renoteId,
+ cw: ps.cw,
+ hashtag: ps.hashtag,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
- visibleUserIds: ps.visibleUserIds ?? [],
- channelId: ps.channelId ?? undefined,
+ visibleUserIds: ps.visibleUserIds,
+ channelId: ps.channelId,
+ scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
+ isActuallyScheduled: ps.isActuallyScheduled,
}).catch((err) => {
if (err instanceof IdentifiableError) {
switch (err.id) {
@@ -285,6 +305,12 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.containsProhibitedWords);
case '4de0363a-3046-481b-9b0f-feff3e211025':
throw new ApiError(meta.errors.containsTooManyMentions);
+ case 'bacdf856-5c51-4159-b88a-804fa5103be5':
+ throw new ApiError(meta.errors.tooManyScheduledNotes);
+ case '94a89a43-3591-400a-9c17-dd166e71fdfa':
+ throw new ApiError(meta.errors.scheduledAtRequired);
+ case 'b34d0c1b-996f-4e34-a428-c636d98df457':
+ throw new ApiError(meta.errors.scheduledAtMustBeInFuture);
default:
throw err;
}
diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
index 1c73edf08e..7fa8004209 100644
--- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
@@ -91,6 +91,7 @@ export default class extends Endpoint { // eslint-
qb.orWhere(new Brackets(qb => {
qb.where('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}));
}
diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
index 2c8459525a..0a3602df20 100644
--- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -18,6 +18,8 @@ import { QueryService } from '@/core/QueryService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
+import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -46,7 +48,7 @@ export const meta = {
bothWithRepliesAndWithFiles: {
message: 'Specifying both withReplies and withFiles is not supported',
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
- id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f'
+ id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f',
},
},
} as const;
@@ -79,9 +81,6 @@ export default class extends Endpoint { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
- @Inject(DI.channelFollowingsRepository)
- private channelFollowingsRepository: ChannelFollowingsRepository,
-
private noteEntityService: NoteEntityService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
@@ -89,6 +88,8 @@ export default class extends Endpoint { // eslint-
private cacheService: CacheService,
private queryService: QueryService,
private userFollowingService: UserFollowingService,
+ private channelMutingService: ChannelMutingService,
+ private channelFollowingService: ChannelFollowingService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -196,11 +197,13 @@ export default class extends Endpoint { // eslint-
withReplies: boolean,
}, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
- const followingChannels = await this.channelFollowingsRepository.find({
- where: {
- followerId: me.id,
- },
- });
+
+ const mutingChannelIds = await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id));
+ const followingChannelIds = await this.channelFollowingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => {
@@ -219,9 +222,7 @@ export default class extends Endpoint { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
- if (followingChannels.length > 0) {
- const followingChannelIds = followingChannels.map(x => x.followeeId);
-
+ if (followingChannelIds.length > 0) {
query.andWhere(new Brackets(qb => {
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
qb.orWhere('note.channelId IS NULL');
@@ -230,6 +231,13 @@ export default class extends Endpoint { // eslint-
query.andWhere('note.channelId IS NULL');
}
+ if (mutingChannelIds.length > 0) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteChannelId IS NULL');
+ qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }));
+ }
+
if (!ps.withReplies) {
query.andWhere(new Brackets(qb => {
qb
diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
index ee61ab43da..ec9e52cf04 100644
--- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
@@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
import { QueryService } from '@/core/QueryService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -76,6 +77,7 @@ export default class extends Endpoint { // eslint-
private idService: IdService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private queryService: QueryService,
+ private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -157,7 +159,19 @@ export default class extends Endpoint { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
- if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+ if (me) {
+ this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+
+ const mutedChannelIds = await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id));
+ if (mutedChannelIds.length > 0) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteChannelId IS NULL')
+ .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
+ }));
+ }
+ }
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts
index 05ffdc1f97..e775bdb7fd 100644
--- a/packages/backend/src/server/api/endpoints/notes/mentions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts
@@ -66,7 +66,7 @@ export default class extends Endpoint { // eslint-
.orWhere(':meIdAsList <@ note.visibleUserIds');
}))
// Avoid scanning primary key index
- .orderBy('CONCAT(note.id)', 'DESC')
+ .orderBy('CONCAT(note.id)', (ps.sinceDate || ps.sinceId) ? 'ASC' : 'DESC')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts
index cae0e752da..a41de25ddf 100644
--- a/packages/backend/src/server/api/endpoints/notes/show.ts
+++ b/packages/backend/src/server/api/endpoints/notes/show.ts
@@ -29,10 +29,16 @@ export const meta = {
id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d',
},
- signinRequired: {
- message: 'Signin required.',
- code: 'SIGNIN_REQUIRED',
- id: '8e75455b-738c-471d-9f80-62693f33372e',
+ contentRestrictedByUser: {
+ message: 'Content restricted by user. Please sign in to view.',
+ code: 'CONTENT_RESTRICTED_BY_USER',
+ id: 'fbcc002d-37d9-4944-a6b0-d9e29f2d33ab',
+ },
+
+ contentRestrictedByServer: {
+ message: 'Content restricted by server settings. Please sign in to view.',
+ code: 'CONTENT_RESTRICTED_BY_SERVER',
+ id: '145f88d2-b03d-4087-8143-a78928883c4b',
},
},
} as const;
@@ -61,15 +67,15 @@ export default class extends Endpoint { // eslint-
});
if (note.user!.requireSigninToViewContents && me == null) {
- throw new ApiError(meta.errors.signinRequired);
+ throw new ApiError(meta.errors.contentRestrictedByUser);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
- throw new ApiError(meta.errors.signinRequired);
+ throw new ApiError(meta.errors.contentRestrictedByServer);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
- throw new ApiError(meta.errors.signinRequired);
+ throw new ApiError(meta.errors.contentRestrictedByServer);
}
return await this.noteEntityService.pack(note, me, {
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index 1f3631ae3d..fe9c412be4 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -5,7 +5,7 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js';
+import type { NotesRepository, MiMeta } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
+import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
export const meta = {
tags: ['notes'],
@@ -61,15 +63,14 @@ export default class extends Endpoint { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
- @Inject(DI.channelFollowingsRepository)
- private channelFollowingsRepository: ChannelFollowingsRepository,
-
private noteEntityService: NoteEntityService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private userFollowingService: UserFollowingService,
+ private channelMutingService: ChannelMutingService,
+ private channelFollowingService: ChannelFollowingService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -140,11 +141,13 @@ export default class extends Endpoint { // eslint-
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
- const followingChannels = await this.channelFollowingsRepository.find({
- where: {
- followerId: me.id,
- },
- });
+
+ const mutingChannelIds = await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id));
+ const followingChannelIds = await this.channelFollowingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
@@ -154,15 +157,14 @@ export default class extends Endpoint { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
- if (followees.length > 0 && followingChannels.length > 0) {
+ if (followees.length > 0 && followingChannelIds.length > 0) {
// ユーザー・チャンネルともにフォローあり
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
- const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where(new Brackets(qb2 => {
qb2
- .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
+ .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
.andWhere('note.channelId IS NULL');
}))
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
@@ -170,22 +172,32 @@ export default class extends Endpoint { // eslint-
} else if (followees.length > 0) {
// ユーザーフォローのみ(チャンネルフォローなし)
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
- query
- .andWhere('note.channelId IS NULL')
- .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
- } else if (followingChannels.length > 0) {
- // チャンネルフォローのみ(ユーザーフォローなし)
- const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
+ .andWhere('note.channelId IS NULL')
+ .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
+ if (mutingChannelIds.length > 0) {
+ qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }
+ }));
+ } else if (followingChannelIds.length > 0) {
+ // チャンネルフォローのみ(ユーザーフォローなし)
+ query.andWhere(new Brackets(qb => {
+ qb
+ // renoteChannelIdは見る必要が無い
+ // ・HTLに流れてくるチャンネル=フォローしているチャンネル
+ // ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ
+ // つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
.orWhere('note.userId = :meId', { meId: me.id });
}));
} else {
// フォローなし
- query
- .andWhere('note.channelId IS NULL')
- .andWhere('note.userId = :meId', { meId: me.id });
+ query.andWhere(new Brackets(qb => {
+ qb
+ .andWhere('note.channelId IS NULL')
+ .andWhere('note.userId = :meId', { meId: me.id });
+ }));
}
query.andWhere(new Brackets(qb => {
@@ -242,6 +254,7 @@ export default class extends Endpoint { // eslint-
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}));
}
diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts
index e9a6a36b02..cd7d46007c 100644
--- a/packages/backend/src/server/api/endpoints/notes/translate.ts
+++ b/packages/backend/src/server/api/endpoints/notes/translate.ts
@@ -95,7 +95,6 @@ export default class extends Endpoint { // eslint-
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
const params = new URLSearchParams();
- params.append('auth_key', this.serverSettings.deeplAuthKey);
params.append('text', note.text);
params.append('target_lang', targetLang);
@@ -104,6 +103,7 @@ export default class extends Endpoint { // eslint-
const res = await this.httpRequestService.send(endpoint, {
method: 'POST',
headers: {
+ 'Authorization': `DeepL-Auth-Key ${this.serverSettings.deeplAuthKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, */*',
},
diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
index 614cd9204d..c0c8653f7b 100644
--- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
import { QueryService } from '@/core/QueryService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -84,6 +85,7 @@ export default class extends Endpoint { // eslint-
private idService: IdService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private queryService: QueryService,
+ private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -187,6 +189,17 @@ export default class extends Endpoint { // eslint-
this.queryService.generateBaseNoteFilteringQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+ // -- ミュートされたチャンネルのリノート対策
+ const mutedChannelIds = await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id));
+ if (mutedChannelIds.length > 0) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteChannelId IS NULL')
+ .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
+ }));
+ }
+
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
@@ -223,6 +236,7 @@ export default class extends Endpoint { // eslint-
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}));
}
diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts
index 6de5fe3d44..96bc2a953a 100644
--- a/packages/backend/src/server/api/endpoints/pages/create.ts
+++ b/packages/backend/src/server/api/endpoints/pages/create.ts
@@ -5,12 +5,13 @@
import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
-import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
-import { IdService } from '@/core/IdService.js';
-import { MiPage, pageNameSchema } from '@/models/Page.js';
+import type { DriveFilesRepository, MiDriveFile, PagesRepository } from '@/models/_.js';
+import { pageNameSchema } from '@/models/Page.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { DI } from '@/di-symbols.js';
+import { PageService } from '@/core/PageService.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -77,11 +78,11 @@ export default class extends Endpoint { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
+ private pageService: PageService,
private pageEntityService: PageEntityService,
- private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
- let eyeCatchingImage = null;
+ let eyeCatchingImage: MiDriveFile | null = null;
if (ps.eyeCatchingImageId != null) {
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId,
@@ -102,24 +103,20 @@ export default class extends Endpoint { // eslint-
}
});
- const page = await this.pagesRepository.insertOne(new MiPage({
- id: this.idService.gen(),
- updatedAt: new Date(),
- title: ps.title,
- name: ps.name,
- summary: ps.summary,
- content: ps.content,
- variables: ps.variables,
- script: ps.script,
- eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
- userId: me.id,
- visibility: 'public',
- alignCenter: ps.alignCenter,
- hideTitleWhenPinned: ps.hideTitleWhenPinned,
- font: ps.font,
- }));
+ try {
+ const page = await this.pageService.create(me, {
+ ...ps,
+ eyeCatchingImage,
+ summary: ps.summary ?? null,
+ });
- return await this.pageEntityService.pack(page);
+ return await this.pageEntityService.pack(page);
+ } catch (err) {
+ if (err instanceof IdentifiableError && err.id === '1a79e38e-3d83-4423-845b-a9d83ff93b61') {
+ throw new ApiError(meta.errors.nameAlreadyExists);
+ }
+ throw err;
+ }
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts
index f2bc946788..a33868552d 100644
--- a/packages/backend/src/server/api/endpoints/pages/delete.ts
+++ b/packages/backend/src/server/api/endpoints/pages/delete.ts
@@ -4,12 +4,14 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import type { PagesRepository, UsersRepository } from '@/models/_.js';
+import type { MiDriveFile, PagesRepository, UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { PageService } from '@/core/PageService.js';
export const meta = {
tags: ['pages'],
@@ -44,36 +46,17 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.pagesRepository)
- private pagesRepository: PagesRepository,
-
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
-
- private moderationLogService: ModerationLogService,
- private roleService: RoleService,
+ private pageService: PageService,
) {
super(meta, paramDef, async (ps, me) => {
- const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
-
- if (page == null) {
- throw new ApiError(meta.errors.noSuchPage);
- }
-
- if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
- throw new ApiError(meta.errors.accessDenied);
- }
-
- await this.pagesRepository.delete(page.id);
-
- if (page.userId !== me.id) {
- const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
- this.moderationLogService.log(me, 'deletePage', {
- pageId: page.id,
- pageUserId: page.userId,
- pageUserUsername: user.username,
- page,
- });
+ try {
+ await this.pageService.delete(me, ps.pageId);
+ } catch (err) {
+ if (err instanceof IdentifiableError) {
+ if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage);
+ if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied);
+ }
+ throw err;
}
});
}
diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts
index a6aeb6002e..6fa5c1d75c 100644
--- a/packages/backend/src/server/api/endpoints/pages/update.ts
+++ b/packages/backend/src/server/api/endpoints/pages/update.ts
@@ -4,13 +4,14 @@
*/
import ms from 'ms';
-import { Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
+import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { pageNameSchema } from '@/models/Page.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { PageService } from '@/core/PageService.js';
export const meta = {
tags: ['pages'],
@@ -75,57 +76,37 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.pagesRepository)
- private pagesRepository: PagesRepository,
-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
+
+ private pageService: PageService,
) {
super(meta, paramDef, async (ps, me) => {
- const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
- if (page == null) {
- throw new ApiError(meta.errors.noSuchPage);
- }
- if (page.userId !== me.id) {
- throw new ApiError(meta.errors.accessDenied);
- }
+ try {
+ let eyeCatchingImage: MiDriveFile | null | undefined | string = ps.eyeCatchingImageId;
+ if (eyeCatchingImage != null) {
+ eyeCatchingImage = await this.driveFilesRepository.findOneBy({
+ id: eyeCatchingImage,
+ userId: me.id,
+ });
- if (ps.eyeCatchingImageId != null) {
- const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
- id: ps.eyeCatchingImageId,
- userId: me.id,
- });
-
- if (eyeCatchingImage == null) {
- throw new ApiError(meta.errors.noSuchFile);
- }
- }
-
- if (ps.name != null) {
- await this.pagesRepository.findBy({
- id: Not(ps.pageId),
- userId: me.id,
- name: ps.name,
- }).then(result => {
- if (result.length > 0) {
- throw new ApiError(meta.errors.nameAlreadyExists);
+ if (eyeCatchingImage == null) {
+ throw new ApiError(meta.errors.noSuchFile);
}
- });
- }
+ }
- await this.pagesRepository.update(page.id, {
- updatedAt: new Date(),
- title: ps.title,
- name: ps.name,
- summary: ps.summary === undefined ? page.summary : ps.summary,
- content: ps.content,
- variables: ps.variables,
- script: ps.script,
- alignCenter: ps.alignCenter,
- hideTitleWhenPinned: ps.hideTitleWhenPinned,
- font: ps.font,
- eyeCatchingImageId: ps.eyeCatchingImageId,
- });
+ await this.pageService.update(me, ps.pageId, {
+ ...ps,
+ eyeCatchingImage,
+ });
+ } catch (err) {
+ if (err instanceof IdentifiableError) {
+ if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage);
+ if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied);
+ if (err.id === 'd05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4') throw new ApiError(meta.errors.nameAlreadyExists);
+ }
+ throw err;
+ }
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
index e8a760e9f8..4515c016a8 100644
--- a/packages/backend/src/server/api/endpoints/roles/notes.ts
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
+import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, RolesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
@@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -68,6 +70,7 @@ export default class extends Endpoint { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService,
+ private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -101,6 +104,21 @@ export default class extends Endpoint { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
+ // -- ミュートされたチャンネル対策
+ const mutingChannelIds = await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id));
+ if (mutingChannelIds.length > 0) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.channelId IS NULL');
+ qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }));
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteChannelId IS NULL');
+ qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }));
+ }
+
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts
index 8756801fe4..c6d477a92f 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/show.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts
@@ -22,7 +22,26 @@ export const meta = {
res: {
type: 'object',
optional: false, nullable: false,
- ref: 'UserList',
+ allOf: [
+ {
+ type: 'object',
+ ref: 'UserList',
+ },
+ {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ likedCount: {
+ type: 'number',
+ optional: true, nullable: false,
+ },
+ isLiked: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
+ },
+ },
+ ],
},
errors: {
diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts
index 5832790a61..b9710250cf 100644
--- a/packages/backend/src/server/api/endpoints/users/notes.ts
+++ b/packages/backend/src/server/api/endpoints/users/notes.ts
@@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
import { ApiError } from '@/server/api/error.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
export const meta = {
tags: ['users', 'notes'],
@@ -77,12 +78,12 @@ export default class extends Endpoint { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private cacheService: CacheService,
private idService: IdService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
+ private channelMutingService: ChannelMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -165,6 +166,11 @@ export default class extends Endpoint { // eslint-
withFiles: boolean,
withRenotes: boolean,
}, me: MiLocalUser | null) {
+ const mutingChannelIds = me
+ ? await this.channelMutingService
+ .list({ requestUserId: me.id }, { idOnly: true })
+ .then(x => x.map(x => x.id))
+ : [];
const isSelf = me && (me.id === ps.userId);
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
@@ -177,14 +183,30 @@ export default class extends Endpoint { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
if (ps.withChannelNotes) {
- if (!isSelf) query.andWhere(new Brackets(qb => {
- qb.orWhere('note.channelId IS NULL');
- qb.orWhere('channel.isSensitive = false');
+ query.andWhere(new Brackets(qb => {
+ if (mutingChannelIds.length > 0) {
+ qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds });
+ }
+
+ if (!isSelf) {
+ qb.andWhere(new Brackets(qb2 => {
+ qb2.orWhere('note.channelId IS NULL');
+ qb2.orWhere('channel.isSensitive = false');
+ }));
+ }
}));
} else {
query.andWhere('note.channelId IS NULL');
}
+ // -- ミュートされたチャンネルのリノート対策
+ if (mutingChannelIds.length > 0) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteChannelId IS NULL');
+ qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
+ }));
+ }
+
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me, {
excludeAuthor: true,
diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts
index d6f1ecd8ed..d84a191f7a 100644
--- a/packages/backend/src/server/api/endpoints/users/reactions.ts
+++ b/packages/backend/src/server/api/endpoints/users/reactions.ts
@@ -28,7 +28,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
- ref: 'NoteReaction',
+ ref: 'NoteReactionWithNote',
},
},
@@ -120,7 +120,7 @@ export default class extends Endpoint { // eslint-
return true;
});
- return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
+ return await this.noteReactionEntityService.packManyWithNote(reactions, me);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/verify-email.ts b/packages/backend/src/server/api/endpoints/verify-email.ts
new file mode 100644
index 0000000000..e069ed59f2
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/verify-email.ts
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UserProfilesRepository } from '@/models/_.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { ApiError } from '../error.js';
+
+export const meta = {
+ requireCredential: false,
+
+ tags: ['account'],
+
+ errors: {
+ noSuchCode: {
+ message: 'No such code.',
+ code: 'NO_SUCH_CODE',
+ id: '97c1f576-e4b8-4b8a-a6dc-9cb65e7f6f85',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ code: { type: 'string' },
+ },
+ required: ['code'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.userProfilesRepository)
+ private userProfilesRepository: UserProfilesRepository,
+
+ private userEntityService: UserEntityService,
+ private globalEventService: GlobalEventService,
+ ) {
+ super(meta, paramDef, async (ps) => {
+ const profile = await this.userProfilesRepository.findOneBy({
+ emailVerifyCode: ps.code,
+ });
+
+ if (profile == null) {
+ throw new ApiError(meta.errors.noSuchCode);
+ }
+
+ await this.userProfilesRepository.update({ userId: profile.userId }, {
+ emailVerified: true,
+ emailVerifyCode: null,
+ });
+
+ this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, {
+ schema: 'MeDetailed',
+ includeSecrets: true,
+ }));
+ });
+ }
+}
+
diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts
index 8e28ab263b..222086c960 100644
--- a/packages/backend/src/server/api/stream/Connection.ts
+++ b/packages/backend/src/server/api/stream/Connection.ts
@@ -11,8 +11,9 @@ import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js';
-import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
+import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
+import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import type { ChannelsService } from './ChannelsService.js';
@@ -35,6 +36,7 @@ export default class Connection {
public userProfile: MiUserProfile | null = null;
public following: Record | undefined> = {};
public followingChannels: Set = new Set();
+ public mutingChannels: Set = new Set();
public userIdsWhoMeMuting: Set = new Set();
public userIdsWhoBlockingMe: Set = new Set();
public userIdsWhoMeMutingRenotes: Set