diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 1b7b68b14..d603052a7 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -20,7 +20,7 @@ jobs: - run: corepack enable - name: Setup Node.js - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 47517ac7e..3fdc2b86b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -36,7 +36,7 @@ jobs: run: | echo "FORMATTED_BRANCH_NAME=$(echo ${{ github.ref_name }} | sed -e 's/\//-/g' )" >> $GITHUB_ENV - name: Build and Push to GitHub Container Registry - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: builder: ${{ steps.buildx.outputs.name }} context: . diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index 070cc08ab..9cf2f89a3 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Build an image from Dockerfile - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2ba6cac88..39731b64f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,7 @@ jobs: - uses: pnpm/action-setup@v4 with: run_install: false - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' @@ -55,7 +55,7 @@ jobs: - uses: pnpm/action-setup@v4 with: run_install: false - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' @@ -80,7 +80,7 @@ jobs: - uses: pnpm/action-setup@v4 with: run_install: false - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 2de05ce2a..afe8124cd 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -47,7 +47,7 @@ jobs: - name: Install FFmpeg uses: FedericoCarboni/setup-ffmpeg@v3 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -97,7 +97,7 @@ jobs: with: run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 6906e176c..16d6a7924 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -37,7 +37,7 @@ jobs: with: run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 68fbb76d9..3139a230c 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -30,7 +30,7 @@ jobs: - run: corepack enable - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 807362541..f3686b80e 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -27,7 +27,7 @@ jobs: with: run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index 89fff6853..cabee90d8 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -28,7 +28,7 @@ jobs: with: run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/locales/index.d.ts b/locales/index.d.ts index 2145dda7a..f86e655c6 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5359,6 +5359,10 @@ export interface Locale extends ILocale { * 初期設定をあとでやり直しますか? */ "laterAreYouSure": string; + /** + * Botアカウントは管理者を必ず記載する必要があります。以下から管理者のアカウントを選択してください。 + */ + "mustBeSetBotOwner": string; }; "_initialTutorial": { /** @@ -6868,6 +6872,10 @@ export interface Locale extends ILocale { * サウンド設定でドライブのファイルを利用 */ "canUseDriveFileInSoundSettings": string; + /** + * リアクションの利用 + */ + "canUseReaction": string; /** * アイコンデコレーションの最大取付個数 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 78dd57833..23c460ed8 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1344,6 +1344,7 @@ _initialAccountSetting: startTutorial: "チュートリアルを開始" skipAreYouSure: "初期設定をスキップしますか?" laterAreYouSure: "初期設定をあとでやり直しますか?" + mustBeSetBotOwner: "Botアカウントは管理者を必ず記載する必要があります。以下から管理者のアカウントを選択してください。" _initialTutorial: launchTutorial: "チュートリアルを見る" @@ -1774,6 +1775,7 @@ _role: canSearchNotes: "ノート検索の利用" canUseTranslator: "翻訳機能の利用" canUseDriveFileInSoundSettings: "サウンド設定でドライブのファイルを利用" + canUseReaction: "リアクションの利用" avatarDecorationLimit: "アイコンデコレーションの最大取付個数" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 8cde13052..477e21317 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -2226,6 +2226,7 @@ _instanceCharts: _timelines: home: "首頁" local: "本地" + media: "媒體" social: "社交" global: "公開" _play: diff --git a/packages/backend/package.json b/packages/backend/package.json index 2ba124347..bb14eb21a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -72,6 +72,7 @@ "@bull-board/fastify": "5.18.1", "@bull-board/ui": "5.18.1", "@discordapp/twemoji": "15.0.3", + "@elastic/elasticsearch": "^8.14.0", "@fastify/accepts": "4.3.0", "@fastify/cookie": "9.3.1", "@fastify/cors": "9.0.1", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 5669c8f07..d58baede2 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -8,6 +8,7 @@ import { Global, Inject, Module } from '@nestjs/common'; import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; import { MeiliSearch } from 'meilisearch'; +import { Client as ElasticSearch } from '@elastic/elasticsearch'; import { DI } from './di-symbols.js'; import { Config, loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; @@ -44,6 +45,30 @@ const $meilisearch: Provider = { inject: [DI.config], }; +const $elasticsearch: Provider = { + provide: DI.elasticsearch, + useFactory: (config: Config) => { + if (config.elasticsearch) { + return new ElasticSearch({ + nodes: { + url: new URL(`${config.elasticsearch.ssl ? 'https' : 'http'}://${config.elasticsearch.host}:${config.elasticsearch.port}`), + ssl: { + rejectUnauthorized: config.elasticsearch.rejectUnauthorized, + }, + }, + auth: (config.elasticsearch.user && config.elasticsearch.pass) ? { + username: config.elasticsearch.user, + password: config.elasticsearch.pass, + } : undefined, + pingTimeout: 30000, + }); + } else { + return null; + } + }, + inject: [DI.config], +}; + const $redis: Provider = { provide: DI.redis, useFactory: (config: Config) => { @@ -160,8 +185,8 @@ const $redisForTimelines: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], - exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], + providers: [$config, $db, $meilisearch, $elasticsearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], + exports: [$config, $db, $meilisearch, $elasticsearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index caf14e5aa..918fa6021 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -66,6 +66,16 @@ type Source = { scope?: 'local' | 'global' | string[]; }; + elasticsearch?: { + host: string; + port: string; + user: string; + pass: string; + ssl?: boolean; + rejectUnauthorized?: boolean; + index: string; + }; + skebStatus?: { method: string; endpoint: string; @@ -149,6 +159,15 @@ export type Config = { index: string; scope?: 'local' | 'global' | string[]; } | undefined; + elasticsearch: { + host: string; + port: string; + user: string; + pass: string; + ssl?: boolean; + rejectUnauthorized?: boolean; + index: string; + } | undefined; skebStatus: { method: string; endpoint: string; @@ -272,6 +291,7 @@ export function loadConfig(): Config { dbReplications: config.dbReplications, dbSlaves: config.dbSlaves, meilisearch: config.meilisearch, + elasticsearch: config.elasticsearch, redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForSystemQueue: config.redisForSystemQueue ? convertRedisOptions(config.redisForSystemQueue, host) : redisForJobQueue, diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 2ab0e5072..3e5223944 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -53,6 +53,7 @@ import { UserFollowingService } from './UserFollowingService.js'; import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; import { UserMutingService } from './UserMutingService.js'; +import { UserRenoteMutingService } from './UserRenoteMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; import { UserAuthService } from './UserAuthService.js'; import { VideoProcessingService } from './VideoProcessingService.js'; @@ -191,6 +192,7 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; +const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService', useExisting: UserRenoteMutingService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; @@ -332,6 +334,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserRenoteMutingService, UserSuspendService, UserAuthService, VideoProcessingService, @@ -467,6 +470,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserRenoteMutingService, $UserSuspendService, $UserAuthService, $VideoProcessingService, @@ -603,6 +607,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserRenoteMutingService, UserSuspendService, UserAuthService, VideoProcessingService, @@ -737,6 +742,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserRenoteMutingService, $UserSuspendService, $UserAuthService, $VideoProcessingService, diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index e4aa0b983..626914c37 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -116,10 +116,10 @@ export class ReactionService { if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } + const policies = await this.roleService.getUserPolicies(user.id); let reaction = _reaction ?? FALLBACK; - - if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) { + if (note.reactionAcceptance === 'likeOnly' || !policies.canUseReaction || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) { reaction = '\u2764'; } else if (_reaction) { const custom = reaction.match(isCustomEmojiRegexp); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index a95588fc4..b3d55eda7 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -53,6 +53,7 @@ export type RolePolicies = { canSearchNotes: boolean; canUseTranslator: boolean; canUseDriveFileInSoundSettings: boolean; + canUseReaction: boolean; canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; @@ -92,6 +93,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canSearchNotes: false, canUseTranslator: true, canUseDriveFileInSoundSettings: false, + canUseReaction: true, canHideAds: false, driveCapacityMb: 100, alwaysMarkNsfw: false, @@ -405,6 +407,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canUseDriveFileInSoundSettings: calc('canUseDriveFileInSoundSettings', vs => vs.some(v => v === true)), + canUseReaction: calc('canUseReaction', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index c55732537..a7871c07f 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -8,6 +8,7 @@ import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import { LoggerService } from '@/core/LoggerService.js'; import { MiNote } from '@/models/Note.js'; import { MiUser } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; @@ -16,9 +17,10 @@ import { isUserRelated } from '@/misc/is-user-related.js'; import { CacheService } from '@/core/CacheService.js'; import { QueryService } from '@/core/QueryService.js'; import { IdService } from '@/core/IdService.js'; +import type Logger from '@/logger.js'; import { UserEntityService } from './entities/UserEntityService.js'; import type { Index, MeiliSearch } from 'meilisearch'; -// import { nonMaxSuppressionV3Impl } from '@tensorflow/tfjs-core/dist/backends/non_max_suppression_impl.js'; +import type { Client as ElasticSearch } from '@elastic/elasticsearch'; type K = string; type V = string | number | boolean; @@ -67,6 +69,8 @@ function compileQuery(q: Q): string { export class SearchService { private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local'; private meilisearchNoteIndex: Index | null = null; + private elasticsearchNoteIndex: string | null = null; + private logger: Logger; constructor( @Inject(DI.config) @@ -75,6 +79,9 @@ export class SearchService { @Inject(DI.meilisearch) private meilisearch: MeiliSearch | null, + @Inject(DI.elasticsearch) + private elasticsearch: ElasticSearch | null, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -82,9 +89,15 @@ export class SearchService { private cacheService: CacheService, private queryService: QueryService, private idService: IdService, + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('note:search'); + if (meilisearch) { this.meilisearchNoteIndex = meilisearch.index(`${config.meilisearch!.index}---notes`); + if (config.meilisearch?.scope) { + this.meilisearchIndexScope = config.meilisearch.scope; + } /*this.meilisearchNoteIndex.updateSettings({ searchableAttributes: [ 'text', @@ -107,10 +120,52 @@ export class SearchService { maxTotalHits: 10000, }, });*/ - } - - if (config.meilisearch?.scope) { - this.meilisearchIndexScope = config.meilisearch.scope; + } else if (this.elasticsearch) { + this.elasticsearchNoteIndex = `${config.elasticsearch!.index}---notes`; + this.elasticsearch.indices.exists({ + index: this.elasticsearchNoteIndex, + }).then((indexExists: boolean) => { + if (!indexExists) { + this.elasticsearch?.indices.create( + { + index: this.elasticsearchNoteIndex + `-${new Date().toISOString().slice(0, 7).replace(/-/g, '')}`, + mappings: { + properties: { + text: { type: 'text' }, + cw: { type: 'text' }, + createdAt: { type: 'long' }, + userId: { type: 'keyword' }, + userHost: { type: 'keyword' }, + channelId: { type: 'keyword' }, + tags: { type: 'keyword' }, + }, + }, + settings: { + index: { + analysis: { + tokenizer: { + kuromoji: { + type: 'kuromoji_tokenizer', + mode: 'search', + }, + }, + analyzer: { + kuromoji_analyzer: { + type: 'custom', + tokenizer: 'kuromoji', + }, + }, + }, + }, + }, + }, + ).catch((error: any) => { + this.logger.error(error); + }); + } + }).catch((error: any) => { + this.logger.error('Error while checking if index exists', error); + }); } } @@ -147,6 +202,23 @@ export class SearchService { }], { primaryKey: 'id', }); + } else if (this.elasticsearch) { + const body = { + createdAt: this.idService.parse(note.id).date.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + tags: note.tags, + }; + await this.elasticsearch.index({ + index: this.elasticsearchNoteIndex + `-${new Date().toISOString().slice(0, 7).replace(/-/g, '')}` as string, + id: note.id, + body: body, + }).catch((error: any) => { + console.error(error); + }); } } @@ -155,7 +227,7 @@ export class SearchService { // if (!['home', 'public'].includes(note.visibility)) return; if (this.meilisearch) { - this.meilisearchNoteIndex!.deleteDocument(note.id); + this.meilisearchNoteIndex?.deleteDocument(note.id); } } @@ -224,6 +296,67 @@ export class SearchService { const dataFilter = data.filter(d => d.result); const filteredNotes = dataFilter.map(d => d.note); return filteredNotes.sort((a, b) => a.id > b.id ? -1 : 1); + } else if (this.elasticsearch) { + const esFilter: any = { + bool: { + must: [], + }, + }; + + if (pagination.untilId) esFilter.bool.must.push({ range: { createdAt: { lt: this.idService.parse(pagination.untilId).date.getTime() } } }); + if (pagination.sinceId) esFilter.bool.must.push({ range: { createdAt: { gt: this.idService.parse(pagination.sinceId).date.getTime() } } }); + if (opts.userId) esFilter.bool.must.push({ term: { userId: opts.userId } }); + if (opts.channelId) esFilter.bool.must.push({ term: { channelId: opts.channelId } }); + if (opts.host) { + if (opts.host === '.') { + esFilter.bool.must.push({ bool: { must_not: [{ exists: { field: 'userHost' } }] } }); + } else { + esFilter.bool.must.push({ term: { userHost: opts.host } }); + } + } + + if (q !== '') { + esFilter.bool.must.push({ + bool: { + should: [ + { wildcard: { 'text': { value: q } } }, + { simple_query_string: { fields: ['text'], 'query': q, default_operator: 'and' } }, + { wildcard: { 'cw': { value: q } } }, + { simple_query_string: { fields: ['cw'], 'query': q, default_operator: 'and' } }, + ], + minimum_should_match: 1, + }, + }); + } + + const res = await (this.elasticsearch.search)({ + index: this.elasticsearchNoteIndex + '*' as string, + body: { + query: esFilter, + sort: [{ createdAt: { order: 'desc' } }], + }, + _source: ['id', 'createdAt'], + size: pagination.limit, + }); + + const noteIds = res.hits.hits.map((hit: any) => hit._id); + if (noteIds.length === 0) return []; + const [ + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + ] = me ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) : [new Set(), new Set()]; + const notes = (await this.notesRepository.findBy({ + id: In(noteIds), + })).filter(note => { + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + return true; + }); + + return notes.sort((a, b) => a.id > b.id ? -1 : 1); } else { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); diff --git a/packages/backend/src/core/UserRenoteMutingService.ts b/packages/backend/src/core/UserRenoteMutingService.ts new file mode 100644 index 000000000..8e532625e --- /dev/null +++ b/packages/backend/src/core/UserRenoteMutingService.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import type { RenoteMutingsRepository } from '@/models/_.js'; +import type { MiRenoteMuting } from '@/models/RenoteMuting.js'; +import { IdService } from '@/core/IdService.js'; +import type { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; + +@Injectable() +export class UserRenoteMutingService { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private idService: IdService, + private cacheService: CacheService, + ) { + } + + @bindThis + public async mute(user: MiUser, target: MiUser): Promise { + await this.renoteMutingsRepository.insert({ + id: this.idService.gen(), + muterId: user.id, + muteeId: target.id, + }); + + await this.cacheService.renoteMutingsCache.refresh(user.id); + } + + @bindThis + public async unmute(mutings: MiRenoteMuting[]): Promise { + if (mutings.length === 0) return; + + await this.renoteMutingsRepository.delete({ + id: In(mutings.map(m => m.id)), + }); + + const muterIds = [...new Set(mutings.map(m => m.muterId))]; + for (const muterId of muterIds) { + await this.cacheService.renoteMutingsCache.refresh(muterId); + } + } +} diff --git a/packages/backend/src/core/chart/charts/per-user-pv.ts b/packages/backend/src/core/chart/charts/per-user-pv.ts index 31708fefa..40ff9061f 100644 --- a/packages/backend/src/core/chart/charts/per-user-pv.ts +++ b/packages/backend/src/core/chart/charts/per-user-pv.ts @@ -52,4 +52,12 @@ export default class PerUserPvChart extends Chart { // eslint-dis 'pv.visitor': 1, }, user.id); } + + @bindThis + public async getChartUsers(span: 'hour' | 'day', amount: number, cursor: Date | null, limit = 0, offset = 0): Promise<{ + userId: string; + count: number; +}[]> { + return await this.getChartPv(span, amount, cursor, limit, offset); + } } diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts index a35d30b1b..4ecbc5bf0 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -147,9 +147,10 @@ export default abstract class Chart { // ↓にしたいけどfindOneとかで型エラーになる //private repositoryForHour: Repository>; //private repositoryForDay: Repository>; - private repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }>; - private repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }>; - + private repositoryForHour: Repository<{ id: number; group?: string | null; date: number;}>; + private repositoryForDay: Repository<{ id: number; group?: string | null; date: number;}>; + private repositoryUserPvForHour: Repository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>; + private repositoryUserPvForDay: Repository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>; /** * 1日に一回程度実行されれば良いような計算処理を入れる(主にCASCADE削除などアプリケーション側で感知できない変動によるズレの修正用) */ @@ -273,6 +274,8 @@ export default abstract class Chart { const { hour, day } = Chart.schemaToEntity(name, schema, grouped); this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour); this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day); + this.repositoryUserPvForHour = db.getRepository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>(hour); + this.repositoryUserPvForDay = db.getRepository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>(day); } @bindThis @@ -722,4 +725,51 @@ export default abstract class Chart { } return object as Unflatten>; } + + @bindThis + public async getChartPv(span: 'hour' | 'day', amount: number, cursor: Date | null, limit: number, offset: number): Promise< + { + userId: string, + count: number, + }[] + > { + const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate(); + const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never; + + const lt = dateUTC([y, m, d, h, _m, _s, _ms]); + + const gt = + span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') : + span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') : + new Error('not happen') as never; + + const repository = + span === 'hour' ? this.repositoryUserPvForHour : + span === 'day' ? this.repositoryUserPvForDay : + new Error('not happen') as never; + + // ログ取得 + const logs = await repository.createQueryBuilder() + .where('date BETWEEN :gt AND :lt', { gt: Chart.dateToTimestamp(gt), lt: Chart.dateToTimestamp(lt) }) + .orderBy('___pv_visitor + ___upv_visitor + ___pv_user + ___upv_user', 'DESC') + .skip(offset) + .take(limit) + .getMany() as { + ___pv_visitor: number, + ___upv_visitor: number, + ___pv_user: number, + ___upv_user: number, + group: string, + }[]; + const result = [] as { + userId: string, + count: number, + }[]; + for (const row of logs) { + const userId = row.group; + const count = row.___pv_user + row.___upv_user + row.___pv_visitor + row.___upv_visitor; + result.push({ userId, count }); + } + return result; + } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 66dbda53e..881d134da 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -501,13 +501,16 @@ export class UserEntityService implements OnModuleInit { } : undefined) : undefined, emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), - // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ - name: r.name, - iconUrl: r.iconUrl, - displayOrder: r.displayOrder, - behavior: r.badgeBehavior ?? undefined, - }))) : undefined, + badgeRoles: this.roleService.getUserBadgeRoles(user.id).then((rs) => rs + .filter((r) => r.isPublic || iAmModerator) + .sort((a, b) => b.displayOrder - a.displayOrder) + .map((r) => ({ + name: r.name, + iconUrl: r.iconUrl, + displayOrder: r.displayOrder, + behavior: r.badgeBehavior ?? undefined, + })), + ), ...(isDetailed ? { url: profile?.url, diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 0ba0b86c9..fb570d0b4 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -7,6 +7,7 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), meilisearch: Symbol('meilisearch'), + elasticsearch: Symbol('elasticsearch'), redis: Symbol('redis'), redisForPub: Symbol('redisForPub'), redisForSub: Symbol('redisForSub'), diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 63dfdcf47..b6451a5a3 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -248,6 +248,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canUseReaction: { + type: 'boolean', + optional: false, nullable: false, + }, canHideAds: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index 00dfa3c35..a680e9b74 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -6,11 +6,10 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { RenoteMutingsRepository } from '@/models/_.js'; -import type { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { UserRenoteMutingService } from '@/core/UserRenoteMutingService.js'; +import type { RenoteMutingsRepository } from '@/models/_.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -64,7 +63,7 @@ export default class extends Endpoint { // eslint- private renoteMutingsRepository: RenoteMutingsRepository, private getterService: GetterService, - private idService: IdService, + private userRenoteMutingService: UserRenoteMutingService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -81,21 +80,19 @@ export default class extends Endpoint { // eslint- }); // Check if already muting - const exist = await this.renoteMutingsRepository.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, + const exist = await this.renoteMutingsRepository.exists({ + where: { + muterId: muter.id, + muteeId: mutee.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyMuting); } // Create mute - await this.renoteMutingsRepository.insert({ - id: this.idService.gen(), - muterId: muter.id, - muteeId: mutee.id, - } as MiRenoteMuting); + await this.userRenoteMutingService.mute(muter, mutee); }); } } diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts index 85d100ce1..19386ae9d 100644 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApiError } from '../../error.js'; +import type { Packed } from '@/misc/json-schema.js'; export const meta = { tags: ['role', 'users'], @@ -92,10 +93,12 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); - return await Promise.all(assigns.map(async assign => ({ + return (await Promise.allSettled(assigns.map(async assign => ({ id: assign.id, user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), - }))); + })))) + .filter((result): result is PromiseFulfilledResult<{ id: string; user: Packed<'UserDetailed'> }> => result.status === 'fulfilled') + .map(result => result.value); }); } } diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index e84585301..a424d7a4c 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; import { QueryService } from '@/core/QueryService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -31,7 +32,7 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, + sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt', '+pv', '-pv'] }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, hostname: { @@ -49,6 +50,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, + private perUserPvChart: PerUserPvChart, private userEntityService: UserEntityService, private queryService: QueryService, @@ -70,7 +72,12 @@ export default class extends Endpoint { // eslint- if (ps.hostname) { query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); } - + const chartUsers: { userId: string; count: number; }[] = []; + if (ps.sort?.endsWith('pv')) { + await this.perUserPvChart.getChartUsers('hour', 0, null, ps.limit, ps.offset).then(users => { + chartUsers.push(...users); + }); + } switch (ps.sort) { case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '-follower': query.orderBy('user.followersCount', 'ASC'); break; @@ -78,6 +85,16 @@ export default class extends Endpoint { // eslint- case '-createdAt': query.orderBy('user.id', 'ASC'); break; case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break; + case '+pv': + if (chartUsers.length > 0) { + query.andWhere('user.id IN (:...userIds)', { userIds: chartUsers.map(user => user.userId) }); + } + break; + case '-pv': + if (chartUsers.length > 0) { + query.andWhere('user.id IN (:...userIds)', { userIds: chartUsers.map(user => user.userId) }); + } + break; default: query.orderBy('user.id', 'ASC'); break; } @@ -88,6 +105,19 @@ export default class extends Endpoint { // eslint- query.offset(ps.offset); const users = await query.getMany(); + if (ps.sort === '+pv') { + users.sort((a, b) => { + const aPv = chartUsers.find(user => user.userId === a.id)?.count ?? 0; + const bPv = chartUsers.find(user => user.userId === b.id)?.count ?? 0; + return bPv - aPv; + }); + } else if (ps.sort === '-pv') { + users.sort((a, b) => { + const aPv = chartUsers.find(user => user.userId === a.id)?.count ?? 0; + const bPv = chartUsers.find(user => user.userId === b.id)?.count ?? 0; + return aPv - bPv; + }); + } return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts index 059e14a36..e5172226a 100644 --- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -14,6 +14,7 @@ import { GetterService } from '@/server/api/GetterService.js'; import { CacheService } from '@/core/CacheService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; +import { Packed } from '@/misc/json-schema.js'; export const meta = { tags: ['users'], @@ -131,10 +132,12 @@ export default class extends Endpoint { // eslint- const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); // Make replies object (includes weights) - const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + const repliesObj = (await Promise.allSettled(topRepliedUsers.map(async (user) => ({ user: await this.userEntityService.pack(user, me, { schema: 'UserDetailed' }), weight: repliedUsers[user] / peak, - }))); + })))) + .filter((result): result is PromiseFulfilledResult<{ user: Packed<'UserDetailed'>; weight: number }> => result.status === 'fulfilled') + .map(result => result.value); return repliesObj; }); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 5dc9f7c66..9930210a6 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -233,7 +233,7 @@ describe('ユーザー', () => { rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); userRoleBadge = await signup({ username: 'userRoleBadge' }); - roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); + roleBadge = await role(root, { asBadge: true, name: 'Badge Role', isPublic: true }); await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); userSilenced = await signup({ username: 'userSilenced' }); await post(userSilenced, { text: 'test' }); @@ -667,7 +667,16 @@ describe('ユーザー', () => { displayOrder: roleBadge.displayOrder, }]); } - assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない + assert.deepStrictEqual(response.roles, [{ + id: roleBadge.id, + name: roleBadge.name, + color: roleBadge.color, + iconUrl: roleBadge.iconUrl, + description: roleBadge.description, + isModerator: roleBadge.isModerator, + isAdministrator: roleBadge.isAdministrator, + displayOrder: roleBadge.displayOrder, + }]); }); test('をID指定のリスト形式で取得することができる(空)', async () => { const parameters = { userIds: [] }; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index d9d08e68a..5667b53df 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -119,9 +119,9 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -160,6 +160,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; +import MkButton from './MkButton.vue'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -168,7 +169,6 @@ import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue import MkMediaList from '@/components/MkMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; -import MkButton from './MkButton.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; @@ -385,7 +385,7 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly' || !$i?.policies.canUseReaction) { sound.playMisskeySfx('reaction'); if (props.mock) { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 9d5bc482d..e6f93c108 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -127,9 +127,9 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -406,7 +406,7 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly' || !$i?.policies.canUseReaction) { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 1524ea0ec..3e451519e 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -53,7 +53,7 @@ const popularUsers: Paging = { params: { state: 'alive', origin: 'local', - sort: '+follower', + sort: '+pv', }, }; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 3194641cd..cb0ac2eb7 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -25,12 +25,29 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+
+ {{ i18n.ts._initialAccountSetting.mustBeSetBotOwner }} +
+
+ {{ i18n.ts.selectUser }} + +
+
+ {{ i18n.ts._initialAccountSetting.youCanEditMoreSettingsInSettingsPageLater }}