Merge branch 'develop' into dev

This commit is contained in:
MomentQYC 2024-10-14 08:05:17 +08:00 committed by GitHub
commit f2396b9acb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 732 additions and 154 deletions

View File

@ -4,6 +4,9 @@
7日間活動していない場合は自動的に招待制へと移行コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。
詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
### General
- Feat: ユーザーの名前に禁止ワードを設定できるように
### Client
- Enhance: l10nの更新
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正

28
locales/index.d.ts vendored
View File

@ -4374,6 +4374,10 @@ export interface Locale extends ILocale {
*
*/
"enableChartsForFederatedInstances": string;
/**
*
*/
"enableStatsForFederatedInstances": string;
/**
*
*/
@ -5178,6 +5182,22 @@ export interface Locale extends ILocale {
* CAPTCHAのテストを目的とした機能です<strong>使</strong>
*/
"testCaptchaWarning": string;
/**
*
*/
"prohibitedWordsForNameOfUser": string;
/**
*
*/
"prohibitedWordsForNameOfUserDescription": string;
/**
*
*/
"yourNameContainsProhibitedWords": string;
/**
* 使
*/
"yourNameContainsProhibitedWordsDescription": string;
"_abuseUserReport": {
/**
*
@ -9661,6 +9681,14 @@ export interface Locale extends ILocale {
*
*/
"userCreated": string;
/**
*
*/
"inactiveModeratorsWarning": string;
/**
*
*/
"inactiveModeratorsInvitationOnlyChanged": string;
};
/**
* Webhookを削除しますか

View File

@ -1089,6 +1089,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
enableStatsForFederatedInstances: "リモートサーバーの情報を取得"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
reactionsDisplaySize: "リアクションの表示サイズ"
limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
@ -1290,6 +1291,10 @@ passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証
messageToFollower: "フォロワーへのメッセージ"
target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
_abuseUserReport:
forward: "転送"
@ -2559,6 +2564,8 @@ _webhookSettings:
abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき"
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
deleteConfirm: "Webhookを削除しますか"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"

View File

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.10.1-beta.3",
"version": "2024.10.1-beta.4",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class EnableStatsForFederatedInstances1727318020265 {
name = 'EnableStatsForFederatedInstances1727318020265'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`);
}
}

View File

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ProhibitedWordsForNameOfUser1728634286056 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`);
}
}

View File

@ -288,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'createAbuseReportNotificationRecipient', {
recipientId: id,
recipient: created,
})
.then();
});
return created;
}
@ -327,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
recipientId: params.id,
before: beforeEntity,
after: afterEntity,
})
.then();
});
return afterEntity;
}
@ -349,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'deleteAbuseReportNotificationRecipient', {
recipientId: id,
recipient: entity,
})
.then();
});
}
/**

View File

@ -110,8 +110,7 @@ export class AbuseReportService {
reportId: report.id,
report: report,
resolvedAs: ps.resolvedAs,
})
.then();
});
}
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
@ -148,8 +147,7 @@ export class AbuseReportService {
.log(moderator, 'forwardAbuseReport', {
reportId: report.id,
report: report,
})
.then();
});
}
@bindThis

View File

@ -274,14 +274,16 @@ export class AccountMoveService {
}
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(oldAccount)) {
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
}
// FIXME: expensive?
for (const followerId of localFollowerIds) {

View File

@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
}
@bindThis
public async fetch(host: string): Promise<MiInstance> {
public async fetchOrRegister(host: string): Promise<MiInstance> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host);
@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
}
}
@bindThis
public async fetch(host: string): Promise<MiInstance | null> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached !== undefined) return cached;
const index = await this.instancesRepository.findOneBy({ host });
if (index == null) {
this.federatedInstanceCache.set(host, null);
return null;
} else {
this.federatedInstanceCache.set(host, index);
return index;
}
}
@bindThis
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
const result = await this.instancesRepository.createQueryBuilder().update()

View File

@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
try {
if (!force) {
const _instance = await this.federatedInstanceService.fetch(host);
const _instance = await this.federatedInstanceService.fetchOrRegister(host);
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
// unlock at the finally caluse

View File

@ -511,14 +511,16 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// Register host
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
this.updateNotesCountQueue.enqueue(i.id, 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
});
}
}
// ハッシュタグ更新
if (data.visibility === 'public' || data.visibility === 'home') {

View File

@ -106,8 +106,9 @@ export class NoteDeleteService {
this.perUserNotesChart.update(user, note, false);
}
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false);
@ -115,6 +116,7 @@ export class NoteDeleteService {
});
}
}
}
for (const cascadingNote of cascadingNotes) {
this.searchService.unindexNote(cascadingNote);

View File

@ -150,8 +150,8 @@ export class SignupService {
}));
});
this.usersChart.update(account, true).then();
this.userService.notifySystemWebhook(account, 'userCreated').then();
this.usersChart.update(account, true);
this.userService.notifySystemWebhook(account, 'userCreated');
return { account, secret };
}

View File

@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'createSystemWebhook', {
systemWebhookId: webhook.id,
webhook: webhook,
})
.then();
});
return webhook;
}
@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
systemWebhookId: beforeEntity.id,
before: beforeEntity,
after: afterEntity,
})
.then();
});
return afterEntity;
}
@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'deleteSystemWebhook', {
systemWebhookId: webhook.id,
webhook,
})
.then();
});
}
/**

View File

@ -305,21 +305,23 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
}
}
//#endregion
this.perUserFollowingChart.update(follower, followee, true);
@ -437,21 +439,23 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
}
//#endregion
this.perUserFollowingChart.update(follower, followee, false);

View File

@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js';
import { type WebhookEventTypes } from '@/models/Webhook.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000;
@ -448,6 +449,22 @@ export class WebhookTestService {
send(toPackedUserLite(dummyUser1));
break;
}
case 'inactiveModeratorsWarning': {
const dummyTime: ModeratorInactivityRemainingTime = {
time: 100000,
asDays: 1,
asHours: 24,
};
send({
remainingTime: dummyTime,
});
break;
}
case 'inactiveModeratorsInvitationOnlyChanged': {
send({});
break;
}
}
}
}

View File

@ -408,13 +408,15 @@ export class ApPersonService implements OnModuleInit {
this.cacheService.uriPersonCache.set(user.uri, user);
// Register host
this.federatedInstanceService.fetch(host).then(i => {
if (this.meta.enableStatsForFederatedInstances) {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
});
}
this.usersChart.update(user, true);

View File

@ -81,6 +81,11 @@ export class MiMeta {
})
public prohibitedWords: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public prohibitedWordsForNameOfUser: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
@ -596,6 +601,11 @@ export class MiMeta {
})
public enableChartsForFederatedInstances: boolean;
@Column('boolean', {
default: true,
})
public enableStatsForFederatedInstances: boolean;
@Column('boolean', {
default: false,
})

View File

@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
'abuseReportResolved',
// ユーザが作成された時
'userCreated',
// モデレータが一定期間不在である警告
'inactiveModeratorsWarning',
// モデレータが一定期間不在のためシステムにより招待制へと変更された
'inactiveModeratorsInvitationOnlyChanged',
] as const;
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];

View File

@ -3,24 +3,110 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { RoleService } from '@/core/RoleService.js';
import { EmailService } from '@/core/EmailService.js';
import { MiUser, type UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
// モデレーターが不在と判断する日付の閾値
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
// 警告通知やログ出力を行う残日数の閾値
const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
// 期限から6時間ごとに通知を行う
const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6;
const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
export type ModeratorInactivityEvaluationResult = {
isModeratorsInactive: boolean;
inactiveModerators: MiUser[];
remainingTime: ModeratorInactivityRemainingTime;
}
export type ModeratorInactivityRemainingTime = {
time: number;
asHours: number;
asDays: number;
};
function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
const subject = 'Moderator Inactivity Warning / モデレーター不在の通知';
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`;
const message = [
'To Moderators,',
'',
`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
'',
'---------------',
'',
'To モデレーター各位',
'',
`モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`,
'招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。',
'',
];
const html = message.join('<br>');
const text = message.join('\n');
return {
subject,
html,
text,
};
}
function generateInvitationOnlyChangedMail() {
const subject = 'Change to Invitation-Only / 招待制に変更されました';
const message = [
'To Moderators,',
'',
`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
'To cancel the invitation only, you need to access the control panel.',
'',
'---------------',
'',
'To モデレーター各位',
'',
`モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`,
'招待制を解除するには、コントロールパネルにアクセスする必要があります。',
'',
];
const html = message.join('<br>');
const text = message.join('\n');
return {
subject,
html,
text,
};
}
@Injectable()
export class CheckModeratorsActivityProcessorService {
private logger: Logger;
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private metaService: MetaService,
private roleService: RoleService,
private emailService: EmailService,
private announcementService: AnnouncementService,
private systemWebhookService: SystemWebhookService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
@ -42,18 +128,23 @@ export class CheckModeratorsActivityProcessorService {
@bindThis
private async processImpl() {
const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
if (isModeratorsInactive) {
const evaluateResult = await this.evaluateModeratorsInactiveDays();
if (evaluateResult.isModeratorsInactive) {
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
await this.changeToInvitationOnly();
// TODO: モデレータに通知メールMisskey通知
// TODO: SystemWebhook通知
await this.notifyChangeToInvitationOnly();
} else {
if (inactivityLimitCountdown <= 2) {
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${inactivityLimitCountdown} days, it will switch to invitation only.`);
const remainingTime = evaluateResult.remainingTime;
if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`);
// TODO: 警告メール
if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
// つまり、のこり2日を切ったら6時間ごとに通知が送られる
await this.notifyInactiveModeratorsWarning(remainingTime);
}
}
}
}
@ -87,7 +178,7 @@ export class CheckModeratorsActivityProcessorService {
* A, B, Cのアクティビティは判定基準日よりも古いため
*/
@bindThis
public async evaluateModeratorsInactiveDays() {
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
const today = new Date();
const inactivePeriod = new Date(today);
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
@ -101,12 +192,18 @@ export class CheckModeratorsActivityProcessorService {
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
const inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
return {
isModeratorsInactive: inactiveModerators.length === moderators.length,
inactiveModerators,
inactivityLimitCountdown,
remainingTime: {
time: remainingTime,
asHours: remainingTimeAsHours,
asDays: remainingTimeAsDays,
},
};
}
@ -115,6 +212,74 @@ export class CheckModeratorsActivityProcessorService {
await this.metaService.update({ disableRegistration: true });
}
@bindThis
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
// -- モデレータへのメール送信
const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));
const mail = generateModeratorInactivityMail(remainingTime);
for (const moderator of moderators) {
const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}
// -- SystemWebhook
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsWarning',
{ remainingTime: remainingTime },
);
}
}
@bindThis
public async notifyChangeToInvitationOnly() {
// -- モデレータへのメールとお知らせ(個人向け)送信
const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));
const mail = generateInvitationOnlyChangedMail();
for (const moderator of moderators) {
this.announcementService.create({
title: mail.subject,
text: mail.text,
forExistingUsers: true,
needConfirmationToRead: true,
userId: moderator.id,
});
const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}
// -- SystemWebhook
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsInvitationOnlyChanged',
{},
);
}
}
@bindThis
private async fetchModerators() {
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する

View File

@ -74,8 +74,17 @@ export class DeliverProcessorService {
try {
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
this.apRequestChart.deliverSucc();
this.federationChart.deliverd(host, true);
// Update instance stats
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(host)
: this.federatedInstanceService.fetch(host));
if (i == null) return;
if (i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: false,
@ -83,9 +92,9 @@ export class DeliverProcessorService {
});
}
if (this.meta.enableStatsForFederatedInstances) {
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.deliverSucc();
this.federationChart.deliverd(i.host, true);
}
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, true);
@ -94,8 +103,11 @@ export class DeliverProcessorService {
return 'Success';
} catch (res) {
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
this.apRequestChart.deliverFail();
this.federationChart.deliverd(host, false);
// Update instance stats
this.federatedInstanceService.fetchOrRegister(host).then(i => {
if (!i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: true,
@ -116,9 +128,6 @@ export class DeliverProcessorService {
});
}
this.apRequestChart.deliverFail();
this.federationChart.deliverd(i.host, false);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, false);
}
@ -129,7 +138,7 @@ export class DeliverProcessorService {
if (!res.isRetryable) {
// 相手が閉鎖していることを明示しているため、配送停止する
if (job.data.isSharedInbox && res.statusCode === 410) {
this.federatedInstanceService.fetch(host).then(i => {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.federatedInstanceService.update(i.id, {
suspensionState: 'goneSuspended',
});

View File

@ -192,21 +192,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
}
}
// Update stats
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
this.apRequestChart.inbox();
this.federationChart.inbox(authUser.user.host);
// Update instance stats
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(authUser.user.host)
: this.federatedInstanceService.fetch(authUser.user.host));
if (i == null) return;
this.updateInstanceQueue.enqueue(i.id, {
latestRequestReceivedAt: new Date(),
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
});
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.inbox();
this.federationChart.inbox(i.host);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestReceived(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
});
// アクティビティを処理

View File

@ -181,6 +181,13 @@ export const meta = {
type: 'string',
},
},
prohibitedWordsForNameOfUser: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
bannedEmailDomains: {
type: 'array',
optional: true, nullable: false,
@ -345,6 +352,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableStatsForFederatedInstances: {
type: 'boolean',
optional: false, nullable: false,
},
enableServerMachineStats: {
type: 'boolean',
optional: false, nullable: false,
@ -591,6 +602,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
mediaSilencedHosts: instance.mediaSilencedHosts,
sensitiveWords: instance.sensitiveWords,
prohibitedWords: instance.prohibitedWords,
prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey,
mcaptchaSecretKey: instance.mcaptchaSecretKey,
@ -632,6 +644,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
truemailAuthKey: instance.truemailAuthKey,
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
enableServerMachineStats: instance.enableServerMachineStats,
enableIdenticonGeneration: instance.enableIdenticonGeneration,
bannedEmailDomains: instance.bannedEmailDomains,

View File

@ -46,6 +46,11 @@ export const paramDef = {
type: 'string',
},
},
prohibitedWordsForNameOfUser: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true },
@ -144,6 +149,7 @@ export const paramDef = {
truemailAuthKey: { type: 'string', nullable: true },
enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' },
enableStatsForFederatedInstances: { type: 'boolean' },
enableServerMachineStats: { type: 'boolean' },
enableIdenticonGeneration: { type: 'boolean' },
serverRules: { type: 'array', items: { type: 'string' } },
@ -227,6 +233,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (Array.isArray(ps.prohibitedWords)) {
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
}
if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
}
if (Array.isArray(ps.silencedHosts)) {
let lastValue = '';
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
@ -659,6 +668,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
}
if (ps.enableStatsForFederatedInstances !== undefined) {
set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
}
if (ps.enableServerMachineStats !== undefined) {
set.enableServerMachineStats = ps.enableServerMachineStats;
}

View File

@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js';
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js';
import { RolePolicies, RoleService } from '@/core/RoleService.js';
@ -114,6 +115,13 @@ export const meta = {
code: 'RESTRICTED_BY_ROLE',
id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
},
nameContainsProhibitedWords: {
message: 'Your new name contains prohibited words.',
code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS',
id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
httpStatusCode: 422,
},
},
res: {
@ -224,6 +232,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private instanceMeta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -248,6 +259,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private cacheService: CacheService,
private httpRequestService: HttpRequestService,
private avatarDecorationService: AvatarDecorationService,
private utilityService: UtilityService,
) {
super(meta, paramDef, async (ps, _user, token) => {
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
@ -451,6 +463,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
if (newName != null) {
let hasProhibitedWords = false;
if (!await this.roleService.isModerator(user)) {
hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser);
}
if (hasProhibitedWords) {
throw new ApiError(meta.errors.nameContainsProhibitedWords);
}
const tokens = mfm.parseSimple(newName);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
}

View File

@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing';
import { Redis } from 'ioredis';
import type { TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import type { TestingModule } from '@nestjs/testing';
function mockRedis() {
const hash = {} as any;
@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => {
if (token === HttpRequestService) {
return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() };
} else if (token === FederatedInstanceService) {
return { fetch: jest.fn() };
return { fetchOrRegister: jest.fn() };
} else if (token === DI.redis) {
return mockRedis;
}
@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => {
test('Lock and update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
test('Lock and don\'t update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do nothing when lock not acquired', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do when lock not acquired but forced', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
});

View File

@ -8,13 +8,16 @@ import { Test, TestingModule } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers';
import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { RoleService } from '@/core/RoleService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MetaService } from '@/core/MetaService.js';
import { DI } from '@/di-symbols.js';
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
import { EmailService } from '@/core/EmailService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
@ -29,10 +32,17 @@ describe('CheckModeratorsActivityProcessorService', () => {
let userProfilesRepository: UserProfilesRepository;
let idService: IdService;
let roleService: jest.Mocked<RoleService>;
let announcementService: jest.Mocked<AnnouncementService>;
let emailService: jest.Mocked<EmailService>;
let systemWebhookService: jest.Mocked<SystemWebhookService>;
let systemWebhook1: MiSystemWebhook;
let systemWebhook2: MiSystemWebhook;
let systemWebhook3: MiSystemWebhook;
// --------------------------------------------------------------------------------------
async function createUser(data: Partial<MiUser> = {}) {
async function createUser(data: Partial<MiUser> = {}, profile: Partial<MiUserProfile> = {}): Promise<MiUser> {
const id = idService.gen();
const user = await usersRepository
.insert({
@ -45,11 +55,27 @@ describe('CheckModeratorsActivityProcessorService', () => {
await userProfilesRepository.insert({
userId: user.id,
...profile,
});
return user;
}
function crateSystemWebhook(data: Partial<MiSystemWebhook> = {}): MiSystemWebhook {
return {
id: idService.gen(),
isActive: true,
updatedAt: new Date(),
latestSentAt: null,
latestStatus: null,
name: 'test',
url: 'https://example.com',
secret: 'test',
on: [],
...data,
};
}
function mockModeratorRole(users: MiUser[]) {
roleService.getModerators.mockReset();
roleService.getModerators.mockResolvedValue(users);
@ -72,6 +98,18 @@ describe('CheckModeratorsActivityProcessorService', () => {
{
provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
},
{
provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }),
},
{
provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }),
},
{
provide: SystemWebhookService, useFactory: () => ({
fetchActiveSystemWebhooks: jest.fn(),
enqueueSystemWebhook: jest.fn(),
}),
},
{
provide: QueueLoggerService, useFactory: () => ({
logger: ({
@ -93,6 +131,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
service = app.get(CheckModeratorsActivityProcessorService);
idService = app.get(IdService);
roleService = app.get(RoleService) as jest.Mocked<RoleService>;
announcementService = app.get(AnnouncementService) as jest.Mocked<AnnouncementService>;
emailService = app.get(EmailService) as jest.Mocked<EmailService>;
systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
app.enableShutdownHooks();
});
@ -102,6 +143,15 @@ describe('CheckModeratorsActivityProcessorService', () => {
now: new Date(baseDate),
shouldClearNativeTimers: true,
});
systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] });
systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] });
systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] });
emailService.sendEmail.mockReturnValue(Promise.resolve());
announcementService.create.mockReturnValue(Promise.resolve({} as never));
systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]);
systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never));
});
afterEach(async () => {
@ -109,6 +159,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
await usersRepository.delete({});
await userProfilesRepository.delete({});
roleService.getModerators.mockReset();
announcementService.create.mockReset();
emailService.sendEmail.mockReset();
systemWebhookService.enqueueSystemWebhook.mockReset();
});
afterAll(async () => {
@ -152,7 +205,7 @@ describe('CheckModeratorsActivityProcessorService', () => {
expect(result.inactiveModerators).toEqual([user1]);
});
test('[countdown] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -165,10 +218,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(1);
expect(result.remainingTime.asDays).toBe(1);
expect(result.remainingTime.asHours).toBe(24);
});
test('[countdown] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -181,10 +235,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(1);
expect(result.remainingTime.asDays).toBe(1);
expect(result.remainingTime.asHours).toBe(25);
});
test('[countdown] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -197,10 +252,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(0);
expect(result.remainingTime.asDays).toBe(0);
expect(result.remainingTime.asHours).toBe(23);
});
test('[countdown] 期限ちょうどの場合、猶予0日として計算される', async () => {
test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -213,10 +269,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.inactivityLimitCountdown).toBe(0);
expect(result.remainingTime.asDays).toBe(0);
expect(result.remainingTime.asHours).toBe(0);
});
test('[countdown] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
@ -229,7 +286,94 @@ describe('CheckModeratorsActivityProcessorService', () => {
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(true);
expect(result.inactiveModerators).toEqual([user1, user2]);
expect(result.inactivityLimitCountdown).toBe(-1);
expect(result.remainingTime.asDays).toBe(-1);
expect(result.remainingTime.asHours).toBe(-1);
});
test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 10) }),
// 猶予はこのユーザ基準で計算される想定。
// 期限より1時間超過->猶予-1日として計算されるはずである
createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }),
]);
mockModeratorRole([user1, user2]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(true);
expect(result.inactiveModerators).toEqual([user1, user2]);
expect(result.remainingTime.asDays).toBe(-2);
expect(result.remainingTime.asHours).toBe(-25);
});
});
describe('notifyInactiveModeratorsWarning', () => {
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
const [user1, user2, user3, user4, root] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
createUser({}, { email: 'user2@example.com', emailVerified: false }),
createUser({}, { email: null, emailVerified: false }),
createUser({}, { email: 'user4@example.com', emailVerified: true }),
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
]);
mockModeratorRole([user1, user2, user3, root]);
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
});
test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => {
const [user1] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
]);
mockModeratorRole([user1]);
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(2);
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook1);
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[1][0]).toEqual(systemWebhook2);
});
});
describe('notifyChangeToInvitationOnly', () => {
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
const [user1, user2, user3, user4, root] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
createUser({}, { email: 'user2@example.com', emailVerified: false }),
createUser({}, { email: null, emailVerified: false }),
createUser({}, { email: 'user4@example.com', emailVerified: true }),
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
]);
mockModeratorRole([user1, user2, user3, root]);
await service.notifyChangeToInvitationOnly();
expect(announcementService.create).toHaveBeenCalledTimes(4);
expect(announcementService.create.mock.calls[0][0].userId).toBe(user1.id);
expect(announcementService.create.mock.calls[1][0].userId).toBe(user2.id);
expect(announcementService.create.mock.calls[2][0].userId).toBe(user3.id);
expect(announcementService.create.mock.calls[3][0].userId).toBe(root.id);
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
});
test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => {
const [user1] = await Promise.all([
createUser({}, { email: 'user1@example.com', emailVerified: true }),
]);
mockModeratorRole([user1]);
await service.notifyChangeToInvitationOnly();
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1);
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook2);
});
});
});

View File

@ -9,6 +9,7 @@ import MkAd from '@/components/global/MkAd.vue';
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { instance } from '@/instance.js';
import { defaultStore } from '@/store.js';
import { MisskeyEntity } from '@/types/date-separated-list.js';
@ -99,10 +100,10 @@ export default defineComponent({
return [el, separator];
} else {
if (props.ad && item._shouldInsertAd_) {
if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) {
return [h('div', {
key: item.id + ':ad',
style: 'padding: 8px; background-size: auto auto; background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px );',
class: $style['ad-wrapper'],
}, [h(MkAd, {
prefer: ['horizontal', 'horizontal-big'],
})]), el];
@ -255,5 +256,11 @@ export default defineComponent({
.date-2-icon {
margin-left: 8px;
}
.ad-wrapper {
padding: 8px;
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
}
</style>

View File

@ -55,6 +55,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.userCreated)" @click="test('userCreated')"><i class="ti ti-send"></i></MkButton>
</div>
<div :class="$style.switchBox">
<MkSwitch v-model="events.inactiveModeratorsWarning" :disabled="disabledEvents.inactiveModeratorsWarning">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsWarning }}</template>
</MkSwitch>
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsWarning)" @click="test('inactiveModeratorsWarning')"><i class="ti ti-send"></i></MkButton>
</div>
<div :class="$style.switchBox">
<MkSwitch v-model="events.inactiveModeratorsInvitationOnlyChanged" :disabled="disabledEvents.inactiveModeratorsInvitationOnlyChanged">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsInvitationOnlyChanged }}</template>
</MkSwitch>
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsInvitationOnlyChanged)" @click="test('inactiveModeratorsInvitationOnlyChanged')"><i class="ti ti-send"></i></MkButton>
</div>
</div>
<div v-show="mode === 'edit'" :class="$style.description">
@ -100,6 +112,8 @@ type EventType = {
abuseReport: boolean;
abuseReportResolved: boolean;
userCreated: boolean;
inactiveModeratorsWarning: boolean;
inactiveModeratorsInvitationOnlyChanged: boolean;
}
const emit = defineEmits<{
@ -123,6 +137,8 @@ const events = ref<EventType>({
abuseReport: true,
abuseReportResolved: true,
userCreated: true,
inactiveModeratorsWarning: true,
inactiveModeratorsInvitationOnlyChanged: true,
});
const isActive = ref<boolean>(true);
@ -130,6 +146,8 @@ const disabledEvents = ref<EventType>({
abuseReport: false,
abuseReportResolved: false,
userCreated: false,
inactiveModeratorsWarning: false,
inactiveModeratorsInvitationOnlyChanged: false,
});
const disableSubmitButton = computed(() => {

View File

@ -51,6 +51,11 @@ watch(name, () => {
// null??使
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: name.value || null,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
title: i18n.ts.yourNameContainsProhibitedWords,
text: i18n.ts.yourNameContainsProhibitedWordsDescription,
},
});
});

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<div ref="rootEl">
<div ref="headerEl" :class="$style.header">
<slot name="header"></slot>
</div>
@ -22,12 +22,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef } from 'vue';
import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, useTemplateRef } from 'vue';
import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
const headerEl = shallowRef<HTMLElement>();
const footerEl = shallowRef<HTMLElement>();
const rootEl = useTemplateRef('rootEl');
const headerEl = useTemplateRef('headerEl');
const footerEl = useTemplateRef('footerEl');
const headerHeight = ref<string | undefined>();
const childStickyTop = ref(0);
@ -76,6 +77,10 @@ onMounted(() => {
onUnmounted(() => {
observer.disconnect();
});
defineExpose({
rootEl,
});
</script>
<style lang='scss' module>

View File

@ -10,6 +10,7 @@ import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import type { ComponentProps as CP } from 'vue-component-type-helpers';
import type { Form, GetFormResultType } from '@/scripts/form.js';
import type { MenuItem } from '@/types/menu.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
@ -22,7 +23,6 @@ import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
@ -35,6 +35,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
endpoint: E,
data: P = {} as any,
token?: string | null | undefined,
customErrors?: Record<string, { title?: string; text: string; }>,
) => {
const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => {
@ -77,6 +78,9 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
} else if (err.message.startsWith('Unexpected token')) {
title = i18n.ts.gotInvalidResponseError;
text = i18n.ts.gotInvalidResponseErrorDescription;
} else if (customErrors && customErrors[err.id] != null) {
title = customErrors[err.id].title;
text = customErrors[err.id].text;
}
alert({
type: 'error',
@ -86,7 +90,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
});
return promise;
}) as typeof misskeyApi;
});
export function promiseDialog<T extends Promise<any>>(
promise: T,

View File

@ -57,6 +57,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-user-x"></i></template>
<template #label>{{ i18n.ts.prohibitedWordsForNameOfUser }}</template>
<div class="_gaps">
<MkTextarea v-model="prohibitedWordsForNameOfUser">
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.hiddenTags }}</template>
@ -131,6 +143,7 @@ const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false);
const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const prohibitedWordsForNameOfUser = ref<string>('');
const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>('');
const blockedHosts = ref<string>('');
@ -143,10 +156,11 @@ async function init() {
emailRequiredForSignup.value = meta.emailRequiredForSignup;
sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n');
preservedUsernames.value = meta.preservedUsernames.join('\n');
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts.join('\n');
silencedHosts.value = meta.silencedHosts?.join('\n') ?? '';
mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
}
@ -190,6 +204,14 @@ function save_prohibitedWords() {
});
}
function save_prohibitedWordsForNameOfUser() {
os.apiWithDialog('admin/update-meta', {
prohibitedWordsForNameOfUser: prohibitedWordsForNameOfUser.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_hiddenTags() {
os.apiWithDialog('admin/update-meta', {
hiddenTags: hiddenTags.value.split('\n'),

View File

@ -29,6 +29,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances">
<template #label>{{ i18n.ts.enableStatsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
@ -120,6 +127,7 @@ const meta = await misskeyApi('admin/meta');
const enableServerMachineStats = ref(meta.enableServerMachineStats);
const enableIdenticonGeneration = ref(meta.enableIdenticonGeneration);
const enableChartsForRemoteUser = ref(meta.enableChartsForRemoteUser);
const enableStatsForFederatedInstances = ref(meta.enableStatsForFederatedInstances);
const enableChartsForFederatedInstances = ref(meta.enableChartsForFederatedInstances);
function onChange_enableServerMachineStats(value: boolean) {
@ -146,6 +154,14 @@ function onChange_enableChartsForRemoteUser(value: boolean) {
});
}
function onChange_enableStatsForFederatedInstances(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableStatsForFederatedInstances: value,
}).then(() => {
fetchInstance(true);
});
}
function onChange_enableChartsForFederatedInstances(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableChartsForFederatedInstances: value,

View File

@ -143,13 +143,17 @@ const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.d
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
function assertVaildLang(lang: string | null): lang is keyof typeof langmap {
return lang != null && lang in langmap;
}
const profile = reactive({
name: $i.name,
description: $i.description,
followedMessage: $i.followedMessage,
location: $i.location,
birthday: $i.birthday,
lang: $i.lang,
lang: assertVaildLang($i.lang) ? $i.lang : null,
isBot: $i.isBot ?? false,
isCat: $i.isCat ?? false,
isVI: $i.isVI ?? false,
@ -205,6 +209,11 @@ function save() {
isBot: !!profile.isBot,
isCat: !!profile.isCat,
isVI: !!profile.isVI,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
title: i18n.ts.yourNameContainsProhibitedWords,
text: i18n.ts.yourNameContainsProhibitedWordsDescription,
},
});
globalEvents.emit('requestClearPageCache');
claimAchievement('profileFilled');

View File

@ -247,13 +247,10 @@ export function getNoteMenu(props: {
function togglePin(pin: boolean): void {
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
noteId: appearNote.id,
}, undefined, null, res => {
if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
os.alert({
type: 'error',
}, undefined, {
'72dab508-c64d-498f-8740-a8eec1ba385a': {
text: i18n.ts.pinLimitExceeded,
});
}
},
});
}

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2024.10.1-beta.3",
"version": "2024.10.1-beta.4",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

View File

@ -5060,7 +5060,7 @@ export type components = {
latestSentAt: string | null;
latestStatus: number | null;
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@ -5137,6 +5137,7 @@ export type operations = {
blockedHosts: string[];
sensitiveWords: string[];
prohibitedWords: string[];
prohibitedWordsForNameOfUser: string[];
bannedEmailDomains?: string[];
preservedUsernames: string[];
hcaptchaSecretKey: string | null;
@ -5177,6 +5178,7 @@ export type operations = {
truemailAuthKey: string | null;
enableChartsForRemoteUser: boolean;
enableChartsForFederatedInstances: boolean;
enableStatsForFederatedInstances: boolean;
enableServerMachineStats: boolean;
enableIdenticonGeneration: boolean;
manifestJsonOverride: string;
@ -9474,6 +9476,7 @@ export type operations = {
blockedHosts?: string[] | null;
sensitiveWords?: string[] | null;
prohibitedWords?: string[] | null;
prohibitedWordsForNameOfUser?: string[] | null;
themeColor?: string | null;
mascotImageUrl?: string | null;
bannerUrl?: string | null;
@ -9579,6 +9582,7 @@ export type operations = {
truemailAuthKey?: string | null;
enableChartsForRemoteUser?: boolean;
enableChartsForFederatedInstances?: boolean;
enableStatsForFederatedInstances?: boolean;
enableServerMachineStats?: boolean;
enableIdenticonGeneration?: boolean;
serverRules?: string[];
@ -10279,7 +10283,7 @@ export type operations = {
'application/json': {
isActive: boolean;
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@ -10389,7 +10393,7 @@ export type operations = {
content: {
'application/json': {
isActive?: boolean;
on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
};
};
};
@ -10502,7 +10506,7 @@ export type operations = {
id: string;
isActive: boolean;
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@ -10561,7 +10565,7 @@ export type operations = {
/** Format: misskey:id */
webhookId: string;
/** @enum {string} */
type: 'abuseReport' | 'abuseReportResolved' | 'userCreated';
type: 'abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged';
override?: {
url?: string;
secret?: string;