merge: upstream changes for 2024.11 (!742)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/742

Closes #645 and #646

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
dakkar 2024-12-15 17:27:12 +00:00
commit e2352839e4
616 changed files with 16825 additions and 7991 deletions

View file

@ -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) ?? null,
targetUser: usersMap.get(it.targetUserId) ?? null,
assignee: it.assigneeId ? (usersMap.get(it.assigneeId) ?? null) : 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) {

View file

@ -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,
});
}
}
}

View file

@ -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?

View file

@ -72,7 +72,7 @@ export class AnnouncementService {
updatedAt: null,
title: values.title,
text: values.text,
imageUrl: values.imageUrl,
imageUrl: values.imageUrl || null,
icon: values.icon,
display: values.display,
forExistingUsers: values.forExistingUsers,
@ -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');
}

View file

@ -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');
}
}
}

View file

@ -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 { TimeService } from '@/core/TimeService.js';
import { EnvService } from '@/core/EnvService.js';
import { AccountMoveService } from './AccountMoveService.js';
@ -222,6 +223,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 };
@ -375,6 +377,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
WebhookTestService,
UtilityService,
FileInfoService,
FlashService,
SearchService,
ClipService,
FeaturedService,
@ -526,6 +529,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$WebhookTestService,
$UtilityService,
$FileInfoService,
$FlashService,
$SearchService,
$ClipService,
$FeaturedService,
@ -676,6 +680,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
WebhookTestService,
UtilityService,
FileInfoService,
FlashService,
SearchService,
ClipService,
FeaturedService,

View file

@ -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

View file

@ -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()

View file

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

View file

@ -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();
}
}

View file

@ -51,13 +51,13 @@ import { FeaturedService } from '@/core/FeaturedService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { LatestNoteService } from '@/core/LatestNoteService.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -224,7 +224,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
@ -413,7 +413,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => {
await this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
if (i.isNSFW && !this.isPureRenote(data)) {
data.cw = 'Instance is marked as NSFW';
}
@ -565,17 +565,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);
}
});
}
}
// ハッシュタグ更新
@ -608,13 +608,21 @@ export class NoteCreateService implements OnApplicationShutdown {
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
}).then(followings => {
}).then(async followings => {
if (note.visibility !== 'specified') {
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
for (const following of followings) {
// TODO: ワードミュート考慮
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
let isRenoteMuted = false;
if (isPureRenote) {
const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId);
isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
}
if (!isRenoteMuted) {
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
}
}
}
});

View file

@ -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);
}
});
}
}
}

View file

@ -220,7 +220,7 @@ export class NoteEditService implements OnApplicationShutdown {
private latestNoteService: LatestNoteService,
private noteCreateService: NoteCreateService,
) {
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
@ -441,7 +441,7 @@ export class NoteEditService implements OnApplicationShutdown {
}
if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => {
await this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
if (i.isNSFW && !this.noteCreateService.isPureRenote(data)) {
data.cw = 'Instance is marked as NSFW';
}
@ -592,13 +592,17 @@ export class NoteEditService implements OnApplicationShutdown {
noindex: MiUser['noindex'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
// Register host
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
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);
}
});
}
}
// ハッシュタグ更新

View file

@ -7,13 +7,15 @@ import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js';
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
import { type UserWebhookPayload } from './UserWebhookService.js';
import type {
DbJobData,
DeliverJobData,
@ -30,8 +32,8 @@ import type {
ObjectStorageQueue,
RelationshipQueue,
SystemQueue,
UserWebhookDeliverQueue,
SystemWebhookDeliverQueue,
UserWebhookDeliverQueue,
ScheduleNotePostQueue,
} from './QueueModule.js';
import type httpSignature from '@peertube/http-signature';
@ -96,6 +98,13 @@ export class QueueService {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
});
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
removeOnComplete: true,
});
}
@bindThis
@ -522,10 +531,10 @@ export class QueueService {
* @see UserWebhookDeliverProcessorService
*/
@bindThis
public userWebhookDeliver(
public userWebhookDeliver<T extends WebhookEventTypes>(
webhook: MiWebhook,
type: typeof webhookEventTypes[number],
content: unknown,
type: T,
content: UserWebhookPayload<T>,
opts?: { attempts?: number },
) {
const data: UserWebhookDeliverJobData = {
@ -554,10 +563,10 @@ export class QueueService {
* @see SystemWebhookDeliverProcessorService
*/
@bindThis
public systemWebhookDeliver(
public systemWebhookDeliver<T extends SystemWebhookEventType>(
webhook: MiSystemWebhook,
type: SystemWebhookEventType,
content: unknown,
type: T,
content: SystemWebhookPayload<T>,
opts?: { attempts?: number },
) {
const data: SystemWebhookDeliverJobData = {

View file

@ -107,6 +107,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;
@ -142,6 +143,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
@ -425,49 +427,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

View file

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

View file

@ -15,8 +15,39 @@ import { QueueService } from '@/core/QueueService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import { Packed } from '@/misc/json-schema.js';
import { AbuseReportResolveType } from '@/models/AbuseUserReport.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export type AbuseReportPayload = {
id: string;
targetUserId: string;
targetUser: Packed<'UserLite'> | null;
targetUserHost: string | null;
reporterId: string;
reporter: Packed<'UserLite'> | null;
reporterHost: string | null;
assigneeId: string | null;
assignee: Packed<'UserLite'> | null;
resolved: boolean;
forwarded: boolean;
comment: string;
moderationNote: string;
resolvedAs: AbuseReportResolveType | null;
};
export type InactiveModeratorsWarningPayload = {
remainingTime: ModeratorInactivityRemainingTime;
};
export type SystemWebhookPayload<T extends SystemWebhookEventType> =
T extends 'abuseReport' | 'abuseReportResolved' ? AbuseReportPayload :
T extends 'userCreated' ? Packed<'UserLite'> :
T extends 'inactiveModeratorsWarning' ? InactiveModeratorsWarningPayload :
T extends 'inactiveModeratorsInvitationOnlyChanged' ? Record<string, never> :
never;
@Injectable()
export class SystemWebhookService implements OnApplicationShutdown {
private logger: Logger;
@ -101,8 +132,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'createSystemWebhook', {
systemWebhookId: webhook.id,
webhook: webhook,
})
.then();
});
return webhook;
}
@ -139,8 +169,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
systemWebhookId: beforeEntity.id,
before: beforeEntity,
after: afterEntity,
})
.then();
});
return afterEntity;
}
@ -158,8 +187,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
.log(updater, 'deleteSystemWebhook', {
systemWebhookId: webhook.id,
webhook,
})
.then();
});
}
/**
@ -171,7 +199,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
webhook: MiSystemWebhook | MiSystemWebhook['id'],
type: T,
content: unknown,
content: SystemWebhookPayload<T>,
) {
const webhookEntity = typeof webhook === 'string'
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)

View file

@ -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

View file

@ -6,11 +6,23 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { type WebhooksRepository } from '@/models/_.js';
import { MiWebhook } from '@/models/Webhook.js';
import { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
export type UserWebhookPayload<T extends WebhookEventTypes> =
T extends 'note' | 'reply' | 'renote' |'mention' | 'edited' ? {
note: Packed<'Note'>,
} :
T extends 'follow' | 'unfollow' ? {
user: Packed<'UserDetailedNotMe'>,
} :
T extends 'followed' ? {
user: Packed<'UserLite'>,
} : never;
@Injectable()
export class UserWebhookService implements OnApplicationShutdown {

View file

@ -123,6 +123,7 @@ export class UtilityService {
return host;
}
@bindThis
private specialSuffix(hostname: string): string | null {
// masto.host provides domain names for its clients, we have to
// treat it as if it were a public suffix
@ -143,6 +144,7 @@ export class UtilityService {
return host;
}
@bindThis
public isFederationAllowedHost(host: string): boolean {
if (this.meta.federation === 'none') return false;
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;

View file

@ -246,14 +246,12 @@ export class WebAuthnService {
@bindThis
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
const challenge = await this.redisClient.getdel(`webauthn:challenge:${userId}`);
if (!challenge) {
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
}
await this.redisClient.del(`webauthn:challenge:${userId}`);
const key = await this.userSecurityKeysRepository.findOneBy({
id: response.id,
userId: userId,

View file

@ -7,16 +7,17 @@ import { Injectable } from '@nestjs/common';
import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js';
import { Packed } from '@/misc/json-schema.js';
import { type WebhookEventTypes } from '@/models/Webhook.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { type UserWebhookPayload, 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 {
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): AbuseReportPayload {
const result: MiAbuseUserReport = {
id: 'dummy-abuse-report1',
targetUserId: 'dummy-target-user',
targetUser: null,
@ -29,8 +30,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 {
@ -73,6 +83,9 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
isExplorable: true,
isHibernated: false,
isDeleted: false,
requireSigninToViewContents: false,
makeNotesFollowersOnlyBefore: null,
makeNotesHiddenBefore: null,
emojis: [],
score: 0,
host: null,
@ -289,7 +302,8 @@ const dummyUser3 = generateDummyUser({
@Injectable()
export class WebhookTestService {
public static NoSuchWebhookError = class extends Error {};
public static NoSuchWebhookError = class extends Error {
};
constructor(
private userWebhookService: UserWebhookService,
@ -307,10 +321,10 @@ export class WebhookTestService {
* - on
*/
@bindThis
public async testUserWebhook(
public async testUserWebhook<T extends WebhookEventTypes>(
params: {
webhookId: MiWebhook['id'],
type: WebhookEventTypes,
type: T,
override?: Partial<Omit<MiWebhook, 'id'>>,
},
sender: MiUser | null,
@ -322,7 +336,7 @@ export class WebhookTestService {
}
const webhook = webhooks[0];
const send = (contents: unknown) => {
const send = <U extends WebhookEventTypes>(type: U, contents: UserWebhookPayload<U>) => {
const merged = {
...webhook,
...params.override,
@ -330,7 +344,7 @@ export class WebhookTestService {
// テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加するチェック処理などをスキップする意図.
// また、Jobの試行回数も1回だけ.
this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 });
this.queueService.userWebhookDeliver(merged, type, contents, { attempts: 1 });
};
const dummyNote1 = generateDummyNote({
@ -362,33 +376,45 @@ export class WebhookTestService {
switch (params.type) {
case 'note': {
send(toPackedNote(dummyNote1));
send('note', { note: toPackedNote(dummyNote1) });
break;
}
case 'reply': {
send(toPackedNote(dummyReply1));
send('reply', { note: toPackedNote(dummyReply1) });
break;
}
case 'renote': {
send(toPackedNote(dummyRenote1));
send('renote', { note: toPackedNote(dummyRenote1) });
break;
}
case 'mention': {
send(toPackedNote(dummyMention1));
send('mention', { note: toPackedNote(dummyMention1) });
break;
}
case 'edited': {
send('edited', { note: toPackedNote(dummyNote1) });
break;
}
case 'follow': {
send(toPackedUserDetailedNotMe(dummyUser1));
send('follow', { user: toPackedUserDetailedNotMe(dummyUser1) });
break;
}
case 'followed': {
send(toPackedUserLite(dummyUser2));
send('followed', { user: toPackedUserLite(dummyUser2) });
break;
}
case 'unfollow': {
send(toPackedUserDetailedNotMe(dummyUser3));
send('unfollow', { user: toPackedUserDetailedNotMe(dummyUser3) });
break;
}
// まだ実装されていない (#9485)
case 'reaction':
return;
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveAssertion: never = params.type;
return;
}
}
}
@ -401,10 +427,10 @@ export class WebhookTestService {
* - on
*/
@bindThis
public async testSystemWebhook(
public async testSystemWebhook<T extends SystemWebhookEventType>(
params: {
webhookId: MiSystemWebhook['id'],
type: SystemWebhookEventType,
type: T,
override?: Partial<Omit<MiSystemWebhook, 'id'>>,
},
) {
@ -414,7 +440,7 @@ export class WebhookTestService {
}
const webhook = webhooks[0];
const send = (contents: unknown) => {
const send = <U extends SystemWebhookEventType>(type: U, contents: SystemWebhookPayload<U>) => {
const merged = {
...webhook,
...params.override,
@ -422,12 +448,12 @@ export class WebhookTestService {
// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加するチェック処理などをスキップする意図.
// また、Jobの試行回数も1回だけ.
this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 });
this.queueService.systemWebhookDeliver(merged, type, contents, { attempts: 1 });
};
switch (params.type) {
case 'abuseReport': {
send(generateAbuseReport({
send('abuseReport', generateAbuseReport({
targetUserId: dummyUser1.id,
targetUser: dummyUser1,
reporterId: dummyUser2.id,
@ -436,7 +462,7 @@ export class WebhookTestService {
break;
}
case 'abuseReportResolved': {
send(generateAbuseReport({
send('abuseReportResolved', generateAbuseReport({
targetUserId: dummyUser1.id,
targetUser: dummyUser1,
reporterId: dummyUser2.id,
@ -448,9 +474,30 @@ export class WebhookTestService {
break;
}
case 'userCreated': {
send(toPackedUserLite(dummyUser1));
send('userCreated', toPackedUserLite(dummyUser1));
break;
}
case 'inactiveModeratorsWarning': {
const dummyTime: ModeratorInactivityRemainingTime = {
time: 100000,
asDays: 1,
asHours: 24,
};
send('inactiveModeratorsWarning', {
remainingTime: dummyTime,
});
break;
}
case 'inactiveModeratorsInvitationOnlyChanged': {
send('inactiveModeratorsInvitationOnlyChanged', {});
break;
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveAssertion: never = params.type;
return;
}
}
}
}

View file

@ -135,6 +135,7 @@ export class ApInboxService {
if (actor.uri) {
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => {
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
this.apPersonService.updatePerson(actor.uri);
});
}
@ -572,7 +573,7 @@ export class ApInboxService {
@bindThis
private async flag(actor: MiRemoteUser, activity: IFlag): Promise<string> {
// Make sure the source instance is allowed to send reports.
const instance = await this.federatedInstanceService.fetch(actor.host);
const instance = await this.federatedInstanceService.fetchOrRegister(actor.host);
if (instance.rejectReports) {
throw new Bull.UnrecoverableError(`Rejecting report from instance: ${actor.host}`);
}

View file

@ -520,6 +520,9 @@ export class ApRendererService {
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
_misskey_summary: profile.description,
_misskey_followedMessage: profile.followedMessage,
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
icon: avatar ? this.renderImage(avatar) : null,
image: banner ? this.renderImage(banner) : null,
backgroundUrl: background ? this.renderImage(background) : null,

View file

@ -11,14 +11,14 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
import type { IObject } from './type.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
import { UtilityService } from "@/core/UtilityService.js";
import type { IObject } from './type.js';
type Request = {
url: string;

View file

@ -558,6 +558,9 @@ const extension_context_definition = {
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary',
'_misskey_followedMessage': 'misskey:_misskey_followedMessage',
'_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents',
'_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore',
'_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore',
'isCat': 'misskey:isCat',
// Firefish
firefish: 'https://joinfirefish.org/ns#',

View file

@ -74,7 +74,7 @@ export class ApImageService {
// 2. or the image is not sensitive
const shouldBeCached = this.meta.cacheRemoteFiles && (this.meta.cacheRemoteSensitiveFiles || !image.sensitive);
await this.federatedInstanceService.fetch(actor.host).then(async i => {
await this.federatedInstanceService.fetchOrRegister(actor.host).then(async i => {
if (i.isNSFW) {
image.sensitive = true;
}

View file

@ -109,6 +109,10 @@ export class ApNoteService {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
}
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
}
if (actor) {
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
if (attribution !== actor.uri) {
@ -119,10 +123,6 @@ export class ApNoteService {
}
}
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
}
if (note) {
const url = (object.url) ? getOneApId(object.url) : note.url;
if (url && url !== note.url) {
@ -411,7 +411,7 @@ export class ApNoteService {
const object = await resolver.resolve(value);
const entryUri = getApId(value);
const err = this.validateNote(object, entryUri);
const err = this.validateNote(object, entryUri, actor, user, updatedNote);
if (err) {
this.logger.error(err.message, {
resolver: { history: resolver.getHistory() },

View file

@ -255,6 +255,12 @@ export class ApPersonService implements OnModuleInit {
if (user == null) throw new Error('failed to create user: user is null');
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => {
// icon and image may be arrays
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
if (Array.isArray(img)) {
img = img.find(item => item && item.url) ?? null;
}
// if we have an explicitly missing image, return an
// explicitly-null set of values
if ((img == null) || (typeof img === 'object' && img.url == null)) {
@ -398,7 +404,7 @@ export class ApPersonService implements OnModuleInit {
usernameLower: person.preferredUsername?.toLowerCase(),
host,
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
notesCount: outboxcollection?.totalItems ?? 0,
followersCount: followerscollection?.totalItems ?? 0,
followingCount: followingcollection?.totalItems ?? 0,
@ -409,6 +415,9 @@ export class ApPersonService implements OnModuleInit {
isBot,
isCat: (person as any).isCat === true,
speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true,
requireSigninToViewContents: (person as any).requireSigninToViewContents === true,
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
emojis,
})) as MiRemoteUser;
@ -462,13 +471,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);
@ -574,7 +585,7 @@ export class ApPersonService implements OnModuleInit {
const updates = {
lastFetchedAt: new Date(),
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured,
emojis: emojiNames,
@ -653,7 +664,7 @@ export class ApPersonService implements OnModuleInit {
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
await this.followingsRepository.update(
{ followerId: exist.id },
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox },
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null },
);
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));

View file

@ -12,8 +12,8 @@ import type { IPoll } from '@/models/Poll.js';
import type { MiRemoteUser } from '@/models/User.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js';
import { getApId, getApType, getNullableApId, getOneApId, isQuestion } from '../type.js';
import { UtilityService } from '@/core/UtilityService.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApResolverService } from '../ApResolverService.js';
import type { Resolver } from '../ApResolverService.js';

View file

@ -17,6 +17,9 @@ export interface IObject {
summary?: string | null;
_misskey_summary?: string;
_misskey_followedMessage?: string | null;
_misskey_requireSigninToViewContents?: boolean;
_misskey_makeNotesFollowersOnlyBefore?: number | null;
_misskey_makeNotesHiddenBefore?: number | null;
published?: string;
cc?: ApObject;
to?: ApObject;

View file

@ -53,6 +53,8 @@ export class AbuseUserReportEntityService {
schema: 'UserDetailedNotMe',
}) : null,
forwarded: report.forwarded,
resolvedAs: report.resolvedAs,
moderationNote: report.moderationNote,
});
}

View file

@ -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,
})),
);
}
}

View file

@ -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',

View file

@ -25,6 +25,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;
@ -86,52 +110,86 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) {
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
// FIXME: このvisibility変更処理が当関数にあるのは若干不自然かもしれない(関数名を treatVisibility とかに変える手もある)
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null)
&& (
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
)
) {
packedNote.visibility = 'followers';
}
}
if (meId === packedNote.userId) return;
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
let hide = false;
// visibility が specified かつ自分が指定されていなかったら非表示
if (packedNote.visibility === 'specified') {
if (meId == null) {
hide = true;
} else if (meId === packedNote.userId) {
hide = false;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
if (packedNote.user.requireSigninToViewContents && meId == null) {
hide = true;
}
if (specified) {
if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if ((hiddenBefore != null)
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
hide = true;
}
}
// visibility が specified かつ自分が指定されていなかったら非表示
if (!hide) {
if (packedNote.visibility === 'specified') {
if (meId == null) {
hide = true;
} else if (meId === packedNote.userId) {
hide = false;
} else {
hide = true;
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (!specified) {
hide = true;
}
}
}
}
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (packedNote.visibility === 'followers') {
if (meId == null) {
hide = true;
} else if (meId === packedNote.userId) {
hide = false;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ
hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション
hide = false;
} else if (packedNote.renote && (meId === packedNote.renote.userId)) {
hide = false;
} else {
// フォロワーかどうか
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
if (!hide) {
if (packedNote.visibility === 'followers') {
if (meId == null) {
hide = true;
} else if (meId === packedNote.userId) {
hide = false;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ
hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション
hide = false;
} else if (packedNote.renote && (meId === packedNote.renote.userId)) {
hide = false;
} else {
// フォロワーかどうか
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
hide = !isFollowing;
hide = !isFollowing;
}
}
}
@ -163,6 +221,7 @@ export class NoteEntityService implements OnModuleInit {
packedNote.reactionEmojis = {};
packedNote.reactions = {};
packedNote.isHidden = true;
// TODO: hiddenReason みたいなのを提供しても良さそう
}
}
@ -257,7 +316,7 @@ export class NoteEntityService implements OnModuleInit {
return true;
} else {
// 指定されているかどうか
return note.visibleUserIds.some((id: any) => meId === id);
return note.visibleUserIds.some(id => meId === id);
}
}
@ -459,7 +518,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>();
@ -470,7 +529,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);

View file

@ -543,6 +543,9 @@ export class UserEntityService implements OnModuleInit {
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? false,
approved: user.approved,
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,
@ -598,11 +601,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,
@ -617,6 +615,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,