merge upstream again

This commit is contained in:
Hazelnoot 2025-04-24 14:23:45 -04:00
commit a4dd19fdd4
167 changed files with 6779 additions and 3952 deletions

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class CompositeNoteIndex1745378064470 {
name = 'CompositeNoteIndex1745378064470';
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`);
// Flush all cached Linear Scan Plans and redo statistics for composite index
// this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly
await queryRunner.query(`ANALYZE "user", "note"`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
}
}

View file

@ -37,17 +37,17 @@
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.11.11",
"@swc/core-darwin-x64": "1.11.11",
"@swc/core-darwin-arm64": "1.11.18",
"@swc/core-darwin-x64": "1.11.18",
"@swc/core-freebsd-x64": "1.3.11",
"@swc/core-linux-arm-gnueabihf": "1.11.11",
"@swc/core-linux-arm64-gnu": "1.11.11",
"@swc/core-linux-arm64-musl": "1.11.11",
"@swc/core-linux-x64-gnu": "1.11.11",
"@swc/core-linux-x64-musl": "1.11.11",
"@swc/core-win32-arm64-msvc": "1.11.11",
"@swc/core-win32-ia32-msvc": "1.11.11",
"@swc/core-win32-x64-msvc": "1.11.11",
"@swc/core-linux-arm-gnueabihf": "1.11.18",
"@swc/core-linux-arm64-gnu": "1.11.18",
"@swc/core-linux-arm64-musl": "1.11.18",
"@swc/core-linux-x64-gnu": "1.11.18",
"@swc/core-linux-x64-musl": "1.11.18",
"@swc/core-win32-arm64-msvc": "1.11.18",
"@swc/core-win32-ia32-msvc": "1.11.18",
"@swc/core-win32-x64-msvc": "1.11.18",
"bufferutil": "4.0.9",
"slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10",
@ -65,8 +65,8 @@
"utf-8-validate": "6.0.5"
},
"dependencies": {
"@aws-sdk/client-s3": "3.772.0",
"@aws-sdk/lib-storage": "3.772.0",
"@aws-sdk/client-s3": "3.782.0",
"@aws-sdk/lib-storage": "3.782.0",
"@discordapp/twemoji": "15.1.0",
"@fastify/accepts": "5.0.2",
"@fastify/cookie": "11.0.2",
@ -76,11 +76,11 @@
"@fastify/multipart": "9.0.3",
"@fastify/static": "8.1.1",
"@fastify/view": "10.0.2",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/sharp-read-bmp": "1.3.0",
"@misskey-dev/summaly": "5.2.0",
"@nestjs/common": "11.0.12",
"@nestjs/core": "11.0.12",
"@nestjs/testing": "11.0.12",
"@nestjs/common": "11.0.16",
"@nestjs/core": "11.0.15",
"@nestjs/testing": "11.0.15",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "8.55.0",
"@sentry/profiling-node": "8.55.0",
@ -88,9 +88,10 @@
"@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.6.0",
"@swc/core": "1.11.11",
"@swc/core": "1.11.18",
"@transfem-org/sfm-js": "0.24.6",
"@twemoji/parser": "15.1.1",
"@types/redis-info": "3.0.3",
"@types/psl": "^1.1.3",
"accepts": "1.3.8",
"ajv": "8.17.1",
@ -100,7 +101,7 @@
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.3",
"bullmq": "5.44.1",
"bullmq": "5.48.1",
"cacheable-lookup": "7.0.0",
"canvas": "^3.1.0",
"cbor": "9.0.2",
@ -113,7 +114,7 @@
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fast-xml-parser": "4.4.1",
"fastify": "5.2.1",
"fastify": "5.3.2",
"fastify-multer": "^2.0.3",
"fastify-raw-body": "5.0.0",
"feed": "4.2.2",
@ -121,7 +122,7 @@
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.2",
"glob": "11.0.0",
"got": "14.4.6",
"got": "14.4.7",
"happy-dom": "16.8.1",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
@ -152,7 +153,7 @@
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.3.6",
"otpauth": "9.4.0",
"parse5": "7.2.1",
"pg": "8.14.1",
"pkce-challenge": "4.1.0",
@ -165,6 +166,7 @@
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.21.4",
"redis-info": "3.1.0",
"redis-lock": "0.1.4",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
@ -172,17 +174,17 @@
"rxjs": "7.8.2",
"sanitize-html": "2.15.0",
"secure-json-parse": "3.0.2",
"sharp": "0.33.5",
"sharp": "0.34.1",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.25.11",
"tinycolor2": "1.6.0",
"tmp": "0.2.3",
"tsc-alias": "1.8.11",
"tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0",
"typeorm": "0.3.21",
"typescript": "5.8.2",
"typeorm": "0.3.22",
"typescript": "5.8.3",
"ulid": "2.4.0",
"uuid": "^9.0.1",
"vary": "1.1.2",
@ -193,7 +195,7 @@
"devDependencies": {
"@jest/globals": "29.7.0",
"@nestjs/platform-express": "10.4.15",
"@sentry/vue": "9.8.0",
"@sentry/vue": "9.12.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.37",
"@types/accepts": "1.3.7",
@ -212,7 +214,7 @@
"@types/jsrsasign": "10.5.15",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/node": "22.13.10",
"@types/node": "22.14.0",
"@types/nodemailer": "6.4.17",
"@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5",
@ -224,8 +226,8 @@
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.13.0",
"@types/semver": "7.5.8",
"@types/sanitize-html": "2.15.0",
"@types/semver": "7.7.0",
"@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5",
"@types/tinycolor2": "1.4.6",
@ -233,9 +235,9 @@
"@types/uuid": "^9.0.4",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.0",
"@typescript-eslint/eslint-plugin": "8.27.0",
"@typescript-eslint/parser": "8.27.0",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0",

View file

@ -25,6 +25,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { RoleService } from '@/core/RoleService.js';
import { AntennaService } from '@/core/AntennaService.js';
@Injectable()
export class AccountMoveService {
@ -66,6 +67,7 @@ export class AccountMoveService {
private queueService: QueueService,
private systemAccountService: SystemAccountService,
private roleService: RoleService,
private antennaService: AntennaService,
) {
}
@ -127,6 +129,7 @@ export class AccountMoveService {
this.deleteScheduledNotes(src),
this.copyRoles(src, dst),
this.updateLists(src, dst),
this.antennaService.onMoveAccount(src, dst),
]);
} catch {
/* skip if any error happens */

View file

@ -5,18 +5,20 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { In } from 'typeorm';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
import type { MiAntenna } from '@/models/Antenna.js';
import type { MiNote } from '@/models/Note.js';
import type { MiUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { CacheService } from './CacheService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@ -37,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private cacheService: CacheService,
private utilityService: UtilityService,
private globalEventService: GlobalEventService,
private fanoutTimelineService: FanoutTimelineService,
@ -111,9 +114,6 @@ export class AntennaService implements OnApplicationShutdown {
@bindThis
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false;
if (antenna.excludeBots && noteUser.isBot) return false;
@ -122,6 +122,18 @@ export class AntennaService implements OnApplicationShutdown {
if (!antenna.withReplies && note.replyId != null) return false;
if (note.visibility === 'specified') {
if (note.userId !== antenna.userId) {
if (note.visibleUserIds == null) return false;
if (!note.visibleUserIds.includes(antenna.userId)) return false;
}
}
if (note.visibility === 'followers') {
const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId);
if (!isFollowing && antenna.userId !== note.userId) return false;
}
if (antenna.src === 'home') {
// TODO
} else if (antenna.src === 'list') {
@ -212,6 +224,41 @@ export class AntennaService implements OnApplicationShutdown {
return this.antennas;
}
@bindThis
public async onMoveAccount(src: MiUser, dst: MiUser): Promise<void> {
// There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it.
// Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list
const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase();
const antennasToMigrate = (await this.getAntennas()).filter(antenna => {
return antenna.users.some(user => {
const { username, host } = Acct.parse(user);
return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct;
});
});
if (antennasToMigrate.length === 0) return;
const antennaIds = antennasToMigrate.map(x => x.id);
// Update the antennas by appending dst users acct to the users list
const dstUserAcct = '@' + Acct.toString({ username: dst.username, host: dst.host });
await this.antennasRepository.createQueryBuilder('antenna')
.update()
.set({
users: () => 'array_append(antenna.users, :dstUserAcct)',
})
.where('antenna.id IN (:...antennaIds)', { antennaIds })
.setParameters({ dstUserAcct })
.execute();
// announce update to event
for (const newAntenna of await this.antennasRepository.findBy({ id: In(antennaIds) })) {
this.globalEventService.publishInternalEvent('antennaUpdated', newAntenna);
}
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onRedisMessage);

View file

@ -232,7 +232,7 @@ export class ChatService {
const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
//this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
}, 3000);
}
@ -302,7 +302,7 @@ export class ChatService {
if (marker == null) continue;
this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
//this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
}
}, 3000);

View file

@ -55,7 +55,7 @@ export class FanoutTimelineEndpointService {
}
@bindThis
private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);

View file

@ -6,7 +6,7 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
import { Window } from 'happy-dom';
import { type Document, type HTMLParagraphElement, Window } from 'happy-dom';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
@ -23,6 +23,8 @@ type ChildNode = DefaultTreeAdapterMap['childNode'];
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
@Injectable()
export class MfmService {
constructor(
@ -343,7 +345,7 @@ export class MfmService {
}
@bindThis
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
if (nodes == null) {
return null;
}
@ -576,6 +578,10 @@ export class MfmService {
appendChildren(nodes, body);
for (const additionalAppender of additionalAppenders) {
additionalAppender(doc, body);
}
const serialized = body.outerHTML;
happyDOM.close().catch(err => {});

View file

@ -640,7 +640,14 @@ export class NoteCreateService implements OnApplicationShutdown {
}, {
jobId: `pollEnd:${note.id}`,
delay,
removeOnComplete: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}

View file

@ -22,6 +22,7 @@ type PushNotificationsTypes = {
note: Packed<'Note'>;
};
'readAllNotifications': undefined;
newChatMessage: Packed<'ChatMessage'>;
};
// Reduce length because push message servers have character limits

View file

@ -5,6 +5,8 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { MetricsTime, type JobType } from 'bullmq';
import { parse as parseRedisInfo } from 'redis-info';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
@ -40,6 +42,18 @@ import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
import { MiNote } from '@/models/Note.js';
export const QUEUE_TYPES = [
'system',
'endedPollNotification',
'deliver',
'inbox',
'db',
'relationship',
'objectStorage',
'userWebhookDeliver',
'systemWebhookDeliver',
] as const;
@Injectable()
export class QueueService {
constructor(
@ -60,50 +74,58 @@ export class QueueService {
this.systemQueue.add('tickCharts', {
}, {
repeat: { pattern: '55 * * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('resyncCharts', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('cleanCharts', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('aggregateRetention', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('clean', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('checkExpiredMutings', {
}, {
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('bakeBufferedReactions', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
removeOnComplete: true,
removeOnComplete: 10,
removeOnFail: 30,
});
}
@ -125,13 +147,21 @@ export class QueueService {
isSharedInbox,
};
return this.deliverQueue.add(to, data, {
const label = to.replace('https://', '').replace('/inbox', '');
return this.deliverQueue.add(label, data, {
attempts: this.config.deliverJobMaxAttempts ?? 12,
backoff: {
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -153,12 +183,18 @@ export class QueueService {
backoff: {
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
};
await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({
name: d[0],
name: d[0].replace('https://', '').replace('/inbox', ''),
data: {
user,
content: contentBody,
@ -179,13 +215,21 @@ export class QueueService {
signature,
};
return this.inboxQueue.add('', data, {
const label = (activity.id ?? '').replace('https://', '').replace('/activity', '');
return this.inboxQueue.add(label, data, {
attempts: this.config.inboxJobMaxAttempts ?? 8,
backoff: {
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -194,8 +238,14 @@ export class QueueService {
return this.dbQueue.add('deleteDriveFiles', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -204,8 +254,14 @@ export class QueueService {
return this.dbQueue.add('exportCustomEmojis', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -224,8 +280,14 @@ export class QueueService {
return this.dbQueue.add('exportNotes', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -234,8 +296,14 @@ export class QueueService {
return this.dbQueue.add('exportClips', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -244,8 +312,14 @@ export class QueueService {
return this.dbQueue.add('exportFavorites', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -256,8 +330,14 @@ export class QueueService {
excludeMuting,
excludeInactive,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -266,8 +346,14 @@ export class QueueService {
return this.dbQueue.add('exportMuting', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -276,8 +362,14 @@ export class QueueService {
return this.dbQueue.add('exportBlocking', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -286,8 +378,14 @@ export class QueueService {
return this.dbQueue.add('exportUserLists', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -296,8 +394,14 @@ export class QueueService {
return this.dbQueue.add('exportAntennas', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -308,8 +412,14 @@ export class QueueService {
fileId: fileId,
withReplies,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -373,8 +483,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -384,8 +500,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -405,8 +527,14 @@ export class QueueService {
name,
data,
opts: {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
},
};
}
@ -417,8 +545,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -428,8 +562,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -439,8 +579,14 @@ export class QueueService {
user: { id: user.id },
antenna,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -450,8 +596,14 @@ export class QueueService {
user: { id: user.id },
soft: opts.soft,
}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -501,8 +653,14 @@ export class QueueService {
withReplies: data.withReplies,
},
opts: {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
...opts,
},
};
@ -513,16 +671,28 @@ export class QueueService {
return this.objectStorageQueue.add('deleteFile', {
key: key,
}, {
removeOnComplete: true,
removeOnFail: true,
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 createCleanRemoteFilesJob() {
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -553,8 +723,14 @@ export class QueueService {
backoff: {
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@ -584,21 +760,201 @@ export class QueueService {
backoff: {
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
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 destroy() {
this.deliverQueue.once('cleaned', (jobs, status) => {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.deliverQueue.clean(0, 0, 'delayed');
private getQueue(type: typeof QUEUE_TYPES[number]): Bull.Queue {
switch (type) {
case 'system': return this.systemQueue;
case 'endedPollNotification': return this.endedPollNotificationQueue;
case 'deliver': return this.deliverQueue;
case 'inbox': return this.inboxQueue;
case 'db': return this.dbQueue;
case 'relationship': return this.relationshipQueue;
case 'objectStorage': return this.objectStorageQueue;
case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
default: throw new Error(`Unrecognized queue type: ${type}`);
}
}
this.inboxQueue.once('cleaned', (jobs, status) => {
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
@bindThis
public async queueClear(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') {
const queue = this.getQueue(queueType);
if (state === '*') {
await Promise.all([
queue.clean(0, 0, 'completed'),
queue.clean(0, 0, 'wait'),
queue.clean(0, 0, 'active'),
queue.clean(0, 0, 'paused'),
queue.clean(0, 0, 'prioritized'),
queue.clean(0, 0, 'delayed'),
queue.clean(0, 0, 'failed'),
]);
} else {
await queue.clean(0, 0, state);
}
}
@bindThis
public async queuePromoteJobs(queueType: typeof QUEUE_TYPES[number]) {
const queue = this.getQueue(queueType);
await queue.promoteJobs();
}
@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) {
if (job.finishedOn != null) {
await job.retry();
} else {
await job.promote();
}
}
}
@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) {
await job.remove();
}
}
@bindThis
private packJobData(job: Bull.Job) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : [];
stacktrace.reverse();
return {
id: job.id,
name: job.name,
data: job.data,
opts: job.opts,
timestamp: job.timestamp,
processedOn: job.processedOn,
processedBy: job.processedBy,
finishedOn: job.finishedOn,
progress: job.progress,
attempts: job.attemptsMade,
delay: job.delay,
failedReason: job.failedReason,
stacktrace: stacktrace,
returnValue: job.returnvalue,
isFailed: !!job.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0),
};
}
@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) {
return this.packJobData(job);
} else {
throw new Error(`Job not found: ${jobId}`);
}
}
@bindThis
public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) {
const RETURN_LIMIT = 100;
const queue = this.getQueue(queueType);
let jobs: Bull.Job[];
if (search) {
jobs = await queue.getJobs(jobTypes, 0, 1000);
jobs = jobs.filter(job => {
const jobString = JSON.stringify(job).toLowerCase();
return search.toLowerCase().split(' ').every(term => {
return jobString.includes(term);
});
});
jobs = jobs.slice(0, RETURN_LIMIT);
} else {
jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT);
}
return jobs.map(job => this.packJobData(job));
}
@bindThis
public async queueGetQueues() {
const fetchings = QUEUE_TYPES.map(async type => {
const queue = this.getQueue(type);
const counts = await queue.getJobCounts();
const isPaused = await queue.isPaused();
const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
return {
name: type,
counts: counts,
isPaused,
metrics: {
completed: metrics_completed,
failed: metrics_failed,
},
};
});
this.inboxQueue.clean(0, 0, 'delayed');
return await Promise.all(fetchings);
}
@bindThis
public async queueGetQueue(queueType: typeof QUEUE_TYPES[number]) {
const queue = this.getQueue(queueType);
const counts = await queue.getJobCounts();
const isPaused = await queue.isPaused();
const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
const db = parseRedisInfo(await (await queue.client).info());
return {
name: queueType,
qualifiedName: queue.qualifiedName,
counts: counts,
isPaused,
metrics: {
completed: metrics_completed,
failed: metrics_failed,
},
db: {
version: db.redis_version,
mode: db.redis_mode,
runId: db.run_id,
processId: db.process_id,
port: parseInt(db.tcp_port),
os: db.os,
uptime: parseInt(db.uptime_in_seconds),
memory: {
total: parseInt(db.total_system_memory) || parseInt(db.maxmemory),
used: parseInt(db.used_memory),
fragmentationRatio: parseInt(db.mem_fragmentation_ratio),
peak: parseInt(db.used_memory_peak),
},
clients: {
connected: parseInt(db.connected_clients),
blocked: parseInt(db.blocked_clients),
},
},
};
}
}

View file

@ -5,11 +5,14 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { OnApplicationShutdown } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm';
import * as Redis from 'ioredis';
import bcrypt from 'bcryptjs';
import { MiLocalUser, MiUser } from '@/models/User.js';
import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js';
import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@ -20,10 +23,13 @@ import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const;
@Injectable()
export class SystemAccountService {
export class SystemAccountService implements OnApplicationShutdown {
private cache: MemoryKVCache<MiLocalUser>;
constructor(
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.db)
private db: DataSource,
@ -42,6 +48,31 @@ export class SystemAccountService {
private idService: IdService,
) {
this.cache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 10); // 10m
this.redisForSub.on('message', this.onMessage);
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'metaUpdated': {
if (body.before != null && body.before.name !== body.after.name) {
for (const account of SYSTEM_ACCOUNT_TYPES) {
await this.updateCorrespondingUserProfile(account, {
name: body.after.name,
});
}
}
break;
}
default:
break;
}
}
}
@bindThis
@ -151,7 +182,7 @@ export class SystemAccountService {
@bindThis
public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: {
name?: string;
name?: string | null;
description?: MiUserProfile['description'];
}): Promise<MiLocalUser> {
const user = await this.fetch(type);
@ -187,4 +218,15 @@ export class SystemAccountService {
public async getProxyActor() {
return await this.fetch('proxy');
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.cache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string): void {
this.dispose();
}
}

View file

@ -431,8 +431,8 @@ export class WebhookTestService {
name: user.name,
username: user.username,
host: user.host,
avatarUrl: user.avatarUrl,
avatarBlurhash: user.avatarBlurhash,
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({
id: it.id,
angle: it.angle,
@ -464,10 +464,10 @@ export class WebhookTestService {
createdAt: new Date().toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null,
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
bannerUrl: user.bannerUrl,
bannerBlurhash: user.bannerBlurhash,
backgroundUrl: user.backgroundUrl,
backgroundBlurhash: user.backgroundBlurhash,
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl,
backgroundBlurhash: user.backgroundId == null ? null : user.backgroundBlurhash,
listenbrainz: null,
isLocked: user.isLocked,
isSilenced: false,

View file

@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common';
import * as mfm from '@transfem-org/sfm-js';
import { MfmService } from '@/core/MfmService.js';
import { MfmService, Appender } 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<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) {
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) {
let noMisskeyContent = false;
const srcMfm = (note.text ?? '') + (apAppend ?? '');
const srcMfm = (note.text ?? '');
const parsed = mfm.parse(srcMfm);
if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
noMisskeyContent = true;
}
const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []);
const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : [], additionalAppender);
return {
content,

View file

@ -20,7 +20,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 } from '@/core/MfmService.js';
import { MfmService, type Appender } 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';
@ -468,10 +468,24 @@ export class ApRendererService {
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
}
let apAppend = '';
const apAppend: Appender[] = [];
if (quote) {
apAppend += `\n\nRE: ${quote}`;
// Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
// the claas 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);
});
}
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;

View file

@ -584,8 +584,8 @@ export class UserEntityService implements OnModuleInit {
name: user.name,
username: user.username,
host: user.host,
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
avatarBlurhash: user.avatarBlurhash,
avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user),
avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash),
description: mastoapi ? mastoapi.description : profile ? profile.description : '',
createdAt: this.idService.parse(user.id).date.toISOString(),
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
@ -643,10 +643,10 @@ export class UserEntityService implements OnModuleInit {
: null,
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
bannerUrl: user.bannerUrl,
bannerBlurhash: user.bannerBlurhash,
backgroundUrl: user.backgroundUrl,
backgroundBlurhash: user.backgroundBlurhash,
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
backgroundUrl: user.backgroundUrl == null ? null : user.backgroundUrl,
backgroundBlurhash: user.backgroundBlurhash == null ? null : user.backgroundBlurhash,
isLocked: user.isLocked,
isSuspended: user.isSuspended,
location: profile!.location,

View file

@ -10,6 +10,7 @@ import { MiUser } from './User.js';
import { MiChannel } from './Channel.js';
import type { MiDriveFile } from './DriveFile.js';
@Index(['userId', 'id'])
@Entity('note')
export class MiNote {
@PrimaryColumn(id())
@ -71,7 +72,6 @@ export class MiNote {
})
public cw: string | null;
@Index()
@Column({
...id(),
comment: 'The ID of author.',

View file

@ -132,11 +132,13 @@ export class MiUser {
@JoinColumn()
public background: MiDriveFile | null;
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
@Column('varchar', {
length: 512, nullable: true,
})
public avatarUrl: string | null;
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
@Column('varchar', {
length: 512, nullable: true,
})
@ -147,11 +149,13 @@ export class MiUser {
})
public backgroundUrl: string | null;
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
@Column('varchar', {
length: 128, nullable: true,
})
public avatarBlurhash: string | null;
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
@Column('varchar', {
length: 128, nullable: true,
})

View file

@ -3,30 +3,48 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
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 { SkLatestNote } from '@/models/LatestNote.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.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';
import { MiAd } from '@/models/Ad.js';
import { MiAnnouncement } from '@/models/Announcement.js';
import { MiAnnouncementRead } from '@/models/AnnouncementRead.js';
import { MiAntenna } from '@/models/Antenna.js';
import { MiApp } from '@/models/App.js';
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
import { MiAuthSession } from '@/models/AuthSession.js';
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
import { MiBlocking } from '@/models/Blocking.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
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 { MiChatApproval } from '@/models/ChatApproval.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiClip } from '@/models/Clip.js';
import { MiClipNote } from '@/models/ClipNote.js';
import { MiClipFavorite } from '@/models/ClipFavorite.js';
import { MiClipNote } from '@/models/ClipNote.js';
import { MiDriveFile } from '@/models/DriveFile.js';
import { MiDriveFolder } from '@/models/DriveFolder.js';
import { MiEmoji } from '@/models/Emoji.js';
import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiFollowing } from '@/models/Following.js';
import { MiFollowRequest } from '@/models/FollowRequest.js';
import { MiGalleryLike } from '@/models/GalleryLike.js';
@ -36,10 +54,10 @@ import { MiInstance } from '@/models/Instance.js';
import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
import { MiPage } from '@/models/Page.js';
import { MiPageLike } from '@/models/PageLike.js';
@ -51,47 +69,43 @@ import { MiPromoRead } from '@/models/PromoRead.js';
import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
import { MiRegistryItem } from '@/models/RegistryItem.js';
import { MiRelay } from '@/models/Relay.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiRole } from '@/models/Role.js';
import { MiRoleAssignment } from '@/models/RoleAssignment.js';
import { MiSignin } from '@/models/Signin.js';
import { MiSwSubscription } from '@/models/SwSubscription.js';
import { MiSystemAccount } from '@/models/SystemAccount.js';
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
import { MiUsedUsername } from '@/models/UsedUsername.js';
import { MiUser } from '@/models/User.js';
import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiUserListMembership } from '@/models/UserListMembership.js';
import { MiUserMemo } from '@/models/UserMemo.js';
import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiUserMemo } from '@/models/UserMemo.js';
import { MiWebhook } from '@/models/Webhook.js';
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
import { MiChannel } from '@/models/Channel.js';
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
import { MiRole } from '@/models/Role.js';
import { MiRoleAssignment } from '@/models/RoleAssignment.js';
import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
import { NoteEdit } from '@/models/NoteEdit.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
import { SkApInboxLog } from '@/models/SkApInboxLog.js';
import { SkApFetchLog } from '@/models/SkApFetchLog.js';
import { SkApContext } from '@/models/SkApContext.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
import { SkLatestNote } from '@/models/LatestNote.js';
export interface MiRepository<T extends ObjectLiteral> {
createTableColumnNames(this: Repository<T> & MiRepository<T>): string[];
insertOne(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>): Promise<T>;
insertOneImpl(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>, queryRunner?: QueryRunner): Promise<T>;
selectAliasColumnNames(this: Repository<T> & MiRepository<T>, queryBuilder: InsertQueryBuilder<T>, builder: SelectQueryBuilder<T>): void;
}
@ -100,6 +114,21 @@ export const miRepository = {
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!;
@ -107,7 +136,9 @@ export const miRepository = {
mainAlias.name = 't';
const columnNames = this.createTableColumnNames();
queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2));
const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames });
// ---- 共通テーブル式(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);
@ -216,7 +247,9 @@ export {
};
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
export type AbuseReportNotificationRecipientRepository = Repository<MiAbuseReportNotificationRecipient> & MiRepository<MiAbuseReportNotificationRecipient>;
export type AbuseReportNotificationRecipientRepository =
Repository<MiAbuseReportNotificationRecipient>
& MiRepository<MiAbuseReportNotificationRecipient>;
export type AccessTokensRepository = Repository<MiAccessToken> & MiRepository<MiAccessToken>;
export type AdsRepository = Repository<MiAd> & MiRepository<MiAd>;
export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>;

View file

@ -5,7 +5,7 @@
// https://github.com/typeorm/typeorm/issues/2400
import pg from 'pg';
import { DataSource, Logger } from 'typeorm';
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';
@ -102,6 +102,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
export type LoggerProps = {
disableQueryTruncation?: boolean;
enableQueryParamLogging?: boolean;
printReplicationMode?: boolean,
};
function highlightSql(sql: string) {
@ -127,8 +128,10 @@ class MyCustomLogger implements Logger {
}
@bindThis
private transformQueryLog(sql: string) {
let modded = sql;
private transformQueryLog(sql: string, opts?: {
prefix?: string;
}) {
let modded = opts?.prefix ? opts.prefix + sql : sql;
if (!this.props.disableQueryTruncation) {
modded = truncateSql(modded);
}
@ -146,18 +149,27 @@ class MyCustomLogger implements Logger {
}
@bindThis
public logQuery(query: string, parameters?: any[]) {
sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters));
public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] `
: undefined;
sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
}
@bindThis
public logQueryError(error: string, query: string, parameters?: any[]) {
sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters));
public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) {
const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] `
: undefined;
sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
}
@bindThis
public logQuerySlow(time: number, query: string, parameters?: any[]) {
sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters));
public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) {
const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] `
: undefined;
sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
}
@bindThis
@ -306,6 +318,7 @@ export function createPostgresDataSource(config: Config) {
? new MyCustomLogger({
disableQueryTruncation: config.logging?.sql?.disableQueryTruncation,
enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging,
printReplicationMode: !!config.dbReplications,
})
: undefined,
maxQueryExecutionTime: 300,

View file

@ -47,7 +47,7 @@ import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
import { QUEUE, baseQueueOptions } from './const.js';
import { QUEUE, baseWorkerOptions } from './const.js';
import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js';
// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
@ -186,7 +186,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return processer(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.SYSTEM),
...baseWorkerOptions(this.config, QUEUE.SYSTEM),
autorun: false,
});
@ -251,7 +251,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return processer(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.DB),
...baseWorkerOptions(this.config, QUEUE.DB),
autorun: false,
});
@ -283,7 +283,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return this.deliverProcessorService.process(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.DELIVER),
...baseWorkerOptions(this.config, QUEUE.DELIVER),
autorun: false,
concurrency: this.config.deliverJobConcurrency ?? 128,
limiter: {
@ -323,7 +323,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return this.inboxProcessorService.process(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.INBOX),
...baseWorkerOptions(this.config, QUEUE.INBOX),
autorun: false,
concurrency: this.config.inboxJobConcurrency ?? 16,
limiter: {
@ -363,7 +363,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return this.userWebhookDeliverProcessorService.process(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER),
...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER),
autorun: false,
concurrency: 64,
limiter: {
@ -403,7 +403,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return this.systemWebhookDeliverProcessorService.process(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER),
...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER),
autorun: false,
concurrency: 16,
limiter: {
@ -453,7 +453,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return processer(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.RELATIONSHIP),
...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP),
autorun: false,
concurrency: this.config.relationshipJobConcurrency ?? 16,
limiter: {
@ -498,7 +498,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return processer(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE),
...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE),
autorun: false,
concurrency: 16,
});
@ -531,7 +531,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
return this.endedPollNotificationProcessorService.process(job);
}
}, {
...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
autorun: false,
});
}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MetricsTime } from 'bullmq';
import { Config } from '@/config.js';
import type * as Bull from 'bullmq';
@ -28,3 +29,12 @@ export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof t
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`,
};
}
export function baseWorkerOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.WorkerOptions {
return {
...baseQueueOptions(config, queueName),
metrics: {
maxDataPoints: MetricsTime.ONE_WEEK,
},
};
}

View file

@ -38,6 +38,7 @@ import * as Acct from '@/misc/acct.js';
import { CacheService } from '@/core/CacheService.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
import type { FindOptionsWhere } from 'typeorm';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
@ -85,6 +86,7 @@ export class ActivityPubServerService {
private queueService: QueueService,
private userKeypairService: UserKeypairService,
private queryService: QueryService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private loggerService: LoggerService,
private readonly cacheService: CacheService,
) {
@ -613,16 +615,28 @@ export class ActivityPubServerService {
const partOf = `${this.config.url}/users/${userId}/outbox`;
if (page) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
.andWhere('note.userId = :userId', { userId: user.id })
.andWhere(new Brackets(qb => {
qb
.where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
}))
.andWhere('note.localOnly = FALSE');
const notes = await query.limit(limit).getMany();
const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({
sinceId: sinceId ?? null,
untilId: untilId ?? null,
limit: limit,
allowPartial: false, // Possibly true? IDK it's OK for ordered collection.
me: null,
redisTimelines: [
`userTimeline:${user.id}`,
`userTimelineWithReplies:${user.id}`,
],
useDbFallback: true,
ignoreAuthorFromMute: true,
excludePureRenotes: false,
noteFilter: (note) => {
if (note.visibility !== 'home' && note.visibility !== 'public') return false;
if (note.localOnly) return false;
return true;
},
dbFallback: async (untilId, sinceId, limit) => {
return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id);
},
}) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id);
if (sinceId) notes.reverse();
@ -659,6 +673,20 @@ export class ActivityPubServerService {
}
}
@bindThis
private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) {
return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
.andWhere('note.userId = :userId', { userId })
.andWhere(new Brackets(qb => {
qb
.where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
}))
.andWhere('note.localOnly = FALSE')
.limit(limit)
.getMany();
}
@bindThis
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null, redact = false) {
if (this.meta.federation === 'none') {

View file

@ -223,7 +223,7 @@ export class ServerService implements OnApplicationShutdown {
reply.header('Cache-Control', 'public, max-age=86400');
if (user) {
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user));
} else {
reply.redirect('/static-assets/user-unknown.png');
}

View file

@ -138,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number =>
!acct.host || acct.host === this.config.host.toLowerCase() ? {
usernameLower: acct.username,
usernameLower: acct.username.toLowerCase(),
host: IsNull(),
isSuspended: false,
} : 422;

View file

@ -72,8 +72,14 @@ export * as 'admin/promo/create' from './endpoints/admin/promo/create.js';
export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js';
export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js';
export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js';
export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js';
export * as 'admin/queue/retry-job' from './endpoints/admin/queue/retry-job.js';
export * as 'admin/queue/remove-job' from './endpoints/admin/queue/remove-job.js';
export * as 'admin/queue/show-job' from './endpoints/admin/queue/show-job.js';
export * as 'admin/queue/promote-jobs' from './endpoints/admin/queue/promote-jobs.js';
export * as 'admin/queue/jobs' from './endpoints/admin/queue/jobs.js';
export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js';
export * as 'admin/queue/queues' from './endpoints/admin/queue/queues.js';
export * as 'admin/queue/queue-stats' from './endpoints/admin/queue/queue-stats.js';
export * as 'admin/reject-quotes' from './endpoints/admin/reject-quotes.js';
export * as 'admin/relays/add' from './endpoints/admin/relays/add.js';
export * as 'admin/relays/list' from './endpoints/admin/relays/list.js';

View file

@ -6,7 +6,7 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueueService } from '@/core/QueueService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
@ -18,8 +18,11 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {},
required: [],
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
state: { type: 'string', enum: ['*', 'completed', 'wait', 'active', 'paused', 'prioritized', 'delayed', 'failed'] },
},
required: ['queue', 'state'],
} as const;
@Injectable()
@ -29,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
this.queueService.destroy();
this.queueService.queueClear(ps.queue, ps.state);
this.moderationLogService.log(me, 'clearQueue');
});

View file

@ -0,0 +1,38 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'read:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } },
search: { type: 'string' },
},
required: ['queue', 'state'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
return this.queueService.queueGetJobs(ps.queue, ps.state, ps.search);
});
}
}

View file

@ -0,0 +1,39 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
},
required: ['queue'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
this.queueService.queuePromoteJobs(ps.queue);
this.moderationLogService.log(me, 'promoteQueue');
});
}
}

View file

@ -1,77 +0,0 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
type: { type: 'string', enum: ['deliver', 'inbox'] },
},
required: ['type'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
let delayedQueues;
switch (ps.type) {
case 'deliver':
delayedQueues = await this.queueService.deliverQueue.getDelayed();
for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
const queue = delayedQueues[queueIndex];
try {
await queue.promote();
} catch (e) {
if (e instanceof Error) {
if (e.message.indexOf('not in a delayed state') !== -1) {
throw e;
}
} else {
throw e;
}
}
}
break;
case 'inbox':
delayedQueues = await this.queueService.inboxQueue.getDelayed();
for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
const queue = delayedQueues[queueIndex];
try {
await queue.promote();
} catch (e) {
if (e instanceof Error) {
if (e.message.indexOf('not in a delayed state') !== -1) {
throw e;
}
} else {
throw e;
}
}
}
break;
}
this.moderationLogService.log(me, 'promoteQueue');
});
}
}

View file

@ -0,0 +1,36 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'read:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
},
required: ['queue'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
return this.queueService.queueGetQueue(ps.queue);
});
}
}

View file

@ -0,0 +1,35 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'read:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
return this.queueService.queueGetQueues();
});
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
jobId: { type: 'string' },
},
required: ['queue', 'jobId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
this.queueService.queueRemoveJob(ps.queue, ps.jobId);
});
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
jobId: { type: 'string' },
},
required: ['queue', 'jobId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
this.queueService.queueRetryJob(ps.queue, ps.jobId);
});
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'read:admin:queue',
} as const;
export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
jobId: { type: 'string' },
},
required: ['queue', 'jobId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
return this.queueService.queueGetJob(ps.queue, ps.jobId);
});
}
}

View file

@ -558,7 +558,7 @@ export class ClientServerService {
return await reply.view('user', {
user, profile, me,
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
avatarUrl: _user.avatarUrl,
sub: request.params.sub,
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({

View file

@ -65,7 +65,7 @@ export class FeedService {
generator: 'Sharkey',
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
link: author.link,
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user),
feedLinks: {
json: `${author.link}.json`,
atom: `${author.link}.atom`,

View file

@ -381,7 +381,8 @@ describe('User', () => {
await alice.client.request('i/delete-account', { password: alice.password });
// NOTE: user deletion query is slow
await sleep(4000);
// FIXME: ensure user is removed successfully
await sleep(10000);
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation
@ -480,7 +481,8 @@ describe('User', () => {
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
// NOTE: user deletion query is slow
await sleep(4000);
// FIXME: ensure user is removed successfully
await sleep(10000);
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation

View file

@ -6,7 +6,6 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import {
api,
failedApiCall,
@ -19,6 +18,7 @@ import {
userList,
} from '../utils.js';
import type * as misskey from 'misskey-js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
return selector(a).localeCompare(selector(b));
@ -235,11 +235,11 @@ describe('アンテナ', () => {
await failedApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
user: alice
user: alice,
}, {
status: 400,
code: 'EMPTY_KEYWORD',
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a'
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
});
});
//#endregion
@ -274,11 +274,11 @@ describe('アンテナ', () => {
await failedApiCall({
endpoint: 'antennas/update',
parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
user: alice
user: alice,
}, {
status: 400,
code: 'EMPTY_KEYWORD',
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4'
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
});
});
@ -375,14 +375,23 @@ describe('アンテナ', () => {
],
},
{
// https://github.com/misskey-dev/misskey/issues/9025
label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。',
label: 'フォロワー限定投稿とDM投稿を含む',
parameters: () => ({}),
posts: [
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }), included: true },
],
},
{
label: 'フォロワー限定投稿とDM投稿を含まない',
parameters: () => ({}),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'public' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'home' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'followers' }) },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [carol.id] }) },
],
},
{

View file

@ -44,7 +44,7 @@ describe('AnnouncementService', () => {
return usersRepository.insert({
id: genAidx(Date.now()),
username: un,
usernameLower: un,
usernameLower: un.toLowerCase(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));

View file

@ -123,8 +123,8 @@ describe('SigninWithPasskeyApiService', () => {
jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify);
const dummyUser = {
id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null,
};
id: uid, username: uid, usernameLower: uid.toLowerCase(), uri: null, host: null,
};
const dummyProfile = {
userId: uid,
password: 'qwerty',

View file

@ -74,7 +74,7 @@ describe('UserEntityService', () => {
...userData,
id: genAidx(Date.now()),
username: un,
usernameLower: un,
usernameLower: un.toLowerCase(),
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));