diff --git a/CHANGELOG_YOJO.md b/CHANGELOG_YOJO.md index 2e63c9ded7..a7aba92b0a 100644 --- a/CHANGELOG_YOJO.md +++ b/CHANGELOG_YOJO.md @@ -6,6 +6,8 @@ Cherrypick 4.11.1 ### General - Enhance: 連合一覧のソートにリバーシのバージョンを追加 - Enhance: リモートのクリップをお気に入りに登録できるように +- Enhance: リモートのPlayを遊べるように +- Enhance: リモートのPlayをお気に入りに登録できるように ### Client - Fix: リアクションが閲覧できる状態でも見れない問題を修正 [#429](https://github.com/yojo-art/cherrypick/pull/429) diff --git a/packages/backend/migration/1726452644817-FlashLikeRemote.js b/packages/backend/migration/1726452644817-FlashLikeRemote.js new file mode 100644 index 0000000000..084f2ced5f --- /dev/null +++ b/packages/backend/migration/1726452644817-FlashLikeRemote.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project, yojo-art team + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class flashLikeRemote1726452644817 { + name = 'flashLikeRemote1726452644817' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "flash_like_remote" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "flashId" character varying(32) NOT NULL, "host" character varying(128) NOT NULL, "authorId" character varying(32) NOT NULL, CONSTRAINT "PK_840a074b84bd1663054e020e43" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_ade312aad367a2902ed415abbc" ON "flash_like_remote" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_f7c8a8fd916efed73a05bc1ea0" ON "flash_like_remote" ("userId", "flashId","host") `); + await queryRunner.query(`ALTER TABLE "flash_like_remote" ADD CONSTRAINT "FK_8c14417c4cc57f04b4d7376707a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "flash_like_remote" ADD CONSTRAINT "FK_75f247337676468f6bd6f22eb24" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "flash_like_remote" DROP CONSTRAINT "FK_75f247337676468f6bd6f22eb24"`); + await queryRunner.query(`ALTER TABLE "flash_like_remote" DROP CONSTRAINT "FK_8c14417c4cc57f04b4d7376707a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f7c8a8fd916efed73a05bc1ea0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ade312aad367a2902ed415abbc"`); + await queryRunner.query(`DROP TABLE "flash_like_remote"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 554e6707b2..b77a034d09 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -75,6 +75,7 @@ import { FileInfoService } from './FileInfoService.js'; import { SearchService } from './SearchService.js'; import { AdvancedSearchService } from './AdvancedSearchService.js'; import { ClipService } from './ClipService.js'; +import { FlashService } from './FlashService.js'; import { FeaturedService } from './FeaturedService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; @@ -229,6 +230,7 @@ const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: Fi const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $AdvancedSearchService: Provider = { provide: 'AdvancedSearchService', useExisting: AdvancedSearchService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; +const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; @@ -387,6 +389,7 @@ const $ApGameService: Provider = { provide: 'ApGameService', useExisting: ApGame SearchService, AdvancedSearchService, ClipService, + FlashService, FeaturedService, FanoutTimelineService, FanoutTimelineEndpointService, @@ -541,6 +544,7 @@ const $ApGameService: Provider = { provide: 'ApGameService', useExisting: ApGame $SearchService, $AdvancedSearchService, $ClipService, + $FlashService, $FeaturedService, $FanoutTimelineService, $FanoutTimelineEndpointService, @@ -696,6 +700,7 @@ const $ApGameService: Provider = { provide: 'ApGameService', useExisting: ApGame SearchService, AdvancedSearchService, ClipService, + FlashService, FeaturedService, FanoutTimelineService, FanoutTimelineEndpointService, @@ -849,6 +854,7 @@ const $ApGameService: Provider = { provide: 'ApGameService', useExisting: ApGame $SearchService, $AdvancedSearchService, $ClipService, + $FlashService, $FeaturedService, $FanoutTimelineService, $FanoutTimelineEndpointService, diff --git a/packages/backend/src/core/FlashService.ts b/packages/backend/src/core/FlashService.ts new file mode 100644 index 0000000000..2b907f4f13 --- /dev/null +++ b/packages/backend/src/core/FlashService.ts @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project, yojo-art team + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +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, ClipNotesRepository, NotesRepository, MiUser } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { emojis } from '@/misc/remote-api-utils.js'; + +@Injectable() +export class FlashService { + 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, + + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private httpRequestService: HttpRequestService, + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private roleService: RoleService, + private idService: IdService, + ) { + } + @bindThis + async showRemoteOrDummy( + flashId: string, + author: MiUser|null, + fetch_emoji = false, + ) : Promise> { + if (author == null) { + throw new Error(); + } + try { + if (author.host == null) { + throw new Error(); + } + return await this.showRemote(flashId, author.host, fetch_emoji); + } catch { + return await awaitAll({ + id: flashId + '@' + (author.host ? author.host : ''), + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + userId: author.id, + user: this.userEntityService.pack(author), + title: 'Unavailable', + summary: '', + script: '', + favoritedCount: 0, + visibility: 'public', + likedCount: 0, + isLiked: false, //後でLike対応する + }); + } + } + @bindThis + public async showRemote( + flashId:string, + host:string, + fetch_emoji = false, + ) : Promise> { + const cache_key = 'flash:show:' + flashId + '@' + 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/flash/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({ + flashId, + }), + }); + 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 = JSON.parse(remote_json); + if (remote.user == null || remote.user.username == null) { + throw new FlashService.FailedToResolveRemoteUserError(); + } + const user = await this.remoteUserResolveService.resolveUser(remote.user.username, host).catch(err => { + throw new FlashService.FailedToResolveRemoteUserError(); + }); + return await awaitAll({ + id: flashId + '@' + host, + createdAt: remote.createdAt ? new Date(remote.createdAt).toISOString() : new Date(0).toISOString(), + updatedAt: remote.updatedAt ? new Date(remote.updatedAt).toISOString() : new Date(0).toISOString(), + userId: user.id, + user: this.userEntityService.pack(user), + title: String(remote.title), + summary: String(remote.summary), + script: String(remote.script), + favoritedCount: remote.favoritedCount, + visibility: remote.visibility ?? 'public', + likedCount: remote.likedCount ?? 0, + isLiked: false, //後でLike対応する + emojis: (remote.summary && fetch_emoji) ? emojis(this.config, this.httpRequestService, this.redisForRemoteApis, host, remote.summary) : {}, + }); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index e10adfec0a..3b585c78d0 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -90,6 +90,7 @@ export const DI = { roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), + flashLikesRemoteRepository: Symbol('flashLikesRemoteRepository'), userMemosRepository: Symbol('userMemosRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), diff --git a/packages/backend/src/models/FlashLikeRemote.ts b/packages/backend/src/models/FlashLikeRemote.ts new file mode 100644 index 0000000000..894f42e44e --- /dev/null +++ b/packages/backend/src/models/FlashLikeRemote.ts @@ -0,0 +1,43 @@ +/* + * 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('flash_like_remote') +@Index(['userId', 'flashId', 'host'], { unique: true }) +export class MiFlashLikeRemote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column(id()) + public authorId: MiUser['id']; + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public author: MiUser | null; + + @Column('varchar', { + length: 32, + }) + public flashId: 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 b0155f78d2..6534c8a959 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -32,6 +32,7 @@ import { MiEvent, MiFlash, MiFlashLike, + MiFlashLikeRemote, MiFollowing, MiFollowRequest, MiGalleryLike, @@ -509,6 +510,12 @@ const $flashLikesRepository: Provider = { inject: [DI.db], }; +const $flashLikesRemoteRepository: Provider = { + provide: DI.flashLikesRemoteRepository, + useFactory: (db: DataSource) => db.getRepository(MiFlashLikeRemote).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $rolesRepository: Provider = { provide: DI.rolesRepository, useFactory: (db: DataSource) => db.getRepository(MiRole).extend(miRepository as MiRepository), @@ -626,6 +633,7 @@ const $officialTagRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $flashLikesRemoteRepository, $userMemosRepository, $abuseReportResolversRepository, $bubbleGameRecordsRepository, @@ -705,6 +713,7 @@ const $officialTagRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $flashLikesRemoteRepository, $userMemosRepository, $abuseReportResolversRepository, $bubbleGameRecordsRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index ca074e7004..a199efd94c 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -84,6 +84,7 @@ import { MiRole } from '@/models/Role.js'; import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; +import { MiFlashLikeRemote } from '@/models/FlashLikeRemote.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; @@ -207,6 +208,7 @@ export { MiRoleAssignment, MiFlash, MiFlashLike, + MiFlashLikeRemote, MiUserMemo, MiBubbleGameRecord, MiReversiGame, @@ -286,6 +288,7 @@ export type RolesRepository = Repository & MiRepository; export type RoleAssignmentsRepository = Repository & MiRepository; export type FlashsRepository = Repository & MiRepository; export type FlashLikesRepository = Repository & MiRepository; +export type FlashLikesRemoteRepository = Repository & MiRepository; export type UserMemoRepository = Repository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts index 42b2172409..3916c3fb4c 100644 --- a/packages/backend/src/models/json-schema/flash.ts +++ b/packages/backend/src/models/json-schema/flash.ts @@ -57,5 +57,14 @@ export const packedFlashSchema = { type: 'boolean', optional: true, nullable: false, }, + emojis: { + type: 'object', + optional: true, nullable: false, + additionalProperties: { + anyOf: [{ + type: 'string', + }], + }, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index c57c3827f2..70fb1e5a28 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -83,6 +83,7 @@ import { MiRole } from '@/models/Role.js'; import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; +import { MiFlashLikeRemote } from '@/models/FlashLikeRemote.js'; import { MiUserMemo } from '@/models/UserMemo.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; @@ -211,6 +212,7 @@ export const entities = [ MiRoleAssignment, MiFlash, MiFlashLike, + MiFlashLikeRemote, MiUserMemo, MiBubbleGameRecord, MiReversiGame, diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts index e4dc5b61c5..d46a6d46d7 100644 --- a/packages/backend/src/server/api/endpoints/flash/like.ts +++ b/packages/backend/src/server/api/endpoints/flash/like.ts @@ -4,10 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; +import type { FlashsRepository, FlashLikesRepository, FlashLikesRemoteRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { FlashService } from '@/core/FlashService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -43,7 +44,7 @@ export const meta = { export const paramDef = { type: 'object', properties: { - flashId: { type: 'string', format: 'misskey:id' }, + flashId: { type: 'string' }, }, required: ['flashId'], } as const; @@ -56,10 +57,40 @@ export default class extends Endpoint { // eslint- @Inject(DI.flashLikesRepository) private flashLikesRepository: FlashLikesRepository, + @Inject(DI.flashLikesRemoteRepository) + private flashLikesRemoteRepository: FlashLikesRemoteRepository, + private flashService: FlashService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const flashIdArray = ps.flashId.split('@'); + const host = flashIdArray.length > 1 ? flashIdArray[1] : null; + if (host) { + const flashId = flashIdArray[0]; + const flash = await flashService.showRemote(flashId, host); + + const exist = await this.flashLikesRemoteRepository.exists({ + where: { + flashId, + host, + userId: me.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyLiked); + } + + await this.flashLikesRemoteRepository.insert({ + id: this.idService.gen(), + flashId, + host, + userId: me.id, + authorId: flash.userId, + }); + return; + } const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); if (flash == null) { throw new ApiError(meta.errors.noSuchFlash); diff --git a/packages/backend/src/server/api/endpoints/flash/my-likes.ts b/packages/backend/src/server/api/endpoints/flash/my-likes.ts index 755cc5acfc..07c4c4dcbe 100644 --- a/packages/backend/src/server/api/endpoints/flash/my-likes.ts +++ b/packages/backend/src/server/api/endpoints/flash/my-likes.ts @@ -5,10 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FlashLikesRepository } from '@/models/_.js'; +import type { FlashLikesRemoteRepository, FlashLikesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { FlashLikeEntityService } from '@/core/entities/FlashLikeEntityService.js'; import { DI } from '@/di-symbols.js'; +import { Packed } from '@/misc/json-schema.js'; +import { FlashService } from '@/core/FlashService.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['account', 'flash'], @@ -44,6 +48,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + withLocal: { type: 'boolean', default: true }, + withRemote: { type: 'boolean', default: true }, }, required: [], } as const; @@ -53,20 +59,43 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.flashLikesRepository) private flashLikesRepository: FlashLikesRepository, + @Inject(DI.flashLikesRemoteRepository) + private flashLikesRemoteRepository: FlashLikesRemoteRepository, private flashLikeEntityService: FlashLikeEntityService, + private flashService: FlashService, private queryService: QueryService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) - .andWhere('like.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('like.flash', 'flash'); + let myFavorites: {id:string, flash:Packed<'Flash'>}[] = []; + if (ps.withLocal) { + const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere('like.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('like.flash', 'flash'); - const likes = await query - .limit(ps.limit) - .getMany(); + const likes = await query + .limit(ps.limit) + .getMany(); + myFavorites = myFavorites.concat(await this.flashLikeEntityService.packMany(likes, me)); + } + if (ps.withRemote) { + const query = this.queryService.makePaginationQuery(this.flashLikesRemoteRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere('like.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('like.author', 'author'); - return this.flashLikeEntityService.packMany(likes, me); + const likes = await query + .limit(ps.limit) + .getMany(); + let remoteLikes = await Promise.all(likes.map(e => awaitAll({ id: e.id, flash: flashService.showRemoteOrDummy(e.flashId, e.author, true) }))); + remoteLikes = remoteLikes.map(flash => { + flash.flash.isLiked = true; + return flash; + }); + myFavorites = myFavorites.concat(remoteLikes); + } + return myFavorites.sort((a, b) => new Date(a.flash.createdAt).getTime() - new Date(b.flash.createdAt).getTime()); }); } } + diff --git a/packages/backend/src/server/api/endpoints/flash/show.ts b/packages/backend/src/server/api/endpoints/flash/show.ts index a6fbd8e76e..9dc59c5664 100644 --- a/packages/backend/src/server/api/endpoints/flash/show.ts +++ b/packages/backend/src/server/api/endpoints/flash/show.ts @@ -4,10 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; +import type { FlashLikesRemoteRepository, FlashsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { DI } from '@/di-symbols.js'; +import { FlashService } from '@/core/FlashService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -27,13 +28,23 @@ export const meta = { code: 'NO_SUCH_FLASH', id: 'f0d34a1a-d29a-401d-90ba-1982122b5630', }, + invalidIdFormat: { + message: 'Invalid id format.', + code: 'INVALID_ID_FORMAT', + id: 'df45c7d1-cd15-4a35-b3e1-8c9f987c4f5c', + }, + failedToResolveRemoteUser: { + message: 'failedToResolveRemoteUser.', + code: 'FAILED_TO_RESOLVE_REMOTE_USER', + id: '56d5e552-d55a-47e3-9f37-6dc85a93ecf9', + }, }, } as const; export const paramDef = { type: 'object', properties: { - flashId: { type: 'string', format: 'misskey:id' }, + flashId: { type: 'string' }, }, required: ['flashId'], } as const; @@ -43,17 +54,47 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.flashsRepository) private flashsRepository: FlashsRepository, + @Inject(DI.flashLikesRemoteRepository) + private flashLikesRemoteRepository: FlashLikesRemoteRepository, + private flashService: FlashService, private flashEntityService: FlashEntityService, ) { super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + const parsed_id = ps.flashId.split('@'); + switch (parsed_id.length) { + case 2:{//is remote + const flash = await flashService.showRemote(parsed_id[0], parsed_id[1], true).catch(err => { + throw new ApiError(meta.errors.failedToResolveRemoteUser); + }); - if (flash == null) { - throw new ApiError(meta.errors.noSuchFlash); - } + if (me) { + const exist = await this.flashLikesRemoteRepository.exists({ + where: { + flashId: parsed_id[0], + host: parsed_id[1], + userId: me.id, + }, + }); + if (exist) { + flash.isLiked = true; + } + } + return flash; + } + case 1:{//is local + const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); - return await this.flashEntityService.pack(flash, me); + if (flash == null) { + throw new ApiError(meta.errors.noSuchFlash); + } + + return await this.flashEntityService.pack(flash, me); + } + default:{ + throw new ApiError(meta.errors.invalidIdFormat); + } + } }); } } diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts index 7869bcdf52..f9ae866907 100644 --- a/packages/backend/src/server/api/endpoints/flash/unlike.ts +++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; +import type { FlashsRepository, FlashLikesRepository, FlashLikesRemoteRepository } 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: { - flashId: { type: 'string', format: 'misskey:id' }, + flashId: { type: 'string' }, }, required: ['flashId'], } as const; @@ -46,11 +46,29 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.flashsRepository) private flashsRepository: FlashsRepository, + @Inject(DI.flashLikesRemoteRepository) + private flashLikesRemoteRepository: FlashLikesRemoteRepository, @Inject(DI.flashLikesRepository) private flashLikesRepository: FlashLikesRepository, ) { super(meta, paramDef, async (ps, me) => { + const flashIdArray = ps.flashId.split('@'); + const host = flashIdArray.length > 1 ? flashIdArray[1] : null; + if (host) { + const exist = await this.flashLikesRemoteRepository.findOneBy({ + flashId: flashIdArray[0], + host: host, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + await this.flashLikesRemoteRepository.delete(exist.id); + return; + } const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); if (flash == null) { throw new ApiError(meta.errors.noSuchFlash); diff --git a/packages/backend/src/server/api/endpoints/users/flashs.ts b/packages/backend/src/server/api/endpoints/users/flashs.ts index e5ea450215..f6c4a45cc8 100644 --- a/packages/backend/src/server/api/endpoints/users/flashs.ts +++ b/packages/backend/src/server/api/endpoints/users/flashs.ts @@ -4,11 +4,19 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { FindOptionsWhere } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import type { FlashsRepository } from '@/models/_.js'; +import type { FlashLikesRemoteRepository, FlashsRepository, MiUser, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import type { Config } from '@/config.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { emojis, fetch_remote_api, fetch_remote_user_id } from '@/misc/remote-api-utils.js'; +import { bindThis } from '@/decorators.js'; export const meta = { tags: ['users', 'flashs'], @@ -31,23 +39,39 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, + sinceId: { type: 'string' }, + untilId: { type: 'string' }, }, required: ['userId'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, @Inject(DI.flashsRepository) private flashsRepository: FlashsRepository, + @Inject(DI.flashLikesRemoteRepository) + private flashLikesRemoteRepository: FlashLikesRemoteRepository, + @Inject(DI.redisForRemoteApis) + private redisForRemoteApis: Redis.Redis, private flashEntityService: FlashEntityService, + private httpRequestService: HttpRequestService, + private userEntityService: UserEntityService, private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { + const q: FindOptionsWhere = { id: ps.userId }; + + const user = await this.usersRepository.findOneBy(q); + if (user === null) return []; + if (userEntityService.isRemoteUser(user)) { + return this.remote(me, user, ps.limit, ps.sinceId, ps.untilId); + } const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId) .andWhere('flash.userId = :userId', { userId: ps.userId }) .andWhere('flash.visibility = \'public\''); @@ -59,4 +83,56 @@ export default class extends Endpoint { return await this.flashEntityService.packMany(flashs); }); } + @bindThis + async remote( + me:MiUser|null, + user:MiUser, + limit:number, + sinceId:string|undefined, + untilId:string|undefined, + ) { + const cache_key = 'flash:user:' + user.id + '-' + sinceId + '-' + untilId; + const cache_value = await this.redisForRemoteApis.get(cache_key); + if (cache_value !== null) { + //ステータス格納 + if (cache_value.startsWith('__')) { + if (cache_value === '__SKIP_FETCH') return []; + //未定義のステータス + return []; + } + return JSON.parse(cache_value); + } + if (user.host == null) { + return []; + } + const remote_user_id = await fetch_remote_user_id(this.config, this.httpRequestService, this.redisForRemoteApis, user); + if (remote_user_id === null) { + return []; + } + const remote_json = await fetch_remote_api(this.config, this.httpRequestService, user.host, '/api/users/flashs', { userId: remote_user_id, limit, sinceId, untilId }); + const json = JSON.parse(remote_json); + const flashs = []; + for (const remote of json) { + const flash = await awaitAll({ + id: remote.id + '@' + user.host, + createdAt: remote.createdAt ? new Date(remote.createdAt).toISOString() : new Date(0).toISOString(), + updatedAt: remote.updatedAt ? new Date(remote.updatedAt).toISOString() : new Date(0).toISOString(), + userId: user.id, + user: this.userEntityService.pack(user), + title: String(remote.title), + summary: String(remote.summary), + script: String(remote.script), + favoritedCount: remote.favoritedCount, + visibility: remote.visibility ?? false, + likedCount: remote.likedCount ?? 0, + emojis: remote.summary ? emojis(this.config, this.httpRequestService, this.redisForRemoteApis, user.host, remote.summary) : {}, + }); + flashs.push(flash); + } + const redisPipeline = this.redisForRemoteApis.pipeline(); + redisPipeline.set(cache_key, JSON.stringify(flashs)); + redisPipeline.expire(cache_key, 10 * 60); + await redisPipeline.exec(); + return flashs; + } } diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index 305f56807c..9f6c696601 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -5009,6 +5009,9 @@ export type components = { visibility: 'private' | 'public'; likedCount: number | null; isLiked?: boolean; + emojis?: { + [key: string]: string; + }; }; Signin: { id: string; @@ -24972,7 +24975,6 @@ export type operations = { requestBody: { content: { 'application/json': { - /** Format: misskey:id */ flashId: string; }; }; @@ -25024,7 +25026,6 @@ export type operations = { requestBody: { content: { 'application/json': { - /** Format: misskey:id */ flashId: string; }; }; @@ -25078,7 +25079,6 @@ export type operations = { requestBody: { content: { 'application/json': { - /** Format: misskey:id */ flashId: string; }; }; @@ -25258,6 +25258,10 @@ export type operations = { sinceId?: string; /** Format: misskey:id */ untilId?: string; + /** @default true */ + withLocal?: boolean; + /** @default true */ + withRemote?: boolean; }; }; }; @@ -28188,9 +28192,7 @@ export type operations = { userId: string; /** @default 10 */ limit?: number; - /** Format: misskey:id */ sinceId?: string; - /** Format: misskey:id */ untilId?: string; }; }; diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index 37e43f65d2..0dd13da9fd 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -8,9 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only

{{ flash.title }}

+
+ {{ flash.likedCount }} + {{ flash.likedCount }} +

- +