Merge remote-tracking branch 'upstream/develop' into refactor-frontend-types
This commit is contained in:
22
packages/backend/migration/1704373210054-support-mcaptcha.js
Normal file
22
packages/backend/migration/1704373210054-support-mcaptcha.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class SupportMcaptcha1704373210054 {
|
||||
name = 'SupportMcaptcha1704373210054'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableMcaptcha" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "mcaptchaSitekey" character varying(1024)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "mcaptchaSecretKey" character varying(1024)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "mcaptchaInstanceUrl" character varying(1024)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mcaptchaInstanceUrl"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mcaptchaSecretKey"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mcaptchaSitekey"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableMcaptcha"`);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Global, Inject, Module } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -12,6 +11,7 @@ import { DI } from './di-symbols.js';
|
||||
import { Config, loadConfig } from './config.js';
|
||||
import { createPostgresDataSource } from './postgres.js';
|
||||
import { RepositoryModule } from './models/RepositoryModule.js';
|
||||
import { allSettled } from './misc/promise-tracker.js';
|
||||
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const $config: Provider = {
|
||||
@@ -33,7 +33,7 @@ const $meilisearch: Provider = {
|
||||
useFactory: (config: Config) => {
|
||||
if (config.meilisearch) {
|
||||
return new MeiliSearch({
|
||||
host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`,
|
||||
host: `${config.meilisearch.ssl ? 'https' : 'http'}://${config.meilisearch.host}:${config.meilisearch.port}`,
|
||||
apiKey: config.meilisearch.apiKey,
|
||||
});
|
||||
} else {
|
||||
@@ -91,17 +91,12 @@ export class GlobalModule implements OnApplicationShutdown {
|
||||
@Inject(DI.redisForPub) private redisForPub: Redis.Redis,
|
||||
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
|
||||
@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// XXX:
|
||||
// Shutting down the existing connections causes errors on Jest as
|
||||
// Misskey has asynchronous postgres/redis connections that are not
|
||||
// awaited.
|
||||
// Let's wait for some random time for them to finish.
|
||||
await setTimeout(5000);
|
||||
}
|
||||
// Wait for all potential DB queries
|
||||
await allSettled();
|
||||
// And then disconnect from DB
|
||||
await Promise.all([
|
||||
this.db.destroy(),
|
||||
this.redisClient.disconnect(),
|
||||
|
||||
@@ -87,6 +87,8 @@ export const ACHIEVEMENT_TYPES = [
|
||||
'brainDiver',
|
||||
'smashTestNotificationButton',
|
||||
'tutorialCompleted',
|
||||
'bubbleGameExplodingHead',
|
||||
'bubbleGameDoubleExplodingHead',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -73,6 +73,37 @@ export class CaptchaService {
|
||||
}
|
||||
}
|
||||
|
||||
// https://codeberg.org/Gusted/mCaptcha/src/branch/main/mcaptcha.go
|
||||
@bindThis
|
||||
public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new Error('mcaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost);
|
||||
const result = await this.httpRequestService.send(endpointUrl.toString(), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
key: siteKey,
|
||||
secret: secret,
|
||||
token: response,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status !== 200) {
|
||||
throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK');
|
||||
}
|
||||
|
||||
const resp = (await result.json()) as { valid: boolean };
|
||||
|
||||
if (!resp.valid) {
|
||||
throw new Error('mcaptcha-request-failed');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
@@ -676,7 +677,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
this.relayService.deliverToRelays(user, noteActivity);
|
||||
}
|
||||
|
||||
dm.execute();
|
||||
trackPromise(dm.execute());
|
||||
})();
|
||||
}
|
||||
//#endregion
|
||||
|
||||
@@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteReadService implements OnApplicationShutdown {
|
||||
@@ -107,7 +108,7 @@ export class NoteReadService implements OnApplicationShutdown {
|
||||
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
@@ -115,9 +116,9 @@ export class NoteReadService implements OnApplicationShutdown {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
@@ -125,7 +126,7 @@ export class NoteReadService implements OnApplicationShutdown {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { UserListService } from '@/core/UserListService.js';
|
||||
import type { FilterUnionByProperty } from '@/types.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService implements OnApplicationShutdown {
|
||||
@@ -74,7 +75,18 @@ export class NotificationService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createNotification<T extends MiNotification['type']>(
|
||||
public createNotification<T extends MiNotification['type']>(
|
||||
notifieeId: MiUser['id'],
|
||||
type: T,
|
||||
data: Omit<FilterUnionByProperty<MiNotification, 'type', T>, 'type' | 'id' | 'createdAt' | 'notifierId'>,
|
||||
notifierId?: MiUser['id'] | null,
|
||||
) {
|
||||
trackPromise(
|
||||
this.#createNotificationInternal(notifieeId, type, data, notifierId),
|
||||
);
|
||||
}
|
||||
|
||||
async #createNotificationInternal<T extends MiNotification['type']>(
|
||||
notifieeId: MiUser['id'],
|
||||
type: T,
|
||||
data: Omit<FilterUnionByProperty<MiNotification, 'type', T>, 'type' | 'id' | 'createdAt' | 'notifierId'>,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Inject, Module, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Bull from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { QUEUE, baseQueueOptions } from '@/queue/const.js';
|
||||
import { allSettled } from '@/misc/promise-tracker.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js';
|
||||
|
||||
@@ -106,14 +106,9 @@ export class QueueModule implements OnApplicationShutdown {
|
||||
) {}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// XXX:
|
||||
// Shutting down the existing connections causes errors on Jest as
|
||||
// Misskey has asynchronous postgres/redis connections that are not
|
||||
// awaited.
|
||||
// Let's wait for some random time for them to finish.
|
||||
await setTimeout(5000);
|
||||
}
|
||||
// Wait for all potential queue jobs
|
||||
await allSettled();
|
||||
// And then close all queues
|
||||
await Promise.all([
|
||||
this.systemQueue.close(),
|
||||
this.endedPollNotificationQueue.close(),
|
||||
|
||||
@@ -182,6 +182,16 @@ export class QueueService {
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createExportClipsJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportClips', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createExportFavoritesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportFavorites', {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
|
||||
const FALLBACK = '❤';
|
||||
const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
|
||||
@@ -268,7 +269,7 @@ export class ReactionService {
|
||||
}
|
||||
}
|
||||
|
||||
dm.execute();
|
||||
trackPromise(dm.execute());
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
@@ -316,7 +317,7 @@ export class ReactionService {
|
||||
dm.addDirectRecipe(reactee as MiRemoteUser);
|
||||
}
|
||||
dm.addFollowersRecipe();
|
||||
dm.execute();
|
||||
trackPromise(dm.execute());
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class DeliverManager {
|
||||
}
|
||||
|
||||
// deliver
|
||||
this.queueService.deliverMany(this.actor, this.activity, inboxes);
|
||||
await this.queueService.deliverMany(this.actor, this.activity, inboxes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export class ServerStatsService implements OnApplicationShutdown {
|
||||
const log = [] as any[];
|
||||
|
||||
ev.on('requestServerStatsLog', x => {
|
||||
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length ?? 50));
|
||||
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length));
|
||||
});
|
||||
|
||||
const tick = async () => {
|
||||
|
||||
23
packages/backend/src/misc/promise-tracker.ts
Normal file
23
packages/backend/src/misc/promise-tracker.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const promiseRefs: Set<WeakRef<Promise<unknown>>> = new Set();
|
||||
|
||||
/**
|
||||
* This tracks promises that other modules decided not to wait for,
|
||||
* and makes sure they are all settled before fully closing down the server.
|
||||
*/
|
||||
export function trackPromise(promise: Promise<unknown>) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
return;
|
||||
}
|
||||
const ref = new WeakRef(promise);
|
||||
promiseRefs.add(ref);
|
||||
promise.finally(() => promiseRefs.delete(ref));
|
||||
}
|
||||
|
||||
export async function allSettled(): Promise<void> {
|
||||
await Promise.allSettled([...promiseRefs].map(r => r.deref()));
|
||||
}
|
||||
@@ -191,6 +191,29 @@ export class MiMeta {
|
||||
})
|
||||
public hcaptchaSecretKey: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableMcaptcha: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public mcaptchaSitekey: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public mcaptchaSecretKey: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public mcaptchaInstanceUrl: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
@@ -467,7 +490,7 @@ export class MiMeta {
|
||||
nullable: true,
|
||||
})
|
||||
public truemailInstance: string | null;
|
||||
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmo
|
||||
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
|
||||
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
|
||||
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
|
||||
import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js';
|
||||
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
|
||||
import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
|
||||
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
|
||||
@@ -53,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
||||
DeleteDriveFilesProcessorService,
|
||||
ExportCustomEmojisProcessorService,
|
||||
ExportNotesProcessorService,
|
||||
ExportClipsProcessorService,
|
||||
ExportFavoritesProcessorService,
|
||||
ExportFollowingProcessorService,
|
||||
ExportMutingProcessorService,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js';
|
||||
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
|
||||
import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js';
|
||||
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
|
||||
import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js';
|
||||
import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js';
|
||||
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
|
||||
import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js';
|
||||
@@ -91,6 +92,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
|
||||
private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService,
|
||||
private exportNotesProcessorService: ExportNotesProcessorService,
|
||||
private exportClipsProcessorService: ExportClipsProcessorService,
|
||||
private exportFavoritesProcessorService: ExportFavoritesProcessorService,
|
||||
private exportFollowingProcessorService: ExportFollowingProcessorService,
|
||||
private exportMutingProcessorService: ExportMutingProcessorService,
|
||||
@@ -164,6 +166,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job);
|
||||
case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job);
|
||||
case 'exportNotes': return this.exportNotesProcessorService.process(job);
|
||||
case 'exportClips': return this.exportClipsProcessorService.process(job);
|
||||
case 'exportFavorites': return this.exportFavoritesProcessorService.process(job);
|
||||
case 'exportFollowing': return this.exportFollowingProcessorService.process(job);
|
||||
case 'exportMuting': return this.exportMutingProcessorService.process(job);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { Writable } from 'node:stream';
|
||||
import { Inject, Injectable, StreamableFile } from '@nestjs/common';
|
||||
import { MoreThan } from 'typeorm';
|
||||
import { format as dateFormat } from 'date-fns';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import type { MiPoll } from '@/models/Poll.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ExportClipsProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.clipsRepository)
|
||||
private clipsRepository: ClipsRepository,
|
||||
|
||||
@Inject(DI.clipNotesRepository)
|
||||
private clipNotesRepository: ClipNotesRepository,
|
||||
|
||||
private driveService: DriveService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('export-clips');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||
this.logger.info(`Exporting clips of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
this.logger.info(`Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
const stream = Writable.toWeb(fs.createWriteStream(path, { flags: 'a' }));
|
||||
const writer = stream.getWriter();
|
||||
writer.closed.catch(this.logger.error);
|
||||
|
||||
await writer.write('[');
|
||||
|
||||
await this.processClips(writer, user, job);
|
||||
|
||||
await writer.write(']');
|
||||
await writer.close();
|
||||
|
||||
this.logger.succ(`Exported to: ${path}`);
|
||||
|
||||
const fileName = 'clips-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
|
||||
const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
|
||||
|
||||
this.logger.succ(`Exported to: ${driveFile.id}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async processClips(writer: WritableStreamDefaultWriter, user: MiUser, job: Bull.Job<DbJobDataWithUser>) {
|
||||
let exportedClipsCount = 0;
|
||||
let cursor: MiClip['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const clips = await this.clipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (clips.length === 0) {
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = clips.at(-1)?.id ?? null;
|
||||
|
||||
for (const clip of clips) {
|
||||
// Stringify but remove the last `]}`
|
||||
const content = JSON.stringify(this.serializeClip(clip)).slice(0, -2);
|
||||
const isFirst = exportedClipsCount === 0;
|
||||
await writer.write(isFirst ? content : ',\n' + content);
|
||||
|
||||
await this.processClipNotes(writer, clip.id);
|
||||
|
||||
await writer.write(']}');
|
||||
exportedClipsCount++;
|
||||
}
|
||||
|
||||
const total = await this.clipsRepository.countBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
job.updateProgress(exportedClipsCount / total);
|
||||
}
|
||||
}
|
||||
|
||||
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise<void> {
|
||||
let exportedClipNotesCount = 0;
|
||||
let cursor: MiClipNote['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const clipNotes = await this.clipNotesRepository.find({
|
||||
where: {
|
||||
clipId,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
relations: ['note', 'note.user'],
|
||||
}) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
|
||||
|
||||
if (clipNotes.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = clipNotes.at(-1)?.id ?? null;
|
||||
|
||||
for (const clipNote of clipNotes) {
|
||||
let poll: MiPoll | undefined;
|
||||
if (clipNote.note.hasPoll) {
|
||||
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });
|
||||
}
|
||||
const content = JSON.stringify(this.serializeClipNote(clipNote, poll));
|
||||
const isFirst = exportedClipNotesCount === 0;
|
||||
await writer.write(isFirst ? content : ',\n' + content);
|
||||
|
||||
exportedClipNotesCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private serializeClip(clip: MiClip): Record<string, unknown> {
|
||||
return {
|
||||
id: clip.id,
|
||||
name: clip.name,
|
||||
description: clip.description,
|
||||
lastClippedAt: clip.lastClippedAt?.toISOString(),
|
||||
clipNotes: [],
|
||||
};
|
||||
}
|
||||
|
||||
private serializeClipNote(clip: MiClipNote & { note: MiNote & { user: MiUser } }, poll: MiPoll | undefined): Record<string, unknown> {
|
||||
return {
|
||||
id: clip.id,
|
||||
createdAt: this.idService.parse(clip.id).date.toISOString(),
|
||||
note: {
|
||||
id: clip.note.id,
|
||||
text: clip.note.text,
|
||||
createdAt: this.idService.parse(clip.note.id).date.toISOString(),
|
||||
fileIds: clip.note.fileIds,
|
||||
replyId: clip.note.replyId,
|
||||
renoteId: clip.note.renoteId,
|
||||
poll: poll,
|
||||
cw: clip.note.cw,
|
||||
visibility: clip.note.visibility,
|
||||
visibleUserIds: clip.note.visibleUserIds,
|
||||
localOnly: clip.note.localOnly,
|
||||
reactionAcceptance: clip.note.reactionAcceptance,
|
||||
uri: clip.note.uri,
|
||||
url: clip.note.url,
|
||||
user: {
|
||||
id: clip.note.user.id,
|
||||
name: clip.note.user.name,
|
||||
username: clip.note.user.username,
|
||||
host: clip.note.user.host,
|
||||
uri: clip.note.user.uri,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
|
||||
import * as ep___i_exportFollowing from './endpoints/i/export-following.js';
|
||||
import * as ep___i_exportMute from './endpoints/i/export-mute.js';
|
||||
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
|
||||
import * as ep___i_exportClips from './endpoints/i/export-clips.js';
|
||||
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
|
||||
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
|
||||
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
|
||||
@@ -569,6 +570,7 @@ const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass:
|
||||
const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default };
|
||||
const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default };
|
||||
const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default };
|
||||
const $i_exportClips: Provider = { provide: 'ep:i/export-clips', useClass: ep___i_exportClips.default };
|
||||
const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default };
|
||||
const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default };
|
||||
const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default };
|
||||
@@ -934,6 +936,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$i_exportFollowing,
|
||||
$i_exportMute,
|
||||
$i_exportNotes,
|
||||
$i_exportClips,
|
||||
$i_exportFavorites,
|
||||
$i_exportUserLists,
|
||||
$i_exportAntennas,
|
||||
@@ -1293,6 +1296,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$i_exportFollowing,
|
||||
$i_exportMute,
|
||||
$i_exportNotes,
|
||||
$i_exportClips,
|
||||
$i_exportFavorites,
|
||||
$i_exportUserLists,
|
||||
$i_exportAntennas,
|
||||
|
||||
@@ -65,6 +65,7 @@ export class SignupApiService {
|
||||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
}
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
@@ -82,6 +83,12 @@ export class SignupApiService {
|
||||
});
|
||||
}
|
||||
|
||||
if (instance.enableMcaptcha && instance.mcaptchaSecretKey && instance.mcaptchaSitekey && instance.mcaptchaInstanceUrl) {
|
||||
await this.captchaService.verifyMcaptcha(instance.mcaptchaSecretKey, instance.mcaptchaSitekey, instance.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (instance.enableRecaptcha && instance.recaptchaSecretKey) {
|
||||
await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Schema } from '@/misc/json-schema.js';
|
||||
import { permissions } from 'misskey-js';
|
||||
import type { Schema } from '@/misc/json-schema.js';
|
||||
import { RolePolicies } from '@/core/RoleService.js';
|
||||
|
||||
import * as ep___admin_meta from './endpoints/admin/meta.js';
|
||||
@@ -209,6 +209,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
|
||||
import * as ep___i_exportFollowing from './endpoints/i/export-following.js';
|
||||
import * as ep___i_exportMute from './endpoints/i/export-mute.js';
|
||||
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
|
||||
import * as ep___i_exportClips from './endpoints/i/export-clips.js';
|
||||
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
|
||||
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
|
||||
import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
|
||||
@@ -568,6 +569,7 @@ const eps = [
|
||||
['i/export-following', ep___i_exportFollowing],
|
||||
['i/export-mute', ep___i_exportMute],
|
||||
['i/export-notes', ep___i_exportNotes],
|
||||
['i/export-clips', ep___i_exportClips],
|
||||
['i/export-favorites', ep___i_exportFavorites],
|
||||
['i/export-user-lists', ep___i_exportUserLists],
|
||||
['i/export-antennas', ep___i_exportAntennas],
|
||||
|
||||
@@ -41,6 +41,18 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableMcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mcaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mcaptchaInstanceUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableRecaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -163,6 +175,10 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mcaptchaSecretKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
recaptchaSecretKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
@@ -468,6 +484,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
mcaptchaSiteKey: instance.mcaptchaSitekey,
|
||||
mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl,
|
||||
enableRecaptcha: instance.enableRecaptcha,
|
||||
recaptchaSiteKey: instance.recaptchaSiteKey,
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
@@ -498,6 +517,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
sensitiveWords: instance.sensitiveWords,
|
||||
preservedUsernames: instance.preservedUsernames,
|
||||
hcaptchaSecretKey: instance.hcaptchaSecretKey,
|
||||
mcaptchaSecretKey: instance.mcaptchaSecretKey,
|
||||
recaptchaSecretKey: instance.recaptchaSecretKey,
|
||||
turnstileSecretKey: instance.turnstileSecretKey,
|
||||
sensitiveMediaDetection: instance.sensitiveMediaDetection,
|
||||
|
||||
@@ -63,6 +63,10 @@ export const paramDef = {
|
||||
enableHcaptcha: { type: 'boolean' },
|
||||
hcaptchaSiteKey: { type: 'string', nullable: true },
|
||||
hcaptchaSecretKey: { type: 'string', nullable: true },
|
||||
enableMcaptcha: { type: 'boolean' },
|
||||
mcaptchaSiteKey: { type: 'string', nullable: true },
|
||||
mcaptchaInstanceUrl: { type: 'string', nullable: true },
|
||||
mcaptchaSecretKey: { type: 'string', nullable: true },
|
||||
enableRecaptcha: { type: 'boolean' },
|
||||
recaptchaSiteKey: { type: 'string', nullable: true },
|
||||
recaptchaSecretKey: { type: 'string', nullable: true },
|
||||
@@ -269,6 +273,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
set.hcaptchaSecretKey = ps.hcaptchaSecretKey;
|
||||
}
|
||||
|
||||
if (ps.enableMcaptcha !== undefined) {
|
||||
set.enableMcaptcha = ps.enableMcaptcha;
|
||||
}
|
||||
|
||||
if (ps.mcaptchaSiteKey !== undefined) {
|
||||
set.mcaptchaSitekey = ps.mcaptchaSiteKey;
|
||||
}
|
||||
|
||||
if (ps.mcaptchaInstanceUrl !== undefined) {
|
||||
set.mcaptchaInstanceUrl = ps.mcaptchaInstanceUrl;
|
||||
}
|
||||
|
||||
if (ps.mcaptchaSecretKey !== undefined) {
|
||||
set.mcaptchaSecretKey = ps.mcaptchaSecretKey;
|
||||
}
|
||||
|
||||
if (ps.enableRecaptcha !== undefined) {
|
||||
set.enableRecaptcha = ps.enableRecaptcha;
|
||||
}
|
||||
@@ -472,7 +492,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
set.verifymailAuthKey = ps.verifymailAuthKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (ps.enableTruemailApi !== undefined) {
|
||||
set.enableTruemailApi = ps.enableTruemailApi;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -92,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
antenna.isActive = true;
|
||||
antenna.lastUsedAt = new Date();
|
||||
this.antennasRepository.update(antenna.id, antenna);
|
||||
trackPromise(this.antennasRepository.update(antenna.id, antenna));
|
||||
|
||||
if (needPublishEvent) {
|
||||
this.globalEventService.publishInternalEvent('antennaUpdated', antenna);
|
||||
|
||||
35
packages/backend/src/server/api/endpoints/i/export-clips.ts
Normal file
35
packages/backend/src/server/api/endpoints/i/export-clips.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
limit: {
|
||||
duration: ms('1day'),
|
||||
max: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
this.queueService.createExportClipsJob(me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,18 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableMcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mcaptchaSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mcaptchaInstanceUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableRecaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -351,6 +363,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
mcaptchaSiteKey: instance.mcaptchaSitekey,
|
||||
mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl,
|
||||
enableRecaptcha: instance.enableRecaptcha,
|
||||
recaptchaSiteKey: instance.recaptchaSiteKey,
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
|
||||
@@ -21,6 +21,7 @@ class UserListChannel extends Channel {
|
||||
private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
|
||||
private listUsersClock: NodeJS.Timeout;
|
||||
private withFiles: boolean;
|
||||
private withRenotes: boolean;
|
||||
|
||||
constructor(
|
||||
private userListsRepository: UserListsRepository,
|
||||
@@ -39,6 +40,7 @@ class UserListChannel extends Channel {
|
||||
public async init(params: any) {
|
||||
this.listId = params.listId as string;
|
||||
this.withFiles = params.withFiles ?? false;
|
||||
this.withRenotes = params.withRenotes ?? true;
|
||||
|
||||
// Check existence and owner
|
||||
const listExist = await this.userListsRepository.exist({
|
||||
@@ -104,6 +106,8 @@ class UserListChannel extends Channel {
|
||||
}
|
||||
}
|
||||
|
||||
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
||||
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
||||
|
||||
194
packages/backend/test/e2e/exports.ts
Normal file
194
packages/backend/test/e2e/exports.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { signup, api, startServer, startJobQueue, port, post } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('export-clips', () => {
|
||||
let app: INestApplicationContext;
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
let bob: misskey.entities.SignupResponse;
|
||||
|
||||
// XXX: Any better way to get the result?
|
||||
async function pollFirstDriveFile() {
|
||||
while (true) {
|
||||
const files = (await api('/drive/files', {}, alice)).body;
|
||||
if (!files.length) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
continue;
|
||||
}
|
||||
if (files.length > 1) {
|
||||
throw new Error('Too many files?');
|
||||
}
|
||||
const file = (await api('/drive/files/show', { fileId: files[0].id }, alice)).body;
|
||||
const res = await fetch(new URL(new URL(file.url).pathname, `http://127.0.0.1:${port}`));
|
||||
return await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
await startJobQueue();
|
||||
alice = await signup({ username: 'alice' });
|
||||
bob = await signup({ username: 'bob' });
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean all clips and files of alice
|
||||
const clips = (await api('/clips/list', {}, alice)).body;
|
||||
for (const clip of clips) {
|
||||
const res = await api('/clips/delete', { clipId: clip.id }, alice);
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to delete clip');
|
||||
}
|
||||
}
|
||||
const files = (await api('/drive/files', {}, alice)).body;
|
||||
for (const file of files) {
|
||||
const res = await api('/drive/files/delete', { fileId: file.id }, alice);
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to delete file');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('basic export', async () => {
|
||||
let res = await api('/clips/create', {
|
||||
name: 'foo',
|
||||
description: 'bar',
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
res = await api('/i/export-clips', {}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
|
||||
const exported = await pollFirstDriveFile();
|
||||
assert.strictEqual(exported[0].name, 'foo');
|
||||
assert.strictEqual(exported[0].description, 'bar');
|
||||
assert.strictEqual(exported[0].clipNotes.length, 0);
|
||||
});
|
||||
|
||||
test('export with notes', async () => {
|
||||
let res = await api('/clips/create', {
|
||||
name: 'foo',
|
||||
description: 'bar',
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const clip = res.body;
|
||||
|
||||
const note1 = await post(alice, {
|
||||
text: 'baz1',
|
||||
});
|
||||
|
||||
const note2 = await post(alice, {
|
||||
text: 'baz2',
|
||||
poll: {
|
||||
choices: ['sakura', 'izumi', 'ako'],
|
||||
},
|
||||
});
|
||||
|
||||
for (const note of [note1, note2]) {
|
||||
res = await api('/clips/add-note', {
|
||||
clipId: clip.id,
|
||||
noteId: note.id,
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
}
|
||||
|
||||
res = await api('/i/export-clips', {}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
|
||||
const exported = await pollFirstDriveFile();
|
||||
assert.strictEqual(exported[0].name, 'foo');
|
||||
assert.strictEqual(exported[0].description, 'bar');
|
||||
assert.strictEqual(exported[0].clipNotes.length, 2);
|
||||
assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1');
|
||||
assert.strictEqual(exported[0].clipNotes[1].note.text, 'baz2');
|
||||
assert.deepStrictEqual(exported[0].clipNotes[1].note.poll.choices[0], 'sakura');
|
||||
});
|
||||
|
||||
test('multiple clips', async () => {
|
||||
let res = await api('/clips/create', {
|
||||
name: 'kawaii',
|
||||
description: 'kawaii',
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const clip1 = res.body;
|
||||
|
||||
res = await api('/clips/create', {
|
||||
name: 'yuri',
|
||||
description: 'yuri',
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const clip2 = res.body;
|
||||
|
||||
const note1 = await post(alice, {
|
||||
text: 'baz1',
|
||||
});
|
||||
|
||||
const note2 = await post(alice, {
|
||||
text: 'baz2',
|
||||
});
|
||||
|
||||
res = await api('/clips/add-note', {
|
||||
clipId: clip1.id,
|
||||
noteId: note1.id,
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
|
||||
res = await api('/clips/add-note', {
|
||||
clipId: clip2.id,
|
||||
noteId: note2.id,
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
|
||||
res = await api('/i/export-clips', {}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
|
||||
const exported = await pollFirstDriveFile();
|
||||
assert.strictEqual(exported[0].name, 'kawaii');
|
||||
assert.strictEqual(exported[0].clipNotes.length, 1);
|
||||
assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1');
|
||||
assert.strictEqual(exported[1].name, 'yuri');
|
||||
assert.strictEqual(exported[1].clipNotes.length, 1);
|
||||
assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2');
|
||||
});
|
||||
|
||||
test('Clipping other user\'s note', async () => {
|
||||
let res = await api('/clips/create', {
|
||||
name: 'kawaii',
|
||||
description: 'kawaii',
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const clip = res.body;
|
||||
|
||||
const note = await post(bob, {
|
||||
text: 'baz',
|
||||
visibility: 'followers',
|
||||
});
|
||||
|
||||
res = await api('/clips/add-note', {
|
||||
clipId: clip.id,
|
||||
noteId: note.id,
|
||||
}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
|
||||
res = await api('/i/export-clips', {}, alice);
|
||||
assert.strictEqual(res.status, 204);
|
||||
|
||||
const exported = await pollFirstDriveFile();
|
||||
assert.strictEqual(exported[0].name, 'kawaii');
|
||||
assert.strictEqual(exported[0].clipNotes.length, 1);
|
||||
assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz');
|
||||
assert.strictEqual(exported[0].clipNotes[0].note.user.username, 'bob');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import { entities } from '../src/postgres.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
export { server as startServer } from '@/boot/common.js';
|
||||
export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
|
||||
|
||||
interface UserToken {
|
||||
token: string;
|
||||
|
||||
Reference in New Issue
Block a user