Merge remote-tracking branch 'misskey-original/develop' into develop
# Conflicts: # README.md # locales/en-US.yml # locales/index.d.ts # locales/ja-JP.yml # package.json # packages/backend/src/core/CustomEmojiService.ts # packages/backend/src/core/NoteCreateService.ts # packages/backend/src/core/RoleService.ts # packages/backend/src/core/activitypub/models/ApNoteService.ts # packages/backend/src/core/activitypub/models/ApPersonService.ts # packages/backend/src/server/api/endpoints/admin/emoji/update.ts # packages/frontend/src/components/MkDialog.vue # packages/frontend/src/components/MkEmojiPicker.section.vue # packages/frontend/src/components/MkEmojiPicker.vue # packages/frontend/src/components/global/MkCustomEmoji.vue # packages/frontend/src/const.ts # packages/frontend/src/os.ts # packages/frontend/src/pages/admin/roles.editor.vue # packages/frontend/src/pages/admin/roles.vue # packages/frontend/src/pages/admin/security.vue # pnpm-lock.yaml
This commit is contained in:
commit
1947a53af6
215 changed files with 6717 additions and 4288 deletions
|
|
@ -20,7 +20,6 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
|||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
|
@ -60,7 +59,6 @@ export class AccountMoveService {
|
|||
private instanceChart: InstanceChart,
|
||||
private metaService: MetaService,
|
||||
private relayService: RelayService,
|
||||
private cacheService: CacheService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
|
@ -84,7 +82,7 @@ export class AccountMoveService {
|
|||
Object.assign(src, update);
|
||||
|
||||
// Update cache
|
||||
this.cacheService.uriPersonCache.set(srcUri, src);
|
||||
this.globalEventService.publishInternalEvent('localUserUpdated', src);
|
||||
|
||||
const srcPerson = await this.apRendererService.renderPerson(src);
|
||||
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
|
||||
|
|
|
|||
|
|
@ -128,10 +128,13 @@ export class CacheService implements OnApplicationShutdown {
|
|||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'userChangeSuspendedState':
|
||||
case 'remoteUserUpdated': {
|
||||
case 'userChangeDeletedState':
|
||||
case 'remoteUserUpdated':
|
||||
case 'localUserUpdated': {
|
||||
const user = await this.usersRepository.findOneBy({ id: body.id });
|
||||
if (user == null) {
|
||||
this.userByIdCache.delete(body.id);
|
||||
this.localUserByIdCache.delete(body.id);
|
||||
for (const [k, v] of this.uriPersonCache.cache.entries()) {
|
||||
if (v.value?.id === body.id) {
|
||||
this.uriPersonCache.delete(k);
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ import { FlashEntityService } from './entities/FlashEntityService.js';
|
|||
import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
|
||||
import { RoleEntityService } from './entities/RoleEntityService.js';
|
||||
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
|
||||
import { MetaEntityService } from './entities/MetaEntityService.js';
|
||||
|
||||
import { ApAudienceService } from './activitypub/ApAudienceService.js';
|
||||
import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
|
||||
|
|
@ -258,6 +259,7 @@ const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisti
|
|||
const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
|
||||
const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
|
||||
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
|
||||
const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
|
||||
|
||||
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
|
||||
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
|
||||
|
|
@ -399,6 +401,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
FlashLikeEntityService,
|
||||
RoleEntityService,
|
||||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
|
|
@ -536,6 +539,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$FlashLikeEntityService,
|
||||
$RoleEntityService,
|
||||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
|
|
@ -673,6 +677,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
FlashLikeEntityService,
|
||||
RoleEntityService,
|
||||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
|
|
@ -809,6 +814,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$FlashLikeEntityService,
|
||||
$RoleEntityService,
|
||||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
|
|
|
|||
|
|
@ -478,6 +478,11 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
return this.emojiRequestsRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getEmojiByName(name: string): Promise<MiEmoji | null> {
|
||||
return this.emojisRepository.findOneBy({ name, host: IsNull() });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.cache.dispose();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountService {
|
||||
|
|
@ -18,6 +19,7 @@ export class DeleteAccountService {
|
|||
|
||||
private userSuspendService: UserSuspendService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -39,5 +41,7 @@ export class DeleteAccountService {
|
|||
await this.usersRepository.update(user.id, {
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('userChangeDeletedState', { id: user.id, isDeleted: true });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,21 +51,35 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async tryLock(host: string): Promise<boolean> {
|
||||
const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET');
|
||||
return mutex !== '1';
|
||||
// public for test
|
||||
public async tryLock(host: string): Promise<string | null> {
|
||||
// TODO: マイグレーションなのであとで消す (2024.3.1)
|
||||
this.redisClient.del(`fetchInstanceMetadata:mutex:${host}`);
|
||||
|
||||
return await this.redisClient.set(
|
||||
`fetchInstanceMetadata:mutex:v2:${host}`, '1',
|
||||
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395
|
||||
'GET' // 古い値を返す(なかったらnull)
|
||||
);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public unlock(host: string): Promise<'OK'> {
|
||||
return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0');
|
||||
// public for test
|
||||
public unlock(host: string): Promise<number> {
|
||||
return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
|
||||
const host = instance.host;
|
||||
// Acquire mutex to ensure no parallel runs
|
||||
if (!await this.tryLock(host)) return;
|
||||
|
||||
// finallyでunlockされてしまうのでtry内でロックチェックをしない
|
||||
// (returnであってもfinallyは実行される)
|
||||
if (!force && await this.tryLock(host) === '1') {
|
||||
// 1が返ってきていたらロックされているという意味なので、何もしない
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!force) {
|
||||
const _instance = await this.federatedInstanceService.fetch(host);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import isSvg from 'is-svg';
|
|||
import probeImageSize from 'probe-image-size';
|
||||
import { type predictionType } from 'nsfwjs';
|
||||
import sharp from 'sharp';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { encode } from 'blurhash';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { AiService } from '@/core/AiService.js';
|
||||
|
|
@ -122,7 +123,7 @@ export class FileInfoService {
|
|||
'image/avif',
|
||||
'image/svg+xml',
|
||||
].includes(type.mime)) {
|
||||
blurhash = await this.getBlurhash(path).catch(e => {
|
||||
blurhash = await this.getBlurhash(path, type.mime).catch(e => {
|
||||
warnings.push(`getBlurhash failed: ${e}`);
|
||||
return undefined;
|
||||
});
|
||||
|
|
@ -407,9 +408,9 @@ export class FileInfoService {
|
|||
* Calculate average color of image
|
||||
*/
|
||||
@bindThis
|
||||
private getBlurhash(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sharp(path)
|
||||
private getBlurhash(path: string, type: string): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
(await sharpBmp(path, type))
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.resize(64, 64, { fit: 'inside' })
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface MainEventTypes {
|
|||
file: Packed<'DriveFile'>;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
notificationFlushed: undefined;
|
||||
unreadNotification: Packed<'Notification'>;
|
||||
unreadMention: MiNote['id'];
|
||||
readAllUnreadMentions: undefined;
|
||||
|
|
@ -211,8 +212,10 @@ type SerializedAll<T> = {
|
|||
|
||||
export interface InternalEventTypes {
|
||||
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
|
||||
userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; };
|
||||
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
|
||||
remoteUserUpdated: { id: MiUser['id']; };
|
||||
localUserUpdated: { id: MiUser['id']; };
|
||||
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
|
||||
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
|
||||
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
|
|
@ -151,8 +153,6 @@ type Option = {
|
|||
export class NoteCreateService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
||||
public static ContainsProhibitedWordsError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
|
@ -464,6 +464,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
|
||||
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
|
||||
}
|
||||
|
||||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
|
||||
|
|
@ -902,7 +907,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
const mentions = extractMentions(tokens);
|
||||
let mentionedUsers = (await Promise.all(mentions.map(m =>
|
||||
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
|
||||
))).filter(x => x != null) as MiUser[];
|
||||
))).filter(isNotNull);
|
||||
|
||||
// Drop duplicate users
|
||||
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
||||
|
|
@ -1076,6 +1081,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
public async checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
|
||||
if (prohibitedWords == null) {
|
||||
prohibitedWords = (await this.metaService.fetch()).prohibitedWords;
|
||||
}
|
||||
|
||||
if (
|
||||
this.utilityService.isKeyWordIncluded(
|
||||
this.utilityService.concatNoteContentsForKeyWordCheck(content),
|
||||
prohibitedWords,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
|
|
|||
|
|
@ -88,46 +88,47 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
userId: MiUser['id'],
|
||||
notes: (MiNote | Packed<'Note'>)[],
|
||||
): Promise<void> {
|
||||
const readMentions: (MiNote | Packed<'Note'>)[] = [];
|
||||
const readSpecifiedNotes: (MiNote | Packed<'Note'>)[] = [];
|
||||
if (notes.length === 0) return;
|
||||
|
||||
const noteIds = new Set<MiNote['id']>();
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.mentions && note.mentions.includes(userId)) {
|
||||
readMentions.push(note);
|
||||
noteIds.add(note.id);
|
||||
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
|
||||
readSpecifiedNotes.push(note);
|
||||
noteIds.add(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) {
|
||||
// Remove the record
|
||||
await this.noteUnreadsRepository.delete({
|
||||
userId: userId,
|
||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
|
||||
});
|
||||
if (noteIds.size === 0) return;
|
||||
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
// Remove the record
|
||||
await this.noteUnreadsRepository.delete({
|
||||
userId: userId,
|
||||
noteId: In(Array.from(noteIds)),
|
||||
});
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
}));
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
}));
|
||||
}
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
}));
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -122,6 +122,14 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'mutualFollow') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
]);
|
||||
if (!(isFollowing && isFollower)) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'followingOrFollower') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
|
|
@ -155,6 +163,8 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
|
||||
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
|
||||
|
||||
if (packed == null) return null;
|
||||
|
||||
// Publish notification event
|
||||
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
||||
|
||||
|
|
@ -204,6 +214,15 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
*/
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async flushAllNotifications(userId: MiUser['id']) {
|
||||
await Promise.all([
|
||||
this.redisClient.del(`notificationTimeline:${userId}`),
|
||||
this.redisClient.del(`latestReadNotification:${userId}`),
|
||||
]);
|
||||
this.globalEventService.publishMainStream(userId, 'notificationFlushed');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
|
|
|||
|
|
@ -115,12 +115,19 @@ export class PushNotificationService implements OnApplicationShutdown {
|
|||
endpoint: subscription.endpoint,
|
||||
auth: subscription.auth,
|
||||
publickey: subscription.publickey,
|
||||
}).then(() => {
|
||||
this.refreshCache(userId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public refreshCache(userId: string): void {
|
||||
this.subscriptionsCache.refresh(userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.subscriptionsCache.dispose();
|
||||
|
|
|
|||
|
|
@ -322,35 +322,36 @@ export class ReactionService {
|
|||
//#endregion
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字列タイプのレガシーな形式のリアクションを現在の形式に変換しつつ、
|
||||
* データベース上には存在する「0個のリアクションがついている」という情報を削除する。
|
||||
*/
|
||||
@bindThis
|
||||
public convertLegacyReactions(reactions: Record<string, number>) {
|
||||
const _reactions = {} as Record<string, number>;
|
||||
public convertLegacyReactions(reactions: MiNote['reactions']): MiNote['reactions'] {
|
||||
return Object.entries(reactions)
|
||||
.filter(([, count]) => {
|
||||
// `ReactionService.prototype.delete`ではリアクション削除時に、
|
||||
// `MiNote['reactions']`のエントリの値をデクリメントしているが、
|
||||
// デクリメントしているだけなのでエントリ自体は0を値として持つ形で残り続ける。
|
||||
// そのため、この処理がなければ、「0個のリアクションがついている」ということになってしまう。
|
||||
return count > 0;
|
||||
})
|
||||
.map(([reaction, count]) => {
|
||||
// unchecked indexed access
|
||||
const convertedReaction = legacies[reaction] as string | undefined;
|
||||
|
||||
for (const reaction of Object.keys(reactions)) {
|
||||
if (reactions[reaction] <= 0) continue;
|
||||
const key = this.decodeReaction(convertedReaction ?? reaction).reaction;
|
||||
|
||||
if (Object.keys(legacies).includes(reaction)) {
|
||||
if (_reactions[legacies[reaction]]) {
|
||||
_reactions[legacies[reaction]] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[legacies[reaction]] = reactions[reaction];
|
||||
}
|
||||
} else {
|
||||
if (_reactions[reaction]) {
|
||||
_reactions[reaction] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[reaction] = reactions[reaction];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [key, count] as const;
|
||||
})
|
||||
.reduce<MiNote['reactions']>((acc, [key, count]) => {
|
||||
// unchecked indexed access
|
||||
const prevCount = acc[key] as number | undefined;
|
||||
|
||||
const _reactions2 = {} as Record<string, number>;
|
||||
acc[key] = (prevCount ?? 0) + count;
|
||||
|
||||
for (const reaction of Object.keys(_reactions)) {
|
||||
_reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
|
||||
}
|
||||
|
||||
return _reactions2;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export type RolePolicies = {
|
|||
canPublicNote: boolean;
|
||||
canEditNote: boolean;
|
||||
canScheduleNote: boolean;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
inviteLimitCycle: number;
|
||||
|
|
@ -68,6 +69,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
gtlAvailable: true,
|
||||
ltlAvailable: true,
|
||||
canPublicNote: true,
|
||||
mentionLimit: 20,
|
||||
canEditNote: true,
|
||||
canScheduleNote: true,
|
||||
canInvite: false,
|
||||
|
|
@ -212,17 +214,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private evalCond(user: MiUser, value: RoleCondFormulaValue): boolean {
|
||||
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
|
||||
try {
|
||||
switch (value.type) {
|
||||
case 'and': {
|
||||
return value.values.every(v => this.evalCond(user, v));
|
||||
return value.values.every(v => this.evalCond(user, roles, v));
|
||||
}
|
||||
case 'or': {
|
||||
return value.values.some(v => this.evalCond(user, v));
|
||||
return value.values.some(v => this.evalCond(user, roles, v));
|
||||
}
|
||||
case 'not': {
|
||||
return !this.evalCond(user, value.value);
|
||||
return !this.evalCond(user, roles, value.value);
|
||||
}
|
||||
case 'roleAssignedTo': {
|
||||
return roles.some(r => r.id === value.roleId);
|
||||
}
|
||||
case 'isLocal': {
|
||||
return this.userEntityService.isLocalUser(user);
|
||||
|
|
@ -284,7 +289,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
const assigns = await this.getUserAssigns(userId);
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
return [...assignedRoles, ...matchedCondRoles];
|
||||
}
|
||||
|
||||
|
|
@ -297,13 +302,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
const assignedRoleIds = assigns.map(x => x.roleId);
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
||||
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||
if (badgeCondRoles.length > 0) {
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula));
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||
} else {
|
||||
return assignedBadgeRoles;
|
||||
|
|
@ -339,6 +344,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
|
||||
canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)),
|
||||
canEditNote: calc('canEditNote', vs => vs.some(v => v === true)),
|
||||
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
|
||||
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
||||
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
||||
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type { Config } from '@/config.js';
|
|||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import type { ThinUser } from '@/queue/types.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
|
|
@ -94,21 +95,35 @@ export class UserFollowingService implements OnModuleInit {
|
|||
this.userBlockingService = this.moduleRef.get('UserBlockingService');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async follow(
|
||||
_follower: { id: MiUser['id'] },
|
||||
_followee: { id: MiUser['id'] },
|
||||
_follower: ThinUser,
|
||||
_followee: ThinUser,
|
||||
{ requestId, silent = false, withReplies }: {
|
||||
requestId?: string,
|
||||
silent?: boolean,
|
||||
withReplies?: boolean,
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
/**
|
||||
* 必ず最新のユーザー情報を取得する
|
||||
*/
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: _follower.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: _followee.id }),
|
||||
]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser];
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
// What?
|
||||
throw new Error('Remote user cannot follow remote user.');
|
||||
}
|
||||
|
||||
// check blocking
|
||||
const [blocking, blocked] = await Promise.all([
|
||||
this.userBlockingService.checkBlocked(follower.id, followee.id),
|
||||
|
|
@ -129,6 +144,24 @@ export class UserFollowingService implements OnModuleInit {
|
|||
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
||||
}
|
||||
|
||||
if (await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
})) {
|
||||
// すでにフォロー関係が存在している場合
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
// リモート → ローカル: acceptを送り返しておしまい
|
||||
this.deliverAccept(follower, followee, requestId);
|
||||
return;
|
||||
}
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
// ローカル → リモート/ローカル: 例外
|
||||
throw new IdentifiableError('ec3f65c0-a9d1-47d9-8791-b2e7b9dcdced', 'already following');
|
||||
}
|
||||
}
|
||||
|
||||
const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
|
||||
// フォロー対象が鍵アカウントである or
|
||||
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
|
||||
|
|
@ -189,8 +222,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
await this.insertFollowingDoc(followee, follower, silent, withReplies);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
this.deliverAccept(follower, followee, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -571,8 +603,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
await this.insertFollowingDoc(followee, follower, false, request.withReplies);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as MiPartialLocalUser, request.requestId!), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined);
|
||||
}
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,20 @@ export class UtilityService {
|
|||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public concatNoteContentsForKeyWordCheck(content: {
|
||||
cw?: string | null;
|
||||
text?: string | null;
|
||||
pollChoices?: string[] | null;
|
||||
others?: string[] | null;
|
||||
}): string {
|
||||
/**
|
||||
* ノートの内容を結合してキーワードチェック用の文字列を生成する
|
||||
* cwとtextは内容が繋がっているかもしれないので間に何も入れずにチェックする
|
||||
*/
|
||||
return `${content.cw ?? ''}${content.text ?? ''}\n${(content.pollChoices ?? []).join('\n')}\n${(content.others ?? []).join('\n')}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isKeyWordIncluded(text: string, keyWords: string[]): boolean {
|
||||
if (keyWords.length === 0) return false;
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export class WebAuthnService {
|
|||
if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
|
||||
const halfLength = (cert.length - 1) / 2;
|
||||
|
||||
const cborMap = new Map<number, number | ArrayBufferLike>();
|
||||
const cborMap = new Map<number, number | Uint8Array>();
|
||||
cborMap.set(1, 2); // kty, EC2
|
||||
cborMap.set(3, -7); // alg, ES256
|
||||
cborMap.set(-1, 1); // crv, P256
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import promiseLimit from 'promise-limit';
|
|||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { concat, unique } from '@/misc/prelude/array.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { getApIds } from './type.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { ApObject } from './type.js';
|
||||
|
|
@ -40,7 +41,7 @@ export class ApAudienceService {
|
|||
const limit = promiseLimit<MiUser | null>(2);
|
||||
const mentionedUsers = (await Promise.all(
|
||||
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))),
|
||||
)).filter((x): x is MiUser => x != null);
|
||||
)).filter(isNotNull);
|
||||
|
||||
if (toGroups.public.length > 0) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { QueueService } from '@/core/QueueService.js';
|
|||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
|
|
@ -36,7 +37,6 @@ import { ApResolverService } from './ApResolverService.js';
|
|||
import { ApAudienceService } from './ApAudienceService.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import { ApQuestionService } from './models/ApQuestionService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { Resolver } from './ApResolverService.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js';
|
||||
|
|
@ -86,7 +86,6 @@ export class ApInboxService {
|
|||
private apPersonService: ApPersonService,
|
||||
private apQuestionService: ApQuestionService,
|
||||
private queueService: QueueService,
|
||||
private cacheService: CacheService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
|
|
@ -523,7 +522,7 @@ export class ApInboxService {
|
|||
const userIds = uris
|
||||
.filter(uri => uri.startsWith(this.config.url + '/users/'))
|
||||
.map(uri => uri.split('/').at(-1))
|
||||
.filter((userId): userId is string => userId !== undefined);
|
||||
.filter(isNotNull);
|
||||
const users = await this.usersRepository.findBy({
|
||||
id: In(userIds),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ export class ApRendererService {
|
|||
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
|
||||
if (ids.length === 0) return [];
|
||||
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
|
||||
return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
|
||||
return ids.map(id => items.find(item => item.id === id)).filter(isNotNull);
|
||||
};
|
||||
|
||||
let inReplyTo;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import promiseLimit from 'promise-limit';
|
|||
import type { MiUser } from '@/models/_.js';
|
||||
import { toArray, unique } from '@/misc/prelude/array.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { isMention } from '../type.js';
|
||||
import { Resolver } from '../ApResolverService.js';
|
||||
import { ApPersonService } from './ApPersonService.js';
|
||||
|
|
@ -27,7 +28,7 @@ export class ApMentionService {
|
|||
const limit = promiseLimit<MiUser | null>(2);
|
||||
const mentionedUsers = (await Promise.all(
|
||||
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))),
|
||||
)).filter((x): x is MiUser => x != null);
|
||||
)).filter(isNotNull);
|
||||
|
||||
return mentionedUsers;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { StatusError } from '@/misc/status-error.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { NoteUpdateService } from '@/core/NoteUpdateService.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, getOneApId, isEmoji, validPost } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
|
|
@ -156,11 +158,47 @@ export class ApNoteService {
|
|||
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
|
||||
}
|
||||
|
||||
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser;
|
||||
const uri = getOneApId(note.attributedTo);
|
||||
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
||||
if (cachedActor && cachedActor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||
|
||||
//#region Contents Check
|
||||
// 添付ファイルとユーザーをこのサーバーで登録する前に内容をチェックする
|
||||
/**
|
||||
* 禁止ワードチェック
|
||||
*/
|
||||
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||
|
||||
// 解決した投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new Error('actor has been suspended');
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||
}
|
||||
|
||||
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
||||
|
|
@ -175,9 +213,6 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
// 添付ファイル
|
||||
// TODO: attachmentは必ずしもImageではない
|
||||
// TODO: attachmentは必ずしも配列ではない
|
||||
|
|
@ -226,7 +261,7 @@ export class ApNoteService {
|
|||
}
|
||||
};
|
||||
|
||||
const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string'));
|
||||
const uris = unique([note._misskey_quote, note.quoteUrl].filter(isNotNull));
|
||||
const results = await Promise.all(uris.map(tryResolveNote));
|
||||
|
||||
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
|
||||
|
|
@ -237,18 +272,6 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
// vote
|
||||
if (reply && reply.hasPoll) {
|
||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||
|
|
@ -278,8 +301,6 @@ export class ApNoteService {
|
|||
|
||||
const apEmojis = emojis.map(emoji => emoji.name);
|
||||
|
||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||
|
||||
try {
|
||||
return await this.noteCreateService.create(actor, {
|
||||
createdAt: note.published ? new Date(note.published) : null,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
|
|
@ -682,7 +683,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
// とりあえずidを別の時間で生成して順番を維持
|
||||
let td = 0;
|
||||
for (const note of featuredNotes.filter((note): note is MiNote => note != null)) {
|
||||
for (const note of featuredNotes.filter(isNotNull)) {
|
||||
td -= 1000;
|
||||
transactionalEntityManager.insert(MiUserNotePining, {
|
||||
id: this.idService.gen(Date.now() + td),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
|||
import type { IPoll } from '@/models/Poll.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { isQuestion } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
|
|
@ -51,7 +52,7 @@ export class ApQuestionService {
|
|||
|
||||
const choices = question[multiple ? 'anyOf' : 'oneOf']
|
||||
?.map((x) => x.name)
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.filter(isNotNull)
|
||||
?? [];
|
||||
|
||||
const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { isHashtag } from '../type.js';
|
||||
import type { IObject, IApHashtag } from '../type.js';
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export function extractApHashtags(tags: IObject | IObject[] | null | undefined):
|
|||
return hashtags.map(tag => {
|
||||
const m = tag.name.match(/^#(.+)/);
|
||||
return m ? m[1] : null;
|
||||
}).filter((x): x is string => x != null);
|
||||
}).filter(isNotNull);
|
||||
}
|
||||
|
||||
export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] {
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ export class DriveFileEntityService {
|
|||
options?: PackOptions,
|
||||
): Promise<Packed<'DriveFile'>[]> {
|
||||
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
|
||||
return items.filter((x): x is Packed<'DriveFile'> => x != null);
|
||||
return items.filter(isNotNull);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UtilityService } from '../UtilityService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
|
||||
@Injectable()
|
||||
export class InstanceEntityService {
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
|
|
@ -22,8 +25,11 @@ export class InstanceEntityService {
|
|||
@bindThis
|
||||
public async pack(
|
||||
instance: MiInstance,
|
||||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
): Promise<Packed<'FederationInstance'>> {
|
||||
const meta = await this.metaService.fetch();
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
firstRetrievedAt: instance.firstRetrievedAt.toISOString(),
|
||||
|
|
@ -48,6 +54,7 @@ export class InstanceEntityService {
|
|||
themeColor: instance.themeColor,
|
||||
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
|
||||
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
|
||||
moderationNote: iAmModerator ? instance.moderationNote : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
154
packages/backend/src/core/entities/MetaEntityService.ts
Normal file
154
packages/backend/src/core/entities/MetaEntityService.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import JSON5 from 'json5';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import type { AdsRepository } from '@/models/_.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
|
||||
@Injectable()
|
||||
export class MetaEntityService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.adsRepository)
|
||||
private adsRepository: AdsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private metaService: MetaService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
) { }
|
||||
|
||||
@bindThis
|
||||
public async pack(meta?: MiMeta): Promise<Packed<'MetaLite'>> {
|
||||
let instance = meta;
|
||||
|
||||
if (!instance) {
|
||||
instance = await this.metaService.fetch();
|
||||
}
|
||||
|
||||
const ads = await this.adsRepository.createQueryBuilder('ads')
|
||||
.where('ads.expiresAt > :now', { now: new Date() })
|
||||
.andWhere('ads.startsAt <= :now', { now: new Date() })
|
||||
.andWhere(new Brackets(qb => {
|
||||
// 曜日のビットフラグを確認する
|
||||
qb.where('ads.dayOfWeek & :dayOfWeek > 0', { dayOfWeek: 1 << new Date().getDay() })
|
||||
.orWhere('ads.dayOfWeek = 0');
|
||||
}))
|
||||
.getMany();
|
||||
|
||||
const packed: Packed<'MetaLite'> = {
|
||||
maintainerName: instance.maintainerName,
|
||||
maintainerEmail: instance.maintainerEmail,
|
||||
|
||||
version: this.config.version,
|
||||
providesTarball: this.config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
|
||||
name: instance.name,
|
||||
shortName: instance.shortName,
|
||||
uri: this.config.url,
|
||||
description: instance.description,
|
||||
langs: instance.langs,
|
||||
tosUrl: instance.termsOfServiceUrl,
|
||||
repositoryUrl: instance.repositoryUrl,
|
||||
feedbackUrl: instance.feedbackUrl,
|
||||
impressumUrl: instance.impressumUrl,
|
||||
privacyPolicyUrl: instance.privacyPolicyUrl,
|
||||
disableRegistration: instance.disableRegistration,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
mcaptchaSiteKey: instance.mcaptchaSitekey,
|
||||
mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl,
|
||||
enableRecaptcha: instance.enableRecaptcha,
|
||||
recaptchaSiteKey: instance.recaptchaSiteKey,
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
|
||||
bannerUrl: instance.bannerUrl,
|
||||
infoImageUrl: instance.infoImageUrl,
|
||||
serverErrorImageUrl: instance.serverErrorImageUrl,
|
||||
notFoundImageUrl: instance.notFoundImageUrl,
|
||||
iconUrl: instance.iconUrl,
|
||||
backgroundImageUrl: instance.backgroundImageUrl,
|
||||
logoImageUrl: instance.logoImageUrl,
|
||||
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
|
||||
// クライアントの手間を減らすためあらかじめJSONに変換しておく
|
||||
defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null,
|
||||
defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null,
|
||||
ads: ads.map(ad => ({
|
||||
id: ad.id,
|
||||
url: ad.url,
|
||||
place: ad.place,
|
||||
ratio: ad.ratio,
|
||||
imageUrl: ad.imageUrl,
|
||||
dayOfWeek: ad.dayOfWeek,
|
||||
})),
|
||||
notesPerOneAd: instance.notesPerOneAd,
|
||||
enableEmail: instance.enableEmail,
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
|
||||
translatorAvailable: instance.deeplAuthKey != null,
|
||||
|
||||
serverRules: instance.serverRules,
|
||||
|
||||
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
||||
|
||||
mediaProxy: this.config.mediaProxy,
|
||||
};
|
||||
|
||||
return packed;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailed(meta?: MiMeta): Promise<Packed<'MetaDetailed'>> {
|
||||
let instance = meta;
|
||||
|
||||
if (!instance) {
|
||||
instance = await this.metaService.fetch();
|
||||
}
|
||||
|
||||
const packed = await this.pack(instance);
|
||||
|
||||
const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null;
|
||||
|
||||
const packDetailed: Packed<'MetaDetailed'> = {
|
||||
...packed,
|
||||
cacheRemoteFiles: instance.cacheRemoteFiles,
|
||||
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
|
||||
requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
|
||||
proxyAccountName: proxyAccount ? proxyAccount.username : null,
|
||||
features: {
|
||||
localTimeline: instance.policies.ltlAvailable,
|
||||
globalTimeline: instance.policies.gtlAvailable,
|
||||
registration: !instance.disableRegistration,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
hcaptcha: instance.enableHcaptcha,
|
||||
recaptcha: instance.enableRecaptcha,
|
||||
turnstile: instance.enableTurnstile,
|
||||
objectStorage: instance.useObjectStorage,
|
||||
serviceWorker: instance.enableServiceWorker,
|
||||
miauth: true,
|
||||
},
|
||||
};
|
||||
|
||||
return packDetailed;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,4 +69,19 @@ export class NoteReactionEntityService implements OnModuleInit {
|
|||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
reactions: MiNoteReaction[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
options?: {
|
||||
withNote: boolean;
|
||||
},
|
||||
): Promise<Packed<'NoteReaction'>[]> {
|
||||
const opts = Object.assign({
|
||||
withNote: false,
|
||||
}, options);
|
||||
|
||||
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ import type { MiNote } from '@/models/Note.js';
|
|||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { FilterUnionByProperty, notificationTypes } from '@/types.js';
|
||||
import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { RoleEntityService } from './RoleEntityService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);
|
||||
const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']);
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]);
|
||||
|
||||
@Injectable()
|
||||
export class NotificationEntityService implements OnModuleInit {
|
||||
|
|
@ -41,6 +41,8 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
|
||||
//private userEntityService: UserEntityService,
|
||||
//private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
|
|
@ -52,130 +54,48 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
this.roleEntityService = this.moduleRef.get('RoleEntityService');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: MiNotification,
|
||||
/**
|
||||
* 通知をパックする共通処理
|
||||
*/
|
||||
async #packInternal <T extends MiNotification | MiGroupedNotification> (
|
||||
src: T,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'>> {
|
||||
): Promise<Packed<'Notification'> | null> {
|
||||
const notification = src;
|
||||
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
|
||||
|
||||
if (options.checkValidNotifier !== false && !(await this.#isValidNotifier(notification, meId))) return null;
|
||||
|
||||
const needsNote = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification;
|
||||
const noteIfNeed = needsNote ? (
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : undefined;
|
||||
const userIfNeed = 'notifierId' in notification ? (
|
||||
hint?.packedUsers != null
|
||||
? hint.packedUsers.get(notification.notifierId)
|
||||
: this.userEntityService.pack(notification.notifierId, { id: meId })
|
||||
) : undefined;
|
||||
const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
type: notification.type,
|
||||
userId: 'notifierId' in notification ? notification.notifierId : undefined,
|
||||
...(userIfNeed != null ? { user: userIfNeed } : {}),
|
||||
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
|
||||
...(notification.type === 'reaction' ? {
|
||||
reaction: notification.reaction,
|
||||
} : {}),
|
||||
...(notification.type === 'roleAssigned' ? {
|
||||
role: role,
|
||||
} : {}),
|
||||
...(notification.type === 'achievementEarned' ? {
|
||||
achievement: notification.achievement,
|
||||
} : {}),
|
||||
...(notification.type === 'app' ? {
|
||||
body: notification.customBody,
|
||||
header: notification.customHeader,
|
||||
icon: notification.customIcon,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
notifications: MiNotification[],
|
||||
meId: MiUser['id'],
|
||||
) {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
let validNotifications = notifications;
|
||||
|
||||
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
|
||||
}) : [];
|
||||
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
||||
detail: true,
|
||||
});
|
||||
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
||||
|
||||
validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId));
|
||||
|
||||
const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull);
|
||||
const users = userIds.length > 0 ? await this.usersRepository.find({
|
||||
where: { id: In(userIds) },
|
||||
}) : [];
|
||||
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId });
|
||||
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||
|
||||
// 既に解決されたフォローリクエストの通知を除外
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
if (followRequestNotifications.length > 0) {
|
||||
const reqs = await this.followRequestsRepository.find({
|
||||
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
|
||||
});
|
||||
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
|
||||
}
|
||||
|
||||
return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, {
|
||||
packedNotes,
|
||||
packedUsers,
|
||||
})));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGrouped(
|
||||
src: MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'>> {
|
||||
const notification = src;
|
||||
const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : undefined;
|
||||
const userIfNeed = 'notifierId' in notification ? (
|
||||
// if the note has been deleted, don't show this notification
|
||||
if (needsNote && !noteIfNeed) return null;
|
||||
|
||||
const needsUser = 'notifierId' in notification;
|
||||
const userIfNeed = needsUser ? (
|
||||
hint?.packedUsers != null
|
||||
? hint.packedUsers.get(notification.notifierId)
|
||||
: this.userEntityService.pack(notification.notifierId, { id: meId })
|
||||
) : undefined;
|
||||
// if the user has been deleted, don't show this notification
|
||||
if (needsUser && !userIfNeed) return null;
|
||||
|
||||
// #region Grouped notifications
|
||||
if (notification.type === 'reaction:grouped') {
|
||||
const reactions = await Promise.all(notification.reactions.map(async reaction => {
|
||||
const reactions = (await Promise.all(notification.reactions.map(async reaction => {
|
||||
const user = hint?.packedUsers != null
|
||||
? hint.packedUsers.get(reaction.userId)!
|
||||
: await this.userEntityService.pack(reaction.userId, { id: meId });
|
||||
|
|
@ -183,7 +103,12 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
user,
|
||||
reaction: reaction.reaction,
|
||||
};
|
||||
}));
|
||||
}))).filter(r => isNotNull(r.user));
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (reactions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
|
|
@ -192,14 +117,19 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
reactions,
|
||||
});
|
||||
} else if (notification.type === 'renote:grouped') {
|
||||
const users = await Promise.all(notification.userIds.map(userId => {
|
||||
const users = (await Promise.all(notification.userIds.map(userId => {
|
||||
const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(userId) : null;
|
||||
if (packedUser) {
|
||||
return packedUser;
|
||||
}
|
||||
|
||||
return this.userEntityService.pack(userId, { id: meId });
|
||||
}));
|
||||
}))).filter(isNotNull);
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (users.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
|
|
@ -208,8 +138,14 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
users,
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
const needsRole = notification.type === 'roleAssigned';
|
||||
const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
// if the role has been deleted, don't show this notification
|
||||
if (needsRole && !role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
|
|
@ -235,15 +171,16 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGroupedMany(
|
||||
notifications: MiGroupedNotification[],
|
||||
async #packManyInternal <T extends MiNotification | MiGroupedNotification> (
|
||||
notifications: T[],
|
||||
meId: MiUser['id'],
|
||||
) {
|
||||
): Promise<T[]> {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
let validNotifications = notifications;
|
||||
|
||||
validNotifications = await this.#filterValidNotifier(validNotifications, meId);
|
||||
|
||||
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
|
|
@ -269,7 +206,7 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||
|
||||
// 既に解決されたフォローリクエストの通知を除外
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<T, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
if (followRequestNotifications.length > 0) {
|
||||
const reqs = await this.followRequestsRepository.find({
|
||||
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
|
||||
|
|
@ -277,9 +214,107 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
|
||||
}
|
||||
|
||||
return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, {
|
||||
packedNotes,
|
||||
packedUsers,
|
||||
})));
|
||||
const packPromises = validNotifications.map(x => {
|
||||
return this.pack(
|
||||
x,
|
||||
meId,
|
||||
{ checkValidNotifier: false },
|
||||
{ packedNotes, packedUsers },
|
||||
);
|
||||
});
|
||||
|
||||
return (await Promise.all(packPromises)).filter(isNotNull);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: MiNotification | MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'> | null> {
|
||||
return await this.#packInternal(src, meId, options, hint);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
notifications: MiNotification[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<MiNotification[]> {
|
||||
return await this.#packManyInternal(notifications, meId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGroupedMany(
|
||||
notifications: MiGroupedNotification[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<MiGroupedNotification[]> {
|
||||
return await this.#packManyInternal(notifications, meId);
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを確認するvalidator
|
||||
*/
|
||||
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
|
||||
notification: T,
|
||||
userIdsWhoMeMuting: Set<MiUser['id']>,
|
||||
userMutedInstances: Set<string>,
|
||||
notifiers: MiUser[],
|
||||
): boolean {
|
||||
if (!('notifierId' in notification)) return true;
|
||||
if (userIdsWhoMeMuting.has(notification.notifierId)) return false;
|
||||
|
||||
const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
|
||||
|
||||
if (notifier == null) return false;
|
||||
if (notifier.host && userMutedInstances.has(notifier.host)) return false;
|
||||
|
||||
if (notifier.isSuspended) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に確認する
|
||||
*/
|
||||
async #isValidNotifier(
|
||||
notification: MiNotification | MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
): Promise<boolean> {
|
||||
return (await this.#filterValidNotifier([notification], meId)).length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に複数確認する
|
||||
*/
|
||||
async #filterValidNotifier <T extends MiNotification | MiGroupedNotification> (
|
||||
notifications: T[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<T[]> {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
userMutedInstances,
|
||||
] = await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(meId),
|
||||
this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)),
|
||||
]);
|
||||
|
||||
const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(isNotNull);
|
||||
const notifiers = notifierIds.length > 0 ? await this.usersRepository.find({
|
||||
where: { id: In(notifierIds) },
|
||||
}) : [];
|
||||
|
||||
const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => {
|
||||
const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers);
|
||||
return isValid ? notification : null;
|
||||
}))) as [T | null] ).filter(isNotNull);
|
||||
|
||||
return filteredNotifications;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { MiPage } from '@/models/Page.js';
|
|||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ export class PageEntityService {
|
|||
script: page.script,
|
||||
eyeCatchingImageId: page.eyeCatchingImageId,
|
||||
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
|
||||
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is MiDriveFile => x != null)),
|
||||
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(isNotNull)),
|
||||
likedCount: page.likedCount,
|
||||
isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import type { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
|
|
@ -385,7 +386,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null,
|
||||
alsoKnownAs: user.alsoKnownAs
|
||||
? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[])
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(isNotNull))
|
||||
: null,
|
||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue