TLをPush型にする(Write only) (MisskeyIO#185)
* 一時的に Redis に書き込むユーザーをモデレータのみに絞る --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
parent
9688704be6
commit
e2b5d82229
1
locales/index.d.ts
vendored
1
locales/index.d.ts
vendored
@ -1107,6 +1107,7 @@ export interface Locale {
|
|||||||
"currentAnnouncements": string;
|
"currentAnnouncements": string;
|
||||||
"pastAnnouncements": string;
|
"pastAnnouncements": string;
|
||||||
"youHaveUnreadAnnouncements": string;
|
"youHaveUnreadAnnouncements": string;
|
||||||
|
"externalServices": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
@ -1104,6 +1104,7 @@ forYou: "あなたへ"
|
|||||||
currentAnnouncements: "現在のお知らせ"
|
currentAnnouncements: "現在のお知らせ"
|
||||||
pastAnnouncements: "過去のお知らせ"
|
pastAnnouncements: "過去のお知らせ"
|
||||||
youHaveUnreadAnnouncements: "未読のお知らせがあります。"
|
youHaveUnreadAnnouncements: "未読のお知らせがあります。"
|
||||||
|
externalServices: "外部サービス"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
export class UserListMembership1696323464251 {
|
||||||
|
name = 'UserListMembership1696323464251'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_list_joining" RENAME TO "user_list_membership"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_list_membership" RENAME TO "user_list_joining"`);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class MetaCacheSettings1696373953614 {
|
||||||
|
name = 'MetaCacheSettings1696373953614'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "perLocalUserUserTimelineCacheMax" integer NOT NULL DEFAULT '300'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "perRemoteUserUserTimelineCacheMax" integer NOT NULL DEFAULT '100'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "perUserHomeTimelineCacheMax" integer NOT NULL DEFAULT '300'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "perUserListTimelineCacheMax" integer NOT NULL DEFAULT '300'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "perUserListTimelineCacheMax"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "perUserHomeTimelineCacheMax"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "perRemoteUserUserTimelineCacheMax"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "perLocalUserUserTimelineCacheMax"`);
|
||||||
|
}
|
||||||
|
}
|
21
packages/backend/migration/1696807733453-userListUserId.js
Normal file
21
packages/backend/migration/1696807733453-userListUserId.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class UserListUserId1696807733453 {
|
||||||
|
name = 'UserListUserId1696807733453'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD "userListUserId" character varying(32) NOT NULL DEFAULT ''`);
|
||||||
|
const memberships = await queryRunner.query(`SELECT "id", "userListId" FROM "user_list_membership"`);
|
||||||
|
for(let i = 0; i < memberships.length; i++) {
|
||||||
|
const userList = await queryRunner.query(`SELECT "userId" FROM "user_list" WHERE "id" = $1`, [memberships[i].userListId]);
|
||||||
|
await queryRunner.query(`UPDATE "user_list_membership" SET "userListUserId" = $1 WHERE "id" = $2`, [userList[0].userId, memberships[i].id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP COLUMN "userListUserId"`);
|
||||||
|
}
|
||||||
|
}
|
16
packages/backend/migration/1696808725134-userListUserId-2.js
Normal file
16
packages/backend/migration/1696808725134-userListUserId-2.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class UserListUserId21696808725134 {
|
||||||
|
name = 'UserListUserId21696808725134'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" DROP DEFAULT`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" SET DEFAULT ''`);
|
||||||
|
}
|
||||||
|
}
|
@ -71,11 +71,18 @@ const $redisForSub: Provider = {
|
|||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const $redisForTimelines: Provider = {
|
||||||
|
provide: DI.redisForTimelines,
|
||||||
|
useFactory: (config: Config) => {
|
||||||
|
return new Redis.Redis(config.redisForTimelines);
|
||||||
|
},
|
||||||
|
inject: [DI.config],
|
||||||
|
};
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RepositoryModule],
|
imports: [RepositoryModule],
|
||||||
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub],
|
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines],
|
||||||
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule],
|
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule],
|
||||||
})
|
})
|
||||||
export class GlobalModule implements OnApplicationShutdown {
|
export class GlobalModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(
|
||||||
@ -83,6 +90,7 @@ export class GlobalModule implements OnApplicationShutdown {
|
|||||||
@Inject(DI.redis) private redisClient: Redis.Redis,
|
@Inject(DI.redis) private redisClient: Redis.Redis,
|
||||||
@Inject(DI.redisForPub) private redisForPub: Redis.Redis,
|
@Inject(DI.redisForPub) private redisForPub: Redis.Redis,
|
||||||
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
|
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
|
||||||
|
@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async dispose(): Promise<void> {
|
public async dispose(): Promise<void> {
|
||||||
@ -99,6 +107,7 @@ export class GlobalModule implements OnApplicationShutdown {
|
|||||||
this.redisClient.disconnect(),
|
this.redisClient.disconnect(),
|
||||||
this.redisForPub.disconnect(),
|
this.redisForPub.disconnect(),
|
||||||
this.redisForSub.disconnect(),
|
this.redisForSub.disconnect(),
|
||||||
|
this.redisForTimelines.disconnect(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,6 +53,7 @@ export type Source = {
|
|||||||
redis: RedisOptionsSource;
|
redis: RedisOptionsSource;
|
||||||
redisForPubsub?: RedisOptionsSource;
|
redisForPubsub?: RedisOptionsSource;
|
||||||
redisForJobQueue?: RedisOptionsSource;
|
redisForJobQueue?: RedisOptionsSource;
|
||||||
|
redisForTimelines?: RedisOptionsSource;
|
||||||
meilisearch?: {
|
meilisearch?: {
|
||||||
host: string;
|
host: string;
|
||||||
port: string;
|
port: string;
|
||||||
@ -94,6 +95,10 @@ export type Source = {
|
|||||||
videoThumbnailGenerator?: string;
|
videoThumbnailGenerator?: string;
|
||||||
|
|
||||||
signToActivityPubGet?: boolean;
|
signToActivityPubGet?: boolean;
|
||||||
|
|
||||||
|
perChannelMaxNoteCacheCount?: number;
|
||||||
|
perUserNotificationsMaxCount?: number;
|
||||||
|
deactivateAntennaThreshold?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,6 +123,10 @@ export type Mixin = {
|
|||||||
redis: RedisOptions & RedisOptionsSource;
|
redis: RedisOptions & RedisOptionsSource;
|
||||||
redisForPubsub: RedisOptions & RedisOptionsSource;
|
redisForPubsub: RedisOptions & RedisOptionsSource;
|
||||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||||
|
redisForTimelines: RedisOptions & RedisOptionsSource;
|
||||||
|
perChannelMaxNoteCacheCount: number;
|
||||||
|
perUserNotificationsMaxCount: number;
|
||||||
|
deactivateAntennaThreshold: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Config = Source & Mixin;
|
export type Config = Source & Mixin;
|
||||||
@ -182,6 +191,10 @@ export function loadConfig() {
|
|||||||
mixin.redis = convertRedisOptions(config.redis, mixin.host);
|
mixin.redis = convertRedisOptions(config.redis, mixin.host);
|
||||||
mixin.redisForPubsub = config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, mixin.host) : mixin.redis;
|
mixin.redisForPubsub = config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, mixin.host) : mixin.redis;
|
||||||
mixin.redisForJobQueue = config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, mixin.host) : mixin.redis;
|
mixin.redisForJobQueue = config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, mixin.host) : mixin.redis;
|
||||||
|
mixin.redisForTimelines = config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, mixin.host) : mixin.redis;
|
||||||
|
mixin.perChannelMaxNoteCacheCount = config.perChannelMaxNoteCacheCount ?? 1000;
|
||||||
|
mixin.perUserNotificationsMaxCount = config.perUserNotificationsMaxCount ?? 300;
|
||||||
|
mixin.deactivateAntennaThreshold = config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7);
|
||||||
|
|
||||||
return Object.assign(config, mixin);
|
return Object.assign(config, mixin);
|
||||||
}
|
}
|
||||||
|
@ -232,7 +232,7 @@ export class AccountMoveService {
|
|||||||
},
|
},
|
||||||
}).then(joinings => joinings.map(joining => joining.userListId));
|
}).then(joinings => joinings.map(joining => joining.userListId));
|
||||||
|
|
||||||
const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
|
const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; userListUserId: string; }> = new Map();
|
||||||
|
|
||||||
// 重複しないようにIDを生成
|
// 重複しないようにIDを生成
|
||||||
const genId = (): string => {
|
const genId = (): string => {
|
||||||
@ -248,6 +248,7 @@ export class AccountMoveService {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
userId: dst.id,
|
userId: dst.id,
|
||||||
userListId: joining.userListId,
|
userListId: joining.userListId,
|
||||||
|
userListUserId: joining.userListUserId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,6 +57,8 @@ import { ProxyAccountService } from './ProxyAccountService.js';
|
|||||||
import { UtilityService } from './UtilityService.js';
|
import { UtilityService } from './UtilityService.js';
|
||||||
import { FileInfoService } from './FileInfoService.js';
|
import { FileInfoService } from './FileInfoService.js';
|
||||||
import { SearchService } from './SearchService.js';
|
import { SearchService } from './SearchService.js';
|
||||||
|
import { FeaturedService } from './FeaturedService.js';
|
||||||
|
import { RedisTimelineService } from './RedisTimelineService.js';
|
||||||
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
||||||
import FederationChart from './chart/charts/federation.js';
|
import FederationChart from './chart/charts/federation.js';
|
||||||
import NotesChart from './chart/charts/notes.js';
|
import NotesChart from './chart/charts/notes.js';
|
||||||
@ -182,6 +184,8 @@ const $WebhookService: Provider = { provide: 'WebhookService', useExisting: Webh
|
|||||||
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
||||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||||
|
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||||
|
const $RedisTimelineService: Provider = { provide: 'RedisTimelineService', useExisting: RedisTimelineService };
|
||||||
|
|
||||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||||
@ -311,6 +315,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||||||
UtilityService,
|
UtilityService,
|
||||||
FileInfoService,
|
FileInfoService,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
FeaturedService,
|
||||||
|
RedisTimelineService,
|
||||||
ChartLoggerService,
|
ChartLoggerService,
|
||||||
FederationChart,
|
FederationChart,
|
||||||
NotesChart,
|
NotesChart,
|
||||||
@ -433,6 +439,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||||||
$UtilityService,
|
$UtilityService,
|
||||||
$FileInfoService,
|
$FileInfoService,
|
||||||
$SearchService,
|
$SearchService,
|
||||||
|
$FeaturedService,
|
||||||
|
$RedisTimelineService,
|
||||||
$ChartLoggerService,
|
$ChartLoggerService,
|
||||||
$FederationChart,
|
$FederationChart,
|
||||||
$NotesChart,
|
$NotesChart,
|
||||||
@ -556,6 +564,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||||||
UtilityService,
|
UtilityService,
|
||||||
FileInfoService,
|
FileInfoService,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
FeaturedService,
|
||||||
|
RedisTimelineService,
|
||||||
FederationChart,
|
FederationChart,
|
||||||
NotesChart,
|
NotesChart,
|
||||||
UsersChart,
|
UsersChart,
|
||||||
@ -677,6 +687,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||||||
$UtilityService,
|
$UtilityService,
|
||||||
$FileInfoService,
|
$FileInfoService,
|
||||||
$SearchService,
|
$SearchService,
|
||||||
|
$FeaturedService,
|
||||||
|
$RedisTimelineService,
|
||||||
$FederationChart,
|
$FederationChart,
|
||||||
$NotesChart,
|
$NotesChart,
|
||||||
$UsersChart,
|
$UsersChart,
|
||||||
|
116
packages/backend/src/core/FeaturedService.ts
Normal file
116
packages/backend/src/core/FeaturedService.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as Redis from 'ioredis';
|
||||||
|
import type { MiNote, MiUser } from '@/models/index.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
|
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||||
|
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
||||||
|
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FeaturedService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private getCurrentWindow(windowRange: number): number {
|
||||||
|
const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime();
|
||||||
|
return Math.floor(passed / windowRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> {
|
||||||
|
const currentWindow = this.getCurrentWindow(windowRange);
|
||||||
|
const redisTransaction = this.redisClient.multi();
|
||||||
|
redisTransaction.zincrby(
|
||||||
|
`${name}:${currentWindow}`,
|
||||||
|
score,
|
||||||
|
element);
|
||||||
|
redisTransaction.expire(
|
||||||
|
`${name}:${currentWindow}`,
|
||||||
|
(windowRange * 3) / 1000,
|
||||||
|
'NX'); // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||||
|
await redisTransaction.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async getRankingOf(name: string, windowRange: number, threshold: number): Promise<string[]> {
|
||||||
|
const currentWindow = this.getCurrentWindow(windowRange);
|
||||||
|
const previousWindow = currentWindow - 1;
|
||||||
|
|
||||||
|
const redisPipeline = this.redisClient.pipeline();
|
||||||
|
redisPipeline.zrange(
|
||||||
|
`${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
||||||
|
redisPipeline.zrange(
|
||||||
|
`${name}:${previousWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
||||||
|
const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => r[1] as string[]) : [[], []]);
|
||||||
|
|
||||||
|
const ranking = new Map<string, number>();
|
||||||
|
for (let i = 0; i < currentRankingResult.length; i += 2) {
|
||||||
|
const noteId = currentRankingResult[i];
|
||||||
|
const score = parseInt(currentRankingResult[i + 1], 10);
|
||||||
|
ranking.set(noteId, score);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < previousRankingResult.length; i += 2) {
|
||||||
|
const noteId = previousRankingResult[i];
|
||||||
|
const score = parseInt(previousRankingResult[i + 1], 10);
|
||||||
|
const exist = ranking.get(noteId);
|
||||||
|
if (exist != null) {
|
||||||
|
ranking.set(noteId, (exist + score) / 2);
|
||||||
|
} else {
|
||||||
|
ranking.set(noteId, score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(ranking.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
|
||||||
|
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||||
|
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||||
|
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||||
|
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getGlobalNotesRanking(threshold: number): Promise<MiNote['id'][]> {
|
||||||
|
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
|
||||||
|
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getPerUserNotesRanking(userId: MiUser['id'], threshold: number): Promise<MiNote['id'][]> {
|
||||||
|
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getHashtagsRanking(threshold: number): Promise<string[]> {
|
||||||
|
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, threshold);
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { setImmediate } from 'node:timers/promises';
|
import { setImmediate } from 'node:timers/promises';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { In, DataSource } from 'typeorm';
|
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import RE2 from 're2';
|
import RE2 from 're2';
|
||||||
@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
|||||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||||
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||||
import { MiNote } from '@/models/entities/Note.js';
|
import { MiNote } from '@/models/entities/Note.js';
|
||||||
import type { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListJoiningsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { MiDriveFile } from '@/models/entities/DriveFile.js';
|
import type { MiDriveFile } from '@/models/entities/DriveFile.js';
|
||||||
import type { MiApp } from '@/models/entities/App.js';
|
import type { MiApp } from '@/models/entities/App.js';
|
||||||
import { concat } from '@/misc/prelude/array.js';
|
import { concat } from '@/misc/prelude/array.js';
|
||||||
@ -53,6 +53,8 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
|||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { SearchService } from '@/core/SearchService.js';
|
import { SearchService } from '@/core/SearchService.js';
|
||||||
|
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||||
|
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
|
||||||
|
|
||||||
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||||
|
|
||||||
@ -161,6 +163,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
@Inject(DI.redis)
|
@Inject(DI.redis)
|
||||||
private redisClient: Redis.Redis,
|
private redisClient: Redis.Redis,
|
||||||
|
|
||||||
|
@Inject(DI.redisForTimelines)
|
||||||
|
private redisForTimelines: Redis.Redis,
|
||||||
|
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@ -179,20 +184,27 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
@Inject(DI.mutedNotesRepository)
|
@Inject(DI.mutedNotesRepository)
|
||||||
private mutedNotesRepository: MutedNotesRepository,
|
private mutedNotesRepository: MutedNotesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.userListJoiningsRepository)
|
||||||
|
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||||
|
|
||||||
@Inject(DI.channelsRepository)
|
@Inject(DI.channelsRepository)
|
||||||
private channelsRepository: ChannelsRepository,
|
private channelsRepository: ChannelsRepository,
|
||||||
|
|
||||||
@Inject(DI.channelFollowingsRepository)
|
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
|
||||||
|
|
||||||
@Inject(DI.noteThreadMutingsRepository)
|
@Inject(DI.noteThreadMutingsRepository)
|
||||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.followingsRepository)
|
||||||
|
private followingsRepository: FollowingsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.channelFollowingsRepository)
|
||||||
|
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
|
private redisTimelineService: RedisTimelineService,
|
||||||
private noteReadService: NoteReadService,
|
private noteReadService: NoteReadService,
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
private relayService: RelayService,
|
private relayService: RelayService,
|
||||||
@ -200,6 +212,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
private hashtagService: HashtagService,
|
private hashtagService: HashtagService,
|
||||||
private antennaService: AntennaService,
|
private antennaService: AntennaService,
|
||||||
private webhookService: WebhookService,
|
private webhookService: WebhookService,
|
||||||
|
private featuredService: FeaturedService,
|
||||||
private remoteUserResolveService: RemoteUserResolveService,
|
private remoteUserResolveService: RemoteUserResolveService,
|
||||||
private apDeliverManagerService: ApDeliverManagerService,
|
private apDeliverManagerService: ApDeliverManagerService,
|
||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
@ -252,19 +265,30 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
|
if (data.renote) {
|
||||||
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
|
switch (data.renote.visibility) {
|
||||||
|
case 'public':
|
||||||
|
// public noteは無条件にrenote可能
|
||||||
|
break;
|
||||||
|
case 'home':
|
||||||
|
// home noteはhome以下にrenote可能
|
||||||
|
if (data.visibility === 'public') {
|
||||||
|
data.visibility = 'home';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'followers':
|
||||||
|
// 他人のfollowers noteはreject
|
||||||
|
if (data.renote.userId !== user.id) {
|
||||||
throw new Error('Renote target is not public or home');
|
throw new Error('Renote target is not public or home');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renote対象がpublicではないならhomeにする
|
|
||||||
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
|
|
||||||
data.visibility = 'home';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renote対象がfollowersならfollowersにする
|
// Renote対象がfollowersならfollowersにする
|
||||||
if (data.renote && data.renote.visibility === 'followers') {
|
|
||||||
data.visibility = 'followers';
|
data.visibility = 'followers';
|
||||||
|
break;
|
||||||
|
case 'specified':
|
||||||
|
// specified / direct noteはreject
|
||||||
|
throw new Error('Renote target is not public or home');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返信対象がpublicではないならhomeにする
|
// 返信対象がpublicではないならhomeにする
|
||||||
@ -367,7 +391,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
hasPoll: data.poll != null,
|
hasPoll: data.poll != null,
|
||||||
cw: data.cw == null ? null : data.cw,
|
cw: data.cw ?? null,
|
||||||
tags: tags.map(tag => normalizeForSearch(tag)),
|
tags: tags.map(tag => normalizeForSearch(tag)),
|
||||||
emojis,
|
emojis,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -402,7 +426,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
const url = profile != null ? profile.url : null;
|
const url = profile != null ? profile.url : null;
|
||||||
return {
|
return {
|
||||||
uri: u.uri,
|
uri: u.uri,
|
||||||
url: url == null ? undefined : url,
|
url: url ?? undefined,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
host: u.host,
|
host: u.host,
|
||||||
} as IMentionedRemoteUsers[0];
|
} as IMentionedRemoteUsers[0];
|
||||||
@ -502,6 +526,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (await this.roleService.isModerator({ id: user.id, isRoot: false })) {
|
||||||
|
this.pushToTl(note, user);
|
||||||
|
}
|
||||||
|
|
||||||
this.antennaService.addNoteToAntennas(note, user);
|
this.antennaService.addNoteToAntennas(note, user);
|
||||||
|
|
||||||
if (data.reply) {
|
if (data.reply) {
|
||||||
@ -714,6 +742,20 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
})
|
})
|
||||||
.where('id = :id', { id: renote.id })
|
.where('id = :id', { id: renote.id })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||||
|
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||||
|
if (renote.channelId != null) {
|
||||||
|
if (renote.replyId == null) {
|
||||||
|
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||||
|
this.featuredService.updateGlobalNotesRanking(renote.id, 5);
|
||||||
|
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
@ -799,6 +841,120 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
return mentionedUsers;
|
return mentionedUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||||
|
const meta = await this.metaService.fetch();
|
||||||
|
|
||||||
|
const r = this.redisForTimelines.pipeline();
|
||||||
|
|
||||||
|
if (note.channelId) {
|
||||||
|
this.redisTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
|
||||||
|
|
||||||
|
this.redisTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||||
|
|
||||||
|
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||||
|
where: {
|
||||||
|
followeeId: note.channelId,
|
||||||
|
},
|
||||||
|
select: ['followerId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const channelFollowing of channelFollowings) {
|
||||||
|
this.redisTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||||
|
if (note.fileIds.length > 0) {
|
||||||
|
this.redisTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO: キャッシュ?
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let [followings, userListMemberships] = await Promise.all([
|
||||||
|
this.followingsRepository.find({
|
||||||
|
where: {
|
||||||
|
followeeId: user.id,
|
||||||
|
followerHost: IsNull(),
|
||||||
|
},
|
||||||
|
select: ['followerId'],
|
||||||
|
}),
|
||||||
|
this.userListJoiningsRepository.find({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
select: ['userListId', 'userListUserId'],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (note.visibility === 'followers') {
|
||||||
|
// TODO: 重そうだから何とかしたい Set 使う?
|
||||||
|
userListMemberships = (userListMemberships).filter(x => followings.some(f => f.followerId === x.userListUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||||
|
for (const following of followings) {
|
||||||
|
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||||
|
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||||
|
|
||||||
|
// 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合
|
||||||
|
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.redisTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||||
|
if (note.fileIds.length > 0) {
|
||||||
|
this.redisTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const userListMembership of userListMemberships) {
|
||||||
|
// ダイレクトのとき、そのリストが対象外のユーザーの場合
|
||||||
|
if (
|
||||||
|
note.visibility === 'specified' &&
|
||||||
|
!note.visibleUserIds.some(v => v === userListMembership.userListUserId)
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
// 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合
|
||||||
|
if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.redisTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
|
||||||
|
if (note.fileIds.length > 0) {
|
||||||
|
this.redisTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
|
||||||
|
this.redisTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||||
|
if (note.fileIds.length > 0) {
|
||||||
|
this.redisTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自分自身以外への返信
|
||||||
|
if (note.replyId && note.replyUserId !== note.userId) {
|
||||||
|
this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||||
|
|
||||||
|
if (note.visibility === 'public' && note.userHost == null) {
|
||||||
|
this.redisTimelineService.push('localTimelineWithReplies', note.id, 300, r);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||||
|
if (note.fileIds.length > 0) {
|
||||||
|
this.redisTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.visibility === 'public' && note.userHost == null) {
|
||||||
|
this.redisTimelineService.push('localTimeline', note.id, 1000, r);
|
||||||
|
if (note.fileIds.length > 0) {
|
||||||
|
this.redisTimelineService.push('localTimelineWithFiles', note.id, 500, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.exec();
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.#shutdownController.abort();
|
this.#shutdownController.abort();
|
||||||
|
@ -26,6 +26,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||||
|
|
||||||
const FALLBACK = '❤';
|
const FALLBACK = '❤';
|
||||||
|
|
||||||
@ -86,6 +87,7 @@ export class ReactionService {
|
|||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private userBlockingService: UserBlockingService,
|
private userBlockingService: UserBlockingService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
|
private featuredService: FeaturedService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
private apDeliverManagerService: ApDeliverManagerService,
|
private apDeliverManagerService: ApDeliverManagerService,
|
||||||
@ -190,6 +192,24 @@ export class ReactionService {
|
|||||||
.where('id = :id', { id: note.id })
|
.where('id = :id', { id: note.id })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||||
|
if (
|
||||||
|
Math.random() < 0.3 &&
|
||||||
|
note.userId !== user.id &&
|
||||||
|
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
|
||||||
|
) {
|
||||||
|
if (note.channelId != null) {
|
||||||
|
if (note.replyId == null) {
|
||||||
|
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||||
|
this.featuredService.updateGlobalNotesRanking(note.id, 1);
|
||||||
|
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
|
|
||||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||||
|
80
packages/backend/src/core/RedisTimelineService.ts
Normal file
80
packages/backend/src/core/RedisTimelineService.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as Redis from 'ioredis';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisTimelineService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.redisForTimelines)
|
||||||
|
private redisForTimelines: Redis.Redis,
|
||||||
|
|
||||||
|
private idService: IdService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public push(tl: string, id: string, maxlen: number, pipeline: Redis.ChainableCommander) {
|
||||||
|
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、
|
||||||
|
// 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する
|
||||||
|
if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) {
|
||||||
|
pipeline.lpush('list:' + tl, id);
|
||||||
|
if (Math.random() < 0.1) { // 10%の確率でトリム
|
||||||
|
pipeline.ltrim('list:' + tl, 0, maxlen - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 末尾のIDを取得
|
||||||
|
this.redisForTimelines.lindex('list:' + tl, -1).then(lastId => {
|
||||||
|
if (lastId == null || (this.idService.parse(id).date.getTime() > this.idService.parse(lastId).date.getTime())) {
|
||||||
|
this.redisForTimelines.lpush('list:' + tl, id);
|
||||||
|
} else {
|
||||||
|
Promise.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public get(name: string, untilId?: string | null, sinceId?: string | null) {
|
||||||
|
if (untilId && sinceId) {
|
||||||
|
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||||
|
.then(ids => ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1));
|
||||||
|
} else if (untilId) {
|
||||||
|
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||||
|
.then(ids => ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1));
|
||||||
|
} else if (sinceId) {
|
||||||
|
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||||
|
.then(ids => ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1));
|
||||||
|
} else {
|
||||||
|
return this.redisForTimelines.lrange('list:' + name, 0, -1)
|
||||||
|
.then(ids => ids.sort((a, b) => a > b ? -1 : 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getMulti(name: string[], untilId?: string | null, sinceId?: string | null): Promise<string[][]> {
|
||||||
|
const pipeline = this.redisForTimelines.pipeline();
|
||||||
|
for (const n of name) {
|
||||||
|
pipeline.lrange('list:' + n, 0, -1);
|
||||||
|
}
|
||||||
|
return pipeline.exec().then(res => {
|
||||||
|
if (res == null) return [];
|
||||||
|
const tls = res.map(r => r[1] as string[]);
|
||||||
|
return tls.map(ids =>
|
||||||
|
(untilId && sinceId)
|
||||||
|
? ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1)
|
||||||
|
: untilId
|
||||||
|
? ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1)
|
||||||
|
: sinceId
|
||||||
|
? ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1)
|
||||||
|
: ids.sort((a, b) => a > b ? -1 : 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -53,6 +53,7 @@ export class UserListService {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
userId: target.id,
|
userId: target.id,
|
||||||
userListId: list.id,
|
userListId: list.id,
|
||||||
|
userListUserId: list.userId,
|
||||||
} as MiUserListJoining);
|
} as MiUserListJoining);
|
||||||
|
|
||||||
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target, me));
|
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target, me));
|
||||||
|
@ -10,6 +10,7 @@ export const DI = {
|
|||||||
redis: Symbol('redis'),
|
redis: Symbol('redis'),
|
||||||
redisForPub: Symbol('redisForPub'),
|
redisForPub: Symbol('redisForPub'),
|
||||||
redisForSub: Symbol('redisForSub'),
|
redisForSub: Symbol('redisForSub'),
|
||||||
|
redisForTimelines: Symbol('redisForTimelines'),
|
||||||
|
|
||||||
//#region Repositories
|
//#region Repositories
|
||||||
usersRepository: Symbol('usersRepository'),
|
usersRepository: Symbol('usersRepository'),
|
||||||
|
@ -448,4 +448,24 @@ export class MiMeta {
|
|||||||
length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
|
length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
|
||||||
})
|
})
|
||||||
public preservedUsernames: string[];
|
public preservedUsernames: string[];
|
||||||
|
|
||||||
|
@Column('integer', {
|
||||||
|
default: 300,
|
||||||
|
})
|
||||||
|
public perLocalUserUserTimelineCacheMax: number;
|
||||||
|
|
||||||
|
@Column('integer', {
|
||||||
|
default: 100,
|
||||||
|
})
|
||||||
|
public perRemoteUserUserTimelineCacheMax: number;
|
||||||
|
|
||||||
|
@Column('integer', {
|
||||||
|
default: 300,
|
||||||
|
})
|
||||||
|
public perUserHomeTimelineCacheMax: number;
|
||||||
|
|
||||||
|
@Column('integer', {
|
||||||
|
default: 300,
|
||||||
|
})
|
||||||
|
public perUserListTimelineCacheMax: number;
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ import { id } from '../id.js';
|
|||||||
import { MiUser } from './User.js';
|
import { MiUser } from './User.js';
|
||||||
import { MiUserList } from './UserList.js';
|
import { MiUserList } from './UserList.js';
|
||||||
|
|
||||||
@Entity('user_list_joining')
|
@Entity('user_list_membership')
|
||||||
@Index(['userId', 'userListId'], { unique: true })
|
@Index(['userId', 'userListId'], { unique: true })
|
||||||
export class MiUserListJoining {
|
export class MiUserListJoining {
|
||||||
@PrimaryColumn(id())
|
@PrimaryColumn(id())
|
||||||
@ -44,4 +44,11 @@ export class MiUserListJoining {
|
|||||||
})
|
})
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public userList: MiUserList | null;
|
public userList: MiUserList | null;
|
||||||
|
|
||||||
|
//#region Denormalized fields
|
||||||
|
@Column({
|
||||||
|
...id(),
|
||||||
|
})
|
||||||
|
public userListUserId: MiUser['id'];
|
||||||
|
//#endregion
|
||||||
}
|
}
|
||||||
|
@ -282,6 +282,22 @@ export const meta = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
|
perLocalUserUserTimelineCacheMax: {
|
||||||
|
type: 'number',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
perRemoteUserUserTimelineCacheMax: {
|
||||||
|
type: 'number',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
perUserHomeTimelineCacheMax: {
|
||||||
|
type: 'number',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
perUserListTimelineCacheMax: {
|
||||||
|
type: 'number',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@ -384,6 +400,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
enableServerMachineStats: instance.enableServerMachineStats,
|
enableServerMachineStats: instance.enableServerMachineStats,
|
||||||
enableIdenticonGeneration: instance.enableIdenticonGeneration,
|
enableIdenticonGeneration: instance.enableIdenticonGeneration,
|
||||||
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
||||||
|
perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax,
|
||||||
|
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
|
||||||
|
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
|
||||||
|
perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -106,6 +106,10 @@ export const paramDef = {
|
|||||||
enableIdenticonGeneration: { type: 'boolean' },
|
enableIdenticonGeneration: { type: 'boolean' },
|
||||||
serverRules: { type: 'array', items: { type: 'string' } },
|
serverRules: { type: 'array', items: { type: 'string' } },
|
||||||
preservedUsernames: { type: 'array', items: { type: 'string' } },
|
preservedUsernames: { type: 'array', items: { type: 'string' } },
|
||||||
|
perLocalUserUserTimelineCacheMax: { type: 'integer' },
|
||||||
|
perRemoteUserUserTimelineCacheMax: { type: 'integer' },
|
||||||
|
perUserHomeTimelineCacheMax: { type: 'integer' },
|
||||||
|
perUserListTimelineCacheMax: { type: 'integer' },
|
||||||
},
|
},
|
||||||
required: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
@ -427,6 +431,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
set.preservedUsernames = ps.preservedUsernames;
|
set.preservedUsernames = ps.preservedUsernames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.perLocalUserUserTimelineCacheMax !== undefined) {
|
||||||
|
set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.perRemoteUserUserTimelineCacheMax !== undefined) {
|
||||||
|
set.perRemoteUserUserTimelineCacheMax = ps.perRemoteUserUserTimelineCacheMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.perUserHomeTimelineCacheMax !== undefined) {
|
||||||
|
set.perUserHomeTimelineCacheMax = ps.perUserHomeTimelineCacheMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.perUserListTimelineCacheMax !== undefined) {
|
||||||
|
set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax;
|
||||||
|
}
|
||||||
|
|
||||||
await this.metaService.update(set);
|
await this.metaService.update(set);
|
||||||
this.moderationLogService.insertModerationLog(me, 'updateMeta');
|
this.moderationLogService.insertModerationLog(me, 'updateMeta');
|
||||||
});
|
});
|
||||||
|
81
packages/frontend/src/pages/admin/external-services.vue
Normal file
81
packages/frontend/src/pages/admin/external-services.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkStickyContainer>
|
||||||
|
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
|
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
|
||||||
|
<FormSuspense :p="init">
|
||||||
|
<FormSection>
|
||||||
|
<template #label>DeepL Translation</template>
|
||||||
|
|
||||||
|
<div class="_gaps_m">
|
||||||
|
<MkInput v-model="deeplAuthKey">
|
||||||
|
<template #prefix><i class="ti ti-key"></i></template>
|
||||||
|
<template #label>DeepL Auth Key</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkSwitch v-model="deeplIsPro">
|
||||||
|
<template #label>Pro account</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
</FormSuspense>
|
||||||
|
</MkSpacer>
|
||||||
|
<template #footer>
|
||||||
|
<div :class="$style.footer">
|
||||||
|
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
|
||||||
|
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||||
|
</MkSpacer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MkStickyContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { } from 'vue';
|
||||||
|
import XHeader from './_header_.vue';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import FormSuspense from '@/components/form/suspense.vue';
|
||||||
|
import FormSection from '@/components/form/section.vue';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { fetchInstance } from '@/instance.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
|
let deeplAuthKey: string = $ref('');
|
||||||
|
let deeplIsPro: boolean = $ref(false);
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const meta = await os.api('admin/meta');
|
||||||
|
deeplAuthKey = meta.deeplAuthKey;
|
||||||
|
deeplIsPro = meta.deeplIsPro;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
os.apiWithDialog('admin/update-meta', {
|
||||||
|
deeplAuthKey,
|
||||||
|
deeplIsPro,
|
||||||
|
}).then(() => {
|
||||||
|
fetchInstance();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerActions = $computed(() => []);
|
||||||
|
|
||||||
|
const headerTabs = $computed(() => []);
|
||||||
|
|
||||||
|
definePageMetadata({
|
||||||
|
title: i18n.ts.externalServices,
|
||||||
|
icon: 'ti ti-link',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.footer {
|
||||||
|
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||||
|
backdrop-filter: var(--blur, blur(15px));
|
||||||
|
}
|
||||||
|
</style>
|
@ -193,6 +193,11 @@ const menuDef = $computed(() => [{
|
|||||||
text: i18n.ts.proxyAccount,
|
text: i18n.ts.proxyAccount,
|
||||||
to: '/admin/proxy-account',
|
to: '/admin/proxy-account',
|
||||||
active: currentPage?.route.name === 'proxy-account',
|
active: currentPage?.route.name === 'proxy-account',
|
||||||
|
}, {
|
||||||
|
icon: 'ti ti-link',
|
||||||
|
text: i18n.ts.externalServices,
|
||||||
|
to: '/admin/external-services',
|
||||||
|
active: currentPage?.route.name === 'external-services',
|
||||||
}, {
|
}, {
|
||||||
icon: 'ti ti-adjustments',
|
icon: 'ti ti-adjustments',
|
||||||
text: i18n.ts.other,
|
text: i18n.ts.other,
|
||||||
|
@ -76,16 +76,24 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<template #label>DeepL Translation</template>
|
<template #label>Timeline caching</template>
|
||||||
|
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<MkInput v-model="deeplAuthKey">
|
<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number">
|
||||||
<template #prefix><i class="ti ti-key"></i></template>
|
<template #label>perLocalUserUserTimelineCacheMax</template>
|
||||||
<template #label>DeepL Auth Key</template>
|
</MkInput>
|
||||||
|
|
||||||
|
<MkInput v-model="perRemoteUserUserTimelineCacheMax" type="number">
|
||||||
|
<template #label>perRemoteUserUserTimelineCacheMax</template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
|
<MkInput v-model="perUserHomeTimelineCacheMax" type="number">
|
||||||
|
<template #label>perUserHomeTimelineCacheMax</template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
|
<MkInput v-model="perUserListTimelineCacheMax" type="number">
|
||||||
|
<template #label>perUserListTimelineCacheMax</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkSwitch v-model="deeplIsPro">
|
|
||||||
<template #label>Pro account</template>
|
|
||||||
</MkSwitch>
|
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
</div>
|
||||||
@ -127,8 +135,10 @@ let cacheRemoteSensitiveFiles: boolean = $ref(false);
|
|||||||
let enableServiceWorker: boolean = $ref(false);
|
let enableServiceWorker: boolean = $ref(false);
|
||||||
let swPublicKey: any = $ref(null);
|
let swPublicKey: any = $ref(null);
|
||||||
let swPrivateKey: any = $ref(null);
|
let swPrivateKey: any = $ref(null);
|
||||||
let deeplAuthKey: string = $ref('');
|
let perLocalUserUserTimelineCacheMax: number = $ref(0);
|
||||||
let deeplIsPro: boolean = $ref(false);
|
let perRemoteUserUserTimelineCacheMax: number = $ref(0);
|
||||||
|
let perUserHomeTimelineCacheMax: number = $ref(0);
|
||||||
|
let perUserListTimelineCacheMax: number = $ref(0);
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
const meta = await os.api('admin/meta');
|
const meta = await os.api('admin/meta');
|
||||||
@ -142,8 +152,10 @@ async function init(): Promise<void> {
|
|||||||
enableServiceWorker = meta.enableServiceWorker;
|
enableServiceWorker = meta.enableServiceWorker;
|
||||||
swPublicKey = meta.swPublickey;
|
swPublicKey = meta.swPublickey;
|
||||||
swPrivateKey = meta.swPrivateKey;
|
swPrivateKey = meta.swPrivateKey;
|
||||||
deeplAuthKey = meta.deeplAuthKey;
|
perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax;
|
||||||
deeplIsPro = meta.deeplIsPro;
|
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
|
||||||
|
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
|
||||||
|
perUserListTimelineCacheMax = meta.perUserListTimelineCacheMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save(): void {
|
function save(): void {
|
||||||
@ -158,8 +170,10 @@ function save(): void {
|
|||||||
enableServiceWorker,
|
enableServiceWorker,
|
||||||
swPublicKey,
|
swPublicKey,
|
||||||
swPrivateKey,
|
swPrivateKey,
|
||||||
deeplAuthKey,
|
perLocalUserUserTimelineCacheMax,
|
||||||
deeplIsPro,
|
perRemoteUserUserTimelineCacheMax,
|
||||||
|
perUserHomeTimelineCacheMax,
|
||||||
|
perUserListTimelineCacheMax,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
fetchInstance();
|
fetchInstance();
|
||||||
});
|
});
|
||||||
|
@ -429,6 +429,10 @@ export const routes = [{
|
|||||||
path: '/proxy-account',
|
path: '/proxy-account',
|
||||||
name: 'proxy-account',
|
name: 'proxy-account',
|
||||||
component: page(() => import('./pages/admin/proxy-account.vue')),
|
component: page(() => import('./pages/admin/proxy-account.vue')),
|
||||||
|
}, {
|
||||||
|
path: '/external-services',
|
||||||
|
name: 'external-services',
|
||||||
|
component: page(() => import('./pages/admin/external-services.vue')),
|
||||||
}, {
|
}, {
|
||||||
path: '/other-settings',
|
path: '/other-settings',
|
||||||
name: 'other-settings',
|
name: 'other-settings',
|
||||||
|
Loading…
Reference in New Issue
Block a user