Merge remote-tracking branch 'upstream/master' into art/bump-2024.3.1
This commit is contained in:
commit
ce12ac5204
194 changed files with 5162 additions and 3013 deletions
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class RepositoryUrlFromSyuiloToMisskeyDev1708266695091 {
|
||||
name = 'RepositoryUrlFromSyuiloToMisskeyDev1708266695091'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`UPDATE "meta" SET "repositoryUrl" = 'https://github.com/misskey-dev/misskey' WHERE "repositoryUrl" = 'https://github.com/syuilo/misskey'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
// no valid down migration
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class PerInstanceModNote1708399372194 {
|
||||
name = 'PerInstanceModNote1708399372194'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "instance" ADD "moderationNote" character varying(16384) NOT NULL DEFAULT ''`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "moderationNote"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,9 +67,9 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.412.0",
|
||||
"@aws-sdk/lib-storage": "3.412.0",
|
||||
"@bull-board/api": "5.14.0",
|
||||
"@bull-board/fastify": "5.14.0",
|
||||
"@bull-board/ui": "5.14.0",
|
||||
"@bull-board/api": "5.14.2",
|
||||
"@bull-board/fastify": "5.14.2",
|
||||
"@bull-board/ui": "5.14.2",
|
||||
"@discordapp/twemoji": "15.0.2",
|
||||
"@fastify/accepts": "4.3.0",
|
||||
"@fastify/cookie": "9.3.1",
|
||||
|
|
@ -79,13 +79,13 @@
|
|||
"@fastify/multipart": "8.1.0",
|
||||
"@fastify/static": "6.12.0",
|
||||
"@fastify/view": "8.2.0",
|
||||
"@misskey-dev/sharp-read-bmp": "^1.1.1",
|
||||
"@misskey-dev/summaly": "^5.0.3",
|
||||
"@nestjs/common": "10.2.10",
|
||||
"@nestjs/core": "10.2.10",
|
||||
"@nestjs/testing": "10.2.10",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.0.3",
|
||||
"@nestjs/common": "10.3.3",
|
||||
"@nestjs/core": "10.3.3",
|
||||
"@nestjs/testing": "10.3.3",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@simplewebauthn/server": "9.0.2",
|
||||
"@simplewebauthn/server": "9.0.3",
|
||||
"@sinonjs/fake-timers": "11.2.2",
|
||||
"@smithy/node-http-handler": "2.1.10",
|
||||
"@swc/cli": "0.1.63",
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.2",
|
||||
"bullmq": "5.1.9",
|
||||
"bullmq": "5.4.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.3.0",
|
||||
|
|
@ -115,10 +115,11 @@
|
|||
"file-type": "19.0.0",
|
||||
"fluent-ffmpeg": "2.1.2",
|
||||
"form-data": "4.0.0",
|
||||
"got": "14.1.0",
|
||||
"got": "14.2.0",
|
||||
"happy-dom": "10.0.3",
|
||||
"hpagent": "1.2.0",
|
||||
"http-link-header": "1.1.1",
|
||||
"htmlescape": "1.1.1",
|
||||
"http-link-header": "1.1.2",
|
||||
"ioredis": "5.3.2",
|
||||
"ip-cidr": "3.1.0",
|
||||
"ipaddr.js": "2.1.0",
|
||||
|
|
@ -127,7 +128,7 @@
|
|||
"jsdom": "23.2.0",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "8.3.2",
|
||||
"jsrsasign": "11.0.0",
|
||||
"jsrsasign": "11.1.0",
|
||||
"meilisearch": "0.37.0",
|
||||
"mfm-js": "0.24.0",
|
||||
"microformats-parser": "2.0.2",
|
||||
|
|
@ -135,10 +136,10 @@
|
|||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"nanoid": "5.0.4",
|
||||
"nanoid": "5.0.6",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.8",
|
||||
"nodemailer": "6.9.10",
|
||||
"nsfwjs": "2.4.2",
|
||||
"oauth": "0.10.0",
|
||||
"oauth2orize": "1.12.0",
|
||||
|
|
@ -158,19 +159,19 @@
|
|||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.20.9",
|
||||
"redis-lock": "0.1.4",
|
||||
"reflect-metadata": "0.1.14",
|
||||
"reflect-metadata": "0.2.1",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"secure-json-parse": "2.7.0",
|
||||
"sharp": "0.32.6",
|
||||
"sharp": "0.33.2",
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.21.24",
|
||||
"systeminformation": "5.22.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tmp": "0.2.2",
|
||||
"tsc-alias": "1.8.8",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typeorm": "0.3.20",
|
||||
|
|
@ -184,7 +185,7 @@
|
|||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@nestjs/platform-express": "10.3.1",
|
||||
"@nestjs/platform-express": "10.3.3",
|
||||
"@simplewebauthn/types": "9.0.1",
|
||||
"@swc/jest": "0.2.31",
|
||||
"@types/accepts": "1.3.7",
|
||||
|
|
@ -194,6 +195,7 @@
|
|||
"@types/color-convert": "2.0.3",
|
||||
"@types/content-disposition": "0.5.8",
|
||||
"@types/fluent-ffmpeg": "2.1.24",
|
||||
"@types/htmlescape": "^1.1.3",
|
||||
"@types/http-link-header": "1.0.5",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
|
|
@ -202,21 +204,21 @@
|
|||
"@types/jsrsasign": "10.5.12",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "20.11.17",
|
||||
"@types/node": "20.11.22",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.14",
|
||||
"@types/oauth": "0.9.4",
|
||||
"@types/oauth2orize": "1.11.3",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.11.0",
|
||||
"@types/pg": "8.11.2",
|
||||
"@types/pug": "2.0.10",
|
||||
"@types/punycode": "2.1.3",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
"@types/rename": "1.0.7",
|
||||
"@types/sanitize-html": "2.9.5",
|
||||
"@types/semver": "7.5.6",
|
||||
"@types/sanitize-html": "2.11.0",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/simple-oauth2": "5.0.7",
|
||||
"@types/sinonjs__fake-timers": "8.1.5",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
|
|
@ -224,17 +226,17 @@
|
|||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.3",
|
||||
"@types/ws": "8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "6.18.1",
|
||||
"@typescript-eslint/parser": "6.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"aws-sdk-client-mock": "3.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.56.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"execa": "8.0.1",
|
||||
"fkill": "^9.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-mock": "29.7.0",
|
||||
"nodemon": "3.0.3",
|
||||
"nodemon": "3.1.0",
|
||||
"pid-port": "1.0.0",
|
||||
"simple-oauth2": "5.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
|||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
|
@ -60,7 +59,6 @@ export class AccountMoveService {
|
|||
private instanceChart: InstanceChart,
|
||||
private metaService: MetaService,
|
||||
private relayService: RelayService,
|
||||
private cacheService: CacheService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
|
@ -84,7 +82,7 @@ export class AccountMoveService {
|
|||
Object.assign(src, update);
|
||||
|
||||
// Update cache
|
||||
this.cacheService.uriPersonCache.set(srcUri, src);
|
||||
this.globalEventService.publishInternalEvent('localUserUpdated', src);
|
||||
|
||||
const srcPerson = await this.apRendererService.renderPerson(src);
|
||||
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
|
||||
|
|
|
|||
|
|
@ -128,10 +128,13 @@ export class CacheService implements OnApplicationShutdown {
|
|||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'userChangeSuspendedState':
|
||||
case 'remoteUserUpdated': {
|
||||
case 'userChangeDeletedState':
|
||||
case 'remoteUserUpdated':
|
||||
case 'localUserUpdated': {
|
||||
const user = await this.usersRepository.findOneBy({ id: body.id });
|
||||
if (user == null) {
|
||||
this.userByIdCache.delete(body.id);
|
||||
this.localUserByIdCache.delete(body.id);
|
||||
for (const [k, v] of this.uriPersonCache.cache.entries()) {
|
||||
if (v.value?.id === body.id) {
|
||||
this.uriPersonCache.delete(k);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ import { FlashEntityService } from './entities/FlashEntityService.js';
|
|||
import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
|
||||
import { RoleEntityService } from './entities/RoleEntityService.js';
|
||||
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
|
||||
import { MetaEntityService } from './entities/MetaEntityService.js';
|
||||
|
||||
import { ApAudienceService } from './activitypub/ApAudienceService.js';
|
||||
import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
|
||||
|
|
@ -254,6 +255,7 @@ const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisti
|
|||
const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
|
||||
const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
|
||||
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
|
||||
const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
|
||||
|
||||
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
|
||||
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
|
||||
|
|
@ -393,6 +395,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
FlashLikeEntityService,
|
||||
RoleEntityService,
|
||||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
|
|
@ -528,6 +531,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$FlashLikeEntityService,
|
||||
$RoleEntityService,
|
||||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
|
|
@ -663,6 +667,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
FlashLikeEntityService,
|
||||
RoleEntityService,
|
||||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
|
|
@ -797,6 +802,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$FlashLikeEntityService,
|
||||
$RoleEntityService,
|
||||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
|
|
|
|||
|
|
@ -393,6 +393,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();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { QueueService } from '@/core/QueueService.js';
|
|||
import { UserSuspendService } from '@/core/UserSuspendService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountService {
|
||||
|
|
@ -18,6 +19,7 @@ export class DeleteAccountService {
|
|||
|
||||
private userSuspendService: UserSuspendService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -39,5 +41,7 @@ export class DeleteAccountService {
|
|||
await this.usersRepository.update(user.id, {
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('userChangeDeletedState', { id: user.id, isDeleted: true });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import isSvg from 'is-svg';
|
|||
import probeImageSize from 'probe-image-size';
|
||||
import { type predictionType } from 'nsfwjs';
|
||||
import sharp from 'sharp';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { encode } from 'blurhash';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { AiService } from '@/core/AiService.js';
|
||||
|
|
@ -122,7 +123,7 @@ export class FileInfoService {
|
|||
'image/avif',
|
||||
'image/svg+xml',
|
||||
].includes(type.mime)) {
|
||||
blurhash = await this.getBlurhash(path).catch(e => {
|
||||
blurhash = await this.getBlurhash(path, type.mime).catch(e => {
|
||||
warnings.push(`getBlurhash failed: ${e}`);
|
||||
return undefined;
|
||||
});
|
||||
|
|
@ -407,9 +408,9 @@ export class FileInfoService {
|
|||
* Calculate average color of image
|
||||
*/
|
||||
@bindThis
|
||||
private getBlurhash(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sharp(path)
|
||||
private getBlurhash(path: string, type: string): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
(await sharpBmp(path, type))
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.resize(64, 64, { fit: 'inside' })
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface MainEventTypes {
|
|||
file: Packed<'DriveFile'>;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
notificationFlushed: undefined;
|
||||
unreadNotification: Packed<'Notification'>;
|
||||
unreadMention: MiNote['id'];
|
||||
readAllUnreadMentions: undefined;
|
||||
|
|
@ -209,8 +210,10 @@ type SerializedAll<T> = {
|
|||
|
||||
export interface InternalEventTypes {
|
||||
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
|
||||
userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; };
|
||||
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
|
||||
remoteUserUpdated: { id: MiUser['id']; };
|
||||
localUserUpdated: { id: MiUser['id']; };
|
||||
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
|
||||
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
|
||||
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
|
|
@ -151,8 +153,6 @@ type Option = {
|
|||
export class NoteCreateService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
||||
public static ContainsProhibitedWordsError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
|
@ -263,8 +263,14 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) {
|
||||
throw new NoteCreateService.ContainsProhibitedWordsError();
|
||||
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');
|
||||
}
|
||||
|
||||
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
|
||||
|
|
@ -379,6 +385,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(
|
||||
|
|
@ -817,7 +827,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
const mentions = extractMentions(tokens);
|
||||
let mentionedUsers = (await Promise.all(mentions.map(m =>
|
||||
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
|
||||
))).filter(x => x != null) as MiUser[];
|
||||
))).filter(isNotNull);
|
||||
|
||||
// Drop duplicate users
|
||||
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
||||
|
|
@ -991,6 +1001,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
public async checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
|
||||
if (prohibitedWords == null) {
|
||||
prohibitedWords = (await this.metaService.fetch()).prohibitedWords;
|
||||
}
|
||||
|
||||
if (
|
||||
this.utilityService.isKeyWordIncluded(
|
||||
this.utilityService.concatNoteContentsForKeyWordCheck(content),
|
||||
prohibitedWords,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
|
|
|||
|
|
@ -88,46 +88,47 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
userId: MiUser['id'],
|
||||
notes: (MiNote | Packed<'Note'>)[],
|
||||
): Promise<void> {
|
||||
const readMentions: (MiNote | Packed<'Note'>)[] = [];
|
||||
const readSpecifiedNotes: (MiNote | Packed<'Note'>)[] = [];
|
||||
if (notes.length === 0) return;
|
||||
|
||||
const noteIds = new Set<MiNote['id']>();
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.mentions && note.mentions.includes(userId)) {
|
||||
readMentions.push(note);
|
||||
noteIds.add(note.id);
|
||||
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
|
||||
readSpecifiedNotes.push(note);
|
||||
noteIds.add(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) {
|
||||
// Remove the record
|
||||
await this.noteUnreadsRepository.delete({
|
||||
userId: userId,
|
||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
|
||||
});
|
||||
if (noteIds.size === 0) return;
|
||||
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
// Remove the record
|
||||
await this.noteUnreadsRepository.delete({
|
||||
userId: userId,
|
||||
noteId: In(Array.from(noteIds)),
|
||||
});
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
}));
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
}));
|
||||
}
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
}));
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -122,6 +122,14 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'mutualFollow') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
]);
|
||||
if (!(isFollowing && isFollower)) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'followingOrFollower') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
|
|
@ -155,6 +163,8 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
|
||||
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
|
||||
|
||||
if (packed == null) return null;
|
||||
|
||||
// Publish notification event
|
||||
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
||||
|
||||
|
|
@ -204,6 +214,15 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
*/
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async flushAllNotifications(userId: MiUser['id']) {
|
||||
await Promise.all([
|
||||
this.redisClient.del(`notificationTimeline:${userId}`),
|
||||
this.redisClient.del(`latestReadNotification:${userId}`),
|
||||
]);
|
||||
this.globalEventService.publishMainStream(userId, 'notificationFlushed');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
|
|
|||
|
|
@ -115,12 +115,19 @@ export class PushNotificationService implements OnApplicationShutdown {
|
|||
endpoint: subscription.endpoint,
|
||||
auth: subscription.auth,
|
||||
publickey: subscription.publickey,
|
||||
}).then(() => {
|
||||
this.refreshCache(userId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public refreshCache(userId: string): void {
|
||||
this.subscriptionsCache.refresh(userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.subscriptionsCache.dispose();
|
||||
|
|
|
|||
|
|
@ -322,35 +322,36 @@ export class ReactionService {
|
|||
//#endregion
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字列タイプのレガシーな形式のリアクションを現在の形式に変換しつつ、
|
||||
* データベース上には存在する「0個のリアクションがついている」という情報を削除する。
|
||||
*/
|
||||
@bindThis
|
||||
public convertLegacyReactions(reactions: Record<string, number>) {
|
||||
const _reactions = {} as Record<string, number>;
|
||||
public convertLegacyReactions(reactions: MiNote['reactions']): MiNote['reactions'] {
|
||||
return Object.entries(reactions)
|
||||
.filter(([, count]) => {
|
||||
// `ReactionService.prototype.delete`ではリアクション削除時に、
|
||||
// `MiNote['reactions']`のエントリの値をデクリメントしているが、
|
||||
// デクリメントしているだけなのでエントリ自体は0を値として持つ形で残り続ける。
|
||||
// そのため、この処理がなければ、「0個のリアクションがついている」ということになってしまう。
|
||||
return count > 0;
|
||||
})
|
||||
.map(([reaction, count]) => {
|
||||
// unchecked indexed access
|
||||
const convertedReaction = legacies[reaction] as string | undefined;
|
||||
|
||||
for (const reaction of Object.keys(reactions)) {
|
||||
if (reactions[reaction] <= 0) continue;
|
||||
const key = this.decodeReaction(convertedReaction ?? reaction).reaction;
|
||||
|
||||
if (Object.keys(legacies).includes(reaction)) {
|
||||
if (_reactions[legacies[reaction]]) {
|
||||
_reactions[legacies[reaction]] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[legacies[reaction]] = reactions[reaction];
|
||||
}
|
||||
} else {
|
||||
if (_reactions[reaction]) {
|
||||
_reactions[reaction] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[reaction] = reactions[reaction];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [key, count] as const;
|
||||
})
|
||||
.reduce<MiNote['reactions']>((acc, [key, count]) => {
|
||||
// unchecked indexed access
|
||||
const prevCount = acc[key] as number | undefined;
|
||||
|
||||
const _reactions2 = {} as Record<string, number>;
|
||||
acc[key] = (prevCount ?? 0) + count;
|
||||
|
||||
for (const reaction of Object.keys(_reactions)) {
|
||||
_reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
|
||||
}
|
||||
|
||||
return _reactions2;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export type RolePolicies = {
|
|||
gtlAvailable: boolean;
|
||||
ltlAvailable: boolean;
|
||||
canPublicNote: boolean;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
inviteLimitCycle: number;
|
||||
|
|
@ -62,6 +63,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
gtlAvailable: true,
|
||||
ltlAvailable: true,
|
||||
canPublicNote: true,
|
||||
mentionLimit: 20,
|
||||
canInvite: false,
|
||||
inviteLimit: 0,
|
||||
inviteLimitCycle: 60 * 24 * 7,
|
||||
|
|
@ -200,17 +202,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);
|
||||
|
|
@ -272,7 +277,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];
|
||||
}
|
||||
|
||||
|
|
@ -285,13 +290,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;
|
||||
|
|
@ -325,6 +330,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
gtlAvailable: calc('gtlAvailable', 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)),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type { Config } from '@/config.js';
|
|||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import type { ThinUser } from '@/queue/types.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
|
|
@ -94,21 +95,35 @@ export class UserFollowingService implements OnModuleInit {
|
|||
this.userBlockingService = this.moduleRef.get('UserBlockingService');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async follow(
|
||||
_follower: { id: MiUser['id'] },
|
||||
_followee: { id: MiUser['id'] },
|
||||
_follower: ThinUser,
|
||||
_followee: ThinUser,
|
||||
{ requestId, silent = false, withReplies }: {
|
||||
requestId?: string,
|
||||
silent?: boolean,
|
||||
withReplies?: boolean,
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
/**
|
||||
* 必ず最新のユーザー情報を取得する
|
||||
*/
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: _follower.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: _followee.id }),
|
||||
]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser];
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
// What?
|
||||
throw new Error('Remote user cannot follow remote user.');
|
||||
}
|
||||
|
||||
// check blocking
|
||||
const [blocking, blocked] = await Promise.all([
|
||||
this.userBlockingService.checkBlocked(follower.id, followee.id),
|
||||
|
|
@ -129,6 +144,24 @@ export class UserFollowingService implements OnModuleInit {
|
|||
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
||||
}
|
||||
|
||||
if (await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
})) {
|
||||
// すでにフォロー関係が存在している場合
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
// リモート → ローカル: acceptを送り返しておしまい
|
||||
this.deliverAccept(follower, followee, requestId);
|
||||
return;
|
||||
}
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
// ローカル → リモート/ローカル: 例外
|
||||
throw new IdentifiableError('ec3f65c0-a9d1-47d9-8791-b2e7b9dcdced', 'already following');
|
||||
}
|
||||
}
|
||||
|
||||
const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
|
||||
// フォロー対象が鍵アカウントである or
|
||||
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
|
||||
|
|
@ -189,8 +222,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
await this.insertFollowingDoc(followee, follower, silent, withReplies);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
this.deliverAccept(follower, followee, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -571,8 +603,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
await this.insertFollowingDoc(followee, follower, false, request.withReplies);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as MiPartialLocalUser, request.requestId!), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined);
|
||||
}
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,20 @@ export class UtilityService {
|
|||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public concatNoteContentsForKeyWordCheck(content: {
|
||||
cw?: string | null;
|
||||
text?: string | null;
|
||||
pollChoices?: string[] | null;
|
||||
others?: string[] | null;
|
||||
}): string {
|
||||
/**
|
||||
* ノートの内容を結合してキーワードチェック用の文字列を生成する
|
||||
* cwとtextは内容が繋がっているかもしれないので間に何も入れずにチェックする
|
||||
*/
|
||||
return `${content.cw ?? ''}${content.text ?? ''}\n${(content.pollChoices ?? []).join('\n')}\n${(content.others ?? []).join('\n')}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isKeyWordIncluded(text: string, keyWords: string[]): boolean {
|
||||
if (keyWords.length === 0) return false;
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export class WebAuthnService {
|
|||
if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
|
||||
const halfLength = (cert.length - 1) / 2;
|
||||
|
||||
const cborMap = new Map<number, number | ArrayBufferLike>();
|
||||
const cborMap = new Map<number, number | Uint8Array>();
|
||||
cborMap.set(1, 2); // kty, EC2
|
||||
cborMap.set(3, -7); // alg, ES256
|
||||
cborMap.set(-1, 1); // crv, P256
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import promiseLimit from 'promise-limit';
|
|||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { concat, unique } from '@/misc/prelude/array.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { getApIds } from './type.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { ApObject } from './type.js';
|
||||
|
|
@ -40,7 +41,7 @@ export class ApAudienceService {
|
|||
const limit = promiseLimit<MiUser | null>(2);
|
||||
const mentionedUsers = (await Promise.all(
|
||||
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))),
|
||||
)).filter((x): x is MiUser => x != null);
|
||||
)).filter(isNotNull);
|
||||
|
||||
if (toGroups.public.length > 0) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { QueueService } from '@/core/QueueService.js';
|
|||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
|
|
@ -35,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';
|
||||
|
|
@ -84,7 +84,6 @@ export class ApInboxService {
|
|||
private apPersonService: ApPersonService,
|
||||
private apQuestionService: ApQuestionService,
|
||||
private queueService: QueueService,
|
||||
private cacheService: CacheService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
|
|
@ -521,7 +520,7 @@ export class ApInboxService {
|
|||
const userIds = uris
|
||||
.filter(uri => uri.startsWith(this.config.url + '/users/'))
|
||||
.map(uri => uri.split('/').at(-1))
|
||||
.filter((userId): userId is string => userId !== undefined);
|
||||
.filter(isNotNull);
|
||||
const users = await this.usersRepository.findBy({
|
||||
id: In(userIds),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -315,7 +315,7 @@ export class ApRendererService {
|
|||
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
|
||||
if (ids.length === 0) return [];
|
||||
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
|
||||
return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
|
||||
return ids.map(id => items.find(item => item.id === id)).filter(isNotNull);
|
||||
};
|
||||
|
||||
let inReplyTo;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import promiseLimit from 'promise-limit';
|
|||
import type { MiUser } from '@/models/_.js';
|
||||
import { toArray, unique } from '@/misc/prelude/array.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { isMention } from '../type.js';
|
||||
import { Resolver } from '../ApResolverService.js';
|
||||
import { ApPersonService } from './ApPersonService.js';
|
||||
|
|
@ -27,7 +28,7 @@ export class ApMentionService {
|
|||
const limit = promiseLimit<MiUser | null>(2);
|
||||
const mentionedUsers = (await Promise.all(
|
||||
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))),
|
||||
)).filter((x): x is MiUser => x != null);
|
||||
)).filter(isNotNull);
|
||||
|
||||
return mentionedUsers;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { StatusError } from '@/misc/status-error.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApMfmService } from '../ApMfmService.js';
|
||||
|
|
@ -151,11 +153,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);
|
||||
|
|
@ -170,9 +208,6 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
// 添付ファイル
|
||||
// TODO: attachmentは必ずしもImageではない
|
||||
// TODO: attachmentは必ずしも配列ではない
|
||||
|
|
@ -221,7 +256,7 @@ export class ApNoteService {
|
|||
}
|
||||
};
|
||||
|
||||
const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string'));
|
||||
const uris = unique([note._misskey_quote, note.quoteUrl].filter(isNotNull));
|
||||
const results = await Promise.all(uris.map(tryResolveNote));
|
||||
|
||||
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
|
||||
|
|
@ -232,18 +267,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 });
|
||||
|
|
@ -273,8 +296,6 @@ export class ApNoteService {
|
|||
|
||||
const apEmojis = emojis.map(emoji => emoji.name);
|
||||
|
||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||
|
||||
try {
|
||||
return await this.noteCreateService.create(actor, {
|
||||
createdAt: note.published ? new Date(note.published) : null,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
|
@ -636,7 +637,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
// とりあえずidを別の時間で生成して順番を維持
|
||||
let td = 0;
|
||||
for (const note of featuredNotes.filter((note): note is MiNote => note != null)) {
|
||||
for (const note of featuredNotes.filter(isNotNull)) {
|
||||
td -= 1000;
|
||||
transactionalEntityManager.insert(MiUserNotePining, {
|
||||
id: this.idService.gen(Date.now() + td),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
|||
import type { IPoll } from '@/models/Poll.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { isQuestion } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
|
|
@ -51,7 +52,7 @@ export class ApQuestionService {
|
|||
|
||||
const choices = question[multiple ? 'anyOf' : 'oneOf']
|
||||
?.map((x) => x.name)
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.filter(isNotNull)
|
||||
?? [];
|
||||
|
||||
const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { isHashtag } from '../type.js';
|
||||
import type { IObject, IApHashtag } from '../type.js';
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export function extractApHashtags(tags: IObject | IObject[] | null | undefined):
|
|||
return hashtags.map(tag => {
|
||||
const m = tag.name.match(/^#(.+)/);
|
||||
return m ? m[1] : null;
|
||||
}).filter((x): x is string => x != null);
|
||||
}).filter(isNotNull);
|
||||
}
|
||||
|
||||
export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] {
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export class DriveFileEntityService {
|
|||
options?: PackOptions,
|
||||
): Promise<Packed<'DriveFile'>[]> {
|
||||
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
|
||||
return items.filter((x): x is Packed<'DriveFile'> => x != null);
|
||||
return items.filter(isNotNull);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UtilityService } from '../UtilityService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
|
||||
@Injectable()
|
||||
export class InstanceEntityService {
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
|
|
@ -22,8 +25,11 @@ export class InstanceEntityService {
|
|||
@bindThis
|
||||
public async pack(
|
||||
instance: MiInstance,
|
||||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
): Promise<Packed<'FederationInstance'>> {
|
||||
const meta = await this.metaService.fetch();
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
firstRetrievedAt: instance.firstRetrievedAt.toISOString(),
|
||||
|
|
@ -48,6 +54,7 @@ export class InstanceEntityService {
|
|||
themeColor: instance.themeColor,
|
||||
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
|
||||
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
|
||||
moderationNote: iAmModerator ? instance.moderationNote : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
154
packages/backend/src/core/entities/MetaEntityService.ts
Normal file
154
packages/backend/src/core/entities/MetaEntityService.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import JSON5 from 'json5';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import type { AdsRepository } from '@/models/_.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
|
||||
@Injectable()
|
||||
export class MetaEntityService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.adsRepository)
|
||||
private adsRepository: AdsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private metaService: MetaService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
) { }
|
||||
|
||||
@bindThis
|
||||
public async pack(meta?: MiMeta): Promise<Packed<'MetaLite'>> {
|
||||
let instance = meta;
|
||||
|
||||
if (!instance) {
|
||||
instance = await this.metaService.fetch();
|
||||
}
|
||||
|
||||
const ads = await this.adsRepository.createQueryBuilder('ads')
|
||||
.where('ads.expiresAt > :now', { now: new Date() })
|
||||
.andWhere('ads.startsAt <= :now', { now: new Date() })
|
||||
.andWhere(new Brackets(qb => {
|
||||
// 曜日のビットフラグを確認する
|
||||
qb.where('ads.dayOfWeek & :dayOfWeek > 0', { dayOfWeek: 1 << new Date().getDay() })
|
||||
.orWhere('ads.dayOfWeek = 0');
|
||||
}))
|
||||
.getMany();
|
||||
|
||||
const packed: Packed<'MetaLite'> = {
|
||||
maintainerName: instance.maintainerName,
|
||||
maintainerEmail: instance.maintainerEmail,
|
||||
|
||||
version: this.config.version,
|
||||
providesTarball: this.config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
|
||||
name: instance.name,
|
||||
shortName: instance.shortName,
|
||||
uri: this.config.url,
|
||||
description: instance.description,
|
||||
langs: instance.langs,
|
||||
tosUrl: instance.termsOfServiceUrl,
|
||||
repositoryUrl: instance.repositoryUrl,
|
||||
feedbackUrl: instance.feedbackUrl,
|
||||
impressumUrl: instance.impressumUrl,
|
||||
privacyPolicyUrl: instance.privacyPolicyUrl,
|
||||
disableRegistration: instance.disableRegistration,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
mcaptchaSiteKey: instance.mcaptchaSitekey,
|
||||
mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl,
|
||||
enableRecaptcha: instance.enableRecaptcha,
|
||||
recaptchaSiteKey: instance.recaptchaSiteKey,
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
|
||||
bannerUrl: instance.bannerUrl,
|
||||
infoImageUrl: instance.infoImageUrl,
|
||||
serverErrorImageUrl: instance.serverErrorImageUrl,
|
||||
notFoundImageUrl: instance.notFoundImageUrl,
|
||||
iconUrl: instance.iconUrl,
|
||||
backgroundImageUrl: instance.backgroundImageUrl,
|
||||
logoImageUrl: instance.logoImageUrl,
|
||||
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
|
||||
// クライアントの手間を減らすためあらかじめJSONに変換しておく
|
||||
defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null,
|
||||
defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null,
|
||||
ads: ads.map(ad => ({
|
||||
id: ad.id,
|
||||
url: ad.url,
|
||||
place: ad.place,
|
||||
ratio: ad.ratio,
|
||||
imageUrl: ad.imageUrl,
|
||||
dayOfWeek: ad.dayOfWeek,
|
||||
})),
|
||||
notesPerOneAd: instance.notesPerOneAd,
|
||||
enableEmail: instance.enableEmail,
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
|
||||
translatorAvailable: instance.deeplAuthKey != null,
|
||||
|
||||
serverRules: instance.serverRules,
|
||||
|
||||
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
||||
|
||||
mediaProxy: this.config.mediaProxy,
|
||||
};
|
||||
|
||||
return packed;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailed(meta?: MiMeta): Promise<Packed<'MetaDetailed'>> {
|
||||
let instance = meta;
|
||||
|
||||
if (!instance) {
|
||||
instance = await this.metaService.fetch();
|
||||
}
|
||||
|
||||
const packed = await this.pack(instance);
|
||||
|
||||
const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null;
|
||||
|
||||
const packDetailed: Packed<'MetaDetailed'> = {
|
||||
...packed,
|
||||
cacheRemoteFiles: instance.cacheRemoteFiles,
|
||||
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
|
||||
requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
|
||||
proxyAccountName: proxyAccount ? proxyAccount.username : null,
|
||||
features: {
|
||||
localTimeline: instance.policies.ltlAvailable,
|
||||
globalTimeline: instance.policies.gtlAvailable,
|
||||
registration: !instance.disableRegistration,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
hcaptcha: instance.enableHcaptcha,
|
||||
recaptcha: instance.enableRecaptcha,
|
||||
turnstile: instance.enableTurnstile,
|
||||
objectStorage: instance.useObjectStorage,
|
||||
serviceWorker: instance.enableServiceWorker,
|
||||
miauth: true,
|
||||
},
|
||||
};
|
||||
|
||||
return packDetailed;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,4 +69,19 @@ export class NoteReactionEntityService implements OnModuleInit {
|
|||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
reactions: MiNoteReaction[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
options?: {
|
||||
withNote: boolean;
|
||||
},
|
||||
): Promise<Packed<'NoteReaction'>[]> {
|
||||
const opts = Object.assign({
|
||||
withNote: false,
|
||||
}, options);
|
||||
|
||||
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ import type { MiNote } from '@/models/Note.js';
|
|||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { FilterUnionByProperty, notificationTypes } from '@/types.js';
|
||||
import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { RoleEntityService } from './RoleEntityService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);
|
||||
const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']);
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]);
|
||||
|
||||
@Injectable()
|
||||
export class NotificationEntityService implements OnModuleInit {
|
||||
|
|
@ -41,6 +41,8 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
|
||||
//private userEntityService: UserEntityService,
|
||||
//private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
|
|
@ -52,130 +54,48 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
this.roleEntityService = this.moduleRef.get('RoleEntityService');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: MiNotification,
|
||||
/**
|
||||
* 通知をパックする共通処理
|
||||
*/
|
||||
async #packInternal <T extends MiNotification | MiGroupedNotification> (
|
||||
src: T,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'>> {
|
||||
): Promise<Packed<'Notification'> | null> {
|
||||
const notification = src;
|
||||
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
|
||||
|
||||
if (options.checkValidNotifier !== false && !(await this.#isValidNotifier(notification, meId))) return null;
|
||||
|
||||
const needsNote = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification;
|
||||
const noteIfNeed = needsNote ? (
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : undefined;
|
||||
const userIfNeed = 'notifierId' in notification ? (
|
||||
hint?.packedUsers != null
|
||||
? hint.packedUsers.get(notification.notifierId)
|
||||
: this.userEntityService.pack(notification.notifierId, { id: meId })
|
||||
) : undefined;
|
||||
const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
type: notification.type,
|
||||
userId: 'notifierId' in notification ? notification.notifierId : undefined,
|
||||
...(userIfNeed != null ? { user: userIfNeed } : {}),
|
||||
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
|
||||
...(notification.type === 'reaction' ? {
|
||||
reaction: notification.reaction,
|
||||
} : {}),
|
||||
...(notification.type === 'roleAssigned' ? {
|
||||
role: role,
|
||||
} : {}),
|
||||
...(notification.type === 'achievementEarned' ? {
|
||||
achievement: notification.achievement,
|
||||
} : {}),
|
||||
...(notification.type === 'app' ? {
|
||||
body: notification.customBody,
|
||||
header: notification.customHeader,
|
||||
icon: notification.customIcon,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
notifications: MiNotification[],
|
||||
meId: MiUser['id'],
|
||||
) {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
let validNotifications = notifications;
|
||||
|
||||
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
|
||||
}) : [];
|
||||
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
||||
detail: true,
|
||||
});
|
||||
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
||||
|
||||
validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId));
|
||||
|
||||
const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull);
|
||||
const users = userIds.length > 0 ? await this.usersRepository.find({
|
||||
where: { id: In(userIds) },
|
||||
}) : [];
|
||||
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId });
|
||||
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||
|
||||
// 既に解決されたフォローリクエストの通知を除外
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
if (followRequestNotifications.length > 0) {
|
||||
const reqs = await this.followRequestsRepository.find({
|
||||
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
|
||||
});
|
||||
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
|
||||
}
|
||||
|
||||
return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, {
|
||||
packedNotes,
|
||||
packedUsers,
|
||||
})));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGrouped(
|
||||
src: MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'>> {
|
||||
const notification = src;
|
||||
const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : undefined;
|
||||
const userIfNeed = 'notifierId' in notification ? (
|
||||
// if the note has been deleted, don't show this notification
|
||||
if (needsNote && !noteIfNeed) return null;
|
||||
|
||||
const needsUser = 'notifierId' in notification;
|
||||
const userIfNeed = needsUser ? (
|
||||
hint?.packedUsers != null
|
||||
? hint.packedUsers.get(notification.notifierId)
|
||||
: this.userEntityService.pack(notification.notifierId, { id: meId })
|
||||
) : undefined;
|
||||
// if the user has been deleted, don't show this notification
|
||||
if (needsUser && !userIfNeed) return null;
|
||||
|
||||
// #region Grouped notifications
|
||||
if (notification.type === 'reaction:grouped') {
|
||||
const reactions = await Promise.all(notification.reactions.map(async reaction => {
|
||||
const reactions = (await Promise.all(notification.reactions.map(async reaction => {
|
||||
const user = hint?.packedUsers != null
|
||||
? hint.packedUsers.get(reaction.userId)!
|
||||
: await this.userEntityService.pack(reaction.userId, { id: meId });
|
||||
|
|
@ -183,7 +103,12 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
user,
|
||||
reaction: reaction.reaction,
|
||||
};
|
||||
}));
|
||||
}))).filter(r => isNotNull(r.user));
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (reactions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
|
|
@ -192,14 +117,19 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
reactions,
|
||||
});
|
||||
} else if (notification.type === 'renote:grouped') {
|
||||
const users = await Promise.all(notification.userIds.map(userId => {
|
||||
const users = (await Promise.all(notification.userIds.map(userId => {
|
||||
const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(userId) : null;
|
||||
if (packedUser) {
|
||||
return packedUser;
|
||||
}
|
||||
|
||||
return this.userEntityService.pack(userId, { id: meId });
|
||||
}));
|
||||
}))).filter(isNotNull);
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (users.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
|
|
@ -208,8 +138,14 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
users,
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
const needsRole = notification.type === 'roleAssigned';
|
||||
const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
// if the role has been deleted, don't show this notification
|
||||
if (needsRole && !role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
|
|
@ -235,15 +171,16 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGroupedMany(
|
||||
notifications: MiGroupedNotification[],
|
||||
async #packManyInternal <T extends MiNotification | MiGroupedNotification> (
|
||||
notifications: T[],
|
||||
meId: MiUser['id'],
|
||||
) {
|
||||
): Promise<T[]> {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
let validNotifications = notifications;
|
||||
|
||||
validNotifications = await this.#filterValidNotifier(validNotifications, meId);
|
||||
|
||||
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
|
|
@ -269,7 +206,7 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||
|
||||
// 既に解決されたフォローリクエストの通知を除外
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<T, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
if (followRequestNotifications.length > 0) {
|
||||
const reqs = await this.followRequestsRepository.find({
|
||||
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
|
||||
|
|
@ -277,9 +214,107 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
|
||||
}
|
||||
|
||||
return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, {
|
||||
packedNotes,
|
||||
packedUsers,
|
||||
})));
|
||||
const packPromises = validNotifications.map(x => {
|
||||
return this.pack(
|
||||
x,
|
||||
meId,
|
||||
{ checkValidNotifier: false },
|
||||
{ packedNotes, packedUsers },
|
||||
);
|
||||
});
|
||||
|
||||
return (await Promise.all(packPromises)).filter(isNotNull);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: MiNotification | MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'> | null> {
|
||||
return await this.#packInternal(src, meId, options, hint);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
notifications: MiNotification[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<MiNotification[]> {
|
||||
return await this.#packManyInternal(notifications, meId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGroupedMany(
|
||||
notifications: MiGroupedNotification[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<MiGroupedNotification[]> {
|
||||
return await this.#packManyInternal(notifications, meId);
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを確認するvalidator
|
||||
*/
|
||||
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
|
||||
notification: T,
|
||||
userIdsWhoMeMuting: Set<MiUser['id']>,
|
||||
userMutedInstances: Set<string>,
|
||||
notifiers: MiUser[],
|
||||
): boolean {
|
||||
if (!('notifierId' in notification)) return true;
|
||||
if (userIdsWhoMeMuting.has(notification.notifierId)) return false;
|
||||
|
||||
const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
|
||||
|
||||
if (notifier == null) return false;
|
||||
if (notifier.host && userMutedInstances.has(notifier.host)) return false;
|
||||
|
||||
if (notifier.isSuspended) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に確認する
|
||||
*/
|
||||
async #isValidNotifier(
|
||||
notification: MiNotification | MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
): Promise<boolean> {
|
||||
return (await this.#filterValidNotifier([notification], meId)).length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に複数確認する
|
||||
*/
|
||||
async #filterValidNotifier <T extends MiNotification | MiGroupedNotification> (
|
||||
notifications: T[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<T[]> {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
userMutedInstances,
|
||||
] = await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(meId),
|
||||
this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)),
|
||||
]);
|
||||
|
||||
const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(isNotNull);
|
||||
const notifiers = notifierIds.length > 0 ? await this.usersRepository.find({
|
||||
where: { id: In(notifierIds) },
|
||||
}) : [];
|
||||
|
||||
const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => {
|
||||
const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers);
|
||||
return isValid ? notification : null;
|
||||
}))) as [T | null] ).filter(isNotNull);
|
||||
|
||||
return filteredNotifications;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { MiPage } from '@/models/Page.js';
|
|||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ export class PageEntityService {
|
|||
script: page.script,
|
||||
eyeCatchingImageId: page.eyeCatchingImageId,
|
||||
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
|
||||
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is MiDriveFile => x != null)),
|
||||
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(isNotNull)),
|
||||
likedCount: page.likedCount,
|
||||
isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import type { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
|
|
@ -384,7 +385,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null,
|
||||
alsoKnownAs: user.alsoKnownAs
|
||||
? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[])
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(isNotNull))
|
||||
: null,
|
||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
|
|
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// we are using {} as "any non-nullish value" as expected
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export function isNotNull<T extends {}>(input: T | undefined | null): input is T {
|
||||
export function isNotNull<T extends NonNullable<unknown>>(input: T | undefined | null): input is T {
|
||||
return input != null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,12 +44,18 @@ import {
|
|||
packedRoleCondFormulaLogicsSchema,
|
||||
packedRoleCondFormulaValueNot,
|
||||
packedRoleCondFormulaValueIsLocalOrRemoteSchema,
|
||||
packedRoleCondFormulaValueAssignedRoleSchema,
|
||||
packedRoleCondFormulaValueCreatedSchema,
|
||||
packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
|
||||
packedRoleCondFormulaValueSchema,
|
||||
} from '@/models/json-schema/role.js';
|
||||
import { packedAdSchema } from '@/models/json-schema/ad.js';
|
||||
import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js';
|
||||
import {
|
||||
packedMetaLiteSchema,
|
||||
packedMetaDetailedOnlySchema,
|
||||
packedMetaDetailedSchema,
|
||||
} from '@/models/json-schema/meta.js';
|
||||
|
||||
export const refs = {
|
||||
UserLite: packedUserLiteSchema,
|
||||
|
|
@ -91,6 +97,7 @@ export const refs = {
|
|||
RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
|
||||
RoleCondFormulaValueNot: packedRoleCondFormulaValueNot,
|
||||
RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema,
|
||||
RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema,
|
||||
RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema,
|
||||
RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
|
||||
RoleCondFormulaValue: packedRoleCondFormulaValueSchema,
|
||||
|
|
@ -99,6 +106,9 @@ export const refs = {
|
|||
RolePolicies: packedRolePoliciesSchema,
|
||||
ReversiGameLite: packedReversiGameLiteSchema,
|
||||
ReversiGameDetailed: packedReversiGameDetailedSchema,
|
||||
MetaLite: packedMetaLiteSchema,
|
||||
MetaDetailedOnly: packedMetaDetailedOnlySchema,
|
||||
MetaDetailed: packedMetaDetailedSchema,
|
||||
};
|
||||
|
||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
||||
|
|
|
|||
|
|
@ -144,4 +144,9 @@ export class MiInstance {
|
|||
nullable: true,
|
||||
})
|
||||
public infoUpdatedAt: Date | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 16384, default: '',
|
||||
})
|
||||
public moderationNote: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,6 +253,8 @@ export class MiMeta {
|
|||
})
|
||||
public turnstileSecretKey: string | null;
|
||||
|
||||
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
|
||||
|
||||
@Column('enum', {
|
||||
enum: ['none', 'all', 'local', 'remote'],
|
||||
default: 'none',
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -249,6 +249,8 @@ export class MiUserProfile {
|
|||
type: 'follower';
|
||||
} | {
|
||||
type: 'mutualFollow';
|
||||
} | {
|
||||
type: 'followingOrFollower';
|
||||
} | {
|
||||
type: 'list';
|
||||
userListId: MiUserList['id'];
|
||||
|
|
|
|||
|
|
@ -107,5 +107,9 @@ export const packedFederationInstanceSchema = {
|
|||
optional: false, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
moderationNote: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
328
packages/backend/src/models/json-schema/meta.ts
Normal file
328
packages/backend/src/models/json-schema/meta.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const packedMetaLiteSchema = {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
maintainerName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
maintainerEmail: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
providesTarball: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
shortName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
uri: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'url',
|
||||
example: 'https://misskey.example.com',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
langs: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
tosUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
repositoryUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
default: 'https://github.com/misskey-dev/misskey',
|
||||
},
|
||||
feedbackUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
default: 'https://github.com/misskey-dev/misskey/issues/new',
|
||||
},
|
||||
defaultDarkTheme: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
defaultLightTheme: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
disableRegistration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
emailRequiredForSignup: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableHcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
hcaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableMcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mcaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mcaptchaInstanceUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableRecaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
recaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableTurnstile: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
turnstileSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mascotImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
default: '/assets/ai.png',
|
||||
},
|
||||
bannerUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
serverErrorImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
infoImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
notFoundImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
iconUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
maxNoteTextLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ads: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'url',
|
||||
},
|
||||
place: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ratio: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
imageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'url',
|
||||
},
|
||||
dayOfWeek: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notesPerOneAd: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
default: 0,
|
||||
},
|
||||
enableEmail: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableServiceWorker: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
translatorAvailable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mediaProxy: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
backgroundImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
impressumUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
logoImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
privacyPolicyUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
serverRules: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
themeColor: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
policies: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'RolePolicies',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedMetaDetailedOnlySchema = {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
features: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
properties: {
|
||||
registration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
emailRequiredForSignup: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
localTimeline: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
globalTimeline: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
hcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
turnstile: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
recaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
objectStorage: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
serviceWorker: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
miauth: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
proxyAccountName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
requireSetup: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
cacheRemoteFiles: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
cacheRemoteSensitiveFiles: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedMetaDetailedSchema = {
|
||||
type: 'object',
|
||||
allOf: [
|
||||
{
|
||||
type: 'object',
|
||||
ref: 'MetaLite',
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
ref: 'MetaDetailedOnly',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
|
@ -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',
|
||||
},
|
||||
|
|
@ -140,6 +163,10 @@ export const packedRolePoliciesSchema = {
|
|||
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'],
|
||||
|
|
@ -148,6 +148,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
|||
import { LdSignatureService } from '@/core/activitypub/LdSignatureService.js';
|
||||
import { ApInboxService } from '@/core/activitypub/ApInboxService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type { InboxJobData } from '../types.js';
|
||||
|
||||
|
|
@ -180,7 +181,17 @@ export class InboxProcessorService {
|
|||
});
|
||||
|
||||
// アクティビティを処理
|
||||
await this.apInboxService.performActivity(authUser.user, activity);
|
||||
try {
|
||||
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 === '85ab9bd7-3a41-4530-959d-f07073900109') return 'actor has been suspended';
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ export class NodeinfoServerService {
|
|||
emailRequiredForSignup: meta.emailRequiredForSignup,
|
||||
enableHcaptcha: meta.enableHcaptcha,
|
||||
enableRecaptcha: meta.enableRecaptcha,
|
||||
enableMcaptcha: meta.enableMcaptcha,
|
||||
enableTurnstile: meta.enableTurnstile,
|
||||
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
|
||||
enableEmail: meta.enableEmail,
|
||||
enableServiceWorker: meta.enableServiceWorker,
|
||||
|
|
|
|||
|
|
@ -293,6 +293,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';
|
||||
|
|
@ -664,6 +665,7 @@ const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep
|
|||
const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default };
|
||||
const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.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 };
|
||||
|
|
@ -1039,6 +1041,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$notes_unrenote,
|
||||
$notes_userListTimeline,
|
||||
$notifications_create,
|
||||
$notifications_flush,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_testNotification,
|
||||
$pagePush,
|
||||
|
|
@ -1408,7 +1411,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$notes_unrenote,
|
||||
$notes_userListTimeline,
|
||||
$notifications_create,
|
||||
$notifications_flush,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_testNotification,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$pages_delete,
|
||||
|
|
|
|||
|
|
@ -293,6 +293,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';
|
||||
|
|
@ -662,6 +663,7 @@ const eps = [
|
|||
['notes/unrenote', ep___notes_unrenote],
|
||||
['notes/user-list-timeline', ep___notes_userListTimeline],
|
||||
['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,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ export const paramDef = {
|
|||
properties: {
|
||||
host: { type: 'string' },
|
||||
isSuspended: { type: 'boolean' },
|
||||
moderationNote: { type: 'string' },
|
||||
},
|
||||
required: ['host', 'isSuspended'],
|
||||
required: ['host'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -47,9 +48,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
await this.federatedInstanceService.update(instance.id, {
|
||||
isSuspended: ps.isSuspended,
|
||||
moderationNote: ps.moderationNote,
|
||||
});
|
||||
|
||||
if (instance.isSuspended !== ps.isSuspended) {
|
||||
if (ps.isSuspended != null && instance.isSuspended !== ps.isSuspended) {
|
||||
if (ps.isSuspended) {
|
||||
this.moderationLogService.log(me, 'suspendRemoteInstance', {
|
||||
id: instance.id,
|
||||
|
|
@ -62,6 +64,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) {
|
||||
this.moderationLogService.log(me, 'updateRemoteInstanceNote', {
|
||||
id: instance.id,
|
||||
host: instance.host,
|
||||
before: instance.moderationNote,
|
||||
after: ps.moderationNote,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,9 +124,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
}
|
||||
|
||||
if (notes.length > 0) {
|
||||
this.noteReadService.read(me.id, notes);
|
||||
}
|
||||
this.noteReadService.read(me.id, notes);
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const instance = await this.instancesRepository
|
||||
.findOneBy({ host: this.utilityService.toPuny(ps.host) });
|
||||
|
||||
return instance ? await this.instanceEntityService.pack(instance) : null;
|
||||
return instance ? await this.instanceEntityService.pack(instance, me) : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const paramDef = {
|
|||
} },
|
||||
visibility: { type: 'string', enum: ['public', 'private'] },
|
||||
},
|
||||
required: ['flashId', 'title', 'summary', 'script', 'permissions'],
|
||||
required: ['flashId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -71,11 +71,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
await this.flashsRepository.update(flash.id, {
|
||||
updatedAt: new Date(),
|
||||
title: ps.title,
|
||||
summary: ps.summary,
|
||||
script: ps.script,
|
||||
permissions: ps.permissions,
|
||||
visibility: ps.visibility,
|
||||
...Object.fromEntries(
|
||||
Object.entries(ps).filter(
|
||||
([key, val]) => (key !== 'flashId') && Object.hasOwn(paramDef.properties, key)
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -100,22 +100,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw err;
|
||||
});
|
||||
|
||||
// Check if already following
|
||||
const exist = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (exist) {
|
||||
throw new ApiError(meta.errors.alreadyFollowing);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.userFollowingService.follow(follower, followee, { withReplies: ps.withReplies });
|
||||
} catch (e) {
|
||||
if (e instanceof IdentifiableError) {
|
||||
if (e.id === 'ec3f65c0-a9d1-47d9-8791-b2e7b9dcdced') throw new ApiError(meta.errors.alreadyFollowing);
|
||||
if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking);
|
||||
if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { MiDriveFile } from '@/models/DriveFile.js';
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
|
@ -69,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
id: fileId,
|
||||
userId: me.id,
|
||||
}),
|
||||
))).filter((file): file is MiDriveFile => file != null);
|
||||
))).filter(isNotNull);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { DriveFilesRepository, GalleryPostsRepository } from '@/models/_.js
|
|||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['gallery'],
|
||||
|
|
@ -67,7 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
id: fileId,
|
||||
userId: me.id,
|
||||
}),
|
||||
))).filter((file): file is MiDriveFile => file != null);
|
||||
))).filter(isNotNull);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error();
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, me) => {
|
||||
const hashtags = await this.hashtagsRepository.createQueryBuilder('tag')
|
||||
.where('tag.name like :q', { q: sqlLikeEscape(ps.query.toLowerCase()) + '%' })
|
||||
.orderBy('tag.count', 'DESC')
|
||||
.orderBy('tag.mentionedLocalUsersCount', 'DESC')
|
||||
.groupBy('tag.id')
|
||||
.limit(ps.limit)
|
||||
.offset(ps.offset)
|
||||
|
|
|
|||
|
|
@ -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'> => ['mention', 'reply', 'quote'].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';
|
||||
|
|
|
|||
|
|
@ -456,9 +456,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, {
|
||||
|
|
|
|||
|
|
@ -3,18 +3,9 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { IsNull, LessThanOrEqual, MoreThan, Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import JSON5 from 'json5';
|
||||
import type { AdsRepository } from '@/models/_.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import { MetaEntityService } from '@/core/entities/MetaEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['meta'],
|
||||
|
|
@ -23,297 +14,10 @@ export const meta = {
|
|||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
maintainerName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
maintainerEmail: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
providesTarball: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
shortName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
uri: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'url',
|
||||
example: 'https://misskey.example.com',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
langs: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
tosUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
repositoryUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
default: 'https://github.com/misskey-dev/misskey',
|
||||
},
|
||||
feedbackUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
default: 'https://github.com/misskey-dev/misskey/issues/new',
|
||||
},
|
||||
defaultDarkTheme: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
defaultLightTheme: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
disableRegistration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
cacheRemoteFiles: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
cacheRemoteSensitiveFiles: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
emailRequiredForSignup: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableHcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
hcaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableMcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mcaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mcaptchaInstanceUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableRecaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
recaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableTurnstile: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
turnstileSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mascotImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
default: '/assets/ai.png',
|
||||
},
|
||||
bannerUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
serverErrorImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
infoImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
notFoundImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
iconUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
maxNoteTextLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ads: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'url',
|
||||
},
|
||||
place: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ratio: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
imageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'url',
|
||||
},
|
||||
dayOfWeek: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notesPerOneAd: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
default: 0,
|
||||
},
|
||||
requireSetup: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
enableEmail: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableServiceWorker: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
translatorAvailable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
proxyAccountName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mediaProxy: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
features: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
properties: {
|
||||
registration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
localTimeline: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
globalTimeline: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
hcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
recaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
objectStorage: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
serviceWorker: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
miauth: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
backgroundImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
impressumUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
logoImageUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
privacyPolicyUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
serverRules: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
themeColor: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
policies: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'RolePolicies',
|
||||
},
|
||||
},
|
||||
oneOf: [
|
||||
{ type: 'object', ref: 'MetaLite' },
|
||||
{ type: 'object', ref: 'MetaDetailed' },
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -328,115 +32,10 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.adsRepository)
|
||||
private adsRepository: AdsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private metaService: MetaService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private metaEntityService: MetaEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const instance = await this.metaService.fetch(true);
|
||||
|
||||
const ads = await this.adsRepository.createQueryBuilder('ads')
|
||||
.where('ads.expiresAt > :now', { now: new Date() })
|
||||
.andWhere('ads.startsAt <= :now', { now: new Date() })
|
||||
.andWhere(new Brackets(qb => {
|
||||
// 曜日のビットフラグを確認する
|
||||
qb.where('ads.dayOfWeek & :dayOfWeek > 0', { dayOfWeek: 1 << new Date().getDay() })
|
||||
.orWhere('ads.dayOfWeek = 0');
|
||||
}))
|
||||
.getMany();
|
||||
|
||||
const response: any = {
|
||||
maintainerName: instance.maintainerName,
|
||||
maintainerEmail: instance.maintainerEmail,
|
||||
|
||||
version: this.config.version,
|
||||
providesTarball: this.config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
|
||||
name: instance.name,
|
||||
shortName: instance.shortName,
|
||||
uri: this.config.url,
|
||||
description: instance.description,
|
||||
langs: instance.langs,
|
||||
tosUrl: instance.termsOfServiceUrl,
|
||||
repositoryUrl: instance.repositoryUrl,
|
||||
feedbackUrl: instance.feedbackUrl,
|
||||
impressumUrl: instance.impressumUrl,
|
||||
privacyPolicyUrl: instance.privacyPolicyUrl,
|
||||
disableRegistration: instance.disableRegistration,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
mcaptchaSiteKey: instance.mcaptchaSitekey,
|
||||
mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl,
|
||||
enableRecaptcha: instance.enableRecaptcha,
|
||||
recaptchaSiteKey: instance.recaptchaSiteKey,
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl,
|
||||
bannerUrl: instance.bannerUrl,
|
||||
infoImageUrl: instance.infoImageUrl,
|
||||
serverErrorImageUrl: instance.serverErrorImageUrl,
|
||||
notFoundImageUrl: instance.notFoundImageUrl,
|
||||
iconUrl: instance.iconUrl,
|
||||
backgroundImageUrl: instance.backgroundImageUrl,
|
||||
logoImageUrl: instance.logoImageUrl,
|
||||
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
|
||||
// クライアントの手間を減らすためあらかじめJSONに変換しておく
|
||||
defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null,
|
||||
defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null,
|
||||
ads: ads.map(ad => ({
|
||||
id: ad.id,
|
||||
url: ad.url,
|
||||
place: ad.place,
|
||||
ratio: ad.ratio,
|
||||
imageUrl: ad.imageUrl,
|
||||
dayOfWeek: ad.dayOfWeek,
|
||||
})),
|
||||
notesPerOneAd: instance.notesPerOneAd,
|
||||
enableEmail: instance.enableEmail,
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
|
||||
translatorAvailable: instance.deeplAuthKey != null,
|
||||
|
||||
serverRules: instance.serverRules,
|
||||
|
||||
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
||||
|
||||
mediaProxy: this.config.mediaProxy,
|
||||
|
||||
...(ps.detail ? {
|
||||
cacheRemoteFiles: instance.cacheRemoteFiles,
|
||||
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
|
||||
requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
|
||||
} : {}),
|
||||
};
|
||||
|
||||
if (ps.detail) {
|
||||
const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null;
|
||||
|
||||
response.proxyAccountName = proxyAccount ? proxyAccount.username : null;
|
||||
response.features = {
|
||||
registration: !instance.disableRegistration,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
hcaptcha: instance.enableHcaptcha,
|
||||
recaptcha: instance.enableRecaptcha,
|
||||
turnstile: instance.enableTurnstile,
|
||||
objectStorage: instance.useObjectStorage,
|
||||
serviceWorker: instance.enableServiceWorker,
|
||||
miauth: true,
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
return ps.detail ? await this.metaEntityService.packDetailed() : await this.metaEntityService.pack();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { isPureRenote } from '@/misc/is-pure-renote.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -84,6 +85,12 @@ export const meta = {
|
|||
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
|
||||
},
|
||||
|
||||
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',
|
||||
|
|
@ -119,6 +126,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;
|
||||
|
||||
|
|
@ -312,6 +325,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
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
|
||||
|
|
@ -376,10 +391,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
};
|
||||
} catch (e) {
|
||||
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
|
||||
if (e instanceof NoteCreateService.ContainsProhibitedWordsError) {
|
||||
throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
if (e instanceof IdentifiableError) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
|
@ -52,7 +53,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
host: acct.host ?? IsNull(),
|
||||
})));
|
||||
|
||||
return await this.userEntityService.packMany(users.filter(x => x !== null) as MiUser[], me, { schema: 'UserDetailed' });
|
||||
return await this.userEntityService.packMany(users.filter(isNotNull), me, { schema: 'UserDetailed' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { SwSubscriptionsRepository } from '@/models/_.js';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account'],
|
||||
|
|
@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// if already subscribed
|
||||
|
|
@ -97,6 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
sendReadMessage: ps.sendReadMessage,
|
||||
});
|
||||
|
||||
this.pushNotificationService.refreshCache(me.id);
|
||||
|
||||
return {
|
||||
state: 'subscribed' as const,
|
||||
key: instance.swPublicKey,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import type { SwSubscriptionsRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account'],
|
||||
|
|
@ -29,12 +30,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.swSubscriptionsRepository)
|
||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.swSubscriptionsRepository.delete({
|
||||
...(me ? { userId: me.id } : {}),
|
||||
endpoint: ps.endpoint,
|
||||
});
|
||||
|
||||
if (me) {
|
||||
this.pushNotificationService.refreshCache(me.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import type { SwSubscriptionsRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -58,6 +59,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.swSubscriptionsRepository)
|
||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const swSubscription = await this.swSubscriptionsRepository.findOneBy({
|
||||
|
|
@ -77,6 +80,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
sendReadMessage: swSubscription.sendReadMessage,
|
||||
});
|
||||
|
||||
this.pushNotificationService.refreshCache(me.id);
|
||||
|
||||
return {
|
||||
userId: swSubscription.userId,
|
||||
endpoint: swSubscription.endpoint,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,15 @@ class HomeTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import fastifyView from '@fastify/view';
|
|||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyProxy from '@fastify/http-proxy';
|
||||
import vary from 'vary';
|
||||
import htmlSafeJsonStringify from 'htmlescape';
|
||||
import type { Config } from '@/config.js';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -28,12 +29,12 @@ import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, Obj
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
import { MetaEntityService } from '@/core/entities/MetaEntityService.js';
|
||||
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
|
||||
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
|
||||
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
||||
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, MiMeta, NotesRepository, PagesRepository, ReversiGamesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { deepClone } from '@/misc/clone.js';
|
||||
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
|
|
@ -93,6 +94,7 @@ export class ClientServerService {
|
|||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private pageEntityService: PageEntityService,
|
||||
private metaEntityService: MetaEntityService,
|
||||
private galleryPostEntityService: GalleryPostEntityService,
|
||||
private clipEntityService: ClipEntityService,
|
||||
private channelEntityService: ChannelEntityService,
|
||||
|
|
@ -173,7 +175,7 @@ export class ClientServerService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private generateCommonPugData(meta: MiMeta) {
|
||||
private async generateCommonPugData(meta: MiMeta) {
|
||||
return {
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
|
|
@ -183,6 +185,8 @@ export class ClientServerService {
|
|||
infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg',
|
||||
notFoundImageUrl: meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg',
|
||||
instanceUrl: this.config.url,
|
||||
metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(meta)),
|
||||
now: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -433,7 +437,7 @@ export class ClientServerService {
|
|||
url: this.config.url,
|
||||
title: meta.name ?? 'Misskey',
|
||||
desc: meta.description,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -520,7 +524,7 @@ export class ClientServerService {
|
|||
user, profile, me,
|
||||
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||
sub: request.params.sub,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
// リモートユーザーなので
|
||||
|
|
@ -570,7 +574,7 @@ export class ClientServerService {
|
|||
avatarUrl: _note.user.avatarUrl,
|
||||
// TODO: Let locale changeable by instance setting
|
||||
summary: getNoteSummary(_note),
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
|
@ -609,7 +613,7 @@ export class ClientServerService {
|
|||
page: _page,
|
||||
profile,
|
||||
avatarUrl: _page.user.avatarUrl,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
|
@ -635,7 +639,7 @@ export class ClientServerService {
|
|||
flash: _flash,
|
||||
profile,
|
||||
avatarUrl: _flash.user.avatarUrl,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
|
@ -661,7 +665,7 @@ export class ClientServerService {
|
|||
clip: _clip,
|
||||
profile,
|
||||
avatarUrl: _clip.user.avatarUrl,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
|
@ -685,7 +689,7 @@ export class ClientServerService {
|
|||
post: _post,
|
||||
profile,
|
||||
avatarUrl: _post.user.avatarUrl,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
|
@ -704,7 +708,7 @@ export class ClientServerService {
|
|||
reply.header('Cache-Control', 'public, max-age=15');
|
||||
return await reply.view('channel', {
|
||||
channel: _channel,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
|
@ -723,7 +727,7 @@ export class ClientServerService {
|
|||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
return await reply.view('reversi-game', {
|
||||
game: _game,
|
||||
...this.generateCommonPugData(meta),
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} else {
|
||||
return await renderBase(reply);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ html
|
|||
var VERSION = "#{version}";
|
||||
var CLIENT_ENTRY = "#{clientEntry.file}";
|
||||
|
||||
script(type='application/json' id='misskey_meta' data-generated-at=now)
|
||||
!= metaJson
|
||||
|
||||
script
|
||||
include ../boot.js
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,15 @@ export const notificationTypes = [
|
|||
'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;
|
||||
|
|
@ -69,6 +77,7 @@ export const moderationLogTypes = [
|
|||
'resetPassword',
|
||||
'suspendRemoteInstance',
|
||||
'unsuspendRemoteInstance',
|
||||
'updateRemoteInstanceNote',
|
||||
'markSensitiveDriveFile',
|
||||
'unmarkSensitiveDriveFile',
|
||||
'resolveAbuseReport',
|
||||
|
|
@ -209,6 +218,12 @@ export type ModerationLogPayloads = {
|
|||
id: string;
|
||||
host: string;
|
||||
};
|
||||
updateRemoteInstanceNote: {
|
||||
id: string;
|
||||
host: string;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
};
|
||||
markSensitiveDriveFile: {
|
||||
fileId: string;
|
||||
fileUserId: string | null;
|
||||
|
|
|
|||
|
|
@ -117,5 +117,185 @@ describe('Mute', () => {
|
|||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
|
||||
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi' });
|
||||
await post(carol, { text: '@alice hi' });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: 'hi', renoteId: aliceNote.id });
|
||||
await post(carol, { text: 'hi', renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { renoteId: aliceNote.id });
|
||||
await post(carol, { renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
|
||||
await api('/i/follow', { userId: alice.id }, bob);
|
||||
await api('/i/follow', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => {
|
||||
await api('/i/update/', { isLocked: true }, alice);
|
||||
await api('/following/create', { userId: alice.id }, bob);
|
||||
await api('/following/create', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification (Grouped)', () => {
|
||||
test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await react(bob, aliceNote, 'like');
|
||||
await react(carol, aliceNote, 'like');
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
|
||||
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi' });
|
||||
await post(carol, { text: '@alice hi' });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: 'hi', renoteId: aliceNote.id });
|
||||
await post(carol, { text: 'hi', renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { renoteId: aliceNote.id });
|
||||
await post(carol, { renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
|
||||
await api('/i/follow', { userId: alice.id }, bob);
|
||||
await api('/i/follow', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => {
|
||||
await api('/i/update/', { isLocked: true }, alice);
|
||||
await api('/following/create', { userId: alice.id }, bob);
|
||||
await api('/following/create', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -176,6 +176,87 @@ describe('Note', () => {
|
|||
assert.strictEqual(deleteRes.status, 204);
|
||||
});
|
||||
|
||||
test('visibility: followersなノートに対してフォロワーはリプライできる', async () => {
|
||||
await api('/following/create', {
|
||||
userId: alice.id,
|
||||
}, bob);
|
||||
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'followers',
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const replyId = aliceNote.body.createdNote.id;
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note',
|
||||
replyId,
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 200);
|
||||
assert.strictEqual(bobReply.body.createdNote.replyId, replyId);
|
||||
|
||||
await api('/following/delete', {
|
||||
userId: alice.id,
|
||||
}, bob);
|
||||
});
|
||||
|
||||
test('visibility: followersなノートに対してフォロワーでないユーザーがリプライしようとすると怒られる', async () => {
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'followers',
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note',
|
||||
replyId: aliceNote.body.createdNote.id,
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 400);
|
||||
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE');
|
||||
});
|
||||
|
||||
test('visibility: specifiedなノートに対してvisibility: specifiedで返信できる', async () => {
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [bob.id],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note',
|
||||
replyId: aliceNote.body.createdNote.id,
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [alice.id],
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 200);
|
||||
});
|
||||
|
||||
test('visibility: specifiedなノートに対してvisibility: follwersで返信しようとすると怒られる', async () => {
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [bob.id],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note with visibility: followers',
|
||||
replyId: aliceNote.body.createdNote.id,
|
||||
visibility: 'followers',
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 400);
|
||||
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY');
|
||||
});
|
||||
|
||||
test('文字数ぎりぎりで怒られない', async () => {
|
||||
const post = {
|
||||
text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字
|
||||
|
|
@ -680,6 +761,171 @@ describe('Note', () => {
|
|||
|
||||
assert.strictEqual(note1.status, 400);
|
||||
});
|
||||
|
||||
test('メンションの数が上限を超えるとエラーになる', async () => {
|
||||
const res = await api('admin/roles/create', {
|
||||
name: 'test',
|
||||
description: '',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
displayOrder: 0,
|
||||
target: 'manual',
|
||||
condFormula: {},
|
||||
isAdministrator: false,
|
||||
isModerator: false,
|
||||
isPublic: false,
|
||||
isExplorable: false,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
policies: {
|
||||
mentionLimit: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const assign = await api('admin/roles/assign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(assign.status, 204);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const note = await api('/notes/create', {
|
||||
text: '@bob potentially annoying text',
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(note.status, 400);
|
||||
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
|
||||
|
||||
await api('admin/roles/unassign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
});
|
||||
|
||||
await api('admin/roles/delete', {
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('ダイレクト投稿もエラーになる', async () => {
|
||||
const res = await api('admin/roles/create', {
|
||||
name: 'test',
|
||||
description: '',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
displayOrder: 0,
|
||||
target: 'manual',
|
||||
condFormula: {},
|
||||
isAdministrator: false,
|
||||
isModerator: false,
|
||||
isPublic: false,
|
||||
isExplorable: false,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
policies: {
|
||||
mentionLimit: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const assign = await api('admin/roles/assign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(assign.status, 204);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const note = await api('/notes/create', {
|
||||
text: 'potentially annoying text',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [ bob.id ],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(note.status, 400);
|
||||
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
|
||||
|
||||
await api('admin/roles/unassign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
});
|
||||
|
||||
await api('admin/roles/delete', {
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('ダイレクトの宛先とメンションが同じ場合は重複してカウントしない', async () => {
|
||||
const res = await api('admin/roles/create', {
|
||||
name: 'test',
|
||||
description: '',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
displayOrder: 0,
|
||||
target: 'manual',
|
||||
condFormula: {},
|
||||
isAdministrator: false,
|
||||
isModerator: false,
|
||||
isPublic: false,
|
||||
isExplorable: false,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
policies: {
|
||||
mentionLimit: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const assign = await api('admin/roles/assign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(assign.status, 204);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const note = await api('/notes/create', {
|
||||
text: '@bob potentially annoying text',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [ bob.id ],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(note.status, 200);
|
||||
|
||||
await api('admin/roles/unassign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
});
|
||||
|
||||
await api('admin/roles/delete', {
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notes/delete', () => {
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ describe('Streaming', () => {
|
|||
let chinatsu: misskey.entities.SignupResponse;
|
||||
let takumi: misskey.entities.SignupResponse;
|
||||
|
||||
let kyokoNote: any;
|
||||
let kanakoNote: any;
|
||||
let takumiNote: any;
|
||||
let kyokoNote: misskey.entities.Note;
|
||||
let kanakoNote: misskey.entities.Note;
|
||||
let takumiNote: misskey.entities.Note;
|
||||
let list: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -68,6 +68,9 @@ describe('Streaming', () => {
|
|||
// Follow: ayano => akari
|
||||
await follow(ayano, akari);
|
||||
|
||||
// Follow: kyoko => chitose
|
||||
await api('following/create', { userId: chitose.id }, kyoko);
|
||||
|
||||
// Mute: chitose => kanako
|
||||
await api('mute/create', { userId: kanako.id }, chitose);
|
||||
|
||||
|
|
@ -170,7 +173,28 @@ describe('Streaming', () => {
|
|||
*/
|
||||
|
||||
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
|
||||
// TODO
|
||||
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'reply to chitose\'s followers-only post', replyId: chitoseNote.id }, kyoko), // kyoko's reply to chitose's followers-only post
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信のリノートが流れない', async () => {
|
||||
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
|
||||
const kyokoReply = await post(kyoko, { text: 'reply to followers-only post', replyId: chitoseNote.id });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { renoteId: kyokoReply.id }, kyoko), // kyoko's renote of kyoko's reply to chitose's followers-only post
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('フォローしていないユーザーの投稿は流れない', async () => {
|
||||
|
|
@ -202,6 +226,79 @@ describe('Streaming', () => {
|
|||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
/**
|
||||
* TODO: 落ちる
|
||||
* @see https://github.com/misskey-dev/misskey/issues/13474
|
||||
test('visibility: specified なノートで visibleUserIds に自分が含まれているときそのノートへのリプライが流れてくる', async () => {
|
||||
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
*/
|
||||
|
||||
test('visibility: specified な投稿に対するリプライで visibleUserIds が拡張されたとき、その拡張されたユーザーの HTL にはそのリプライが流れない', async () => {
|
||||
const chitoseToKyoko = await post(chitose, { text: 'direct note from chitose to kyoko', visibility: 'specified', visibleUserIds: [kyoko.id] });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyoko.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('visibility: specified な投稿に対するリプライで visibleUserIds が収縮されたとき、その収縮されたユーザーの HTL にはそのリプライが流れない', async () => {
|
||||
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'direct reply from kyoko to chitose', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id] }, kyoko),
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('withRenotes: false のときリノートが流れない', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { renoteId: kyokoNote.id }, kyoko), // kyoko renote
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
{ withRenotes: false },
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('withRenotes: false のとき引用リノートが流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'quote', renoteId: kyokoNote.id }, kyoko), // kyoko quote
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
{ withRenotes: false },
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
test('withRenotes: false のとき投票のみのリノートが流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { poll: { choices: ['kinoko', 'takenoko'] }, renoteId: kyokoNote.id }, kyoko), // kyoko renote with poll
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
{ withRenotes: false },
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
}); // Home
|
||||
|
||||
describe('Local Timeline', () => {
|
||||
|
|
|
|||
|
|
@ -90,4 +90,45 @@ describe('ReactionService', () => {
|
|||
assert.strictEqual(await reactionService.normalize('unknown'), '❤');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertLegacyReactions', () => {
|
||||
test('空の入力に対しては何もしない', () => {
|
||||
const input = {};
|
||||
assert.deepStrictEqual(reactionService.convertLegacyReactions(input), input);
|
||||
});
|
||||
|
||||
test('Unicode絵文字リアクションを変換してしまわない', () => {
|
||||
const input = { '👍': 1, '🍮': 2 };
|
||||
assert.deepStrictEqual(reactionService.convertLegacyReactions(input), input);
|
||||
});
|
||||
|
||||
test('カスタム絵文字リアクションを変換してしまわない', () => {
|
||||
const input = { ':like@.:': 1, ':pudding@example.tld:': 2 };
|
||||
assert.deepStrictEqual(reactionService.convertLegacyReactions(input), input);
|
||||
});
|
||||
|
||||
test('文字列によるレガシーなリアクションを変換する', () => {
|
||||
const input = { 'like': 1, 'pudding': 2 };
|
||||
const output = { '👍': 1, '🍮': 2 };
|
||||
assert.deepStrictEqual(reactionService.convertLegacyReactions(input), output);
|
||||
});
|
||||
|
||||
test('host部分が省略されたレガシーなカスタム絵文字リアクションを変換する', () => {
|
||||
const input = { ':custom_emoji:': 1 };
|
||||
const output = { ':custom_emoji@.:': 1 };
|
||||
assert.deepStrictEqual(reactionService.convertLegacyReactions(input), output);
|
||||
});
|
||||
|
||||
test('「0個のリアクション」情報を削除する', () => {
|
||||
const input = { 'angry': 0 };
|
||||
const output = {};
|
||||
assert.deepStrictEqual(reactionService.convertLegacyReactions(input), output);
|
||||
});
|
||||
|
||||
test('host部分の有無によりデコードすると同じ表記になるカスタム絵文字リアクションの個数情報を正しく足し合わせる', () => {
|
||||
const input = { ':custom_emoji:': 1, ':custom_emoji@.:': 2 };
|
||||
const output = { ':custom_emoji@.:': 3 };
|
||||
assert.deepStrictEqual(reactionService.convertLegacyReactions(input), output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -251,6 +251,34 @@ describe('RoleService', () => {
|
|||
expect(user2Policies.canManageCustomEmojis).toBe(true);
|
||||
});
|
||||
|
||||
test('コンディショナルロール: マニュアルロールにアサイン済み', async () => {
|
||||
const [user1, user2, role1] = await Promise.all([
|
||||
createUser(),
|
||||
createUser(),
|
||||
createRole({
|
||||
name: 'manual role',
|
||||
}),
|
||||
]);
|
||||
const role2 = await createRole({
|
||||
name: 'conditional role',
|
||||
target: 'conditional',
|
||||
condFormula: {
|
||||
// idはバックエンドのロジックに必要ない?
|
||||
id: 'bdc612bd-9d54-4675-ae83-0499c82ea670',
|
||||
type: 'roleAssignedTo',
|
||||
roleId: role1.id,
|
||||
},
|
||||
});
|
||||
await roleService.assign(user2.id, role1.id);
|
||||
|
||||
const [u1role, u2role] = await Promise.all([
|
||||
roleService.getUserRoles(user1.id),
|
||||
roleService.getUserRoles(user2.id),
|
||||
]);
|
||||
expect(u1role.some(r => r.id === role2.id)).toBe(false);
|
||||
expect(u2role.some(r => r.id === role2.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('expired role', async () => {
|
||||
const user = await createUser();
|
||||
const role = await createRole({
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ export const uploadUrl = async (user: UserToken, url: string): Promise<Packed<'D
|
|||
return catcher;
|
||||
};
|
||||
|
||||
export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
|
||||
export function connectStream<C extends keyof misskey.Channels>(user: UserToken, channel: C, listener: (message: Record<string, any>) => any, params?: misskey.Channels[C]['params']): Promise<WebSocket> {
|
||||
return new Promise((res, rej) => {
|
||||
const url = new URL(`ws://127.0.0.1:${port}/streaming`);
|
||||
const options: ClientOptions = {};
|
||||
|
|
@ -390,7 +390,7 @@ export function connectStream(user: UserToken, channel: string, listener: (messa
|
|||
});
|
||||
}
|
||||
|
||||
export const waitFire = async (user: UserToken, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
|
||||
export const waitFire = async <C extends keyof misskey.Channels>(user: UserToken, channel: C, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: misskey.Channels[C]['params']) => {
|
||||
return new Promise<boolean>(async (res, rej) => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
|
|
@ -435,7 +435,7 @@ export const waitFire = async (user: UserToken, channel: string, trgr: () => any
|
|||
*/
|
||||
export function makeStreamCatcher<T>(
|
||||
user: UserToken,
|
||||
channel: string,
|
||||
channel: keyof misskey.Channels,
|
||||
cond: (message: Record<string, any>) => boolean,
|
||||
extractor: (message: Record<string, any>) => T,
|
||||
timeout = 60 * 1000): Promise<T> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue