Merge branch 'develop' into fix-passwordless

This commit is contained in:
かっこかり 2024-10-13 17:18:56 +09:00 committed by GitHub
commit 373d2aeb04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 849 additions and 165 deletions

View file

@ -1,8 +1,20 @@
## 2024.10.1
### Note
- 悪質なユーザからサーバを守る措置の一環として、モデレータ権限を持つユーザの最終アクティブ日時を確認し、
7日間活動していない場合は自動的に招待制へと移行コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。
詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
### Client
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
- 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

8
locales/index.d.ts vendored
View file

@ -5170,6 +5170,10 @@ export interface Locale extends ILocale {
*
*/
"target": string;
/**
* CAPTCHAのテストを目的とした機能です<strong>使</strong>
*/
"testCaptchaWarning": string;
"_abuseUserReport": {
/**
*
@ -5700,6 +5704,10 @@ export interface Locale extends ILocale {
* URLやWebページのURLを指定します
*/
"inquiryUrlDescription": string;
/**
*
*/
"thisSettingWillAutomaticallyOffWhenModeratorsInactive": string;
};
"_accountMigration": {
/**

View file

@ -1288,6 +1288,7 @@ passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証
yourBrowserDoesNotSupportPasskey: "お使いのブラウザはパスキーをサポートしていません。"
messageToFollower: "フォロワーへのメッセージ"
target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
_abuseUserReport:
forward: "転送"
@ -1441,6 +1442,7 @@ _serverSettings:
reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。"
inquiryUrl: "問い合わせ先URL"
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行"

View file

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

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class 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

@ -61,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
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 abuseReport of abuseReports) {
@ -370,7 +373,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 unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
for (const recipient of userRecipients) {

View file

@ -119,5 +119,18 @@ export class CaptchaService {
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
public async update(id: MiEmoji['id'], data: {
public async update(data: (
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
) & {
driveFile?: MiDriveFile;
name?: string;
category?: string | null;
aliases?: string[];
license?: string | null;
isSensitive?: boolean;
localOnly?: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
}, moderator?: MiUser): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
}, moderator?: MiUser): Promise<
null
| 'NO_SUCH_EMOJI'
| '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, {
updatedAt: new Date(),
@ -135,7 +149,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
const packed = await this.emojiEntityService.packDetailed(emoji.id);
if (emoji.name === data.name) {
if (!doNameUpdate) {
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: [packed],
});
@ -157,6 +171,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
after: updated,
});
}
return null;
}
@bindThis

View file

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

View file

@ -101,6 +101,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable()
export class RoleService implements OnApplicationShutdown, OnModuleInit {
private rootUserIdCache: MemorySingleCache<MiUser['id']>;
private rolesCache: MemorySingleCache<MiRole[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
private notificationService: NotificationService;
@ -136,6 +137,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService,
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.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
@ -423,22 +425,35 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return check.isExplorable;
}
/**
* ID一覧を取得する.
*
* @param opts.includeAdmins (デフォルト: true)
* @param opts.includeRoot rootユーザも含めるか(デフォルト: false)
* @param opts.excludeExpire (デフォルト: false)
*/
@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 moderatorRoles = includeAdmins
? roles.filter(r => r.isModerator || r.isAdministrator)
: roles.filter(r => r.isModerator);
// TODO: isRootなアカウントも含める
const assigns = moderatorRoles.length > 0
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
: [];
const now = Date.now();
const result = [
// Setを経由して重複を除去ユーザIDは重複する可能性があるので
...new Set(
const now = Date.now();
const resultSet = new Set(
assigns
.filter(it =>
(excludeExpire)
@ -446,19 +461,35 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
: true,
)
.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
public async getModerators(includeAdmins = true): Promise<MiUser[]> {
const ids = await this.getModeratorIds(includeAdmins);
const users = ids.length > 0 ? await this.usersRepository.findBy({
public async getModerators(opts?: {
includeAdmins?: boolean,
includeRoot?: boolean,
excludeExpire?: boolean,
}): Promise<MiUser[]> {
const ids = await this.getModeratorIds(opts);
return ids.length > 0
? await this.usersRepository.findBy({
id: In(ids),
}) : [];
return users;
})
: [];
}
@bindThis

View file

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

View file

@ -258,6 +258,11 @@ export class MiMeta {
})
public turnstileSecretKey: string | null;
@Column('boolean', {
default: false,
})
public enableTestcaptcha: boolean;
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
@Column('enum', {

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@ -66,7 +67,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string {
// onActiveとかonCompletedのattemptsMadeがなぜか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}`;
}
@ -120,24 +121,35 @@ export class QueueProcessorService implements OnApplicationShutdown {
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService,
) {
this.logger = this.queueLoggerService.logger;
function renderError(e: Error): any {
if (e) { // 何故かeがundefinedで来ることがある
function renderError(e?: Error) {
// 何故かeがundefinedで来ることがある
if (!e) return '?';
if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') {
return `${e.name}: ${e.message}`;
}
return {
stack: e.stack,
message: e.message,
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
@ -150,6 +162,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
case 'clean': return this.cleanProcessorService.process();
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('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.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) {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion
@ -229,15 +242,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.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) {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion
@ -269,15 +282,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.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('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) {
Sentry.captureMessage(`Queue: Deliver: ${err.message}`, {
Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion
@ -309,15 +322,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.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) {
Sentry.captureMessage(`Queue: Inbox: ${err.message}`, {
Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion
@ -349,15 +362,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.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('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) {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion
@ -389,15 +402,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.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('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) {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion
@ -436,15 +449,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.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) {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion
@ -477,15 +490,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('active', (job) => logger.debug(`active id=${job.id}`))
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.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) {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
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}`));
}
//#endregion

View file

@ -0,0 +1,127 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { RoleService } from '@/core/RoleService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
// モデレーターが不在と判断する日付の閾値
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
@Injectable()
export class CheckModeratorsActivityProcessorService {
private logger: Logger;
constructor(
private metaService: MetaService,
private roleService: RoleService,
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 { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
if (isModeratorsInactive) {
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
await this.changeToInvitationOnly();
// TODO: モデレータに通知メールMisskey通知
// TODO: SystemWebhook通知
} else {
if (inactivityLimitCountdown <= 2) {
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${inactivityLimitCountdown} days, it will switch to invitation only.`);
// TODO: 警告メール
}
}
}
/**
* 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() {
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 inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
return {
isModeratorsInactive: inactiveModerators.length === moderators.length,
inactiveModerators,
inactivityLimitCountdown,
};
}
@bindThis
private async changeToInvitationOnly() {
await this.metaService.update({ disableRegistration: true });
}
@bindThis
private async fetchModerators() {
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
return this.roleService.getModerators({
includeAdmins: true,
includeRoot: true,
excludeExpire: true,
});
}
}

View file

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

View file

@ -71,6 +71,7 @@ export class SigninApiService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
};
}>,
reply: FastifyReply,
@ -205,6 +206,12 @@ export class SigninApiService {
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) {

View file

@ -67,6 +67,7 @@ export class SignupApiService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
}
}>,
reply: FastifyReply,
@ -99,6 +100,12 @@ export class SignupApiService {
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'];

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.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 { 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);
}
let emojiId;
if (ps.id) {
emojiId = ps.id;
const emoji = await this.customEmojiService.getEmojiById(ps.id);
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;
}
// JSON schemeのanyOfの型変換がうまくいっていないらしい
const required = { id: ps.id, name: ps.name } as
| { id: MiEmoji['id']; name?: string }
| { id?: MiEmoji['id']; name: string };
await this.customEmojiService.update(emojiId, {
const error = await this.customEmojiService.update({
...required,
driveFile,
name: ps.name,
category: ps.category,
aliases: ps.aliases,
license: ps.license,
@ -104,6 +93,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
}, 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',
optional: false, nullable: true,
},
enableTestcaptcha: {
type: 'boolean',
optional: false, nullable: false,
},
swPublickey: {
type: 'string',
optional: false, nullable: true,
@ -555,6 +559,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl,

View file

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

View file

@ -78,6 +78,7 @@ export const paramDef = {
enableTurnstile: { type: 'boolean' },
turnstileSiteKey: { type: 'string', nullable: true },
turnstileSecretKey: { type: 'string', nullable: true },
enableTestcaptcha: { type: 'boolean' },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' },
@ -357,6 +358,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.turnstileSecretKey = ps.turnstileSecretKey;
}
if (ps.enableTestcaptcha !== undefined) {
set.enableTestcaptcha = ps.enableTestcaptcha;
}
if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
}

View file

@ -10,6 +10,8 @@ import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
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 { RoleService } from '@/core/RoleService.js';
import {
@ -31,8 +33,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js';
import { NotificationService } from '@/core/NotificationService.js';
import { RoleCondFormulaValue } from '@/models/Role.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);
@ -277,9 +277,9 @@ describe('RoleService', () => {
});
describe('getModeratorIds', () => {
test('includeAdmins = false, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
test('includeAdmins = false, includeRoot = false, 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 });
@ -295,13 +295,17 @@ describe('RoleService', () => {
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]);
});
test('includeAdmins = false, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
test('includeAdmins = false, includeRoot = false, excludeExpire = true', 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 });
@ -317,13 +321,17 @@ describe('RoleService', () => {
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]);
});
test('includeAdmins = true, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
test('includeAdmins = true, includeRoot = false, 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 });
@ -339,13 +347,17 @@ describe('RoleService', () => {
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]);
});
test('includeAdmins = true, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
test('includeAdmins = true, includeRoot = false, excludeExpire = true', 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 });
@ -361,9 +373,111 @@ describe('RoleService', () => {
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]);
});
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', () => {

View file

@ -0,0 +1,235 @@
/*
* 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 { MiUser, 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';
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>;
// --------------------------------------------------------------------------------------
async function createUser(data: Partial<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,
});
return user;
}
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: 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>;
app.enableShutdownHooks();
});
beforeEach(async () => {
clock = lolex.install({
now: new Date(baseDate),
shouldClearNativeTimers: true,
});
});
afterEach(async () => {
clock.uninstall();
await usersRepository.delete({});
await userProfilesRepository.delete({});
roleService.getModerators.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('[countdown] 猶予まで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.inactivityLimitCountdown).toBe(1);
});
test('[countdown] 猶予まで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.inactivityLimitCountdown).toBe(1);
});
test('[countdown] 猶予まで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.inactivityLimitCountdown).toBe(0);
});
test('[countdown] 期限ちょうどの場合、猶予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.inactivityLimitCountdown).toBe(0);
});
test('[countdown] 期限より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.inactivityLimitCountdown).toBe(-1);
});
});
});

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 ref="captchaEl"></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>
</template>
@ -29,7 +40,7 @@ export type Captcha = {
getResponse(id: string): string;
};
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha';
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha';
type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha;
@ -54,12 +65,16 @@ const available = ref(false);
const captchaEl = shallowRef<HTMLDivElement | undefined>();
const testcaptchaInput = ref('');
const testcaptchaPassed = ref(false);
const variable = computed(() => {
switch (props.provider) {
case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha';
case 'turnstile': return 'turnstile';
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 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
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);
if (loaded || props.provider === 'mcaptcha') {
if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
available.value = true;
} else if (src.value !== null) {
(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
@ -91,6 +107,8 @@ if (loaded || props.provider === 'mcaptcha') {
function reset() {
if (captcha.value.reset) captcha.value.reset();
testcaptchaPassed.value = false;
testcaptchaInput.value = '';
}
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(() => {
if (available.value) {
window.addEventListener('message', onReceivedMessage);

View file

@ -100,10 +100,12 @@ export default defineComponent({
return [el, separator];
} else {
if (props.ad && item._shouldInsertAd_) {
return [h(MkAd, {
return [h('div', {
key: item.id + ':ad',
style: 'padding: 8px; background-size: auto auto; background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px );',
}, [h(MkAd, {
prefer: ['horizontal', 'horizontal-big'],
}), el];
})]), el];
} else {
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.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.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
</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>
@ -44,6 +45,7 @@ export type PwResponse = {
mCaptchaResponse: string | null;
reCaptchaResponse: string | null;
turnstileResponse: string | null;
testcaptchaResponse: string | null;
};
};
</script>
@ -75,18 +77,21 @@ const hCaptcha = useTemplateRef('hcaptcha');
const mCaptcha = useTemplateRef('mcaptcha');
const reCaptcha = useTemplateRef('recaptcha');
const turnstile = useTemplateRef('turnstile');
const testcaptcha = useTemplateRef('testcaptcha');
const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null);
const testcaptchaResponse = ref<string | null>(null);
const captchaFailed = computed((): boolean => {
return (
(instance.enableHcaptcha && !hCaptchaResponse.value) ||
(instance.enableMcaptcha && !mCaptchaResponse.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,
reCaptchaResponse: reCaptchaResponse.value,
turnstileResponse: turnstileResponse.value,
testcaptchaResponse: testcaptchaResponse.value,
},
});
}
@ -113,6 +119,7 @@ function resetCaptcha() {
mCaptcha.value?.reset();
reCaptcha.value?.reset();
turnstile.value?.reset();
testcaptcha.value?.reset();
}
defineExpose({

View file

@ -68,6 +68,8 @@ import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'
import * as Misskey from 'misskey-js';
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 { showSuspendedDialog } from '@/scripts/show-suspended-dialog.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 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<{
(ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void;
}>();
@ -189,6 +188,7 @@ async function onPasswordSubmitted(pw: PwResponse) {
'm-captcha-response': pw.captcha.mCaptchaResponse,
'g-recaptcha-response': pw.captcha.reCaptchaResponse,
'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.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.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;">
<template v-if="submitting">
<MkLoading :em="true" :colored="false"/>
@ -108,6 +109,7 @@ const hcaptcha = ref<Captcha | undefined>();
const mcaptcha = ref<Captcha | undefined>();
const recaptcha = ref<Captcha | undefined>();
const turnstile = ref<Captcha | undefined>();
const testcaptcha = ref<Captcha | undefined>();
const username = ref<string>('');
const password = ref<string>('');
@ -123,6 +125,7 @@ const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null);
const testcaptchaResponse = ref<string | null>(null);
const usernameAbortController = ref<null | AbortController>(null);
const emailAbortController = ref<null | AbortController>(null);
@ -132,6 +135,7 @@ const shouldDisableSubmitting = computed((): boolean => {
instance.enableMcaptcha && !mCaptchaResponse.value ||
instance.enableRecaptcha && !reCaptchaResponse.value ||
instance.enableTurnstile && !turnstileResponse.value ||
instance.enableTestcaptcha && !testcaptchaResponse.value ||
instance.emailRequiredForSignup && emailState.value !== 'ok' ||
usernameState.value !== 'ok' ||
passwordRetypeState.value !== 'match';
@ -259,6 +263,7 @@ async function onSubmit(): Promise<void> {
'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value,
'turnstile-response': turnstileResponse.value,
'testcaptcha-response': testcaptchaResponse.value,
};
const res = await fetch(`${config.apiUrl}/signup`, {
@ -301,6 +306,7 @@ function onSignupApiError() {
mcaptcha.value?.reset?.();
recaptcha.value?.reset?.();
turnstile.value?.reset?.();
testcaptcha.value?.reset?.();
os.alert({
type: 'error',

View file

@ -54,9 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import contains from '@/scripts/contains.js';
import * as os from '@/os.js';
import type { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
@ -484,6 +484,10 @@ defineExpose({
}
.root {
// universal.vue--MI-stickyBottom
--MI-stickyTop: 0;
--MI-stickyBottom: 0;
position: fixed;
top: 0;
left: 0;

View file

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

View file

@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl">
<div ref="headerEl">
<div>
<div ref="headerEl" :class="$style.header">
<slot name="header"></slot>
</div>
<div
ref="bodyEl"
:class="$style.body"
:data-sticky-container-header-height="headerHeight"
:data-sticky-container-footer-height="footerHeight"
style="position: relative; z-index: 0;"
>
<slot></slot>
</div>
<div ref="footerEl">
<div ref="footerEl" :class="$style.footer">
<slot name="footer"></slot>
</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';
const rootEl = shallowRef<HTMLElement>();
const headerEl = shallowRef<HTMLElement>();
const footerEl = shallowRef<HTMLElement>();
const bodyEl = shallowRef<HTMLElement>();
const headerHeight = ref<string | undefined>();
const childStickyTop = ref(0);
@ -67,31 +64,11 @@ onMounted(() => {
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) {
headerEl.value.style.position = 'sticky';
headerEl.value.style.top = 'var(--MI-stickyTop, 0)';
headerEl.value.style.zIndex = '1';
observer.observe(headerEl.value);
}
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);
}
});
@ -99,8 +76,25 @@ onMounted(() => {
onUnmounted(() => {
observer.disconnect();
});
defineExpose({
rootEl: rootEl,
});
</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

@ -266,6 +266,9 @@ const patronsWithIcon = [{
}, {
name: 'なっかあ',
icon: 'https://assets.misskey-hub.net/patrons/c2f5f3e394e74a64912284a2f4ca710e.jpg',
}, {
name: '如月ユカ',
icon: 'https://assets.misskey-hub.net/patrons/f24a042076a041b6811a2f124eb620ca.jpg',
}];
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 === 'recaptcha'" #suffix>reCAPTCHA</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-if="botProtectionForm.modified.value" #footer>
<MkFormFooter :form="botProtectionForm"/>
@ -23,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
<option value="testcaptcha">testCaptcha</option>
</MkRadios>
<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'"/>
</FormSlot>
</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>
</MkFolder>
</template>
@ -101,6 +110,7 @@ import { i18n } from '@/i18n.js';
import { useForm } from '@/scripts/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
@ -115,6 +125,8 @@ const botProtectionForm = useForm({
? 'turnstile'
: meta.enableMcaptcha
? 'mcaptcha'
: meta.enableTestcaptcha
? 'testcaptcha'
: null,
hcaptchaSiteKey: meta.hcaptchaSiteKey,
hcaptchaSecretKey: meta.hcaptchaSecretKey,
@ -140,6 +152,7 @@ const botProtectionForm = useForm({
enableTurnstile: state.provider === 'turnstile',
turnstileSiteKey: state.turnstileSiteKey,
turnstileSecretKey: state.turnstileSecretKey,
enableTestcaptcha: state.provider === 'testcaptcha',
});
fetchInstance(true);
});

View file

@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<MkSwitch v-model="enableRegistration" @change="onChange_enableRegistration">
<template #label>{{ i18n.ts.enableRegistration }}</template>
<template #caption>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</template>
</MkSwitch>
<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">

View file

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

View file

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

View file

@ -4972,6 +4972,7 @@ export type components = {
recaptchaSiteKey: string | null;
enableTurnstile: boolean;
turnstileSiteKey: string | null;
enableTestcaptcha: boolean;
swPublickey: string | null;
/** @default /assets/ai.png */
mascotImageUrl: string;
@ -5102,6 +5103,7 @@ export type operations = {
recaptchaSiteKey: string | null;
enableTurnstile: boolean;
turnstileSiteKey: string | null;
enableTestcaptcha: boolean;
swPublickey: string | null;
/** @default /assets/ai.png */
mascotImageUrl: string | null;
@ -9491,6 +9493,7 @@ export type operations = {
enableTurnstile?: boolean;
turnstileSiteKey?: string | null;
turnstileSecretKey?: string | null;
enableTestcaptcha?: boolean;
/** @enum {string} */
sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
/** @enum {string} */