merge upstream for 2024.2.1
This commit is contained in:
parent
eab7d5bd27
commit
af548d05ca
137 changed files with 4524 additions and 2933 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));
|
||||
|
|
|
@ -129,10 +129,12 @@ export class CacheService implements OnApplicationShutdown {
|
|||
switch (type) {
|
||||
case 'userChangeSuspendedState':
|
||||
case 'userChangeDeletedState':
|
||||
case 'remoteUserUpdated': {
|
||||
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);
|
||||
|
|
|
@ -416,6 +416,11 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
return this.emojisRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getEmojiByName(name: string): Promise<MiEmoji | null> {
|
||||
return this.emojisRepository.findOneBy({ name, host: IsNull() });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.cache.dispose();
|
||||
|
|
|
@ -69,6 +69,7 @@ export interface MainEventTypes {
|
|||
file: Packed<'DriveFile'>;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
notificationFlushed: undefined;
|
||||
unreadNotification: Packed<'Notification'>;
|
||||
unreadMention: MiNote['id'];
|
||||
readAllUnreadMentions: undefined;
|
||||
|
@ -216,6 +217,7 @@ export interface InternalEventTypes {
|
|||
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']; };
|
||||
|
|
|
@ -479,176 +479,176 @@ export class MfmService {
|
|||
|
||||
const handlers: {
|
||||
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
|
||||
} = {
|
||||
async bold(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '**';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '**';
|
||||
return el;
|
||||
},
|
||||
} = {
|
||||
async bold(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '**';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '**';
|
||||
return el;
|
||||
},
|
||||
|
||||
async small(node) {
|
||||
const el = doc.createElement('small');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
async small(node) {
|
||||
const el = doc.createElement('small');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
async strike(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '~~';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '~~';
|
||||
return el;
|
||||
},
|
||||
async strike(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '~~';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '~~';
|
||||
return el;
|
||||
},
|
||||
|
||||
async italic(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
return el;
|
||||
},
|
||||
async italic(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
return el;
|
||||
},
|
||||
|
||||
async fn(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
return el;
|
||||
},
|
||||
async fn(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
return el;
|
||||
},
|
||||
|
||||
blockCode(node) {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
blockCode(node) {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
|
||||
const nodes = node.props.code
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
const nodes = node.props.code
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
}
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
}
|
||||
|
||||
pre.appendChild(inner);
|
||||
return pre;
|
||||
},
|
||||
pre.appendChild(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
async center(node) {
|
||||
const el = doc.createElement('div');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
async center(node) {
|
||||
const el = doc.createElement('div');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode(node) {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
emojiCode(node) {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji(node) {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
},
|
||||
unicodeEmoji(node) {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
},
|
||||
|
||||
hashtag: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
a.setAttribute('class', 'hashtag');
|
||||
return a;
|
||||
},
|
||||
hashtag: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
a.setAttribute('class', 'hashtag');
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
return el;
|
||||
},
|
||||
inlineCode(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
mathInline(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
mathBlock(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
async link(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
await appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
async link(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
await appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
|
||||
async mention(node) {
|
||||
const { username, host, acct } = node.props;
|
||||
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
async mention(node) {
|
||||
const { username, host, acct } = node.props;
|
||||
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
|
||||
const el = doc.createElement('span');
|
||||
if (!resolved) {
|
||||
el.textContent = acct;
|
||||
} else {
|
||||
el.setAttribute('class', 'h-card');
|
||||
el.setAttribute('translate', 'no');
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
|
||||
a.className = 'u-url mention';
|
||||
const span = doc.createElement('span');
|
||||
span.textContent = resolved.username || username;
|
||||
a.textContent = '@';
|
||||
a.appendChild(span);
|
||||
el.appendChild(a);
|
||||
}
|
||||
const el = doc.createElement('span');
|
||||
if (!resolved) {
|
||||
el.textContent = acct;
|
||||
} else {
|
||||
el.setAttribute('class', 'h-card');
|
||||
el.setAttribute('translate', 'no');
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
|
||||
a.className = 'u-url mention';
|
||||
const span = doc.createElement('span');
|
||||
span.textContent = resolved.username || username;
|
||||
a.textContent = '@';
|
||||
a.appendChild(span);
|
||||
el.appendChild(a);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
return el;
|
||||
},
|
||||
|
||||
async quote(node) {
|
||||
const el = doc.createElement('blockquote');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
async quote(node) {
|
||||
const el = doc.createElement('blockquote');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text(node) {
|
||||
const el = doc.createElement('span');
|
||||
const nodes = node.props.text
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
text(node) {
|
||||
const el = doc.createElement('span');
|
||||
const nodes = node.props.text
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
}
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
return el;
|
||||
},
|
||||
|
||||
url(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url.replace(/^https?:\/\//, '');
|
||||
return a;
|
||||
},
|
||||
url(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url.replace(/^https?:\/\//, '');
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `https"google.com/${node.props.query}`);
|
||||
a.textContent = node.props.content;
|
||||
return a;
|
||||
},
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `https"google.com/${node.props.query}`);
|
||||
a.textContent = node.props.content;
|
||||
return a;
|
||||
},
|
||||
|
||||
async plain(node) {
|
||||
const el = doc.createElement('span');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
async plain(node) {
|
||||
const el = doc.createElement('span');
|
||||
await appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
await appendChildren(nodes, doc.body);
|
||||
|
||||
if (quoteUri !== null) {
|
||||
|
|
|
@ -386,6 +386,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
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(
|
||||
|
@ -438,7 +442,13 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) {
|
||||
const hasProhibitedWords = await this.checkProhibitedWordsContain({
|
||||
cw: data.cw,
|
||||
text: data.text,
|
||||
pollChoices: data.poll?.choices,
|
||||
}, meta.prohibitedWords);
|
||||
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
|
||||
}
|
||||
|
||||
|
@ -554,6 +564,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
@ -1315,6 +1329,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();
|
||||
|
|
|
@ -51,6 +51,7 @@ import { CacheService } from '@/core/CacheService.js';
|
|||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited';
|
||||
|
@ -146,8 +147,6 @@ type Option = {
|
|||
export class NoteEditService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
||||
public static ContainsProhibitedWordsError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
@ -283,7 +282,13 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) {
|
||||
const hasProhibitedWords = await this.checkProhibitedWordsContain({
|
||||
cw: data.cw,
|
||||
text: data.text,
|
||||
pollChoices: data.poll?.choices,
|
||||
}, meta.prohibitedWords);
|
||||
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
|
||||
}
|
||||
|
||||
|
@ -407,6 +412,10 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
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 update: Partial<MiNote> = {};
|
||||
if (data.text !== oldnote.text) {
|
||||
update.text = data.text;
|
||||
|
@ -790,7 +799,7 @@ export class NoteEditService 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) as MiUser[];
|
||||
|
||||
// Drop duplicate users
|
||||
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
||||
|
@ -964,6 +973,23 @@ export class NoteEditService 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();
|
||||
|
|
|
@ -70,7 +70,7 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
already present in the `note_unread` table: `upsert` makes sure
|
||||
we don't throw a "duplicate key" error, while still updating
|
||||
the other columns if they've changed */
|
||||
await this.noteUnreadsRepository.upsert(unread,['userId', 'noteId']);
|
||||
await this.noteUnreadsRepository.upsert(unread, ['userId', 'noteId']);
|
||||
|
||||
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
|
||||
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -36,6 +36,7 @@ export type RolePolicies = {
|
|||
ltlAvailable: boolean;
|
||||
btlAvailable: boolean;
|
||||
canPublicNote: boolean;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
inviteLimitCycle: number;
|
||||
|
@ -65,6 +66,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
ltlAvailable: true,
|
||||
btlAvailable: false,
|
||||
canPublicNote: true,
|
||||
mentionLimit: 20,
|
||||
canInvite: false,
|
||||
inviteLimit: 0,
|
||||
inviteLimitCycle: 60 * 24 * 7,
|
||||
|
@ -204,17 +206,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);
|
||||
|
@ -276,7 +281,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];
|
||||
}
|
||||
|
||||
|
@ -289,13 +294,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;
|
||||
|
@ -330,6 +335,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)),
|
||||
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
|
||||
canPublicNote: calc('canPublicNote', 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)),
|
||||
|
|
|
@ -101,33 +101,24 @@ export class UserFollowingService implements OnModuleInit {
|
|||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* ThinUserでなくともユーザーの情報が最新でない場合はこちらを使うべき
|
||||
*/
|
||||
@bindThis
|
||||
public async followByThinUser(
|
||||
_follower: ThinUser,
|
||||
_followee: ThinUser,
|
||||
options: Parameters<typeof this.follow>[2] = {},
|
||||
) {
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: _follower.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: _followee.id }),
|
||||
]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser];
|
||||
|
||||
await this.follow(follower, followee, options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async follow(
|
||||
follower: MiLocalUser | MiRemoteUser,
|
||||
followee: MiLocalUser | MiRemoteUser,
|
||||
_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.');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -36,7 +36,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';
|
||||
|
|
|
@ -25,6 +25,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 { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApMfmService } from '../ApMfmService.js';
|
||||
|
@ -38,7 +40,6 @@ import { ApQuestionService } from './ApQuestionService.js';
|
|||
import { ApImageService } from './ApImageService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IPost } from '../type.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApNoteService {
|
||||
|
@ -157,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);
|
||||
|
@ -176,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は必ずしも配列ではない
|
||||
|
@ -238,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 });
|
||||
|
@ -279,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,
|
||||
|
@ -318,14 +338,14 @@ export class ApNoteService {
|
|||
*/
|
||||
@bindThis
|
||||
public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
const uri = typeof value === 'string' ? value : value.id;
|
||||
if (uri == null) throw new Error('uri is null');
|
||||
const noteUri = typeof value === 'string' ? value : value.id;
|
||||
if (noteUri == null) throw new Error('uri is null');
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
||||
if (noteUri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const UpdatedNote = await this.notesRepository.findOneBy({ uri });
|
||||
const UpdatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
||||
if (UpdatedNote == null) throw new Error('Note is not registered');
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
|
@ -365,11 +385,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);
|
||||
|
@ -384,9 +440,6 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
// 添付ファイル
|
||||
// TODO: attachmentは必ずしもImageではない
|
||||
// TODO: attachmentは必ずしも配列ではない
|
||||
|
@ -446,24 +499,12 @@ 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 });
|
||||
const replyPoll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||
|
||||
const tryCreateVote = async (name: string, index: number): Promise<null> => {
|
||||
if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) {
|
||||
if (replyPoll.expiresAt && Date.now() > new Date(replyPoll.expiresAt).getTime()) {
|
||||
this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
|
||||
} else if (index >= 0) {
|
||||
this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
|
||||
|
@ -476,7 +517,7 @@ export class ApNoteService {
|
|||
};
|
||||
|
||||
if (note.name) {
|
||||
return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name));
|
||||
return await tryCreateVote(note.name, replyPoll.choices.findIndex(x => x === note.name));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -487,8 +528,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.noteEditService.edit(actor, UpdatedNote.id, {
|
||||
createdAt: note.published ? new Date(note.published) : null,
|
||||
|
|
|
@ -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', 'edited'] as (typeof notificationTypes[number])[]);
|
||||
const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited']);
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] 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,13 +54,15 @@ 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'>>;
|
||||
|
@ -66,6 +70,9 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
},
|
||||
): Promise<Packed<'Notification'> | null> {
|
||||
const notification = src;
|
||||
|
||||
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
|
||||
|
@ -75,9 +82,7 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
})
|
||||
) : undefined;
|
||||
// if the note has been deleted, don't show this notification
|
||||
if (needsNote && !noteIfNeed) {
|
||||
return null;
|
||||
}
|
||||
if (needsNote && !noteIfNeed) return null;
|
||||
|
||||
const needsUser = 'notifierId' in notification;
|
||||
const userIfNeed = needsUser ? (
|
||||
|
@ -86,122 +91,9 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
: this.userEntityService.pack(notification.notifierId, { id: meId })
|
||||
) : undefined;
|
||||
// if the user has been deleted, don't show this notification
|
||||
if (needsUser && !userIfNeed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
})))).filter(n => n);
|
||||
}
|
||||
|
||||
@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'> | null> {
|
||||
const notification = src;
|
||||
const needsNote = NOTE_REQUIRED_GROUPED_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;
|
||||
// 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;
|
||||
}
|
||||
if (needsUser && !userIfNeed) return null;
|
||||
|
||||
// #region Grouped notifications
|
||||
if (notification.type === 'reaction:grouped') {
|
||||
const reactions = (await Promise.all(notification.reactions.map(async reaction => {
|
||||
const user = hint?.packedUsers != null
|
||||
|
@ -211,9 +103,9 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
user,
|
||||
reaction: reaction.reaction,
|
||||
};
|
||||
}))).filter(r => r.user);
|
||||
}))).filter(r => isNotNull(r.user));
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (!reactions.length) {
|
||||
if (reactions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -232,9 +124,9 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
}
|
||||
|
||||
return this.userEntityService.pack(userId, { id: meId });
|
||||
}))).filter(u => u);
|
||||
}))).filter(isNotNull);
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (!users.length) {
|
||||
if (users.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -246,6 +138,7 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
users,
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const needsRole = notification.type === 'roleAssigned';
|
||||
const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
|
@ -278,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) },
|
||||
|
@ -312,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)) },
|
||||
|
@ -320,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,
|
||||
})))).filter(n => n);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
36
packages/backend/src/misc/FileWriterStream.ts
Normal file
36
packages/backend/src/misc/FileWriterStream.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import type { PathLike } from 'node:fs';
|
||||
|
||||
/**
|
||||
* `fs.createWriteStream()`相当のことを行う`WritableStream` (Web標準)
|
||||
*/
|
||||
export class FileWriterStream extends WritableStream<Uint8Array> {
|
||||
constructor(path: PathLike) {
|
||||
let file: fs.FileHandle | null = null;
|
||||
|
||||
super({
|
||||
start: async () => {
|
||||
file = await fs.open(path, 'a');
|
||||
},
|
||||
write: async (chunk, controller) => {
|
||||
if (file === null) {
|
||||
controller.error();
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await file.write(chunk);
|
||||
},
|
||||
close: async () => {
|
||||
await file?.close();
|
||||
},
|
||||
abort: async () => {
|
||||
await file?.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
35
packages/backend/src/misc/JsonArrayStream.ts
Normal file
35
packages/backend/src/misc/JsonArrayStream.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { TransformStream } from 'node:stream/web';
|
||||
|
||||
/**
|
||||
* ストリームに流れてきた各データについて`JSON.stringify()`した上で、それらを一つの配列にまとめる
|
||||
*/
|
||||
export class JsonArrayStream extends TransformStream<unknown, string> {
|
||||
constructor() {
|
||||
/** 最初の要素かどうかを変数に記録 */
|
||||
let isFirst = true;
|
||||
|
||||
super({
|
||||
start(controller) {
|
||||
controller.enqueue('[');
|
||||
},
|
||||
flush(controller) {
|
||||
controller.enqueue(']');
|
||||
},
|
||||
transform(chunk, controller) {
|
||||
if (isFirst) {
|
||||
isFirst = false;
|
||||
} else {
|
||||
// 妥当なJSON配列にするためには最初以外の要素の前に`,`を挿入しなければならない
|
||||
controller.enqueue(',\n');
|
||||
}
|
||||
|
||||
controller.enqueue(JSON.stringify(chunk));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -187,6 +187,10 @@ export class RedisSingleCache<T> {
|
|||
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
|
||||
|
||||
export class MemoryKVCache<T> {
|
||||
/**
|
||||
* データを持つマップ
|
||||
* @deprecated これを直接操作するべきではない
|
||||
*/
|
||||
public cache: Map<string, { date: number; value: T; }>;
|
||||
private lifetime: number;
|
||||
private gcIntervalHandle: NodeJS.Timeout;
|
||||
|
@ -201,6 +205,10 @@ export class MemoryKVCache<T> {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
/**
|
||||
* Mapにキャッシュをセットします
|
||||
* @deprecated これを直接呼び出すべきではない。InternalEventなどで変更を全てのプロセス/マシンに通知するべき
|
||||
*/
|
||||
public set(key: string, value: T): void {
|
||||
this.cache.set(key, {
|
||||
date: Date.now(),
|
||||
|
|
|
@ -44,6 +44,7 @@ import {
|
|||
packedRoleCondFormulaLogicsSchema,
|
||||
packedRoleCondFormulaValueNot,
|
||||
packedRoleCondFormulaValueIsLocalOrRemoteSchema,
|
||||
packedRoleCondFormulaValueAssignedRoleSchema,
|
||||
packedRoleCondFormulaValueCreatedSchema,
|
||||
packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
|
||||
packedRoleCondFormulaValueSchema,
|
||||
|
@ -96,6 +97,7 @@ export const refs = {
|
|||
RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
|
||||
RoleCondFormulaValueNot: packedRoleCondFormulaValueNot,
|
||||
RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema,
|
||||
RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema,
|
||||
RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema,
|
||||
RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
|
||||
RoleCondFormulaValue: packedRoleCondFormulaValueSchema,
|
||||
|
|
|
@ -29,6 +29,11 @@ type CondFormulaValueIsRemote = {
|
|||
type: 'isRemote';
|
||||
};
|
||||
|
||||
type CondFormulaValueRoleAssignedTo = {
|
||||
type: 'roleAssignedTo';
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
type CondFormulaValueCreatedLessThan = {
|
||||
type: 'createdLessThan';
|
||||
sec: number;
|
||||
|
@ -75,6 +80,7 @@ export type RoleCondFormulaValue = { id: string } & (
|
|||
CondFormulaValueNot |
|
||||
CondFormulaValueIsLocal |
|
||||
CondFormulaValueIsRemote |
|
||||
CondFormulaValueRoleAssignedTo |
|
||||
CondFormulaValueCreatedLessThan |
|
||||
CondFormulaValueCreatedMoreThan |
|
||||
CondFormulaValueFollowersLessThanOrEq |
|
||||
|
|
|
@ -256,6 +256,8 @@ export class MiUserProfile {
|
|||
type: 'follower';
|
||||
} | {
|
||||
type: 'mutualFollow';
|
||||
} | {
|
||||
type: 'followingOrFollower';
|
||||
} | {
|
||||
type: 'list';
|
||||
userListId: MiUserList['id'];
|
||||
|
|
|
@ -57,6 +57,26 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
export const packedRoleCondFormulaValueAssignedRoleSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string', optional: false,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
enum: ['roleAssignedTo'],
|
||||
},
|
||||
roleId: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedRoleCondFormulaValueCreatedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
@ -115,6 +135,9 @@ export const packedRoleCondFormulaValueSchema = {
|
|||
{
|
||||
ref: 'RoleCondFormulaValueIsLocalOrRemote',
|
||||
},
|
||||
{
|
||||
ref: 'RoleCondFormulaValueAssignedRole',
|
||||
},
|
||||
{
|
||||
ref: 'RoleCondFormulaValueCreated',
|
||||
},
|
||||
|
@ -136,10 +159,18 @@ export const packedRolePoliciesSchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
btlAvailable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
canPublicNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mentionLimit: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
canInvite: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
@ -13,7 +13,7 @@ export const notificationRecieveConfig = {
|
|||
type: {
|
||||
type: 'string',
|
||||
nullable: false,
|
||||
enum: ['all', 'following', 'follower', 'mutualFollow', 'never'],
|
||||
enum: ['all', 'following', 'follower', 'mutualFollow', 'followingOrFollower', 'never'],
|
||||
},
|
||||
},
|
||||
required: ['type'],
|
||||
|
@ -170,6 +170,9 @@ export const packedUserLiteSchema = {
|
|||
emojis: {
|
||||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
onlineStatus: {
|
||||
type: 'string',
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { ReadableStream, TextEncoderStream } from 'node:stream/web';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MoreThan } from 'typeorm';
|
||||
import { format as dateFormat } from 'date-fns';
|
||||
|
@ -18,10 +18,82 @@ import { bindThis } from '@/decorators.js';
|
|||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { JsonArrayStream } from '@/misc/JsonArrayStream.js';
|
||||
import { FileWriterStream } from '@/misc/FileWriterStream.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
class NoteStream extends ReadableStream<Record<string, unknown>> {
|
||||
constructor(
|
||||
job: Bull.Job,
|
||||
notesRepository: NotesRepository,
|
||||
pollsRepository: PollsRepository,
|
||||
driveFileEntityService: DriveFileEntityService,
|
||||
idService: IdService,
|
||||
userId: string,
|
||||
) {
|
||||
let exportedNotesCount = 0;
|
||||
let cursor: MiNote['id'] | null = null;
|
||||
|
||||
const serialize = (
|
||||
note: MiNote,
|
||||
poll: MiPoll | null,
|
||||
files: Packed<'DriveFile'>[],
|
||||
): Record<string, unknown> => {
|
||||
return {
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
createdAt: idService.parse(note.id).date.toISOString(),
|
||||
fileIds: note.fileIds,
|
||||
files: files,
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
poll: poll,
|
||||
cw: note.cw,
|
||||
visibility: note.visibility,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
};
|
||||
};
|
||||
|
||||
super({
|
||||
async pull(controller): Promise<void> {
|
||||
const notes = await notesRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
...(cursor !== null ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100, // 100件ずつ取得
|
||||
order: { id: 1 },
|
||||
});
|
||||
|
||||
if (notes.length === 0) {
|
||||
job.updateProgress(100);
|
||||
controller.close();
|
||||
}
|
||||
|
||||
cursor = notes.at(-1)?.id ?? null;
|
||||
|
||||
for (const note of notes) {
|
||||
const poll = note.hasPoll
|
||||
? await pollsRepository.findOneByOrFail({ noteId: note.id }) // N+1
|
||||
: null;
|
||||
const files = await driveFileEntityService.packManyByIds(note.fileIds); // N+1
|
||||
const content = serialize(note, poll, files);
|
||||
|
||||
controller.enqueue(content);
|
||||
exportedNotesCount++;
|
||||
}
|
||||
|
||||
const total = await notesRepository.countBy({ userId });
|
||||
job.updateProgress(exportedNotesCount / total);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExportNotesProcessorService {
|
||||
private logger: Logger;
|
||||
|
@ -59,67 +131,19 @@ export class ExportNotesProcessorService {
|
|||
this.logger.info(`Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
const stream = fs.createWriteStream(path, { flags: 'a' });
|
||||
// メモリが足りなくならないようにストリームで処理する
|
||||
await new NoteStream(
|
||||
job,
|
||||
this.notesRepository,
|
||||
this.pollsRepository,
|
||||
this.driveFileEntityService,
|
||||
this.idService,
|
||||
user.id,
|
||||
)
|
||||
.pipeThrough(new JsonArrayStream())
|
||||
.pipeThrough(new TextEncoderStream())
|
||||
.pipeTo(new FileWriterStream(path));
|
||||
|
||||
const write = (text: string): Promise<void> => {
|
||||
return new Promise<void>((res, rej) => {
|
||||
stream.write(text, err => {
|
||||
if (err) {
|
||||
this.logger.error(err);
|
||||
rej(err);
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await write('[');
|
||||
|
||||
let exportedNotesCount = 0;
|
||||
let cursor: MiNote['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const notes = await this.notesRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
}) as MiNote[];
|
||||
|
||||
if (notes.length === 0) {
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = notes.at(-1)?.id ?? null;
|
||||
|
||||
for (const note of notes) {
|
||||
let poll: MiPoll | undefined;
|
||||
if (note.hasPoll) {
|
||||
poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id });
|
||||
}
|
||||
const files = await this.driveFileEntityService.packManyByIds(note.fileIds);
|
||||
const content = JSON.stringify(this.serialize(note, poll, files));
|
||||
const isFirst = exportedNotesCount === 0;
|
||||
await write(isFirst ? content : ',\n' + content);
|
||||
exportedNotesCount++;
|
||||
}
|
||||
|
||||
const total = await this.notesRepository.countBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
job.updateProgress(exportedNotesCount / total);
|
||||
}
|
||||
|
||||
await write(']');
|
||||
|
||||
stream.end();
|
||||
this.logger.succ(`Exported to: ${path}`);
|
||||
|
||||
const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
|
||||
|
@ -130,22 +154,4 @@ export class ExportNotesProcessorService {
|
|||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(note: MiNote, poll: MiPoll | null = null, files: Packed<'DriveFile'>[]): Record<string, unknown> {
|
||||
return {
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
createdAt: this.idService.parse(note.id).date.toISOString(),
|
||||
fileIds: note.fileIds,
|
||||
files: files,
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
poll: poll,
|
||||
cw: note.cw,
|
||||
visibility: note.visibility,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as vm from 'node:vm';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ZipReader } from 'slacc';
|
||||
|
|
|
@ -196,7 +196,10 @@ export class InboxProcessorService {
|
|||
await this.apInboxService.performActivity(authUser.user, activity);
|
||||
} catch (e) {
|
||||
if (e instanceof IdentifiableError) {
|
||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') return 'blocked notes with prohibited words';
|
||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
|
||||
return 'blocked notes with prohibited words';
|
||||
}
|
||||
if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') return 'actor has been suspended';
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ export class RelationshipProcessorService {
|
|||
@bindThis
|
||||
public async processFollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
|
||||
this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id} ${job.data.withReplies ? "with replies" : "without replies"}`);
|
||||
await this.userFollowingService.followByThinUser(job.data.from, job.data.to, {
|
||||
await this.userFollowingService.follow(job.data.from, job.data.to, {
|
||||
requestId: job.data.requestId,
|
||||
silent: job.data.silent,
|
||||
withReplies: job.data.withReplies,
|
||||
|
|
|
@ -305,6 +305,7 @@ import * as ep___notes_translate from './endpoints/notes/translate.js';
|
|||
import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
|
||||
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||
import * as ep___notifications_flush from './endpoints/notifications/flush.js';
|
||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
|
||||
import * as ep___pagePush from './endpoints/page-push.js';
|
||||
|
@ -689,6 +690,7 @@ const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timelin
|
|||
const $notes_edit: Provider = { provide: 'ep:notes/edit', useClass: ep___notes_edit.default };
|
||||
const $notes_versions: Provider = { provide: 'ep:notes/versions', useClass: ep___notes_versions.default };
|
||||
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
|
||||
const $notifications_flush: Provider = { provide: 'ep:notifications/flush', useClass: ep___notifications_flush.default };
|
||||
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
|
||||
const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default };
|
||||
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
|
||||
|
@ -1077,6 +1079,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$notes_edit,
|
||||
$notes_versions,
|
||||
$notifications_create,
|
||||
$notifications_flush,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_testNotification,
|
||||
$pagePush,
|
||||
|
@ -1459,7 +1462,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$notes_edit,
|
||||
$notes_versions,
|
||||
$notifications_create,
|
||||
$notifications_flush,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_testNotification,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$pages_delete,
|
||||
|
|
|
@ -305,6 +305,7 @@ import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeli
|
|||
import * as ep___notes_edit from './endpoints/notes/edit.js';
|
||||
import * as ep___notes_versions from './endpoints/notes/versions.js';
|
||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||
import * as ep___notifications_flush from './endpoints/notifications/flush.js';
|
||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
|
||||
import * as ep___pagePush from './endpoints/page-push.js';
|
||||
|
@ -687,6 +688,7 @@ const eps = [
|
|||
['notes/edit', ep___notes_edit],
|
||||
['notes/versions', ep___notes_versions],
|
||||
['notifications/create', ep___notifications_create],
|
||||
['notifications/flush', ep___notifications_flush],
|
||||
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
|
||||
['notifications/test-notification', ep___notifications_testNotification],
|
||||
['page-push', ep___pagePush],
|
||||
|
|
|
@ -31,7 +31,10 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
ref: 'EmojiDetailed',
|
||||
res: {
|
||||
type: 'object',
|
||||
ref: 'EmojiDetailed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -57,7 +57,10 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
} },
|
||||
},
|
||||
required: ['id', 'name', 'aliases'],
|
||||
anyOf: [
|
||||
{ required: ['id'] },
|
||||
{ required: ['name'] },
|
||||
],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
|
@ -70,27 +73,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let driveFile;
|
||||
|
||||
if (ps.fileId) {
|
||||
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
const emoji = await this.customEmojiService.getEmojiById(ps.id);
|
||||
if (emoji != null) {
|
||||
if (ps.name !== emoji.name) {
|
||||
|
||||
let emojiId;
|
||||
if (ps.id) {
|
||||
emojiId = ps.id;
|
||||
const emoji = await this.customEmojiService.getEmojiById(ps.id);
|
||||
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
|
||||
if (ps.name && (ps.name !== emoji.name)) {
|
||||
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
|
||||
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
|
||||
}
|
||||
} else {
|
||||
throw new ApiError(meta.errors.noSuchEmoji);
|
||||
if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
|
||||
const emoji = await this.customEmojiService.getEmojiByName(ps.name);
|
||||
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
|
||||
emojiId = emoji.id;
|
||||
}
|
||||
|
||||
await this.customEmojiService.update(ps.id, {
|
||||
await this.customEmojiService.update(emojiId, {
|
||||
driveFile,
|
||||
name: ps.name,
|
||||
category: ps.category ?? null,
|
||||
category: ps.category,
|
||||
aliases: ps.aliases,
|
||||
license: ps.license ?? null,
|
||||
license: ps.license,
|
||||
isSensitive: ps.isSensitive,
|
||||
localOnly: ps.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
|
|
|
@ -71,7 +71,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
withReplies: { type: 'boolean' }
|
||||
withReplies: { type: 'boolean' },
|
||||
},
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
|
|
@ -3,11 +3,11 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js';
|
||||
import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
||||
|
@ -48,10 +48,10 @@ export const paramDef = {
|
|||
markAsRead: { type: 'boolean', default: true },
|
||||
// 後方互換のため、廃止された通知タイプも受け付ける
|
||||
includeTypes: { type: 'array', items: {
|
||||
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
|
||||
type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
|
||||
} },
|
||||
excludeTypes: { type: 'array', items: {
|
||||
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
|
||||
type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
|
||||
} },
|
||||
},
|
||||
required: [],
|
||||
|
@ -79,12 +79,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return [];
|
||||
}
|
||||
// excludeTypes に全指定されている場合はクエリしない
|
||||
if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) {
|
||||
if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
|
||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
|
||||
|
||||
const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const notificationsRes = await this.redisClient.xrevrange(
|
||||
|
@ -162,7 +162,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
groupedNotifications = groupedNotifications.slice(0, ps.limit);
|
||||
|
||||
const noteIds = groupedNotifications
|
||||
.filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote' | 'edited'> => ['mention', 'reply', 'quote', 'edited'].includes(notification.type))
|
||||
.map(notification => notification.noteId!);
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
|
|
|
@ -488,9 +488,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.hashtagService.updateUsertags(user, tags);
|
||||
//#endregion
|
||||
|
||||
if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates);
|
||||
if (Object.keys(updates).includes('alsoKnownAs')) {
|
||||
this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates });
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.usersRepository.update(user.id, updates);
|
||||
this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id });
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
|
|
|
@ -44,11 +44,6 @@ describe('api:notes/create', () => {
|
|||
.toBe(INVALID);
|
||||
});
|
||||
|
||||
test('over 3000 characters post', async () => {
|
||||
expect(v({ text: await tooLong }))
|
||||
.toBe(INVALID);
|
||||
});
|
||||
|
||||
test('whitespace-only post', () => {
|
||||
expect(v({ text: ' ' }))
|
||||
.toBe(INVALID);
|
||||
|
|
|
@ -91,6 +91,12 @@ export const meta = {
|
|||
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
|
||||
},
|
||||
|
||||
cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
|
||||
message: 'You cannot reply to a specified visibility note with extended visibility.',
|
||||
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
|
||||
id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
|
||||
},
|
||||
|
||||
cannotCreateAlreadyExpiredPoll: {
|
||||
message: 'Poll is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
|
||||
|
@ -126,6 +132,12 @@ export const meta = {
|
|||
code: 'CONTAINS_PROHIBITED_WORDS',
|
||||
id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
|
||||
},
|
||||
|
||||
containsTooManyMentions: {
|
||||
message: 'Cannot post because it exceeds the allowed number of mentions.',
|
||||
code: 'CONTAINS_TOO_MANY_MENTIONS',
|
||||
id: '4de0363a-3046-481b-9b0f-feff3e211025',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -323,7 +335,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
} else if (isPureRenote(reply)) {
|
||||
throw new ApiError(meta.errors.cannotReplyToPureRenote);
|
||||
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
|
||||
throw new ApiError(meta.errors.noSuchReplyTarget);
|
||||
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
|
||||
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
|
||||
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
|
@ -389,9 +403,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
} catch (e) {
|
||||
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
|
||||
if (e instanceof IdentifiableError) {
|
||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
|
||||
throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
|
||||
throw new ApiError(meta.errors.containsTooManyMentions);
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { NoteEditService } from '@/core/NoteEditService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { isPureRenote } from '@/misc/is-pure-renote.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
|
@ -19,6 +20,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 300,
|
||||
|
@ -53,18 +56,42 @@ export const meta = {
|
|||
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
|
||||
},
|
||||
|
||||
cannotRenoteDueToVisibility: {
|
||||
message: 'You can not Renote due to target visibility.',
|
||||
code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
|
||||
id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
|
||||
},
|
||||
|
||||
noSuchReplyTarget: {
|
||||
message: 'No such reply target.',
|
||||
code: 'NO_SUCH_REPLY_TARGET',
|
||||
id: '749ee0f6-d3da-459a-bf02-282e2da4292c',
|
||||
},
|
||||
|
||||
cannotReplyToInvisibleNote: {
|
||||
message: 'You cannot reply to an invisible Note.',
|
||||
code: 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE',
|
||||
id: 'b98980fa-3780-406c-a935-b6d0eeee10d1',
|
||||
},
|
||||
|
||||
cannotReplyToPureRenote: {
|
||||
message: 'You can not reply to a pure Renote.',
|
||||
code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
|
||||
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
|
||||
},
|
||||
|
||||
maxLength: {
|
||||
message: 'You tried posting a note which is too long.',
|
||||
code: 'MAX_LENGTH',
|
||||
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
|
||||
},
|
||||
|
||||
cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
|
||||
message: 'You cannot reply to a specified visibility note with extended visibility.',
|
||||
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
|
||||
id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
|
||||
},
|
||||
|
||||
cannotCreateAlreadyExpiredPoll: {
|
||||
message: 'Poll is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
|
||||
|
@ -83,6 +110,12 @@ export const meta = {
|
|||
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
|
||||
},
|
||||
|
||||
noSuchFile: {
|
||||
message: 'Some files are not found.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
|
||||
},
|
||||
|
||||
accountLocked: {
|
||||
message: 'You migrated. Your account is now locked.',
|
||||
code: 'ACCOUNT_LOCKED',
|
||||
|
@ -137,17 +170,17 @@ export const meta = {
|
|||
id: '33510210-8452-094c-6227-4a6c05d99f02',
|
||||
},
|
||||
|
||||
maxLength: {
|
||||
message: 'You tried posting a note which is too long.',
|
||||
code: 'MAX_LENGTH',
|
||||
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
|
||||
},
|
||||
|
||||
containsProhibitedWords: {
|
||||
message: 'Cannot post because it contains prohibited words.',
|
||||
code: 'CONTAINS_PROHIBITED_WORDS',
|
||||
id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
|
||||
},
|
||||
|
||||
containsTooManyMentions: {
|
||||
message: 'Cannot post because it exceeds the allowed number of mentions.',
|
||||
code: 'CONTAINS_TOO_MANY_MENTIONS',
|
||||
id: '4de0363a-3046-481b-9b0f-feff3e211025',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -201,7 +234,7 @@ export const paramDef = {
|
|||
uniqueItems: true,
|
||||
minItems: 2,
|
||||
maxItems: 10,
|
||||
items: { type: 'string', minLength: 1, maxLength: 50 },
|
||||
items: { type: 'string', minLength: 1, maxLength: 150 },
|
||||
},
|
||||
multiple: { type: 'boolean' },
|
||||
expiresAt: { type: 'integer', nullable: true },
|
||||
|
@ -210,38 +243,33 @@ export const paramDef = {
|
|||
required: ['choices'],
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{
|
||||
// (re)note with text, files and poll are optional
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
nullable: false,
|
||||
},
|
||||
// (re)note with text, files and poll are optional
|
||||
if: {
|
||||
properties: {
|
||||
renoteId: {
|
||||
type: 'null',
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
{
|
||||
// (re)note with files, text and poll are optional
|
||||
required: ['fileIds'],
|
||||
},
|
||||
{
|
||||
// (re)note with files, text and poll are optional
|
||||
required: ['mediaIds'],
|
||||
},
|
||||
{
|
||||
// (re)note with poll, text and files are optional
|
||||
properties: {
|
||||
poll: { type: 'object', nullable: false },
|
||||
fileIds: {
|
||||
type: 'null',
|
||||
},
|
||||
mediaIds: {
|
||||
type: 'null',
|
||||
},
|
||||
poll: {
|
||||
type: 'null',
|
||||
},
|
||||
required: ['poll'],
|
||||
},
|
||||
{
|
||||
// pure renote
|
||||
required: ['renoteId'],
|
||||
},
|
||||
then: {
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
pattern: '[^\\s]+',
|
||||
},
|
||||
},
|
||||
],
|
||||
required: ['text'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
|
@ -292,7 +320,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.getMany();
|
||||
|
||||
if (files.length !== fileIds.length) {
|
||||
throw new ApiError(meta.errors.noSuchNote);
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -301,14 +329,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (ps.renoteId === ps.editId) {
|
||||
throw new ApiError(meta.errors.cannotQuoteCurrentPost);
|
||||
}
|
||||
|
||||
|
||||
if (ps.renoteId != null) {
|
||||
// Fetch renote to note
|
||||
renote = await this.notesRepository.findOneBy({ id: ps.renoteId });
|
||||
|
||||
if (renote == null) {
|
||||
throw new ApiError(meta.errors.noSuchRenoteTarget);
|
||||
} else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) {
|
||||
} else if (isPureRenote(renote)) {
|
||||
throw new ApiError(meta.errors.cannotReRenote);
|
||||
}
|
||||
|
||||
|
@ -329,6 +357,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
}
|
||||
|
||||
if (renote.visibility === 'followers' && renote.userId !== me.id) {
|
||||
// 他人のfollowers noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
} else if (renote.visibility === 'specified') {
|
||||
// specified / direct noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
}
|
||||
|
||||
if (renote.channelId && renote.channelId !== ps.channelId) {
|
||||
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
|
||||
// リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
|
||||
|
@ -350,8 +386,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
if (reply == null) {
|
||||
throw new ApiError(meta.errors.noSuchReplyTarget);
|
||||
} else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) {
|
||||
} else if (isPureRenote(reply)) {
|
||||
throw new ApiError(meta.errors.cannotReplyToPureRenote);
|
||||
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
|
||||
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
|
||||
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
|
||||
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
|
@ -415,9 +455,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
} catch (e) {
|
||||
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
|
||||
if (e instanceof IdentifiableError) {
|
||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
|
||||
throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
|
||||
throw new ApiError(meta.errors.containsTooManyMentions);
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notifications', 'account'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:notifications',
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private notificationService: NotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
this.notificationService.flushAllNotifications(me.id);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.limit(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me, { withNote: true })));
|
||||
return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,13 +73,21 @@ class HomeTimelineChannel extends Channel {
|
|||
|
||||
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
|
||||
|
||||
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
||||
// 純粋なリノート(引用リノートでないリノート)の場合
|
||||
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) {
|
||||
if (!this.withRenotes) return;
|
||||
if (note.renote.reply) {
|
||||
const reply = note.renote.reply;
|
||||
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
|
||||
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
||||
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
|
||||
|
||||
|
||||
if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return;
|
||||
if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return;
|
||||
|
||||
|
|
|
@ -28,12 +28,21 @@ export const notificationTypes = [
|
|||
'quote',
|
||||
'reaction',
|
||||
'pollEnded',
|
||||
'edited',
|
||||
'receiveFollowRequest',
|
||||
'followRequestAccepted',
|
||||
'roleAssigned',
|
||||
'achievementEarned',
|
||||
'app',
|
||||
'test'] as const;
|
||||
'test',
|
||||
] as const;
|
||||
|
||||
export const groupedNotificationTypes = [
|
||||
...notificationTypes,
|
||||
'reaction:grouped',
|
||||
'renote:grouped',
|
||||
] as const;
|
||||
|
||||
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
|
||||
|
||||
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue