mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-23 00:34:14 +02:00
Merge remote-tracking branch 'origin/develop' into misskey-js
This commit is contained in:
17
packages/backend/migration/1679309757174-antenna-active.js
Normal file
17
packages/backend/migration/1679309757174-antenna-active.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export class antennaActive1679309757174 {
|
||||
name = 'antennaActive1679309757174'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "antenna" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT 'now'`);
|
||||
await queryRunner.query(`ALTER TABLE "antenna" ADD "isActive" boolean NOT NULL DEFAULT true`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_084c2abb8948ef59a37dce6ac1" ON "antenna" ("lastUsedAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_36ef5192a1ce55ed0e40aa4db5" ON "antenna" ("isActive") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_36ef5192a1ce55ed0e40aa4db5"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_084c2abb8948ef59a37dce6ac1"`);
|
||||
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isActive"`);
|
||||
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "lastUsedAt"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class enableChartsForRemoteUser1679639483253 {
|
||||
name = 'enableChartsForRemoteUser1679639483253'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForRemoteUser" boolean NOT NULL DEFAULT true`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForRemoteUser"`);
|
||||
}
|
||||
}
|
||||
11
packages/backend/migration/1679651580149-cleanup.js
Normal file
11
packages/backend/migration/1679651580149-cleanup.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export class cleanup1679651580149 {
|
||||
name = 'cleanup1679651580149'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class enableChartsForFederatedInstances1679652081809 {
|
||||
name = 'enableChartsForFederatedInstances1679652081809'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForFederatedInstances" boolean NOT NULL DEFAULT true`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForFederatedInstances"`);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@
|
||||
"@tensorflow/tfjs-node": "4.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.294.0",
|
||||
"@aws-sdk/lib-storage": "^3.294.0",
|
||||
"@aws-sdk/node-http-handler": "^3.292.0",
|
||||
"@bull-board/api": "5.0.0",
|
||||
"@bull-board/fastify": "5.0.0",
|
||||
"@bull-board/ui": "5.0.0",
|
||||
@@ -59,7 +62,6 @@
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "5.3.1",
|
||||
"autwh": "0.1.0",
|
||||
"aws-sdk": "2.1318.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bull": "4.10.4",
|
||||
@@ -190,6 +192,7 @@
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.1",
|
||||
"@typescript-eslint/parser": "5.54.1",
|
||||
"aws-sdk-client-mock": "^2.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
|
||||
@@ -71,12 +71,14 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
this.antennas.push({
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
lastUsedAt: new Date(body.lastUsedAt),
|
||||
});
|
||||
break;
|
||||
case 'antennaUpdated':
|
||||
this.antennas[this.antennas.findIndex(a => a.id === body.id)] = {
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
lastUsedAt: new Date(body.lastUsedAt),
|
||||
};
|
||||
break;
|
||||
case 'antennaDeleted':
|
||||
@@ -217,7 +219,9 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
@bindThis
|
||||
public async getAntennas() {
|
||||
if (!this.antennasFetched) {
|
||||
this.antennas = await this.antennasRepository.find();
|
||||
this.antennas = await this.antennasRepository.findBy({
|
||||
isActive: true,
|
||||
});
|
||||
this.antennasFetched = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import type { EmojisRepository, Note } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
@@ -16,7 +16,7 @@ import { query } from '@/misc/prelude/url.js';
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
private cache: Cache<Emoji | null>;
|
||||
private cache: KVCache<Emoji | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@@ -34,7 +34,7 @@ export class CustomEmojiService {
|
||||
private globalEventService: GlobalEventService,
|
||||
private reactionService: ReactionService,
|
||||
) {
|
||||
this.cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||
this.cache = new KVCache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid';
|
||||
import sharp from 'sharp';
|
||||
import { sharpBmp } from 'sharp-read-bmp';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
@@ -36,7 +37,6 @@ import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { correctFilename } from '@/misc/correct-filename.js';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import type S3 from 'aws-sdk/clients/s3.js';
|
||||
|
||||
type AddFileArgs = {
|
||||
/** User who wish to add file */
|
||||
@@ -81,6 +81,7 @@ type UploadFromUrlArgs = {
|
||||
export class DriveService {
|
||||
private registerLogger: Logger;
|
||||
private downloaderLogger: Logger;
|
||||
private deleteLogger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@@ -118,6 +119,7 @@ export class DriveService {
|
||||
const logger = new Logger('drive', 'blue');
|
||||
this.registerLogger = logger.createSubLogger('register', 'yellow');
|
||||
this.downloaderLogger = logger.createSubLogger('downloader');
|
||||
this.deleteLogger = logger.createSubLogger('delete');
|
||||
}
|
||||
|
||||
/***
|
||||
@@ -368,7 +370,7 @@ export class DriveService {
|
||||
Body: stream,
|
||||
ContentType: type,
|
||||
CacheControl: 'max-age=31536000, immutable',
|
||||
} as S3.PutObjectRequest;
|
||||
} as PutObjectCommandInput;
|
||||
|
||||
if (filename) params.ContentDisposition = contentDisposition(
|
||||
'inline',
|
||||
@@ -378,21 +380,16 @@ export class DriveService {
|
||||
);
|
||||
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||
|
||||
const s3 = this.s3Service.getS3(meta);
|
||||
|
||||
const upload = s3.upload(params, {
|
||||
partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024,
|
||||
});
|
||||
|
||||
await upload.promise()
|
||||
await this.s3Service.upload(meta, params)
|
||||
.then(
|
||||
result => {
|
||||
if (result) {
|
||||
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
||||
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||
} else {
|
||||
this.registerLogger.error(`Upload Result Empty: key = ${key}, filename = ${filename}`);
|
||||
} else { // AbortMultipartUploadCommandOutput
|
||||
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
|
||||
}
|
||||
},
|
||||
})
|
||||
.catch(
|
||||
err => {
|
||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
|
||||
},
|
||||
@@ -528,10 +525,10 @@ export class DriveService {
|
||||
};
|
||||
|
||||
const properties: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
} = {};
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
} = {};
|
||||
|
||||
if (info.width) {
|
||||
properties['width'] = info.width;
|
||||
@@ -616,17 +613,20 @@ export class DriveService {
|
||||
|
||||
if (user) {
|
||||
this.driveFileEntityService.pack(file, { self: true }).then(packedFile => {
|
||||
// Publish driveFileCreated event
|
||||
// Publish driveFileCreated event
|
||||
this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile);
|
||||
this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile);
|
||||
});
|
||||
}
|
||||
|
||||
// 統計を更新
|
||||
this.driveChart.update(file, true);
|
||||
this.perUserDriveChart.update(file, true);
|
||||
if (file.userHost !== null) {
|
||||
this.instanceChart.updateDrive(file, true);
|
||||
if (file.userHost == null) {
|
||||
// ローカルユーザーのみ
|
||||
this.perUserDriveChart.update(file, true);
|
||||
} else {
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateDrive(file, true);
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
@@ -692,7 +692,7 @@ export class DriveService {
|
||||
|
||||
@bindThis
|
||||
private async deletePostProcess(file: DriveFile, isExpired = false) {
|
||||
// リモートファイル期限切れ削除後は直リンクにする
|
||||
// リモートファイル期限切れ削除後は直リンクにする
|
||||
if (isExpired && file.userHost !== null && file.uri != null) {
|
||||
this.driveFilesRepository.update(file.id, {
|
||||
isLink: true,
|
||||
@@ -709,33 +709,36 @@ export class DriveService {
|
||||
this.driveFilesRepository.delete(file.id);
|
||||
}
|
||||
|
||||
// 統計を更新
|
||||
this.driveChart.update(file, false);
|
||||
this.perUserDriveChart.update(file, false);
|
||||
if (file.userHost !== null) {
|
||||
this.instanceChart.updateDrive(file, false);
|
||||
if (file.userHost == null) {
|
||||
// ローカルユーザーのみ
|
||||
this.perUserDriveChart.update(file, false);
|
||||
} else {
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateDrive(file, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deleteObjectStorageFile(key: string) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const s3 = this.s3Service.getS3(meta);
|
||||
|
||||
try {
|
||||
await s3.deleteObject({
|
||||
Bucket: meta.objectStorageBucket!,
|
||||
const param = {
|
||||
Bucket: meta.objectStorageBucket,
|
||||
Key: key,
|
||||
}).promise();
|
||||
} as DeleteObjectCommandInput;
|
||||
|
||||
await this.s3Service.delete(meta, param);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NoSuchKey') {
|
||||
console.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err);
|
||||
if (err.name === 'NoSuchKey') {
|
||||
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { InstancesRepository } from '@/models/index.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
@@ -9,7 +9,7 @@ import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService {
|
||||
private cache: Cache<Instance>;
|
||||
private cache: KVCache<Instance>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.instancesRepository)
|
||||
@@ -18,7 +18,7 @@ export class FederatedInstanceService {
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.cache = new Cache<Instance>(1000 * 60 * 60);
|
||||
this.cache = new KVCache<Instance>(1000 * 60 * 60);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { LocalUser } from '@/models/entities/User.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const;
|
||||
|
||||
@Injectable()
|
||||
export class InstanceActorService {
|
||||
private cache: Cache<LocalUser>;
|
||||
private cache: KVCache<LocalUser>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
@@ -19,7 +19,7 @@ export class InstanceActorService {
|
||||
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
) {
|
||||
this.cache = new Cache<LocalUser>(Infinity);
|
||||
this.cache = new KVCache<LocalUser>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -19,7 +19,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js
|
||||
import { checkWordMute } from '@/misc/check-word-mute.js';
|
||||
import type { Channel } from '@/models/entities/Channel.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
@@ -46,7 +46,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
||||
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||
const mutedWordsCache = new KVCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
@@ -435,15 +435,20 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
createdAt: User['createdAt'];
|
||||
isBot: User['isBot'];
|
||||
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
|
||||
// 統計を更新
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
this.notesChart.update(note, true);
|
||||
this.perUserNotesChart.update(user, note, true);
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
this.perUserNotesChart.update(user, note, true);
|
||||
}
|
||||
|
||||
// Register host
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(i => {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteDeleteService {
|
||||
@@ -39,6 +40,7 @@ export class NoteDeleteService {
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private metaService: MetaService,
|
||||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private instanceChart: InstanceChart,
|
||||
@@ -95,14 +97,19 @@ export class NoteDeleteService {
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// 統計を更新
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
this.notesChart.update(note, false);
|
||||
this.perUserNotesChart.update(user, note, false);
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(i => {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { bindThis } from '@/decorators.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
|
||||
const FALLBACK = '❤';
|
||||
|
||||
const legacies: Record<string, string> = {
|
||||
'like': '👍',
|
||||
'love': '❤', // ここに記述する場合は異体字セレクタを入れない
|
||||
@@ -147,7 +149,11 @@ export class ReactionService {
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
this.perUserReactionsChart.update(user, note);
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
this.perUserReactionsChart.update(user, note);
|
||||
}
|
||||
|
||||
// カスタム絵文字リアクションだったら絵文字情報も送る
|
||||
const decodedReaction = this.decodeReaction(reaction);
|
||||
@@ -251,12 +257,6 @@ export class ReactionService {
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getFallbackReaction(): Promise<string> {
|
||||
const meta = await this.metaService.fetch();
|
||||
return meta.useStarForReactionFallback ? '⭐' : '👍';
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertLegacyReactions(reactions: Record<string, number>) {
|
||||
const _reactions = {} as Record<string, number>;
|
||||
@@ -290,7 +290,7 @@ export class ReactionService {
|
||||
|
||||
@bindThis
|
||||
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
|
||||
if (reaction == null) return await this.getFallbackReaction();
|
||||
if (reaction == null) return FALLBACK;
|
||||
|
||||
reacterHost = this.utilityService.toPunyNullable(reacterHost);
|
||||
|
||||
@@ -318,7 +318,7 @@ export class ReactionService {
|
||||
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
|
||||
}
|
||||
|
||||
return await this.getFallbackReaction();
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IsNull } from 'typeorm';
|
||||
import type { LocalUser, User } from '@/models/entities/User.js';
|
||||
import type { RelaysRepository, UsersRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { Relay } from '@/models/entities/Relay.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
@@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const;
|
||||
|
||||
@Injectable()
|
||||
export class RelayService {
|
||||
private relaysCache: Cache<Relay[]>;
|
||||
private relaysCache: KVCache<Relay[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
@@ -30,7 +30,7 @@ export class RelayService {
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
this.relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
|
||||
this.relaysCache = new KVCache<Relay[]>(1000 * 60 * 10);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { In } from 'typeorm';
|
||||
import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -57,8 +57,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||
|
||||
@Injectable()
|
||||
export class RoleService implements OnApplicationShutdown {
|
||||
private rolesCache: Cache<Role[]>;
|
||||
private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>;
|
||||
private rolesCache: KVCache<Role[]>;
|
||||
private roleAssignmentByUserIdCache: KVCache<RoleAssignment[]>;
|
||||
|
||||
public static AlreadyAssignedError = class extends Error {};
|
||||
public static NotAssignedError = class extends Error {};
|
||||
@@ -84,8 +84,8 @@ export class RoleService implements OnApplicationShutdown {
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.rolesCache = new Cache<Role[]>(Infinity);
|
||||
this.roleAssignmentByUserIdCache = new Cache<RoleAssignment[]>(Infinity);
|
||||
this.rolesCache = new KVCache<Role[]>(Infinity);
|
||||
this.roleAssignmentByUserIdCache = new KVCache<RoleAssignment[]>(Infinity);
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
@@ -192,6 +192,12 @@ export class RoleService implements OnApplicationShutdown {
|
||||
case 'followingMoreThanOrEq': {
|
||||
return user.followingCount >= value.value;
|
||||
}
|
||||
case 'notesLessThanOrEq': {
|
||||
return user.notesCount <= value.value;
|
||||
}
|
||||
case 'notesMoreThanOrEq': {
|
||||
return user.notesCount >= value.value;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { URL } from 'node:url';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import S3 from 'aws-sdk/clients/s3.js';
|
||||
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { NodeHttpHandler, NodeHttpHandlerOptions } from '@aws-sdk/node-http-handler';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Meta } from '@/models/entities/Meta.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3';
|
||||
|
||||
@Injectable()
|
||||
export class S3Service {
|
||||
@@ -18,25 +23,47 @@ export class S3Service {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getS3(meta: Meta) {
|
||||
public getS3Client(meta: Meta): S3Client {
|
||||
const u = meta.objectStorageEndpoint
|
||||
? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}`
|
||||
: `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`;
|
||||
? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}`
|
||||
: `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent
|
||||
|
||||
return new S3({
|
||||
endpoint: meta.objectStorageEndpoint && meta.objectStorageEndpoint.length > 0
|
||||
? meta.objectStorageEndpoint
|
||||
: undefined,
|
||||
accessKeyId: meta.objectStorageAccessKey!,
|
||||
secretAccessKey: meta.objectStorageSecretKey!,
|
||||
const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy);
|
||||
const handlerOption: NodeHttpHandlerOptions = {};
|
||||
if (meta.objectStorageUseSSL) {
|
||||
handlerOption.httpsAgent = agent as https.Agent;
|
||||
} else {
|
||||
handlerOption.httpAgent = agent as http.Agent;
|
||||
}
|
||||
|
||||
return new S3Client({
|
||||
endpoint: meta.objectStorageEndpoint ? u : undefined,
|
||||
credentials: (meta.objectStorageAccessKey !== null && meta.objectStorageSecretKey !== null) ? {
|
||||
accessKeyId: meta.objectStorageAccessKey,
|
||||
secretAccessKey: meta.objectStorageSecretKey,
|
||||
} : undefined,
|
||||
region: meta.objectStorageRegion ?? undefined,
|
||||
sslEnabled: meta.objectStorageUseSSL,
|
||||
s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted
|
||||
? false
|
||||
: meta.objectStorageS3ForcePathStyle,
|
||||
httpOptions: {
|
||||
agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
|
||||
},
|
||||
tls: meta.objectStorageUseSSL,
|
||||
forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted
|
||||
requestHandler: new NodeHttpHandler(handlerOption),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async upload(meta: Meta, input: PutObjectCommandInput) {
|
||||
const client = this.getS3Client(meta);
|
||||
return new Upload({
|
||||
client,
|
||||
params: input,
|
||||
partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com')
|
||||
? 500 * 1024 * 1024
|
||||
: 8 * 1024 * 1024,
|
||||
}).done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public delete(meta: Meta, input: DeleteObjectCommandInput) {
|
||||
const client = this.getS3Client(meta);
|
||||
return client.send(new DeleteObjectCommand(input));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { WebhookService } from '@/core/WebhookService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -23,7 +23,7 @@ export class UserBlockingService implements OnApplicationShutdown {
|
||||
private logger: Logger;
|
||||
|
||||
// キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ
|
||||
private blockingsByUserIdCache: Cache<User['id'][]>;
|
||||
private blockingsByUserIdCache: KVCache<User['id'][]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
@@ -58,7 +58,7 @@ export class UserBlockingService implements OnApplicationShutdown {
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('user-block');
|
||||
|
||||
this.blockingsByUserIdCache = new Cache<User['id'][]>(Infinity);
|
||||
this.blockingsByUserIdCache = new KVCache<User['id'][]>(Infinity);
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { LocalUser, User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
@@ -11,10 +11,10 @@ import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class UserCacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: Cache<User>;
|
||||
public localUserByNativeTokenCache: Cache<LocalUser | null>;
|
||||
public localUserByIdCache: Cache<LocalUser>;
|
||||
public uriPersonCache: Cache<User | null>;
|
||||
public userByIdCache: KVCache<User>;
|
||||
public localUserByNativeTokenCache: KVCache<LocalUser | null>;
|
||||
public localUserByIdCache: KVCache<LocalUser>;
|
||||
public uriPersonCache: KVCache<User | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
@@ -27,10 +27,10 @@ export class UserCacheService implements OnApplicationShutdown {
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.userByIdCache = new Cache<User>(Infinity);
|
||||
this.localUserByNativeTokenCache = new Cache<LocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new Cache<LocalUser>(Infinity);
|
||||
this.uriPersonCache = new Cache<User | null>(Infinity);
|
||||
this.userByIdCache = new KVCache<User>(Infinity);
|
||||
this.localUserByNativeTokenCache = new KVCache<LocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new KVCache<LocalUser>(Infinity);
|
||||
this.uriPersonCache = new KVCache<User | null>(Infinity);
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
@@ -57,6 +58,7 @@ export class UserFollowingService {
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private metaService: MetaService,
|
||||
private notificationService: NotificationService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private webhookService: WebhookService,
|
||||
@@ -200,14 +202,18 @@ export class UserFollowingService {
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(i => {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(i => {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
@@ -320,14 +326,18 @@ export class UserFollowingService {
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(i => {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(i => {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { UserKeypairsRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserKeypairStoreService {
|
||||
private cache: Cache<UserKeypair>;
|
||||
private cache: KVCache<UserKeypair>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userKeypairsRepository)
|
||||
private userKeypairsRepository: UserKeypairsRepository,
|
||||
) {
|
||||
this.cache = new Cache<UserKeypair>(Infinity);
|
||||
this.cache = new KVCache<UserKeypair>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -3,7 +3,7 @@ import escapeRegexp from 'escape-regexp';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { UserPublickey } from '@/models/entities/UserPublickey.js';
|
||||
import { UserCacheService } from '@/core/UserCacheService.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
@@ -31,8 +31,8 @@ export type UriParseResult = {
|
||||
|
||||
@Injectable()
|
||||
export class ApDbResolverService {
|
||||
private publicKeyCache: Cache<UserPublickey | null>;
|
||||
private publicKeyByUserIdCache: Cache<UserPublickey | null>;
|
||||
private publicKeyCache: KVCache<UserPublickey | null>;
|
||||
private publicKeyByUserIdCache: KVCache<UserPublickey | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@@ -50,8 +50,8 @@ export class ApDbResolverService {
|
||||
private userCacheService: UserCacheService,
|
||||
private apPersonService: ApPersonService,
|
||||
) {
|
||||
this.publicKeyCache = new Cache<UserPublickey | null>(Infinity);
|
||||
this.publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
|
||||
this.publicKeyCache = new KVCache<UserPublickey | null>(Infinity);
|
||||
this.publicKeyByUserIdCache = new KVCache<UserPublickey | null>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -30,6 +30,7 @@ import { StatusError } from '@/misc/status-error.js';
|
||||
import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
@@ -50,6 +51,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
private idService: IdService;
|
||||
private globalEventService: GlobalEventService;
|
||||
private metaService: MetaService;
|
||||
private federatedInstanceService: FederatedInstanceService;
|
||||
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
||||
private userCacheService: UserCacheService;
|
||||
@@ -92,6 +94,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
//private userEntityService: UserEntityService,
|
||||
//private idService: IdService,
|
||||
//private globalEventService: GlobalEventService,
|
||||
//private metaService: MetaService,
|
||||
//private federatedInstanceService: FederatedInstanceService,
|
||||
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
|
||||
//private userCacheService: UserCacheService,
|
||||
@@ -112,6 +115,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
this.userEntityService = this.moduleRef.get('UserEntityService');
|
||||
this.idService = this.moduleRef.get('IdService');
|
||||
this.globalEventService = this.moduleRef.get('GlobalEventService');
|
||||
this.metaService = this.moduleRef.get('MetaService');
|
||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
|
||||
this.userCacheService = this.moduleRef.get('UserCacheService');
|
||||
@@ -327,10 +331,12 @@ export class ApPersonService implements OnModuleInit {
|
||||
}
|
||||
|
||||
// Register host
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.federatedInstanceService.fetch(host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
this.instanceChart.newUser(i.host);
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
});
|
||||
|
||||
this.usersChart.update(user!, true);
|
||||
|
||||
@@ -37,6 +37,7 @@ export class AntennaEntityService {
|
||||
notify: antenna.notify,
|
||||
withReplies: antenna.withReplies,
|
||||
withFile: antenna.withFile,
|
||||
isActive: antenna.isActive,
|
||||
hasUnreadNote,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { Promiseable } from '@/misc/prelude/await-all.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
|
||||
@@ -52,7 +52,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private antennaService: AntennaService;
|
||||
private roleService: RoleService;
|
||||
private userInstanceCache: Cache<Instance | null>;
|
||||
private userInstanceCache: KVCache<Instance | null>;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
@@ -121,7 +121,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
//private antennaService: AntennaService,
|
||||
//private roleService: RoleService,
|
||||
) {
|
||||
this.userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
|
||||
this.userInstanceCache = new KVCache<Instance | null>(1000 * 60 * 60 * 3);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
|
||||
@@ -2,11 +2,11 @@ import { bindThis } from '@/decorators.js';
|
||||
|
||||
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
|
||||
|
||||
export class Cache<T> {
|
||||
export class KVCache<T> {
|
||||
public cache: Map<string | null, { date: number; value: T; }>;
|
||||
private lifetime: number;
|
||||
|
||||
constructor(lifetime: Cache<never>['lifetime']) {
|
||||
constructor(lifetime: KVCache<never>['lifetime']) {
|
||||
this.cache = new Map();
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
@@ -87,3 +87,88 @@ export class Cache<T> {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export class Cache<T> {
|
||||
private cachedAt: number | null = null;
|
||||
private value: T | undefined;
|
||||
private lifetime: number;
|
||||
|
||||
constructor(lifetime: Cache<never>['lifetime']) {
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public set(value: T): void {
|
||||
this.cachedAt = Date.now();
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public get(): T | undefined {
|
||||
if (this.cachedAt == null) return undefined;
|
||||
if ((Date.now() - this.cachedAt) > this.lifetime) {
|
||||
this.value = undefined;
|
||||
this.cachedAt = null;
|
||||
return undefined;
|
||||
}
|
||||
return this.value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public delete() {
|
||||
this.value = undefined;
|
||||
this.cachedAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
||||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||
*/
|
||||
@bindThis
|
||||
public async fetch(fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
|
||||
const cachedValue = this.get();
|
||||
if (cachedValue !== undefined) {
|
||||
if (validator) {
|
||||
if (validator(cachedValue)) {
|
||||
// Cache HIT
|
||||
return cachedValue;
|
||||
}
|
||||
} else {
|
||||
// Cache HIT
|
||||
return cachedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache MISS
|
||||
const value = await fetcher();
|
||||
this.set(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
||||
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||
*/
|
||||
@bindThis
|
||||
public async fetchMaybe(fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
|
||||
const cachedValue = this.get();
|
||||
if (cachedValue !== undefined) {
|
||||
if (validator) {
|
||||
if (validator(cachedValue)) {
|
||||
// Cache HIT
|
||||
return cachedValue;
|
||||
}
|
||||
} else {
|
||||
// Cache HIT
|
||||
return cachedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache MISS
|
||||
const value = await fetcher();
|
||||
if (value !== undefined) {
|
||||
this.set(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// 与えられた拡張子とファイル名が一致しているかどうかを確認し、
|
||||
// 一致していない場合は拡張子を付与して返す
|
||||
export function correctFilename(filename: string, ext: string | null) {
|
||||
const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown';
|
||||
if (filename.endsWith(dotExt)) {
|
||||
return filename;
|
||||
}
|
||||
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
|
||||
return filename;
|
||||
}
|
||||
if (ext === 'tif' && filename.endsWith('.tiff')) {
|
||||
return filename;
|
||||
}
|
||||
return `${filename}${dotExt}`;
|
||||
const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown';
|
||||
if (filename.endsWith(dotExt)) {
|
||||
return filename;
|
||||
}
|
||||
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
|
||||
return filename;
|
||||
}
|
||||
if (ext === 'tif' && filename.endsWith('.tiff')) {
|
||||
return filename;
|
||||
}
|
||||
return `${filename}${dotExt}`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ export class Antenna {
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone')
|
||||
public lastUsedAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
@@ -83,4 +87,10 @@ export class Antenna {
|
||||
|
||||
@Column('boolean')
|
||||
public notify: boolean;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -42,11 +42,6 @@ export class Meta {
|
||||
})
|
||||
public disableRegistration: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public useStarForReactionFallback: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
@@ -396,6 +391,16 @@ export class Meta {
|
||||
})
|
||||
public enableActiveEmailValidation: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public enableChartsForRemoteUser: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public enableChartsForFederatedInstances: boolean;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: { },
|
||||
})
|
||||
|
||||
@@ -54,6 +54,16 @@ type CondFormulaValueFollowingMoreThanOrEq = {
|
||||
value: number;
|
||||
};
|
||||
|
||||
type CondFormulaValueNotesLessThanOrEq = {
|
||||
type: 'notesLessThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
type CondFormulaValueNotesMoreThanOrEq = {
|
||||
type: 'notesMoreThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type RoleCondFormulaValue =
|
||||
CondFormulaValueAnd |
|
||||
CondFormulaValueOr |
|
||||
@@ -65,7 +75,9 @@ export type RoleCondFormulaValue =
|
||||
CondFormulaValueFollowersLessThanOrEq |
|
||||
CondFormulaValueFollowersMoreThanOrEq |
|
||||
CondFormulaValueFollowingLessThanOrEq |
|
||||
CondFormulaValueFollowingMoreThanOrEq;
|
||||
CondFormulaValueFollowingMoreThanOrEq |
|
||||
CondFormulaValueNotesLessThanOrEq |
|
||||
CondFormulaValueNotesMoreThanOrEq;
|
||||
|
||||
@Entity()
|
||||
export class Role {
|
||||
|
||||
@@ -75,6 +75,10 @@ export const packedAntennaSchema = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isActive: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
hasUnreadNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, LessThan } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
|
||||
import type { AntennaNotesRepository, AntennasRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -26,6 +26,9 @@ export class CleanProcessorService {
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
@@ -55,8 +58,16 @@ export class CleanProcessorService {
|
||||
reason: 'word',
|
||||
});
|
||||
|
||||
this.antennaNotesRepository.delete({
|
||||
this.mutedNotesRepository.delete({
|
||||
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
|
||||
reason: 'word',
|
||||
});
|
||||
|
||||
// 7日以上使われてないアンテナを停止
|
||||
this.antennasRepository.update({
|
||||
lastUsedAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 7))),
|
||||
}, {
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js';
|
||||
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
@@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js';
|
||||
@Injectable()
|
||||
export class DeliverProcessorService {
|
||||
private logger: Logger;
|
||||
private suspendedHostsCache: Cache<Instance[]>;
|
||||
private suspendedHostsCache: KVCache<Instance[]>;
|
||||
private latest: string | null;
|
||||
|
||||
constructor(
|
||||
@@ -46,7 +46,7 @@ export class DeliverProcessorService {
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
|
||||
this.suspendedHostsCache = new Cache<Instance[]>(1000 * 60 * 60);
|
||||
this.suspendedHostsCache = new KVCache<Instance[]>(1000 * 60 * 60);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -88,10 +88,12 @@ export class DeliverProcessorService {
|
||||
}
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
|
||||
this.instanceChart.requestSent(i.host, true);
|
||||
this.apRequestChart.deliverSucc();
|
||||
this.federationChart.deliverd(i.host, true);
|
||||
|
||||
if (meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, true);
|
||||
}
|
||||
});
|
||||
|
||||
return 'Success';
|
||||
@@ -107,9 +109,12 @@ export class DeliverProcessorService {
|
||||
});
|
||||
}
|
||||
|
||||
this.instanceChart.requestSent(i.host, false);
|
||||
this.apRequestChart.deliverFail();
|
||||
this.federationChart.deliverd(i.host, false);
|
||||
|
||||
if (meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, false);
|
||||
}
|
||||
});
|
||||
|
||||
if (res instanceof StatusError) {
|
||||
|
||||
@@ -184,9 +184,12 @@ export class InboxProcessorService {
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
|
||||
this.instanceChart.requestReceived(i.host);
|
||||
this.apRequestChart.inbox();
|
||||
this.federationChart.inbox(i.host);
|
||||
|
||||
if (meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestReceived(i.host);
|
||||
}
|
||||
});
|
||||
|
||||
// アクティビティを処理
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import NotesChart from '@/core/chart/charts/notes.js';
|
||||
@@ -118,7 +118,7 @@ export class NodeinfoServerService {
|
||||
};
|
||||
};
|
||||
|
||||
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
||||
const cache = new KVCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
||||
|
||||
fastify.get(nodeinfo2_1path, async (request, reply) => {
|
||||
const base = await cache.fetch(null, () => nodeinfo2());
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { LocalUser } from '@/models/entities/User.js';
|
||||
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { KVCache } from '@/misc/cache.js';
|
||||
import type { App } from '@/models/entities/App.js';
|
||||
import { UserCacheService } from '@/core/UserCacheService.js';
|
||||
import isNativeToken from '@/misc/is-native-token.js';
|
||||
@@ -18,7 +18,7 @@ export class AuthenticationError extends Error {
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticateService {
|
||||
private appCache: Cache<App>;
|
||||
private appCache: KVCache<App>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
@@ -32,7 +32,7 @@ export class AuthenticateService {
|
||||
|
||||
private userCacheService: UserCacheService,
|
||||
) {
|
||||
this.appCache = new Cache<App>(Infinity);
|
||||
this.appCache = new KVCache<App>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
|
||||
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
|
||||
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
|
||||
import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js';
|
||||
import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js';
|
||||
import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js';
|
||||
import * as ep___admin_relays_add from './endpoints/admin/relays/add.js';
|
||||
import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
|
||||
@@ -370,6 +371,7 @@ const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useCla
|
||||
const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default };
|
||||
const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default };
|
||||
const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default };
|
||||
const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default };
|
||||
const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default };
|
||||
const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default };
|
||||
const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default };
|
||||
@@ -702,6 +704,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$admin_queue_clear,
|
||||
$admin_queue_deliverDelayed,
|
||||
$admin_queue_inboxDelayed,
|
||||
$admin_queue_promote,
|
||||
$admin_queue_stats,
|
||||
$admin_relays_add,
|
||||
$admin_relays_list,
|
||||
@@ -1028,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$admin_queue_clear,
|
||||
$admin_queue_deliverDelayed,
|
||||
$admin_queue_inboxDelayed,
|
||||
$admin_queue_promote,
|
||||
$admin_queue_stats,
|
||||
$admin_relays_add,
|
||||
$admin_relays_list,
|
||||
|
||||
@@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
|
||||
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
|
||||
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
|
||||
import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js';
|
||||
import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js';
|
||||
import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js';
|
||||
import * as ep___admin_relays_add from './endpoints/admin/relays/add.js';
|
||||
import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
|
||||
@@ -368,6 +369,7 @@ const eps = [
|
||||
['admin/queue/clear', ep___admin_queue_clear],
|
||||
['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed],
|
||||
['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed],
|
||||
['admin/queue/promote', ep___admin_queue_promote],
|
||||
['admin/queue/stats', ep___admin_queue_stats],
|
||||
['admin/relays/add', ep___admin_relays_add],
|
||||
['admin/relays/list', ep___admin_relays_list],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@@ -19,6 +19,11 @@ export const meta = {
|
||||
code: 'NO_SUCH_EMOJI',
|
||||
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
|
||||
},
|
||||
sameNameEmojiExists: {
|
||||
message: 'Emoji that have same name already exists.',
|
||||
code: 'SAME_NAME_EMOJI_EXISTS',
|
||||
id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -26,7 +31,7 @@ export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string' },
|
||||
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
|
||||
category: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
@@ -57,9 +62,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emoji = await this.emojisRepository.findOneBy({ id: ps.id });
|
||||
|
||||
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: ps.name, host: IsNull() });
|
||||
if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
|
||||
|
||||
if (sameNameEmoji != null && sameNameEmoji.id !== ps.id) throw new ApiError(meta.errors.sameNameEmojiExists);
|
||||
await this.emojisRepository.update(emoji.id, {
|
||||
updatedAt: new Date(),
|
||||
name: ps.name,
|
||||
|
||||
@@ -239,6 +239,14 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
enableChartsForRemoteUser: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableChartsForFederatedInstances: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
policies: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
@@ -299,7 +307,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
translatorAvailable: instance.deeplAuthKey != null,
|
||||
cacheRemoteFiles: instance.cacheRemoteFiles,
|
||||
useStarForReactionFallback: instance.useStarForReactionFallback,
|
||||
pinnedUsers: instance.pinnedUsers,
|
||||
hiddenTags: instance.hiddenTags,
|
||||
blockedHosts: instance.blockedHosts,
|
||||
@@ -337,6 +344,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
deeplIsPro: instance.deeplIsPro,
|
||||
enableIpLogging: instance.enableIpLogging,
|
||||
enableActiveEmailValidation: instance.enableActiveEmailValidation,
|
||||
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
|
||||
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
|
||||
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', enum: ['deliver', 'inbox'] },
|
||||
},
|
||||
required: ['type'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
private moderationLogService: ModerationLogService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let delayedQueues;
|
||||
|
||||
switch (ps.type) {
|
||||
case 'deliver':
|
||||
delayedQueues = await this.queueService.deliverQueue.getDelayed();
|
||||
for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
|
||||
const queue = delayedQueues[queueIndex];
|
||||
await queue.promote();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'inbox':
|
||||
delayedQueues = await this.queueService.inboxQueue.getDelayed();
|
||||
for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
|
||||
const queue = delayedQueues[queueIndex];
|
||||
await queue.promote();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.moderationLogService.insertModerationLog(me, 'promoteQueue');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
disableRegistration: { type: 'boolean', nullable: true },
|
||||
useStarForReactionFallback: { type: 'boolean', nullable: true },
|
||||
pinnedUsers: { type: 'array', nullable: true, items: {
|
||||
type: 'string',
|
||||
} },
|
||||
@@ -93,6 +92,8 @@ export const paramDef = {
|
||||
objectStorageS3ForcePathStyle: { type: 'boolean' },
|
||||
enableIpLogging: { type: 'boolean' },
|
||||
enableActiveEmailValidation: { type: 'boolean' },
|
||||
enableChartsForRemoteUser: { type: 'boolean' },
|
||||
enableChartsForFederatedInstances: { type: 'boolean' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -114,10 +115,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
set.disableRegistration = ps.disableRegistration;
|
||||
}
|
||||
|
||||
if (typeof ps.useStarForReactionFallback === 'boolean') {
|
||||
set.useStarForReactionFallback = ps.useStarForReactionFallback;
|
||||
}
|
||||
|
||||
if (Array.isArray(ps.pinnedUsers)) {
|
||||
set.pinnedUsers = ps.pinnedUsers.filter(Boolean);
|
||||
}
|
||||
@@ -382,6 +379,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
set.enableActiveEmailValidation = ps.enableActiveEmailValidation;
|
||||
}
|
||||
|
||||
if (ps.enableChartsForRemoteUser !== undefined) {
|
||||
set.enableChartsForRemoteUser = ps.enableChartsForRemoteUser;
|
||||
}
|
||||
|
||||
if (ps.enableChartsForFederatedInstances !== undefined) {
|
||||
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
|
||||
}
|
||||
|
||||
await this.metaService.update(set);
|
||||
this.moderationLogService.insertModerationLog(me, 'updateMeta');
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.keywords.length === 0) {
|
||||
if ((ps.keywords.length === 0) || ps.keywords[0].every(x => x === '')) {
|
||||
throw new Error('invalid param');
|
||||
}
|
||||
|
||||
@@ -103,9 +103,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const antenna = await this.antennasRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
lastUsedAt: now,
|
||||
userId: me.id,
|
||||
name: ps.name,
|
||||
src: ps.src,
|
||||
|
||||
@@ -101,6 +101,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
this.noteReadService.read(me.id, notes);
|
||||
}
|
||||
|
||||
this.antennasRepository.update(antenna.id, {
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const paramDef = {
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
|
||||
type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) },
|
||||
sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -63,6 +64,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
}
|
||||
}
|
||||
|
||||
switch (ps.sort) {
|
||||
case '+createdAt': query.orderBy('file.createdAt', 'DESC'); break;
|
||||
case '-createdAt': query.orderBy('file.createdAt', 'ASC'); break;
|
||||
case '+name': query.orderBy('file.name', 'DESC'); break;
|
||||
case '-name': query.orderBy('file.name', 'ASC'); break;
|
||||
case '+size': query.orderBy('file.size', 'DESC'); break;
|
||||
case '-size': query.orderBy('file.size', 'ASC'); break;
|
||||
}
|
||||
|
||||
const files = await query.take(ps.limit).getMany();
|
||||
|
||||
return await this.driveFileEntityService.packMany(files, { detail: false, self: true });
|
||||
|
||||
@@ -48,6 +48,7 @@ export const meta = {
|
||||
message: 'No such user.',
|
||||
code: 'NO_SUCH_USER',
|
||||
id: '4362f8dc-731f-4ad8-a694-be5a88922a24',
|
||||
httpStatusCode: 404,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { summaly } from 'summaly';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
@@ -9,6 +8,7 @@ import type Logger from '@/logger.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
@@ -40,9 +40,9 @@ export class UrlPreviewService {
|
||||
|
||||
@bindThis
|
||||
public async handle(
|
||||
request: FastifyRequest<{ Querystring: { url: string; lang: string; } }>,
|
||||
request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
): Promise<object | undefined> {
|
||||
const url = request.query.url;
|
||||
if (typeof url !== 'string') {
|
||||
reply.code(400);
|
||||
@@ -78,7 +78,7 @@ export class UrlPreviewService {
|
||||
|
||||
this.logger.succ(`Got preview of ${url}: ${summary.title}`);
|
||||
|
||||
if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
|
||||
if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
|
||||
throw new Error('unsupported schema included');
|
||||
}
|
||||
|
||||
@@ -95,9 +95,15 @@ export class UrlPreviewService {
|
||||
return summary;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to get preview of ${url}: ${err}`);
|
||||
reply.code(200);
|
||||
reply.code(422);
|
||||
reply.header('Cache-Control', 'max-age=86400, immutable');
|
||||
return {};
|
||||
return {
|
||||
error: new ApiError({
|
||||
message: 'Failed to get preview',
|
||||
code: 'URL_PREVIEW_FAILED',
|
||||
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
962
packages/backend/test/e2e/clips.ts
Normal file
962
packages/backend/test/e2e/clips.ts
Normal file
@@ -0,0 +1,962 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { JTDDataType } from 'ajv/dist/jtd';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { paramDef as CreateParamDef } from '@/server/api/endpoints/clips/create.js';
|
||||
import { paramDef as UpdateParamDef } from '@/server/api/endpoints/clips/update.js';
|
||||
import { paramDef as DeleteParamDef } from '@/server/api/endpoints/clips/delete.js';
|
||||
import { paramDef as ShowParamDef } from '@/server/api/endpoints/clips/show.js';
|
||||
import { paramDef as FavoriteParamDef } from '@/server/api/endpoints/clips/favorite.js';
|
||||
import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unfavorite.js';
|
||||
import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js';
|
||||
import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js';
|
||||
import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js';
|
||||
import {
|
||||
signup,
|
||||
post,
|
||||
startServer,
|
||||
api,
|
||||
successfulApiCall,
|
||||
failedApiCall,
|
||||
ApiRequest,
|
||||
hiddenNote,
|
||||
} from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
describe('クリップ', () => {
|
||||
type User = Packed<'User'>;
|
||||
type Note = Packed<'Note'>;
|
||||
type Clip = Packed<'Clip'>;
|
||||
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let alice: User;
|
||||
let bob: User;
|
||||
let aliceNote: Note;
|
||||
let aliceHomeNote: Note;
|
||||
let aliceFollowersNote: Note;
|
||||
let aliceSpecifiedNote: Note;
|
||||
let bobNote: Note;
|
||||
let bobHomeNote: Note;
|
||||
let bobFollowersNote: Note;
|
||||
let bobSpecifiedNote: Note;
|
||||
|
||||
const compareBy = <T extends { id: string }, >(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
|
||||
return selector(a).localeCompare(selector(b));
|
||||
};
|
||||
|
||||
type CreateParam = JTDDataType<typeof CreateParamDef>;
|
||||
const defaultCreate = (): Partial<CreateParam> => ({
|
||||
name: 'test',
|
||||
});
|
||||
const create = async (parameters: Partial<CreateParam> = {}, request: Partial<ApiRequest> = {}): Promise<Clip> => {
|
||||
const clip = await successfulApiCall<Clip>({
|
||||
endpoint: '/clips/create',
|
||||
parameters: {
|
||||
...defaultCreate(),
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
|
||||
// 入力が結果として入っていること
|
||||
assert.deepStrictEqual(clip, {
|
||||
...clip,
|
||||
...defaultCreate(),
|
||||
...parameters,
|
||||
});
|
||||
return clip;
|
||||
};
|
||||
|
||||
const createMany = async (parameters: Partial<CreateParam>, count = 10, user = alice): Promise<Clip[]> => {
|
||||
return await Promise.all([...Array(count)].map((_, i) => create({
|
||||
name: `test${i}`,
|
||||
...parameters,
|
||||
}, { user })));
|
||||
};
|
||||
|
||||
type UpdateParam = JTDDataType<typeof UpdateParamDef>;
|
||||
const update = async (parameters: Partial<UpdateParam>, request: Partial<ApiRequest> = {}): Promise<Clip> => {
|
||||
const clip = await successfulApiCall<Clip>({
|
||||
endpoint: '/clips/update',
|
||||
parameters: {
|
||||
name: 'updated',
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
|
||||
// 入力が結果として入っていること。clipIdはidになるので消しておく
|
||||
delete (parameters as { clipId?: string }).clipId;
|
||||
assert.deepStrictEqual(clip, {
|
||||
...clip,
|
||||
...parameters,
|
||||
});
|
||||
return clip;
|
||||
};
|
||||
|
||||
type DeleteParam = JTDDataType<typeof DeleteParamDef>;
|
||||
const deleteClip = async (parameters: DeleteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
|
||||
return await successfulApiCall<void>({
|
||||
endpoint: '/clips/delete',
|
||||
parameters,
|
||||
user: alice,
|
||||
...request,
|
||||
}, {
|
||||
status: 204,
|
||||
});
|
||||
};
|
||||
|
||||
type ShowParam = JTDDataType<typeof ShowParamDef>;
|
||||
const show = async (parameters: ShowParam, request: Partial<ApiRequest> = {}): Promise<Clip> => {
|
||||
return await successfulApiCall<Clip>({
|
||||
endpoint: '/clips/show',
|
||||
parameters,
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
};
|
||||
|
||||
const list = async (request: Partial<ApiRequest>): Promise<Clip[]> => {
|
||||
return successfulApiCall<Clip[]>({
|
||||
endpoint: '/clips/list',
|
||||
parameters: {},
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
};
|
||||
|
||||
const usersClips = async (request: Partial<ApiRequest>): Promise<Clip[]> => {
|
||||
return await successfulApiCall<Clip[]>({
|
||||
endpoint: '/users/clips',
|
||||
parameters: {},
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
alice = await signup({ username: 'alice' });
|
||||
bob = await signup({ username: 'bob' });
|
||||
|
||||
// FIXME: misskey-jsのNoteはoutdatedなので直接変換できない
|
||||
aliceNote = await post(alice, { text: 'test' }) as any;
|
||||
aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any;
|
||||
aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any;
|
||||
aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any;
|
||||
bobNote = await post(bob, { text: 'test' }) as any;
|
||||
bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any;
|
||||
bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any;
|
||||
bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any;
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// テスト間で影響し合わないように毎回全部消す。
|
||||
for (const user of [alice, bob]) {
|
||||
const list = await api('/clips/list', { limit: 11 }, user);
|
||||
for (const clip of list.body) {
|
||||
await api('/clips/delete', { clipId: clip.id }, user);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('の作成ができる', async () => {
|
||||
const res = await create();
|
||||
// ISO 8601で日付が返ってくること
|
||||
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
|
||||
assert.strictEqual(res.lastClippedAt, null);
|
||||
assert.strictEqual(res.name, 'test');
|
||||
assert.strictEqual(res.description, null);
|
||||
assert.strictEqual(res.isPublic, false);
|
||||
assert.strictEqual(res.favoritedCount, 0);
|
||||
assert.strictEqual(res.isFavorited, false);
|
||||
});
|
||||
|
||||
test('の作成はポリシーで定められた数以上はできない。', async () => {
|
||||
// ポリシー + 1まで作れるという所がミソ
|
||||
const clipLimit = DEFAULT_POLICIES.clipLimit + 1;
|
||||
for (let i = 0; i < clipLimit; i++) {
|
||||
await create();
|
||||
}
|
||||
|
||||
await failedApiCall({
|
||||
endpoint: '/clips/create',
|
||||
parameters: defaultCreate(),
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'TOO_MANY_CLIPS',
|
||||
id: '920f7c2d-6208-4b76-8082-e632020f5883',
|
||||
});
|
||||
});
|
||||
|
||||
const createClipAllowedPattern = [
|
||||
{ label: 'nameが最大長', parameters: { name: 'x'.repeat(100) } },
|
||||
{ label: 'private', parameters: { isPublic: false } },
|
||||
{ label: 'public', parameters: { isPublic: true } },
|
||||
{ label: 'descriptionがnull', parameters: { description: null } },
|
||||
{ label: 'descriptionが最大長', parameters: { description: 'a'.repeat(2048) } },
|
||||
];
|
||||
test.each(createClipAllowedPattern)('の作成は$labelでもできる', async ({ parameters }) => await create(parameters));
|
||||
|
||||
const createClipDenyPattern = [
|
||||
{ label: 'nameがnull', parameters: { name: null } },
|
||||
{ label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } },
|
||||
{ label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } },
|
||||
{ label: 'descriptionがゼロ長', parameters: { description: '' } },
|
||||
{ label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } },
|
||||
];
|
||||
test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({
|
||||
endpoint: '/clips/create',
|
||||
parameters: {
|
||||
...defaultCreate(),
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
}));
|
||||
|
||||
test('の更新ができる', async () => {
|
||||
const res = await update({
|
||||
clipId: (await create()).id,
|
||||
name: 'updated',
|
||||
description: 'new description',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
// ISO 8601で日付が返ってくること
|
||||
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
|
||||
assert.strictEqual(res.lastClippedAt, null);
|
||||
assert.strictEqual(res.name, 'updated');
|
||||
assert.strictEqual(res.description, 'new description');
|
||||
assert.strictEqual(res.isPublic, true);
|
||||
assert.strictEqual(res.favoritedCount, 0);
|
||||
assert.strictEqual(res.isFavorited, false);
|
||||
});
|
||||
|
||||
test.each(createClipAllowedPattern)('の更新は$labelでもできる', async ({ parameters }) => await update({
|
||||
clipId: (await create()).id,
|
||||
name: 'updated',
|
||||
...parameters,
|
||||
}));
|
||||
|
||||
test.each([
|
||||
{ label: 'clipIdがnull', parameters: { clipId: null } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'b4d92d70-b216-46fa-9a3f-a8c811699257',
|
||||
} },
|
||||
{ label: '他人のクリップ', user: (): User => bob, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'b4d92d70-b216-46fa-9a3f-a8c811699257',
|
||||
} },
|
||||
...createClipDenyPattern as any,
|
||||
])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/update',
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
name: 'updated',
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assertion,
|
||||
}));
|
||||
|
||||
test('の削除ができる', async () => {
|
||||
await deleteClip({
|
||||
clipId: (await create()).id,
|
||||
});
|
||||
assert.deepStrictEqual(await list({}), []);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'clipIdがnull', parameters: { clipId: null } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '70ca08ba-6865-4630-b6fb-8494759aa754',
|
||||
} },
|
||||
{ label: '他人のクリップ', user: (): User => bob, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '70ca08ba-6865-4630-b6fb-8494759aa754',
|
||||
} },
|
||||
])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/delete',
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assertion,
|
||||
}));
|
||||
|
||||
test('のID指定取得ができる', async () => {
|
||||
const clip = await create();
|
||||
const res = await show({ clipId: clip.id });
|
||||
assert.deepStrictEqual(res, clip);
|
||||
});
|
||||
|
||||
test('のID指定取得は他人のPrivateなクリップは取得できない', async () => {
|
||||
const clip = await create({ isPublic: false }, { user: bob } );
|
||||
failedApiCall({
|
||||
endpoint: '/clips/show',
|
||||
parameters: { clipId: clip.id },
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20',
|
||||
} },
|
||||
])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({
|
||||
endpoint: '/clips/show',
|
||||
parameters: {
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assetion,
|
||||
}));
|
||||
|
||||
test('の一覧(clips/list)が取得できる(空)', async () => {
|
||||
const res = await list({});
|
||||
assert.deepStrictEqual(res, []);
|
||||
});
|
||||
|
||||
test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => {
|
||||
const clipLimit = DEFAULT_POLICIES.clipLimit + 1;
|
||||
const clips = await createMany({}, clipLimit);
|
||||
const res = await list({
|
||||
parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる
|
||||
});
|
||||
|
||||
// 返ってくる配列には順序保障がないのでidでソートして厳密比較
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
clips.sort(compareBy(s => s.id)),
|
||||
);
|
||||
});
|
||||
|
||||
test('の一覧が取得できる(空)', async () => {
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
},
|
||||
});
|
||||
assert.deepStrictEqual(res, []);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: '' },
|
||||
{ label: '他人アカウントから', user: (): User => bob },
|
||||
])('の一覧が$label取得できる', async () => {
|
||||
const clips = await createMany({ isPublic: true });
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 返ってくる配列には順序保障がないのでidでソートして厳密比較
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy<Clip>(s => s.id)),
|
||||
clips.sort(compareBy(s => s.id)));
|
||||
|
||||
// 認証状態で見たときだけisFavoritedが入っている
|
||||
for (const clip of res) {
|
||||
assert.strictEqual(clip.isFavorited, false);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: '未認証', user: (): undefined => undefined },
|
||||
{ label: '存在しないユーザーのもの', parameters: { userId: 'xxxxxxx' } },
|
||||
])('の一覧は$labelでも取得できる', async ({ parameters, user }) => {
|
||||
const clips = await createMany({ isPublic: true });
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
limit: clips.length,
|
||||
...parameters,
|
||||
},
|
||||
user: (user ?? ((): User => alice))(),
|
||||
});
|
||||
|
||||
// 未認証で見たときはisFavoritedは入らない
|
||||
for (const clip of res) {
|
||||
assert.strictEqual('isFavorited' in clip, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('の一覧はPrivateなクリップを含まない(自分のものであっても。)', async () => {
|
||||
await create({ isPublic: false });
|
||||
const aliceClip = await create({ isPublic: true });
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
limit: 2,
|
||||
},
|
||||
});
|
||||
assert.deepStrictEqual(res, [aliceClip]);
|
||||
});
|
||||
|
||||
test('の一覧はID指定で範囲選択ができる', async () => {
|
||||
const clips = await createMany({ isPublic: true }, 7);
|
||||
clips.sort(compareBy(s => s.id));
|
||||
const res = await usersClips({
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
sinceId: clips[1].id,
|
||||
untilId: clips[5].id,
|
||||
limit: 4,
|
||||
},
|
||||
});
|
||||
|
||||
// Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy<Clip>(s => s.id)),
|
||||
[clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない
|
||||
clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id));
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'userId未指定', parameters: { userId: undefined } },
|
||||
{ label: 'limitゼロ', parameters: { limit: 0 } },
|
||||
{ label: 'limit最大+1', parameters: { limit: 101 } },
|
||||
])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({
|
||||
endpoint: '/users/clips',
|
||||
parameters: {
|
||||
userId: alice.id,
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
}));
|
||||
|
||||
test.each([
|
||||
{ label: '作成', endpoint: '/clips/create' },
|
||||
{ label: '更新', endpoint: '/clips/update' },
|
||||
{ label: '削除', endpoint: '/clips/delete' },
|
||||
{ label: '取得', endpoint: '/clips/list' },
|
||||
{ label: 'お気に入り設定', endpoint: '/clips/favorite' },
|
||||
{ label: 'お気に入り解除', endpoint: '/clips/unfavorite' },
|
||||
{ label: 'お気に入り取得', endpoint: '/clips/my-favorites' },
|
||||
{ label: 'ノート追加', endpoint: '/clips/add-note' },
|
||||
{ label: 'ノート削除', endpoint: '/clips/remove-note' },
|
||||
])('の$labelは未認証ではできない', async ({ endpoint }) => await failedApiCall({
|
||||
endpoint: endpoint,
|
||||
parameters: {},
|
||||
user: undefined,
|
||||
}, {
|
||||
status: 401,
|
||||
code: 'CREDENTIAL_REQUIRED',
|
||||
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
|
||||
}));
|
||||
|
||||
describe('のお気に入り', () => {
|
||||
let aliceClip: Clip;
|
||||
|
||||
type FavoriteParam = JTDDataType<typeof FavoriteParamDef>;
|
||||
const favorite = async (parameters: FavoriteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
|
||||
return successfulApiCall<void>({
|
||||
endpoint: '/clips/favorite',
|
||||
parameters,
|
||||
user: alice,
|
||||
...request,
|
||||
}, {
|
||||
status: 204,
|
||||
});
|
||||
};
|
||||
|
||||
type UnfavoriteParam = JTDDataType<typeof UnfavoriteParamDef>;
|
||||
const unfavorite = async (parameters: UnfavoriteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
|
||||
return successfulApiCall<void>({
|
||||
endpoint: '/clips/unfavorite',
|
||||
parameters,
|
||||
user: alice,
|
||||
...request,
|
||||
}, {
|
||||
status: 204,
|
||||
});
|
||||
};
|
||||
|
||||
const myFavorites = async (request: Partial<ApiRequest> = {}): Promise<Clip[]> => {
|
||||
return successfulApiCall<Clip[]>({
|
||||
endpoint: '/clips/my-favorites',
|
||||
parameters: {},
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
aliceClip = await create();
|
||||
});
|
||||
|
||||
test('を設定できる。', async () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
const clip = await show({ clipId: aliceClip.id });
|
||||
assert.strictEqual(clip.favoritedCount, 1);
|
||||
assert.strictEqual(clip.isFavorited, true);
|
||||
});
|
||||
|
||||
test('はPublicな他人のクリップに設定できる。', async () => {
|
||||
const publicClip = await create({ isPublic: true });
|
||||
await favorite({ clipId: publicClip.id }, { user: bob });
|
||||
const clip = await show({ clipId: publicClip.id }, { user: bob });
|
||||
assert.strictEqual(clip.favoritedCount, 1);
|
||||
assert.strictEqual(clip.isFavorited, true);
|
||||
|
||||
// isFavoritedは見る人によって切り替わる。
|
||||
const clip2 = await show({ clipId: publicClip.id });
|
||||
assert.strictEqual(clip2.favoritedCount, 1);
|
||||
assert.strictEqual(clip2.isFavorited, false);
|
||||
});
|
||||
|
||||
test('は1つのクリップに対して複数人が設定できる。', async () => {
|
||||
const publicClip = await create({ isPublic: true });
|
||||
await favorite({ clipId: publicClip.id }, { user: bob });
|
||||
await favorite({ clipId: publicClip.id });
|
||||
const clip = await show({ clipId: publicClip.id }, { user: bob });
|
||||
assert.strictEqual(clip.favoritedCount, 2);
|
||||
assert.strictEqual(clip.isFavorited, true);
|
||||
|
||||
const clip2 = await show({ clipId: publicClip.id });
|
||||
assert.strictEqual(clip2.favoritedCount, 2);
|
||||
assert.strictEqual(clip2.isFavorited, true);
|
||||
});
|
||||
|
||||
test('は11を超えて設定できる。', async () => {
|
||||
const clips = [
|
||||
aliceClip,
|
||||
...await createMany({}, 10, alice),
|
||||
...await createMany({ isPublic: true }, 10, bob),
|
||||
];
|
||||
for (const clip of clips) {
|
||||
await favorite({ clipId: clip.id });
|
||||
}
|
||||
|
||||
// pagenationはない。全部一気にとれる。
|
||||
const favorited = await myFavorites();
|
||||
assert.strictEqual(favorited.length, clips.length);
|
||||
for (const clip of favorited) {
|
||||
assert.strictEqual(clip.favoritedCount, 1);
|
||||
assert.strictEqual(clip.isFavorited, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('は同じクリップに対して二回設定できない。', async () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
await failedApiCall({
|
||||
endpoint: '/clips/favorite',
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'ALREADY_FAVORITED',
|
||||
id: '92658936-c625-4273-8326-2d790129256e',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'clipIdがnull', parameters: { clipId: null } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5',
|
||||
} },
|
||||
{ label: '他人のクリップ', user: (): User => bob, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5',
|
||||
} },
|
||||
])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/favorite',
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assertion,
|
||||
}));
|
||||
|
||||
test('を設定解除できる。', async () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
await unfavorite({ clipId: aliceClip.id });
|
||||
const clip = await show({ clipId: aliceClip.id });
|
||||
assert.strictEqual(clip.favoritedCount, 0);
|
||||
assert.strictEqual(clip.isFavorited, false);
|
||||
assert.deepStrictEqual(await myFavorites(), []);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'clipIdがnull', parameters: { clipId: null } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '2603966e-b865-426c-94a7-af4a01241dc1',
|
||||
} },
|
||||
{ label: '他人のクリップ', user: (): User => bob, assertion: {
|
||||
code: 'NOT_FAVORITED',
|
||||
id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187',
|
||||
} },
|
||||
{ label: 'お気に入りしていないクリップ', assertion: {
|
||||
code: 'NOT_FAVORITED',
|
||||
id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187',
|
||||
} },
|
||||
])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/unfavorite',
|
||||
parameters: {
|
||||
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
|
||||
...parameters,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assertion,
|
||||
}));
|
||||
|
||||
test('を取得できる。', async () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
const favorited = await myFavorites();
|
||||
assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]);
|
||||
});
|
||||
|
||||
test('を取得したとき他人のお気に入りは含まない。', async () => {
|
||||
await favorite({ clipId: aliceClip.id });
|
||||
const favorited = await myFavorites({ user: bob });
|
||||
assert.deepStrictEqual(favorited, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('に紐づくノート', () => {
|
||||
let aliceClip: Clip;
|
||||
|
||||
const sampleNotes = (): Note[] => [
|
||||
aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote,
|
||||
bobNote, bobHomeNote, bobFollowersNote, bobSpecifiedNote,
|
||||
];
|
||||
|
||||
type AddNoteParam = JTDDataType<typeof AddNoteParamDef>;
|
||||
const addNote = async (parameters: AddNoteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
|
||||
return successfulApiCall<void>({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters,
|
||||
user: alice,
|
||||
...request,
|
||||
}, {
|
||||
status: 204,
|
||||
});
|
||||
};
|
||||
|
||||
type RemoveNoteParam = JTDDataType<typeof RemoveNoteParamDef>;
|
||||
const removeNote = async (parameters: RemoveNoteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
|
||||
return successfulApiCall<void>({
|
||||
endpoint: '/clips/remove-note',
|
||||
parameters,
|
||||
user: alice,
|
||||
...request,
|
||||
}, {
|
||||
status: 204,
|
||||
});
|
||||
};
|
||||
|
||||
type NotesParam = JTDDataType<typeof NotesParamDef>;
|
||||
const notes = async (parameters: Partial<NotesParam>, request: Partial<ApiRequest> = {}): Promise<Note[]> => {
|
||||
return successfulApiCall<Note[]>({
|
||||
endpoint: '/clips/notes',
|
||||
parameters,
|
||||
user: alice,
|
||||
...request,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
aliceClip = await create();
|
||||
});
|
||||
|
||||
test('を追加できる。', async () => {
|
||||
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
|
||||
const res = await show({ clipId: aliceClip.id });
|
||||
assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString());
|
||||
assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]);
|
||||
|
||||
// 他人の非公開ノートも突っ込める
|
||||
await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id });
|
||||
await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id });
|
||||
await addNote({ clipId: aliceClip.id, noteId: bobSpecifiedNote.id });
|
||||
});
|
||||
|
||||
test('として同じノートを二回紐づけることはできない', async () => {
|
||||
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
|
||||
await failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'ALREADY_CLIPPED',
|
||||
id: '734806c4-542c-463a-9311-15c512803965',
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: 17000msくらいかかる...
|
||||
test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => {
|
||||
const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1;
|
||||
const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, {
|
||||
text: `test ${i}`,
|
||||
}) as unknown)) as Note[];
|
||||
await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id })));
|
||||
|
||||
await failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
},
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'TOO_MANY_CLIP_NOTES',
|
||||
id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118',
|
||||
});
|
||||
});
|
||||
|
||||
test('は他人のクリップへ追加できない。', async () => await failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
},
|
||||
user: bob,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
|
||||
}));
|
||||
|
||||
test.each([
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: 'noteId未指定', parameters: { noteId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
|
||||
} },
|
||||
{ label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_NOTE',
|
||||
id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b',
|
||||
} },
|
||||
{ label: '他人のクリップ', user: (): object => bob, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
|
||||
} },
|
||||
])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({
|
||||
endpoint: '/clips/add-note',
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
...parameters,
|
||||
},
|
||||
user: (user ?? ((): User => alice))(),
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assetion,
|
||||
}));
|
||||
|
||||
test('を削除できる。', async () => {
|
||||
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
|
||||
await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id });
|
||||
assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: 'noteId未指定', parameters: { noteId: undefined } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる
|
||||
} },
|
||||
{ label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: {
|
||||
code: 'NO_SUCH_NOTE',
|
||||
id: 'aff017de-190e-434b-893e-33a9ff5049d8', // add-noteと異なる
|
||||
} },
|
||||
{ label: '他人のクリップ', user: (): object => bob, assetion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる
|
||||
} },
|
||||
])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({
|
||||
endpoint: '/clips/remove-note',
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
noteId: aliceNote.id,
|
||||
...parameters,
|
||||
},
|
||||
user: (user ?? ((): User => alice))(),
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assetion,
|
||||
}));
|
||||
|
||||
test('を取得できる。', async () => {
|
||||
const noteList = sampleNotes();
|
||||
for (const note of noteList) {
|
||||
await addNote({ clipId: aliceClip.id, noteId: note.id });
|
||||
}
|
||||
|
||||
const res = await notes({ clipId: aliceClip.id });
|
||||
|
||||
// 自分のノートは非公開でも入れられるし、見える
|
||||
// 他人の非公開ノートは入れられるけど、除外される
|
||||
const expects = [
|
||||
aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote,
|
||||
bobNote, bobHomeNote,
|
||||
];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
});
|
||||
|
||||
test('を始端IDとlimitで取得できる。', async () => {
|
||||
const noteList = sampleNotes();
|
||||
noteList.sort(compareBy(s => s.id));
|
||||
for (const note of noteList) {
|
||||
await addNote({ clipId: aliceClip.id, noteId: note.id });
|
||||
}
|
||||
|
||||
const res = await notes({
|
||||
clipId: aliceClip.id,
|
||||
sinceId: noteList[2].id,
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
// Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較
|
||||
const expects = [noteList[3], noteList[4], noteList[5]];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
});
|
||||
|
||||
test('をID範囲指定で取得できる。', async () => {
|
||||
const noteList = sampleNotes();
|
||||
noteList.sort(compareBy(s => s.id));
|
||||
for (const note of noteList) {
|
||||
await addNote({ clipId: aliceClip.id, noteId: note.id });
|
||||
}
|
||||
|
||||
const res = await notes({
|
||||
clipId: aliceClip.id,
|
||||
sinceId: noteList[1].id,
|
||||
untilId: noteList[4].id,
|
||||
});
|
||||
|
||||
// Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較
|
||||
const expects = [noteList[2], noteList[3]];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
});
|
||||
|
||||
test.todo('Remoteのノートもクリップできる。どうテストしよう?');
|
||||
|
||||
test('は他人のPublicなクリップからも取得できる。', async () => {
|
||||
const bobClip = await create({ isPublic: true }, { user: bob } );
|
||||
await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob });
|
||||
const res = await notes({ clipId: bobClip.id });
|
||||
assert.deepStrictEqual(res, [aliceNote]);
|
||||
});
|
||||
|
||||
test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => {
|
||||
const publicClip = await create({ isPublic: true });
|
||||
await addNote({ clipId: publicClip.id, noteId: aliceNote.id });
|
||||
await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id });
|
||||
await addNote({ clipId: publicClip.id, noteId: aliceFollowersNote.id });
|
||||
await addNote({ clipId: publicClip.id, noteId: aliceSpecifiedNote.id });
|
||||
|
||||
const res = await notes({ clipId: publicClip.id }, { user: undefined });
|
||||
const expects = [
|
||||
aliceNote, aliceHomeNote,
|
||||
// 認証なしだと非公開ノートは結果には含むけどhideされる。
|
||||
hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote),
|
||||
];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
});
|
||||
|
||||
test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.');
|
||||
|
||||
test.each([
|
||||
{ label: 'clipId未指定', parameters: { clipId: undefined } },
|
||||
{ label: 'limitゼロ', parameters: { limit: 0 } },
|
||||
{ label: 'limit最大+1', parameters: { limit: 101 } },
|
||||
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00',
|
||||
} },
|
||||
{ label: '他人のPrivateなクリップから', user: (): object => bob, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00',
|
||||
} },
|
||||
{ label: '未認証でPrivateなクリップから', user: (): undefined => undefined, assertion: {
|
||||
code: 'NO_SUCH_CLIP',
|
||||
id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00',
|
||||
} },
|
||||
])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({
|
||||
endpoint: '/clips/notes',
|
||||
parameters: {
|
||||
clipId: aliceClip.id,
|
||||
...parameters,
|
||||
},
|
||||
user: (user ?? ((): User => alice))(),
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
...assertion,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -162,14 +162,14 @@ describe('Endpoints', () => {
|
||||
const res = await api('/users/show', {
|
||||
userId: '000000000000000000000000',
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
|
||||
test('間違ったIDで怒られる', async () => {
|
||||
const res = await api('/users/show', {
|
||||
userId: 'kyoppie',
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -841,4 +841,12 @@ describe('Endpoints', () => {
|
||||
assert.strictEqual(res.body[0].id, carolPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL preview', () => {
|
||||
test('Error from summaly becomes HTTP 422', async () => {
|
||||
const res = await simpleGet('/url?url=https://e:xample.com');
|
||||
assert.strictEqual(res.status, 422);
|
||||
assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { DeleteObjectCommandOutput, DeleteObjectCommand, NoSuchKey, InvalidObjectState, S3Client } from '@aws-sdk/client-s3';
|
||||
import { mockClient } from 'aws-sdk-client-mock';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { S3Service } from '@/core/S3Service';
|
||||
import type { Meta } from '@/models';
|
||||
import type { DeleteObjectOutput } from 'aws-sdk/clients/s3';
|
||||
import type { AWSError } from 'aws-sdk/lib/error';
|
||||
import type { PromiseResult, Request } from 'aws-sdk/lib/request';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
|
||||
describe('DriveService', () => {
|
||||
let app: TestingModule;
|
||||
let driveService: DriveService;
|
||||
const s3Mock = mockClient(S3Client);
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [GlobalModule, CoreModule],
|
||||
providers: [DriveService, S3Service],
|
||||
providers: [DriveService],
|
||||
}).compile();
|
||||
app.enableShutdownHooks();
|
||||
driveService = app.get<DriveService>(DriveService);
|
||||
});
|
||||
|
||||
const s3Service = app.get<S3Service>(S3Service);
|
||||
const s3 = s3Service.getS3({} as Meta);
|
||||
beforeEach(async () => {
|
||||
s3Mock.reset();
|
||||
});
|
||||
|
||||
// new S3() surprisingly does not return an instance of class S3.
|
||||
// Let's use getPrototypeOf here to get a real prototype, since spying on S3.prototype doesn't work.
|
||||
// TODO: Use `aws-sdk-client-mock` package when upgrading to AWS SDK v3.
|
||||
jest.spyOn(Object.getPrototypeOf(s3), 'deleteObject').mockImplementation(() => {
|
||||
// Roughly mock AWS request object
|
||||
return {
|
||||
async promise(): Promise<PromiseResult<DeleteObjectOutput, AWSError>> {
|
||||
const err = new Error('mock') as AWSError;
|
||||
err.code = 'NoSuchKey';
|
||||
throw err;
|
||||
},
|
||||
} as Request<DeleteObjectOutput, AWSError>;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('Object storage', () => {
|
||||
test('delete a file', async () => {
|
||||
s3Mock.on(DeleteObjectCommand)
|
||||
.resolves({} as DeleteObjectCommandOutput);
|
||||
|
||||
await driveService.deleteObjectStorageFile('peace of the world');
|
||||
});
|
||||
|
||||
test('delete a file then unexpected error', async () => {
|
||||
s3Mock.on(DeleteObjectCommand)
|
||||
.rejects(new InvalidObjectState({ $metadata: {}, message: '' }));
|
||||
|
||||
await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error);
|
||||
});
|
||||
|
||||
test('delete a file with no valid key', async () => {
|
||||
try {
|
||||
await driveService.deleteObjectStorageFile('lol no way');
|
||||
} catch (err: any) {
|
||||
console.log(err.cause);
|
||||
throw err;
|
||||
}
|
||||
// Some S3 implementations returns 404 Not Found on deleting with a non-existent key
|
||||
s3Mock.on(DeleteObjectCommand)
|
||||
.rejects(new NoSuchKey({ $metadata: {}, message: 'allowed error.' }));
|
||||
|
||||
await driveService.deleteObjectStorageFile('lol no way');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,19 +74,19 @@ describe('ReactionService', () => {
|
||||
});
|
||||
|
||||
test('fallback - undefined', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(undefined), '👍');
|
||||
assert.strictEqual(await reactionService.toDbReaction(undefined), '❤');
|
||||
});
|
||||
|
||||
test('fallback - null', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(null), '👍');
|
||||
assert.strictEqual(await reactionService.toDbReaction(null), '❤');
|
||||
});
|
||||
|
||||
test('fallback - empty', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(''), '👍');
|
||||
assert.strictEqual(await reactionService.toDbReaction(''), '❤');
|
||||
});
|
||||
|
||||
test('fallback - unknown', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('unknown'), '👍');
|
||||
assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
77
packages/backend/test/unit/S3Service.ts
Normal file
77
packages/backend/test/unit/S3Service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { mockClient } from 'aws-sdk-client-mock';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { S3Service } from '@/core/S3Service';
|
||||
import { Meta } from '@/models';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
|
||||
describe('S3Service', () => {
|
||||
let app: TestingModule;
|
||||
let s3Service: S3Service;
|
||||
const s3Mock = mockClient(S3Client);
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [GlobalModule, CoreModule],
|
||||
providers: [S3Service],
|
||||
}).compile();
|
||||
app.enableShutdownHooks();
|
||||
s3Service = app.get<S3Service>(S3Service);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
s3Mock.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('upload', () => {
|
||||
test('upload a file', async () => {
|
||||
s3Mock.on(PutObjectCommand).resolves({});
|
||||
|
||||
await s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, {
|
||||
Bucket: 'fake',
|
||||
Key: 'fake',
|
||||
Body: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
test('upload a large file', async () => {
|
||||
s3Mock.on(CreateMultipartUploadCommand).resolves({ UploadId: '1' });
|
||||
s3Mock.on(UploadPartCommand).resolves({ ETag: '1' });
|
||||
s3Mock.on(CompleteMultipartUploadCommand).resolves({ Bucket: 'fake', Key: 'fake' });
|
||||
|
||||
await s3Service.upload({} as Meta, {
|
||||
Bucket: 'fake',
|
||||
Key: 'fake',
|
||||
Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ
|
||||
});
|
||||
});
|
||||
|
||||
test('upload a file error', async () => {
|
||||
s3Mock.on(PutObjectCommand).rejects({ name: 'Fake Error' });
|
||||
|
||||
await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, {
|
||||
Bucket: 'fake',
|
||||
Key: 'fake',
|
||||
Body: 'x',
|
||||
})).rejects.toThrowError(Error);
|
||||
});
|
||||
|
||||
test('upload a large file error', async () => {
|
||||
s3Mock.on(UploadPartCommand).rejects();
|
||||
|
||||
await expect(s3Service.upload({} as Meta, {
|
||||
Bucket: 'fake',
|
||||
Key: 'fake',
|
||||
Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ
|
||||
})).rejects.toThrowError(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as assert from 'node:assert';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { isAbsolute, basename } from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import WebSocket from 'ws';
|
||||
import fetch, { Blob, File, RequestInit } from 'node-fetch';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -22,6 +24,36 @@ export const api = async (endpoint: string, params: any, me?: any) => {
|
||||
return await request(`api/${normalized}`, params, me);
|
||||
};
|
||||
|
||||
export type ApiRequest = {
|
||||
endpoint: string,
|
||||
parameters: object,
|
||||
user: object | undefined,
|
||||
};
|
||||
|
||||
export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
|
||||
status: number,
|
||||
} = { status: 200 }): Promise<T> => {
|
||||
const { endpoint, parameters, user } = request;
|
||||
const { status } = assertion;
|
||||
const res = await api(endpoint, parameters, user);
|
||||
assert.strictEqual(res.status, status, inspect(res.body));
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const failedApiCall = async <T, >(request: ApiRequest, assertion: {
|
||||
status: number,
|
||||
code: string,
|
||||
id: string
|
||||
}): Promise<T> => {
|
||||
const { endpoint, parameters, user } = request;
|
||||
const { status, code, id } = assertion;
|
||||
const res = await api(endpoint, parameters, user);
|
||||
assert.strictEqual(res.status, status, inspect(res.body));
|
||||
assert.strictEqual(res.body.error.code, code, inspect(res.body));
|
||||
assert.strictEqual(res.body.error.id, id, inspect(res.body));
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
|
||||
const auth = me ? {
|
||||
i: me.token,
|
||||
@@ -69,6 +101,21 @@ export const post = async (user: any, params?: misskey.Endpoints['notes/create']
|
||||
return res.body ? res.body.createdNote : null;
|
||||
};
|
||||
|
||||
// 非公開ノートをAPI越しに見たときのノート NoteEntityService.ts
|
||||
export const hiddenNote = (note: any): any => {
|
||||
const temp = {
|
||||
...note,
|
||||
fileIds: [],
|
||||
files: [],
|
||||
text: null,
|
||||
cw: null,
|
||||
isHidden: true,
|
||||
};
|
||||
delete temp.visibleUserIds;
|
||||
delete temp.poll;
|
||||
return temp;
|
||||
};
|
||||
|
||||
export const react = async (user: any, note: any, reaction: string): Promise<any> => {
|
||||
await api('notes/reactions/create', {
|
||||
noteId: note.id,
|
||||
|
||||
Reference in New Issue
Block a user