Compare commits

..

19 Commits

Author SHA1 Message Date
github-actions[bot]
fb23b24f5c Bump version to 2024.10.1-beta.4 2024-10-13 11:43:27 +00:00
おさむのひと
33b34ad7b8 feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知 (#14757)
* feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知

* fix misskey-js.api.md

* Revert "feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知"

This reverts commit 3ab953bdf8.

* 通知をやめてユーザ単位でのお知らせ機能に変更

* テスト用実装を戻す

* Update packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>

* fix remove empty then

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-10-13 20:32:12 +09:00
syuilo
5229f5de4d refactor(backend): remove unnecessary .then 2024-10-13 20:32:02 +09:00
syuilo
ff47fef572 feat: リモートサーバーのサーバー情報を収集しないオプション (#14634)
* wip

* wip

* Update FetchInstanceMetadataService.ts

* Update FetchInstanceMetadataService.ts

* Update types.ts
2024-10-13 20:22:16 +09:00
かっこかり
45d42b8641 feat: ユーザーの名前に禁止ワードを設定できるように (#14756)
* wip

* 🎨

* Enhance: モデレーター以上は制限の影響を受けないように

* refactor

* better error handling

* fix

* Revert "better error handling"

This reverts commit 5670b29cfa.

* error handling

* エラーが出ないのを修正

* translation

* Update Changelog

* status code

* ✌️

* モデレーター以上は影響ないことを明記

* 🎨

* update changelog

* spdx

* Update update.ts

* refactor

* eliminate `screen name`

* remove untracked file

---------

Co-authored-by: KanariKanaru <93921745+kanarikanaru@users.noreply.github.com>
2024-10-13 20:21:25 +09:00
syuilo
c4c69cd267 🎨 2024-10-12 11:28:58 +09:00
FineArchs
ee08e9f51e refactor: MkStickyContainerで<style />を使う (#14755)
* remove rootEL ref

* use css module

* use v-bind in css

* --MI prefix

* remove unused ref

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-10-12 11:20:55 +09:00
syuilo
85bb1ff1db 🎨 2024-10-12 11:18:26 +09:00
syuilo
824c51a19f fix(frontend): fix style
Fix #14754
2024-10-12 10:32:00 +09:00
syuilo
ef90f83917 Update index.d.ts 2024-10-12 10:31:40 +09:00
syuilo
a87a18f40d Update about-misskey.vue 2024-10-12 10:11:55 +09:00
かっこかり
2f09d69773 fix(backend): キューのエラーログを簡略化するように (#14748)
* reduce federation log spam

* Don't record stack trace for unrecoverable errors.
* Avoid logging duplicate stace traces.

(cherry picked from commit ed0570110bf8cb8e8959591dccfa3c35999106ce)

* improve error summaries

(cherry picked from commit 20dd66f735d9778df0371001e303549dce619260)

* fix lint errors

(cherry picked from commit 83869e1c470b12b3bf4b23d885514d926620662a)

* condense job info

(cherry picked from commit 786702e076ad1af14538849512ad31c0ced7afe6)

* fix maxAttempts calculation

(cherry picked from commit b4d10aa8f821e594ec9c907eb2a5bdb3c73c67d5)

* condense error info

(cherry picked from commit f62cd8941ced74a4865aa5eae4f4a1c7aa1d30f1)

* normalize ID logging

(cherry picked from commit d8e1e4890d28347239162e26235eb68b1ff96654)

* further condense error details

(cherry picked from commit d867c2089b3b24680df0713a2aa0914789e45670)

* collapse AbortErrors

(cherry picked from commit 5171ba7113ebc7242527768afb9ab4cec534e3b3)

* don't log job name unless it has one

(cherry picked from commit a5316c06ed770b60f7b4c7ff5aa8c71cc0558db7)

* Update Changelog

* Record origin

---------

Co-authored-by: Hazel K <acomputerdog@gmail.com>
2024-10-11 21:29:03 +09:00
github-actions[bot]
777804605e Bump version to 2024.10.1-beta.3 2024-10-11 12:13:47 +00:00
syuilo
af1cbc131f wip (#14745) 2024-10-11 21:05:53 +09:00
syuilo
c397b42242 chore: add description 2024-10-11 21:01:50 +09:00
おさむのひと
a2cd6a7709 feat(backend): 7日間運営のアクティビティがないサーバを自動的に招待制にする (#14746)
* feat(backend): 7日間運営のアクティビティがないサーバを自動的に招待制にする

* fix RoleService.

* fix

* fix

* fix

* add test and fix

* fix

* fix CHANGELOG.md

* fix test
2024-10-11 20:59:36 +09:00
FineArchs
12bc671511 fix: admin/emoji/update で不正なエラーが発生する (#14750)
* fix emoji updating bug

* update changelog

* type fix

* " -> '

* conprehensiveness check

* lint

* undefined -> null
2024-10-11 17:17:45 +09:00
かっこかり
d376aab45e Update CHANGELOG.md (書き方を揃える) 2024-10-10 17:39:20 +09:00
syuilo
1ad3148533 clean up 2024-10-10 17:35:10 +09:00
64 changed files with 1536 additions and 286 deletions

View File

@@ -1,8 +1,23 @@
## 2024.10.1 ## 2024.10.1
### Note
- 悪質なユーザからサーバを守る措置の一環として、モデレータ権限を持つユーザの最終アクティブ日時を確認し、
7日間活動していない場合は自動的に招待制へと移行コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。
詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
### General
- Feat: ユーザーの名前に禁止ワードを設定できるように
### Client ### Client
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
- Enhance: l10nの更新 - Enhance: l10nの更新
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
### Server
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
### Server
- Fix: キューのエラーログを簡略化するように
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649)
## 2024.10.0 ## 2024.10.0

36
locales/index.d.ts vendored
View File

@@ -4366,6 +4366,10 @@ export interface Locale extends ILocale {
* リモートサーバーのチャートを生成 * リモートサーバーのチャートを生成
*/ */
"enableChartsForFederatedInstances": string; "enableChartsForFederatedInstances": string;
/**
* リモートサーバーの情報を取得
*/
"enableStatsForFederatedInstances": string;
/** /**
* ノートのアクションにクリップを追加 * ノートのアクションにクリップを追加
*/ */
@@ -5166,6 +5170,26 @@ export interface Locale extends ILocale {
* 対象 * 対象
*/ */
"target": string; "target": string;
/**
* CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>
*/
"testCaptchaWarning": string;
/**
* 禁止ワード(ユーザーの名前)
*/
"prohibitedWordsForNameOfUser": string;
/**
* このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。
*/
"prohibitedWordsForNameOfUserDescription": string;
/**
* 変更しようとした名前に禁止された文字列が含まれています
*/
"yourNameContainsProhibitedWords": string;
/**
* 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。
*/
"yourNameContainsProhibitedWordsDescription": string;
"_abuseUserReport": { "_abuseUserReport": {
/** /**
* 転送 * 転送
@@ -5696,6 +5720,10 @@ export interface Locale extends ILocale {
* サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。 * サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。
*/ */
"inquiryUrlDescription": string; "inquiryUrlDescription": string;
/**
* 一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。
*/
"thisSettingWillAutomaticallyOffWhenModeratorsInactive": string;
}; };
"_accountMigration": { "_accountMigration": {
/** /**
@@ -9633,6 +9661,14 @@ export interface Locale extends ILocale {
* ユーザーが作成されたとき * ユーザーが作成されたとき
*/ */
"userCreated": string; "userCreated": string;
/**
* モデレーターが一定期間非アクティブになったとき
*/
"inactiveModeratorsWarning": string;
/**
* モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき
*/
"inactiveModeratorsInvitationOnlyChanged": string;
}; };
/** /**
* Webhookを削除しますか * Webhookを削除しますか

View File

@@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
enableChartsForRemoteUser: "リモートユーザーのチャートを生成" enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
enableStatsForFederatedInstances: "リモートサーバーの情報を取得"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
reactionsDisplaySize: "リアクションの表示サイズ" reactionsDisplaySize: "リアクションの表示サイズ"
limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する" limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
@@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
messageToFollower: "フォロワーへのメッセージ" messageToFollower: "フォロワーへのメッセージ"
target: "対象" target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
_abuseUserReport: _abuseUserReport:
forward: "転送" forward: "転送"
@@ -1440,6 +1446,7 @@ _serverSettings:
reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。"
inquiryUrl: "問い合わせ先URL" inquiryUrl: "問い合わせ先URL"
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
_accountMigration: _accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行" moveFrom: "別のアカウントからこのアカウントに移行"
@@ -2552,6 +2559,8 @@ _webhookSettings:
abuseReport: "ユーザーから通報があったとき" abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき" abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき" userCreated: "ユーザーが作成されたとき"
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
deleteConfirm: "Webhookを削除しますか" deleteConfirm: "Webhookを削除しますか"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"

View File

@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.10.1-beta.2", "version": "2024.10.1-beta.4",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "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,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Testcaptcha1728550878802 {
name = 'Testcaptcha1728550878802'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`);
}
}

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

@@ -61,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
return; return;
} }
const moderatorIds = await this.roleService.getModeratorIds(true, true); const moderatorIds = await this.roleService.getModeratorIds({
includeAdmins: true,
excludeExpire: true,
});
for (const moderatorId of moderatorIds) { for (const moderatorId of moderatorIds) {
for (const abuseReport of abuseReports) { for (const abuseReport of abuseReports) {
@@ -285,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'createAbuseReportNotificationRecipient', { .log(updater, 'createAbuseReportNotificationRecipient', {
recipientId: id, recipientId: id,
recipient: created, recipient: created,
}) });
.then();
return created; return created;
} }
@@ -324,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
recipientId: params.id, recipientId: params.id,
before: beforeEntity, before: beforeEntity,
after: afterEntity, after: afterEntity,
}) });
.then();
return afterEntity; return afterEntity;
} }
@@ -346,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
.log(updater, 'deleteAbuseReportNotificationRecipient', { .log(updater, 'deleteAbuseReportNotificationRecipient', {
recipientId: id, recipientId: id,
recipient: entity, recipient: entity,
}) });
.then();
} }
/** /**
@@ -370,7 +370,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
} }
// モデレータ権限の有無で通知先設定を振り分ける // モデレータ権限の有無で通知先設定を振り分ける
const authorizedUserIds = await this.roleService.getModeratorIds(true, true); const authorizedUserIds = await this.roleService.getModeratorIds({
includeAdmins: true,
excludeExpire: true,
});
const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>(); const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>(); const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
for (const recipient of userRecipients) { for (const recipient of userRecipients) {

View File

@@ -110,8 +110,7 @@ export class AbuseReportService {
reportId: report.id, reportId: report.id,
report: report, report: report,
resolvedAs: ps.resolvedAs, resolvedAs: ps.resolvedAs,
}) });
.then();
} }
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) }) return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
@@ -148,8 +147,7 @@ export class AbuseReportService {
.log(moderator, 'forwardAbuseReport', { .log(moderator, 'forwardAbuseReport', {
reportId: report.id, reportId: report.id,
report: report, report: report,
}) });
.then();
} }
@bindThis @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. // 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)) { 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); this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false); this.instanceChart.updateFollowers(i.host, false);
} }
}); });
} }
}
// FIXME: expensive? // FIXME: expensive?
for (const followerId of localFollowerIds) { for (const followerId of localFollowerIds) {

View File

@@ -119,5 +119,18 @@ export class CaptchaService {
throw new Error(`turnstile-failed: ${errorCodes}`); throw new Error(`turnstile-failed: ${errorCodes}`);
} }
} }
@bindThis
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('testcaptcha-failed: no response provided');
}
const success = response === 'testcaptcha-passed';
if (!success) {
throw new Error('testcaptcha-failed');
}
}
} }

View File

@@ -103,19 +103,33 @@ export class CustomEmojiService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async update(id: MiEmoji['id'], data: { public async update(data: (
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
) & {
driveFile?: MiDriveFile; driveFile?: MiDriveFile;
name?: string;
category?: string | null; category?: string | null;
aliases?: string[]; aliases?: string[];
license?: string | null; license?: string | null;
isSensitive?: boolean; isSensitive?: boolean;
localOnly?: boolean; localOnly?: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
}, moderator?: MiUser): Promise<void> { }, moderator?: MiUser): Promise<
const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); null
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); | 'NO_SUCH_EMOJI'
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); | 'SAME_NAME_EMOJI_EXISTS'
> {
const emoji = data.id
? await this.getEmojiById(data.id)
: await this.getEmojiByName(data.name!);
if (emoji === null) return 'NO_SUCH_EMOJI';
const id = emoji.id;
// IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要
const doNameUpdate = data.id && data.name && (data.name !== emoji.name);
if (doNameUpdate) {
const isDuplicate = await this.checkDuplicate(data.name!);
if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS';
}
await this.emojisRepository.update(emoji.id, { await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(), updatedAt: new Date(),
@@ -135,7 +149,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
const packed = await this.emojiEntityService.packDetailed(emoji.id); const packed = await this.emojiEntityService.packDetailed(emoji.id);
if (emoji.name === data.name) { if (!doNameUpdate) {
this.globalEventService.publishBroadcastStream('emojiUpdated', { this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: [packed], emojis: [packed],
}); });
@@ -157,6 +171,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
after: updated, after: updated,
}); });
} }
return null;
} }
@bindThis @bindThis

View File

@@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async fetch(host: string): Promise<MiInstance> { public async fetchOrRegister(host: string): Promise<MiInstance> {
host = this.utilityService.toPuny(host); host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(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 @bindThis
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> { public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
const result = await this.instancesRepository.createQueryBuilder().update() const result = await this.instancesRepository.createQueryBuilder().update()

View File

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

View File

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

View File

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

View File

@@ -93,6 +93,13 @@ export class QueueService {
repeat: { pattern: '0 0 * * *' }, repeat: { pattern: '0 0 * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
removeOnComplete: true,
});
} }
@bindThis @bindThis

View File

@@ -101,6 +101,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable() @Injectable()
export class RoleService implements OnApplicationShutdown, OnModuleInit { export class RoleService implements OnApplicationShutdown, OnModuleInit {
private rootUserIdCache: MemorySingleCache<MiUser['id']>;
private rolesCache: MemorySingleCache<MiRole[]>; private rolesCache: MemorySingleCache<MiRole[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>; private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
private notificationService: NotificationService; private notificationService: NotificationService;
@@ -136,6 +137,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
this.rootUserIdCache = new MemorySingleCache<MiUser['id']>(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
@@ -416,29 +418,42 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> { public async isExplorable(role: { id: MiRole['id'] } | null): Promise<boolean> {
if (role == null) return false; if (role == null) return false;
const check = await this.rolesRepository.findOneBy({ id: role.id }); const check = await this.rolesRepository.findOneBy({ id: role.id });
if (check == null) return false; if (check == null) return false;
return check.isExplorable; return check.isExplorable;
} }
/**
* モデレーター権限のロールが割り当てられているユーザID一覧を取得する.
*
* @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true)
* @param opts.includeRoot rootユーザも含めるか(デフォルト: false)
* @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false)
*/
@bindThis @bindThis
public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> { public async getModeratorIds(opts?: {
includeAdmins?: boolean,
includeRoot?: boolean,
excludeExpire?: boolean,
}): Promise<MiUser['id'][]> {
const includeAdmins = opts?.includeAdmins ?? true;
const includeRoot = opts?.includeRoot ?? false;
const excludeExpire = opts?.excludeExpire ?? false;
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const moderatorRoles = includeAdmins const moderatorRoles = includeAdmins
? roles.filter(r => r.isModerator || r.isAdministrator) ? roles.filter(r => r.isModerator || r.isAdministrator)
: roles.filter(r => r.isModerator); : roles.filter(r => r.isModerator);
// TODO: isRootなアカウントも含める
const assigns = moderatorRoles.length > 0 const assigns = moderatorRoles.length > 0
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) }) ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
: []; : [];
const now = Date.now();
const result = [
// Setを経由して重複を除去ユーザIDは重複する可能性があるので // Setを経由して重複を除去ユーザIDは重複する可能性があるので
...new Set( const now = Date.now();
const resultSet = new Set(
assigns assigns
.filter(it => .filter(it =>
(excludeExpire) (excludeExpire)
@@ -446,19 +461,35 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
: true, : true,
) )
.map(a => a.userId), .map(a => a.userId),
), );
];
return result.sort((x, y) => x.localeCompare(y)); if (includeRoot) {
const rootUserId = await this.rootUserIdCache.fetch(async () => {
const it = await this.usersRepository.createQueryBuilder('users')
.select('id')
.where({ isRoot: true })
.getRawOne<{ id: string }>();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return it!.id;
});
resultSet.add(rootUserId);
}
return [...resultSet].sort((x, y) => x.localeCompare(y));
} }
@bindThis @bindThis
public async getModerators(includeAdmins = true): Promise<MiUser[]> { public async getModerators(opts?: {
const ids = await this.getModeratorIds(includeAdmins); includeAdmins?: boolean,
const users = ids.length > 0 ? await this.usersRepository.findBy({ includeRoot?: boolean,
excludeExpire?: boolean,
}): Promise<MiUser[]> {
const ids = await this.getModeratorIds(opts);
return ids.length > 0
? await this.usersRepository.findBy({
id: In(ids), id: In(ids),
}) : []; })
return users; : [];
} }
@bindThis @bindThis

View File

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

View File

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

View File

@@ -305,21 +305,23 @@ export class UserFollowingService implements OnModuleInit {
//#endregion //#endregion
//#region Update instance stats //#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { 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); this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true); this.instanceChart.updateFollowing(i.host, true);
} }
}); });
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { } 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); this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true); this.instanceChart.updateFollowers(i.host, true);
} }
}); });
} }
}
//#endregion //#endregion
this.perUserFollowingChart.update(follower, followee, true); this.perUserFollowingChart.update(follower, followee, true);
@@ -437,21 +439,23 @@ export class UserFollowingService implements OnModuleInit {
//#endregion //#endregion
//#region Update instance stats //#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { 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); this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false); this.instanceChart.updateFollowing(i.host, false);
} }
}); });
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { } 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); this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false); this.instanceChart.updateFollowers(i.host, false);
} }
}); });
} }
}
//#endregion //#endregion
this.perUserFollowingChart.update(follower, followee, false); 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 { type WebhookEventTypes } from '@/models/Webhook.js';
import { UserWebhookService } from '@/core/UserWebhookService.js'; import { UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000; const oneDayMillis = 24 * 60 * 60 * 1000;
@@ -446,6 +447,22 @@ export class WebhookTestService {
send(toPackedUserLite(dummyUser1)); send(toPackedUserLite(dummyUser1));
break; 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); this.cacheService.uriPersonCache.set(user.uri, user);
// Register host // 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.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host); this.instanceChart.newUser(i.host);
} }
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
}); });
}
this.usersChart.update(user, true); this.usersChart.update(user, true);

View File

@@ -96,6 +96,7 @@ export class MetaEntityService {
recaptchaSiteKey: instance.recaptchaSiteKey, recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile, enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey, turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
swPublickey: instance.swPublicKey, swPublickey: instance.swPublicKey,
themeColor: instance.themeColor, themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',

View File

@@ -81,6 +81,11 @@ export class MiMeta {
}) })
public prohibitedWords: string[]; public prohibitedWords: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public prohibitedWordsForNameOfUser: string[];
@Column('varchar', { @Column('varchar', {
length: 1024, array: true, default: '{}', length: 1024, array: true, default: '{}',
}) })
@@ -258,6 +263,11 @@ export class MiMeta {
}) })
public turnstileSecretKey: string | null; public turnstileSecretKey: string | null;
@Column('boolean', {
default: false,
})
public enableTestcaptcha: boolean;
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
@Column('enum', { @Column('enum', {
@@ -519,6 +529,11 @@ export class MiMeta {
}) })
public enableChartsForFederatedInstances: boolean; public enableChartsForFederatedInstances: boolean;
@Column('boolean', {
default: true,
})
public enableStatsForFederatedInstances: boolean;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
}) })

View File

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

View File

@@ -115,6 +115,10 @@ export const packedMetaLiteSchema = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
enableTestcaptcha: {
type: 'boolean',
optional: false, nullable: false,
},
swPublickey: { swPublickey: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View File

@@ -6,6 +6,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js'; import { QueueProcessorService } from './QueueProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
@@ -80,6 +81,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
DeliverProcessorService, DeliverProcessorService,
InboxProcessorService, InboxProcessorService,
AggregateRetentionProcessorService, AggregateRetentionProcessorService,
CheckExpiredMutingsProcessorService,
CheckModeratorsActivityProcessorService,
QueueProcessorService, QueueProcessorService,
], ],
exports: [ exports: [

View File

@@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@@ -66,7 +67,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string {
// onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
const currentAttempts = job.attemptsMade + (increment ? 1 : 0); const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
const maxAttempts = job.opts ? job.opts.attempts : 0; const maxAttempts = job.opts.attempts ?? 0;
return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
} }
@@ -120,24 +121,35 @@ export class QueueProcessorService implements OnApplicationShutdown {
private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService, private cleanProcessorService: CleanProcessorService,
) { ) {
this.logger = this.queueLoggerService.logger; this.logger = this.queueLoggerService.logger;
function renderError(e: Error): any { function renderError(e?: Error) {
if (e) { // 何故かeがundefinedで来ることがある // 何故かeがundefinedで来ることがある
if (!e) return '?';
if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') {
return `${e.name}: ${e.message}`;
}
return { return {
stack: e.stack, stack: e.stack,
message: e.message, message: e.message,
name: e.name, name: e.name,
}; };
} else {
return {
stack: '?',
message: '?',
name: '?',
};
} }
function renderJob(job?: Bull.Job) {
if (!job) return '?';
return {
name: job.name || undefined,
info: getJobInfo(job),
failedReason: job.failedReason || undefined,
data: job.data,
};
} }
//#region system //#region system
@@ -150,6 +162,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
case 'clean': return this.cleanProcessorService.process(); case 'clean': return this.cleanProcessorService.process();
default: throw new Error(`unrecognized job type ${job.name} for system`); default: throw new Error(`unrecognized job type ${job.name} for system`);
} }
@@ -172,15 +185,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`)) .on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err: Error) => { .on('failed', (job, err: Error) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, { Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion
@@ -229,15 +242,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`)) .on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion
@@ -269,15 +282,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: Deliver: ${err.message}`, { Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion
@@ -309,15 +322,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: Inbox: ${err.message}`, { Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion
@@ -349,15 +362,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, { Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion
@@ -389,15 +402,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, { Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion
@@ -436,15 +449,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`)) .on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, { Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion
@@ -477,15 +490,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`)) .on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (config.sentryForBackend) { if (config.sentryForBackend) {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, { Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
}); });
} }
}) })
.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
} }
//#endregion //#endregion

View File

@@ -0,0 +1,292 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import type 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 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');
}
@bindThis
public async process(): Promise<void> {
this.logger.info('start.');
const meta = await this.metaService.fetch(false);
if (!meta.disableRegistration) {
await this.processImpl();
} else {
this.logger.info('is already invitation only.');
}
this.logger.succ('finish.');
}
@bindThis
private async processImpl() {
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();
await this.notifyChangeToInvitationOnly();
} else {
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.`);
if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
// つまり、のこり2日を切ったら6時間ごとに通知が送られる
await this.notifyInactiveModeratorsWarning(remainingTime);
}
}
}
}
/**
* モデレーターが不在であるかどうかを確認する。trueの場合はモデレーターが不在である。
* isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に、
* {@link MiUser.lastActiveDate}の値が実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前よりも古いユーザがいるかどうかを確認する。
* {@link MiUser.lastActiveDate}がnullの場合は、そのユーザは確認の対象外とする。
*
* -----
*
* ### サンプルパターン
* - 実行日時: 2022-01-30 12:00:00
* - 判定基準: 2022-01-23 12:00:00実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前)
*
* #### パターン①
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
* - モデレータB: lastActiveDate = 2022-01-23 12:00:00 ※セーフ判定基準と同値なのでギリギリ残り0日
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日
* - モデレータD: lastActiveDate = null
*
* この場合、モデレータBのアクティビティのみ判定基準日よりも古くないため、モデレーターが在席と判断される。
*
* #### パターン②
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
* - モデレータB: lastActiveDate = 2022-01-22 12:00:00 ※アウト(残り-1日
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日
* - モデレータD: lastActiveDate = null
*
* この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
*/
@bindThis
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
const today = new Date();
const inactivePeriod = new Date(today);
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
const moderators = await this.fetchModerators()
.then(it => it.filter(it => it.lastActiveDate != null));
const inactiveModerators = moderators
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime());
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
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,
remainingTime: {
time: remainingTime,
asHours: remainingTimeAsHours,
asDays: remainingTimeAsDays,
},
};
}
@bindThis
private async changeToInvitationOnly() {
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: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
return this.roleService.getModerators({
includeAdmins: true,
includeRoot: true,
excludeExpire: true,
});
}
}

View File

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

View File

@@ -192,21 +192,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
} }
} }
// Update stats this.apRequestChart.inbox();
this.federatedInstanceService.fetch(authUser.user.host).then(i => { 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, { this.updateInstanceQueue.enqueue(i.id, {
latestRequestReceivedAt: new Date(), latestRequestReceivedAt: new Date(),
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
}); });
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.inbox();
this.federationChart.inbox(i.host);
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestReceived(i.host); this.instanceChart.requestReceived(i.host);
} }
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
}); });
// アクティビティを処理 // アクティビティを処理

View File

@@ -119,6 +119,7 @@ export class ApiServerService {
'g-recaptcha-response'?: string; 'g-recaptcha-response'?: string;
'turnstile-response'?: string; 'turnstile-response'?: string;
'm-captcha-response'?: string; 'm-captcha-response'?: string;
'testcaptcha-response'?: string;
} }
}>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); }>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
@@ -132,6 +133,7 @@ export class ApiServerService {
'g-recaptcha-response'?: string; 'g-recaptcha-response'?: string;
'turnstile-response'?: string; 'turnstile-response'?: string;
'm-captcha-response'?: string; 'm-captcha-response'?: string;
'testcaptcha-response'?: string;
}; };
}>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));

View File

@@ -71,6 +71,7 @@ export class SigninApiService {
'g-recaptcha-response'?: string; 'g-recaptcha-response'?: string;
'turnstile-response'?: string; 'turnstile-response'?: string;
'm-captcha-response'?: string; 'm-captcha-response'?: string;
'testcaptcha-response'?: string;
}; };
}>, }>,
reply: FastifyReply, reply: FastifyReply,
@@ -194,6 +195,12 @@ export class SigninApiService {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, err);
}); });
} }
if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
});
}
} }
if (same) { if (same) {

View File

@@ -67,6 +67,7 @@ export class SignupApiService {
'g-recaptcha-response'?: string; 'g-recaptcha-response'?: string;
'turnstile-response'?: string; 'turnstile-response'?: string;
'm-captcha-response'?: string; 'm-captcha-response'?: string;
'testcaptcha-response'?: string;
} }
}>, }>,
reply: FastifyReply, reply: FastifyReply,
@@ -99,6 +100,12 @@ export class SignupApiService {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, err);
}); });
} }
if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
});
}
} }
const username = body['username']; const username = body['username'];

View File

@@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import type { DriveFilesRepository } from '@/models/_.js'; import type { DriveFilesRepository, MiEmoji } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
@@ -78,25 +78,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
} }
let emojiId; // JSON schemeのanyOfの型変換がうまくいっていないらしい
if (ps.id) { const required = { id: ps.id, name: ps.name } as
emojiId = ps.id; | { id: MiEmoji['id']; name?: string }
const emoji = await this.customEmojiService.getEmojiById(ps.id); | { id?: MiEmoji['id']; name: string };
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
if (ps.name && (ps.name !== emoji.name)) {
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
}
} else {
if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
const emoji = await this.customEmojiService.getEmojiByName(ps.name);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
emojiId = emoji.id;
}
await this.customEmojiService.update(emojiId, { const error = await this.customEmojiService.update({
...required,
driveFile, driveFile,
name: ps.name,
category: ps.category, category: ps.category,
aliases: ps.aliases, aliases: ps.aliases,
license: ps.license, license: ps.license,
@@ -104,6 +93,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
localOnly: ps.localOnly, localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
}, me); }, me);
switch (error) {
case null: return;
case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji);
case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists);
}
// 網羅性チェック
const mustBeNever: never = error;
}); });
} }
} }

View File

@@ -69,6 +69,10 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
enableTestcaptcha: {
type: 'boolean',
optional: false, nullable: false,
},
swPublickey: { swPublickey: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@@ -173,6 +177,13 @@ export const meta = {
type: 'string', type: 'string',
}, },
}, },
prohibitedWordsForNameOfUser: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
bannedEmailDomains: { bannedEmailDomains: {
type: 'array', type: 'array',
optional: true, nullable: false, optional: true, nullable: false,
@@ -337,6 +348,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
enableStatsForFederatedInstances: {
type: 'boolean',
optional: false, nullable: false,
},
enableServerMachineStats: { enableServerMachineStats: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@@ -555,6 +570,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
recaptchaSiteKey: instance.recaptchaSiteKey, recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile, enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey, turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
swPublickey: instance.swPublicKey, swPublickey: instance.swPublicKey,
themeColor: instance.themeColor, themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl, mascotImageUrl: instance.mascotImageUrl,
@@ -581,6 +597,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
mediaSilencedHosts: instance.mediaSilencedHosts, mediaSilencedHosts: instance.mediaSilencedHosts,
sensitiveWords: instance.sensitiveWords, sensitiveWords: instance.sensitiveWords,
prohibitedWords: instance.prohibitedWords, prohibitedWords: instance.prohibitedWords,
prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
preservedUsernames: instance.preservedUsernames, preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey, hcaptchaSecretKey: instance.hcaptchaSecretKey,
mcaptchaSecretKey: instance.mcaptchaSecretKey, mcaptchaSecretKey: instance.mcaptchaSecretKey,
@@ -622,6 +639,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
truemailAuthKey: instance.truemailAuthKey, truemailAuthKey: instance.truemailAuthKey,
enableChartsForRemoteUser: instance.enableChartsForRemoteUser, enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
enableServerMachineStats: instance.enableServerMachineStats, enableServerMachineStats: instance.enableServerMachineStats,
enableIdenticonGeneration: instance.enableIdenticonGeneration, enableIdenticonGeneration: instance.enableIdenticonGeneration,
bannedEmailDomains: instance.bannedEmailDomains, bannedEmailDomains: instance.bannedEmailDomains,

View File

@@ -71,13 +71,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
break; break;
} }
case 'moderator': { case 'moderator': {
const moderatorIds = await this.roleService.getModeratorIds(false); const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false });
if (moderatorIds.length === 0) return []; if (moderatorIds.length === 0) return [];
query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds }); query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds });
break; break;
} }
case 'adminOrModerator': { case 'adminOrModerator': {
const adminOrModeratorIds = await this.roleService.getModeratorIds(); const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true });
if (adminOrModeratorIds.length === 0) return []; if (adminOrModeratorIds.length === 0) return [];
query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds }); query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds });
break; break;

View File

@@ -46,6 +46,11 @@ export const paramDef = {
type: 'string', type: 'string',
}, },
}, },
prohibitedWordsForNameOfUser: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true }, mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true },
@@ -78,6 +83,7 @@ export const paramDef = {
enableTurnstile: { type: 'boolean' }, enableTurnstile: { type: 'boolean' },
turnstileSiteKey: { type: 'string', nullable: true }, turnstileSiteKey: { type: 'string', nullable: true },
turnstileSecretKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true },
enableTestcaptcha: { type: 'boolean' },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' }, setSensitiveFlagAutomatically: { type: 'boolean' },
@@ -130,6 +136,7 @@ export const paramDef = {
truemailAuthKey: { type: 'string', nullable: true }, truemailAuthKey: { type: 'string', nullable: true },
enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' },
enableStatsForFederatedInstances: { type: 'boolean' },
enableServerMachineStats: { type: 'boolean' }, enableServerMachineStats: { type: 'boolean' },
enableIdenticonGeneration: { type: 'boolean' }, enableIdenticonGeneration: { type: 'boolean' },
serverRules: { type: 'array', items: { type: 'string' } }, serverRules: { type: 'array', items: { type: 'string' } },
@@ -213,6 +220,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (Array.isArray(ps.prohibitedWords)) { if (Array.isArray(ps.prohibitedWords)) {
set.prohibitedWords = ps.prohibitedWords.filter(Boolean); set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
} }
if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
}
if (Array.isArray(ps.silencedHosts)) { if (Array.isArray(ps.silencedHosts)) {
let lastValue = ''; let lastValue = '';
set.silencedHosts = ps.silencedHosts.sort().filter((h) => { set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
@@ -357,6 +367,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.turnstileSecretKey = ps.turnstileSecretKey; set.turnstileSecretKey = ps.turnstileSecretKey;
} }
if (ps.enableTestcaptcha !== undefined) {
set.enableTestcaptcha = ps.enableTestcaptcha;
}
if (ps.sensitiveMediaDetection !== undefined) { if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection; set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
} }
@@ -565,6 +579,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
} }
if (ps.enableStatsForFederatedInstances !== undefined) {
set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
}
if (ps.enableServerMachineStats !== undefined) { if (ps.enableServerMachineStats !== undefined) {
set.enableServerMachineStats = ps.enableServerMachineStats; 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 { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.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 type { MiLocalUser, MiUser } from '@/models/User.js';
import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js'; import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.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 { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { HashtagService } from '@/core/HashtagService.js'; import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RolePolicies, RoleService } from '@/core/RoleService.js'; import { RolePolicies, RoleService } from '@/core/RoleService.js';
@@ -114,6 +115,13 @@ export const meta = {
code: 'RESTRICTED_BY_ROLE', code: 'RESTRICTED_BY_ROLE',
id: '8feff0ba-5ab5-585b-31f4-4df816663fad', 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: { res: {
@@ -223,6 +231,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@Inject(DI.meta)
private instanceMeta: MiMeta,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@@ -247,6 +258,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private cacheService: CacheService, private cacheService: CacheService,
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private avatarDecorationService: AvatarDecorationService, private avatarDecorationService: AvatarDecorationService,
private utilityService: UtilityService,
) { ) {
super(meta, paramDef, async (ps, _user, token) => { super(meta, paramDef, async (ps, _user, token) => {
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
@@ -449,6 +461,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields; const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
if (newName != null) { 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); const tokens = mfm.parseSimple(newName);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
} }

View File

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

View File

@@ -10,6 +10,8 @@ import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock'; import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers'; import * as lolex from '@sinonjs/fake-timers';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { import {
@@ -31,8 +33,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { RoleCondFormulaValue } from '@/models/Role.js'; import { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
const moduleMocker = new ModuleMocker(global); const moduleMocker = new ModuleMocker(global);
@@ -277,9 +277,9 @@ describe('RoleService', () => {
}); });
describe('getModeratorIds', () => { describe('getModeratorIds', () => {
test('includeAdmins = false, excludeExpire = false', async () => { test('includeAdmins = false, includeRoot = false, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]); ]);
const role1 = await createRole({ name: 'admin', isAdministrator: true }); const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -295,13 +295,17 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]); ]);
const result = await roleService.getModeratorIds(false, false); const result = await roleService.getModeratorIds({
includeAdmins: false,
includeRoot: false,
excludeExpire: false,
});
expect(result).toEqual([modeUser1.id, modeUser2.id]); expect(result).toEqual([modeUser1.id, modeUser2.id]);
}); });
test('includeAdmins = false, excludeExpire = true', async () => { test('includeAdmins = false, includeRoot = false, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]); ]);
const role1 = await createRole({ name: 'admin', isAdministrator: true }); const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -317,13 +321,17 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]); ]);
const result = await roleService.getModeratorIds(false, true); const result = await roleService.getModeratorIds({
includeAdmins: false,
includeRoot: false,
excludeExpire: true,
});
expect(result).toEqual([modeUser1.id]); expect(result).toEqual([modeUser1.id]);
}); });
test('includeAdmins = true, excludeExpire = false', async () => { test('includeAdmins = true, includeRoot = false, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]); ]);
const role1 = await createRole({ name: 'admin', isAdministrator: true }); const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -339,13 +347,17 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]); ]);
const result = await roleService.getModeratorIds(true, false); const result = await roleService.getModeratorIds({
includeAdmins: true,
includeRoot: false,
excludeExpire: false,
});
expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]); expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]);
}); });
test('includeAdmins = true, excludeExpire = true', async () => { test('includeAdmins = true, includeRoot = false, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]); ]);
const role1 = await createRole({ name: 'admin', isAdministrator: true }); const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -361,9 +373,111 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]); ]);
const result = await roleService.getModeratorIds(true, true); const result = await roleService.getModeratorIds({
includeAdmins: true,
includeRoot: false,
excludeExpire: true,
});
expect(result).toEqual([adminUser1.id, modeUser1.id]); expect(result).toEqual([adminUser1.id, modeUser1.id]);
}); });
test('includeAdmins = false, includeRoot = true, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
const role2 = await createRole({ name: 'moderator', isModerator: true });
const role3 = await createRole({ name: 'normal' });
await Promise.all([
assignRole({ userId: adminUser1.id, roleId: role1.id }),
assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: modeUser1.id, roleId: role2.id }),
assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: normalUser1.id, roleId: role3.id }),
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
const result = await roleService.getModeratorIds({
includeAdmins: false,
includeRoot: true,
excludeExpire: false,
});
expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]);
});
test('root has moderator role', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
const role2 = await createRole({ name: 'moderator', isModerator: true });
const role3 = await createRole({ name: 'normal' });
await Promise.all([
assignRole({ userId: adminUser1.id, roleId: role1.id }),
assignRole({ userId: modeUser1.id, roleId: role2.id }),
assignRole({ userId: rootUser.id, roleId: role2.id }),
assignRole({ userId: normalUser1.id, roleId: role3.id }),
]);
const result = await roleService.getModeratorIds({
includeAdmins: false,
includeRoot: true,
excludeExpire: false,
});
expect(result).toEqual([modeUser1.id, rootUser.id]);
});
test('root has administrator role', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
const role2 = await createRole({ name: 'moderator', isModerator: true });
const role3 = await createRole({ name: 'normal' });
await Promise.all([
assignRole({ userId: adminUser1.id, roleId: role1.id }),
assignRole({ userId: rootUser.id, roleId: role1.id }),
assignRole({ userId: modeUser1.id, roleId: role2.id }),
assignRole({ userId: normalUser1.id, roleId: role3.id }),
]);
const result = await roleService.getModeratorIds({
includeAdmins: true,
includeRoot: true,
excludeExpire: false,
});
expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]);
});
test('root has moderator role(expire)', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
const role2 = await createRole({ name: 'moderator', isModerator: true });
const role3 = await createRole({ name: 'normal' });
await Promise.all([
assignRole({ userId: adminUser1.id, roleId: role1.id }),
assignRole({ userId: modeUser1.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: rootUser.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
assignRole({ userId: normalUser1.id, roleId: role3.id }),
]);
const result = await roleService.getModeratorIds({
includeAdmins: false,
includeRoot: true,
excludeExpire: true,
});
expect(result).toEqual([rootUser.id]);
});
}); });
describe('conditional role', () => { describe('conditional role', () => {

View File

@@ -0,0 +1,379 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { jest } from '@jest/globals';
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 { 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));
describe('CheckModeratorsActivityProcessorService', () => {
let app: TestingModule;
let clock: lolex.InstalledClock;
let service: CheckModeratorsActivityProcessorService;
// --------------------------------------------------------------------------------------
let usersRepository: UsersRepository;
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> = {}, profile: Partial<MiUserProfile> = {}): Promise<MiUser> {
const id = idService.gen();
const user = await usersRepository
.insert({
id: id,
username: `user_${id}`,
usernameLower: `user_${id}`.toLowerCase(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
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);
}
// --------------------------------------------------------------------------------------
beforeAll(async () => {
app = await Test
.createTestingModule({
imports: [
GlobalModule,
],
providers: [
CheckModeratorsActivityProcessorService,
IdService,
{
provide: RoleService, useFactory: () => ({ getModerators: jest.fn() }),
},
{
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: ({
createSubLogger: () => ({
info: jest.fn(),
warn: jest.fn(),
succ: jest.fn(),
}),
}),
}),
},
],
})
.compile();
usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository);
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();
});
beforeEach(async () => {
clock = lolex.install({
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 () => {
clock.uninstall();
await usersRepository.delete({});
await userProfilesRepository.delete({});
roleService.getModerators.mockReset();
announcementService.create.mockReset();
emailService.sendEmail.mockReset();
systemWebhookService.enqueueSystemWebhook.mockReset();
});
afterAll(async () => {
await app.close();
});
// --------------------------------------------------------------------------------------
describe('evaluateModeratorsInactiveDays', () => {
test('[isModeratorsInactive] inactiveなモデレーターがいても他のモデレーターがアクティブなら"運営が非アクティブ"としてみなされない', async () => {
const [user1, user2, user3, user4] = await Promise.all([
// 期限よりも1秒新しいタイミングでアクティブ化セーフ
createUser({ lastActiveDate: subDays(addSeconds(baseDate, 1), 7) }),
// 期限ちょうどにアクティブ化(セーフ)
createUser({ lastActiveDate: subDays(baseDate, 7) }),
// 期限よりも1秒古いタイミングでアクティブ化アウト
createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }),
// 対象外
createUser({ lastActiveDate: null }),
]);
mockModeratorRole([user1, user2, user3, user4]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user3]);
});
test('[isModeratorsInactive] 全員非アクティブなら"運営が非アクティブ"としてみなされる', async () => {
const [user1, user2] = await Promise.all([
// 期限よりも1秒古いタイミングでアクティブ化アウト
createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }),
// 対象外
createUser({ lastActiveDate: null }),
]);
mockModeratorRole([user1, user2]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(true);
expect(result.inactiveModerators).toEqual([user1]);
});
test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
// 期限まで残り24時間->猶予1日として計算されるはずである
createUser({ lastActiveDate: subDays(baseDate, 6) }),
]);
mockModeratorRole([user1, user2]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.remainingTime.asDays).toBe(1);
expect(result.remainingTime.asHours).toBe(24);
});
test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
// 期限まで残り25時間->猶予1日として計算されるはずである
createUser({ lastActiveDate: subDays(addHours(baseDate, 1), 6) }),
]);
mockModeratorRole([user1, user2]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.remainingTime.asDays).toBe(1);
expect(result.remainingTime.asHours).toBe(25);
});
test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
// 期限まで残り23時間->猶予0日として計算されるはずである
createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 6) }),
]);
mockModeratorRole([user1, user2]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.remainingTime.asDays).toBe(0);
expect(result.remainingTime.asHours).toBe(23);
});
test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
// 期限ちょうど->猶予0日として計算されるはずである
createUser({ lastActiveDate: subDays(baseDate, 7) }),
]);
mockModeratorRole([user1, user2]);
const result = await service.evaluateModeratorsInactiveDays();
expect(result.isModeratorsInactive).toBe(false);
expect(result.inactiveModerators).toEqual([user1]);
expect(result.remainingTime.asDays).toBe(0);
expect(result.remainingTime.asHours).toBe(0);
});
test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
const [user1, user2] = await Promise.all([
createUser({ lastActiveDate: subDays(baseDate, 8) }),
// 猶予はこのユーザ基準で計算される想定。
// 期限より1時間超過->猶予-1日として計算されるはずである
createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 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(-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);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -10,6 +10,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<div id="mcaptcha__widget-container" class="m-captcha-style"></div> <div id="mcaptcha__widget-container" class="m-captcha-style"></div>
<div ref="captchaEl"></div> <div ref="captchaEl"></div>
</div> </div>
<div v-if="props.provider == 'testcaptcha'" style="background: #eee; border: solid 1px #888; padding: 8px; color: #000; max-width: 320px; display: flex; gap: 10px; align-items: center; box-shadow: 2px 2px 6px #0004; border-radius: 4px;">
<img src="/client-assets/testcaptcha.png" style="width: 60px; height: 60px; "/>
<div v-if="testcaptchaPassed">
<div style="color: green;">Test captcha passed!</div>
</div>
<div v-else>
<div style="font-size: 13px; margin-bottom: 4px;">Type "ai-chan-kawaii" to pass captcha</div>
<input v-model="testcaptchaInput" data-cy-testcaptcha-input/>
<button type="button" data-cy-testcaptcha-submit @click="testcaptchaSubmit">Submit</button>
</div>
</div>
<div v-else ref="captchaEl"></div> <div v-else ref="captchaEl"></div>
</div> </div>
</template> </template>
@@ -29,7 +40,7 @@ export type Captcha = {
getResponse(id: string): string; getResponse(id: string): string;
}; };
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha'; export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha';
type CaptchaContainer = { type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha; readonly [_ in CaptchaProvider]?: Captcha;
@@ -54,12 +65,16 @@ const available = ref(false);
const captchaEl = shallowRef<HTMLDivElement | undefined>(); const captchaEl = shallowRef<HTMLDivElement | undefined>();
const testcaptchaInput = ref('');
const testcaptchaPassed = ref(false);
const variable = computed(() => { const variable = computed(() => {
switch (props.provider) { switch (props.provider) {
case 'hcaptcha': return 'hcaptcha'; case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha'; case 'recaptcha': return 'grecaptcha';
case 'turnstile': return 'turnstile'; case 'turnstile': return 'turnstile';
case 'mcaptcha': return 'mcaptcha'; case 'mcaptcha': return 'mcaptcha';
case 'testcaptcha': return 'testcaptcha';
} }
}); });
@@ -71,6 +86,7 @@ const src = computed(() => {
case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
case 'mcaptcha': return null; case 'mcaptcha': return null;
case 'testcaptcha': return null;
} }
}); });
@@ -78,7 +94,7 @@ const scriptId = computed(() => `script-${props.provider}`);
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
if (loaded || props.provider === 'mcaptcha') { if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
available.value = true; available.value = true;
} else if (src.value !== null) { } else if (src.value !== null) {
(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
@@ -91,6 +107,8 @@ if (loaded || props.provider === 'mcaptcha') {
function reset() { function reset() {
if (captcha.value.reset) captcha.value.reset(); if (captcha.value.reset) captcha.value.reset();
testcaptchaPassed.value = false;
testcaptchaInput.value = '';
} }
async function requestRender() { async function requestRender() {
@@ -127,6 +145,12 @@ function onReceivedMessage(message: MessageEvent) {
} }
} }
function testcaptchaSubmit() {
testcaptchaPassed.value = testcaptchaInput.value === 'ai-chan-kawaii';
callback(testcaptchaPassed.value ? 'testcaptcha-passed' : undefined);
if (!testcaptchaPassed.value) testcaptchaInput.value = '';
}
onMounted(() => { onMounted(() => {
if (available.value) { if (available.value) {
window.addEventListener('message', onReceivedMessage); window.addEventListener('message', onReceivedMessage);

View File

@@ -100,10 +100,12 @@ export default defineComponent({
return [el, separator]; return [el, separator];
} else { } else {
if (props.ad && item._shouldInsertAd_) { if (props.ad && item._shouldInsertAd_) {
return [h(MkAd, { return [h('div', {
key: item.id + ':ad', 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 );',
}, [h(MkAd, {
prefer: ['horizontal', 'horizontal-big'], prefer: ['horizontal', 'horizontal-big'],
}), el]; })]), el];
} else { } else {
return el; return el;
} }

View File

@@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
</div> </div>
<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> <MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
@@ -44,6 +45,7 @@ export type PwResponse = {
mCaptchaResponse: string | null; mCaptchaResponse: string | null;
reCaptchaResponse: string | null; reCaptchaResponse: string | null;
turnstileResponse: string | null; turnstileResponse: string | null;
testcaptchaResponse: string | null;
}; };
}; };
</script> </script>
@@ -75,18 +77,21 @@ const hCaptcha = useTemplateRef('hcaptcha');
const mCaptcha = useTemplateRef('mcaptcha'); const mCaptcha = useTemplateRef('mcaptcha');
const reCaptcha = useTemplateRef('recaptcha'); const reCaptcha = useTemplateRef('recaptcha');
const turnstile = useTemplateRef('turnstile'); const turnstile = useTemplateRef('turnstile');
const testcaptcha = useTemplateRef('testcaptcha');
const hCaptchaResponse = ref<string | null>(null); const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null); const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null); const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null); const turnstileResponse = ref<string | null>(null);
const testcaptchaResponse = ref<string | null>(null);
const captchaFailed = computed((): boolean => { const captchaFailed = computed((): boolean => {
return ( return (
(instance.enableHcaptcha && !hCaptchaResponse.value) || (instance.enableHcaptcha && !hCaptchaResponse.value) ||
(instance.enableMcaptcha && !mCaptchaResponse.value) || (instance.enableMcaptcha && !mCaptchaResponse.value) ||
(instance.enableRecaptcha && !reCaptchaResponse.value) || (instance.enableRecaptcha && !reCaptchaResponse.value) ||
(instance.enableTurnstile && !turnstileResponse.value) (instance.enableTurnstile && !turnstileResponse.value) ||
(instance.enableTestcaptcha && !testcaptchaResponse.value)
); );
}); });
@@ -104,6 +109,7 @@ function onSubmit() {
mCaptchaResponse: mCaptchaResponse.value, mCaptchaResponse: mCaptchaResponse.value,
reCaptchaResponse: reCaptchaResponse.value, reCaptchaResponse: reCaptchaResponse.value,
turnstileResponse: turnstileResponse.value, turnstileResponse: turnstileResponse.value,
testcaptchaResponse: testcaptchaResponse.value,
}, },
}); });
} }
@@ -113,6 +119,7 @@ function resetCaptcha() {
mCaptcha.value?.reset(); mCaptcha.value?.reset();
reCaptcha.value?.reset(); reCaptcha.value?.reset();
turnstile.value?.reset(); turnstile.value?.reset();
testcaptcha.value?.reset();
} }
defineExpose({ defineExpose({

View File

@@ -68,6 +68,8 @@ import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { login } from '@/account.js'; import { login } from '@/account.js';
@@ -79,9 +81,6 @@ import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue';
import XTotp from '@/components/MkSignin.totp.vue'; import XTotp from '@/components/MkSignin.totp.vue';
import XPasskey from '@/components/MkSignin.passkey.vue'; import XPasskey from '@/components/MkSignin.passkey.vue';
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void; (ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void;
}>(); }>();
@@ -188,6 +187,7 @@ async function onPasswordSubmitted(pw: PwResponse) {
'm-captcha-response': pw.captcha.mCaptchaResponse, 'm-captcha-response': pw.captcha.mCaptchaResponse,
'g-recaptcha-response': pw.captcha.reCaptchaResponse, 'g-recaptcha-response': pw.captcha.reCaptchaResponse,
'turnstile-response': pw.captcha.turnstileResponse, 'turnstile-response': pw.captcha.turnstileResponse,
'testcaptcha-response': pw.captcha.testcaptchaResponse,
}); });
} }
} }

View File

@@ -66,6 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;"> <MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
<template v-if="submitting"> <template v-if="submitting">
<MkLoading :em="true" :colored="false"/> <MkLoading :em="true" :colored="false"/>
@@ -108,6 +109,7 @@ const hcaptcha = ref<Captcha | undefined>();
const mcaptcha = ref<Captcha | undefined>(); const mcaptcha = ref<Captcha | undefined>();
const recaptcha = ref<Captcha | undefined>(); const recaptcha = ref<Captcha | undefined>();
const turnstile = ref<Captcha | undefined>(); const turnstile = ref<Captcha | undefined>();
const testcaptcha = ref<Captcha | undefined>();
const username = ref<string>(''); const username = ref<string>('');
const password = ref<string>(''); const password = ref<string>('');
@@ -123,6 +125,7 @@ const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null); const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null); const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null); const turnstileResponse = ref<string | null>(null);
const testcaptchaResponse = ref<string | null>(null);
const usernameAbortController = ref<null | AbortController>(null); const usernameAbortController = ref<null | AbortController>(null);
const emailAbortController = ref<null | AbortController>(null); const emailAbortController = ref<null | AbortController>(null);
@@ -132,6 +135,7 @@ const shouldDisableSubmitting = computed((): boolean => {
instance.enableMcaptcha && !mCaptchaResponse.value || instance.enableMcaptcha && !mCaptchaResponse.value ||
instance.enableRecaptcha && !reCaptchaResponse.value || instance.enableRecaptcha && !reCaptchaResponse.value ||
instance.enableTurnstile && !turnstileResponse.value || instance.enableTurnstile && !turnstileResponse.value ||
instance.enableTestcaptcha && !testcaptchaResponse.value ||
instance.emailRequiredForSignup && emailState.value !== 'ok' || instance.emailRequiredForSignup && emailState.value !== 'ok' ||
usernameState.value !== 'ok' || usernameState.value !== 'ok' ||
passwordRetypeState.value !== 'match'; passwordRetypeState.value !== 'match';
@@ -259,6 +263,7 @@ async function onSubmit(): Promise<void> {
'm-captcha-response': mCaptchaResponse.value, 'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value, 'g-recaptcha-response': reCaptchaResponse.value,
'turnstile-response': turnstileResponse.value, 'turnstile-response': turnstileResponse.value,
'testcaptcha-response': testcaptchaResponse.value,
}; };
const res = await fetch(`${config.apiUrl}/signup`, { const res = await fetch(`${config.apiUrl}/signup`, {
@@ -301,6 +306,7 @@ function onSignupApiError() {
mcaptcha.value?.reset?.(); mcaptcha.value?.reset?.();
recaptcha.value?.reset?.(); recaptcha.value?.reset?.();
turnstile.value?.reset?.(); turnstile.value?.reset?.();
testcaptcha.value?.reset?.();
os.alert({ os.alert({
type: 'error', type: 'error',

View File

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

View File

@@ -51,6 +51,11 @@ watch(name, () => {
// 空文字列をnullにしたいので??は使うな // 空文字列をnullにしたいので??は使うな
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: name.value || null, name: name.value || null,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
title: i18n.ts.yourNameContainsProhibitedWords,
text: i18n.ts.yourNameContainsProhibitedWordsDescription,
},
}); });
}); });

View File

@@ -54,9 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue'; import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import contains from '@/scripts/contains.js'; import contains from '@/scripts/contains.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import type { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@@ -484,6 +484,10 @@ defineExpose({
} }
.root { .root {
// universal.vueとかで直接--MI-stickyBottomが定義されていたりするのでリセット
--MI-stickyTop: 0;
--MI-stickyBottom: 0;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;

View File

@@ -30,13 +30,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</component> </component>
</div> </div>
<div v-else :class="$style.menu"> <div v-else :class="$style.menu">
<div :class="$style.menuContainer">
<div>Ads by {{ host }}</div> <div>Ads by {{ host }}</div>
<!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>--> <!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>-->
<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton> <MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton>
<button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button> <button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button>
</div> </div>
</div>
</div> </div>
<div v-else></div> <div v-else></div>
</template> </template>
@@ -123,8 +121,7 @@ function reduceFrequency(): void {
<style lang="scss" module> <style lang="scss" module>
.root { .root {
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--bg) 8px, var(--bg) 14px );
} }
.main { .main {
@@ -202,14 +199,11 @@ function reduceFrequency(): void {
} }
.menu { .menu {
padding: 8px;
text-align: center; text-align: center;
}
.menuContainer {
padding: 8px; padding: 8px;
margin: 0 auto; margin: 0 auto;
max-width: 400px; max-width: 400px;
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-divider); border: solid 1px var(--MI_THEME-divider);
} }

View File

@@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div ref="rootEl"> <div>
<div ref="headerEl"> <div ref="headerEl" :class="$style.header">
<slot name="header"></slot> <slot name="header"></slot>
</div> </div>
<div <div
ref="bodyEl" :class="$style.body"
:data-sticky-container-header-height="headerHeight" :data-sticky-container-header-height="headerHeight"
:data-sticky-container-footer-height="footerHeight" :data-sticky-container-footer-height="footerHeight"
style="position: relative; z-index: 0;"
> >
<slot></slot> <slot></slot>
</div> </div>
<div ref="footerEl"> <div ref="footerEl" :class="$style.footer">
<slot name="footer"></slot> <slot name="footer"></slot>
</div> </div>
</div> </div>
@@ -27,10 +26,8 @@ import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef }
import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js'; import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
const rootEl = shallowRef<HTMLElement>();
const headerEl = shallowRef<HTMLElement>(); const headerEl = shallowRef<HTMLElement>();
const footerEl = shallowRef<HTMLElement>(); const footerEl = shallowRef<HTMLElement>();
const bodyEl = shallowRef<HTMLElement>();
const headerHeight = ref<string | undefined>(); const headerHeight = ref<string | undefined>();
const childStickyTop = ref(0); const childStickyTop = ref(0);
@@ -67,31 +64,11 @@ onMounted(() => {
watch([parentStickyTop, parentStickyBottom], calc); watch([parentStickyTop, parentStickyBottom], calc);
watch(childStickyTop, () => {
if (bodyEl.value == null) return;
bodyEl.value.style.setProperty('--MI-stickyTop', `${childStickyTop.value}px`);
}, {
immediate: true,
});
watch(childStickyBottom, () => {
if (bodyEl.value == null) return;
bodyEl.value.style.setProperty('--MI-stickyBottom', `${childStickyBottom.value}px`);
}, {
immediate: true,
});
if (headerEl.value != null) { if (headerEl.value != null) {
headerEl.value.style.position = 'sticky';
headerEl.value.style.top = 'var(--MI-stickyTop, 0)';
headerEl.value.style.zIndex = '1';
observer.observe(headerEl.value); observer.observe(headerEl.value);
} }
if (footerEl.value != null) { if (footerEl.value != null) {
footerEl.value.style.position = 'sticky';
footerEl.value.style.bottom = 'var(--MI-stickyBottom, 0)';
footerEl.value.style.zIndex = '1';
observer.observe(footerEl.value); observer.observe(footerEl.value);
} }
}); });
@@ -99,8 +76,25 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
observer.disconnect(); observer.disconnect();
}); });
defineExpose({
rootEl: rootEl,
});
</script> </script>
<style lang='scss' module>
.body {
position: relative;
z-index: 0;
--MI-stickyTop: v-bind("childStickyTop + 'px'");
--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
}
.header {
position: sticky;
top: var(--MI-stickyTop, 0);
z-index: 1;
}
.footer {
position: sticky;
bottom: var(--MI-stickyBottom, 0);
z-index: 1;
}
</style>

View File

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

View File

@@ -266,6 +266,9 @@ const patronsWithIcon = [{
}, { }, {
name: 'なっかあ', name: 'なっかあ',
icon: 'https://assets.misskey-hub.net/patrons/c2f5f3e394e74a64912284a2f4ca710e.jpg', icon: 'https://assets.misskey-hub.net/patrons/c2f5f3e394e74a64912284a2f4ca710e.jpg',
}, {
name: '如月ユカ',
icon: 'https://assets.misskey-hub.net/patrons/f24a042076a041b6811a2f124eb620ca.jpg',
}]; }];
const patrons = [ const patrons = [

View File

@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template> <template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template> <template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template>
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template> <template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<template v-if="botProtectionForm.modified.value" #footer> <template v-if="botProtectionForm.modified.value" #footer>
<MkFormFooter :form="botProtectionForm"/> <MkFormFooter :form="botProtectionForm"/>
@@ -23,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="mcaptcha">mCaptcha</option> <option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option> <option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option> <option value="turnstile">Turnstile</option>
<option value="testcaptcha">testCaptcha</option>
</MkRadios> </MkRadios>
<template v-if="botProtectionForm.state.provider === 'hcaptcha'"> <template v-if="botProtectionForm.state.provider === 'hcaptcha'">
@@ -85,6 +87,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/> <MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/>
</FormSlot> </FormSlot>
</template> </template>
<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'">
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
<FormSlot>
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="testcaptcha"/>
</FormSlot>
</template>
</div> </div>
</MkFolder> </MkFolder>
</template> </template>
@@ -101,6 +110,7 @@ import { i18n } from '@/i18n.js';
import { useForm } from '@/scripts/use-form.js'; import { useForm } from '@/scripts/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue'; import MkFormFooter from '@/components/MkFormFooter.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
@@ -115,6 +125,8 @@ const botProtectionForm = useForm({
? 'turnstile' ? 'turnstile'
: meta.enableMcaptcha : meta.enableMcaptcha
? 'mcaptcha' ? 'mcaptcha'
: meta.enableTestcaptcha
? 'testcaptcha'
: null, : null,
hcaptchaSiteKey: meta.hcaptchaSiteKey, hcaptchaSiteKey: meta.hcaptchaSiteKey,
hcaptchaSecretKey: meta.hcaptchaSecretKey, hcaptchaSecretKey: meta.hcaptchaSecretKey,
@@ -140,6 +152,7 @@ const botProtectionForm = useForm({
enableTurnstile: state.provider === 'turnstile', enableTurnstile: state.provider === 'turnstile',
turnstileSiteKey: state.turnstileSiteKey, turnstileSiteKey: state.turnstileSiteKey,
turnstileSecretKey: state.turnstileSecretKey, turnstileSecretKey: state.turnstileSecretKey,
enableTestcaptcha: state.provider === 'testcaptcha',
}); });
fetchInstance(true); fetchInstance(true);
}); });

View File

@@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<MkSwitch v-model="enableRegistration" @change="onChange_enableRegistration"> <MkSwitch v-model="enableRegistration" @change="onChange_enableRegistration">
<template #label>{{ i18n.ts.enableRegistration }}</template> <template #label>{{ i18n.ts.enableRegistration }}</template>
<template #caption>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup"> <MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">
@@ -56,6 +57,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </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> <MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template> <template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.hiddenTags }}</template> <template #label>{{ i18n.ts.hiddenTags }}</template>
@@ -130,6 +143,7 @@ const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false); const emailRequiredForSignup = ref<boolean>(false);
const sensitiveWords = ref<string>(''); const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>(''); const prohibitedWords = ref<string>('');
const prohibitedWordsForNameOfUser = ref<string>('');
const hiddenTags = ref<string>(''); const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>(''); const preservedUsernames = ref<string>('');
const blockedHosts = ref<string>(''); const blockedHosts = ref<string>('');
@@ -142,10 +156,11 @@ async function init() {
emailRequiredForSignup.value = meta.emailRequiredForSignup; emailRequiredForSignup.value = meta.emailRequiredForSignup;
sensitiveWords.value = meta.sensitiveWords.join('\n'); sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n'); prohibitedWords.value = meta.prohibitedWords.join('\n');
prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n'); hiddenTags.value = meta.hiddenTags.join('\n');
preservedUsernames.value = meta.preservedUsernames.join('\n'); preservedUsernames.value = meta.preservedUsernames.join('\n');
blockedHosts.value = meta.blockedHosts.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'); mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
} }
@@ -189,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() { function save_hiddenTags() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
hiddenTags: hiddenTags.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'),

View File

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

View File

@@ -142,13 +142,17 @@ const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.d
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); 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({ const profile = reactive({
name: $i.name, name: $i.name,
description: $i.description, description: $i.description,
followedMessage: $i.followedMessage, followedMessage: $i.followedMessage,
location: $i.location, location: $i.location,
birthday: $i.birthday, birthday: $i.birthday,
lang: $i.lang, lang: assertVaildLang($i.lang) ? $i.lang : null,
isBot: $i.isBot ?? false, isBot: $i.isBot ?? false,
isCat: $i.isCat ?? false, isCat: $i.isCat ?? false,
}); });
@@ -202,6 +206,11 @@ function save() {
lang: profile.lang || null, lang: profile.lang || null,
isBot: !!profile.isBot, isBot: !!profile.isBot,
isCat: !!profile.isCat, isCat: !!profile.isCat,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
title: i18n.ts.yourNameContainsProhibitedWords,
text: i18n.ts.yourNameContainsProhibitedWordsDescription,
},
}); });
globalEvents.emit('requestClearPageCache'); globalEvents.emit('requestClearPageCache');
claimAchievement('profileFilled'); claimAchievement('profileFilled');

View File

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

View File

@@ -17,8 +17,6 @@
--MI-minBottomSpacingMobile: calc(72px + max(12px, env(safe-area-inset-bottom, 0px))); --MI-minBottomSpacingMobile: calc(72px + max(12px, env(safe-area-inset-bottom, 0px)));
--MI-minBottomSpacing: var(--MI-minBottomSpacingMobile); --MI-minBottomSpacing: var(--MI-minBottomSpacingMobile);
//--ad: rgb(255 169 0 / 10%);
@media (max-width: 500px) { @media (max-width: 500px) {
--MI-margin: var(--MI-marginHalf); --MI-margin: var(--MI-marginHalf);
} }

View File

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

View File

@@ -4972,6 +4972,7 @@ export type components = {
recaptchaSiteKey: string | null; recaptchaSiteKey: string | null;
enableTurnstile: boolean; enableTurnstile: boolean;
turnstileSiteKey: string | null; turnstileSiteKey: string | null;
enableTestcaptcha: boolean;
swPublickey: string | null; swPublickey: string | null;
/** @default /assets/ai.png */ /** @default /assets/ai.png */
mascotImageUrl: string; mascotImageUrl: string;
@@ -5047,7 +5048,7 @@ export type components = {
latestSentAt: string | null; latestSentAt: string | null;
latestStatus: number | null; latestStatus: number | null;
name: string; name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string; url: string;
secret: string; secret: string;
}; };
@@ -5102,6 +5103,7 @@ export type operations = {
recaptchaSiteKey: string | null; recaptchaSiteKey: string | null;
enableTurnstile: boolean; enableTurnstile: boolean;
turnstileSiteKey: string | null; turnstileSiteKey: string | null;
enableTestcaptcha: boolean;
swPublickey: string | null; swPublickey: string | null;
/** @default /assets/ai.png */ /** @default /assets/ai.png */
mascotImageUrl: string | null; mascotImageUrl: string | null;
@@ -5122,6 +5124,7 @@ export type operations = {
blockedHosts: string[]; blockedHosts: string[];
sensitiveWords: string[]; sensitiveWords: string[];
prohibitedWords: string[]; prohibitedWords: string[];
prohibitedWordsForNameOfUser: string[];
bannedEmailDomains?: string[]; bannedEmailDomains?: string[];
preservedUsernames: string[]; preservedUsernames: string[];
hcaptchaSecretKey: string | null; hcaptchaSecretKey: string | null;
@@ -5162,6 +5165,7 @@ export type operations = {
truemailAuthKey: string | null; truemailAuthKey: string | null;
enableChartsForRemoteUser: boolean; enableChartsForRemoteUser: boolean;
enableChartsForFederatedInstances: boolean; enableChartsForFederatedInstances: boolean;
enableStatsForFederatedInstances: boolean;
enableServerMachineStats: boolean; enableServerMachineStats: boolean;
enableIdenticonGeneration: boolean; enableIdenticonGeneration: boolean;
manifestJsonOverride: string; manifestJsonOverride: string;
@@ -9459,6 +9463,7 @@ export type operations = {
blockedHosts?: string[] | null; blockedHosts?: string[] | null;
sensitiveWords?: string[] | null; sensitiveWords?: string[] | null;
prohibitedWords?: string[] | null; prohibitedWords?: string[] | null;
prohibitedWordsForNameOfUser?: string[] | null;
themeColor?: string | null; themeColor?: string | null;
mascotImageUrl?: string | null; mascotImageUrl?: string | null;
bannerUrl?: string | null; bannerUrl?: string | null;
@@ -9491,6 +9496,7 @@ export type operations = {
enableTurnstile?: boolean; enableTurnstile?: boolean;
turnstileSiteKey?: string | null; turnstileSiteKey?: string | null;
turnstileSecretKey?: string | null; turnstileSecretKey?: string | null;
enableTestcaptcha?: boolean;
/** @enum {string} */ /** @enum {string} */
sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote'; sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
/** @enum {string} */ /** @enum {string} */
@@ -9542,6 +9548,7 @@ export type operations = {
truemailAuthKey?: string | null; truemailAuthKey?: string | null;
enableChartsForRemoteUser?: boolean; enableChartsForRemoteUser?: boolean;
enableChartsForFederatedInstances?: boolean; enableChartsForFederatedInstances?: boolean;
enableStatsForFederatedInstances?: boolean;
enableServerMachineStats?: boolean; enableServerMachineStats?: boolean;
enableIdenticonGeneration?: boolean; enableIdenticonGeneration?: boolean;
serverRules?: string[]; serverRules?: string[];
@@ -10242,7 +10249,7 @@ export type operations = {
'application/json': { 'application/json': {
isActive: boolean; isActive: boolean;
name: string; name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string; url: string;
secret: string; secret: string;
}; };
@@ -10352,7 +10359,7 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
isActive?: boolean; isActive?: boolean;
on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
}; };
}; };
}; };
@@ -10465,7 +10472,7 @@ export type operations = {
id: string; id: string;
isActive: boolean; isActive: boolean;
name: string; name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string; url: string;
secret: string; secret: string;
}; };
@@ -10524,7 +10531,7 @@ export type operations = {
/** Format: misskey:id */ /** Format: misskey:id */
webhookId: string; webhookId: string;
/** @enum {string} */ /** @enum {string} */
type: 'abuseReport' | 'abuseReportResolved' | 'userCreated'; type: 'abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged';
override?: { override?: {
url?: string; url?: string;
secret?: string; secret?: string;