From 5d56799070006923701dcdaaa61d69c00e034209 Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 12 Apr 2023 11:40:08 +0900 Subject: [PATCH 01/10] feat: role timeline Resolve #10581 --- CHANGELOG.md | 4 +- locales/ja-JP.yml | 1 + .../backend/src/core/GlobalEventService.ts | 7 ++ .../backend/src/core/NoteCreateService.ts | 2 + packages/backend/src/core/RoleService.ts | 23 ++++ packages/backend/src/server/ServerModule.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../server/api/endpoints/antennas/notes.ts | 3 +- .../server/api/endpoints/i/notifications.ts | 3 +- .../src/server/api/endpoints/roles/notes.ts | 109 ++++++++++++++++++ .../src/server/api/stream/ChannelsService.ts | 3 + .../api/stream/channels/role-timeline.ts | 75 ++++++++++++ .../backend/src/server/api/stream/types.ts | 8 ++ .../frontend/src/components/MkTimeline.vue | 10 ++ packages/frontend/src/pages/explore.vue | 2 +- packages/frontend/src/pages/role.vue | 27 ++++- packages/frontend/src/ui/deck.vue | 1 + packages/frontend/src/ui/deck/column-core.vue | 2 + packages/frontend/src/ui/deck/deck-store.ts | 1 + .../src/ui/deck/role-timeline-column.vue | 67 +++++++++++ 21 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/roles/notes.ts create mode 100644 packages/backend/src/server/api/stream/channels/role-timeline.ts create mode 100644 packages/frontend/src/ui/deck/role-timeline-column.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index b1cd253a1c77..bd195790fadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ ## 13.x.x (unreleased) ### General -- カスタム絵文字関連の変更 +- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加 + - Deckのカラムとしても追加可能 +- カスタム絵文字関連の改善 * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 4c5bb60e0c93..092a4aed325a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1943,6 +1943,7 @@ _deck: channel: "チャンネル" mentions: "あなた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 9f4de5f9853a..2c2687a90ce2 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 { @@ -81,6 +83,11 @@ export class GlobalEventService { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishRoleTimelineStream(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 32e4fe7f8acd..79629cb2a83c 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -547,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/RoleService.ts b/packages/backend/src/core/RoleService.ts index 77645e3f0642..2a4271aa986f 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, @@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown { this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); } + @bindThis + public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise { + 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/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 6bae0bafda26..c41e805504dc 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -34,6 +34,7 @@ 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 { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; @Module({ imports: [ @@ -67,6 +68,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 ca89d8285362..689f90287ecb 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 dab897117d59..d0fe6a57c185 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/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index f08c20ae4817..df83fe5f2a51 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -76,11 +76,12 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } + const limit = ps.limit + (ps.untilId ? 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 + 'COUNT', limit); if (noteIdsRes.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 f27b4e86d43a..ba0487f2237f 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 { 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 000000000000..d79528593fc9 --- /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 { + 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); // untilIdに指定したものも含まれるため+1 + const noteIdsRes = await this.redisClient.xrevrange( + `roleTimeline:${role.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', limit); + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + 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/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index f9ef8218c140..c77ba66028af 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 000000000000..9d106c8b2f98 --- /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 ed73897e730a..101f6bf26123 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']; @@ -209,6 +213,10 @@ export type StreamMessages = { name: `userListStream:${UserList['id']}`; payload: EventUnionFromDictionary>; }; + roleTimeline: { + name: `roleTimelineStream:${Role['id']}`; + payload: EventUnionFromDictionary>; + }; antenna: { name: `antennaStream:${Antenna['id']}`; payload: EventUnionFromDictionary>; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 6741e7a18bab..fb0a3a4b67d8 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -15,6 +15,7 @@ const props = defineProps<{ list?: string; antenna?: string; channel?: string; + role?: string; sound?: boolean; }>(); @@ -121,6 +122,15 @@ if (props.src === 'antenna') { channelId: props.channel, }); connection.on('note', prepend); +} else if (props.src === 'role') { + endpoint = 'roles/notes'; + query = { + roleId: props.role, + }; + connection = stream.useChannel('roleTimeline', { + roleId: props.role, + }); + connection.on('note', prepend); } const pagination = { diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index 2131188dde65..5f3728b677a3 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -1,7 +1,7 @@ From 49749b46c4c8914947e0950069bcf4431f157c4f Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Wed, 12 Apr 2023 12:52:14 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(server):=20Misskey=20Web=E3=81=A7?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=83=95=E3=83=AC=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=83=AA=E3=83=BC=E3=81=AA=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=E3=82=92=E5=87=BA=E3=81=99=20(#1059?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * (add) user-friendly error page * Update CHANGELOG.md * (add) cache-control header * Add ClientLoggerService * Log params and query * remove error stack on client * fix pug * 文面を調整 * :art] --------- Co-authored-by: tamaina --- CHANGELOG.md | 2 + gulpfile.js | 2 +- packages/backend/src/server/ServerModule.ts | 2 + .../src/server/web/ClientLoggerService.ts | 14 +++ .../src/server/web/ClientServerService.ts | 24 ++++ packages/backend/src/server/web/error.css | 110 ++++++++++++++++++ .../backend/src/server/web/views/error.pug | 65 +++++++++++ 7 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/server/web/ClientLoggerService.ts create mode 100644 packages/backend/src/server/web/error.css create mode 100644 packages/backend/src/server/web/views/error.pug diff --git a/CHANGELOG.md b/CHANGELOG.md index bd195790fadc..d992cc94a4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - ### Server +- Misskey Webでのサーバーサイドエラー画面を改善 +- Misskey Webでのサーバーサイドエラーのログが残るように - ノート作成時のアンテナ追加パフォーマンスを改善 - フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io diff --git a/gulpfile.js b/gulpfile.js index a04ab4c1ad4e..6507aad60e5e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => { }); gulp.task('build:backend:style', () => { - return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css']) + return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css']) .pipe(cssnano({ zindex: false })) diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index c41e805504dc..da86b2c1d3fc 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -34,6 +34,7 @@ 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({ @@ -43,6 +44,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline. ], providers: [ ClientServerService, + ClientLoggerService, FeedService, UrlPreviewService, ActivityPubServerService, diff --git a/packages/backend/src/server/web/ClientLoggerService.ts b/packages/backend/src/server/web/ClientLoggerService.ts new file mode 100644 index 000000000000..6a882aa76698 --- /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 99ae1b7af6d2..50b23a0682e5 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 000000000000..ab913f7a9fee --- /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 000000000000..b177ae411041 --- /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 From 5c3a4a82245db6e841a7f60f48b81ee78b94d68c Mon Sep 17 00:00:00 2001 From: Nanashia Date: Wed, 12 Apr 2023 13:20:16 +0900 Subject: [PATCH 03/10] test(backend): Add tests for users (#10546) Co-authored-by: tamaina --- packages/backend/test/e2e/users.ts | 868 +++++++++++++++++++++++++++++ packages/backend/test/utils.ts | 39 +- 2 files changed, 903 insertions(+), 4 deletions(-) create mode 100644 packages/backend/test/e2e/users.ts diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts new file mode 100644 index 000000000000..bc3455e3463a --- /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 = (orig: T): Partial => { + return Object.entries({ ...orig }) + .filter(([, value]) => value !== undefined) + .reduce((obj: Partial, [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 => { + return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; + }; + + const userLite = (user: User): Partial => { + 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 => { + 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 => { + 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 => { + 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 4f501a8726cd..809ed2c66cc1 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 (request: ApiRequest, assertion: { - status: number, -} = { status: 200 }): Promise => { + status?: number, +} = {}): Promise => { 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 => { return res.body; }; +export const role = async (user: any, role: any = {}, policies: any = {}): Promise => { + 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; From d06d1e868263e89de1a8f7dbd6636c6cc37df607 Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 12 Apr 2023 16:07:58 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix(backend):=20=E3=82=AB=E3=82=B9?= =?UTF-8?q?=E3=82=BF=E3=83=A0=E7=B5=B5=E6=96=87=E5=AD=97=E3=81=A7=E3=83=AA?= =?UTF-8?q?=E3=82=A2=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=81=93=E3=81=A8=E3=81=8C=E3=81=82=E3=82=8B?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + packages/backend/src/core/CustomEmojiService.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d992cc94a4d7..59149f59d115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - カスタム絵文字関連の改善 * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように +- カスタム絵文字でリアクションできないことがある問題を修正 ### Client - diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 416c3de5a867..c66eaf7ada87 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -44,7 +44,12 @@ 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) => { + return new Map(JSON.parse(value).map((x) => [x.name, { + ...x, + updatedAt: new Date(x.updatedAt), + }])); + }, }); } From 72031e49fc754a470171c79c9ead9f040037fbba Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 12 Apr 2023 16:10:17 +0900 Subject: [PATCH 05/10] Update CustomEmojiService.ts --- packages/backend/src/core/CustomEmojiService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index c66eaf7ada87..072dab7ce2e9 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -45,6 +45,7 @@ export class CustomEmojiService { 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) => { + if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す) return new Map(JSON.parse(value).map((x) => [x.name, { ...x, updatedAt: new Date(x.updatedAt), From e3aeab8122f6be66fd45b457eb1252a4cbe584cd Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 12 Apr 2023 17:02:54 +0900 Subject: [PATCH 06/10] fix type --- packages/backend/src/misc/cache.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index d35414acf7de..f413246a1fc3 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -8,7 +8,7 @@ export class RedisKVCache { private memoryCache: MemoryKVCache; private fetcher: (key: string) => Promise; private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T; + private fromRedisConverter: (value: string) => T | undefined; constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { lifetime: RedisKVCache['lifetime']; @@ -92,7 +92,7 @@ export class RedisSingleCache { private memoryCache: MemorySingleCache; private fetcher: () => Promise; private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T; + private fromRedisConverter: (value: string) => T | undefined; constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { lifetime: RedisSingleCache['lifetime']; From b7d056fb2266a91c4d671f200ef6c9f744f9416f Mon Sep 17 00:00:00 2001 From: hutchisr <42283663+hutchisr@users.noreply.github.com> Date: Wed, 12 Apr 2023 04:22:50 -0700 Subject: [PATCH 07/10] Use unique identifier for each follow request (#10600) Co-authored-by: anemone --- .../backend/src/core/UserFollowingService.ts | 6 +- .../src/server/ActivityPubServerService.ts | 88 +++++++++++++------ 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index dacaa7263adf..a8eded673351 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'); @@ -44,6 +45,9 @@ export class UserFollowingService implements OnModuleInit { constructor( private moduleRef: ModuleRef, + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -411,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/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 579962207419..e13e9265ab52 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; - + // カーソルが指定されている場合 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(); } } From 6ea057f8f89eec482e2b51bbd34ce3b8ebd739c6 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 12 Apr 2023 12:09:28 +0000 Subject: [PATCH 08/10] fix type in CustomEmojiService --- packages/backend/src/core/CustomEmojiService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 072dab7ce2e9..0f26d2266625 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -46,9 +46,9 @@ export class CustomEmojiService { toRedisConverter: (value) => JSON.stringify(Array.from(value.values())), fromRedisConverter: (value) => { if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す) - return new Map(JSON.parse(value).map((x) => [x.name, { + return new Map(JSON.parse(value).map((x: Emoji) => [x.name, { ...x, - updatedAt: new Date(x.updatedAt), + updatedAt: x.updatedAt && new Date(x.updatedAt), }])); }, }); From 3ff5a5ae293a86926043114bc8060846cb36489c Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 12 Apr 2023 12:32:27 +0000 Subject: [PATCH 09/10] fix type in CustomEmojiService 2 --- packages/backend/src/core/CustomEmojiService.ts | 3 ++- packages/backend/src/server/api/stream/types.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 0f26d2266625..de9b748f4d09 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 { Serialized } from '@/server/api/stream/types'; @Injectable() export class CustomEmojiService { @@ -46,7 +47,7 @@ export class CustomEmojiService { toRedisConverter: (value) => JSON.stringify(Array.from(value.values())), fromRedisConverter: (value) => { if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す) - return new Map(JSON.parse(value).map((x: Emoji) => [x.name, { + return new Map(JSON.parse(value).map((x: Serialized) => [x.name, { ...x, updatedAt: x.updatedAt && new Date(x.updatedAt), }])); diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 101f6bf26123..d9dba682cdc2 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -172,7 +172,7 @@ type EventUnionFromDictionary< > = U[keyof U]; // redis通すとDateのインスタンスはstringに変換されるので -type Serialized = { +export type Serialized = { [K in keyof T]: T[K] extends Date ? string From 4c0ef07f6f96c8a251c96cc8449bc51904c33a81 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 12 Apr 2023 12:34:34 +0000 Subject: [PATCH 10/10] fix --- packages/backend/src/core/CustomEmojiService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index de9b748f4d09..eb18fb1b734c 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -13,7 +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 { Serialized } from '@/server/api/stream/types'; +import type { Serialized } from '@/server/api/stream/types.js'; @Injectable() export class CustomEmojiService {