summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-04-30 09:01:47 +0000
committerGitHub <noreply@github.com>2025-04-30 09:01:47 +0000
commita6d1727205fbd018bc72926fa7955110982ccdfa (patch)
tree3e1dda28c14e58f931dd55eb1cbb09131ba09111 /packages/backend/src/core
parentMerge pull request #15735 from misskey-dev/develop (diff)
parentRelease: 2025.4.1 (diff)
downloadmisskey-a6d1727205fbd018bc72926fa7955110982ccdfa.tar.gz
misskey-a6d1727205fbd018bc72926fa7955110982ccdfa.tar.bz2
misskey-a6d1727205fbd018bc72926fa7955110982ccdfa.zip
Merge pull request #15842 from misskey-dev/develop
Release: 2025.4.1
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/AccountMoveService.ts3
-rw-r--r--packages/backend/src/core/AntennaService.ts69
-rw-r--r--packages/backend/src/core/ChatService.ts4
-rw-r--r--packages/backend/src/core/DriveService.ts7
-rw-r--r--packages/backend/src/core/FanoutTimelineEndpointService.ts22
-rw-r--r--packages/backend/src/core/ImageProcessingService.ts1
-rw-r--r--packages/backend/src/core/MfmService.ts10
-rw-r--r--packages/backend/src/core/NoteCreateService.ts9
-rw-r--r--packages/backend/src/core/PushNotificationService.ts1
-rw-r--r--packages/backend/src/core/QueryService.ts38
-rw-r--r--packages/backend/src/core/QueueService.ts498
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/core/SearchService.ts12
-rw-r--r--packages/backend/src/core/SystemAccountService.ts46
-rw-r--r--packages/backend/src/core/WebhookTestService.ts8
-rw-r--r--packages/backend/src/core/activitypub/ApMfmService.ts10
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts22
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts8
18 files changed, 660 insertions, 111 deletions
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts
index 406563bee8..f8e3eaf01f 100644
--- a/packages/backend/src/core/AccountMoveService.ts
+++ b/packages/backend/src/core/AccountMoveService.ts
@@ -25,6 +25,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { RoleService } from '@/core/RoleService.js';
+import { AntennaService } from '@/core/AntennaService.js';
@Injectable()
export class AccountMoveService {
@@ -63,6 +64,7 @@ export class AccountMoveService {
private queueService: QueueService,
private systemAccountService: SystemAccountService,
private roleService: RoleService,
+ private antennaService: AntennaService,
) {
}
@@ -123,6 +125,7 @@ export class AccountMoveService {
this.copyMutings(src, dst),
this.copyRoles(src, dst),
this.updateLists(src, dst),
+ this.antennaService.onMoveAccount(src, dst),
]);
} catch {
/* skip if any error happens */
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 828cf4f706..ec79675b06 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -5,18 +5,20 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
-import type { MiAntenna } from '@/models/Antenna.js';
-import type { MiNote } from '@/models/Note.js';
-import type { MiUser } from '@/models/User.js';
+import { In } from 'typeorm';
+import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { UtilityService } from '@/core/UtilityService.js';
+import { bindThis } from '@/decorators.js';
+import { DI } from '@/di-symbols.js';
import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js';
-import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
-import { UtilityService } from '@/core/UtilityService.js';
-import { bindThis } from '@/decorators.js';
-import type { GlobalEvents } from '@/core/GlobalEventService.js';
-import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
+import type { MiAntenna } from '@/models/Antenna.js';
+import type { MiNote } from '@/models/Note.js';
+import type { MiUser } from '@/models/User.js';
+import { CacheService } from './CacheService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@@ -37,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
+ private cacheService: CacheService,
private utilityService: UtilityService,
private globalEventService: GlobalEventService,
private fanoutTimelineService: FanoutTimelineService,
@@ -111,9 +114,6 @@ export class AntennaService implements OnApplicationShutdown {
@bindThis
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
- if (note.visibility === 'specified') return false;
- if (note.visibility === 'followers') return false;
-
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false;
if (antenna.excludeBots && noteUser.isBot) return false;
@@ -122,6 +122,18 @@ export class AntennaService implements OnApplicationShutdown {
if (!antenna.withReplies && note.replyId != null) return false;
+ if (note.visibility === 'specified') {
+ if (note.userId !== antenna.userId) {
+ if (note.visibleUserIds == null) return false;
+ if (!note.visibleUserIds.includes(antenna.userId)) return false;
+ }
+ }
+
+ if (note.visibility === 'followers') {
+ const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId);
+ if (!isFollowing && antenna.userId !== note.userId) return false;
+ }
+
if (antenna.src === 'home') {
// TODO
} else if (antenna.src === 'list') {
@@ -209,6 +221,41 @@ export class AntennaService implements OnApplicationShutdown {
}
@bindThis
+ public async onMoveAccount(src: MiUser, dst: MiUser): Promise<void> {
+ // There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it.
+
+ // Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list
+ const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase();
+ const antennasToMigrate = (await this.getAntennas()).filter(antenna => {
+ return antenna.users.some(user => {
+ const { username, host } = Acct.parse(user);
+ return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct;
+ });
+ });
+
+ if (antennasToMigrate.length === 0) return;
+
+ const antennaIds = antennasToMigrate.map(x => x.id);
+
+ // Update the antennas by appending dst users acct to the users list
+ const dstUserAcct = '@' + Acct.toString({ username: dst.username, host: dst.host });
+
+ await this.antennasRepository.createQueryBuilder('antenna')
+ .update()
+ .set({
+ users: () => 'array_append(antenna.users, :dstUserAcct)',
+ })
+ .where('antenna.id IN (:...antennaIds)', { antennaIds })
+ .setParameters({ dstUserAcct })
+ .execute();
+
+ // announce update to event
+ for (const newAntenna of await this.antennasRepository.findBy({ id: In(antennaIds) })) {
+ this.globalEventService.publishInternalEvent('antennaUpdated', newAntenna);
+ }
+ }
+
+ @bindThis
public dispose(): void {
this.redisForSub.off('message', this.onRedisMessage);
}
diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts
index b0e8cfb61c..9d294a80cb 100644
--- a/packages/backend/src/core/ChatService.ts
+++ b/packages/backend/src/core/ChatService.ts
@@ -232,7 +232,7 @@ export class ChatService {
const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
- //this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
+ this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
}, 3000);
}
@@ -302,7 +302,7 @@ export class ChatService {
if (marker == null) continue;
this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
- //this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
+ this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
}
}, 3000);
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index 1550fe3d3c..5f1e373429 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -522,9 +522,16 @@ export class DriveService {
const policies = await this.roleService.getUserPolicies(user.id);
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
+ const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb;
this.registerLogger.debug('drive capacity override applied');
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
+ if (maxFileSize < info.size) {
+ if (isLocalUser) {
+ throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.');
+ }
+ }
+
// If usage limit exceeded
if (driveCapacity < usage + info.size) {
if (isLocalUser) {
diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts
index b05af99c5e..1ffeb4b3a4 100644
--- a/packages/backend/src/core/FanoutTimelineEndpointService.ts
+++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts
@@ -8,10 +8,12 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
+import type { MiMeta } from '@/models/Meta.js';
import { Packed } from '@/misc/json-schema.js';
import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
+import { UtilityService } from '@/core/UtilityService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
@@ -30,6 +32,7 @@ type TimelineOptions = {
alwaysIncludeMyNotes?: boolean;
ignoreAuthorFromBlock?: boolean;
ignoreAuthorFromMute?: boolean;
+ ignoreAuthorFromInstanceBlock?: boolean;
excludeNoFiles?: boolean;
excludeReplies?: boolean;
excludePureRenotes: boolean;
@@ -42,9 +45,13 @@ export class FanoutTimelineEndpointService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
private noteEntityService: NoteEntityService,
private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
+ private utilityService: UtilityService,
) {
}
@@ -54,7 +61,7 @@ export class FanoutTimelineEndpointService {
}
@bindThis
- private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
+ async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
@@ -119,6 +126,19 @@ export class FanoutTimelineEndpointService {
};
}
+ {
+ const parentFilter = filter;
+ filter = (note) => {
+ if (!ps.ignoreAuthorFromInstanceBlock) {
+ if (this.utilityService.isBlockedHost(this.meta.blockedHosts, note.userHost)) return false;
+ }
+ if (note.userId !== note.renoteUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.renoteUserHost)) return false;
+ if (note.userId !== note.replyUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.replyUserHost)) return false;
+
+ return parentFilter(note);
+ };
+ }
+
const redisTimeline: MiNote[] = [];
let readFromRedis = 0;
let lastSuccessfulRate = 1; // rateをキャッシュする?
diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts
index 6f978b34c8..6f60475442 100644
--- a/packages/backend/src/core/ImageProcessingService.ts
+++ b/packages/backend/src/core/ImageProcessingService.ts
@@ -34,6 +34,7 @@ export const webpDefault: sharp.WebpOptions = {
smartSubsample: true,
mixed: true,
effort: 2,
+ loop: 0,
};
export const avifDefault: sharp.AvifOptions = {
diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts
index 00208927e2..28d980f718 100644
--- a/packages/backend/src/core/MfmService.ts
+++ b/packages/backend/src/core/MfmService.ts
@@ -6,7 +6,7 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
-import { Window, XMLSerializer } from 'happy-dom';
+import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
@@ -23,6 +23,8 @@ type ChildNode = DefaultTreeAdapterMap['childNode'];
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
+export type Appender = (document: Document, body: HTMLParagraphElement) => void;
+
@Injectable()
export class MfmService {
constructor(
@@ -267,7 +269,7 @@ export class MfmService {
}
@bindThis
- public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
+ public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
if (nodes == null) {
return null;
}
@@ -492,6 +494,10 @@ export class MfmService {
appendChildren(nodes, body);
+ for (const additionalAppender of additionalAppenders) {
+ additionalAppender(doc, body);
+ }
+
// Remove the unnecessary namespace
const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 1ddb2b173d..469426f87e 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -576,7 +576,14 @@ export class NoteCreateService implements OnApplicationShutdown {
noteId: note.id,
}, {
delay,
- removeOnComplete: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts
index 1479bb00d9..9333c1ebc5 100644
--- a/packages/backend/src/core/PushNotificationService.ts
+++ b/packages/backend/src/core/PushNotificationService.ts
@@ -22,6 +22,7 @@ type PushNotificationsTypes = {
note: Packed<'Note'>;
};
'readAllNotifications': undefined;
+ newChatMessage: Packed<'ChatMessage'>;
};
// Reduce length because push message servers have character limits
diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts
index 412ab33b3f..119eb49c02 100644
--- a/packages/backend/src/core/QueryService.ts
+++ b/packages/backend/src/core/QueryService.ts
@@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
-import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
+import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm';
@@ -36,6 +36,9 @@ export class QueryService {
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
private idService: IdService,
) {
}
@@ -251,4 +254,37 @@ export class QueryService {
q.setParameters(mutingQuery.getParameters());
}
+
+ @bindThis
+ public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
+ let nonBlockedHostQuery: (part: string) => string;
+ if (this.meta.blockedHosts.length === 0) {
+ nonBlockedHostQuery = () => '1=1';
+ } else {
+ nonBlockedHostQuery = (match: string) => `${match} NOT ILIKE ALL(ARRAY[:...blocked])`;
+ q.setParameters({ blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) });
+ }
+
+ if (excludeAuthor) {
+ const instanceSuspension = (user: string) => new Brackets(qb => qb
+ .where(`note.${user}Id IS NULL`) // no corresponding user
+ .orWhere(`note.userId = note.${user}Id`)
+ .orWhere(`note.${user}Host IS NULL`) // local
+ .orWhere(nonBlockedHostQuery(`note.${user}Host`)));
+
+ q
+ .andWhere(instanceSuspension('replyUser'))
+ .andWhere(instanceSuspension('renoteUser'));
+ } else {
+ const instanceSuspension = (user: string) => new Brackets(qb => qb
+ .where(`note.${user}Id IS NULL`) // no corresponding user
+ .orWhere(`note.${user}Host IS NULL`) // local
+ .orWhere(nonBlockedHostQuery(`note.${user}Host`)));
+
+ q
+ .andWhere(instanceSuspension('user'))
+ .andWhere(instanceSuspension('replyUser'))
+ .andWhere(instanceSuspension('renoteUser'));
+ }
+ }
}
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index da76dd1284..a1e806816b 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -5,6 +5,8 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
+import { MetricsTime, type JobType } from 'bullmq';
+import { parse as parseRedisInfo } from 'redis-info';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
@@ -38,6 +40,18 @@ import type {
import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
+export const QUEUE_TYPES = [
+ 'system',
+ 'endedPollNotification',
+ 'deliver',
+ 'inbox',
+ 'db',
+ 'relationship',
+ 'objectStorage',
+ 'userWebhookDeliver',
+ 'systemWebhookDeliver',
+] as const;
+
@Injectable()
export class QueueService {
constructor(
@@ -57,50 +71,58 @@ export class QueueService {
this.systemQueue.add('tickCharts', {
}, {
repeat: { pattern: '55 * * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
this.systemQueue.add('resyncCharts', {
}, {
repeat: { pattern: '0 0 * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
this.systemQueue.add('cleanCharts', {
}, {
repeat: { pattern: '0 0 * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
this.systemQueue.add('aggregateRetention', {
}, {
repeat: { pattern: '0 0 * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
this.systemQueue.add('clean', {
}, {
repeat: { pattern: '0 0 * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
this.systemQueue.add('checkExpiredMutings', {
}, {
repeat: { pattern: '*/5 * * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
this.systemQueue.add('bakeBufferedReactions', {
}, {
repeat: { pattern: '0 0 * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
- removeOnComplete: true,
+ removeOnComplete: 10,
+ removeOnFail: 30,
});
}
@@ -122,13 +144,21 @@ export class QueueService {
isSharedInbox,
};
- return this.deliverQueue.add(to, data, {
+ const label = to.replace('https://', '').replace('/inbox', '');
+
+ return this.deliverQueue.add(label, data, {
attempts: this.config.deliverJobMaxAttempts ?? 12,
backoff: {
type: 'custom',
},
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -150,12 +180,18 @@ export class QueueService {
backoff: {
type: 'custom',
},
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
};
await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({
- name: d[0],
+ name: d[0].replace('https://', '').replace('/inbox', ''),
data: {
user,
content: contentBody,
@@ -176,13 +212,21 @@ export class QueueService {
signature,
};
- return this.inboxQueue.add('', data, {
+ const label = (activity.id ?? '').replace('https://', '').replace('/activity', '');
+
+ return this.inboxQueue.add(label, data, {
attempts: this.config.inboxJobMaxAttempts ?? 8,
backoff: {
type: 'custom',
},
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -191,8 +235,14 @@ export class QueueService {
return this.dbQueue.add('deleteDriveFiles', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -201,8 +251,14 @@ export class QueueService {
return this.dbQueue.add('exportCustomEmojis', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -211,8 +267,14 @@ export class QueueService {
return this.dbQueue.add('exportNotes', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -221,8 +283,14 @@ export class QueueService {
return this.dbQueue.add('exportClips', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -231,8 +299,14 @@ export class QueueService {
return this.dbQueue.add('exportFavorites', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -243,8 +317,14 @@ export class QueueService {
excludeMuting,
excludeInactive,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -253,8 +333,14 @@ export class QueueService {
return this.dbQueue.add('exportMuting', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -263,8 +349,14 @@ export class QueueService {
return this.dbQueue.add('exportBlocking', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -273,8 +365,14 @@ export class QueueService {
return this.dbQueue.add('exportUserLists', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -283,8 +381,14 @@ export class QueueService {
return this.dbQueue.add('exportAntennas', {
user: { id: user.id },
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -295,8 +399,14 @@ export class QueueService {
fileId: fileId,
withReplies,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -312,8 +422,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -323,8 +439,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -344,8 +466,14 @@ export class QueueService {
name,
data,
opts: {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
},
};
}
@@ -356,8 +484,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -367,8 +501,14 @@ export class QueueService {
user: { id: user.id },
fileId: fileId,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -378,8 +518,14 @@ export class QueueService {
user: { id: user.id },
antenna,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -389,8 +535,14 @@ export class QueueService {
user: { id: user.id },
soft: opts.soft,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -440,8 +592,14 @@ export class QueueService {
withReplies: data.withReplies,
},
opts: {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
...opts,
},
};
@@ -452,16 +610,28 @@ export class QueueService {
return this.objectStorageQueue.add('deleteFile', {
key: key,
}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@bindThis
public createCleanRemoteFilesJob() {
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -492,8 +662,14 @@ export class QueueService {
backoff: {
type: 'custom',
},
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@@ -523,21 +699,201 @@ export class QueueService {
backoff: {
type: 'custom',
},
- removeOnComplete: true,
- removeOnFail: true,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
});
}
@bindThis
- public destroy() {
- this.deliverQueue.once('cleaned', (jobs, status) => {
- //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
- });
- this.deliverQueue.clean(0, 0, 'delayed');
+ private getQueue(type: typeof QUEUE_TYPES[number]): Bull.Queue {
+ switch (type) {
+ case 'system': return this.systemQueue;
+ case 'endedPollNotification': return this.endedPollNotificationQueue;
+ case 'deliver': return this.deliverQueue;
+ case 'inbox': return this.inboxQueue;
+ case 'db': return this.dbQueue;
+ case 'relationship': return this.relationshipQueue;
+ case 'objectStorage': return this.objectStorageQueue;
+ case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
+ case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
+ default: throw new Error(`Unrecognized queue type: ${type}`);
+ }
+ }
+
+ @bindThis
+ public async queueClear(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') {
+ const queue = this.getQueue(queueType);
+
+ if (state === '*') {
+ await Promise.all([
+ queue.clean(0, 0, 'completed'),
+ queue.clean(0, 0, 'wait'),
+ queue.clean(0, 0, 'active'),
+ queue.clean(0, 0, 'paused'),
+ queue.clean(0, 0, 'prioritized'),
+ queue.clean(0, 0, 'delayed'),
+ queue.clean(0, 0, 'failed'),
+ ]);
+ } else {
+ await queue.clean(0, 0, state);
+ }
+ }
+
+ @bindThis
+ public async queuePromoteJobs(queueType: typeof QUEUE_TYPES[number]) {
+ const queue = this.getQueue(queueType);
+ await queue.promoteJobs();
+ }
+
+ @bindThis
+ public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
+ const queue = this.getQueue(queueType);
+ const job: Bull.Job | null = await queue.getJob(jobId);
+ if (job) {
+ if (job.finishedOn != null) {
+ await job.retry();
+ } else {
+ await job.promote();
+ }
+ }
+ }
+
+ @bindThis
+ public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
+ const queue = this.getQueue(queueType);
+ const job: Bull.Job | null = await queue.getJob(jobId);
+ if (job) {
+ await job.remove();
+ }
+ }
+
+ @bindThis
+ private packJobData(job: Bull.Job) {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : [];
+ stacktrace.reverse();
- this.inboxQueue.once('cleaned', (jobs, status) => {
- //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
+ return {
+ id: job.id,
+ name: job.name,
+ data: job.data,
+ opts: job.opts,
+ timestamp: job.timestamp,
+ processedOn: job.processedOn,
+ processedBy: job.processedBy,
+ finishedOn: job.finishedOn,
+ progress: job.progress,
+ attempts: job.attemptsMade,
+ delay: job.delay,
+ failedReason: job.failedReason,
+ stacktrace: stacktrace,
+ returnValue: job.returnvalue,
+ isFailed: !!job.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0),
+ };
+ }
+
+ @bindThis
+ public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
+ const queue = this.getQueue(queueType);
+ const job: Bull.Job | null = await queue.getJob(jobId);
+ if (job) {
+ return this.packJobData(job);
+ } else {
+ throw new Error(`Job not found: ${jobId}`);
+ }
+ }
+
+ @bindThis
+ public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) {
+ const RETURN_LIMIT = 100;
+ const queue = this.getQueue(queueType);
+ let jobs: Bull.Job[];
+
+ if (search) {
+ jobs = await queue.getJobs(jobTypes, 0, 1000);
+
+ jobs = jobs.filter(job => {
+ const jobString = JSON.stringify(job).toLowerCase();
+ return search.toLowerCase().split(' ').every(term => {
+ return jobString.includes(term);
+ });
+ });
+
+ jobs = jobs.slice(0, RETURN_LIMIT);
+ } else {
+ jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT);
+ }
+
+ return jobs.map(job => this.packJobData(job));
+ }
+
+ @bindThis
+ public async queueGetQueues() {
+ const fetchings = QUEUE_TYPES.map(async type => {
+ const queue = this.getQueue(type);
+
+ const counts = await queue.getJobCounts();
+ const isPaused = await queue.isPaused();
+ const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
+ const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
+
+ return {
+ name: type,
+ counts: counts,
+ isPaused,
+ metrics: {
+ completed: metrics_completed,
+ failed: metrics_failed,
+ },
+ };
});
- this.inboxQueue.clean(0, 0, 'delayed');
+
+ return await Promise.all(fetchings);
+ }
+
+ @bindThis
+ public async queueGetQueue(queueType: typeof QUEUE_TYPES[number]) {
+ const queue = this.getQueue(queueType);
+ const counts = await queue.getJobCounts();
+ const isPaused = await queue.isPaused();
+ const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
+ const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
+ const db = parseRedisInfo(await (await queue.client).info());
+
+ return {
+ name: queueType,
+ qualifiedName: queue.qualifiedName,
+ counts: counts,
+ isPaused,
+ metrics: {
+ completed: metrics_completed,
+ failed: metrics_failed,
+ },
+ db: {
+ version: db.redis_version,
+ mode: db.redis_mode,
+ runId: db.run_id,
+ processId: db.process_id,
+ port: parseInt(db.tcp_port),
+ os: db.os,
+ uptime: parseInt(db.uptime_in_seconds),
+ memory: {
+ total: parseInt(db.total_system_memory) || parseInt(db.maxmemory),
+ used: parseInt(db.used_memory),
+ fragmentationRatio: parseInt(db.mem_fragmentation_ratio),
+ peak: parseInt(db.used_memory_peak),
+ },
+ clients: {
+ connected: parseInt(db.connected_clients),
+ blocked: parseInt(db.blocked_clients),
+ },
+ },
+ };
}
}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 601959cc96..fc97780ba3 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -46,6 +46,7 @@ export type RolePolicies = {
canUseTranslator: boolean;
canHideAds: boolean;
driveCapacityMb: number;
+ maxFileSizeMb: number;
alwaysMarkNsfw: boolean;
canUpdateBioMedia: boolean;
pinLimit: number;
@@ -81,6 +82,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canUseTranslator: true,
canHideAds: false,
driveCapacityMb: 100,
+ maxFileSizeMb: 10,
alwaysMarkNsfw: false,
canUpdateBioMedia: true,
pinLimit: 5,
@@ -391,6 +393,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
+ maxFileSizeMb: calc('maxFileSizeMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)),
pinLimit: calc('pinLimit', vs => Math.max(...vs)),
diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts
index aa787c93de..d94281920e 100644
--- a/packages/backend/src/core/SearchService.ts
+++ b/packages/backend/src/core/SearchService.ts
@@ -234,6 +234,7 @@ export class SearchService {
}
this.queryService.generateVisibilityQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
@@ -295,9 +296,14 @@ export class SearchService {
this.cacheService.userBlockedCache.fetch(me.id),
])
: [new Set<string>(), new Set<string>()];
- const notes = (await this.notesRepository.findBy({
- id: In(res.hits.map(x => x.id)),
- })).filter(note => {
+
+ const query = this.notesRepository.createQueryBuilder('note');
+
+ query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) });
+
+ this.queryService.generateBlockedHostQueryForNote(query);
+
+ const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
return true;
diff --git a/packages/backend/src/core/SystemAccountService.ts b/packages/backend/src/core/SystemAccountService.ts
index 1e050c3054..53c047dd74 100644
--- a/packages/backend/src/core/SystemAccountService.ts
+++ b/packages/backend/src/core/SystemAccountService.ts
@@ -5,11 +5,14 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
+import type { OnApplicationShutdown } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm';
+import * as Redis from 'ioredis';
import bcrypt from 'bcryptjs';
import { MiLocalUser, MiUser } from '@/models/User.js';
import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js';
import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@@ -20,10 +23,13 @@ import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const;
@Injectable()
-export class SystemAccountService {
+export class SystemAccountService implements OnApplicationShutdown {
private cache: MemoryKVCache<MiLocalUser>;
constructor(
+ @Inject(DI.redisForSub)
+ private redisForSub: Redis.Redis,
+
@Inject(DI.db)
private db: DataSource,
@@ -42,6 +48,31 @@ export class SystemAccountService {
private idService: IdService,
) {
this.cache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 10); // 10m
+
+ this.redisForSub.on('message', this.onMessage);
+ }
+
+ @bindThis
+ private async onMessage(_: string, data: string): Promise<void> {
+ const obj = JSON.parse(data);
+
+ if (obj.channel === 'internal') {
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
+ switch (type) {
+ case 'metaUpdated': {
+ if (body.before != null && body.before.name !== body.after.name) {
+ for (const account of SYSTEM_ACCOUNT_TYPES) {
+ await this.updateCorrespondingUserProfile(account, {
+ name: body.after.name,
+ });
+ }
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
}
@bindThis
@@ -145,7 +176,7 @@ export class SystemAccountService {
@bindThis
public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: {
- name?: string;
+ name?: string | null;
description?: MiUserProfile['description'];
}): Promise<MiLocalUser> {
const user = await this.fetch(type);
@@ -169,4 +200,15 @@ export class SystemAccountService {
return updated;
}
+
+ @bindThis
+ public dispose(): void {
+ this.redisForSub.off('message', this.onMessage);
+ this.cache.dispose();
+ }
+
+ @bindThis
+ public onApplicationShutdown(signal?: string): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 222153fd2a..9cf985b688 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -411,8 +411,8 @@ export class WebhookTestService {
name: user.name,
username: user.username,
host: user.host,
- avatarUrl: user.avatarUrl,
- avatarBlurhash: user.avatarBlurhash,
+ avatarUrl: user.avatarId == null ? null : user.avatarUrl,
+ avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({
id: it.id,
angle: it.angle,
@@ -441,8 +441,8 @@ export class WebhookTestService {
createdAt: new Date().toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null,
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
- bannerUrl: user.bannerUrl,
- bannerBlurhash: user.bannerBlurhash,
+ bannerUrl: user.bannerId == null ? null : user.bannerUrl,
+ bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
isLocked: user.isLocked,
isSilenced: false,
isSuspended: user.isSuspended,
diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts
index 4036d2794a..f4c07e472c 100644
--- a/packages/backend/src/core/activitypub/ApMfmService.ts
+++ b/packages/backend/src/core/activitypub/ApMfmService.ts
@@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common';
import * as mfm from 'mfm-js';
-import { MfmService } from '@/core/MfmService.js';
+import { MfmService, Appender } from '@/core/MfmService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { extractApHashtagObjects } from './models/tag.js';
@@ -25,17 +25,17 @@ export class ApMfmService {
}
@bindThis
- public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) {
+ public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) {
let noMisskeyContent = false;
- const srcMfm = (note.text ?? '') + (apAppend ?? '');
+ const srcMfm = (note.text ?? '');
const parsed = mfm.parse(srcMfm);
- if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
+ if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
noMisskeyContent = true;
}
- const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers));
+ const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender);
return {
content,
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index f01874952f..55521d6e3a 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
import type { MiPoll } from '@/models/Poll.js';
import type { MiPollVote } from '@/models/PollVote.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
-import { MfmService } from '@/core/MfmService.js';
+import { MfmService, type Appender } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js';
@@ -430,10 +430,24 @@ export class ApRendererService {
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
}
- let apAppend = '';
+ const apAppend: Appender[] = [];
if (quote) {
- apAppend += `\n\nRE: ${quote}`;
+ // Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
+ // the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
+ // For compatibility, the span part should be kept as possible.
+ apAppend.push((doc, body) => {
+ body.appendChild(doc.createElement('br'));
+ body.appendChild(doc.createElement('br'));
+ const span = doc.createElement('span');
+ span.className = 'quote-inline';
+ span.appendChild(doc.createTextNode('RE: '));
+ const link = doc.createElement('a');
+ link.setAttribute('href', quote);
+ link.textContent = quote;
+ span.appendChild(link);
+ body.appendChild(span);
+ });
}
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
@@ -509,7 +523,7 @@ export class ApRendererService {
const urlPart = match[0];
const urlPartParsed = new URL(urlPart);
const restPart = maybeUrl.slice(match[0].length);
-
+
return `<a href="${urlPartParsed.href}" rel="me nofollow noopener" target="_blank">${urlPart}</a>${restPart}`;
} catch (e) {
return maybeUrl;
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index e252ff509e..d4769d24d4 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -486,8 +486,8 @@ export class UserEntityService implements OnModuleInit {
name: user.name,
username: user.username,
host: user.host,
- avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
- avatarBlurhash: user.avatarBlurhash,
+ avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user),
+ avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash),
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
id: ud.id,
angle: ud.angle || undefined,
@@ -533,8 +533,8 @@ export class UserEntityService implements OnModuleInit {
createdAt: this.idService.parse(user.id).date.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
- bannerUrl: user.bannerUrl,
- bannerBlurhash: user.bannerBlurhash,
+ bannerUrl: user.bannerId == null ? null : user.bannerUrl,
+ bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
isSuspended: user.isSuspended,