From 431d8c7802df0df8863211fd797a2eb073be7229 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 19 Oct 2023 16:22:19 +0900 Subject: fix(backend): Redisがからのときにhybrid-timelineにwithReplies = trueでアクセスするとSQLのシンタックスエラーになる MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #12064 --- packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts | 2 ++ 1 file changed, 2 insertions(+) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 9986c6e436..f8a7a6a8b7 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -123,6 +123,8 @@ export default class extends Endpoint { // eslint- noteIds.sort((a, b) => a > b ? -1 : 1); noteIds = noteIds.slice(0, ps.limit); + shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); + if (!shouldFallbackToDb) { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) -- cgit v1.2.3-freya From 93d3501c907945a875453ed974955c22377aa272 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Thu, 19 Oct 2023 17:29:09 +0900 Subject: fix: replies are included even if withReplies = false in local timeline (#12074) --- .../backend/src/server/api/endpoints/notes/local-timeline.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 9d5688f96f..3b6c93fdf9 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -163,6 +163,18 @@ export default class extends Endpoint { // eslint- query.andWhere('note.fileIds != \'{}\''); } + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { -- cgit v1.2.3-freya From 991fa054a6844e16b22cb8e89d78af04a8df078e Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Thu, 19 Oct 2023 19:34:52 +0900 Subject: chore: STLのdb fallbackでwithRepliesがtrueのときにすべてのリプライを除外しないように (#12075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiFollowingを見るのは実装コストが高いため現状実装していない Co-authored-by: syuilo --- .../server/api/endpoints/notes/hybrid-timeline.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index f8a7a6a8b7..cbab13f30d 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -182,15 +182,17 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); -- cgit v1.2.3-freya From 6ff5bfd2bc6c286c689fee9577b9e9036112e689 Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 20 Oct 2023 07:48:31 +0900 Subject: clean up --- packages/backend/src/core/NoteCreateService.ts | 1 - .../src/core/entities/InstanceEntityService.ts | 2 +- packages/backend/src/misc/nyaize.ts | 20 -------------------- .../server/api/endpoints/notes/user-list-timeline.ts | 8 +------- 4 files changed, 2 insertions(+), 29 deletions(-) delete mode 100644 packages/backend/src/misc/nyaize.ts (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index f5cfe03122..364a300d23 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -55,7 +55,6 @@ import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; -import { nyaize } from '@/misc/nyaize.js'; import { UtilityService } from '@/core/UtilityService.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 9afe87eab7..8bba150ece 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import type { MiInstance } from '@/models/Instance.js'; import { MetaService } from '@/core/MetaService.js'; diff --git a/packages/backend/src/misc/nyaize.ts b/packages/backend/src/misc/nyaize.ts deleted file mode 100644 index 0ac77e1006..0000000000 --- a/packages/backend/src/misc/nyaize.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function nyaize(text: string): string { - return text - // ja-JP - .replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ') - // en-US - .replace(/(?<=n)a/gi, x => x === 'A' ? 'YA' : 'ya') - .replace(/(?<=morn)ing/gi, x => x === 'ING' ? 'YAN' : 'yan') - .replace(/(?<=every)one/gi, x => x === 'ONE' ? 'NYAN' : 'nyan') - // ko-KR - .replace(/[나-낳]/g, match => String.fromCharCode( - match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0), - )) - .replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, '다냥') - .replace(/(야(?=\?))|(야$)|(야(?= ))/gm, '냥'); -} diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 2b31e6169c..96e1e94f7c 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -3,12 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js'; +import type { NotesRepository, UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; @@ -67,9 +64,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, -- cgit v1.2.3-freya From 2c0a139da60ddf33e82353acbb985230c71c78da Mon Sep 17 00:00:00 2001 From: syuilo Date: Sat, 21 Oct 2023 18:38:07 +0900 Subject: feat: Avatar decoration (#12096) * wip * Update ja-JP.yml * Update profile.vue * .js * Update home.test.ts --- locales/index.d.ts | 4 + locales/ja-JP.yml | 4 + .../migration/1697847397844-avatar-decoration.js | 18 +++ .../backend/src/core/AvatarDecorationService.ts | 129 +++++++++++++++++++++ packages/backend/src/core/CoreModule.ts | 6 + packages/backend/src/core/GlobalEventService.ts | 5 +- packages/backend/src/core/RoleService.ts | 6 + .../backend/src/core/entities/UserEntityService.ts | 13 ++- packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/AvatarDecoration.ts | 39 +++++++ packages/backend/src/models/RepositoryModule.ts | 10 +- packages/backend/src/models/User.ts | 5 + packages/backend/src/models/_.ts | 3 + packages/backend/src/models/json-schema/user.ts | 20 ++++ packages/backend/src/postgres.ts | 2 + packages/backend/src/server/api/EndpointsModule.ts | 20 ++++ packages/backend/src/server/api/endpoints.ts | 10 ++ .../endpoints/admin/avatar-decorations/create.ts | 44 +++++++ .../endpoints/admin/avatar-decorations/delete.ts | 39 +++++++ .../api/endpoints/admin/avatar-decorations/list.ts | 101 ++++++++++++++++ .../endpoints/admin/avatar-decorations/update.ts | 50 ++++++++ .../server/api/endpoints/get-avatar-decorations.ts | 79 +++++++++++++ .../backend/src/server/api/endpoints/i/update.ts | 16 +++ packages/backend/src/types.ts | 16 +++ packages/backend/test/e2e/users.ts | 2 + packages/frontend/.storybook/fakes.ts | 1 + .../frontend/src/components/global/MkAvatar.vue | 15 ++- .../src/pages/admin/avatar-decorations.vue | 103 ++++++++++++++++ packages/frontend/src/pages/admin/index.vue | 5 + .../frontend/src/pages/admin/modlog.ModLog.vue | 12 +- packages/frontend/src/pages/settings/profile.vue | 55 +++++++++ packages/frontend/src/router.ts | 4 + packages/frontend/test/home.test.ts | 8 +- packages/frontend/test/note.test.ts | 4 +- packages/frontend/test/url-preview.test.ts | 4 +- packages/misskey-js/etc/misskey-js.api.md | 22 +++- packages/misskey-js/src/consts.ts | 16 +++ packages/misskey-js/src/entities.ts | 16 +++ 38 files changed, 888 insertions(+), 19 deletions(-) create mode 100644 packages/backend/migration/1697847397844-avatar-decoration.js create mode 100644 packages/backend/src/core/AvatarDecorationService.ts create mode 100644 packages/backend/src/models/AvatarDecoration.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts create mode 100644 packages/backend/src/server/api/endpoints/get-avatar-decorations.ts create mode 100644 packages/frontend/src/pages/admin/avatar-decorations.vue (limited to 'packages/backend/src/server/api/endpoints') diff --git a/locales/index.d.ts b/locales/index.d.ts index 363032eaa2..11be41235a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1142,6 +1142,7 @@ export interface Locale { "privacyPolicy": string; "privacyPolicyUrl": string; "tosAndPrivacyPolicy": string; + "avatarDecorations": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -2295,6 +2296,9 @@ export interface Locale { "createAd": string; "deleteAd": string; "updateAd": string; + "createAvatarDecoration": string; + "updateAvatarDecoration": string; + "deleteAvatarDecoration": string; }; "_fileViewer": { "title": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f1b57f8bde..11b0833928 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1139,6 +1139,7 @@ impressumDescription: "ドイツなどの一部の国と地域では表示が義 privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" +avatarDecorations: "アイコンデコレーション" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -2208,6 +2209,9 @@ _moderationLogTypes: createAd: "広告を作成" deleteAd: "広告を削除" updateAd: "広告を更新" + createAvatarDecoration: "アイコンデコレーションを作成" + updateAvatarDecoration: "アイコンデコレーションを更新" + deleteAvatarDecoration: "アイコンデコレーションを削除" _fileViewer: title: "ファイルの詳細" diff --git a/packages/backend/migration/1697847397844-avatar-decoration.js b/packages/backend/migration/1697847397844-avatar-decoration.js new file mode 100644 index 0000000000..1f22139746 --- /dev/null +++ b/packages/backend/migration/1697847397844-avatar-decoration.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AvatarDecoration1697847397844 { + name = 'AvatarDecoration1697847397844' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "avatar_decoration" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "url" character varying(1024) NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(2048) NOT NULL, "roleIdsThatCanBeUsedThisDecoration" character varying(128) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_b6de9296f6097078e1dc53f7603" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`); + await queryRunner.query(`DROP TABLE "avatar_decoration"`); + } +} diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts new file mode 100644 index 0000000000..e97946f9dc --- /dev/null +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { AvatarDecorationsRepository, MiAvatarDecoration, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { MemorySingleCache } from '@/misc/cache.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +@Injectable() +export class AvatarDecorationService implements OnApplicationShutdown { + public cache: MemorySingleCache; + + constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.avatarDecorationsRepository) + private avatarDecorationsRepository: AvatarDecorationsRepository, + + private idService: IdService, + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + ) { + this.cache = new MemorySingleCache(1000 * 60 * 30); + + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'avatarDecorationCreated': + case 'avatarDecorationUpdated': + case 'avatarDecorationDeleted': { + this.cache.delete(); + break; + } + default: + break; + } + } + } + + @bindThis + public async create(options: Partial, moderator?: MiUser): Promise { + const created = await this.avatarDecorationsRepository.insert({ + id: this.idService.gen(), + ...options, + }).then(x => this.avatarDecorationsRepository.findOneByOrFail(x.identifiers[0])); + + this.globalEventService.publishInternalEvent('avatarDecorationCreated', created); + + if (moderator) { + this.moderationLogService.log(moderator, 'createAvatarDecoration', { + avatarDecorationId: created.id, + avatarDecoration: created, + }); + } + + return created; + } + + @bindThis + public async update(id: MiAvatarDecoration['id'], params: Partial, moderator?: MiUser): Promise { + const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); + + const date = new Date(); + await this.avatarDecorationsRepository.update(avatarDecoration.id, { + updatedAt: date, + ...params, + }); + + const updated = await this.avatarDecorationsRepository.findOneByOrFail({ id: avatarDecoration.id }); + this.globalEventService.publishInternalEvent('avatarDecorationUpdated', updated); + + if (moderator) { + this.moderationLogService.log(moderator, 'updateAvatarDecoration', { + avatarDecorationId: avatarDecoration.id, + before: avatarDecoration, + after: updated, + }); + } + } + + @bindThis + public async delete(id: MiAvatarDecoration['id'], moderator?: MiUser): Promise { + const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); + + await this.avatarDecorationsRepository.delete({ id: avatarDecoration.id }); + this.globalEventService.publishInternalEvent('avatarDecorationDeleted', avatarDecoration); + + if (moderator) { + this.moderationLogService.log(moderator, 'deleteAvatarDecoration', { + avatarDecorationId: avatarDecoration.id, + avatarDecoration: avatarDecoration, + }); + } + } + + @bindThis + public async getAll(noCache = false): Promise { + if (noCache) { + this.cache.delete(); + } + return this.cache.fetch(() => this.avatarDecorationsRepository.find()); + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index e7e66646fc..b46afb1909 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -11,6 +11,7 @@ import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; +import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; @@ -140,6 +141,7 @@ const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExis const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; +const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; @@ -273,6 +275,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AntennaService, AppLockService, AchievementService, + AvatarDecorationService, CaptchaService, CreateSystemUserService, CustomEmojiService, @@ -399,6 +402,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AntennaService, $AppLockService, $AchievementService, + $AvatarDecorationService, $CaptchaService, $CreateSystemUserService, $CustomEmojiService, @@ -526,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AntennaService, AppLockService, AchievementService, + AvatarDecorationService, CaptchaService, CreateSystemUserService, CustomEmojiService, @@ -651,6 +656,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AntennaService, $AppLockService, $AchievementService, + $AvatarDecorationService, $CaptchaService, $CreateSystemUserService, $CustomEmojiService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index b74fbbe584..bfbdecf688 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -18,7 +18,7 @@ import type { MiSignin } from '@/models/Signin.js'; import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -188,6 +188,9 @@ export interface InternalEventTypes { antennaCreated: MiAntenna; antennaDeleted: MiAntenna; antennaUpdated: MiAntenna; + avatarDecorationCreated: MiAvatarDecoration; + avatarDecorationDeleted: MiAvatarDecoration; + avatarDecorationUpdated: MiAvatarDecoration; metaUpdated: MiMeta; followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 2c2ff7af1d..ef05920d50 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -227,6 +227,12 @@ export class RoleService implements OnApplicationShutdown { } } + @bindThis + public async getRoles() { + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); + return roles; + } + @bindThis public async getUserAssigns(userId: MiUser['id']) { const now = Date.now(); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index b0577fc1a0..66facce4c2 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -21,9 +21,10 @@ import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { IdService } from '@/core/IdService.js'; +import type { AnnouncementService } from '@/core/AnnouncementService.js'; +import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import type { OnModuleInit } from '@nestjs/common'; -import type { AnnouncementService } from '../AnnouncementService.js'; -import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; @@ -62,6 +63,7 @@ export class UserEntityService implements OnModuleInit { private roleService: RoleService; private federatedInstanceService: FederatedInstanceService; private idService: IdService; + private avatarDecorationService: AvatarDecorationService; constructor( private moduleRef: ModuleRef, @@ -126,6 +128,7 @@ export class UserEntityService implements OnModuleInit { this.roleService = this.moduleRef.get('RoleService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.idService = this.moduleRef.get('IdService'); + this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); } //#region Validators @@ -328,8 +331,6 @@ export class UserEntityService implements OnModuleInit { ...announcement, })) : null; - const falsy = opts.detail ? false : undefined; - const packed = { id: user.id, name: user.name, @@ -337,6 +338,10 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({ + id: decoration.id, + url: decoration.url, + }))) : [], isBot: user.isBot, isCat: user.isCat, instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index edcdd21d60..8411cb8229 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -18,6 +18,7 @@ export const DI = { announcementsRepository: Symbol('announcementsRepository'), announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), + avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts new file mode 100644 index 0000000000..08ebbdeac1 --- /dev/null +++ b/packages/backend/src/models/AvatarDecoration.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { id } from './util/id.js'; + +@Entity('avatar_decoration') +export class MiAvatarDecoration { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public updatedAt: Date | null; + + @Column('varchar', { + length: 1024, + }) + public url: string; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 2048, + }) + public description: string; + + // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public roleIdsThatCanBeUsedThisDecoration: string[]; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 9efd6841b1..866fdfe6d4 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; +import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -39,6 +39,12 @@ const $appsRepository: Provider = { inject: [DI.db], }; +const $avatarDecorationsRepository: Provider = { + provide: DI.avatarDecorationsRepository, + useFactory: (db: DataSource) => db.getRepository(MiAvatarDecoration), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), @@ -402,6 +408,7 @@ const $userMemosRepository: Provider = { $announcementsRepository, $announcementReadsRepository, $appsRepository, + $avatarDecorationsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -468,6 +475,7 @@ const $userMemosRepository: Provider = { $announcementsRepository, $announcementReadsRepository, $appsRepository, + $avatarDecorationsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 796d7c8356..c98426a7b6 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -138,6 +138,11 @@ export class MiUser { }) public bannerBlurhash: string | null; + @Column('varchar', { + length: 512, array: true, default: '{}', + }) + public avatarDecorations: string[]; + @Index() @Column('varchar', { length: 128, array: true, default: '{}', diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index f974f95ed8..d7c327f164 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -10,6 +10,7 @@ import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; @@ -77,6 +78,7 @@ export { MiAnnouncementRead, MiAntenna, MiApp, + MiAvatarDecoration, MiAuthSession, MiBlocking, MiChannelFollowing, @@ -143,6 +145,7 @@ export type AnnouncementsRepository = Repository; export type AnnouncementReadsRepository = Repository; export type AntennasRepository = Repository; export type AppsRepository = Repository; +export type AvatarDecorationsRepository = Repository; export type AuthSessionsRepository = Repository; export type BlockingsRepository = Repository; export type ChannelFollowingsRepository = Repository; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 57d2d976ff..bf283fbeb2 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -37,6 +37,26 @@ export const packedUserLiteSchema = { type: 'string', nullable: true, optional: false, }, + avatarDecorations: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + url: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + }, + }, + }, isAdmin: { type: 'boolean', nullable: false, optional: true, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index d4c6ad82ce..cd611839a4 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -18,6 +18,7 @@ import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; @@ -129,6 +130,7 @@ export const entities = [ MiMeta, MiInstance, MiApp, + MiAvatarDecoration, MiAuthSession, MiAccessToken, MiUser, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index f834561456..f234a2637d 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -18,6 +18,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; +import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; +import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; +import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; +import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; @@ -176,6 +180,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; +import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_search from './endpoints/hashtags/search.js'; import * as ep___hashtags_show from './endpoints/hashtags/show.js'; @@ -368,6 +373,10 @@ const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default }; const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default }; const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default }; +const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-decorations/create', useClass: ep___admin_avatarDecorations_create.default }; +const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; +const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; +const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; @@ -526,6 +535,7 @@ const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useCla const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default }; const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default }; const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default }; +const $getAvatarDecorations: Provider = { provide: 'ep:get-avatar-decorations', useClass: ep___getAvatarDecorations.default }; const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default }; const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default }; const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default }; @@ -722,6 +732,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_announcements_delete, $admin_announcements_list, $admin_announcements_update, + $admin_avatarDecorations_create, + $admin_avatarDecorations_delete, + $admin_avatarDecorations_list, + $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, @@ -880,6 +894,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $gallery_posts_unlike, $gallery_posts_update, $getOnlineUsersCount, + $getAvatarDecorations, $hashtags_list, $hashtags_search, $hashtags_show, @@ -1070,6 +1085,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_announcements_delete, $admin_announcements_list, $admin_announcements_update, + $admin_avatarDecorations_create, + $admin_avatarDecorations_delete, + $admin_avatarDecorations_list, + $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, @@ -1228,6 +1247,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $gallery_posts_unlike, $gallery_posts_update, $getOnlineUsersCount, + $getAvatarDecorations, $hashtags_list, $hashtags_search, $hashtags_show, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d12a035afa..8d34edca9d 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -18,6 +18,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; +import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; +import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; +import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; +import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; @@ -176,6 +180,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; +import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_search from './endpoints/hashtags/search.js'; import * as ep___hashtags_show from './endpoints/hashtags/show.js'; @@ -366,6 +371,10 @@ const eps = [ ['admin/announcements/delete', ep___admin_announcements_delete], ['admin/announcements/list', ep___admin_announcements_list], ['admin/announcements/update', ep___admin_announcements_update], + ['admin/avatar-decorations/create', ep___admin_avatarDecorations_create], + ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], + ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], + ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], ['admin/drive/cleanup', ep___admin_drive_cleanup], @@ -524,6 +533,7 @@ const eps = [ ['gallery/posts/unlike', ep___gallery_posts_unlike], ['gallery/posts/update', ep___gallery_posts_update], ['get-online-users-count', ep___getOnlineUsersCount], + ['get-avatar-decorations', ep___getAvatarDecorations], ['hashtags/list', ep___hashtags_list], ['hashtags/search', ep___hashtags_search], ['hashtags/show', ep___hashtags_show], diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts new file mode 100644 index 0000000000..c1869b141a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + description: { type: 'string' }, + url: { type: 'string', minLength: 1 }, + roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['name', 'description', 'url'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.create({ + name: ps.name, + description: ps.description, + url: ps.url, + roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts new file mode 100644 index 0000000000..5aba24b426 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.delete(ps.id, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts new file mode 100644 index 0000000000..9a32a59081 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; +import type { MiAnnouncement } from '@/models/Announcement.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const avatarDecorations = await this.avatarDecorationService.getAll(true); + + return avatarDecorations.map(avatarDecoration => ({ + id: avatarDecoration.id, + createdAt: this.idService.parse(avatarDecoration.id).date.toISOString(), + updatedAt: avatarDecoration.updatedAt?.toISOString() ?? null, + name: avatarDecoration.name, + description: avatarDecoration.description, + url: avatarDecoration.url, + roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts new file mode 100644 index 0000000000..564014a3df --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1 }, + description: { type: 'string' }, + url: { type: 'string', minLength: 1 }, + roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.update(ps.id, { + name: ps.name, + description: ps.description, + url: ps.url, + roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts new file mode 100644 index 0000000000..ec602a0dc5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + const decorations = await this.avatarDecorationService.getAll(true); + + return decorations.map(decoration => ({ + id: decoration.id, + name: decoration.name, + description: decoration.description, + url: decoration.url, + roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 431bb4c60a..f1837e7082 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -32,6 +32,7 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { Config } from '@/config.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -131,6 +132,9 @@ export const paramDef = { birthday: { ...birthdaySchema, nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true }, + avatarDecorations: { type: 'array', maxItems: 1, items: { + type: 'string', + } }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, fields: { type: 'array', @@ -207,6 +211,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private cacheService: CacheService, private httpRequestService: HttpRequestService, + private avatarDecorationService: AvatarDecorationService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -296,6 +301,17 @@ export default class extends Endpoint { // eslint- updates.bannerBlurhash = null; } + if (ps.avatarDecorations) { + const decorations = await this.avatarDecorationService.getAll(true); + const myRoles = await this.roleService.getUserRoles(user.id); + const allRoles = await this.roleService.getRoles(); + const decorationIds = decorations + .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) + .map(d => d.id); + + updates.avatarDecorations = ps.avatarDecorations.filter(id => decorationIds.includes(id)); + } + if (ps.pinnedPageId) { const page = await this.pagesRepository.findOneBy({ id: ps.pinnedPageId }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 316073c992..69224360b3 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -60,6 +60,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createAvatarDecoration', + 'updateAvatarDecoration', + 'deleteAvatarDecoration', ] as const; export type ModerationLogPayloads = { @@ -221,6 +224,19 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; + updateAvatarDecoration: { + avatarDecorationId: string; + before: any; + after: any; + }; + deleteAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; }; export type Serialized = { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 53db1ac28a..520d9b14e4 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -68,6 +68,7 @@ describe('ユーザー', () => { host: user.host, avatarUrl: user.avatarUrl, avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations, isBot: user.isBot, isCat: user.isCat, instance: user.instance, @@ -349,6 +350,7 @@ describe('ユーザー', () => { assert.strictEqual(response.host, null); assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); assert.strictEqual(response.avatarBlurhash, null); + assert.deepStrictEqual(response.avatarDecorations, []); assert.strictEqual(response.isBot, false); assert.strictEqual(response.isCat, false); assert.strictEqual(response.instance, undefined); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 811c243926..c2e6ee52f3 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -74,6 +74,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi onlineStatus: 'unknown', avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', + avatarDecorations: [], emojis: [], bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', bannerColor: '#000000', diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 27c25b9490..de684425a2 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -47,6 +48,7 @@ const props = withDefaults(defineProps<{ link?: boolean; preview?: boolean; indicator?: boolean; + decoration?: string; }>(), { target: null, link: false, @@ -134,7 +136,7 @@ watch(() => props.user.avatarBlurhash, () => { .indicator { position: absolute; - z-index: 1; + z-index: 2; bottom: 0; left: 0; width: 20%; @@ -278,4 +280,13 @@ watch(() => props.user.avatarBlurhash, () => { } } } + +.decoration { + position: absolute; + z-index: 1; + top: -50%; + left: -50%; + width: 200%; + pointer-events: none; +} diff --git a/packages/frontend/src/pages/admin/avatar-decorations.vue b/packages/frontend/src/pages/admin/avatar-decorations.vue new file mode 100644 index 0000000000..b4007e6d20 --- /dev/null +++ b/packages/frontend/src/pages/admin/avatar-decorations.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index a508c20cf3..b304edbf57 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -115,6 +115,11 @@ const menuDef = $computed(() => [{ text: i18n.ts.customEmojis, to: '/admin/emojis', active: currentPage?.route.name === 'emojis', + }, { + icon: 'ti ti-sparkles', + text: i18n.ts.avatarDecorations, + to: '/admin/avatar-decorations', + active: currentPage?.route.name === 'avatarDecorations', }, { icon: 'ti ti-whirl', text: i18n.ts.federation, diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 0af226f02e..bceefcf6c8 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only +
raw diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 5e4889f61c..c44a58d04a 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -83,6 +83,23 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+
+
{{ avatarDecoration.name }}
+ +
+
+
+ @@ -126,6 +143,7 @@ import MkInfo from '@/components/MkInfo.vue'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); +let avatarDecorations: any[] = $ref([]); const profile = reactive({ name: $i.name, @@ -146,6 +164,10 @@ watch(() => profile, () => { const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []); const fieldEditMode = ref(false); +os.api('get-avatar-decorations').then(_avatarDecorations => { + avatarDecorations = _avatarDecorations; +}); + function addField() { fields.value.push({ id: Math.random().toString(), @@ -244,6 +266,20 @@ function changeBanner(ev) { }); } +function toggleDecoration(avatarDecoration) { + if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) { + os.apiWithDialog('i/update', { + avatarDecorations: [], + }); + $i.avatarDecorations = []; + } else { + os.apiWithDialog('i/update', { + avatarDecorations: [avatarDecoration.id], + }); + $i.avatarDecorations.push(avatarDecoration); + } +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); @@ -338,4 +374,23 @@ definePageMetadata({ .dragItemForm { flex-grow: 1; } + +.avatarDecoration { + cursor: pointer; + padding: 16px 16px 24px 16px; + border: solid 2px var(--divider); + border-radius: 8px; + text-align: center; +} + +.avatarDecorationActive { + border-color: var(--accent); +} + +.avatarDecorationName { + position: relative; + z-index: 10; + font-weight: bold; + margin-bottom: 16px; +} diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 6c33d0d8ee..2258edebbb 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -343,6 +343,10 @@ export const routes = [{ path: '/emojis', name: 'emojis', component: page(() => import('./pages/custom-emojis-manager.vue')), + }, { + path: '/avatar-decorations', + name: 'avatarDecorations', + component: page(() => import('./pages/admin/avatar-decorations.vue')), }, { path: '/queue', name: 'queue', diff --git a/packages/frontend/test/home.test.ts b/packages/frontend/test/home.test.ts index 80b26c081a..6d38b7e526 100644 --- a/packages/frontend/test/home.test.ts +++ b/packages/frontend/test/home.test.ts @@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type * as Misskey from 'misskey-js'; -import { directives } from '@/directives'; -import { components } from '@/components/index'; +import { directives } from '@/directives/index.js'; +import { components } from '@/components/index.js'; import XHome from '@/pages/user/home.vue'; describe('XHome', () => { @@ -34,6 +34,8 @@ describe('XHome', () => { createdAt: '1970-01-01T00:00:00.000Z', fields: [], pinnedNotes: [], + avatarUrl: 'https://example.com', + avatarDecorations: [], }); const anchor = home.container.querySelector('a[href^="https://example.com/"]'); @@ -54,6 +56,8 @@ describe('XHome', () => { createdAt: '1970-01-01T00:00:00.000Z', fields: [], pinnedNotes: [], + avatarUrl: 'https://example.com', + avatarDecorations: [], }); const anchor = home.container.querySelector('a[href^="https://example.com/"]'); diff --git a/packages/frontend/test/note.test.ts b/packages/frontend/test/note.test.ts index 3e4faad287..8ccc05ff3e 100644 --- a/packages/frontend/test/note.test.ts +++ b/packages/frontend/test/note.test.ts @@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type * as Misskey from 'misskey-js'; -import { components } from '@/components'; -import { directives } from '@/directives'; +import { components } from '@/components/index.js'; +import { directives } from '@/directives/index.js'; import MkMediaImage from '@/components/MkMediaImage.vue'; describe('MkMediaImage', () => { diff --git a/packages/frontend/test/url-preview.test.ts b/packages/frontend/test/url-preview.test.ts index 0cf3a417e2..811f07d9c7 100644 --- a/packages/frontend/test/url-preview.test.ts +++ b/packages/frontend/test/url-preview.test.ts @@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type { summaly } from 'summaly'; -import { components } from '@/components'; -import { directives } from '@/directives'; +import { components } from '@/components/index.js'; +import { directives } from '@/directives/index.js'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; type SummalyResult = Awaited>; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 0136df2030..4fabc195de 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2634,10 +2634,22 @@ type ModerationLog = { } | { type: 'deleteAd'; info: ModerationLogPayloads['deleteAd']; +} | { + type: 'createAvatarDecoration'; + info: ModerationLogPayloads['createAvatarDecoration']; +} | { + type: 'updateAvatarDecoration'; + info: ModerationLogPayloads['updateAvatarDecoration']; +} | { + type: 'deleteAvatarDecoration'; + info: ModerationLogPayloads['deleteAvatarDecoration']; +} | { + type: 'resolveAbuseReport'; + info: ModerationLogPayloads['resolveAbuseReport']; }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration"]; // @public (undocumented) export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"]; @@ -2965,6 +2977,10 @@ type UserLite = { onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; avatarUrl: string; avatarBlurhash: string; + avatarDecorations: { + id: ID; + url: string; + }[]; emojis: { name: string; url: string; @@ -2989,8 +3005,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/entities.ts:109:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts -// src/entities.ts:605:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/entities.ts:113:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts +// src/entities.ts:609:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index c4ddead823..48a36a31d6 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -78,6 +78,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createAvatarDecoration', + 'updateAvatarDecoration', + 'deleteAvatarDecoration', ] as const; export type ModerationLogPayloads = { @@ -239,4 +242,17 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; + updateAvatarDecoration: { + avatarDecorationId: string; + before: any; + after: any; + }; + deleteAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 50b4a40c49..a2a283d234 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -16,6 +16,10 @@ export type UserLite = { onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; avatarUrl: string; avatarBlurhash: string; + avatarDecorations: { + id: ID; + url: string; + }[]; emojis: { name: string; url: string; @@ -693,4 +697,16 @@ export type ModerationLog = { } | { type: 'deleteAd'; info: ModerationLogPayloads['deleteAd']; +} | { + type: 'createAvatarDecoration'; + info: ModerationLogPayloads['createAvatarDecoration']; +} | { + type: 'updateAvatarDecoration'; + info: ModerationLogPayloads['updateAvatarDecoration']; +} | { + type: 'deleteAvatarDecoration'; + info: ModerationLogPayloads['deleteAvatarDecoration']; +} | { + type: 'resolveAbuseReport'; + info: ModerationLogPayloads['resolveAbuseReport']; }); -- cgit v1.2.3-freya From 12fe09c6e7c204c5b064eab2c8eda7329ff3a7a3 Mon Sep 17 00:00:00 2001 From: CyberRex Date: Sat, 21 Oct 2023 18:38:51 +0900 Subject: プロフィールのURL認証をrel=meで可能に (#12100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/server/api/endpoints/i/update.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index f1837e7082..79ead57a66 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -437,9 +437,13 @@ export default class extends Endpoint { // eslint- const myLink = `${this.config.url}/@${user.username}`; - const includesMyLink = Array.from(doc.getElementsByTagName('a')).some(a => a.href === myLink); + const aEls = Array.from(doc.getElementsByTagName('a')); + const linkEls = Array.from(doc.getElementsByTagName('link')); - if (includesMyLink) { + const includesMyLink = aEls.some(a => a.href === myLink); + const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); + + if (includesMyLink || includesRelMeLinks) { await this.userProfilesRepository.createQueryBuilder('profile').update() .where('userId = :userId', { userId: user.id }) .set({ -- cgit v1.2.3-freya From 722584bf72a1432dbaad8b868c9b44bb5cd7cd30 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 21 Oct 2023 18:39:19 +0900 Subject: すべてのフォロー中の人のwithRepliesを変える機能 (#12049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: endpoint to update all following * feat(frontend): change show replies for all * docs(changelog): すでにフォローしたすべての人の返信をTLに追加できるように * fix: cancel not working --- CHANGELOG.md | 1 + locales/index.d.ts | 4 ++ locales/ja-JP.yml | 4 ++ packages/backend/src/server/api/EndpointsModule.ts | 4 ++ packages/backend/src/server/api/endpoints.ts | 2 + .../server/api/endpoints/following/update-all.ts | 54 ++++++++++++++++++++++ packages/frontend/src/pages/settings/general.vue | 11 +++++ 7 files changed, 80 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/following/update-all.ts (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index 6171569604..d06efad1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Enhance: フォロー/フォロー解除したときに過去分のHTLにも含まれる投稿が反映されるように - Enhance: ローカリゼーションの更新 - Enhance: 依存関係の更新 +- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように ### Client - Enhance: TLの返信表示オプションを記憶するように diff --git a/locales/index.d.ts b/locales/index.d.ts index 11be41235a..d31ac0a9b2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1135,6 +1135,10 @@ export interface Locale { "fileAttachedOnly": string; "showRepliesToOthersInTimeline": string; "hideRepliesToOthersInTimeline": string; + "showRepliesToOthersInTimelineAll": string; + "hideRepliesToOthersInTimelineAll": string; + "confirmShowRepliesAll": string; + "confirmHideRepliesAll": string; "externalServices": string; "impressum": string; "impressumUrl": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 11b0833928..a63c698bb9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1132,6 +1132,10 @@ mutualFollow: "相互フォロー" fileAttachedOnly: "ファイル付きのみ" showRepliesToOthersInTimeline: "TLに他の人への返信を含める" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" +showRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めるようにする" +hideRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めないようにする" +confirmShowRepliesAll: "この操作は元の戻せません。本当にTLに現在フォロー中の人全員の返信を含めるようにしますか" +confirmHideRepliesAll: "この操作は元の戻せません。本当にTLに現在フォロー中の人全員の返信を含めないようにしますか" externalServices: "外部サービス" impressum: "運営者情報" impressumUrl: "運営者情報URL" diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index f234a2637d..ab0e4c6273 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -165,6 +165,7 @@ import * as ep___federation_stats from './endpoints/federation/stats.js'; import * as ep___following_create from './endpoints/following/create.js'; import * as ep___following_delete from './endpoints/following/delete.js'; import * as ep___following_update from './endpoints/following/update.js'; +import * as ep___following_update_all from './endpoints/following/update-all.js'; import * as ep___following_invalidate from './endpoints/following/invalidate.js'; import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; @@ -520,6 +521,7 @@ const $federation_stats: Provider = { provide: 'ep:federation/stats', useClass: const $following_create: Provider = { provide: 'ep:following/create', useClass: ep___following_create.default }; const $following_delete: Provider = { provide: 'ep:following/delete', useClass: ep___following_delete.default }; const $following_update: Provider = { provide: 'ep:following/update', useClass: ep___following_update.default }; +const $following_update_all: Provider = { provide: 'ep:following/update-all', useClass: ep___following_update_all.default }; const $following_invalidate: Provider = { provide: 'ep:following/invalidate', useClass: ep___following_invalidate.default }; const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default }; const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default }; @@ -879,6 +881,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $following_create, $following_delete, $following_update, + $following_update_all, $following_invalidate, $following_requests_accept, $following_requests_cancel, @@ -1232,6 +1235,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $following_create, $following_delete, $following_update, + $following_update_all, $following_invalidate, $following_requests_accept, $following_requests_cancel, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 8d34edca9d..79e62672fa 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -165,6 +165,7 @@ import * as ep___federation_stats from './endpoints/federation/stats.js'; import * as ep___following_create from './endpoints/following/create.js'; import * as ep___following_delete from './endpoints/following/delete.js'; import * as ep___following_update from './endpoints/following/update.js'; +import * as ep___following_update_all from './endpoints/following/update-all.js'; import * as ep___following_invalidate from './endpoints/following/invalidate.js'; import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; @@ -518,6 +519,7 @@ const eps = [ ['following/create', ep___following_create], ['following/delete', ep___following_delete], ['following/update', ep___following_update], + ['following/update-all', ep___following_update_all], ['following/invalidate', ep___following_invalidate], ['following/requests/accept', ep___following_requests_accept], ['following/requests/cancel', ep___following_requests_cancel], diff --git a/packages/backend/src/server/api/endpoints/following/update-all.ts b/packages/backend/src/server/api/endpoints/following/update-all.ts new file mode 100644 index 0000000000..28734cfdbd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/update-all.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { FollowingsRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['following', 'users'], + + limit: { + duration: ms('1hour'), + max: 10, + }, + + requireCredential: true, + + kind: 'write:following', +} as const; + +export const paramDef = { + type: 'object', + properties: { + notify: { type: 'string', enum: ['normal', 'none'] }, + withReplies: { type: 'boolean' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + await this.followingsRepository.update({ + followerId: me.id, + }, { + notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, + withReplies: ps.withReplies != null ? ps.withReplies : undefined, + }); + + return; + }); + } +} diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 30443fded6..f186cf2ae3 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -30,6 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.showFixedPostForm }} {{ i18n.ts.showFixedPostFormInChannel }} {{ i18n.ts.withRepliesByDefaultForNewlyFollowed }} + {{ i18n.ts.showRepliesToOthersInTimelineAll }} + {{ i18n.ts.hideRepliesToOthersInTimelineAll }} @@ -332,6 +334,15 @@ async function setPinnedList() { defaultStore.set('pinnedUserLists', [list]); } +async function updateRepliesAll(withReplies: boolean) { + const { canceled } = os.confirm({ + type: 'warning', + text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll, + }); + if (canceled) return; + await os.api('following/update-all', { withReplies }); +} + function removePinnedList() { defaultStore.set('pinnedUserLists', []); } -- cgit v1.2.3-freya From f51bca41c5f59f9ffce346a3ec32badaf1ccda31 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 21 Oct 2023 18:41:12 +0900 Subject: Feat: 外部サイトからテーマ・プラグインのインストールができるように (#12034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: 外部サイトからテーマ・プラグインのインストールができるように * Update Changelog * Change Changelog * Remove unnecessary imports * Update fetch-external-resources.ts * Update CHANGELOG.md * Update CHANGELOG.md --- CHANGELOG.md | 13 + locales/index.d.ts | 55 ++++ locales/ja-JP.yml | 42 +++ packages/backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../api/endpoints/fetch-external-resources.ts | 72 +++++ packages/frontend/src/components/global/MkUrl.vue | 23 +- packages/frontend/src/pages/install-extentions.vue | 354 +++++++++++++++++++++ .../frontend/src/pages/settings/plugin.install.vue | 117 +------ .../frontend/src/pages/settings/theme.install.vue | 67 ++-- packages/frontend/src/router.ts | 4 + packages/frontend/src/scripts/install-plugin.ts | 129 ++++++++ packages/frontend/src/scripts/install-theme.ts | 37 +++ packages/misskey-js/etc/misskey-js.api.md | 16 + packages/misskey-js/src/api.types.ts | 7 + 15 files changed, 784 insertions(+), 158 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/fetch-external-resources.ts create mode 100644 packages/frontend/src/pages/install-extentions.vue create mode 100644 packages/frontend/src/scripts/install-plugin.ts create mode 100644 packages/frontend/src/scripts/install-theme.ts (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index d06efad1ef..2613e6682c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ --> +## 2023.x.x (unreleased) + +### General +- + +## Client +- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました + - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください + https://misskey-hub.net/docs/advanced/publish-on-your-website.html + +### Server +- + ## 2023.10.2 ### General diff --git a/locales/index.d.ts b/locales/index.d.ts index d31ac0a9b2..bb9b4b3dc4 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2313,6 +2313,61 @@ export interface Locale { "attachedNotes": string; "thisPageCanBeSeenFromTheAuthor": string; }; + "_externalResourceInstaller": { + "title": string; + "checkVendorBeforeInstall": string; + "_plugin": { + "title": string; + "metaTitle": string; + }; + "_theme": { + "title": string; + "metaTitle": string; + }; + "_meta": { + "base": string; + }; + "_vendorInfo": { + "title": string; + "endpoint": string; + "hashVerify": string; + }; + "_errors": { + "_invalidParams": { + "title": string; + "description": string; + }; + "_resourceTypeNotSupported": { + "title": string; + "description": string; + }; + "_failedToFetch": { + "title": string; + "fetchErrorDescription": string; + "parseErrorDescription": string; + }; + "_hashUnmatched": { + "title": string; + "description": string; + }; + "_pluginParseFailed": { + "title": string; + "description": string; + }; + "_pluginInstallFailed": { + "title": string; + "description": string; + }; + "_themeParseFailed": { + "title": string; + "description": string; + }; + "_themeInstallFailed": { + "title": string; + "description": string; + }; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a63c698bb9..d3d6a80b1f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2225,3 +2225,45 @@ _fileViewer: uploadedAt: "追加日" attachedNotes: "添付されているノート" thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。" + +_externalResourceInstaller: + title: "外部サイトからインストール" + checkVendorBeforeInstall: "配布元が信頼できるかを確認した上でインストールしてください。" + _plugin: + title: "このプラグインをインストールしますか?" + metaTitle: "プラグイン情報" + _theme: + title: "このテーマをインストールしますか?" + metaTitle: "テーマ情報" + _meta: + base: "基本のカラースキーム" + _vendorInfo: + title: "配布元情報" + endpoint: "参照したエンドポイント" + hashVerify: "ファイル整合性の確認" + _errors: + _invalidParams: + title: "パラメータが不足しています" + description: "外部サイトからデータを取得するために必要な情報が不足しています。URLをお確かめください。" + _resourceTypeNotSupported: + title: "この外部リソースには対応していません" + description: "この外部サイトから取得したリソースの種別には対応していません。サイト管理者にお問い合わせください。" + _failedToFetch: + title: "データの取得に失敗しました" + fetchErrorDescription: "外部サイトとの通信に失敗しました。もう一度試しても改善しない場合、サイト管理者にお問い合わせください。" + parseErrorDescription: "外部サイトから取得したデータが読み取れませんでした。サイト管理者にお問い合わせください。" + _hashUnmatched: + title: "正しいデータが取得できませんでした" + description: "提供されたデータの整合性の確認に失敗しました。セキュリティ上、インストールは続行できません。サイト管理者にお問い合わせください。" + _pluginParseFailed: + title: "AiScript エラー" + description: "データは取得できたものの、AiScriptの解析時にエラーがあったため読み込めませんでした。プラグインの作者にお問い合わせください。エラーの詳細はJavascriptコンソールをご確認ください。" + _pluginInstallFailed: + title: "プラグインのインストールに失敗しました" + description: "プラグインのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" + _themeParseFailed: + title: "テーマ解析エラー" + description: "データは取得できたものの、テーマファイルの解析時にエラーがあったため読み込めませんでした。テーマの作者にお問い合わせください。エラーの詳細はJavascriptコンソールをご確認ください。" + _themeInstallFailed: + title: "テーマのインストールに失敗しました" + description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ab0e4c6273..376226be69 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -357,6 +357,7 @@ import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; +import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; import * as ep___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; @@ -713,6 +714,7 @@ const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_s const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; +const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resources', useClass: ep___fetchExternalResources.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @Module({ @@ -1073,6 +1075,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_achievements, $users_updateMemo, $fetchRss, + $fetchExternalResources, $retention, ], exports: [ @@ -1424,6 +1427,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_achievements, $users_updateMemo, $fetchRss, + $fetchExternalResources, $retention, ], }) diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 79e62672fa..8be91469be 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -357,6 +357,7 @@ import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; +import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; import * as ep___retention from './endpoints/retention.js'; const eps = [ @@ -711,6 +712,7 @@ const eps = [ ['users/achievements', ep___users_achievements], ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], + ['fetch-external-resources', ep___fetchExternalResources], ['retention', ep___retention], ]; diff --git a/packages/backend/src/server/api/endpoints/fetch-external-resources.ts b/packages/backend/src/server/api/endpoints/fetch-external-resources.ts new file mode 100644 index 0000000000..d7b46cc666 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/fetch-external-resources.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHash } from 'crypto'; +import ms from 'ms'; +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { ApiError } from '../error.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: true, + + limit: { + duration: ms('1hour'), + max: 50, + }, + + errors: { + invalidSchema: { + message: 'External resource returned invalid schema.', + code: 'EXT_RESOURCE_RETURNED_INVALID_SCHEMA', + id: 'bb774091-7a15-4a70-9dc5-6ac8cf125856', + }, + hashUnmached: { + message: 'Hash did not match.', + code: 'EXT_RESOURCE_HASH_DIDNT_MATCH', + id: '693ba8ba-b486-40df-a174-72f8279b56a4', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + url: { type: 'string' }, + hash: { type: 'string' }, + }, + required: ['url', 'hash'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private httpRequestService: HttpRequestService, + ) { + super(meta, paramDef, async (ps) => { + const res = await this.httpRequestService.getJson<{ + type: string; + data: string; + }>(ps.url); + + if (!res.data || !res.type) { + throw new ApiError(meta.errors.invalidSchema); + } + + const resHash = createHash('sha512').update(res.data.replace(/\r\n/g, '\n')).digest('hex'); + if (resHash !== ps.hash) { + throw new ApiError(meta.errors.hashUnmached); + } + + return { + type: res.type, + data: res.data, + }; + }); + } +} diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index a8832cde01..db8a8399b5 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -31,23 +31,28 @@ import * as os from '@/os.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ url: string; rel?: string; -}>(); + showUrlPreview?: boolean; +}>(), { + showUrlPreview: true, +}); const self = props.url.startsWith(local); const url = new URL(props.url); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url'); const el = ref(); -useTooltip(el, (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { - showing, - url: props.url, - source: el.value, - }, {}, 'closed'); -}); +if (props.showUrlPreview) { + useTooltip(el, (showing) => { + os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { + showing, + url: props.url, + source: el.value, + }, {}, 'closed'); + }); +} const schema = url.protocol; const hostname = decodePunycode(url.hostname); diff --git a/packages/frontend/src/pages/install-extentions.vue b/packages/frontend/src/pages/install-extentions.vue new file mode 100644 index 0000000000..9674b522aa --- /dev/null +++ b/packages/frontend/src/pages/install-extentions.vue @@ -0,0 +1,354 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue index 47ebe9cfd6..693e02d0ed 100644 --- a/packages/frontend/src/pages/settings/plugin.install.vue +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -18,130 +18,35 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index f3d0c12dce..8d9c3cf730 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -92,10 +92,10 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id" :class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]" - @click="toggleDecoration(avatarDecoration)" + @click="openDecoration(avatarDecoration)" >
{{ avatarDecoration.name }}
- +
@@ -266,18 +266,10 @@ function changeBanner(ev) { }); } -function toggleDecoration(avatarDecoration) { - if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) { - os.apiWithDialog('i/update', { - avatarDecorations: [], - }); - $i.avatarDecorations = []; - } else { - os.apiWithDialog('i/update', { - avatarDecorations: [avatarDecoration.id], - }); - $i.avatarDecorations.push(avatarDecoration); - } +function openDecoration(avatarDecoration) { + os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), { + decoration: avatarDecoration, + }, {}, 'closed'); } const headerActions = $computed(() => []); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 208fe5b16d..0a6806ae60 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2996,6 +2996,8 @@ type UserLite = { avatarDecorations: { id: ID; url: string; + angle?: number; + flipH?: boolean; }[]; emojis: { name: string; @@ -3021,8 +3023,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/entities.ts:113:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts -// src/entities.ts:609:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/entities.ts:115:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts +// src/entities.ts:611:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index a2a283d234..38bac3b7c3 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -19,6 +19,8 @@ export type UserLite = { avatarDecorations: { id: ID; url: string; + angle?: number; + flipH?: boolean; }[]; emojis: { name: string; -- cgit v1.2.3-freya From 5dc04633236e6562e3e1508145ab69fb603f0c24 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 22 Oct 2023 16:23:50 +0900 Subject: fix(backend): ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../src/server/api/endpoints/notes/local-timeline.ts | 2 +- packages/backend/test/e2e/timelines.ts | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index fb49ef573c..f8371379b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ ### Server - Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 +- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 ## 2023.10.2 diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 3b6c93fdf9..4b9882e834 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -120,7 +120,7 @@ export default class extends Endpoint { // eslint- if (me && (note.userId === me.id)) { return true; } - if (!ps.withReplies && note.replyId && (me == null || note.replyUserId !== me.id)) return false; + if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; if (note.renoteId) { diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 974d2f6820..760bb8a574 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -526,6 +526,20 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); + test.concurrent('他人のその人自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await waitForPushToTl(); + + const res = await api('/notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }); + test.concurrent('チャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); -- cgit v1.2.3-freya From e6c54de814c4d43785cc3c26378479bd053d9b63 Mon Sep 17 00:00:00 2001 From: syuilo Date: Mon, 23 Oct 2023 15:17:25 +0900 Subject: enhance(backend): RedisへのTLのキャッシュをオフにできるように MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + locales/index.d.ts | 1 + locales/ja-JP.yml | 1 + .../backend/migration/1698041201306-enable-ftt.js | 16 + packages/backend/src/core/NoteCreateService.ts | 1 + packages/backend/src/core/QueryService.ts | 2 +- packages/backend/src/models/Meta.ts | 5 + .../backend/src/server/api/endpoints/admin/meta.ts | 9 +- .../src/server/api/endpoints/admin/update-meta.ts | 5 + .../server/api/endpoints/notes/hybrid-timeline.ts | 326 ++++++++++++--------- .../server/api/endpoints/notes/local-timeline.ts | 209 +++++++------ .../src/server/api/endpoints/notes/timeline.ts | 285 ++++++++++-------- packages/backend/test/unit/activitypub.ts | 1 + packages/frontend/src/pages/admin/settings.vue | 10 +- 14 files changed, 508 insertions(+), 364 deletions(-) create mode 100644 packages/backend/migration/1698041201306-enable-ftt.js (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index f8371379b6..678fa86a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正 ### Server +- Enhance: RedisへのTLのキャッシュをオフにできるように - Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 - Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 003c453a46..bfe25c94a7 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1190,6 +1190,7 @@ export interface Locale { "manifestJsonOverride": string; "shortName": string; "shortNameDescription": string; + "fanoutTimelineDescription": string; }; "_accountMigration": { "moveFrom": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 33a39ad08b..2b475e2134 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1188,6 +1188,7 @@ _serverSettings: manifestJsonOverride: "manifest.jsonのオーバーライド" shortName: "略称" shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。" + fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" diff --git a/packages/backend/migration/1698041201306-enable-ftt.js b/packages/backend/migration/1698041201306-enable-ftt.js new file mode 100644 index 0000000000..6769ed53b5 --- /dev/null +++ b/packages/backend/migration/1698041201306-enable-ftt.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class EnableFtt1698041201306 { + name = 'EnableFtt1698041201306' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableFanoutTimeline" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFanoutTimeline"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index fae512336d..ba2ead4f83 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -825,6 +825,7 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { const meta = await this.metaService.fetch(); + if (!meta.enableFanoutTimeline) return; const r = this.redisForTimelines.pipeline(); diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index ae8f8a3f19..f006ed4944 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -40,7 +40,7 @@ export class QueryService { ) { } - public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder { + public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder { if (sinceId && untilId) { q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 23ae513ede..360239f509 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -489,6 +489,11 @@ export class MiMeta { }) public preservedUsernames: string[]; + @Column('boolean', { + default: true, + }) + public enableFanoutTimeline: boolean; + @Column('integer', { default: 300, }) diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index f294934344..73c84a8674 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -106,11 +106,11 @@ export const meta = { optional: false, nullable: false, }, silencedHosts: { - type: "array", + type: 'array', optional: true, nullable: false, items: { - type: "string", + type: 'string', optional: false, nullable: false, }, @@ -291,6 +291,10 @@ export const meta = { type: 'object', optional: false, nullable: false, }, + enableFanoutTimeline: { + type: 'boolean', + optional: false, nullable: false, + }, perLocalUserUserTimelineCacheMax: { type: 'number', optional: false, nullable: false, @@ -419,6 +423,7 @@ export default class extends Endpoint { // eslint- enableIdenticonGeneration: instance.enableIdenticonGeneration, policies: { ...DEFAULT_POLICIES, ...instance.policies }, manifestJsonOverride: instance.manifestJsonOverride, + enableFanoutTimeline: instance.enableFanoutTimeline, perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index f05819b186..c58569a31c 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -120,6 +120,7 @@ export const paramDef = { serverRules: { type: 'array', items: { type: 'string' } }, preservedUsernames: { type: 'array', items: { type: 'string' } }, manifestJsonOverride: { type: 'string' }, + enableFanoutTimeline: { type: 'boolean' }, perLocalUserUserTimelineCacheMax: { type: 'integer' }, perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' }, @@ -480,6 +481,10 @@ export default class extends Endpoint { // eslint- set.manifestJsonOverride = ps.manifestJsonOverride; } + if (ps.enableFanoutTimeline !== undefined) { + set.enableFanoutTimeline = ps.enableFanoutTimeline; + } + if (ps.perLocalUserUserTimelineCacheMax !== undefined) { set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax; } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index cbab13f30d..518c34b949 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -17,6 +17,8 @@ import { CacheService } from '@/core/CacheService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -75,6 +77,7 @@ export default class extends Endpoint { // eslint- private funoutTimelineService: FunoutTimelineService, private queryService: QueryService, private userFollowingService: UserFollowingService, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -85,163 +88,200 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.stlDisabled); } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); - - let noteIds: string[]; - let shouldFallbackToDb = false; - - if (ps.withFiles) { - const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimelineWithFiles:${me.id}`, - 'localTimelineWithFiles', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - } else if (ps.withReplies) { - const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimeline:${me.id}`, - 'localTimeline', - 'localTimelineWithReplies', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); - } else { - const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimeline:${me.id}`, - 'localTimeline', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - shouldFallbackToDb = htlNoteIds.length === 0; - } - - noteIds.sort((a, b) => a > b ? -1 : 1); - noteIds = noteIds.slice(0, ps.limit); - - shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); + const serverSettings = await this.metaService.fetch(); - if (!shouldFallbackToDb) { - 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') - .leftJoinAndSelect('note.channel', 'channel'); + if (serverSettings.enableFanoutTimeline) { + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]); - let timeline = await query.getMany(); + let noteIds: string[]; + let shouldFallbackToDb = false; - timeline = timeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } - - return true; - }); + if (ps.withFiles) { + const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ + `homeTimelineWithFiles:${me.id}`, + 'localTimelineWithFiles', + ], untilId, sinceId); + noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); + } else if (ps.withReplies) { + const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([ + `homeTimeline:${me.id}`, + 'localTimeline', + 'localTimelineWithReplies', + ], untilId, sinceId); + noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); + } else { + const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ + `homeTimeline:${me.id}`, + 'localTimeline', + ], untilId, sinceId); + noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); + shouldFallbackToDb = htlNoteIds.length === 0; + } - // TODO: フィルタした結果件数が足りなかった場合の対応 + noteIds.sort((a, b) => a > b ? -1 : 1); + noteIds = noteIds.slice(0, ps.limit); - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); - process.nextTick(() => { - this.activeUsersChart.read(me); - }); + if (!shouldFallbackToDb) { + 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') + .leftJoinAndSelect('note.channel', 'channel'); - return await this.noteEntityService.packMany(timeline, me); - } else { // fallback to db - const followees = await this.userFollowingService.getFollowees(me.id); + let timeline = await query.getMany(); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere(new Brackets(qb => { - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } else { - qb.where('note.userId = :meId', { meId: me.id }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + timeline = timeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } } - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); + return true; + }); + + // TODO: フィルタした結果件数が足りなかった場合の対応 + + timeline.sort((a, b) => a.id > b.id ? -1 : 1); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + } else { // fallback to db + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me); } + } else { + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me); + } + }); + } - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); + private async getFromDb(ps: { + untilId: string | null, + sinceId: string | null, + limit: number, + includeMyRenotes: boolean, + includeRenotedMyNotes: boolean, + includeLocalRenotes: boolean, + withFiles: boolean, + withReplies: boolean, + }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { + if (followees.length > 0) { + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + } else { + qb.where('note.userId = :meId', { meId: me.id }); + qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(timeline, me); - } + })); + } + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); }); + + return await this.noteEntityService.packMany(timeline, me); } } diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 4b9882e834..879f71a2f6 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -69,6 +71,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private funoutTimelineService: FunoutTimelineService, private queryService: QueryService, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -79,112 +82,140 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.ltlDisabled); } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set(), new Set()]; + const serverSettings = await this.metaService.fetch(); - let noteIds: string[]; + if (serverSettings.enableFanoutTimeline) { + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = me ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) : [new Set(), new Set(), new Set()]; - if (ps.withFiles) { - noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); - } else { - const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([ - 'localTimeline', - 'localTimelineWithReplies', - ], untilId, sinceId); - noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds])); - noteIds.sort((a, b) => a > b ? -1 : 1); - } - - noteIds = noteIds.slice(0, ps.limit); + let noteIds: string[]; - if (noteIds.length > 0) { - 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') - .leftJoinAndSelect('note.channel', 'channel'); - - let timeline = await query.getMany(); + if (ps.withFiles) { + noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); + } else { + const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([ + 'localTimeline', + 'localTimelineWithReplies', + ], untilId, sinceId); + noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds])); + noteIds.sort((a, b) => a > b ? -1 : 1); + } - timeline = timeline.filter(note => { - if (me && (note.userId === me.id)) { - return true; - } - if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } + noteIds = noteIds.slice(0, ps.limit); - return true; - }); + if (noteIds.length > 0) { + 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') + .leftJoinAndSelect('note.channel', 'channel'); - // TODO: フィルタした結果件数が足りなかった場合の対応 + let timeline = await query.getMany(); - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + timeline = timeline.filter(note => { + if (me && (note.userId === me.id)) { + return true; + } + if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); + return true; + }); - return await this.noteEntityService.packMany(timeline, me); - } else { // fallback to db - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + // TODO: フィルタした結果件数が足りなかった場合の対応 - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return await this.noteEntityService.packMany(timeline, me); + } else { // fallback to db + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me); } + } else { + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me); + } + }); + } - if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); + private async getFromDb(ps: { + sinceId: string | null, + untilId: string | null, + limit: number, + withFiles: boolean, + withReplies: boolean, + }, me: MiLocalUser | null) { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId) + .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') + .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); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); })); - } + })); + } - const timeline = await query.limit(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return await this.noteEntityService.packMany(timeline, me); + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); } }); + + return await this.noteEntityService.packMany(timeline, me); } } diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 3b597107ae..c435fa7ec9 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['notes'], @@ -63,144 +65,171 @@ export default class extends Endpoint { // eslint- private funoutTimelineService: FunoutTimelineService, private userFollowingService: UserFollowingService, private queryService: QueryService, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const [ - followings, - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); - - let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); - noteIds = noteIds.slice(0, ps.limit); - - if (noteIds.length > 0) { - 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') - .leftJoinAndSelect('note.channel', 'channel'); - - let timeline = await query.getMany(); - - timeline = timeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; + const serverSettings = await this.metaService.fetch(); + + if (serverSettings.enableFanoutTimeline) { + const [ + followings, + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]); + + let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); + noteIds = noteIds.slice(0, ps.limit); + + if (noteIds.length > 0) { + 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') + .leftJoinAndSelect('note.channel', 'channel'); + + let timeline = await query.getMany(); + + timeline = timeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } + if (note.reply && note.reply.visibility === 'followers') { + if (!Object.hasOwn(followings, note.reply.userId)) return false; } - } - if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId)) return false; - } - - return true; - }); - - // TODO: フィルタした結果件数が足りなかった場合の対応 - - timeline.sort((a, b) => a.id > b.id ? -1 : 1); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(timeline, me); - } else { // fallback to db - const followees = await this.userFollowingService.getFollowees(me.id); - - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.channelId IS NULL') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - } else { - query.andWhere('note.userId = :meId', { meId: me.id }); + return true; + }); + + // TODO: フィルタした結果件数が足りなかった場合の対応 + + timeline.sort((a, b) => a.id > b.id ? -1 : 1); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + } else { // fallback to db + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + }, me); } + } else { + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + }, me); + } + }); + } - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); + private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.channelId IS NULL') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (followees.length > 0) { + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + + query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + } else { + query.andWhere('note.userId = :meId', { meId: me.id }); + } + + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); })); - - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(timeline, me); - } + })); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); }); + + return await this.noteEntityService.packMany(timeline, me); } } diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index c418e8413d..832d1f490f 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -93,6 +93,7 @@ describe('ActivityPub', () => { const metaInitial = { cacheRemoteFiles: true, cacheRemoteSensitiveFiles: true, + enableFanoutTimeline: true, perUserHomeTimelineCacheMax: 100, perLocalUserUserTimelineCacheMax: 100, perRemoteUserUserTimelineCacheMax: 100, diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 0072d666c9..7f2d365c3b 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -87,9 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only - +
+ + + + + @@ -165,6 +170,7 @@ let cacheRemoteSensitiveFiles: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false); let swPublicKey: any = $ref(null); let swPrivateKey: any = $ref(null); +let enableFanoutTimeline: boolean = $ref(false); let perLocalUserUserTimelineCacheMax: number = $ref(0); let perRemoteUserUserTimelineCacheMax: number = $ref(0); let perUserHomeTimelineCacheMax: number = $ref(0); @@ -185,6 +191,7 @@ async function init(): Promise { enableServiceWorker = meta.enableServiceWorker; swPublicKey = meta.swPublickey; swPrivateKey = meta.swPrivateKey; + enableFanoutTimeline = meta.enableFanoutTimeline; perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax; perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax; perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax; @@ -206,6 +213,7 @@ async function save(): void { enableServiceWorker, swPublicKey, swPrivateKey, + enableFanoutTimeline, perLocalUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax, perUserHomeTimelineCacheMax, -- cgit v1.2.3-freya From 0c730968a363482a36f20ddec0a77eef1fd8639e Mon Sep 17 00:00:00 2001 From: yukineko <27853966+hideki0403@users.noreply.github.com> Date: Tue, 24 Oct 2023 14:34:32 +0900 Subject: fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STL/リストが空になることがある問題を修正 (#12088) (#12124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: RedisTimelineが有効の場合にHTLがリセットされた状態になる問題を修正 * add: CHANGELOG.md * fix: LTL, STLでもTLが空になることがある問題を修正 * update: CHANGELOG.md * fix: DBへのフォールバック時にwithRenotesが考慮されていないのを修正 * feat: リストにもDBフォールバックを実装 * fix: リストのDBフォールバック時の挙動を修正 --------- Co-authored-by: syuilo --- CHANGELOG.md | 1 + .../server/api/endpoints/notes/hybrid-timeline.ts | 14 +- .../server/api/endpoints/notes/local-timeline.ts | 14 +- .../src/server/api/endpoints/notes/timeline.ts | 24 ++-- .../api/endpoints/notes/user-list-timeline.ts | 148 +++++++++++++++++---- 5 files changed, 153 insertions(+), 48 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index cc89995cf7..0f875b4b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 - Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 - Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正 +- Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 ## 2023.10.2 diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 518c34b949..7d0c9f1185 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -131,6 +131,8 @@ export default class extends Endpoint { // eslint- shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); + let redisTimeline: MiNote[] = []; + if (!shouldFallbackToDb) { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) @@ -141,9 +143,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); - let timeline = await query.getMany(); + redisTimeline = await query.getMany(); - timeline = timeline.filter(note => { + redisTimeline = redisTimeline.filter(note => { if (note.userId === me.id) { return true; } @@ -159,15 +161,15 @@ export default class extends Endpoint { // eslint- return true; }); - // TODO: フィルタした結果件数が足りなかった場合の対応 - - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); + } + if (redisTimeline.length > 0) { process.nextTick(() => { this.activeUsersChart.read(me); }); - return await this.noteEntityService.packMany(timeline, me); + return await this.noteEntityService.packMany(redisTimeline, me); } else { // fallback to db return await this.getFromDb({ untilId, diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 879f71a2f6..94a640e70a 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -110,6 +110,8 @@ export default class extends Endpoint { // eslint- noteIds = noteIds.slice(0, ps.limit); + let redisTimeline: MiNote[] = []; + if (noteIds.length > 0) { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) @@ -120,9 +122,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); - let timeline = await query.getMany(); + redisTimeline = await query.getMany(); - timeline = timeline.filter(note => { + redisTimeline = redisTimeline.filter(note => { if (me && (note.userId === me.id)) { return true; } @@ -139,17 +141,17 @@ export default class extends Endpoint { // eslint- return true; }); - // TODO: フィルタした結果件数が足りなかった場合の対応 - - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); + } + if (redisTimeline.length > 0) { process.nextTick(() => { if (me) { this.activeUsersChart.read(me); } }); - return await this.noteEntityService.packMany(timeline, me); + return await this.noteEntityService.packMany(redisTimeline, me); } else { // fallback to db return await this.getFromDb({ untilId, diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index c435fa7ec9..ac88c1f82b 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiNote, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -89,6 +89,8 @@ export default class extends Endpoint { // eslint- let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); noteIds = noteIds.slice(0, ps.limit); + let redisTimeline: MiNote[] = []; + if (noteIds.length > 0) { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) @@ -99,9 +101,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); - let timeline = await query.getMany(); + redisTimeline = await query.getMany(); - timeline = timeline.filter(note => { + redisTimeline = redisTimeline.filter(note => { if (note.userId === me.id) { return true; } @@ -120,15 +122,15 @@ export default class extends Endpoint { // eslint- return true; }); - // TODO: フィルタした結果件数が足りなかった場合の対応 - - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); + } + if (redisTimeline.length > 0) { process.nextTick(() => { this.activeUsersChart.read(me); }); - return await this.noteEntityService.packMany(timeline, me); + return await this.noteEntityService.packMany(redisTimeline, me); } else { // fallback to db return await this.getFromDb({ untilId, @@ -138,6 +140,7 @@ export default class extends Endpoint { // eslint- includeRenotedMyNotes: ps.includeRenotedMyNotes, includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, + withRenotes: ps.withRenotes, }, me); } } else { @@ -149,12 +152,13 @@ export default class extends Endpoint { // eslint- includeRenotedMyNotes: ps.includeRenotedMyNotes, includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, + withRenotes: ps.withRenotes, }, me); } }); } - private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; }, me: MiLocalUser) { + private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); //#region Construct query @@ -222,6 +226,10 @@ export default class extends Endpoint { // eslint- if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (ps.withRenotes === false) { + query.andWhere('note.renoteId IS NULL'); + } //#endregion const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 96e1e94f7c..9ead1410c2 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, UserListsRepository } from '@/models/_.js'; +import type { MiNote, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -13,7 +13,9 @@ import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { QueryService } from '@/core/QueryService.js'; import { ApiError } from '../../error.js'; +import { Brackets } from 'typeorm'; export const meta = { tags: ['notes', 'lists'], @@ -70,11 +72,16 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, private cacheService: CacheService, private idService: IdService, private funoutTimelineService: FunoutTimelineService, + private queryService: QueryService, + ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -102,44 +109,129 @@ export default class extends Endpoint { // eslint- let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId); noteIds = noteIds.slice(0, ps.limit); - if (noteIds.length === 0) { - return []; - } + let redisTimeline: MiNote[] = []; - 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') - .leftJoinAndSelect('note.channel', 'channel'); + if (noteIds.length > 0) { + 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') + .leftJoinAndSelect('note.channel', 'channel'); - let timeline = await query.getMany(); + redisTimeline = await query.getMany(); + + redisTimeline = redisTimeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } - timeline = timeline.filter(note => { - if (note.userId === me.id) { return true; + }); + + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); + } + + if (redisTimeline.length > 0) { + this.activeUsersChart.read(me); + return await this.noteEntityService.packMany(redisTimeline, me); + } else { // fallback to db + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) + .andWhere('note.channelId IS NULL') // チャンネルノートではない + .andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })) + .orWhere(new Brackets(qb => { + qb // 返信だけど自分宛ての返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { + qb // 返信だけどwithRepliesがtrueの場合 + .where('note.replyId IS NOT NULL') + .andWhere('userListMemberships.withReplies = true'); + })); + })); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); } - return true; - }); + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - // TODO: フィルタした結果件数が足りなかった場合の対応 + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + const timeline = await query.limit(ps.limit).getMany(); - this.activeUsersChart.read(me); + this.activeUsersChart.read(me); - return await this.noteEntityService.packMany(timeline, me); + return await this.noteEntityService.packMany(timeline, me); + } }); } } -- cgit v1.2.3-freya From afb37f0b0395c208a767b2e78e027f4426116e91 Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 24 Oct 2023 14:38:15 +0900 Subject: :art: --- .../backend/src/server/api/endpoints/get-avatar-decorations.ts | 5 ++++- packages/frontend/src/pages/settings/profile.vue | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts index ec602a0dc5..dbe1626149 100644 --- a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts +++ b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts @@ -8,6 +8,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['users'], @@ -63,16 +64,18 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private avatarDecorationService: AvatarDecorationService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const decorations = await this.avatarDecorationService.getAll(true); + const allRoles = await this.roleService.getRoles(); return decorations.map(decoration => ({ id: decoration.id, name: decoration.name, description: decoration.description, url: decoration.url, - roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration, + roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(role => role.id === roleId)), })); }); } diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 2a0b678ed1..2ac8d15545 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -96,6 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only >
{{ avatarDecoration.name }}
+
@@ -389,4 +390,10 @@ definePageMetadata({ font-weight: bold; margin-bottom: 20px; } + +.avatarDecorationLock { + position: absolute; + bottom: 12px; + right: 12px; +} -- cgit v1.2.3-freya From dc0582739f8c02be1b7297132872dffd8f3e4755 Mon Sep 17 00:00:00 2001 From: ikasoba <57828948+ikasoba@users.noreply.github.com> Date: Wed, 25 Oct 2023 21:19:43 +0900 Subject: Fix: STLでフォローしていないチャンネルが表示される問題を修正 (#12143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * タイムラインをpostgresから取得する際にフォローしていないチャンネルが取得されるのを修正 * CHANGELOGに追記 --- CHANGELOG.md | 1 + .../src/server/api/endpoints/notes/hybrid-timeline.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index c693748f67..3decae2b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 - Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正 - Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 +- Fix: STLでフォローしていないチャンネルが取得される問題を修正 ## 2023.10.2 diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 7d0c9f1185..4eeec563d7 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js'; +import type { NotesRepository, FollowingsRepository, MiNote, ChannelFollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -69,6 +69,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private noteEntityService: NoteEntityService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, @@ -208,6 +211,11 @@ export default class extends Endpoint { // eslint- withReplies: boolean, }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { @@ -226,6 +234,14 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + if (followingChannels.length > 0) { + const followingChannelIds = followingChannels.map(x => x.followeeId); + + query.andWhere('note.channelId IN (:...followingChannelIds) OR note.channelId IS NULL', { followingChannelIds }); + } else { + query.andWhere('note.channelId IS NULL'); + } + if (!ps.withReplies) { query.andWhere(new Brackets(qb => { qb -- cgit v1.2.3-freya From a8ee67caceb645b83a0857a88009c7b9b1a6e408 Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Fri, 27 Oct 2023 18:34:02 +0900 Subject: Fix: チャンネルのフォロー・アンフォローの反映速度を改善 (#12149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * チャンネルのフォロー・アンフォローの反映速度を改善 * fix lint * userFollowingChannelsCacheの場所をCacheServiceからChannelFollowingServiceに移動 --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/backend/src/core/CacheService.ts | 15 +-- .../backend/src/core/ChannelFollowingService.ts | 104 +++++++++++++++++++++ packages/backend/src/core/CoreModule.ts | 6 ++ .../src/server/api/StreamingApiServerService.ts | 3 + .../src/server/api/endpoints/channels/follow.ts | 16 +--- .../src/server/api/endpoints/channels/unfollow.ts | 12 +-- .../backend/src/server/api/stream/Connection.ts | 4 +- 8 files changed, 127 insertions(+), 34 deletions(-) create mode 100644 packages/backend/src/core/ChannelFollowingService.ts (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index 17874bba2a..9a1b2bc960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正 - Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 - Fix: STLでフォローしていないチャンネルが取得される問題を修正 +- Fix: フォローしているチャンネルをフォロー解除した時(またはその逆)、タイムラインに反映される間隔を改善 ## 2023.10.2 diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 22c510cc37..e1413342b1 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; @@ -26,7 +26,6 @@ export class CacheService implements OnApplicationShutdown { public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: RedisKVCache>; public userFollowingsCache: RedisKVCache | undefined>>; - public userFollowingChannelsCache: RedisKVCache>; constructor( @Inject(DI.redis) @@ -53,9 +52,6 @@ export class CacheService implements OnApplicationShutdown { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private userEntityService: UserEntityService, ) { //this.onMessage = this.onMessage.bind(this); @@ -150,13 +146,7 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => JSON.parse(value), }); - this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { - lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), - }); + // NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている this.redisForSub.on('message', this.onMessage); } @@ -221,7 +211,6 @@ export class CacheService implements OnApplicationShutdown { this.userBlockedCache.dispose(); this.renoteMutingsCache.dispose(); this.userFollowingsCache.dispose(); - this.userFollowingChannelsCache.dispose(); } @bindThis diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts new file mode 100644 index 0000000000..75843b9773 --- /dev/null +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -0,0 +1,104 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { ChannelFollowingsRepository } from '@/models/_.js'; +import { MiChannel } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { RedisKVCache } from '@/misc/cache.js'; + +@Injectable() +export class ChannelFollowingService implements OnModuleInit { + public userFollowingChannelsCache: RedisKVCache>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.channelFollowingsRepository.find({ + where: { followerId: key }, + select: ['followeeId'], + }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); + } + + onModuleInit() { + } + + @bindThis + public async follow( + requestUser: MiLocalUser, + targetChannel: MiChannel, + ): Promise { + await this.channelFollowingsRepository.insert({ + id: this.idService.gen(), + followerId: requestUser.id, + followeeId: targetChannel.id, + }); + + this.globalEventService.publishInternalEvent('followChannel', { + userId: requestUser.id, + channelId: targetChannel.id, + }); + } + + @bindThis + public async unfollow( + requestUser: MiLocalUser, + targetChannel: MiChannel, + ): Promise { + await this.channelFollowingsRepository.delete({ + followerId: requestUser.id, + followeeId: targetChannel.id, + }); + + this.globalEventService.publishInternalEvent('unfollowChannel', { + userId: requestUser.id, + channelId: targetChannel.id, + }); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'followChannel': { + this.userFollowingChannelsCache.refresh(body.userId); + break; + } + case 'unfollowChannel': { + this.userFollowingChannelsCache.delete(body.userId); + break; + } + } + } + } + + @bindThis + public dispose(): void { + this.userFollowingChannelsCache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index b46afb1909..c17ea9999a 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -63,6 +63,7 @@ import { SearchService } from './SearchService.js'; import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FunoutTimelineService } from './FunoutTimelineService.js'; +import { ChannelFollowingService } from './ChannelFollowingService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; import NotesChart from './chart/charts/notes.js'; @@ -193,6 +194,7 @@ const $SearchService: Provider = { provide: 'SearchService', useExisting: Search const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FunoutTimelineService: Provider = { provide: 'FunoutTimelineService', useExisting: FunoutTimelineService }; +const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; @@ -327,6 +329,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ClipService, FeaturedService, FunoutTimelineService, + ChannelFollowingService, ChartLoggerService, FederationChart, NotesChart, @@ -454,6 +457,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ClipService, $FeaturedService, $FunoutTimelineService, + $ChannelFollowingService, $ChartLoggerService, $FederationChart, $NotesChart, @@ -582,6 +586,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ClipService, FeaturedService, FunoutTimelineService, + ChannelFollowingService, FederationChart, NotesChart, UsersChart, @@ -708,6 +713,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ClipService, $FeaturedService, $FunoutTimelineService, + $ChannelFollowingService, $FederationChart, $NotesChart, $UsersChart, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index badcec1b33..dc3a00617c 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -15,6 +15,7 @@ import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -39,6 +40,7 @@ export class StreamingApiServerService { private channelsService: ChannelsService, private notificationService: NotificationService, private usersService: UserService, + private channelFollowingService: ChannelFollowingService, ) { } @@ -93,6 +95,7 @@ export class StreamingApiServerService { this.noteReadService, this.notificationService, this.cacheService, + this.channelFollowingService, user, app, ); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 76ec6be805..bb5a477eb8 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -5,9 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFollowingsRepository, ChannelsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; +import type { ChannelsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -41,11 +41,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - private idService: IdService, + private channelFollowingService: ChannelFollowingService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -56,11 +52,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - await this.channelFollowingsRepository.insert({ - id: this.idService.gen(), - followerId: me.id, - followeeId: channel.id, - }); + await this.channelFollowingService.follow(me, channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index 46883dd548..c95332c7f8 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -5,8 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFollowingsRepository, ChannelsRepository } from '@/models/_.js'; +import type { ChannelsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -40,9 +41,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, + private channelFollowingService: ChannelFollowingService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -53,10 +52,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - await this.channelFollowingsRepository.delete({ - followerId: me.id, - followeeId: channel.id, - }); + await this.channelFollowingService.unfollow(me, channel); }); } } diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index f981e63871..2d8fec30b1 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -13,6 +13,7 @@ import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; @@ -42,6 +43,7 @@ export default class Connection { private noteReadService: NoteReadService, private notificationService: NotificationService, private cacheService: CacheService, + private channelFollowingService: ChannelFollowingService, user: MiUser | null | undefined, token: MiAccessToken | null | undefined, @@ -56,7 +58,7 @@ export default class Connection { const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ this.cacheService.userProfileCache.fetch(this.user.id), this.cacheService.userFollowingsCache.fetch(this.user.id), - this.cacheService.userFollowingChannelsCache.fetch(this.user.id), + this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id), this.cacheService.userMutingsCache.fetch(this.user.id), this.cacheService.userBlockedCache.fetch(this.user.id), this.cacheService.renoteMutingsCache.fetch(this.user.id), -- cgit v1.2.3-freya From e5ff8d8445668eb2bcd1e9fa38a2d0041b6ca678 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 28 Oct 2023 07:54:06 +0900 Subject: enhance: プラグイン削除時にアクセストークンも削除する (#12167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * (enhance) プラグイン削除時にトークンも削除 * update changelog --- CHANGELOG.md | 1 + .../src/server/api/endpoints/i/revoke-token.ts | 29 ++++++++++++++++------ packages/frontend/src/pages/settings/plugin.vue | 6 +++-- 3 files changed, 27 insertions(+), 9 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ec9ee8e7..304cc0ebe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください https://misskey-hub.net/docs/advanced/publish-on-your-website.html - Feat: AiScript関数`Mk:nyaize()`が追加されました +- Enhance: プラグインを削除した際には、使用されていたアクセストークンも同時に削除されるようになりました - Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正 - Fix: ユーザーページの ノート > ファイル付き タブにリプライが表示されてしまう diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 8e2f271005..e8bb282533 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -18,8 +18,12 @@ export const paramDef = { type: 'object', properties: { tokenId: { type: 'string', format: 'misskey:id' }, + token: { type: 'string' }, }, - required: ['tokenId'], + anyOf: [ + { required: ['tokenId'] }, + { required: ['token'] }, + ], } as const; @Injectable() @@ -29,13 +33,24 @@ export default class extends Endpoint { // eslint- private accessTokensRepository: AccessTokensRepository, ) { super(meta, paramDef, async (ps, me) => { - const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } }); + if (ps.tokenId) { + const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } }); - if (tokenExist) { - await this.accessTokensRepository.delete({ - id: ps.tokenId, - userId: me.id, - }); + if (tokenExist) { + await this.accessTokensRepository.delete({ + id: ps.tokenId, + userId: me.id, + }); + } + } else if (ps.token) { + const tokenExist = await this.accessTokensRepository.exist({ where: { token: ps.token } }); + + if (tokenExist) { + await this.accessTokensRepository.delete({ + token: ps.token, + userId: me.id, + }); + } } }); } diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 4a2d8d600e..d72d8d00f3 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -77,9 +77,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; const plugins = ref(ColdDeviceStorage.get('plugins')); -function uninstall(plugin) { +async function uninstall(plugin) { ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id)); - os.success(); + await os.apiWithDialog('i/revoke-token', { + token: plugin.token, + }); nextTick(() => { unisonReload(); }); -- cgit v1.2.3-freya From a161a9c1e701f578aeec017e468cfd87bba65877 Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Sun, 29 Oct 2023 14:16:36 +0900 Subject: Fix: notes/timelineにフォローしているチャンネルのノートを含める (#12179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * notes/timelineにフォローしているチャンネルのノートを含める * fix CHANGELOG.md --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> --- CHANGELOG.md | 1 + .../src/server/api/endpoints/notes/timeline.ts | 41 ++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index b909b26cae..420bc87cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 - Fix: STLでフォローしていないチャンネルが取得される問題を修正 - Fix: `hashtags/trend`にてRedisからトレンドの情報が取得できない際にInternal Server Errorになる問題を修正 +- Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 ## 2023.10.2 diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index ac88c1f82b..e048bc4dd2 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { MiNote, NotesRepository } from '@/models/_.js'; +import type { MiNote, NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -58,6 +58,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, private idService: IdService, @@ -160,22 +163,48 @@ export default class extends Endpoint { // eslint- private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.channelId IS NULL') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (followees.length > 0) { + if (followees.length > 0 && followingChannels.length > 0) { + // ユーザー・チャンネルともにフォローあり const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - - query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + const followingChannelIds = followingChannels.map(x => x.followeeId); + query.andWhere(new Brackets(qb => { + qb + .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) + .orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); + })); + } else if (followees.length > 0) { + // ユーザーフォローのみ(チャンネルフォローなし) + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + query + .andWhere('note.channelId IS NULL') + .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + } else if (followingChannels.length > 0) { + // チャンネルフォローのみ(ユーザーフォローなし) + const followingChannelIds = followingChannels.map(x => x.followeeId); + query.andWhere(new Brackets(qb => { + qb + .where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }) + .orWhere('note.userId = :meId', { meId: me.id }); + })); } else { - query.andWhere('note.userId = :meId', { meId: me.id }); + // フォローなし + query + .andWhere('note.channelId IS NULL') + .andWhere('note.userId = :meId', { meId: me.id }); } query.andWhere(new Brackets(qb => { -- cgit v1.2.3-freya From 0fc36d11d705ede03e0349e467599040ec6c329c Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 29 Oct 2023 16:13:30 +0900 Subject: fix(backend): STLにGTLの投稿が混ざる問題を修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #12169 --- packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 4eeec563d7..7f9d728976 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -237,7 +237,10 @@ export default class extends Endpoint { // eslint- if (followingChannels.length > 0) { const followingChannelIds = followingChannels.map(x => x.followeeId); - query.andWhere('note.channelId IN (:...followingChannelIds) OR note.channelId IS NULL', { followingChannelIds }); + query.andWhere(new Brackets(qb => { + qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); + qb.andWhere('note.channelId IS NULL'); + })); } else { query.andWhere('note.channelId IS NULL'); } -- cgit v1.2.3-freya From aefc941df3d445720ae0ba60215e58f75b8e0001 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 29 Oct 2023 16:28:32 +0900 Subject: typo --- packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 7f9d728976..19c24a78f4 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -239,7 +239,7 @@ export default class extends Endpoint { // eslint- query.andWhere(new Brackets(qb => { qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); - qb.andWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId IS NULL'); })); } else { query.andWhere('note.channelId IS NULL'); -- cgit v1.2.3-freya From 2de4d3329d0d9ea9b014b773156e55a976904a8c Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Mon, 30 Oct 2023 08:19:27 +0900 Subject: Fix: フォローしたユーザが、自分のフォローしていないチャンネルにノートを投稿した時、そのノートがHTLで見えてしまう (#12186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #12181 の問題に対処 「ユーザー・チャンネルともにフォローあり」のときの絞り込みに不備があり、フォローしているユーザの投稿であればチャンネルのフォロー関係なく通過するようになってしまっていた * fix CHANGELOG.md --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> --- CHANGELOG.md | 2 +- packages/backend/src/server/api/endpoints/notes/timeline.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4ce5bede..abfb0692bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ - Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 - Fix: STLでフォローしていないチャンネルが取得される問題を修正 - Fix: `hashtags/trend`にてRedisからトレンドの情報が取得できない際にInternal Server Errorになる問題を修正 -- Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 +- Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 #12181 ## 2023.10.2 diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index e048bc4dd2..5016bd3acb 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -183,7 +183,11 @@ export default class extends Endpoint { // eslint- const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb - .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) + .where(new Brackets(qb2 => { + qb2 + .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) + .andWhere('note.channelId IS NULL'); + })) .orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); })); } else if (followees.length > 0) { -- cgit v1.2.3-freya From 7015cc937bd872e9c7ebafb56bdaea4ca138e0df Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Mon, 30 Oct 2023 13:48:22 +0900 Subject: fix(backend): We can renote pure renote (#12171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: make pure renote detection an function * fix: we can renote pure renote * docs(changelog): リノートをリノートできるのを修正 * fix: remaining debug log * chore: move isPureRenote to misc * chore: make isPureRenote type guard * chore: use isPureRenote in other places * fix CHANGELOG * style: fix lint --------- Co-authored-by: syuilo --- CHANGELOG.md | 1 + packages/backend/src/core/NoteDeleteService.ts | 5 +++-- packages/backend/src/misc/is-pure-renote.ts | 10 ++++++++++ packages/backend/src/server/ActivityPubServerService.ts | 3 ++- packages/backend/src/server/api/endpoints/notes/create.ts | 5 +++-- 5 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/misc/is-pure-renote.ts (limited to 'packages/backend/src/server/api/endpoints') diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dfe43ff0f..66b2e0ae4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ - Fix: STLでフォローしていないチャンネルが取得される問題を修正 - Fix: `hashtags/trend`にてRedisからトレンドの情報が取得できない際にInternal Server Errorになる問題を修正 - Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 #12181 +- Fix: リノートをリノートできるのを修正 ## 2023.10.2 diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 9a817ffd76..632daf991a 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,6 +24,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; @Injectable() export class NoteDeleteService { @@ -77,8 +78,8 @@ export class NoteDeleteService { if (this.userEntityService.isLocalUser(user) && !note.localOnly) { let renote: MiNote | null = null; - // if deletd note is renote - if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + // if deleted note is renote + if (isPureRenote(note)) { renote = await this.notesRepository.findOneBy({ id: note.renoteId, }); diff --git a/packages/backend/src/misc/is-pure-renote.ts b/packages/backend/src/misc/is-pure-renote.ts new file mode 100644 index 0000000000..994d981522 --- /dev/null +++ b/packages/backend/src/misc/is-pure-renote.ts @@ -0,0 +1,10 @@ +import type { MiNote } from '@/models/Note.js'; + +export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable } { + if (!note.renoteId) return false; + + if (note.text) return false; // it's quoted with text + if (note.fileIds.length !== 0) return false; // it's quoted with files + if (note.hasPoll) return false; // it's quoted with poll + return true; +} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index a7f6f82daf..2e64d41c91 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -26,6 +26,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; @@ -88,7 +89,7 @@ export class ActivityPubServerService { */ @bindThis private async packActivity(note: MiNote): Promise { - if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + if (isPureRenote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); } diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 3ae4ac044a..649068fb20 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,6 +17,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; export const meta = { tags: ['notes'], @@ -221,7 +222,7 @@ export default class extends Endpoint { // eslint- if (renote == null) { throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { + } else if (isPureRenote(renote)) { throw new ApiError(meta.errors.cannotReRenote); } @@ -254,7 +255,7 @@ export default class extends Endpoint { // eslint- if (reply == null) { throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { + } else if (isPureRenote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); } -- cgit v1.2.3-freya From 4f180ad45cc8c09a198a3de536681766dd2e5be8 Mon Sep 17 00:00:00 2001 From: GrapeApple0 <84321396+GrapeApple0@users.noreply.github.com> Date: Mon, 30 Oct 2023 15:33:15 +0900 Subject: feat: アイコンデコレーションの管理をロールで設定できるように (#12173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * アイコンデコレーションの管理をロールで設定できるように * インポートミス * Update packages/frontend/src/ui/_common_/common.ts Co-authored-by: syuilo * Update packages/frontend/src/ui/_common_/common.ts Co-authored-by: syuilo --------- Co-authored-by: syuilo --- locales/index.d.ts | 2 + locales/ja-JP.yml | 2 + packages/backend/src/core/RoleService.ts | 3 + .../endpoints/admin/avatar-decorations/create.ts | 2 +- .../endpoints/admin/avatar-decorations/delete.ts | 3 +- .../api/endpoints/admin/avatar-decorations/list.ts | 2 +- .../endpoints/admin/avatar-decorations/update.ts | 2 +- packages/frontend/src/const.ts | 1 + .../src/pages/admin/avatar-decorations.vue | 103 --------------------- packages/frontend/src/pages/admin/index.vue | 2 +- packages/frontend/src/pages/admin/roles.editor.vue | 20 ++++ packages/frontend/src/pages/admin/roles.vue | 8 ++ packages/frontend/src/pages/avatar-decorations.vue | 102 ++++++++++++++++++++ packages/frontend/src/router.ts | 6 +- packages/frontend/src/ui/_common_/common.ts | 5 + 15 files changed, 153 insertions(+), 110 deletions(-) delete mode 100644 packages/frontend/src/pages/admin/avatar-decorations.vue create mode 100644 packages/frontend/src/pages/avatar-decorations.vue (limited to 'packages/backend/src/server/api/endpoints') diff --git a/locales/index.d.ts b/locales/index.d.ts index e99b278c8c..8bc073d1e5 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -982,6 +982,7 @@ export interface Locale { "unassign": string; "color": string; "manageCustomEmojis": string; + "manageAvatarDecorations": string; "youCannotCreateAnymore": string; "cannotPerformTemporary": string; "cannotPerformTemporaryDescription": string; @@ -1575,6 +1576,7 @@ export interface Locale { "inviteLimitCycle": string; "inviteExpirationTime": string; "canManageCustomEmojis": string; + "canManageAvatarDecorations": string; "driveCapacity": string; "alwaysMarkNsfw": string; "pinMax": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 32877b806a..035cecd25a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -979,6 +979,7 @@ assign: "アサイン" unassign: "アサインを解除" color: "色" manageCustomEmojis: "カスタム絵文字の管理" +manageAvatarDecorations: "アバターデコレーションの管理" youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" @@ -1496,6 +1497,7 @@ _role: inviteLimitCycle: "招待コードの発行間隔" inviteExpirationTime: "招待コードの有効期限" canManageCustomEmojis: "カスタム絵文字の管理" + canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" pinMax: "ノートのピン留めの最大数" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index ef05920d50..d6a414694a 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -32,6 +32,7 @@ export type RolePolicies = { inviteLimitCycle: number; inviteExpirationTime: number; canManageCustomEmojis: boolean; + canManageAvatarDecorations: boolean; canSearchNotes: boolean; canUseTranslator: boolean; canHideAds: boolean; @@ -57,6 +58,7 @@ export const DEFAULT_POLICIES: RolePolicies = { inviteLimitCycle: 60 * 24 * 7, inviteExpirationTime: 0, canManageCustomEmojis: false, + canManageAvatarDecorations: false, canSearchNotes: false, canUseTranslator: true, canHideAds: false, @@ -306,6 +308,7 @@ export class RoleService implements OnApplicationShutdown { inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), + canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts index c1869b141a..ec143fcb53 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireRolePolicy: 'canManageAvatarDecorations', } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts index 5aba24b426..6f1f386871 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts @@ -13,8 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, - + requireRolePolicy: 'canManageAvatarDecorations', errors: { }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts index 9a32a59081..d9c669377d 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireRolePolicy: 'canManageAvatarDecorations', res: { type: 'array', diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts index 564014a3df..5ea9a40762 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireRolePolicy: 'canManageAvatarDecorations', errors: { }, diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 3998df9efe..b3071fd924 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -66,6 +66,7 @@ export const ROLE_POLICIES = [ 'inviteLimitCycle', 'inviteExpirationTime', 'canManageCustomEmojis', + 'canManageAvatarDecorations', 'canSearchNotes', 'canUseTranslator', 'canHideAds', diff --git a/packages/frontend/src/pages/admin/avatar-decorations.vue b/packages/frontend/src/pages/admin/avatar-decorations.vue deleted file mode 100644 index b4007e6d20..0000000000 --- a/packages/frontend/src/pages/admin/avatar-decorations.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index b304edbf57..2bb1e80c18 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -118,7 +118,7 @@ const menuDef = $computed(() => [{ }, { icon: 'ti ti-sparkles', text: i18n.ts.avatarDecorations, - to: '/admin/avatar-decorations', + to: '/avatar-decorations', active: currentPage?.route.name === 'avatarDecorations', }, { icon: 'ti ti-whirl', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 89dd7d4c7c..1db99e61f4 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -259,6 +259,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+