From 4838494aa78d38dc6dcaf26a4f47d161441adadb Mon Sep 17 00:00:00 2001 From: kozakura913 <98575220+kozakura913@users.noreply.github.com> Date: Sat, 14 Sep 2024 14:25:08 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=AA=E3=83=A2=E3=83=BC=E3=83=88=E3=82=AF?= =?UTF-8?q?=E3=83=AA=E3=83=83=E3=83=97=E3=81=AE=E3=81=8A=E6=B0=97=E3=81=AB?= =?UTF-8?q?=E5=85=A5=E3=82=8A=E7=99=BB=E9=8C=B2=20(#438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1726276463152-ClipFavoriteRemote.js | 22 ++++ packages/backend/src/core/ClipService.ts | 84 +++++++++++++ packages/backend/src/di-symbols.ts | 1 + .../backend/src/models/ClipFavoriteRemote.ts | 35 ++++++ .../backend/src/models/RepositoryModule.ts | 9 ++ packages/backend/src/models/_.ts | 3 + packages/backend/src/postgres.ts | 2 + .../server/api/endpoints/clips/favorite.ts | 39 ++++++- .../api/endpoints/clips/my-favorites.ts | 33 ++++-- .../src/server/api/endpoints/clips/show.ts | 110 ++++-------------- .../server/api/endpoints/clips/unfavorite.ts | 22 +++- .../cherrypick-js/etc/cherrypick-js.api.md | 4 + .../cherrypick-js/src/autogen/endpoint.ts | 3 +- .../cherrypick-js/src/autogen/entities.ts | 1 + packages/cherrypick-js/src/autogen/types.ts | 11 +- 15 files changed, 277 insertions(+), 102 deletions(-) create mode 100644 packages/backend/migration/1726276463152-ClipFavoriteRemote.js create mode 100644 packages/backend/src/models/ClipFavoriteRemote.ts diff --git a/packages/backend/migration/1726276463152-ClipFavoriteRemote.js b/packages/backend/migration/1726276463152-ClipFavoriteRemote.js new file mode 100644 index 0000000000..e7ce57e658 --- /dev/null +++ b/packages/backend/migration/1726276463152-ClipFavoriteRemote.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project, yojo-art team + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class clipFavoriteRemote1726276463152 { + name = 'clipFavoriteRemote1726276463152' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "clip_favorite_remote" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "clipId" character varying(32) NOT NULL, "host" character varying(128) NOT NULL, CONSTRAINT "PK_5cfc42c4522f5253fd759947ec" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_99c7abefa295355f5725ce959f" ON "clip_favorite_remote" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_7ca9b4f7544e2b2fdf959bc9f4" ON "clip_favorite_remote" ("userId", "clipId","host") `); + await queryRunner.query(`ALTER TABLE "clip_favorite_remote" ADD CONSTRAINT "FK_99c7abefa295355f5725ce959f1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "clip_favorite_remote" DROP CONSTRAINT "FK_99c7abefa295355f5725ce959f1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7ca9b4f7544e2b2fdf959bc9f4"`); + await queryRunner.query(`DROP INDEX "public"."IDX_99c7abefa295355f5725ce959f"`); + await queryRunner.query(`DROP TABLE "clip_favorite_remote"`); + } +} diff --git a/packages/backend/src/core/ClipService.ts b/packages/backend/src/core/ClipService.ts index 929a9db064..b2ef5f080a 100644 --- a/packages/backend/src/core/ClipService.ts +++ b/packages/backend/src/core/ClipService.ts @@ -5,6 +5,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { QueryFailedError } from 'typeorm'; +import got, * as Got from 'got'; +import * as Redis from 'ioredis'; +import type { Config } from '@/config.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DI } from '@/di-symbols.js'; import type { ClipsRepository, MiNote, MiClip, ClipNotesRepository, NotesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; @@ -12,6 +19,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; import type { MiLocalUser } from '@/models/User.js'; +import { Packed } from '@/misc/json-schema.js'; @Injectable() export class ClipService { @@ -20,8 +28,13 @@ export class ClipService { public static AlreadyAddedError = class extends Error {}; public static TooManyClipNotesError = class extends Error {}; public static TooManyClipsError = class extends Error {}; + public static FailedToResolveRemoteUserError = class extends Error {}; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.redisForRemoteApis) + private redisForRemoteApis: Redis.Redis, @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, @@ -31,6 +44,9 @@ export class ClipService { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + private httpRequestService: HttpRequestService, + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, private roleService: RoleService, private idService: IdService, ) { @@ -155,4 +171,72 @@ export class ClipService { this.notesRepository.decrement({ id: noteId }, 'clippedCount', 1); } + @bindThis + public async showRemote( + clipId:string, + host:string, + ) : Promise> { + const cache_key = 'clip:show:' + clipId + '@' + host; + const cache_value = await this.redisForRemoteApis.get(cache_key); + let remote_json = null; + if (cache_value === null) { + const timeout = 30 * 1000; + const operationTimeout = 60 * 1000; + const url = 'https://' + host + '/api/clips/show'; + const res = got.post(url, { + headers: { + 'User-Agent': this.config.userAgent, + 'Content-Type': 'application/json; charset=utf-8', + }, + timeout: { + lookup: timeout, + connect: timeout, + secureConnect: timeout, + socket: timeout, // read timeout + response: timeout, + send: timeout, + request: operationTimeout, // whole operation timeout + }, + agent: { + http: this.httpRequestService.httpAgent, + https: this.httpRequestService.httpsAgent, + }, + http2: true, + retry: { + limit: 1, + }, + enableUnixSockets: false, + body: JSON.stringify({ + clipId, + }), + }); + remote_json = await res.text(); + const redisPipeline = this.redisForRemoteApis.pipeline(); + redisPipeline.set(cache_key, remote_json); + redisPipeline.expire(cache_key, 10 * 60); + await redisPipeline.exec(); + } else { + remote_json = cache_value; + } + const remote_clip = JSON.parse(remote_json); + if (remote_clip.user == null || remote_clip.user.username == null) { + throw new ClipService.FailedToResolveRemoteUserError(); + } + const user = await this.remoteUserResolveService.resolveUser(remote_clip.user.username, host).catch(err => { + throw new ClipService.FailedToResolveRemoteUserError(); + }); + return await awaitAll({ + id: clipId + '@' + host, + createdAt: remote_clip.createdAt ? remote_clip.createdAt : null, + lastClippedAt: remote_clip.lastClippedAt ? remote_clip.lastClippedAt : null, + userId: user.id, + user: this.userEntityService.pack(user), + name: remote_clip.name, + description: remote_clip.description, + isPublic: true, + favoritedCount: remote_clip.favoritedCount, + isFavorited: false, + notesCount: remote_clip.notesCount, + }); + } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index e9427d8e34..e10adfec0a 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -72,6 +72,7 @@ export const DI = { clipsRepository: Symbol('clipsRepository'), clipNotesRepository: Symbol('clipNotesRepository'), clipFavoritesRepository: Symbol('clipFavoritesRepository'), + clipFavoritesRemoteRepository: Symbol('clipFavoritesRemoteRepository'), antennasRepository: Symbol('antennasRepository'), promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), diff --git a/packages/backend/src/models/ClipFavoriteRemote.ts b/packages/backend/src/models/ClipFavoriteRemote.ts new file mode 100644 index 0000000000..ead069d9c4 --- /dev/null +++ b/packages/backend/src/models/ClipFavoriteRemote.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project, yojo-art team + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('clip_favorite_remote') +@Index(['userId', 'clipId', 'host'], { unique: true }) +export class MiClipFavoriteRemote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('varchar', { + length: 32, + }) + public clipId: string; + + @Column('varchar', { + length: 128, + }) + public host: string; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index a24a3c91f8..b0155f78d2 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -24,6 +24,7 @@ import { MiChannelFollowing, MiClip, MiClipFavorite, + MiClipFavoriteRemote, MiClipNote, MiDriveFile, MiDriveFolder, @@ -412,6 +413,12 @@ const $clipFavoritesRepository: Provider = { inject: [DI.db], }; +const $clipFavoritesRemoteRepository: Provider = { + provide: DI.clipFavoritesRemoteRepository, + useFactory: (db: DataSource) => db.getRepository(MiClipFavoriteRemote).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $antennasRepository: Provider = { provide: DI.antennasRepository, useFactory: (db: DataSource) => db.getRepository(MiAntenna).extend(miRepository as MiRepository), @@ -601,6 +608,7 @@ const $officialTagRepository: Provider = { $clipsRepository, $clipNotesRepository, $clipFavoritesRepository, + $clipFavoritesRemoteRepository, $antennasRepository, $promoNotesRepository, $promoReadsRepository, @@ -679,6 +687,7 @@ const $officialTagRepository: Provider = { $clipsRepository, $clipNotesRepository, $clipFavoritesRepository, + $clipFavoritesRemoteRepository, $antennasRepository, $promoNotesRepository, $promoReadsRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index f44fd5f4ec..ca074e7004 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -27,6 +27,7 @@ import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; import { MiClip } from '@/models/Clip.js'; import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; +import { MiClipFavoriteRemote } from '@/models/ClipFavoriteRemote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; @@ -149,6 +150,7 @@ export { MiClip, MiClipNote, MiClipFavorite, + MiClipFavoriteRemote, MiDriveFile, MiDriveFolder, MiEmoji, @@ -227,6 +229,7 @@ export type ChannelFavoritesRepository = Repository & MiRepos export type ClipsRepository = Repository & MiRepository; export type ClipNotesRepository = Repository & MiRepository; export type ClipFavoritesRepository = Repository & MiRepository; +export type ClipFavoritesRemoteRepository = Repository & MiRepository; export type DriveFilesRepository = Repository & MiRepository; export type DriveFoldersRepository = Repository & MiRepository; export type EmojisRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 22c637be26..c57c3827f2 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -26,6 +26,7 @@ import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; import { MiClip } from '@/models/Clip.js'; import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; +import { MiClipFavoriteRemote } from '@/models/ClipFavoriteRemote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; @@ -190,6 +191,7 @@ export const entities = [ MiClip, MiClipNote, MiClipFavorite, + MiClipFavoriteRemote, MiAntenna, MiPromoNote, MiPromoRead, diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts index a73f0f892d..9a60223875 100644 --- a/packages/backend/src/server/api/endpoints/clips/favorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -4,10 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { ClipsRepository, ClipFavoritesRepository } from '@/models/_.js'; +import type { ClipsRepository, ClipFavoritesRepository, ClipFavoritesRemoteRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { ClipService } from '@/core/ClipService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -36,6 +37,11 @@ export const meta = { code: 'UNIMPLEMENTED', id: '37561aed-4ba4-4a53-9efe-a0aa255e9bb3', }, + failedToResolveRemoteUser: { + message: 'failedToResolveRemoteUser.', + code: 'FAILED_TO_RESOLVE_REMOTE_USER', + id: '56d5e552-d55a-47e3-9f37-6dc85a93ecf9', + }, }, } as const; @@ -55,13 +61,42 @@ export default class extends Endpoint { // eslint- @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, + @Inject(DI.clipFavoritesRemoteRepository) + private clipFavoritesRemoteRepository: ClipFavoritesRemoteRepository, + private clipService: ClipService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - if (ps.clipId.split('@').length > 1) { + const clipIdArray = ps.clipId.split('@'); + if (clipIdArray.length > 2) { throw new ApiError(meta.errors.unimplemented); } + const host = clipIdArray.length > 1 ? clipIdArray[1] : null; + if (host) { + const clipId = clipIdArray[0]; + await clipService.showRemote(clipId, host); + + const exist = await this.clipFavoritesRemoteRepository.exists({ + where: { + clipId: clipId, + host: host, + userId: me.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.clipFavoritesRemoteRepository.insert({ + id: this.idService.gen(), + clipId: clipId, + host: host, + userId: me.id, + }); + return; + } const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); if (clip == null) { throw new ApiError(meta.errors.noSuchClip); diff --git a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts index 44719592d1..2814954c52 100644 --- a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts +++ b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts @@ -5,9 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ClipFavoritesRepository } from '@/models/_.js'; +import type { ClipFavoritesRemoteRepository, ClipFavoritesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; +import { ClipService } from '@/core/ClipService.js'; +import { Packed } from '@/misc/json-schema.js'; export const meta = { tags: ['account', 'clip'], @@ -30,6 +32,8 @@ export const meta = { export const paramDef = { type: 'object', properties: { + withLocal: { type: 'boolean', default: true }, + withRemote: { type: 'boolean', default: true }, }, required: [], } as const; @@ -39,18 +43,33 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, + @Inject(DI.clipFavoritesRemoteRepository) + private clipFavoritesRemoteRepository: ClipFavoritesRemoteRepository, + private clipService: ClipService, private clipEntityService: ClipEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.clipFavoritesRepository.createQueryBuilder('favorite') - .andWhere('favorite.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('favorite.clip', 'clip'); + let myFavorites: Packed<'Clip'>[] = []; + if (ps.withLocal) { + const query = this.clipFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.clip', 'clip'); - const favorites = await query - .getMany(); + const favorites = await query + .getMany(); + const localFavorites = await this.clipEntityService.packMany(favorites.map(x => x.clip!), me); + myFavorites = myFavorites.concat(localFavorites); + } + if (ps.withRemote) { + const query = this.clipFavoritesRemoteRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }); - return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); + const favorites = await query.getMany(); + const remoteFavorites = await Promise.all(favorites.map(e => clipService.showRemote(e.clipId, e.host))); + myFavorites = myFavorites.concat(remoteFavorites); + } + return myFavorites.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts index 37c90f53fd..c26c368ddb 100644 --- a/packages/backend/src/server/api/endpoints/clips/show.ts +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -7,14 +7,10 @@ import { Inject, Injectable } from '@nestjs/common'; import got, * as Got from 'got'; import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ClipsRepository } from '@/models/_.js'; +import type { ClipFavoritesRemoteRepository, ClipsRepository } from '@/models/_.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { ClipService } from '@/core/ClipService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -60,24 +56,33 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.config) - private config: Config, @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, - @Inject(DI.redisForRemoteApis) - private redisForRemoteApis: Redis.Redis, + @Inject(DI.clipFavoritesRemoteRepository) + private clipFavoritesRemoteRepository: ClipFavoritesRemoteRepository, - private httpRequestService: HttpRequestService, - private userEntityService: UserEntityService, - private remoteUserResolveService: RemoteUserResolveService, + private clipService: ClipService, private clipEntityService: ClipEntityService, ) { super(meta, paramDef, async (ps, me) => { const parsed_id = ps.clipId.split('@'); if (parsed_id.length === 2 ) {//is remote - const url = 'https://' + parsed_id[1] + '/api/clips/show'; - console.log(url); - return remote(config, httpRequestService, userEntityService, remoteUserResolveService, redisForRemoteApis, url, parsed_id[0], parsed_id[1], ps.clipId); + const clip = await clipService.showRemote(parsed_id[0], parsed_id[1]).catch(err => { + throw new ApiError(meta.errors.failedToResolveRemoteUser); + }); + if (me) { + const exist = await this.clipFavoritesRemoteRepository.exists({ + where: { + clipId: parsed_id[0], + host: parsed_id[1], + userId: me.id, + }, + }); + if (exist) { + clip.isFavorited = true; + } + } + return clip; } if (parsed_id.length !== 1 ) {//is not local throw new ApiError(meta.errors.invalidIdFormat); @@ -100,76 +105,3 @@ export default class extends Endpoint { // eslint- } } -async function remote( - config:Config, - httpRequestService: HttpRequestService, - userEntityService: UserEntityService, - remoteUserResolveService: RemoteUserResolveService, - redisForRemoteApis: Redis.Redis, - url:string, - clipId:string, - host:string, - local_id:string, -) { - const cache_key = 'clip:show:' + local_id; - const cache_value = await redisForRemoteApis.get(cache_key); - let remote_json = null; - if (cache_value === null) { - const timeout = 30 * 1000; - const operationTimeout = 60 * 1000; - const res = got.post(url, { - headers: { - 'User-Agent': config.userAgent, - 'Content-Type': 'application/json; charset=utf-8', - }, - timeout: { - lookup: timeout, - connect: timeout, - secureConnect: timeout, - socket: timeout, // read timeout - response: timeout, - send: timeout, - request: operationTimeout, // whole operation timeout - }, - agent: { - http: httpRequestService.httpAgent, - https: httpRequestService.httpsAgent, - }, - http2: true, - retry: { - limit: 1, - }, - enableUnixSockets: false, - body: JSON.stringify({ - clipId, - }), - }); - remote_json = await res.text(); - const redisPipeline = redisForRemoteApis.pipeline(); - redisPipeline.set(cache_key, remote_json); - redisPipeline.expire(cache_key, 10 * 60); - await redisPipeline.exec(); - } else { - remote_json = cache_value; - } - const remote_clip = JSON.parse(remote_json); - if (remote_clip.user == null || remote_clip.user.username == null) { - throw new ApiError(meta.errors.failedToResolveRemoteUser); - } - const user = await remoteUserResolveService.resolveUser(remote_clip.user.username, host).catch(err => { - throw new ApiError(meta.errors.failedToResolveRemoteUser); - }); - return await awaitAll({ - id: local_id, - createdAt: remote_clip.createdAt ? remote_clip.createdAt : null, - lastClippedAt: remote_clip.lastClippedAt ? remote_clip.lastClippedAt : null, - userId: user.id, - user: userEntityService.pack(user), - name: remote_clip.name, - description: remote_clip.description, - isPublic: true, - favoritedCount: remote_clip.favoritedCount, - isFavorited: false, - notesCount: remote_clip.notesCount, - }); -} diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts index a458fda4a0..d4178ad6fb 100644 --- a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { ClipsRepository, ClipFavoritesRepository } from '@/models/_.js'; +import type { ClipsRepository, ClipFavoritesRepository, ClipFavoritesRemoteRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -36,7 +36,7 @@ export const meta = { export const paramDef = { type: 'object', properties: { - clipId: { type: 'string', format: 'misskey:id' }, + clipId: { type: 'string' }, }, required: ['clipId'], } as const; @@ -49,8 +49,26 @@ export default class extends Endpoint { // eslint- @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, + @Inject(DI.clipFavoritesRemoteRepository) + private clipFavoritesRemoteRepository: ClipFavoritesRemoteRepository, ) { super(meta, paramDef, async (ps, me) => { + const clipIdArray = ps.clipId.split('@'); + const host = clipIdArray.length > 1 ? clipIdArray[1] : null; + if (host) { + const exist = await this.clipFavoritesRemoteRepository.findOneBy({ + clipId: clipIdArray[0], + host: host, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.clipFavoritesRemoteRepository.delete(exist.id); + return; + } const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); if (clip == null) { throw new ApiError(meta.errors.noSuchClip); diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index 9745df5276..002dd540ce 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -946,6 +946,9 @@ type ClipsFavoriteRequest = operations['clips___favorite']['requestBody']['conte // @public (undocumented) type ClipsListResponse = operations['clips___list']['responses']['200']['content']['application/json']; +// @public (undocumented) +type ClipsMyFavoritesRequest = operations['clips___my-favorites']['requestBody']['content']['application/json']; + // @public (undocumented) type ClipsMyFavoritesResponse = operations['clips___my-favorites']['responses']['200']['content']['application/json']; @@ -1395,6 +1398,7 @@ declare namespace entities { ClipsUpdateResponse, ClipsFavoriteRequest, ClipsUnfavoriteRequest, + ClipsMyFavoritesRequest, ClipsMyFavoritesResponse, DriveResponse, DriveFilesRequest, diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index 8d6fffc8b6..8eedc1e72e 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -202,6 +202,7 @@ import type { ClipsUpdateResponse, ClipsFavoriteRequest, ClipsUnfavoriteRequest, + ClipsMyFavoritesRequest, ClipsMyFavoritesResponse, DriveResponse, DriveFilesRequest, @@ -746,7 +747,7 @@ export type Endpoints = { 'clips/update': { req: ClipsUpdateRequest; res: ClipsUpdateResponse }; 'clips/favorite': { req: ClipsFavoriteRequest; res: EmptyResponse }; 'clips/unfavorite': { req: ClipsUnfavoriteRequest; res: EmptyResponse }; - 'clips/my-favorites': { req: EmptyRequest; res: ClipsMyFavoritesResponse }; + 'clips/my-favorites': { req: ClipsMyFavoritesRequest; res: ClipsMyFavoritesResponse }; 'drive': { req: EmptyRequest; res: DriveResponse }; 'drive/files': { req: DriveFilesRequest; res: DriveFilesResponse }; 'drive/files/attached-notes': { req: DriveFilesAttachedNotesRequest; res: DriveFilesAttachedNotesResponse }; diff --git a/packages/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index f4e78f94c9..9ca8029125 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -205,6 +205,7 @@ export type ClipsUpdateRequest = operations['clips___update']['requestBody']['co export type ClipsUpdateResponse = operations['clips___update']['responses']['200']['content']['application/json']; export type ClipsFavoriteRequest = operations['clips___favorite']['requestBody']['content']['application/json']; export type ClipsUnfavoriteRequest = operations['clips___unfavorite']['requestBody']['content']['application/json']; +export type ClipsMyFavoritesRequest = operations['clips___my-favorites']['requestBody']['content']['application/json']; export type ClipsMyFavoritesResponse = operations['clips___my-favorites']['responses']['200']['content']['application/json']; export type DriveResponse = operations['drive']['responses']['200']['content']['application/json']; export type DriveFilesRequest = operations['drive___files']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index d2db313387..6e74295563 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -13814,7 +13814,6 @@ export type operations = { requestBody: { content: { 'application/json': { - /** Format: misskey:id */ clipId: string; }; }; @@ -13863,6 +13862,16 @@ export type operations = { * **Credential required**: *Yes* / **Permission**: *read:clip-favorite* */ 'clips___my-favorites': { + requestBody: { + content: { + 'application/json': { + /** @default true */ + withLocal?: boolean; + /** @default true */ + withRemote?: boolean; + }; + }; + }; responses: { /** @description OK (with results) */ 200: {