summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2024-08-30 12:08:31 +0100
committerdakkar <dakkar@thenautilus.net>2024-08-30 12:08:31 +0100
commit6151099f5ba07d14a651aea8f816dd280d74209d (patch)
tree4b014a2f4b198facc8bdd92c70b49fec2801d51a /packages/backend/src/core
parentmerge: thunk the min/max promises (!603) (diff)
parentMerge pull request #14391 from misskey-dev/develop (diff)
downloadsharkey-6151099f5ba07d14a651aea8f816dd280d74209d.tar.gz
sharkey-6151099f5ba07d14a651aea8f816dd280d74209d.tar.bz2
sharkey-6151099f5ba07d14a651aea8f816dd280d74209d.zip
Merge remote-tracking branch 'misskey/master' into feature/misskey-2024.8
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/DeleteAccountService.ts60
-rw-r--r--packages/backend/src/core/FileInfoService.ts6
-rw-r--r--packages/backend/src/core/ModerationLogService.ts3
-rw-r--r--packages/backend/src/core/NoteCreateService.ts2
-rw-r--r--packages/backend/src/core/NoteDeleteService.ts2
-rw-r--r--packages/backend/src/core/ReversiService.ts33
-rw-r--r--packages/backend/src/core/UserSuspendService.ts79
-rw-r--r--packages/backend/src/core/activitypub/ApRequestService.ts26
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts50
-rw-r--r--packages/backend/src/core/activitypub/type.ts6
-rw-r--r--packages/backend/src/core/entities/FlashEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts4
12 files changed, 243 insertions, 29 deletions
diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts
index 79b614edba..7f1b8f3efb 100644
--- a/packages/backend/src/core/DeleteAccountService.ts
+++ b/packages/backend/src/core/DeleteAccountService.ts
@@ -4,12 +4,15 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import type { UsersRepository } from '@/models/_.js';
+import { Not, IsNull } from 'typeorm';
+import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js';
-import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable()
export class DeleteAccountService {
@@ -17,9 +20,14 @@ export class DeleteAccountService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
- private userSuspendService: UserSuspendService,
+ @Inject(DI.followingsRepository)
+ private followingsRepository: FollowingsRepository,
+
+ private userEntityService: UserEntityService,
+ private apRendererService: ApRendererService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
+ private moderationLogService: ModerationLogService,
) {
}
@@ -27,16 +35,52 @@ export class DeleteAccountService {
public async deleteAccount(user: {
id: string;
host: string | null;
- }): Promise<void> {
+ }, moderator?: MiUser): Promise<void> {
const _user = await this.usersRepository.findOneByOrFail({ id: user.id });
if (_user.isRoot) throw new Error('cannot delete a root account');
+ if (moderator != null) {
+ this.moderationLogService.log(moderator, 'deleteAccount', {
+ userId: user.id,
+ userUsername: _user.username,
+ userHost: user.host,
+ });
+ }
+
// 物理削除する前にDelete activityを送信する
- await this.userSuspendService.doPostSuspend(user).catch(e => {});
+ if (this.userEntityService.isLocalUser(user)) {
+ // 知り得る全SharedInboxにDelete配信
+ const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
- this.queueService.createDeleteAccountJob(user, {
- soft: false,
- });
+ const queue: string[] = [];
+
+ const followings = await this.followingsRepository.find({
+ where: [
+ { followerSharedInbox: Not(IsNull()) },
+ { followeeSharedInbox: Not(IsNull()) },
+ ],
+ select: ['followerSharedInbox', 'followeeSharedInbox'],
+ });
+
+ const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
+
+ for (const inbox of inboxes) {
+ if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
+ }
+
+ for (const inbox of queue) {
+ this.queueService.deliver(user, content, inbox, true);
+ }
+
+ this.queueService.createDeleteAccountJob(user, {
+ soft: false,
+ });
+ } else {
+ // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
+ this.queueService.createDeleteAccountJob(user, {
+ soft: true,
+ });
+ }
await this.usersRepository.update(user.id, {
isDeleted: true,
diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts
index 9a9eae3cae..cc66e9fe3a 100644
--- a/packages/backend/src/core/FileInfoService.ts
+++ b/packages/backend/src/core/FileInfoService.ts
@@ -12,7 +12,7 @@ import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
-import { encode } from 'blurhash';
+import * as blurhash from 'blurhash';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
@@ -283,7 +283,7 @@ export class FileInfoService {
}
/**
- * Calculate average color of image
+ * Calculate blurhash string of image
*/
@bindThis
private getBlurhash(path: string, type: string): Promise<string> {
@@ -298,7 +298,7 @@ export class FileInfoService {
let hash;
try {
- hash = encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
+ hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
} catch (e) {
return reject(e);
}
diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts
index 6c155c9a62..2c02af217d 100644
--- a/packages/backend/src/core/ModerationLogService.ts
+++ b/packages/backend/src/core/ModerationLogService.ts
@@ -9,7 +9,8 @@ import type { ModerationLogsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js';
-import { ModerationLogPayloads, moderationLogTypes } from '@/types.js';
+import type { ModerationLogPayloads } from '@/types.js';
+import { moderationLogTypes } from '@/types.js';
@Injectable()
export class ModerationLogService {
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index a7f2bf5a5c..ef9ea0ee99 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -707,7 +707,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const meta = await this.metaService.fetch();
this.notesChart.update(note, true);
- if (meta.enableChartsForRemoteUser || (user.host == null)) {
+ if (note.visibility !== 'specified' && (meta.enableChartsForRemoteUser || (user.host == null))) {
this.perUserNotesChart.update(user, note, true);
}
diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts
index af65750a01..7ce6d7c605 100644
--- a/packages/backend/src/core/NoteDeleteService.ts
+++ b/packages/backend/src/core/NoteDeleteService.ts
@@ -99,7 +99,7 @@ export class NoteDeleteService {
this.deliverToConcerned(user, note, content);
}
- // also deliever delete activity to cascaded notes
+ // also deliver delete activity to cascaded notes
const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
for (const cascadingNote of federatedLocalCascadingNotes) {
if (!cascadingNote.user) continue;
diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts
index 75fdb82ca0..8c0a8f6cc7 100644
--- a/packages/backend/src/core/ReversiService.ts
+++ b/packages/backend/src/core/ReversiService.ts
@@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { ModuleRef } from '@nestjs/core';
+import { reversiUpdateKeys } from 'misskey-js';
import * as Reversi from 'misskey-reversi';
import { IsNull, LessThan, MoreThan } from 'typeorm';
import type {
@@ -399,7 +400,33 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
- public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) {
+ public isValidReversiUpdateKey(key: unknown): key is typeof reversiUpdateKeys[number] {
+ if (typeof key !== 'string') return false;
+ return (reversiUpdateKeys as string[]).includes(key);
+ }
+
+ @bindThis
+ public isValidReversiUpdateValue<K extends typeof reversiUpdateKeys[number]>(key: K, value: unknown): value is MiReversiGame[K] {
+ switch (key) {
+ case 'map':
+ return Array.isArray(value) && value.every(row => typeof row === 'string');
+ case 'bw':
+ return typeof value === 'string' && ['random', '1', '2'].includes(value);
+ case 'isLlotheo':
+ return typeof value === 'boolean';
+ case 'canPutEverywhere':
+ return typeof value === 'boolean';
+ case 'loopedBoard':
+ return typeof value === 'boolean';
+ case 'timeLimitForEachTurn':
+ return typeof value === 'number' && value >= 0;
+ default:
+ return false;
+ }
+ }
+
+ @bindThis
+ public async updateSettings<K extends typeof reversiUpdateKeys[number]>(gameId: MiReversiGame['id'], user: MiUser, key: K, value: MiReversiGame[K]) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
@@ -407,10 +434,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if ((game.user1Id === user.id) && game.user1Ready) return;
if ((game.user2Id === user.id) && game.user2Ready) return;
- if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return;
-
- // TODO: より厳格なバリデーション
-
const updatedGame = {
...game,
[key]: value,
diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts
index d594a223f4..7920e58e36 100644
--- a/packages/backend/src/core/UserSuspendService.ts
+++ b/packages/backend/src/core/UserSuspendService.ts
@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
-import type { FollowingsRepository } from '@/models/_.js';
+import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
@@ -13,24 +13,75 @@ import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
+import { RelationshipJobData } from '@/queue/types.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable()
export class UserSuspendService {
constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
+ @Inject(DI.followRequestsRepository)
+ private followRequestsRepository: FollowRequestsRepository,
+
private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
+ private moderationLogService: ModerationLogService,
) {
}
@bindThis
- public async doPostSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
+ public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
+ await this.usersRepository.update(user.id, {
+ isSuspended: true,
+ });
+
+ this.moderationLogService.log(moderator, 'suspend', {
+ userId: user.id,
+ userUsername: user.username,
+ userHost: user.host,
+ });
+
+ (async () => {
+ await this.postSuspend(user).catch(e => {});
+ await this.unFollowAll(user).catch(e => {});
+ })();
+ }
+
+ @bindThis
+ public async unsuspend(user: MiUser, moderator: MiUser): Promise<void> {
+ await this.usersRepository.update(user.id, {
+ isSuspended: false,
+ });
+
+ this.moderationLogService.log(moderator, 'unsuspend', {
+ userId: user.id,
+ userUsername: user.username,
+ userHost: user.host,
+ });
+
+ (async () => {
+ await this.postUnsuspend(user).catch(e => {});
+ })();
+ }
+
+ @bindThis
+ private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
+ this.followRequestsRepository.delete({
+ followeeId: user.id,
+ });
+ this.followRequestsRepository.delete({
+ followerId: user.id,
+ });
+
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
@@ -58,7 +109,7 @@ export class UserSuspendService {
}
@bindThis
- public async doPostUnsuspend(user: MiUser): Promise<void> {
+ private async postUnsuspend(user: MiUser): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) {
@@ -86,4 +137,26 @@ export class UserSuspendService {
}
}
}
+
+ @bindThis
+ private async unFollowAll(follower: MiUser) {
+ const followings = await this.followingsRepository.find({
+ where: {
+ followerId: follower.id,
+ followeeId: Not(IsNull()),
+ },
+ });
+
+ const jobs: RelationshipJobData[] = [];
+ for (const following of followings) {
+ if (following.followeeId && following.followerId) {
+ jobs.push({
+ from: { id: following.followerId },
+ to: { id: following.followeeId },
+ silent: true,
+ });
+ }
+ }
+ this.queueService.createUnfollowJob(jobs);
+ }
}
diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts
index fd8d65445a..918e43ae9c 100644
--- a/packages/backend/src/core/activitypub/ApRequestService.ts
+++ b/packages/backend/src/core/activitypub/ApRequestService.ts
@@ -6,6 +6,7 @@
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
+import { Window } from 'happy-dom';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
@@ -182,7 +183,8 @@ export class ApRequestService {
* @param url URL to fetch
*/
@bindThis
- public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> {
+ public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> {
+ const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const req = ApRequestCreator.createSignedGet({
@@ -200,9 +202,29 @@ export class ApRequestService {
headers: req.request.headers,
}, {
throwErrorWhenResponseNotOk: true,
- validators: [validateContentTypeSetAsActivityPub],
});
+ //#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき
+ const contentType = res.headers.get('content-type');
+
+ if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) {
+ const html = await res.text();
+ const window = new Window();
+ const document = window.document;
+ document.documentElement.innerHTML = html;
+
+ const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
+ if (alternate) {
+ const href = alternate.getAttribute('href');
+ if (href) {
+ return await this.signedGet(href, user, false);
+ }
+ }
+ }
+ //#endregion
+
+ validateContentTypeSetAsActivityPub(res);
+
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts
index bf8565d402..10b1fc0bf4 100644
--- a/packages/backend/src/core/activitypub/models/ApPersonService.ts
+++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts
@@ -48,7 +48,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js';
import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js';
-import type { IActor, IObject } from '../type.js';
+import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
const nameLength = 128;
const summaryLength = 2048;
@@ -308,6 +308,21 @@ export class ApPersonService implements OnModuleInit {
const isBot = getApType(object) === 'Service' || getApType(object) === 'Application';
+ const [followingVisibility, followersVisibility] = await Promise.all(
+ [
+ this.isPublicCollection(person.following, resolver),
+ this.isPublicCollection(person.followers, resolver),
+ ].map((p): Promise<'public' | 'private'> => p
+ .then(isPublic => isPublic ? 'public' : 'private')
+ .catch(err => {
+ if (!(err instanceof StatusError) || err.isRetryable) {
+ this.logger.error('error occurred while fetching following/followers collection', { stack: err });
+ }
+ return 'private';
+ })
+ )
+ );
+
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
@@ -382,6 +397,8 @@ export class ApPersonService implements OnModuleInit {
description: _description,
url,
fields,
+ followingVisibility,
+ followersVisibility,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
userHost: host,
@@ -490,6 +507,23 @@ export class ApPersonService implements OnModuleInit {
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
+ const [followingVisibility, followersVisibility] = await Promise.all(
+ [
+ this.isPublicCollection(person.following, resolver),
+ this.isPublicCollection(person.followers, resolver),
+ ].map((p): Promise<'public' | 'private' | undefined> => p
+ .then(isPublic => isPublic ? 'public' : 'private')
+ .catch(err => {
+ if (!(err instanceof StatusError) || err.isRetryable) {
+ this.logger.error('error occurred while fetching following/followers collection', { stack: err });
+ // Do not update the visibiility on transient errors.
+ return undefined;
+ }
+ return 'private';
+ })
+ )
+ );
+
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
@@ -561,6 +595,8 @@ export class ApPersonService implements OnModuleInit {
url,
fields,
description: _description,
+ followingVisibility,
+ followersVisibility,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
listenbrainz: person.listenbrainz ?? null,
@@ -733,4 +769,16 @@ export class ApPersonService implements OnModuleInit {
return 'ok';
}
+
+ @bindThis
+ private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> {
+ if (collection) {
+ const resolved = await resolver.resolveCollection(collection);
+ if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts
index c92e2b805f..2f58825de1 100644
--- a/packages/backend/src/core/activitypub/type.ts
+++ b/packages/backend/src/core/activitypub/type.ts
@@ -100,13 +100,15 @@ export interface IActivity extends IObject {
export interface ICollection extends IObject {
type: 'Collection';
totalItems: number;
- items: ApObject;
+ first?: IObject | string;
+ items?: ApObject;
}
export interface IOrderedCollection extends IObject {
type: 'OrderedCollection';
totalItems: number;
- orderedItems: ApObject;
+ first?: IObject | string;
+ orderedItems?: ApObject;
}
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts
index d110f7afc6..4aa7104c1e 100644
--- a/packages/backend/src/core/entities/FlashEntityService.ts
+++ b/packages/backend/src/core/entities/FlashEntityService.ts
@@ -49,6 +49,7 @@ export class FlashEntityService {
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,
});
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index e1ef9985bc..2b1c4d5c63 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -490,12 +490,12 @@ export class UserEntityService implements OnModuleInit {
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
const followingCount = profile == null ? null :
- (profile.followingVisibility === 'public') || isMe ? user.followingCount :
+ (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
(profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount :
null;
const followersCount = profile == null ? null :
- (profile.followersVisibility === 'public') || isMe ? user.followersCount :
+ (profile.followersVisibility === 'public') || isMe || iAmModerator ? user.followersCount :
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
null;