1
0
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:
Kagami Sascha Rosylight
2023-03-25 07:58:26 +01:00
122 changed files with 3817 additions and 589 deletions

View 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"`);
}
}

View File

@@ -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"`);
}
}

View 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`);
}
}

View File

@@ -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"`);
}
}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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,
});
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}
});
}

View File

@@ -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);
}
});
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -37,6 +37,7 @@ export class AntennaEntityService {
notify: antenna.notify,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
isActive: antenna.isActive,
hasUnreadNote,
};
}

View File

@@ -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() {

View File

@@ -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;
}
}

View File

@@ -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}`;
}

View File

@@ -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;
}

View File

@@ -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: { },
})

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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')

View File

@@ -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) {

View File

@@ -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);
}
});
// アクティビティを処理

View File

@@ -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());

View File

@@ -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

View File

@@ -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,

View File

@@ -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],

View File

@@ -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,

View File

@@ -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 },
};
});

View File

@@ -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');
});
}
}

View File

@@ -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');
});

View File

@@ -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,

View File

@@ -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);
});
}

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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',
}),
};
}
}
}

View 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,
}));
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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'), '');
});
});
});

View 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);
});
});
});

View File

@@ -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,