Merge tag '2024.10.1' into feature/2024.10
This commit is contained in:
commit
f079edaf3c
454 changed files with 9728 additions and 3363 deletions
|
|
@ -65,6 +65,8 @@ type Source = {
|
|||
|
||||
publishTarballInsteadOfProvideRepositoryUrl?: boolean;
|
||||
|
||||
setupPassword?: string;
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
|
@ -179,6 +181,7 @@ export type Config = {
|
|||
|
||||
version: string;
|
||||
publishTarballInsteadOfProvideRepositoryUrl: boolean;
|
||||
setupPassword: string | undefined;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
|
|
@ -280,6 +283,7 @@ export function loadConfig(): Config {
|
|||
return {
|
||||
version,
|
||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
setupPassword: config.setupPassword,
|
||||
url: url.origin,
|
||||
port: config.port ?? parseInt(process.env.PORT ?? '3000', 10),
|
||||
socket: config.socket,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { RoleService } from '@/core/RoleService.js';
|
|||
import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { IdService } from './IdService.js';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -42,6 +43,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
private emailService: EmailService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
|
@ -59,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) {
|
||||
|
|
@ -135,6 +140,26 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
return;
|
||||
}
|
||||
|
||||
const usersMap = await this.userEntityService.packMany(
|
||||
[
|
||||
...new Set([
|
||||
...abuseReports.map(it => it.reporter ?? it.reporterId),
|
||||
...abuseReports.map(it => it.targetUser ?? it.targetUserId),
|
||||
...abuseReports.map(it => it.assignee ?? it.assigneeId),
|
||||
].filter(x => x != null)),
|
||||
],
|
||||
null,
|
||||
{ schema: 'UserLite' },
|
||||
).then(it => new Map(it.map(it => [it.id, it])));
|
||||
const convertedReports = abuseReports.map(it => {
|
||||
return {
|
||||
...it,
|
||||
reporter: usersMap.get(it.reporterId),
|
||||
targetUser: usersMap.get(it.targetUserId),
|
||||
assignee: it.assigneeId ? usersMap.get(it.assigneeId) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const recipientWebhookIds = await this.fetchWebhookRecipients()
|
||||
.then(it => it
|
||||
.filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook')
|
||||
|
|
@ -142,7 +167,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.filter(x => x != null));
|
||||
for (const webhookId of recipientWebhookIds) {
|
||||
await Promise.all(
|
||||
abuseReports.map(it => {
|
||||
convertedReports.map(it => {
|
||||
return this.systemWebhookService.enqueueSystemWebhook(
|
||||
webhookId,
|
||||
type,
|
||||
|
|
@ -263,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.log(updater, 'createAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: created,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
|
@ -302,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
recipientId: params.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
|
|
@ -324,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.log(updater, 'deleteAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: entity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -348,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 unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
|
||||
for (const recipient of userRecipients) {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ export class AbuseReportService {
|
|||
constructor(
|
||||
@Inject(DI.abuseUserReportsRepository)
|
||||
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private abuseReportNotificationService: AbuseReportNotificationService,
|
||||
private queueService: QueueService,
|
||||
|
|
@ -77,62 +79,98 @@ export class AbuseReportService {
|
|||
* - SystemWebhook
|
||||
*
|
||||
* @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える
|
||||
* @param operator 通報を処理したユーザ
|
||||
* @param moderator 通報を処理したユーザ
|
||||
* @see AbuseReportNotificationService.notify
|
||||
*/
|
||||
@bindThis
|
||||
public async resolve(
|
||||
params: {
|
||||
reportId: string;
|
||||
forward: boolean;
|
||||
resolvedAs: MiAbuseUserReport['resolvedAs'];
|
||||
}[],
|
||||
operator: MiUser,
|
||||
moderator: MiUser,
|
||||
) {
|
||||
const paramsMap = new Map(params.map(it => [it.reportId, it]));
|
||||
const reports = await this.abuseUserReportsRepository.findBy({
|
||||
id: In(params.map(it => it.reportId)),
|
||||
});
|
||||
|
||||
const targetUserMap = new Map();
|
||||
for (const report of reports) {
|
||||
const shouldForward = paramsMap.get(report.id)!.forward;
|
||||
|
||||
if (shouldForward && report.targetUserHost != null) {
|
||||
targetUserMap.set(report.id, await this.usersRepository.findOneByOrFail({ id: report.targetUserId }));
|
||||
} else {
|
||||
targetUserMap.set(report.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
for (const report of reports) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const ps = paramsMap.get(report.id)!;
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
resolved: true,
|
||||
assigneeId: operator.id,
|
||||
forwarded: ps.forward && report.targetUserHost !== null,
|
||||
assigneeId: moderator.id,
|
||||
resolvedAs: ps.resolvedAs,
|
||||
});
|
||||
|
||||
const targetUser = targetUserMap.get(report.id)!;
|
||||
if (targetUser != null) {
|
||||
const actor = await this.instanceActorService.getInstanceActor();
|
||||
|
||||
// eslint-disable-next-line
|
||||
const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
|
||||
const contextAssignedFlag = this.apRendererService.addContext(flag);
|
||||
this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false);
|
||||
}
|
||||
|
||||
this.moderationLogService
|
||||
.log(operator, 'resolveAbuseReport', {
|
||||
.log(moderator, 'resolveAbuseReport', {
|
||||
reportId: report.id,
|
||||
report: report,
|
||||
forwarded: ps.forward && report.targetUserHost !== null,
|
||||
resolvedAs: ps.resolvedAs,
|
||||
});
|
||||
}
|
||||
|
||||
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
|
||||
.then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved'));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async forward(
|
||||
reportId: MiAbuseUserReport['id'],
|
||||
moderator: MiUser,
|
||||
) {
|
||||
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
|
||||
|
||||
if (report.targetUserHost == null) {
|
||||
throw new Error('The target user host is null.');
|
||||
}
|
||||
|
||||
if (report.forwarded) {
|
||||
throw new Error('The report has already been forwarded.');
|
||||
}
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
forwarded: true,
|
||||
});
|
||||
|
||||
const actor = await this.instanceActorService.getInstanceActor();
|
||||
const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId });
|
||||
|
||||
const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
|
||||
const contextAssignedFlag = this.apRendererService.addContext(flag);
|
||||
this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false);
|
||||
|
||||
this.moderationLogService
|
||||
.log(moderator, 'forwardAbuseReport', {
|
||||
reportId: report.id,
|
||||
report: report,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(
|
||||
reportId: MiAbuseUserReport['id'],
|
||||
params: {
|
||||
moderationNote?: MiAbuseUserReport['moderationNote'];
|
||||
},
|
||||
moderator: MiUser,
|
||||
) {
|
||||
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
moderationNote: params.moderationNote,
|
||||
});
|
||||
|
||||
if (params.moderationNote != null && report.moderationNote !== params.moderationNote) {
|
||||
this.moderationLogService.log(moderator, 'updateAbuseReportNote', {
|
||||
reportId: report.id,
|
||||
report: report,
|
||||
before: report.moderationNote,
|
||||
after: params.moderationNote,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,13 +274,15 @@ export class AccountMoveService {
|
|||
}
|
||||
|
||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: expensive?
|
||||
|
|
|
|||
|
|
@ -209,6 +209,13 @@ export class AnnouncementService {
|
|||
return;
|
||||
}
|
||||
|
||||
const announcement = await this.announcementsRepository.findOneBy({ id: announcementId });
|
||||
if (announcement != null && announcement.userId === user.id) {
|
||||
await this.announcementsRepository.update(announcementId, {
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
if ((await this.getUnreadAnnouncements(user)).length === 0) {
|
||||
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,5 +149,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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe
|
|||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserSearchService } from '@/core/UserSearchService.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { FlashService } from '@/core/FlashService.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AnnouncementService } from './AnnouncementService.js';
|
||||
|
|
@ -220,6 +221,7 @@ const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useEx
|
|||
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
|
||||
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||
const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService };
|
||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
||||
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||
|
|
@ -373,6 +375,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
|
|
@ -522,6 +525,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$WebhookTestService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$FlashService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
|
|
@ -672,6 +676,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
|
|
|
|||
|
|
@ -112,19 +112,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(),
|
||||
|
|
@ -151,7 +165,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],
|
||||
});
|
||||
|
|
@ -173,6 +187,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
after: updated,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance> {
|
||||
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
|
|
@ -85,6 +85,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance | null> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
if (index == null) {
|
||||
this.federatedInstanceCache.set(host, null);
|
||||
return null;
|
||||
} else {
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
|
||||
const result = await this.instancesRepository.createQueryBuilder().update()
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
|
|||
|
||||
try {
|
||||
if (!force) {
|
||||
const _instance = await this.federatedInstanceService.fetch(host);
|
||||
const _instance = await this.federatedInstanceService.fetchOrRegister(host);
|
||||
const now = Date.now();
|
||||
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
|
||||
// unlock at the finally caluse
|
||||
|
|
|
|||
40
packages/backend/src/core/FlashService.ts
Normal file
40
packages/backend/src/core/FlashService.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { type FlashsRepository } from '@/models/_.js';
|
||||
|
||||
/**
|
||||
* MisskeyPlay関係のService
|
||||
*/
|
||||
@Injectable()
|
||||
export class FlashService {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashRepository: FlashsRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 人気のあるPlay一覧を取得する.
|
||||
*/
|
||||
public async featured(opts?: { offset?: number, limit: number }) {
|
||||
const builder = this.flashRepository.createQueryBuilder('flash')
|
||||
.andWhere('flash.likedCount > 0')
|
||||
.andWhere('flash.visibility = :visibility', { visibility: 'public' })
|
||||
.addOrderBy('flash.likedCount', 'DESC')
|
||||
.addOrderBy('flash.updatedAt', 'DESC')
|
||||
.addOrderBy('flash.id', 'DESC');
|
||||
|
||||
if (opts?.offset) {
|
||||
builder.skip(opts.offset);
|
||||
}
|
||||
|
||||
builder.take(opts?.limit ?? 10);
|
||||
|
||||
return await builder.getMany();
|
||||
}
|
||||
}
|
||||
|
|
@ -222,7 +222,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
private cacheService: CacheService,
|
||||
private latestNoteService: LatestNoteService,
|
||||
) {
|
||||
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -563,17 +563,17 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// Register host
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
if (note.renote && note.text) {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
} else if (!note.renote) {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
}
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
if (note.renote && note.text || !note.renote) {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
}
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ハッシュタグ更新
|
||||
|
|
|
|||
|
|
@ -115,25 +115,22 @@ export class NoteDeleteService {
|
|||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (note.renoteId && note.text) {
|
||||
// Decrement notes count (user)
|
||||
this.decNotesCountOfUser(user);
|
||||
} else if (!note.renoteId) {
|
||||
if (note.renoteId && note.text || !note.renoteId) {
|
||||
// Decrement notes count (user)
|
||||
this.decNotesCountOfUser(user);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
if (note.renoteId && note.text) {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
} else if (!note.renoteId) {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
}
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
if (note.renoteId && note.text || !note.renoteId) {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
}
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@ export class QueueService {
|
|||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkModeratorsActivity', {
|
||||
}, {
|
||||
// 毎時30分に起動
|
||||
repeat: { pattern: '30 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -105,6 +105,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;
|
||||
|
|
@ -140,6 +141,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
|
||||
|
||||
|
|
@ -422,49 +424,78 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@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;
|
||||
const check = await this.rolesRepository.findOneBy({ id: role.id });
|
||||
if (check == null) return false;
|
||||
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)) })
|
||||
: [];
|
||||
|
||||
// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
|
||||
const now = Date.now();
|
||||
const result = [
|
||||
// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
|
||||
...new Set(
|
||||
assigns
|
||||
.filter(it =>
|
||||
(excludeExpire)
|
||||
? (it.expiresAt == null || it.expiresAt.getTime() > now)
|
||||
: true,
|
||||
)
|
||||
.map(a => a.userId),
|
||||
),
|
||||
];
|
||||
const resultSet = new Set(
|
||||
assigns
|
||||
.filter(it =>
|
||||
(excludeExpire)
|
||||
? (it.expiresAt == null || it.expiresAt.getTime() > now)
|
||||
: 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({
|
||||
id: In(ids),
|
||||
}) : [];
|
||||
return users;
|
||||
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),
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -155,8 +155,8 @@ export class SignupService {
|
|||
}));
|
||||
});
|
||||
|
||||
this.usersChart.update(account, true).then();
|
||||
this.userService.notifySystemWebhook(account, 'userCreated').then();
|
||||
this.usersChart.update(account, true);
|
||||
this.userService.notifySystemWebhook(account, 'userCreated');
|
||||
|
||||
return { account, secret };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
.log(updater, 'createSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook: webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return webhook;
|
||||
}
|
||||
|
|
@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
systemWebhookId: beforeEntity.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
|
|
@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
.log(updater, 'deleteSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,18 @@ import { Packed } from '@/misc/json-schema.js';
|
|||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport {
|
||||
return {
|
||||
type AbuseUserReportDto = Omit<MiAbuseUserReport, 'targetUser' | 'reporter' | 'assignee'> & {
|
||||
targetUser: Packed<'UserLite'> | null,
|
||||
reporter: Packed<'UserLite'> | null,
|
||||
assignee: Packed<'UserLite'> | null,
|
||||
};
|
||||
|
||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): AbuseUserReportDto {
|
||||
const result: MiAbuseUserReport = {
|
||||
id: 'dummy-abuse-report1',
|
||||
targetUserId: 'dummy-target-user',
|
||||
targetUser: null,
|
||||
|
|
@ -29,8 +36,17 @@ function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUser
|
|||
comment: 'This is a dummy report for testing purposes.',
|
||||
targetUserHost: null,
|
||||
reporterHost: null,
|
||||
resolvedAs: null,
|
||||
moderationNote: 'foo',
|
||||
...override,
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
targetUser: result.targetUser ? toPackedUserLite(result.targetUser) : null,
|
||||
reporter: result.reporter ? toPackedUserLite(result.reporter) : null,
|
||||
assignee: result.assignee ? toPackedUserLite(result.assignee) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
|
|
@ -287,7 +303,8 @@ const dummyUser3 = generateDummyUser({
|
|||
|
||||
@Injectable()
|
||||
export class WebhookTestService {
|
||||
public static NoSuchWebhookError = class extends Error {};
|
||||
public static NoSuchWebhookError = class extends Error {
|
||||
};
|
||||
|
||||
constructor(
|
||||
private userWebhookService: UserWebhookService,
|
||||
|
|
@ -449,6 +466,22 @@ export class WebhookTestService {
|
|||
send(toPackedUserLite(dummyUser1));
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsWarning': {
|
||||
const dummyTime: ModeratorInactivityRemainingTime = {
|
||||
time: 100000,
|
||||
asDays: 1,
|
||||
asHours: 24,
|
||||
};
|
||||
|
||||
send({
|
||||
remainingTime: dummyTime,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsInvitationOnlyChanged': {
|
||||
send({});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -434,13 +434,15 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||
|
||||
// Register host
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
}
|
||||
|
||||
this.usersChart.update(user, true);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export class AbuseUserReportEntityService {
|
|||
schema: 'UserDetailedNotMe',
|
||||
}) : null,
|
||||
forwarded: report.forwarded,
|
||||
resolvedAs: report.resolvedAs,
|
||||
moderationNote: report.moderationNote,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { FlashLikesRepository, FlashsRepository } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiFlash } from '@/models/Flash.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
|
@ -20,10 +18,8 @@ export class FlashEntityService {
|
|||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
|
|
@ -34,25 +30,36 @@ export class FlashEntityService {
|
|||
src: MiFlash['id'] | MiFlash,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
packedUser?: Packed<'UserLite'>,
|
||||
likedFlashIds?: MiFlash['id'][],
|
||||
},
|
||||
): Promise<Packed<'Flash'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
// { schema: 'UserDetailed' } すると無限ループするので注意
|
||||
const user = hint?.packedUser ?? await this.userEntityService.pack(flash.user ?? flash.userId, me);
|
||||
|
||||
let isLiked = undefined;
|
||||
if (meId) {
|
||||
isLiked = hint?.likedFlashIds
|
||||
? hint.likedFlashIds.includes(flash.id)
|
||||
: await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } });
|
||||
}
|
||||
|
||||
return {
|
||||
id: flash.id,
|
||||
createdAt: this.idService.parse(flash.id).date.toISOString(),
|
||||
updatedAt: flash.updatedAt.toISOString(),
|
||||
userId: flash.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意
|
||||
user: user,
|
||||
title: flash.title,
|
||||
summary: flash.summary,
|
||||
script: flash.script,
|
||||
visibility: flash.visibility,
|
||||
likedCount: flash.likedCount,
|
||||
isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined,
|
||||
});
|
||||
isLiked: isLiked,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -63,7 +70,19 @@ export class FlashEntityService {
|
|||
const _users = flashes.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) })));
|
||||
const _likedFlashIds = me
|
||||
? await this.flashLikesRepository.createQueryBuilder('flashLike')
|
||||
.select('flashLike.flashId')
|
||||
.where('flashLike.userId = :userId', { userId: me.id })
|
||||
.getRawMany<{ flashLike_flashId: string }>()
|
||||
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
|
||||
: [];
|
||||
return Promise.all(
|
||||
flashes.map(flash => this.pack(flash, me, {
|
||||
packedUser: _userMap.get(flash.userId),
|
||||
likedFlashIds: _likedFlashIds,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export class MetaEntityService {
|
|||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableFC: instance.enableFC,
|
||||
fcSiteKey: instance.fcSiteKey,
|
||||
enableTestcaptcha: instance.enableTestcaptcha,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,30 @@ import type { UserEntityService } from './UserEntityService.js';
|
|||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
||||
// is-renote.tsとよしなにリンク
|
||||
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
|
||||
return (
|
||||
note.renote != null &&
|
||||
note.reply == null &&
|
||||
note.text == null &&
|
||||
note.cw == null &&
|
||||
(note.fileIds == null || note.fileIds.length === 0) &&
|
||||
!note.hasPoll
|
||||
);
|
||||
}
|
||||
|
||||
function getAppearNoteIds(notes: MiNote[]): Set<string> {
|
||||
const appearNoteIds = new Set<string>();
|
||||
for (const note of notes) {
|
||||
if (isPureRenote(note)) {
|
||||
appearNoteIds.add(note.renoteId);
|
||||
} else {
|
||||
appearNoteIds.add(note.id);
|
||||
}
|
||||
}
|
||||
return appearNoteIds;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
|
|
@ -94,7 +118,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
hide = false;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
|
||||
const specified = packedNote.visibleUserIds!.some(id => meId === id);
|
||||
|
||||
if (specified) {
|
||||
hide = false;
|
||||
|
|
@ -244,7 +268,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
return true;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
return note.visibleUserIds.some((id: any) => meId === id);
|
||||
return note.visibleUserIds.some(id => meId === id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +461,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
) {
|
||||
if (notes.length === 0) return [];
|
||||
|
||||
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null;
|
||||
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const myReactionsMap = new Map<MiNote['id'], string | null>();
|
||||
|
|
@ -448,7 +472,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const oldId = this.idService.gen(Date.now() - 2000);
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
|
||||
if (isPureRenote(note)) {
|
||||
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
|
||||
if (reactionsCount === 0) {
|
||||
myReactionsMap.set(note.renote.id, null);
|
||||
|
|
|
|||
|
|
@ -597,11 +597,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
|
||||
followersVisibility: profile!.followersVisibility,
|
||||
followingVisibility: profile!.followingVisibility,
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
||||
securityKeys: profile!.twoFactorEnabled
|
||||
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
|
||||
: false,
|
||||
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
|
|
@ -616,6 +611,14 @@ export class UserEntityService implements OnModuleInit {
|
|||
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
|
||||
} : {}),
|
||||
|
||||
...(isDetailed && (isMe || iAmModerator) ? {
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
||||
securityKeys: profile!.twoFactorEnabled
|
||||
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
|
||||
: false,
|
||||
} : {}),
|
||||
|
||||
...(isDetailed && isMe ? {
|
||||
avatarId: user.avatarId,
|
||||
bannerId: user.bannerId,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
// NoteEntityService.isPureRenote とよしなにリンク
|
||||
|
||||
type Renote =
|
||||
MiNote & {
|
||||
renoteId: NonNullable<MiNote['renoteId']>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export class MiAbuseUserReport {
|
|||
})
|
||||
public resolved: boolean;
|
||||
|
||||
/**
|
||||
* リモートサーバーに転送したかどうか
|
||||
*/
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
@ -60,6 +63,21 @@ export class MiAbuseUserReport {
|
|||
})
|
||||
public comment: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 8192, default: '',
|
||||
})
|
||||
public moderationNote: string;
|
||||
|
||||
/**
|
||||
* accept 是認 ... 通報内容が正当であり、肯定的に対応された
|
||||
* reject 否認 ... 通報内容が正当でなく、否定的に対応された
|
||||
* null ... その他
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
public resolvedAs: 'accept' | 'reject' | null;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ
|
|||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
export const flashVisibility = ['public', 'private'] as const;
|
||||
export type FlashVisibility = typeof flashVisibility[number];
|
||||
|
||||
@Entity('flash')
|
||||
export class MiFlash {
|
||||
@PrimaryColumn(id())
|
||||
|
|
@ -63,5 +66,5 @@ export class MiFlash {
|
|||
@Column('varchar', {
|
||||
length: 512, default: 'public',
|
||||
})
|
||||
public visibility: 'public' | 'private';
|
||||
public visibility: FlashVisibility;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ export class MiMeta {
|
|||
})
|
||||
public prohibitedWords: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
public prohibitedWordsForNameOfUser: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
|
|
@ -286,6 +291,11 @@ export class MiMeta {
|
|||
})
|
||||
public fcSecretKey: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableTestcaptcha: boolean;
|
||||
|
||||
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
|
||||
|
||||
@Column('enum', {
|
||||
|
|
@ -569,6 +579,11 @@ export class MiMeta {
|
|||
})
|
||||
public enableChartsForFederatedInstances: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public enableStatsForFederatedInstances: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { userExportableEntities } from '@/types.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiNote } from './Note.js';
|
||||
import { MiAccessToken } from './AccessToken.js';
|
||||
import { MiRole } from './Role.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
import { userExportableEntities } from '@/types.js';
|
||||
|
||||
export type MiNotification = {
|
||||
type: 'note';
|
||||
|
|
@ -86,6 +86,10 @@ export type MiNotification = {
|
|||
createdAt: string;
|
||||
exportedEntity: typeof userExportableEntities[number];
|
||||
fileId: MiDriveFile['id'];
|
||||
} | {
|
||||
type: 'login';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
} | {
|
||||
type: 'app';
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
|
|||
'abuseReportResolved',
|
||||
// ユーザが作成された時
|
||||
'userCreated',
|
||||
// モデレータが一定期間不在である警告
|
||||
'inactiveModeratorsWarning',
|
||||
// モデレータが一定期間不在のためシステムにより招待制へと変更された
|
||||
'inactiveModeratorsInvitationOnlyChanged',
|
||||
] as const;
|
||||
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,10 @@ export const packedMetaLiteSchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableTestcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
|
|||
|
|
@ -322,6 +322,16 @@ export const packedNotificationSchema = {
|
|||
format: 'id',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['login'],
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -392,21 +392,6 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
nullable: false, optional: false,
|
||||
enum: ['public', 'followers', 'private'],
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
usePasswordLessLogin: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
securityKeys: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
roles: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
|
|
@ -428,6 +413,18 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
type: 'string',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
usePasswordLessLogin: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
securityKeys: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
//#region relations
|
||||
isFollowing: {
|
||||
type: 'boolean',
|
||||
|
|
@ -689,6 +686,21 @@ export const packedMeDetailedOnlySchema = {
|
|||
nullable: false, optional: false,
|
||||
ref: 'RolePolicies',
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
usePasswordLessLogin: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
securityKeys: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
//#region secrets
|
||||
email: {
|
||||
type: 'string',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -84,6 +85,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
|||
DeliverProcessorService,
|
||||
InboxProcessorService,
|
||||
AggregateRetentionProcessorService,
|
||||
CheckExpiredMutingsProcessorService,
|
||||
CheckModeratorsActivityProcessorService,
|
||||
QueueProcessorService,
|
||||
],
|
||||
exports: [
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -68,7 +69,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}`;
|
||||
}
|
||||
|
|
@ -124,6 +125,7 @@ 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;
|
||||
|
|
@ -164,6 +166,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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -74,8 +74,17 @@ export class DeliverProcessorService {
|
|||
try {
|
||||
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
|
||||
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.apRequestChart.deliverSucc();
|
||||
this.federationChart.deliverd(host, true);
|
||||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(host)
|
||||
: this.federatedInstanceService.fetch(host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
if (i.isNotResponding) {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
isNotResponding: false,
|
||||
|
|
@ -83,9 +92,9 @@ export class DeliverProcessorService {
|
|||
});
|
||||
}
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
this.apRequestChart.deliverSucc();
|
||||
this.federationChart.deliverd(i.host, true);
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
}
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, true);
|
||||
|
|
@ -94,8 +103,11 @@ export class DeliverProcessorService {
|
|||
|
||||
return 'Success';
|
||||
} catch (res) {
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.apRequestChart.deliverFail();
|
||||
this.federationChart.deliverd(host, false);
|
||||
|
||||
// Update instance stats
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
if (!i.isNotResponding) {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
isNotResponding: true,
|
||||
|
|
@ -116,9 +128,6 @@ export class DeliverProcessorService {
|
|||
});
|
||||
}
|
||||
|
||||
this.apRequestChart.deliverFail();
|
||||
this.federationChart.deliverd(i.host, false);
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, false);
|
||||
}
|
||||
|
|
@ -129,7 +138,7 @@ export class DeliverProcessorService {
|
|||
if (!res.isRetryable) {
|
||||
// 相手が閉鎖していることを明示しているため、配送停止する
|
||||
if (job.data.isSharedInbox && res.statusCode === 410) {
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
suspensionState: 'goneSuspended',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('inbox');
|
||||
this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
|
||||
this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -194,21 +194,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
|
||||
this.apRequestChart.inbox();
|
||||
this.federationChart.inbox(authUser.user.host);
|
||||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(authUser.user.host)
|
||||
: this.federatedInstanceService.fetch(authUser.user.host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
this.updateInstanceQueue.enqueue(i.id, {
|
||||
latestRequestReceivedAt: new Date(),
|
||||
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
|
||||
});
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
|
||||
this.apRequestChart.inbox();
|
||||
this.federationChart.inbox(i.host);
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestReceived(i.host);
|
||||
}
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
|
||||
// アクティビティを処理
|
||||
|
|
|
|||
|
|
@ -119,25 +119,30 @@ export class ApiServerService {
|
|||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
}
|
||||
}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
|
||||
|
||||
fastify.post<{
|
||||
Body: {
|
||||
username: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
signature?: string;
|
||||
authenticatorData?: string;
|
||||
clientDataJSON?: string;
|
||||
credentialId?: string;
|
||||
challengeId?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
};
|
||||
}>('/signin', (request, reply) => this.signinApiService.signin(request, reply));
|
||||
}>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));
|
||||
|
||||
fastify.post<{
|
||||
Body: {
|
||||
credential?: AuthenticationResponseJSON;
|
||||
context?: string;
|
||||
};
|
||||
}>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply));
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
|
|||
import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js';
|
||||
import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js';
|
||||
import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js';
|
||||
import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js';
|
||||
import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js';
|
||||
import * as ep___admin_sendEmail from './endpoints/admin/send-email.js';
|
||||
import * as ep___admin_serverInfo from './endpoints/admin/server-info.js';
|
||||
import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js';
|
||||
|
|
@ -470,6 +472,8 @@ const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass
|
|||
const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default };
|
||||
const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default };
|
||||
const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default };
|
||||
const $admin_forwardAbuseUserReport: Provider = { provide: 'ep:admin/forward-abuse-user-report', useClass: ep___admin_forwardAbuseUserReport.default };
|
||||
const $admin_updateAbuseUserReport: Provider = { provide: 'ep:admin/update-abuse-user-report', useClass: ep___admin_updateAbuseUserReport.default };
|
||||
const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default };
|
||||
const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default };
|
||||
const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default };
|
||||
|
|
@ -876,6 +880,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_relays_remove,
|
||||
$admin_resetPassword,
|
||||
$admin_resolveAbuseUserReport,
|
||||
$admin_forwardAbuseUserReport,
|
||||
$admin_updateAbuseUserReport,
|
||||
$admin_sendEmail,
|
||||
$admin_serverInfo,
|
||||
$admin_showModerationLogs,
|
||||
|
|
@ -1276,6 +1282,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_relays_remove,
|
||||
$admin_resetPassword,
|
||||
$admin_resolveAbuseUserReport,
|
||||
$admin_forwardAbuseUserReport,
|
||||
$admin_updateAbuseUserReport,
|
||||
$admin_sendEmail,
|
||||
$admin_serverInfo,
|
||||
$admin_showModerationLogs,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { IsNull } from 'typeorm';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
MiMeta,
|
||||
SigninsRepository,
|
||||
UserProfilesRepository,
|
||||
UserSecurityKeysRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -21,6 +23,8 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||
import { CaptchaService } from '@/core/CaptchaService.js';
|
||||
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||
|
|
@ -43,6 +47,9 @@ export class SigninApiService {
|
|||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
@Inject(DI.signinsRepository)
|
||||
private signinsRepository: SigninsRepository,
|
||||
|
||||
|
|
@ -51,6 +58,7 @@ export class SigninApiService {
|
|||
private signinService: SigninService,
|
||||
private userAuthService: UserAuthService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
private captchaService: CaptchaService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -59,9 +67,15 @@ export class SigninApiService {
|
|||
request: FastifyRequest<{
|
||||
Body: {
|
||||
username: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
|
|
@ -98,11 +112,6 @@ export class SigninApiService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token != null && typeof token !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
|
|
@ -133,6 +142,27 @@ export class SigninApiService {
|
|||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1);
|
||||
|
||||
if (password == null) {
|
||||
reply.code(200);
|
||||
if (profile.twoFactorEnabled) {
|
||||
return {
|
||||
finished: false,
|
||||
next: 'password',
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
} else {
|
||||
return {
|
||||
finished: false,
|
||||
next: 'captcha',
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.approved && this.meta.approvalRequiredForSignup) {
|
||||
reply.code(403);
|
||||
|
|
@ -148,7 +178,7 @@ export class SigninApiService {
|
|||
// Compare password
|
||||
const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!);
|
||||
|
||||
const fail = async (status?: number, failure?: { id: string }) => {
|
||||
const fail = async (status?: number, failure?: { id: string; }) => {
|
||||
// Append signin history
|
||||
await this.signinsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
|
|
@ -162,6 +192,44 @@ export class SigninApiService {
|
|||
};
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
|
||||
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
|
||||
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableFC && this.meta.fcSecretKey) {
|
||||
await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
|
||||
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
|
||||
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(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 (profile.password!.startsWith('$2')) {
|
||||
const newHash = await argon2.hash(password);
|
||||
|
|
@ -220,7 +288,7 @@ export class SigninApiService {
|
|||
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} else if (securityKeysAvailable) {
|
||||
if (!same && !profile.usePasswordLessLogin) {
|
||||
return await fail(403, {
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
|
|
@ -230,7 +298,23 @@ export class SigninApiService {
|
|||
const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
|
||||
|
||||
reply.code(200);
|
||||
return authRequest;
|
||||
return {
|
||||
finished: false,
|
||||
next: 'passkey',
|
||||
authRequest,
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
} else {
|
||||
if (!same || !profile.twoFactorEnabled) {
|
||||
return await fail(403, {
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
});
|
||||
} else {
|
||||
reply.code(200);
|
||||
return {
|
||||
finished: false,
|
||||
next: 'totp',
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
}
|
||||
}
|
||||
// never get here
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { SigninsRepository } from '@/models/_.js';
|
||||
import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { SigninEntityService } from '@/core/entities/SigninEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -19,7 +22,12 @@ export class SigninService {
|
|||
@Inject(DI.signinsRepository)
|
||||
private signinsRepository: SigninsRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private signinEntityService: SigninEntityService,
|
||||
private emailService: EmailService,
|
||||
private notificationService: NotificationService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
|
|
@ -28,7 +36,8 @@ export class SigninService {
|
|||
@bindThis
|
||||
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
|
||||
setImmediate(async () => {
|
||||
// Append signin history
|
||||
this.notificationService.createNotification(user.id, 'login', {});
|
||||
|
||||
const record = await this.signinsRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
userId: user.id,
|
||||
|
|
@ -37,15 +46,22 @@ export class SigninService {
|
|||
success: true,
|
||||
});
|
||||
|
||||
// Publish signin event
|
||||
this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record));
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
if (profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, 'New login / ログインがありました',
|
||||
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。',
|
||||
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。');
|
||||
}
|
||||
});
|
||||
|
||||
reply.code(200);
|
||||
return {
|
||||
finished: true,
|
||||
id: user.id,
|
||||
i: user.token,
|
||||
};
|
||||
i: user.token!,
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export class SignupApiService {
|
|||
'turnstile-response'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
}
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
|
|
@ -111,6 +112,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'];
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
|
|||
import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js';
|
||||
import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js';
|
||||
import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js';
|
||||
import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js';
|
||||
import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js';
|
||||
import * as ep___admin_sendEmail from './endpoints/admin/send-email.js';
|
||||
import * as ep___admin_serverInfo from './endpoints/admin/server-info.js';
|
||||
import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js';
|
||||
|
|
@ -474,6 +476,8 @@ const eps = [
|
|||
['admin/relays/remove', ep___admin_relays_remove],
|
||||
['admin/reset-password', ep___admin_resetPassword],
|
||||
['admin/resolve-abuse-user-report', ep___admin_resolveAbuseUserReport],
|
||||
['admin/forward-abuse-user-report', ep___admin_forwardAbuseUserReport],
|
||||
['admin/update-abuse-user-report', ep___admin_updateAbuseUserReport],
|
||||
['admin/send-email', ep___admin_sendEmail],
|
||||
['admin/server-info', ep___admin_serverInfo],
|
||||
['admin/show-moderation-logs', ep___admin_showModerationLogs],
|
||||
|
|
|
|||
|
|
@ -71,9 +71,22 @@ export const meta = {
|
|||
},
|
||||
assignee: {
|
||||
type: 'object',
|
||||
nullable: true, optional: true,
|
||||
nullable: true, optional: false,
|
||||
ref: 'UserDetailedNotMe',
|
||||
},
|
||||
forwarded: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
resolvedAs: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
enum: ['accept', 'reject', null],
|
||||
},
|
||||
moderationNote: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -88,7 +101,6 @@ export const paramDef = {
|
|||
state: { type: 'string', nullable: true, default: null },
|
||||
reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' },
|
||||
targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' },
|
||||
forwarded: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import { SignupService } from '@/core/SignupService.js';
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
|
@ -17,19 +20,19 @@ import { ApiError } from '@/server/api/error.js';
|
|||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'MeDetailed',
|
||||
properties: {
|
||||
token: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
accessDenied: {
|
||||
message: 'Access denied.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
||||
},
|
||||
|
||||
wrongInitialPassword: {
|
||||
message: 'Initial password is incorrect.',
|
||||
code: 'INCORRECT_INITIAL_PASSWORD',
|
||||
id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62',
|
||||
},
|
||||
|
||||
// From ApiCallService.ts
|
||||
noCredential: {
|
||||
message: 'Credential required.',
|
||||
|
|
@ -51,6 +54,18 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'MeDetailed',
|
||||
properties: {
|
||||
token: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Required token permissions, but we need to check them manually.
|
||||
// ApiCallService checks access in a way that would prevent creating the first account.
|
||||
softPermissions: [
|
||||
|
|
@ -64,6 +79,7 @@ export const paramDef = {
|
|||
properties: {
|
||||
username: localUsernameSchema,
|
||||
password: passwordSchema,
|
||||
setupPassword: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['username', 'password'],
|
||||
} as const;
|
||||
|
|
@ -71,13 +87,49 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private userEntityService: UserEntityService,
|
||||
private signupService: SignupService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, _me, token) => {
|
||||
await this.ensurePermissions(_me, token);
|
||||
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
|
||||
const realUsers = await this.instanceActorService.realLocalUsersPresent();
|
||||
|
||||
if (!realUsers && me == null && token == null) {
|
||||
// 初回セットアップの場合
|
||||
if (this.config.setupPassword != null) {
|
||||
// 初期パスワードが設定されている場合
|
||||
if (ps.setupPassword !== this.config.setupPassword) {
|
||||
// 初期パスワードが違う場合
|
||||
throw new ApiError(meta.errors.wrongInitialPassword);
|
||||
}
|
||||
} else if (ps.setupPassword != null && ps.setupPassword.trim() !== '') {
|
||||
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
|
||||
throw new ApiError(meta.errors.wrongInitialPassword);
|
||||
}
|
||||
} else {
|
||||
if (token && !meta.softPermissions.every(p => token.permission.includes(p))) {
|
||||
// Tokens have scoped permissions which may be *less* than the user's official role, so we need to check.
|
||||
throw new ApiError(meta.errors.noPermission);
|
||||
}
|
||||
|
||||
if (me && !await this.roleService.isAdministrator(me)) {
|
||||
// Only administrators (including root) can create users.
|
||||
throw new ApiError(meta.errors.noAdmin);
|
||||
}
|
||||
|
||||
// Anonymous access is only allowed for initial instance setup (this check may be redundant)
|
||||
if (!me && realUsers) {
|
||||
throw new ApiError(meta.errors.noCredential);
|
||||
}
|
||||
}
|
||||
|
||||
const { account, secret } = await this.signupService.signup({
|
||||
username: ps.username,
|
||||
|
|
@ -96,21 +148,4 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
private async ensurePermissions(me: MiUser | null, token: MiAccessToken | null): Promise<void> {
|
||||
// Tokens have scoped permissions which may be *less* than the user's official role, so we need to check.
|
||||
if (token && !meta.softPermissions.every(p => token.permission.includes(p))) {
|
||||
throw new ApiError(meta.errors.noPermission);
|
||||
}
|
||||
|
||||
// Only administrators (including root) can create users.
|
||||
if (me && !await this.roleService.isAdministrator(me)) {
|
||||
throw new ApiError(meta.errors.noAdmin);
|
||||
}
|
||||
|
||||
// Anonymous access is only allowed for initial instance setup.
|
||||
if (!me && await this.instanceActorService.realLocalUsersPresent()) {
|
||||
throw new ApiError(meta.errors.noCredential);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -79,25 +79,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 (nameNfc && (nameNfc !== emoji.name)) {
|
||||
const isDuplicate = await this.customEmojiService.checkDuplicate(nameNfc);
|
||||
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
|
||||
}
|
||||
} else {
|
||||
if (!nameNfc) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
|
||||
const emoji = await this.customEmojiService.getEmojiByName(nameNfc);
|
||||
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
|
||||
emojiId = emoji.id;
|
||||
}
|
||||
// JSON schemeのanyOfの型変換がうまくいっていないらしい
|
||||
const required = { id: ps.id, name: nameNfc } 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: nameNfc,
|
||||
category: ps.category?.normalize('NFC'),
|
||||
aliases: ps.aliases?.map(a => a.normalize('NFC')),
|
||||
license: ps.license,
|
||||
|
|
@ -105,6 +94,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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { AbuseUserReportsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'write:admin:resolve-abuse-user-report',
|
||||
|
||||
errors: {
|
||||
noSuchAbuseReport: {
|
||||
message: 'No such abuse report.',
|
||||
code: 'NO_SUCH_ABUSE_REPORT',
|
||||
id: '8763e21b-d9bc-40be-acf6-54c1a6986493',
|
||||
kind: 'server',
|
||||
httpStatusCode: 404,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reportId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['reportId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.abuseUserReportsRepository)
|
||||
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
||||
private abuseReportService: AbuseReportService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId });
|
||||
if (!report) {
|
||||
throw new ApiError(meta.errors.noSuchAbuseReport);
|
||||
}
|
||||
|
||||
await this.abuseReportService.forward(report.id, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableTestcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
@ -189,6 +193,13 @@ export const meta = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
prohibitedWordsForNameOfUser: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
bannedEmailDomains: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
|
|
@ -368,6 +379,10 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableStatsForFederatedInstances: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableServerMachineStats: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -614,6 +629,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableFC: instance.enableFC,
|
||||
fcSiteKey: instance.fcSiteKey,
|
||||
enableTestcaptcha: instance.enableTestcaptcha,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl,
|
||||
|
|
@ -642,6 +658,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
mediaSilencedHosts: instance.mediaSilencedHosts,
|
||||
sensitiveWords: instance.sensitiveWords,
|
||||
prohibitedWords: instance.prohibitedWords,
|
||||
prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
|
||||
preservedUsernames: instance.preservedUsernames,
|
||||
bubbleInstances: instance.bubbleInstances,
|
||||
hcaptchaSecretKey: instance.hcaptchaSecretKey,
|
||||
|
|
@ -688,6 +705,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
truemailAuthKey: instance.truemailAuthKey,
|
||||
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
|
||||
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
|
||||
enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
|
||||
enableServerMachineStats: instance.enableServerMachineStats,
|
||||
enableAchievements: instance.enableAchievements,
|
||||
enableIdenticonGeneration: instance.enableIdenticonGeneration,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
reportId: { type: 'string', format: 'misskey:id' },
|
||||
forward: { type: 'boolean', default: false },
|
||||
resolvedAs: { type: 'string', enum: ['accept', 'reject', null], nullable: true },
|
||||
},
|
||||
required: ['reportId'],
|
||||
} as const;
|
||||
|
|
@ -50,7 +50,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchAbuseReport);
|
||||
}
|
||||
|
||||
await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me);
|
||||
await this.abuseReportService.resolve([{ reportId: report.id, resolvedAs: ps.resolvedAs ?? null }], me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,13 +72,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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { AbuseUserReportsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'write:admin:resolve-abuse-user-report',
|
||||
|
||||
errors: {
|
||||
noSuchAbuseReport: {
|
||||
message: 'No such abuse report.',
|
||||
code: 'NO_SUCH_ABUSE_REPORT',
|
||||
id: '15f51cf5-46d1-4b1d-a618-b35bcbed0662',
|
||||
kind: 'server',
|
||||
httpStatusCode: 404,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reportId: { type: 'string', format: 'misskey:id' },
|
||||
moderationNote: { type: 'string' },
|
||||
},
|
||||
required: ['reportId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.abuseUserReportsRepository)
|
||||
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
||||
private abuseReportService: AbuseReportService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId });
|
||||
if (!report) {
|
||||
throw new ApiError(meta.errors.noSuchAbuseReport);
|
||||
}
|
||||
|
||||
await this.abuseReportService.update(report.id, {
|
||||
moderationNote: ps.moderationNote,
|
||||
}, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,11 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
prohibitedWordsForNameOfUser: {
|
||||
type: 'array', nullable: true, items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
|
||||
mascotImageUrl: { type: 'string', nullable: true },
|
||||
bannerUrl: { type: 'string', nullable: true },
|
||||
|
|
@ -84,6 +89,7 @@ export const paramDef = {
|
|||
enableFC: { type: 'boolean' },
|
||||
fcSiteKey: { type: 'string', nullable: true },
|
||||
fcSecretKey: { 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' },
|
||||
|
|
@ -140,6 +146,7 @@ export const paramDef = {
|
|||
truemailAuthKey: { type: 'string', nullable: true },
|
||||
enableChartsForRemoteUser: { type: 'boolean' },
|
||||
enableChartsForFederatedInstances: { type: 'boolean' },
|
||||
enableStatsForFederatedInstances: { type: 'boolean' },
|
||||
enableServerMachineStats: { type: 'boolean' },
|
||||
enableAchievements: { type: 'boolean' },
|
||||
enableIdenticonGeneration: { type: 'boolean' },
|
||||
|
|
@ -230,6 +237,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (Array.isArray(ps.prohibitedWords)) {
|
||||
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
|
||||
set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(ps.silencedHosts)) {
|
||||
let lastValue = '';
|
||||
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
|
||||
|
|
@ -390,6 +400,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.enableFC = ps.enableFC;
|
||||
}
|
||||
|
||||
if (ps.enableTestcaptcha !== undefined) {
|
||||
set.enableTestcaptcha = ps.enableTestcaptcha;
|
||||
}
|
||||
|
||||
if (ps.fcSiteKey !== undefined) {
|
||||
set.fcSiteKey = ps.fcSiteKey;
|
||||
}
|
||||
|
|
@ -610,6 +624,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
|
||||
}
|
||||
|
||||
if (ps.enableStatsForFederatedInstances !== undefined) {
|
||||
set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
|
||||
}
|
||||
|
||||
if (ps.enableServerMachineStats !== undefined) {
|
||||
set.enableServerMachineStats = ps.enableServerMachineStats;
|
||||
}
|
||||
|
|
@ -709,7 +727,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
if (Array.isArray(ps.federationHosts)) {
|
||||
set.blockedHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FlashService } from '@/core/FlashService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flash'],
|
||||
|
|
@ -27,26 +28,25 @@ export const meta = {
|
|||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
properties: {
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
private flashService: FlashService,
|
||||
private flashEntityService: FlashEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.flashsRepository.createQueryBuilder('flash')
|
||||
.andWhere('flash.likedCount > 0')
|
||||
.orderBy('flash.likedCount', 'DESC');
|
||||
|
||||
const flashs = await query.limit(10).getMany();
|
||||
|
||||
return await this.flashEntityService.packMany(flashs, me);
|
||||
const result = await this.flashService.featured({
|
||||
offset: ps.offset,
|
||||
limit: ps.limit,
|
||||
});
|
||||
return await this.flashEntityService.packMany(result, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom';
|
|||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { birthdaySchema, listenbrainzSchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
|
||||
import type { MiUserProfile } from '@/models/UserProfile.js';
|
||||
|
|
@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { HashtagService } from '@/core/HashtagService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RolePolicies, RoleService } from '@/core/RoleService.js';
|
||||
|
|
@ -126,6 +127,13 @@ export const meta = {
|
|||
code: 'RESTRICTED_BY_ROLE',
|
||||
id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
|
||||
},
|
||||
|
||||
nameContainsProhibitedWords: {
|
||||
message: 'Your new name contains prohibited words.',
|
||||
code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS',
|
||||
id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
|
||||
httpStatusCode: 422,
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
|
@ -241,6 +249,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private instanceMeta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -265,6 +276,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private cacheService: CacheService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private avatarDecorationService: AvatarDecorationService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, _user, token) => {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
|
||||
|
|
@ -485,6 +497,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
|
||||
|
||||
if (newName != null) {
|
||||
let hasProhibitedWords = false;
|
||||
if (!await this.roleService.isModerator(user)) {
|
||||
hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser);
|
||||
}
|
||||
if (hasProhibitedWords) {
|
||||
throw new ApiError(meta.errors.nameContainsProhibitedWords);
|
||||
}
|
||||
|
||||
const tokens = mfm.parseSimple(newName);
|
||||
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
}
|
||||
for (const [k, v] of Object.entries(themeProps)) {
|
||||
if (k.startsWith('font')) continue;
|
||||
document.documentElement.style.setProperty(`--${k}`, v.toString());
|
||||
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
|
||||
|
||||
// HTMLの theme-color 適用
|
||||
if (k === 'htmlThemeColor') {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
*/
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
#splash {
|
||||
|
|
@ -17,7 +17,7 @@ html {
|
|||
width: 100vw;
|
||||
height: 100vh;
|
||||
cursor: wait;
|
||||
background-color: var(--bg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ html {
|
|||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translateY(80px);
|
||||
color: var(--accent);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
#splashSpinner > .spinner {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
*/
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
html.embed {
|
||||
|
|
@ -24,7 +24,7 @@ html.embed {
|
|||
width: 100vw;
|
||||
height: 100vh;
|
||||
cursor: wait;
|
||||
background-color: var(--bg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ html.embed #splash {
|
|||
box-sizing: border-box;
|
||||
min-height: 300px;
|
||||
border-radius: var(--radius, 12px);
|
||||
border: 1px solid var(--divider, #e8e8e8);
|
||||
border: 1px solid var(--MI_THEME-divider, #e8e8e8);
|
||||
}
|
||||
|
||||
html.embed.norounded #splash {
|
||||
|
|
@ -67,7 +67,7 @@ html.embed.noborder #splash {
|
|||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translateY(70px);
|
||||
color: var(--accent);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
#splashSpinner > .spinner {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
* roleAssigned - ロールが付与された
|
||||
* achievementEarned - 実績を獲得
|
||||
* exportCompleted - エクスポートが完了
|
||||
* login - ログイン
|
||||
* app - アプリ通知
|
||||
* test - テスト通知(サーバー側)
|
||||
*/
|
||||
|
|
@ -35,6 +36,7 @@ export const notificationTypes = [
|
|||
'roleAssigned',
|
||||
'achievementEarned',
|
||||
'exportCompleted',
|
||||
'login',
|
||||
'app',
|
||||
'test',
|
||||
] as const;
|
||||
|
|
@ -104,6 +106,8 @@ export const moderationLogTypes = [
|
|||
'markSensitiveDriveFile',
|
||||
'unmarkSensitiveDriveFile',
|
||||
'resolveAbuseReport',
|
||||
'forwardAbuseReport',
|
||||
'updateAbuseReportNote',
|
||||
'createInvitation',
|
||||
'createAd',
|
||||
'updateAd',
|
||||
|
|
@ -298,7 +302,18 @@ export type ModerationLogPayloads = {
|
|||
resolveAbuseReport: {
|
||||
reportId: string;
|
||||
report: any;
|
||||
forwarded: boolean;
|
||||
forwarded?: boolean;
|
||||
resolvedAs?: string | null;
|
||||
};
|
||||
forwardAbuseReport: {
|
||||
reportId: string;
|
||||
report: any;
|
||||
};
|
||||
updateAbuseReportNote: {
|
||||
reportId: string;
|
||||
report: any;
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
createInvitation: {
|
||||
invitations: any[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue