summaryrefslogtreecommitdiff
path: root/packages/backend
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend')
-rw-r--r--packages/backend/src/core/AntennaService.ts26
-rw-r--r--packages/backend/src/core/CustomEmojiService.ts20
-rw-r--r--packages/backend/src/core/GlobalEventService.ts7
-rw-r--r--packages/backend/src/core/NoteCreateService.ts11
-rw-r--r--packages/backend/src/core/QueueModule.ts13
-rw-r--r--packages/backend/src/core/QueueService.ts113
-rw-r--r--packages/backend/src/core/RoleService.ts23
-rw-r--r--packages/backend/src/core/UserBlockingService.ts16
-rw-r--r--packages/backend/src/core/UserFollowingService.ts15
-rw-r--r--packages/backend/src/core/UserListService.ts8
-rw-r--r--packages/backend/src/core/entities/ChannelEntityService.ts2
-rw-r--r--packages/backend/src/misc/cache.ts4
-rw-r--r--packages/backend/src/queue/DbQueueProcessorsService.ts2
-rw-r--r--packages/backend/src/queue/QueueProcessorModule.ts4
-rw-r--r--packages/backend/src/queue/QueueProcessorService.ts40
-rw-r--r--packages/backend/src/queue/RelationshipQueueProcessorsService.ts26
-rw-r--r--packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportBlockingProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportFollowingProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportMutingProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportNotesProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportUserListsProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/ImportBlockingProcessorService.ts79
-rw-r--r--packages/backend/src/queue/processors/ImportFollowingProcessorService.ts74
-rw-r--r--packages/backend/src/queue/processors/ImportMutingProcessorService.ts6
-rw-r--r--packages/backend/src/queue/processors/RelationshipProcessorService.ts68
-rw-r--r--packages/backend/src/queue/types.ts39
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts88
-rw-r--r--packages/backend/src/server/ServerModule.ts4
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts4
-rw-r--r--packages/backend/src/server/api/endpoints.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts8
-rw-r--r--packages/backend/src/server/api/endpoints/admin/suspend-user.ts35
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/notes.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/roles/notes.ts109
-rw-r--r--packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts2
-rw-r--r--packages/backend/src/server/api/stream/ChannelsService.ts3
-rw-r--r--packages/backend/src/server/api/stream/channels/role-timeline.ts75
-rw-r--r--packages/backend/src/server/api/stream/types.ts10
-rw-r--r--packages/backend/src/server/web/ClientLoggerService.ts14
-rw-r--r--packages/backend/src/server/web/ClientServerService.ts24
-rw-r--r--packages/backend/src/server/web/error.css110
-rw-r--r--packages/backend/src/server/web/views/error.pug65
-rw-r--r--packages/backend/test/e2e/users.ts868
-rw-r--r--packages/backend/test/utils.ts39
47 files changed, 1860 insertions, 236 deletions
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 02e0b455fd..166c78f479 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -91,14 +91,24 @@ export class AntennaService implements OnApplicationShutdown {
}
@bindThis
- public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> {
- this.redisClient.xadd(
- `antennaTimeline:${antenna.id}`,
- 'MAXLEN', '~', '200',
- '*',
- 'note', note.id);
-
- this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
+ public async addNoteToAntennas(note: Note, noteUser: { id: User['id']; username: string; host: string | null; }): Promise<void> {
+ const antennas = await this.getAntennas();
+ const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
+ const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
+
+ const redisPipeline = this.redisClient.pipeline();
+
+ for (const antenna of matchedAntennas) {
+ redisPipeline.xadd(
+ `antennaTimeline:${antenna.id}`,
+ 'MAXLEN', '~', '200',
+ '*',
+ 'note', note.id);
+
+ this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
+ }
+
+ redisPipeline.exec();
}
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index 3de936dd65..185171dee2 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -13,6 +13,7 @@ import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { Config } from '@/config.js';
import { query } from '@/misc/prelude/url.js';
+import type { Serialized } from '@/server/api/stream/types.js';
@Injectable()
export class CustomEmojiService {
@@ -44,7 +45,13 @@ export class CustomEmojiService {
memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
toRedisConverter: (value) => JSON.stringify(Array.from(value.values())),
- fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
+ fromRedisConverter: (value) => {
+ if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す)
+ return new Map(JSON.parse(value).map((x: Serialized<Emoji>) => [x.name, {
+ ...x,
+ updatedAt: x.updatedAt ? new Date(x.updatedAt) : null,
+ }]));
+ },
});
}
@@ -267,16 +274,7 @@ export class CustomEmojiService {
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null;
-
- const isLocal = emoji.host == null;
- const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
- const url = isLocal
- ? emojiUrl
- : this.config.proxyRemoteFiles
- ? `${this.config.mediaProxy}/emoji.webp?${query({ url: emojiUrl })}`
- : emojiUrl;
-
- return url;
+ return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
}
/**
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 9f4de5f985..2c2687a90c 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -14,11 +14,13 @@ import type {
MainStreamTypes,
NoteStreamTypes,
UserListStreamTypes,
+ RoleTimelineStreamTypes,
} from '@/server/api/stream/types.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
+import { Role } from '@/models';
@Injectable()
export class GlobalEventService {
@@ -82,6 +84,11 @@ export class GlobalEventService {
}
@bindThis
+ public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
+ this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ @bindThis
public publishNotesStream(note: Packed<'Note'>): void {
this.publish('notesStream', null, note);
}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index fb7ee7080a..79629cb2a8 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -493,14 +493,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
});
- // Antenna
- for (const antenna of (await this.antennaService.getAntennas())) {
- this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
- if (hit) {
- this.antennaService.addNoteToAntenna(antenna, note, user);
- }
- });
- }
+ this.antennaService.addNoteToAntennas(note, user);
if (data.reply) {
this.saveReply(data.reply, note);
@@ -554,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown {
this.globalEventService.publishNotesStream(noteObj);
+ this.roleService.addNoteToRoleTimeline(noteObj);
+
this.webhookService.getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
for (const webhook of webhooks) {
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index 8733a7d7eb..bac85d7a15 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -3,7 +3,7 @@ import Bull from 'bull';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Provider } from '@nestjs/common';
-import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../queue/types.js';
+import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js';
function q<T>(config: Config, name: string, limitPerSec = -1) {
return new Bull<T>(name, {
@@ -41,7 +41,8 @@ export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>;
-export type DbQueue = Bull.Queue<DbJobData>;
+export type DbQueue = Bull.Queue<DbJobData<keyof DbJobMap>>;
+export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>;
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
@@ -75,6 +76,12 @@ const $db: Provider = {
inject: [DI.config],
};
+const $relationship: Provider = {
+ provide: 'queue:relationship',
+ useFactory: (config: Config) => q(config, 'relationship'),
+ inject: [DI.config],
+};
+
const $objectStorage: Provider = {
provide: 'queue:objectStorage',
useFactory: (config: Config) => q(config, 'objectStorage'),
@@ -96,6 +103,7 @@ const $webhookDeliver: Provider = {
$deliver,
$inbox,
$db,
+ $relationship,
$objectStorage,
$webhookDeliver,
],
@@ -105,6 +113,7 @@ const $webhookDeliver: Provider = {
$deliver,
$inbox,
$db,
+ $relationship,
$objectStorage,
$webhookDeliver,
],
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 498ceced7a..375ac49911 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -6,9 +6,10 @@ import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
-import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
-import type { ThinUser } from '../queue/types.js';
+import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
+import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import type httpSignature from '@peertube/http-signature';
+import Bull from 'bull';
@Injectable()
export class QueueService {
@@ -21,6 +22,7 @@ export class QueueService {
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
+ @Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
) {}
@@ -56,7 +58,7 @@ export class QueueService {
activity: activity,
signature,
};
-
+
return this.inboxQueue.add(data, {
attempts: this.config.inboxJobMaxAttempts ?? 8,
timeout: 5 * 60 * 1000, // 5min
@@ -71,7 +73,7 @@ export class QueueService {
@bindThis
public createDeleteDriveFilesJob(user: ThinUser) {
return this.dbQueue.add('deleteDriveFiles', {
- user: user,
+ user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
@@ -81,7 +83,7 @@ export class QueueService {
@bindThis
public createExportCustomEmojisJob(user: ThinUser) {
return this.dbQueue.add('exportCustomEmojis', {
- user: user,
+ user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
@@ -91,7 +93,7 @@ export class QueueService {
@bindThis
public createExportNotesJob(user: ThinUser) {
return this.dbQueue.add('exportNotes', {
- user: user,
+ user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
@@ -101,7 +103,7 @@ export class QueueService {
@bindThis
public createExportFavoritesJob(user: ThinUser) {
return this.dbQueue.add('exportFavorites', {
- user: user,
+ user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
@@ -111,7 +113,7 @@ export class QueueService {
@bindThis
public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
return this.dbQueue.add('exportFollowing', {
- user: user,
+ user: { id: user.id },
excludeMuting,
excludeInactive,
}, {
@@ -123,7 +125,7 @@ export class QueueService {
@bindThis
public createExportMuteJob(user: ThinUser) {
return this.dbQueue.add('exportMuting', {
- user: user,
+ user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
@@ -133,7 +135,7 @@ export class QueueService {
@bindThis
public createExportBlockingJob(user: ThinUser) {
return this.dbQueue.add('exportBlocking', {
- user: user,
+ user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
@@ -143,7 +145,7 @@ export class QueueService {
@bindThis
public createExportUserListsJob(user: ThinUser) {
return this.dbQueue.add('exportUserLists', {
- user: user,
+ user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
@@ -153,7 +155,7 @@ export class QueueService {
@bindThis
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importFollowing', {
- user: user,
+ user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
@@ -162,9 +164,15 @@ export class QueueService {
}
@bindThis
+ public createImportFollowingToDbJob(user: ThinUser, targets: string[]) {
+ const jobs = targets.map(rel => this.generateToDbJobData('importFollowingToDb', { user, target: rel }));
+ return this.dbQueue.addBulk(jobs);
+ }
+
+ @bindThis
public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importMuting', {
- user: user,
+ user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
@@ -175,7 +183,7 @@ export class QueueService {
@bindThis
public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importBlocking', {
- user: user,
+ user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
@@ -184,9 +192,31 @@ export class QueueService {
}
@bindThis
+ public createImportBlockingToDbJob(user: ThinUser, targets: string[]) {
+ const jobs = targets.map(rel => this.generateToDbJobData('importBlockingToDb', { user, target: rel }));
+ return this.dbQueue.addBulk(jobs);
+ }
+
+ @bindThis
+ private generateToDbJobData<T extends 'importFollowingToDb' | 'importBlockingToDb', D extends DbJobData<T>>(name: T, data: D): {
+ name: string,
+ data: D,
+ opts: Bull.JobOptions,
+ } {
+ return {
+ name,
+ data,
+ opts: {
+ removeOnComplete: true,
+ removeOnFail: true,
+ },
+ };
+ }
+
+ @bindThis
public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importUserLists', {
- user: user,
+ user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
@@ -197,7 +227,7 @@ export class QueueService {
@bindThis
public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importCustomEmojis', {
- user: user,
+ user: { id: user.id },
fileId: fileId,
}, {
removeOnComplete: true,
@@ -208,7 +238,7 @@ export class QueueService {
@bindThis
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
return this.dbQueue.add('deleteAccount', {
- user: user,
+ user: { id: user.id },
soft: opts.soft,
}, {
removeOnComplete: true,
@@ -217,6 +247,51 @@ export class QueueService {
}
@bindThis
+ public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean }[]) {
+ const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel));
+ return this.relationshipQueue.addBulk(jobs);
+ }
+
+ @bindThis
+ public createUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[]) {
+ const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel));
+ return this.relationshipQueue.addBulk(jobs);
+ }
+
+ @bindThis
+ public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
+ const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel));
+ return this.relationshipQueue.addBulk(jobs);
+ }
+
+ @bindThis
+ public createUnblockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
+ const jobs = blockings.map(rel => this.generateRelationshipJobData('unblock', rel));
+ return this.relationshipQueue.addBulk(jobs);
+ }
+
+ @bindThis
+ private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): {
+ name: string,
+ data: RelationshipJobData,
+ opts: Bull.JobOptions,
+ } {
+ return {
+ name,
+ data: {
+ from: { id: data.from.id },
+ to: { id: data.to.id },
+ silent: data.silent,
+ requestId: data.requestId,
+ },
+ opts: {
+ removeOnComplete: true,
+ removeOnFail: true,
+ },
+ };
+ }
+
+ @bindThis
public createDeleteObjectStorageFileJob(key: string) {
return this.objectStorageQueue.add('deleteFile', {
key: key,
@@ -246,7 +321,7 @@ export class QueueService {
createdAt: Date.now(),
eventId: uuid(),
};
-
+
return this.webhookDeliverQueue.add(data, {
attempts: 4,
timeout: 1 * 60 * 1000, // 1min
@@ -264,7 +339,7 @@ export class QueueService {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.deliverQueue.clean(0, 'delayed');
-
+
this.inboxQueue.once('cleaned', (jobs, status) => {
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 77645e3f06..2a4271aa98 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
+import type { Packed } from '@/misc/json-schema';
import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = {
@@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown {
public static NotAssignedError = class extends Error {};
constructor(
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@@ -399,6 +403,25 @@ export class RoleService implements OnApplicationShutdown {
}
@bindThis
+ public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> {
+ const roles = await this.getUserRoles(note.userId);
+
+ const redisPipeline = this.redisClient.pipeline();
+
+ for (const role of roles) {
+ redisPipeline.xadd(
+ `roleTimeline:${role.id}`,
+ 'MAXLEN', '~', '1000',
+ '*',
+ 'note', note.id);
+
+ this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
+ }
+
+ redisPipeline.exec();
+ }
+
+ @bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisForSub.off('message', this.onMessage);
}
diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts
index b3e306346e..3ca22f8bbc 100644
--- a/packages/backend/src/core/UserBlockingService.ts
+++ b/packages/backend/src/core/UserBlockingService.ts
@@ -24,7 +24,7 @@ export class UserBlockingService implements OnModuleInit {
constructor(
private moduleRef: ModuleRef,
-
+
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@@ -54,12 +54,12 @@ export class UserBlockingService implements OnModuleInit {
}
@bindThis
- public async block(blocker: User, blockee: User) {
+ public async block(blocker: User, blockee: User, silent = false) {
await Promise.all([
- this.cancelRequest(blocker, blockee),
- this.cancelRequest(blockee, blocker),
- this.userFollowingService.unfollow(blocker, blockee),
- this.userFollowingService.unfollow(blockee, blocker),
+ this.cancelRequest(blocker, blockee, silent),
+ this.cancelRequest(blockee, blocker, silent),
+ this.userFollowingService.unfollow(blocker, blockee, silent),
+ this.userFollowingService.unfollow(blockee, blocker, silent),
this.removeFromList(blockee, blocker),
]);
@@ -89,7 +89,7 @@ export class UserBlockingService implements OnModuleInit {
}
@bindThis
- private async cancelRequest(follower: User, followee: User) {
+ private async cancelRequest(follower: User, followee: User, silent = false) {
const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
@@ -110,7 +110,7 @@ export class UserBlockingService implements OnModuleInit {
}).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed));
}
- if (this.userEntityService.isLocalUser(follower)) {
+ if (this.userEntityService.isLocalUser(follower) && !silent) {
this.userEntityService.pack(followee, follower, {
detail: true,
}).then(async packed => {
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index d7bb8f3920..a8eded6733 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
+import type { Config } from '@/config.js';
import Logger from '../logger.js';
const logger = new Logger('following/create');
@@ -43,7 +44,10 @@ export class UserFollowingService implements OnModuleInit {
constructor(
private moduleRef: ModuleRef,
-
+
+ @Inject(DI.config)
+ private config: Config,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -79,7 +83,7 @@ export class UserFollowingService implements OnModuleInit {
}
@bindThis
- public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> {
+ public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string, silent = false): Promise<void> {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
@@ -139,7 +143,7 @@ export class UserFollowingService implements OnModuleInit {
}
}
- await this.insertFollowingDoc(followee, follower);
+ await this.insertFollowingDoc(followee, follower, silent);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
@@ -155,6 +159,7 @@ export class UserFollowingService implements OnModuleInit {
follower: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
},
+ silent = false,
): Promise<void> {
if (follower.id === followee.id) return;
@@ -233,7 +238,7 @@ export class UserFollowingService implements OnModuleInit {
this.perUserFollowingChart.update(follower, followee, true);
// Publish follow event
- if (this.userEntityService.isLocalUser(follower)) {
+ if (this.userEntityService.isLocalUser(follower) && !silent) {
this.userEntityService.pack(followee.id, follower, {
detail: true,
}).then(async packed => {
@@ -410,7 +415,7 @@ export class UserFollowingService implements OnModuleInit {
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
- const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee));
+ const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
this.queueService.deliver(follower, content, followee.inbox, false);
}
}
diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts
index bc726a1feb..08cc907ebf 100644
--- a/packages/backend/src/core/UserListService.ts
+++ b/packages/backend/src/core/UserListService.ts
@@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
+import { QueueService } from '@/core/QueueService.js';
@Injectable()
export class UserListService {
@@ -29,6 +30,7 @@ export class UserListService {
private roleService: RoleService,
private globalEventService: GlobalEventService,
private proxyAccountService: ProxyAccountService,
+ private queueService: QueueService,
) {
}
@@ -47,14 +49,14 @@ export class UserListService {
userId: target.id,
userListId: list.id,
} as UserListJoining);
-
+
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
-
+
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
if (this.userEntityService.isRemoteUser(target)) {
const proxy = await this.proxyAccountService.fetch();
if (proxy) {
- this.userFollowingService.follow(proxy, target);
+ this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
}
}
}
diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts
index 72e9b25544..987002606f 100644
--- a/packages/backend/src/core/entities/ChannelEntityService.ts
+++ b/packages/backend/src/core/entities/ChannelEntityService.ts
@@ -84,7 +84,7 @@ export class ChannelEntityService {
} : {}),
...(detailed ? {
- pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me),
+ pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)),
} : {}),
};
}
diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index d35414acf7..f413246a1f 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -8,7 +8,7 @@ export class RedisKVCache<T> {
private memoryCache: MemoryKVCache<T>;
private fetcher: (key: string) => Promise<T>;
private toRedisConverter: (value: T) => string;
- private fromRedisConverter: (value: string) => T;
+ private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
lifetime: RedisKVCache<T>['lifetime'];
@@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
private memoryCache: MemorySingleCache<T>;
private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string;
- private fromRedisConverter: (value: string) => T;
+ private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
lifetime: RedisSingleCache<T>['lifetime'];
diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts
index 910e2bea45..233a36dd04 100644
--- a/packages/backend/src/queue/DbQueueProcessorsService.ts
+++ b/packages/backend/src/queue/DbQueueProcessorsService.ts
@@ -52,8 +52,10 @@ export class DbQueueProcessorsService {
q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
+ q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done));
+ q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index 6a8f35cdda..4db9b38547 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -4,6 +4,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js';
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
+import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@@ -32,6 +33,7 @@ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessor
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
+import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
@Module({
imports: [
@@ -61,9 +63,11 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro
DeleteAccountProcessorService,
DeleteFileProcessorService,
CleanRemoteFilesProcessorService,
+ RelationshipProcessorService,
SystemQueueProcessorsService,
ObjectStorageQueueProcessorsService,
DbQueueProcessorsService,
+ RelationshipQueueProcessorsService,
WebhookDeliverProcessorService,
EndedPollNotificationProcessorService,
DeliverProcessorService,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index 8457747cb0..706110f6fc 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
+import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
@Injectable()
export class QueueProcessorService {
@@ -27,6 +28,7 @@ export class QueueProcessorService {
private systemQueueProcessorsService: SystemQueueProcessorsService,
private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService,
private dbQueueProcessorsService: DbQueueProcessorsService,
+ private relationshipQueueProcessorsService: RelationshipQueueProcessorsService,
private webhookDeliverProcessorService: WebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private deliverProcessorService: DeliverProcessorService,
@@ -52,14 +54,15 @@ export class QueueProcessorService {
};
}
}
-
+
const systemLogger = this.logger.createSubLogger('system');
const deliverLogger = this.logger.createSubLogger('deliver');
const webhookLogger = this.logger.createSubLogger('webhook');
const inboxLogger = this.logger.createSubLogger('inbox');
const dbLogger = this.logger.createSubLogger('db');
+ const relationshipLogger = this.logger.createSubLogger('relationship');
const objectStorageLogger = this.logger.createSubLogger('objectStorage');
-
+
this.queueService.systemQueue
.on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => systemLogger.debug(`active id=${job.id}`))
@@ -67,7 +70,7 @@ export class QueueProcessorService {
.on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
.on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`));
-
+
this.queueService.deliverQueue
.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
@@ -75,7 +78,7 @@ export class QueueProcessorService {
.on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`))
.on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
-
+
this.queueService.inboxQueue
.on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`))
@@ -83,7 +86,7 @@ export class QueueProcessorService {
.on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) }))
.on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`));
-
+
this.queueService.dbQueue
.on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => dbLogger.debug(`active id=${job.id}`))
@@ -91,7 +94,15 @@ export class QueueProcessorService {
.on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
.on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`));
-
+
+ this.queueService.relationshipQueue
+ .on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`))
+ .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`))
+ .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`))
+ .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
+ .on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) }))
+ .on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`));
+
this.queueService.objectStorageQueue
.on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
@@ -99,7 +110,7 @@ export class QueueProcessorService {
.on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
.on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`));
-
+
this.queueService.webhookDeliverQueue
.on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
@@ -107,26 +118,27 @@ export class QueueProcessorService {
.on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`))
.on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
-
+
this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job));
this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job));
this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done));
this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job));
this.dbQueueProcessorsService.start(this.queueService.dbQueue);
+ this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue);
this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue);
-
+
this.queueService.systemQueue.add('tickCharts', {
}, {
repeat: { cron: '55 * * * *' },
removeOnComplete: true,
});
-
+
this.queueService.systemQueue.add('resyncCharts', {
}, {
repeat: { cron: '0 0 * * *' },
removeOnComplete: true,
});
-
+
this.queueService.systemQueue.add('cleanCharts', {
}, {
repeat: { cron: '0 0 * * *' },
@@ -138,19 +150,19 @@ export class QueueProcessorService {
repeat: { cron: '0 0 * * *' },
removeOnComplete: true,
});
-
+
this.queueService.systemQueue.add('clean', {
}, {
repeat: { cron: '0 0 * * *' },
removeOnComplete: true,
});
-
+
this.queueService.systemQueue.add('checkExpiredMutings', {
}, {
repeat: { cron: '*/5 * * * *' },
removeOnComplete: true,
});
-
+
this.systemQueueProcessorsService.start(this.queueService.systemQueue);
}
}
diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts
new file mode 100644
index 0000000000..af086fa4e7
--- /dev/null
+++ b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts
@@ -0,0 +1,26 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
+import type Bull from 'bull';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+
+@Injectable()
+export class RelationshipQueueProcessorsService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ private relationshipProcessorService: RelationshipProcessorService,
+ ) {
+ }
+
+ @bindThis
+ public start(q: Bull.Queue): void {
+ const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative?
+ q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
+ q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
+ q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
+ q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job));
+ }
+}
diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
index fa0c1733f6..604497cf54 100644
--- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
@@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserJobData } from '../types.js';
+import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js';
@Injectable()
@@ -31,7 +31,7 @@ export class DeleteDriveFilesProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Deleting drive files of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
index 7f2c2d08b5..a020006732 100644
--- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
@@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserJobData } from '../types.js';
+import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js';
@Injectable()
@@ -36,7 +36,7 @@ export class ExportBlockingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting blocking of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
index e9330772b9..daefcdf2f5 100644
--- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
@@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserJobData } from '../types.js';
+import type { DbJobDataWithUser } from '../types.js';
@Injectable()
export class ExportFavoritesProcessorService {
@@ -42,7 +42,7 @@ export class ExportFavoritesProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting favorites of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
index 064b126e44..59443de57f 100644
--- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
@@ -12,7 +12,7 @@ import type { Following } from '@/models/entities/Following.js';
import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserJobData } from '../types.js';
+import type { DbExportFollowingData } from '../types.js';
import { bindThis } from '@/decorators.js';
@Injectable()
@@ -40,7 +40,7 @@ export class ExportFollowingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbExportFollowingData>, done: () => void): Promise<void> {
this.logger.info(`Exporting following of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
index 94c7ea8a46..a2a718b892 100644
--- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
@@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserJobData } from '../types.js';
+import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js';
@Injectable()
@@ -39,7 +39,7 @@ export class ExportMutingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting muting of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
index 2f74dd63cc..1aa20d6f1d 100644
--- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
@@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserJobData } from '../types.js';
+import type { DbJobDataWithUser } from '../types.js';
@Injectable()
export class ExportNotesProcessorService {
@@ -39,7 +39,7 @@ export class ExportNotesProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting notes of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
index 6400161b8c..ce8ed2f5e8 100644
--- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
@@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserJobData } from '../types.js';
+import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js';
@Injectable()
@@ -39,7 +39,7 @@ export class ExportUserListsProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting user lists of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts
index b8d9b3a52d..3f075b02d2 100644
--- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts
@@ -1,38 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { UsersRepository, BlockingsRepository, DriveFilesRepository } from '@/models/index.js';
-import type { Config } from '@/config.js';
+import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
-import { UserBlockingService } from '@/core/UserBlockingService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserImportJobData } from '../types.js';
+import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js';
+import { QueueService } from '@/core/QueueService.js';
@Injectable()
export class ImportBlockingProcessorService {
private logger: Logger;
constructor(
- @Inject(DI.config)
- private config: Config,
-
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
- @Inject(DI.blockingsRepository)
- private blockingsRepository: BlockingsRepository,
-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
+ private queueService: QueueService,
private utilityService: UtilityService,
- private userBlockingService: UserBlockingService,
private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService,
@@ -59,46 +52,50 @@ export class ImportBlockingProcessorService {
}
const csv = await this.downloadService.downloadTextFile(file.url);
+ const targets = csv.trim().split('\n');
+ this.queueService.createImportBlockingToDbJob({ id: user.id }, targets);
- let linenum = 0;
+ this.logger.succ('Import jobs created');
+ done();
+ }
- for (const line of csv.trim().split('\n')) {
- linenum++;
+ @bindThis
+ public async processDb(job: Bull.Job<DbUserImportToDbJobData>): Promise<void> {
+ const line = job.data.target;
+ const user = job.data.user;
- try {
- const acct = line.split(',')[0].trim();
- const { username, host } = Acct.parse(acct);
+ try {
+ const acct = line.split(',')[0].trim();
+ const { username, host } = Acct.parse(acct);
- let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({
- host: IsNull(),
- usernameLower: username.toLowerCase(),
- }) : await this.usersRepository.findOneBy({
- host: this.utilityService.toPuny(host!),
- usernameLower: username.toLowerCase(),
- });
+ if (!host) return;
- if (host == null && target == null) continue;
+ let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
+ host: IsNull(),
+ usernameLower: username.toLowerCase(),
+ }) : await this.usersRepository.findOneBy({
+ host: this.utilityService.toPuny(host),
+ usernameLower: username.toLowerCase(),
+ });
- if (target == null) {
- target = await this.remoteUserResolveService.resolveUser(username, host);
- }
+ if (host == null && target == null) return;
- if (target == null) {
- throw `cannot resolve user: @${username}@${host}`;
- }
+ if (target == null) {
+ target = await this.remoteUserResolveService.resolveUser(username, host);
+ }
- // skip myself
- if (target.id === job.data.user.id) continue;
+ if (target == null) {
+ throw `Unable to resolve user: @${username}@${host}`;
+ }
- this.logger.info(`Block[${linenum}] ${target.id} ...`);
+ // skip myself
+ if (target.id === job.data.user.id) return;
- await this.userBlockingService.block(user, target);
- } catch (e) {
- this.logger.warn(`Error in line:${linenum} ${e}`);
- }
- }
+ this.logger.info(`Block ${target.id} ...`);
- this.logger.succ('Imported');
- done();
+ this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]);
+ } catch (e) {
+ this.logger.warn(`Error: ${e}`);
+ }
}
}
diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts
index 037a6f2456..aa5cf12c50 100644
--- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts
@@ -2,34 +2,30 @@ import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
-import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DownloadService } from '@/core/DownloadService.js';
-import { UserFollowingService } from '@/core/UserFollowingService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull';
-import type { DbUserImportJobData } from '../types.js';
+import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js';
+import { QueueService } from '@/core/QueueService.js';
@Injectable()
export class ImportFollowingProcessorService {
private logger: Logger;
constructor(
- @Inject(DI.config)
- private config: Config,
-
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
+ private queueService: QueueService,
private utilityService: UtilityService,
- private userFollowingService: UserFollowingService,
private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService,
@@ -56,46 +52,50 @@ export class ImportFollowingProcessorService {
}
const csv = await this.downloadService.downloadTextFile(file.url);
+ const targets = csv.trim().split('\n');
+ this.queueService.createImportFollowingToDbJob({ id: user.id }, targets);
- let linenum = 0;
+ this.logger.succ('Import jobs created');
+ done();
+ }
+
+ @bindThis
+ public async processDb(job: Bull.Job<DbUserImportToDbJobData>): Promise<void> {
+ const line = job.data.target;
+ const user = job.data.user;
- for (const line of csv.trim().split('\n')) {
- linenum++;
+ try {
+ const acct = line.split(',')[0].trim();
+ const { username, host } = Acct.parse(acct);
- try {
- const acct = line.split(',')[0].trim();
- const { username, host } = Acct.parse(acct);
+ if (!host) return;
- let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({
- host: IsNull(),
- usernameLower: username.toLowerCase(),
- }) : await this.usersRepository.findOneBy({
- host: this.utilityService.toPuny(host!),
- usernameLower: username.toLowerCase(),
- });
+ let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
+ host: IsNull(),
+ usernameLower: username.toLowerCase(),
+ }) : await this.usersRepository.findOneBy({
+ host: this.utilityService.toPuny(host),
+ usernameLower: username.toLowerCase(),
+ });
- if (host == null && target == null) continue;
+ if (host == null && target == null) return;
- if (target == null) {
- target = await this.remoteUserResolveService.resolveUser(username, host);
- }
+ if (target == null) {
+ target = await this.remoteUserResolveService.resolveUser(username, host);
+ }
- if (target == null) {
- throw `cannot resolve user: @${username}@${host}`;
- }
+ if (target == null) {
+ throw `Unable to resolve user: @${username}@${host}`;
+ }
- // skip myself
- if (target.id === job.data.user.id) continue;
+ // skip myself
+ if (target.id === job.data.user.id) return;
- this.logger.info(`Follow[${linenum}] ${target.id} ...`);
+ this.logger.info(`Follow ${target.id} ...`);
- this.userFollowingService.follow(user, target);
- } catch (e) {
- this.logger.warn(`Error in line:${linenum} ${e}`);
- }
+ this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true }]);
+ } catch (e) {
+ this.logger.warn(`Error: ${e}`);
}
-
- this.logger.succ('Imported');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts
index 83d382057b..379994ee79 100644
--- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts
@@ -66,11 +66,13 @@ export class ImportMutingProcessorService {
const acct = line.split(',')[0].trim();
const { username, host } = Acct.parse(acct);
- let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({
+ if (!host) continue;
+
+ let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
host: IsNull(),
usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({
- host: this.utilityService.toPuny(host!),
+ host: this.utilityService.toPuny(host),
usernameLower: username.toLowerCase(),
});
diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts
new file mode 100644
index 0000000000..a5006dcf03
--- /dev/null
+++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts
@@ -0,0 +1,68 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type Bull from 'bull';
+
+import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { UserBlockingService } from '@/core/UserBlockingService.js';
+import { bindThis } from '@/decorators.js';
+import type Logger from '@/logger.js';
+
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import { RelationshipJobData } from '../types.js';
+import type { UsersRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+
+@Injectable()
+export class RelationshipProcessorService {
+ private logger: Logger;
+
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ private queueLoggerService: QueueLoggerService,
+ private userFollowingService: UserFollowingService,
+ private userBlockingService: UserBlockingService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('follow-block');
+ }
+
+ @bindThis
+ public async processFollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
+ this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id}`);
+ await this.userFollowingService.follow(job.data.from, job.data.to, job.data.requestId, job.data.silent);
+ return 'ok';
+ }
+
+ @bindThis
+ public async processUnfollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
+ this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`);
+ const [follower, followee] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
+ this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
+ ]);
+ await this.userFollowingService.unfollow(follower, followee, job.data.silent);
+ return 'ok';
+ }
+
+ @bindThis
+ public async processBlock(job: Bull.Job<RelationshipJobData>): Promise<string> {
+ this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`);
+ const [blockee, blocker] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
+ this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
+ ]);
+ await this.userBlockingService.block(blockee, blocker, job.data.silent);
+ return 'ok';
+ }
+
+ @bindThis
+ public async processUnblock(job: Bull.Job<RelationshipJobData>): Promise<string> {
+ this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`);
+ const [blockee, blocker] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
+ this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
+ ]);
+ await this.userBlockingService.unblock(blockee, blocker);
+ return 'ok';
+ }
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 5d650c6864..23c973d449 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -21,9 +21,39 @@ export type InboxJobData = {
signature: httpSignature.IParsedSignature;
};
-export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData;
+export type RelationshipJobData = {
+ from: ThinUser;
+ to: ThinUser;
+ silent?: boolean;
+ requestId?: string;
+}
-export type DbUserJobData = {
+export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
+
+export type DbJobMap = {
+ deleteDriveFiles: DbJobDataWithUser;
+ exportCustomEmojis: DbJobDataWithUser;
+ exportNotes: DbJobDataWithUser;
+ exportFavorites: DbJobDataWithUser;
+ exportFollowing: DbExportFollowingData;
+ exportMuting: DbJobDataWithUser;
+ exportBlocking: DbJobDataWithUser;
+ exportUserLists: DbJobDataWithUser;
+ importFollowing: DbUserImportJobData;
+ importFollowingToDb: DbUserImportToDbJobData;
+ importMuting: DbUserImportJobData;
+ importBlocking: DbUserImportJobData;
+ importBlockingToDb: DbUserImportToDbJobData;
+ importUserLists: DbUserImportJobData;
+ importCustomEmojis: DbUserImportJobData;
+ deleteAccount: DbUserDeleteJobData;
+}
+
+export type DbJobDataWithUser = {
+ user: ThinUser;
+}
+
+export type DbExportFollowingData = {
user: ThinUser;
excludeMuting: boolean;
excludeInactive: boolean;
@@ -39,6 +69,11 @@ export type DbUserImportJobData = {
fileId: DriveFile['id'];
};
+export type DbUserImportToDbJobData = {
+ user: ThinUser;
+ target: string;
+};
+
export type ObjectStorageJobData = ObjectStorageFileJobData | Record<string, unknown>;
export type ObjectStorageFileJobData = {
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index 5799622074..e13e9265ab 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -6,7 +6,7 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
import accepts from 'accepts';
import vary from 'vary';
import { DI } from '@/di-symbols.js';
-import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js';
+import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js';
import * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@@ -54,6 +54,9 @@ export class ActivityPubServerService {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
+ @Inject(DI.followRequestsRepository)
+ private followRequestsRepository: FollowRequestsRepository,
+
private utilityService: UtilityService,
private userEntityService: UserEntityService,
private apRendererService: ApRendererService,
@@ -205,22 +208,22 @@ export class ActivityPubServerService {
reply.code(400);
return;
}
-
+
const page = request.query.page === 'true';
-
+
const user = await this.usersRepository.findOneBy({
id: userId,
host: IsNull(),
});
-
+
if (user == null) {
reply.code(404);
return;
}
-
+
//#region Check ff visibility
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
-
+
if (profile.ffVisibility === 'private') {
reply.code(403);
reply.header('Cache-Control', 'public, max-age=30');
@@ -231,31 +234,31 @@ export class ActivityPubServerService {
return;
}
//#endregion
-
+
const limit = 10;
const partOf = `${this.config.url}/users/${userId}/following`;
-
+
if (page) {
const query = {
followerId: user.id,
} as FindOptionsWhere<Following>;
-
+
// カーソルが指定されている場合
if (cursor) {
query.id = LessThan(cursor);
}
-
+
// Get followings
const followings = await this.followingsRepository.find({
where: query,
take: limit + 1,
order: { id: -1 },
});
-
+
// 「次のページ」があるかどうか
const inStock = followings.length === limit + 1;
if (inStock) followings.pop();
-
+
const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId)));
const rendered = this.apRendererService.renderOrderedCollectionPage(
`${partOf}?${url.query({
@@ -269,7 +272,7 @@ export class ActivityPubServerService {
cursor: followings[followings.length - 1].id,
})}` : undefined,
);
-
+
this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered));
} else {
@@ -330,33 +333,33 @@ export class ActivityPubServerService {
reply.code(400);
return;
}
-
+
const untilId = request.query.until_id;
if (untilId != null && typeof untilId !== 'string') {
reply.code(400);
return;
}
-
+
const page = request.query.page === 'true';
-
+
if (countIf(x => x != null, [sinceId, untilId]) > 1) {
reply.code(400);
return;
}
-
+
const user = await this.usersRepository.findOneBy({
id: userId,
host: IsNull(),
});
-
+
if (user == null) {
reply.code(404);
return;
}
-
+
const limit = 20;
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 })
@@ -365,11 +368,11 @@ export class ActivityPubServerService {
.orWhere('note.visibility = \'home\'');
}))
.andWhere('note.localOnly = FALSE');
-
+
const notes = await query.take(limit).getMany();
-
+
if (sinceId) notes.reverse();
-
+
const activities = await Promise.all(notes.map(note => this.packActivity(note)));
const rendered = this.apRendererService.renderOrderedCollectionPage(
`${partOf}?${url.query({
@@ -387,7 +390,7 @@ export class ActivityPubServerService {
until_id: notes[notes.length - 1].id,
})}` : undefined,
);
-
+
this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered));
} else {
@@ -457,7 +460,7 @@ export class ActivityPubServerService {
// note
fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
vary(reply.raw, 'Accept');
-
+
const note = await this.notesRepository.findOneBy({
id: request.params.note,
visibility: In(['public', 'home']),
@@ -639,6 +642,41 @@ export class ActivityPubServerService {
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
});
+ // follow
+ fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => {
+ // This may be used before the follow is completed, so we do not
+ // check if the following exists and only check if the follow request exists.
+
+ const followRequest = await this.followRequestsRepository.findOneBy({
+ id: request.params.followRequestId,
+ });
+
+ if (followRequest == null) {
+ reply.code(404);
+ return;
+ }
+
+ const [follower, followee] = await Promise.all([
+ this.usersRepository.findOneBy({
+ id: followRequest.followerId,
+ host: IsNull(),
+ }),
+ this.usersRepository.findOneBy({
+ id: followRequest.followeeId,
+ host: Not(IsNull()),
+ }),
+ ]);
+
+ if (follower == null || followee == null) {
+ reply.code(404);
+ return;
+ }
+
+ reply.header('Cache-Control', 'public, max-age=180');
+ this.setResponseType(request, reply);
+ return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
+ });
+
done();
}
}
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 6bae0bafda..da86b2c1d3 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
+import { ClientLoggerService } from './web/ClientLoggerService.js';
+import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
@Module({
imports: [
@@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
],
providers: [
ClientServerService,
+ ClientLoggerService,
FeedService,
UrlPreviewService,
ActivityPubServerService,
@@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
DriveChannelService,
GlobalTimelineChannelService,
HashtagChannelService,
+ RoleTimelineChannelService,
HomeTimelineChannelService,
HybridTimelineChannelService,
LocalTimelineChannelService,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index ca89d82853..689f90287e 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js';
+import * as ep___roles_notes from './endpoints/roles/notes.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -628,6 +629,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r
const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
+const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default };
const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
@@ -966,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$roles_list,
$roles_show,
$roles_users,
+ $roles_notes,
$requestResetPassword,
$resetDb,
$resetPassword,
@@ -1298,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$roles_list,
$roles_show,
$roles_users,
+ $roles_notes,
$requestResetPassword,
$resetDb,
$resetPassword,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index dab897117d..d0fe6a57c1 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js';
+import * as ep___roles_notes from './endpoints/roles/notes.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -626,6 +627,7 @@ const eps = [
['roles/list', ep___roles_list],
['roles/show', ep___roles_show],
['roles/users', ep___roles_users],
+ ['roles/notes', ep___roles_notes],
['request-reset-password', ep___requestResetPassword],
['reset-db', ep___resetDb],
['reset-password', ep___resetPassword],
diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts
index b073209a5b..54ce095488 100644
--- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts
+++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts
@@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
-import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js';
+import { QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
@@ -29,7 +29,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.notesRepository)
private followingsRepository: FollowingsRepository,
- private userFollowingService: UserFollowingService,
+ private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
const followings = await this.followingsRepository.findBy({
@@ -41,9 +41,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.usersRepository.findOneByOrFail({ id: f.followeeId }),
])));
- for (const pair of pairs) {
- this.userFollowingService.unfollow(pair[0], pair[1]);
- }
+ this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true })));
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts
index 3c99225272..eabbceac0e 100644
--- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts
@@ -1,15 +1,15 @@
+import { IsNull, Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
+import type { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
-import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
+import { QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
@@ -36,12 +36,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
- private userEntityService: UserEntityService,
- private userFollowingService: UserFollowingService,
private userSuspendService: UserSuspendService,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
- private globalEventService: GlobalEventService,
+ private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
@@ -71,20 +69,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@bindThis
private async unFollowAll(follower: User) {
- const followings = await this.followingsRepository.findBy({
- followerId: follower.id,
+ const followings = await this.followingsRepository.find({
+ where: {
+ followerId: follower.id,
+ followeeId: Not(IsNull()),
+ },
});
-
+
+ const jobs: RelationshipJobData[] = [];
for (const following of followings) {
- const followee = await this.usersRepository.findOneBy({
- id: following.followeeId,
- });
-
- if (followee == null) {
- throw `Cant find followee ${following.followeeId}`;
+ if (following.followeeId && following.followerId) {
+ jobs.push({
+ from: { id: following.followerId },
+ to: { id: following.followeeId },
+ silent: true,
+ });
}
-
- await this.userFollowingService.unfollow(follower, followee, true);
}
+ this.queueService.createUnfollowJob(jobs);
}
}
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index f08c20ae48..88623ce26a 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -76,17 +76,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchAntenna);
}
+ const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange(
`antennaTimeline:${antenna.id}`,
- ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
- '-',
- 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
+ ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
+ ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
+ 'COUNT', limit);
if (noteIdsRes.length === 0) {
return [];
}
- const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
+ const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) {
return [];
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index f27b4e86d4..ba0487f223 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -91,11 +91,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
+ const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const notificationsRes = await this.redisClient.xrevrange(
`notificationTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
'-',
- 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
+ 'COUNT', limit);
if (notificationsRes.length === 0) {
return [];
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
new file mode 100644
index 0000000000..b45d4af1fe
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -0,0 +1,109 @@
+import { Inject, Injectable } from '@nestjs/common';
+import Redis from 'ioredis';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { NotesRepository, RolesRepository } from '@/models/index.js';
+import { QueryService } from '@/core/QueryService.js';
+import { DI } from '@/di-symbols.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['role', 'notes'],
+
+ requireCredential: true,
+
+ errors: {
+ noSuchRole: {
+ message: 'No such role.',
+ code: 'NO_SUCH_ROLE',
+ id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e',
+ },
+ },
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'Note',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roleId: { type: 'string', format: 'misskey:id' },
+ 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: ['roleId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.rolesRepository)
+ private rolesRepository: RolesRepository,
+
+ private idService: IdService,
+ private noteEntityService: NoteEntityService,
+ private queryService: QueryService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const role = await this.rolesRepository.findOneBy({
+ id: ps.roleId,
+ });
+
+ if (role == null) {
+ throw new ApiError(meta.errors.noSuchRole);
+ }
+
+ const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
+ const noteIdsRes = await this.redisClient.xrevrange(
+ `roleTimeline:${role.id}`,
+ ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
+ ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
+ 'COUNT', limit);
+
+ if (noteIdsRes.length === 0) {
+ return [];
+ }
+
+ const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
+
+ if (noteIds.length === 0) {
+ return [];
+ }
+
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ this.queryService.generateVisibilityQuery(query, me);
+ this.queryService.generateMutedUserQuery(query, me);
+ this.queryService.generateBlockedUserQuery(query, me);
+
+ const notes = await query.getMany();
+ notes.sort((a, b) => a.id > b.id ? -1 : 1);
+
+ return await this.noteEntityService.packMany(notes, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
index 6c340d8fb2..b001159ee8 100644
--- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
+++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
@@ -41,8 +41,6 @@ export const paramDef = {
],
} as const;
-// TODO: avatar,bannerをJOINしたいけどエラーになる
-
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index f9ef8218c1..c77ba66028 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js';
import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js';
+import { RoleTimelineChannelService } from './channels/role-timeline.js';
@Injectable()
export class ChannelsService {
@@ -24,6 +25,7 @@ export class ChannelsService {
private globalTimelineChannelService: GlobalTimelineChannelService,
private userListChannelService: UserListChannelService,
private hashtagChannelService: HashtagChannelService,
+ private roleTimelineChannelService: RoleTimelineChannelService,
private antennaChannelService: AntennaChannelService,
private channelChannelService: ChannelChannelService,
private driveChannelService: DriveChannelService,
@@ -43,6 +45,7 @@ export class ChannelsService {
case 'globalTimeline': return this.globalTimelineChannelService;
case 'userList': return this.userListChannelService;
case 'hashtag': return this.hashtagChannelService;
+ case 'roleTimeline': return this.roleTimelineChannelService;
case 'antenna': return this.antennaChannelService;
case 'channel': return this.channelChannelService;
case 'drive': return this.driveChannelService;
diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts
new file mode 100644
index 0000000000..9d106c8b2f
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts
@@ -0,0 +1,75 @@
+import { Injectable } from '@nestjs/common';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { bindThis } from '@/decorators.js';
+import Channel from '../channel.js';
+import { StreamMessages } from '../types.js';
+
+class RoleTimelineChannel extends Channel {
+ public readonly chName = 'roleTimeline';
+ public static shouldShare = false;
+ public static requireCredential = false;
+ private roleId: string;
+
+ constructor(
+ private noteEntityService: NoteEntityService,
+
+ id: string,
+ connection: Channel['connection'],
+ ) {
+ super(id, connection);
+ //this.onNote = this.onNote.bind(this);
+ }
+
+ @bindThis
+ public async init(params: any) {
+ this.roleId = params.roleId as string;
+
+ this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent);
+ }
+
+ @bindThis
+ private async onEvent(data: StreamMessages['roleTimeline']['payload']) {
+ if (data.type === 'note') {
+ const note = data.body;
+
+ // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+ if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
+ // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
+ if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
+
+ if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
+
+ this.send('note', note);
+ } else {
+ this.send(data.type, data.body);
+ }
+ }
+
+ @bindThis
+ public dispose() {
+ // Unsubscribe events
+ this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
+ }
+}
+
+@Injectable()
+export class RoleTimelineChannelService {
+ public readonly shouldShare = RoleTimelineChannel.shouldShare;
+ public readonly requireCredential = RoleTimelineChannel.requireCredential;
+
+ constructor(
+ private noteEntityService: NoteEntityService,
+ ) {
+ }
+
+ @bindThis
+ public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
+ return new RoleTimelineChannel(
+ this.noteEntityService,
+ id,
+ connection,
+ );
+ }
+}
diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts
index ed73897e73..d9dba682cd 100644
--- a/packages/backend/src/server/api/stream/types.ts
+++ b/packages/backend/src/server/api/stream/types.ts
@@ -148,6 +148,10 @@ export interface AntennaStreamTypes {
note: Note;
}
+export interface RoleTimelineStreamTypes {
+ note: Packed<'Note'>;
+}
+
export interface AdminStreamTypes {
newAbuseUserReport: {
id: AbuseUserReport['id'];
@@ -168,7 +172,7 @@ type EventUnionFromDictionary<
> = U[keyof U];
// redis通すとDateのインスタンスはstringに変換されるので
-type Serialized<T> = {
+export type Serialized<T> = {
[K in keyof T]:
T[K] extends Date
? string
@@ -209,6 +213,10 @@ export type StreamMessages = {
name: `userListStream:${UserList['id']}`;
payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
};
+ roleTimeline: {
+ name: `roleTimelineStream:${Role['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
+ };
antenna: {
name: `antennaStream:${Antenna['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;
diff --git a/packages/backend/src/server/web/ClientLoggerService.ts b/packages/backend/src/server/web/ClientLoggerService.ts
new file mode 100644
index 0000000000..6a882aa766
--- /dev/null
+++ b/packages/backend/src/server/web/ClientLoggerService.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import type Logger from '@/logger.js';
+import { LoggerService } from '@/core/LoggerService.js';
+
+@Injectable()
+export class ClientLoggerService {
+ public logger: Logger;
+
+ constructor(
+ private loggerService: LoggerService,
+ ) {
+ this.logger = this.loggerService.getLogger('client');
+ }
+}
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 99ae1b7af6..50b23a0682 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -1,6 +1,7 @@
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
+import { v4 as uuid } from 'uuid';
import { createBullBoard } from '@bull-board/api';
import { BullAdapter } from '@bull-board/api/bullAdapter.js';
import { FastifyAdapter } from '@bull-board/fastify';
@@ -26,6 +27,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
+import type Logger from '@/logger.js';
import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
@@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' };
import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js';
import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
+import { ClientLoggerService } from './ClientLoggerService.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`;
@Injectable()
export class ClientServerService {
+ private logger: Logger;
+
constructor(
@Inject(DI.config)
private config: Config,
@@ -85,6 +90,7 @@ export class ClientServerService {
private urlPreviewService: UrlPreviewService,
private feedService: FeedService,
private roleService: RoleService,
+ private clientLoggerService: ClientLoggerService,
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@@ -649,6 +655,24 @@ export class ClientServerService {
return await renderBase(reply);
});
+ fastify.setErrorHandler(async (error, request, reply) => {
+ const errId = uuid();
+ this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, {
+ path: request.routerPath,
+ params: request.params,
+ query: request.query,
+ code: error.name,
+ stack: error.stack,
+ id: errId,
+ });
+ reply.code(500);
+ reply.header('Cache-Control', 'max-age=10, must-revalidate');
+ return await reply.view('error', {
+ code: error.code,
+ id: errId,
+ });
+ });
+
done();
}
}
diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css
new file mode 100644
index 0000000000..ab913f7a9f
--- /dev/null
+++ b/packages/backend/src/server/web/error.css
@@ -0,0 +1,110 @@
+* {
+ font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+}
+
+#misskey_app,
+#splash {
+ display: none !important;
+}
+
+body,
+html {
+ background-color: #222;
+ color: #dfddcc;
+ justify-content: center;
+ margin: auto;
+ padding: 10px;
+ text-align: center;
+}
+
+button {
+ border-radius: 999px;
+ padding: 0px 12px 0px 12px;
+ border: none;
+ cursor: pointer;
+ margin-bottom: 12px;
+}
+
+.button-big {
+ background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
+ line-height: 50px;
+}
+
+.button-big:hover {
+ background: rgb(153, 204, 0);
+}
+
+.button-small {
+ background: #444;
+ line-height: 40px;
+}
+
+.button-small:hover {
+ background: #555;
+}
+
+.button-label-big {
+ color: #222;
+ font-weight: bold;
+ font-size: 20px;
+ padding: 12px;
+}
+
+.button-label-small {
+ color: rgb(153, 204, 0);
+ font-size: 16px;
+ padding: 12px;
+}
+
+a {
+ color: rgb(134, 179, 0);
+ text-decoration: none;
+}
+
+p,
+li {
+ font-size: 16px;
+}
+
+.dont-worry,
+#msg {
+ font-size: 18px;
+}
+
+.icon-warning {
+ color: #dec340;
+ height: 4rem;
+ padding-top: 2rem;
+}
+
+h1 {
+ font-size: 32px;
+}
+
+code {
+ display: block;
+ font-family: Fira, FiraCode, monospace;
+ background: #333;
+ padding: 0.5rem 1rem;
+ max-width: 40rem;
+ border-radius: 10px;
+ justify-content: center;
+ margin: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+summary {
+ cursor: pointer;
+}
+
+summary > * {
+ display: inline;
+ white-space: pre-wrap;
+}
+
+@media screen and (max-width: 500px) {
+ details {
+ width: 50%;
+ }
+} \ No newline at end of file
diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug
new file mode 100644
index 0000000000..b177ae4110
--- /dev/null
+++ b/packages/backend/src/server/web/views/error.pug
@@ -0,0 +1,65 @@
+doctype html
+
+//
+ -
+ _____ _ _
+ | |_|___ ___| |_ ___ _ _
+ | | | | |_ -|_ -| '_| -_| | |
+ |_|_|_|_|___|___|_,_|___|_ |
+ |___|
+ Thank you for using Misskey!
+ If you are reading this message... how about joining the development?
+ https://github.com/misskey-dev/misskey
+
+
+html
+
+ head
+ meta(charset='utf-8')
+ meta(name='viewport' content='width=device-width, initial-scale=1')
+ meta(name='application-name' content='Misskey')
+ meta(name='referrer' content='origin')
+
+ title
+ block title
+ = 'An error has occurred... | Misskey'
+
+ style
+ include ../error.css
+
+body
+ svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
+ path(stroke="none", d="M0 0h24v24H0z", fill="none")
+ path(d="M12 9v2m0 4v.01")
+ path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
+
+ h1 An error has occurred!
+
+ button.button-big(onclick="location.reload();")
+ span.button-label-big Refresh
+
+ p.dont-worry Don't worry, it's (probably) not your fault.
+
+ p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
+
+ div#errors
+ code.
+ ERROR CODE: #{code}
+ ERROR ID: #{id}
+
+ p You may also try the following options:
+
+ p Update your os and browser.
+ p Disable an adblocker.
+
+ a(href="/flush")
+ button.button-small
+ span.button-label-small Clear preferences and cache
+ br
+ a(href="/cli")
+ button.button-small
+ span.button-label-small Start the simple client
+ br
+ a(href="/bios")
+ button.button-small
+ span.button-label-small Start the repair tool
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
new file mode 100644
index 0000000000..bc3455e346
--- /dev/null
+++ b/packages/backend/test/e2e/users.ts
@@ -0,0 +1,868 @@
+process.env.NODE_ENV = 'test';
+
+import * as assert from 'assert';
+import { inspect } from 'node:util';
+import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import type { Packed } from '@/misc/json-schema.js';
+import {
+ signup,
+ post,
+ page,
+ role,
+ startServer,
+ api,
+ successfulApiCall,
+ failedApiCall,
+ uploadFile,
+} from '../utils.js';
+import type * as misskey from 'misskey-js';
+import type { INestApplicationContext } from '@nestjs/common';
+
+describe('ユーザー', () => {
+ // エンティティとしてのユーザーを主眼においたテストを記述する
+ // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする)
+
+ const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => {
+ return Object.entries({ ...orig })
+ .filter(([, value]) => value !== undefined)
+ .reduce((obj: Partial<T>, [key, value]) => {
+ obj[key as keyof T] = value;
+ return obj;
+ }, {});
+ };
+
+ // FIXME: 足りないキーがたくさんある
+ type UserLite = misskey.entities.UserLite & {
+ badgeRoles: any[],
+ };
+
+ type UserDetailedNotMe = UserLite &
+ misskey.entities.UserDetailed & {
+ roles: any[],
+ };
+
+ type MeDetailed = UserDetailedNotMe &
+ misskey.entities.MeDetailed & {
+ showTimelineReplies: boolean,
+ achievements: object[],
+ loggedInDays: number,
+ policies: object,
+ };
+
+ type User = MeDetailed & { token: string };
+
+ const show = async (id: string, me = alice): Promise<MeDetailed | UserDetailedNotMe> => {
+ return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any;
+ };
+
+ const userLite = (user: User): Partial<UserLite> => {
+ return stripUndefined({
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ host: user.host,
+ avatarUrl: user.avatarUrl,
+ avatarBlurhash: user.avatarBlurhash,
+ isBot: user.isBot,
+ isCat: user.isCat,
+ instance: user.instance,
+ emojis: user.emojis,
+ onlineStatus: user.onlineStatus,
+ badgeRoles: user.badgeRoles,
+
+ // BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。
+ isAdmin: undefined,
+ isModerator: undefined,
+ });
+ };
+
+ const userDetailedNotMe = (user: User): Partial<UserDetailedNotMe> => {
+ return stripUndefined({
+ ...userLite(user),
+ url: user.url,
+ uri: user.uri,
+ movedToUri: user.movedToUri,
+ alsoKnownAs: user.alsoKnownAs,
+ createdAt: user.createdAt,
+ updatedAt: user.updatedAt,
+ lastFetchedAt: user.lastFetchedAt,
+ bannerUrl: user.bannerUrl,
+ bannerBlurhash: user.bannerBlurhash,
+ isLocked: user.isLocked,
+ isSilenced: user.isSilenced,
+ isSuspended: user.isSuspended,
+ description: user.description,
+ location: user.location,
+ birthday: user.birthday,
+ lang: user.lang,
+ fields: user.fields,
+ followersCount: user.followersCount,
+ followingCount: user.followingCount,
+ notesCount: user.notesCount,
+ pinnedNoteIds: user.pinnedNoteIds,
+ pinnedNotes: user.pinnedNotes,
+ pinnedPageId: user.pinnedPageId,
+ pinnedPage: user.pinnedPage,
+ publicReactions: user.publicReactions,
+ ffVisibility: user.ffVisibility,
+ twoFactorEnabled: user.twoFactorEnabled,
+ usePasswordLessLogin: user.usePasswordLessLogin,
+ securityKeys: user.securityKeys,
+ roles: user.roles,
+ });
+ };
+
+ const userDetailedNotMeWithRelations = (user: User): Partial<UserDetailedNotMe> => {
+ return stripUndefined({
+ ...userDetailedNotMe(user),
+ isFollowing: user.isFollowing ?? false,
+ isFollowed: user.isFollowed ?? false,
+ hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false,
+ hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false,
+ isBlocking: user.isBlocking ?? false,
+ isBlocked: user.isBlocked ?? false,
+ isMuted: user.isMuted ?? false,
+ isRenoteMuted: user.isRenoteMuted ?? false,
+ });
+ };
+
+ const meDetailed = (user: User, security = false): Partial<MeDetailed> => {
+ return stripUndefined({
+ ...userDetailedNotMe(user),
+ avatarId: user.avatarId,
+ bannerId: user.bannerId,
+ isModerator: user.isModerator,
+ isAdmin: user.isAdmin,
+ injectFeaturedNote: user.injectFeaturedNote,
+ receiveAnnouncementEmail: user.receiveAnnouncementEmail,
+ alwaysMarkNsfw: user.alwaysMarkNsfw,
+ autoSensitive: user.autoSensitive,
+ carefulBot: user.carefulBot,
+ autoAcceptFollowed: user.autoAcceptFollowed,
+ noCrawle: user.noCrawle,
+ isExplorable: user.isExplorable,
+ isDeleted: user.isDeleted,
+ hideOnlineStatus: user.hideOnlineStatus,
+ hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
+ hasUnreadMentions: user.hasUnreadMentions,
+ hasUnreadAnnouncement: user.hasUnreadAnnouncement,
+ hasUnreadAntenna: user.hasUnreadAntenna,
+ hasUnreadChannel: user.hasUnreadChannel,
+ hasUnreadNotification: user.hasUnreadNotification,
+ hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
+ mutedWords: user.mutedWords,
+ mutedInstances: user.mutedInstances,
+ mutingNotificationTypes: user.mutingNotificationTypes,
+ emailNotificationTypes: user.emailNotificationTypes,
+ showTimelineReplies: user.showTimelineReplies,
+ achievements: user.achievements,
+ loggedInDays: user.loggedInDays,
+ policies: user.policies,
+ ...(security ? {
+ email: user.email,
+ emailVerified: user.emailVerified,
+ securityKeysList: user.securityKeysList,
+ } : {}),
+ });
+ };
+
+ let app: INestApplicationContext;
+
+ let root: User;
+ let alice: User;
+ let aliceNote: misskey.entities.Note;
+ let alicePage: misskey.entities.Page;
+ let aliceList: misskey.entities.UserList;
+
+ let bob: User;
+ let bobNote: misskey.entities.Note;
+
+ let carol: User;
+ let dave: User;
+ let ellen: User;
+ let frank: User;
+
+ let usersReplying: User[];
+
+ let userNoNote: User;
+ let userNotExplorable: User;
+ let userLocking: User;
+ let userAdmin: User;
+ let roleAdmin: any;
+ let userModerator: User;
+ let roleModerator: any;
+ let userRolePublic: User;
+ let rolePublic: any;
+ let userRoleBadge: User;
+ let roleBadge: any;
+ let userSilenced: User;
+ let roleSilenced: any;
+ let userSuspended: User;
+ let userDeletedBySelf: User;
+ let userDeletedByAdmin: User;
+ let userFollowingAlice: User;
+ let userFollowedByAlice: User;
+ let userBlockingAlice: User;
+ let userBlockedByAlice: User;
+ let userMutingAlice: User;
+ let userMutedByAlice: User;
+ let userRnMutingAlice: User;
+ let userRnMutedByAlice: User;
+ let userFollowRequesting: User;
+ let userFollowRequested: User;
+
+ beforeAll(async () => {
+ app = await startServer();
+ }, 1000 * 60 * 2);
+
+ beforeAll(async () => {
+ root = await signup({ username: 'alice' });
+ alice = root;
+ aliceNote = await post(alice, { text: 'test' }) as any;
+ alicePage = await page(alice);
+ aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body;
+ bob = await signup({ username: 'bob' });
+ bobNote = await post(bob, { text: 'test' }) as any;
+ carol = await signup({ username: 'carol' });
+ dave = await signup({ username: 'dave' });
+ ellen = await signup({ username: 'ellen' });
+ frank = await signup({ username: 'frank' });
+
+ // @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする
+ usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => {
+ const u = await signup({ username: `replying${i}` });
+ for (let j = 0; j < 10 - i; j++) {
+ const p = await post(u, { text: `test${j}` });
+ await post(alice, { text: `@${u.username} test${j}`, replyId: p.id });
+ }
+
+ return (await acc).concat(u);
+ }, Promise.resolve([] as User[]));
+
+ userNoNote = await signup({ username: 'userNoNote' });
+ userNotExplorable = await signup({ username: 'userNotExplorable' });
+ await post(userNotExplorable, { text: 'test' });
+ await api('i/update', { isExplorable: false }, userNotExplorable);
+ userLocking = await signup({ username: 'userLocking' });
+ await post(userLocking, { text: 'test' });
+ await api('i/update', { isLocked: true }, userLocking);
+ userAdmin = await signup({ username: 'userAdmin' });
+ roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' });
+ await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root);
+ userModerator = await signup({ username: 'userModerator' });
+ roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' });
+ await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root);
+ userRolePublic = await signup({ username: 'userRolePublic' });
+ rolePublic = await role(root, { isPublic: true, name: 'Public Role' });
+ await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root);
+ userRoleBadge = await signup({ username: 'userRoleBadge' });
+ roleBadge = await role(root, { asBadge: true, name: 'Badge Role' });
+ await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root);
+ userSilenced = await signup({ username: 'userSilenced' });
+ await post(userSilenced, { text: 'test' });
+ roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } });
+ await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root);
+ userSuspended = await signup({ username: 'userSuspended' });
+ await post(userSuspended, { text: 'test' });
+ await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended });
+ await api('admin/suspend-user', { userId: userSuspended.id }, root);
+ userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' });
+ await post(userDeletedBySelf, { text: 'test' });
+ await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf);
+ userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' });
+ await post(userDeletedByAdmin, { text: 'test' });
+ await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root);
+ userFollowingAlice = await signup({ username: 'userFollowingAlice' });
+ await post(userFollowingAlice, { text: 'test' });
+ await api('following/create', { userId: alice.id }, userFollowingAlice);
+ userFollowedByAlice = await signup({ username: 'userFollowedByAlice' });
+ await post(userFollowedByAlice, { text: 'test' });
+ await api('following/create', { userId: userFollowedByAlice.id }, alice);
+ userBlockingAlice = await signup({ username: 'userBlockingAlice' });
+ await post(userBlockingAlice, { text: 'test' });
+ await api('blocking/create', { userId: alice.id }, userBlockingAlice);
+ userBlockedByAlice = await signup({ username: 'userBlockedByAlice' });
+ await post(userBlockedByAlice, { text: 'test' });
+ await api('blocking/create', { userId: userBlockedByAlice.id }, alice);
+ userMutingAlice = await signup({ username: 'userMutingAlice' });
+ await post(userMutingAlice, { text: 'test' });
+ await api('mute/create', { userId: alice.id }, userMutingAlice);
+ userMutedByAlice = await signup({ username: 'userMutedByAlice' });
+ await post(userMutedByAlice, { text: 'test' });
+ await api('mute/create', { userId: userMutedByAlice.id }, alice);
+ userRnMutingAlice = await signup({ username: 'userRnMutingAlice' });
+ await post(userRnMutingAlice, { text: 'test' });
+ await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice);
+ userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' });
+ await post(userRnMutedByAlice, { text: 'test' });
+ await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice);
+ userFollowRequesting = await signup({ username: 'userFollowRequesting' });
+ await post(userFollowRequesting, { text: 'test' });
+ userFollowRequested = userLocking;
+ await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting);
+ }, 1000 * 60 * 10);
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ alice = {
+ ...alice,
+ ...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any,
+ };
+ aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice });
+ });
+
+ //#region サインアップ(signup)
+
+ test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => {
+ // SignupApiService.ts
+ const response = await successfulApiCall({
+ endpoint: 'signup',
+ parameters: { username: 'zoe', password: 'password' },
+ user: undefined,
+ }) as unknown as User; // BUG MeDetailedに足りないキーがある
+
+ // signupの時はtokenが含まれる特別なMeDetailedが返ってくる
+ assert.match(response.token, /[a-zA-Z0-9]{16}/);
+
+ // UserLite
+ assert.match(response.id, /[0-9a-z]{10}/);
+ assert.strictEqual(response.name, null);
+ assert.strictEqual(response.username, 'zoe');
+ assert.strictEqual(response.host, null);
+ assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
+ assert.strictEqual(response.avatarBlurhash, null);
+ assert.strictEqual(response.isBot, false);
+ assert.strictEqual(response.isCat, false);
+ assert.strictEqual(response.instance, undefined);
+ assert.deepStrictEqual(response.emojis, {});
+ assert.strictEqual(response.onlineStatus, 'unknown');
+ assert.deepStrictEqual(response.badgeRoles, []);
+ // UserDetailedNotMeOnly
+ assert.strictEqual(response.url, null);
+ assert.strictEqual(response.uri, null);
+ assert.strictEqual(response.movedToUri, null);
+ assert.strictEqual(response.alsoKnownAs, null);
+ assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString());
+ assert.strictEqual(response.updatedAt, null);
+ assert.strictEqual(response.lastFetchedAt, null);
+ assert.strictEqual(response.bannerUrl, null);
+ assert.strictEqual(response.bannerBlurhash, null);
+ assert.strictEqual(response.isLocked, false);
+ assert.strictEqual(response.isSilenced, false);
+ assert.strictEqual(response.isSuspended, false);
+ assert.strictEqual(response.description, null);
+ assert.strictEqual(response.location, null);
+ assert.strictEqual(response.birthday, null);
+ assert.strictEqual(response.lang, null);
+ assert.deepStrictEqual(response.fields, []);
+ assert.strictEqual(response.followersCount, 0);
+ assert.strictEqual(response.followingCount, 0);
+ assert.strictEqual(response.notesCount, 0);
+ assert.deepStrictEqual(response.pinnedNoteIds, []);
+ assert.deepStrictEqual(response.pinnedNotes, []);
+ assert.strictEqual(response.pinnedPageId, null);
+ assert.strictEqual(response.pinnedPage, null);
+ assert.strictEqual(response.publicReactions, false);
+ assert.strictEqual(response.ffVisibility, 'public');
+ assert.strictEqual(response.twoFactorEnabled, false);
+ assert.strictEqual(response.usePasswordLessLogin, false);
+ assert.strictEqual(response.securityKeys, false);
+ assert.deepStrictEqual(response.roles, []);
+
+ // MeDetailedOnly
+ assert.strictEqual(response.avatarId, null);
+ assert.strictEqual(response.bannerId, null);
+ assert.strictEqual(response.isModerator, false);
+ assert.strictEqual(response.isAdmin, false);
+ assert.strictEqual(response.injectFeaturedNote, true);
+ assert.strictEqual(response.receiveAnnouncementEmail, true);
+ assert.strictEqual(response.alwaysMarkNsfw, false);
+ assert.strictEqual(response.autoSensitive, false);
+ assert.strictEqual(response.carefulBot, false);
+ assert.strictEqual(response.autoAcceptFollowed, true);
+ assert.strictEqual(response.noCrawle, false);
+ assert.strictEqual(response.isExplorable, true);
+ assert.strictEqual(response.isDeleted, false);
+ assert.strictEqual(response.hideOnlineStatus, false);
+ assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
+ assert.strictEqual(response.hasUnreadMentions, false);
+ assert.strictEqual(response.hasUnreadAnnouncement, false);
+ assert.strictEqual(response.hasUnreadAntenna, false);
+ assert.strictEqual(response.hasUnreadChannel, false);
+ assert.strictEqual(response.hasUnreadNotification, false);
+ assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
+ assert.deepStrictEqual(response.mutedWords, []);
+ assert.deepStrictEqual(response.mutedInstances, []);
+ assert.deepStrictEqual(response.mutingNotificationTypes, []);
+ assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
+ assert.strictEqual(response.showTimelineReplies, false);
+ assert.deepStrictEqual(response.achievements, []);
+ assert.deepStrictEqual(response.loggedInDays, 0);
+ assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
+ assert.notStrictEqual(response.email, undefined);
+ assert.strictEqual(response.emailVerified, false);
+ assert.deepStrictEqual(response.securityKeysList, []);
+ });
+
+ //#endregion
+ //#region 自分の情報(i)
+
+ test('を読み取ることができる。(自分)', async () => {
+ const response = await successfulApiCall({
+ endpoint: 'i',
+ parameters: {},
+ user: userNoNote,
+ });
+ const expected = meDetailed(userNoNote, true);
+ expected.loggedInDays = 1; // iはloggedInDaysを更新する
+ assert.deepStrictEqual(response, expected);
+ });
+
+ //#endregion
+ //#region 自分の情報の更新(i/update)
+
+ test.each([
+ { parameters: (): object => ({ name: null }) },
+ { parameters: (): object => ({ name: 'x'.repeat(50) }) },
+ { parameters: (): object => ({ name: 'x' }) },
+ { parameters: (): object => ({ name: 'My name' }) },
+ { parameters: (): object => ({ description: null }) },
+ { parameters: (): object => ({ description: 'x'.repeat(1500) }) },
+ { parameters: (): object => ({ description: 'x' }) },
+ { parameters: (): object => ({ description: 'My description' }) },
+ { parameters: (): object => ({ location: null }) },
+ { parameters: (): object => ({ location: 'x'.repeat(50) }) },
+ { parameters: (): object => ({ location: 'x' }) },
+ { parameters: (): object => ({ location: 'My location' }) },
+ { parameters: (): object => ({ birthday: '0000-00-00' }) },
+ { parameters: (): object => ({ birthday: '9999-99-99' }) },
+ { parameters: (): object => ({ lang: 'en-US' }) },
+ { parameters: (): object => ({ fields: [] }) },
+ { parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) },
+ { parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない
+ { parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) },
+ { parameters: (): object => ({ isLocked: true }) },
+ { parameters: (): object => ({ isLocked: false }) },
+ { parameters: (): object => ({ isExplorable: false }) },
+ { parameters: (): object => ({ isExplorable: true }) },
+ { parameters: (): object => ({ hideOnlineStatus: true }) },
+ { parameters: (): object => ({ hideOnlineStatus: false }) },
+ { parameters: (): object => ({ publicReactions: false }) },
+ { parameters: (): object => ({ publicReactions: true }) },
+ { parameters: (): object => ({ autoAcceptFollowed: true }) },
+ { parameters: (): object => ({ autoAcceptFollowed: false }) },
+ { parameters: (): object => ({ noCrawle: true }) },
+ { parameters: (): object => ({ noCrawle: false }) },
+ { parameters: (): object => ({ isBot: true }) },
+ { parameters: (): object => ({ isBot: false }) },
+ { parameters: (): object => ({ isCat: true }) },
+ { parameters: (): object => ({ isCat: false }) },
+ { parameters: (): object => ({ showTimelineReplies: true }) },
+ { parameters: (): object => ({ showTimelineReplies: false }) },
+ { parameters: (): object => ({ injectFeaturedNote: true }) },
+ { parameters: (): object => ({ injectFeaturedNote: false }) },
+ { parameters: (): object => ({ receiveAnnouncementEmail: true }) },
+ { parameters: (): object => ({ receiveAnnouncementEmail: false }) },
+ { parameters: (): object => ({ alwaysMarkNsfw: true }) },
+ { parameters: (): object => ({ alwaysMarkNsfw: false }) },
+ { parameters: (): object => ({ autoSensitive: true }) },
+ { parameters: (): object => ({ autoSensitive: false }) },
+ { parameters: (): object => ({ ffVisibility: 'private' }) },
+ { parameters: (): object => ({ ffVisibility: 'followers' }) },
+ { parameters: (): object => ({ ffVisibility: 'public' }) },
+ { parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) },
+ { parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) },
+ { parameters: (): object => ({ mutedWords: [] }) },
+ { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) },
+ { parameters: (): object => ({ mutedInstances: [] }) },
+ { parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) },
+ { parameters: (): object => ({ mutingNotificationTypes: [] }) },
+ { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) },
+ { parameters: (): object => ({ emailNotificationTypes: [] }) },
+ ] as const)('を書き換えることができる($#)', async ({ parameters }) => {
+ const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice });
+ const expected = { ...meDetailed(alice, true), ...parameters() };
+ assert.deepStrictEqual(response, expected, inspect(parameters()));
+ });
+
+ test('を書き換えることができる(Avatar)', async () => {
+ const aliceFile = (await uploadFile(alice)).body;
+ const parameters = { avatarId: aliceFile.id };
+ const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
+ assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
+ assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/);
+ const expected = {
+ ...meDetailed(alice, true),
+ avatarId: aliceFile.id,
+ avatarBlurhash: response.avatarBlurhash,
+ avatarUrl: response.avatarUrl,
+ };
+ assert.deepStrictEqual(response, expected, inspect(parameters));
+
+ if (1) return; // BUG 521eb95 以降アバターのリセットができない。
+ const parameters2 = { avatarId: null };
+ const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
+ const expected2 = {
+ ...meDetailed(alice, true),
+ avatarId: null,
+ avatarBlurhash: null,
+ avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる
+ };
+ assert.deepStrictEqual(response2, expected2, inspect(parameters));
+ });
+
+ test('を書き換えることができる(Banner)', async () => {
+ const aliceFile = (await uploadFile(alice)).body;
+ const parameters = { bannerId: aliceFile.id };
+ const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
+ assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
+ assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/);
+ const expected = {
+ ...meDetailed(alice, true),
+ bannerId: aliceFile.id,
+ bannerBlurhash: response.bannerBlurhash,
+ bannerUrl: response.bannerUrl,
+ };
+ assert.deepStrictEqual(response, expected, inspect(parameters));
+
+ if (1) return; // BUG 521eb95 以降バナーのリセットができない。
+ const parameters2 = { bannerId: null };
+ const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
+ const expected2 = {
+ ...meDetailed(alice, true),
+ bannerId: null,
+ bannerBlurhash: null,
+ bannerUrl: null,
+ };
+ assert.deepStrictEqual(response2, expected2, inspect(parameters));
+ });
+
+ //#endregion
+ //#region 自分の情報の更新(i/pin, i/unpin)
+
+ test('を書き換えることができる(ピン止めノート)', async () => {
+ const parameters = { noteId: aliceNote.id };
+ const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice });
+ const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] };
+ assert.deepStrictEqual(response, expected);
+
+ const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice });
+ const expected2 = meDetailed(alice, false);
+ assert.deepStrictEqual(response2, expected2);
+ });
+
+ //#endregion
+ //#region ユーザー(users)
+
+ test.each([
+ { label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id },
+ { label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+ { label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+ { label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+ { label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+ { label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+ { label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+ ] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => {
+ const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
+
+ // 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する
+ const users = await Promise.all(response.map(u => show(u.id)));
+ const expected = users.sort((x, y) => {
+ const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
+ return index * (parameters.sort?.startsWith('+') ? -1 : 1);
+ });
+ assert.deepStrictEqual(response, expected);
+ });
+ test.each([
+ { label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true },
+ { label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true },
+ { label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true },
+ { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true },
+ { label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+ { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+ { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
+ { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+ { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+ ] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => {
+ const parameters = { limit: 100 };
+ const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
+ const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected);
+ });
+ test.todo('をリスト形式で取得することができる(リモート, hostname指定)');
+ test.todo('をリスト形式で取得することができる(pagenation)');
+
+ //#endregion
+ //#region ユーザー情報(users/show)
+
+ test.each([
+ { label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed },
+ { label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations },
+ { label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe },
+ { label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed },
+ { label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations },
+ { label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe },
+ ] as const)('を取得することができる($label)', async ({ parameters, user, type }) => {
+ const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() });
+ const expected = type(alice);
+ assert.deepStrictEqual(response, expected);
+ });
+ test.each([
+ { label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin },
+ { label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined },
+ { label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator },
+ { label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined },
+ { label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced },
+ { label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
+ { label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted },
+ { label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
+ { label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted },
+ { label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
+ { label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing },
+ { label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed },
+ { label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking },
+ { label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked },
+ { label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted },
+ { label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted },
+ { label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou },
+ { label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou },
+ ] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => {
+ const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice });
+ assert.strictEqual(selector(response), (expected ?? ((): true => true))());
+ });
+ test('を取得することができ、Publicなロールがセットされていること', async () => {
+ const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice });
+ assert.deepStrictEqual(response.badgeRoles, []);
+ assert.deepStrictEqual(response.roles, [{
+ id: rolePublic.id,
+ name: rolePublic.name,
+ color: rolePublic.color,
+ iconUrl: rolePublic.iconUrl,
+ description: rolePublic.description,
+ isModerator: rolePublic.isModerator,
+ isAdministrator: rolePublic.isAdministrator,
+ displayOrder: rolePublic.displayOrder,
+ }]);
+ });
+ test('を取得することができ、バッヂロールがセットされていること', async () => {
+ const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
+ assert.deepStrictEqual(response.badgeRoles, [{
+ name: roleBadge.name,
+ iconUrl: roleBadge.iconUrl,
+ displayOrder: roleBadge.displayOrder,
+ }]);
+ assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない
+ });
+ test('をID指定のリスト形式で取得することができる(空)', async () => {
+ const parameters = { userIds: [] };
+ const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
+ const expected: [] = [];
+ assert.deepStrictEqual(response, expected);
+ });
+ test('をID指定のリスト形式で取得することができる', async() => {
+ const parameters = { userIds: [bob.id, alice.id, carol.id] };
+ const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
+ const expected = [
+ await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }),
+ await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }),
+ await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }),
+ ];
+ assert.deepStrictEqual(response, expected);
+ });
+ test.each([
+ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+ { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+ { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+ { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+ { label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+ { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+ { label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root },
+ // BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる
+ //{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true },
+ { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+ { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+ ] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => {
+ const parameters = { userIds: [user().id] };
+ const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice });
+ const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)];
+ assert.deepStrictEqual(response, expected);
+ });
+ test.todo('をID指定のリスト形式で取得することができる(リモート)');
+
+ //#endregion
+ //#region 検索(users/search)
+
+ test('を検索することができる', async () => {
+ const parameters = { query: 'carol', limit: 10 };
+ const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
+ const expected = [await show(carol.id)];
+ assert.deepStrictEqual(response, expected);
+ });
+ test('を検索することができる(UserLite)', async () => {
+ const parameters = { query: 'carol', detail: false, limit: 10 };
+ const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
+ const expected = [userLite(await show(carol.id))];
+ assert.deepStrictEqual(response, expected);
+ });
+ test.each([
+ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+ { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+ { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+ { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+ { label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+ { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+ { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
+ { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+ { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+ ] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => {
+ const parameters = { query: user().username, limit: 1 };
+ const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
+ const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ assert.deepStrictEqual(response, expected);
+ });
+ test.todo('を検索することができる(リモート)');
+ test.todo('を検索することができる(pagenation)');
+
+ //#endregion
+ //#region ID指定検索(users/search-by-username-and-host)
+
+ test.each([
+ { label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] },
+ { label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] },
+ { label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] },
+ { label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] },
+ { label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] },
+ { label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] },
+ { label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] },
+ { label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] },
+ { label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] },
+ ])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => {
+ const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
+ const expected = await Promise.all(user().map(u => show(u.id)));
+ assert.deepStrictEqual(response, expected);
+ });
+ test.each([
+ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+ { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+ { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+ { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+ { label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+ { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+ { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
+ { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+ { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+ ] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => {
+ const parameters = { username: user().username };
+ const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
+ const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ assert.deepStrictEqual(response, expected);
+ });
+ test.todo('をID&ホスト指定で検索できる(リモート)');
+
+ //#endregion
+ //#region ID指定検索(users/get-frequently-replied-users)
+
+ test('がよくリプライをするユーザーのリストを取得できる', async () => {
+ const parameters = { userId: alice.id, limit: 5 };
+ const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
+ const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({
+ user: await show(s.id),
+ weight: (usersReplying.length - i) / usersReplying.length,
+ })));
+ assert.deepStrictEqual(response, expected);
+ });
+ test.each([
+ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+ { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+ { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+ { label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true },
+ { label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+ { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+ { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended },
+ { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+ { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+ ] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => {
+ const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0];
+ await post(alice, { text: `@${user().username} test`, replyId: replyTo.id });
+ const parameters = { userId: alice.id, limit: 100 };
+ const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
+ const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected);
+ });
+
+ //#endregion
+ //#region ハッシュタグ(hashtags/users)
+
+ test.each([
+ { label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+ { label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+ { label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+ { label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+ { label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+ { label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+ ] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => {
+ const hashtag = 'test_hashtag';
+ await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice });
+ const parameters = { tag: hashtag, limit: 5, ...sort };
+ const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
+ const users = await Promise.all(response.map(u => show(u.id)));
+ const expected = users.sort((x, y) => {
+ const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
+ return index * (parameters.sort.startsWith('+') ? -1 : 1);
+ });
+ assert.deepStrictEqual(response, expected);
+ });
+ test.each([
+ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+ { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+ { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+ { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+ { label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+ { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+ { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
+ { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+ { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+ ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => {
+ const hashtag = `user_test${user().username}`;
+ if (user() !== userSuspended) {
+ // サスペンドユーザーはupdateできない。
+ await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() });
+ }
+ const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const;
+ const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
+ const expected = [await show(user().id)];
+ assert.deepStrictEqual(response, expected);
+ });
+ test.todo('をハッシュタグ指定で取得することができる(リモート)');
+
+ //#endregion
+ //#region オススメユーザー(users/recommendation)
+
+ // BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note"
+ test.skip('のオススメを取得することができる', async () => {
+ const parameters = {};
+ const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice });
+ const expected = await Promise.all(response.map(u => show(u.id)));
+ assert.deepStrictEqual(response, expected);
+ });
+
+ //#endregion
+ //#region ピン止めユーザー(pinned-users)
+
+ test('のピン止めユーザーを取得することができる', async () => {
+ await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root });
+ const parameters = {} as const;
+ const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice });
+ const expected = await Promise.all([bob, carol].map(u => show(u.id)));
+ assert.deepStrictEqual(response, expected);
+ });
+
+ //#endregion
+
+ test.todo('を管理人として確認することができる(admin/show-user)');
+ test.todo('を管理人として確認することができる(admin/show-users)');
+ test.todo('をサーバー向けに取得することができる(federation/users)');
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 4f501a8726..809ed2c66c 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -6,6 +6,7 @@ import WebSocket from 'ws';
import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom';
+import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js';
@@ -31,12 +32,12 @@ export type ApiRequest = {
};
export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
- status: number,
-} = { status: 200 }): Promise<T> => {
+ status?: number,
+} = {}): Promise<T> => {
const { endpoint, parameters, user } = request;
- const { status } = assertion;
const res = await api(endpoint, parameters, user);
- assert.strictEqual(res.status, status, inspect(res.body));
+ const status = assertion.status ?? (res.body == null ? 204 : 200);
+ assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true }));
return res.body;
};
@@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise<any> => {
return res.body;
};
+export const role = async (user: any, role: any = {}, policies: any = {}): Promise<any> => {
+ const res = await api('admin/roles/create', {
+ asBadge: false,
+ canEditMembersByModerator: false,
+ color: null,
+ condFormula: {
+ id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85',
+ type: 'isRemote',
+ },
+ description: '',
+ displayOrder: 0,
+ iconUrl: null,
+ isAdministrator: false,
+ isModerator: false,
+ isPublic: false,
+ name: 'New Role',
+ target: 'manual',
+ policies: {
+ ...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, {
+ priority: 0,
+ useDefault: true,
+ value: v,
+ }]),
+ ...policies,
+ },
+ ...role,
+ }, user);
+ return res.body;
+};
+
interface UploadOptions {
/** Optional, absolute path or relative from ./resources/ */
path?: string | URL;