Skip to content

Commit

Permalink
リモートクリップのお気に入り登録 (kokonect-link#438)
Browse files Browse the repository at this point in the history
  • Loading branch information
kozakura913 authored Sep 14, 2024
1 parent 142f9e5 commit 4838494
Show file tree
Hide file tree
Showing 15 changed files with 277 additions and 102 deletions.
22 changes: 22 additions & 0 deletions packages/backend/migration/1726276463152-ClipFavoriteRemote.js
Original file line number Diff line number Diff line change
@@ -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"`);
}
}
84 changes: 84 additions & 0 deletions packages/backend/src/core/ClipService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@

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';
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 {
Expand All @@ -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,

Expand All @@ -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,
) {
Expand Down Expand Up @@ -155,4 +171,72 @@ export class ClipService {

this.notesRepository.decrement({ id: noteId }, 'clippedCount', 1);
}
@bindThis
public async showRemote(
clipId:string,
host:string,
) : Promise<Packed<'Clip'>> {
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,
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/di-symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
35 changes: 35 additions & 0 deletions packages/backend/src/models/ClipFavoriteRemote.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions packages/backend/src/models/RepositoryModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
MiChannelFollowing,
MiClip,
MiClipFavorite,
MiClipFavoriteRemote,
MiClipNote,
MiDriveFile,
MiDriveFolder,
Expand Down Expand Up @@ -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<MiClipFavoriteRemote>),
inject: [DI.db],
};

const $antennasRepository: Provider = {
provide: DI.antennasRepository,
useFactory: (db: DataSource) => db.getRepository(MiAntenna).extend(miRepository as MiRepository<MiAntenna>),
Expand Down Expand Up @@ -601,6 +608,7 @@ const $officialTagRepository: Provider = {
$clipsRepository,
$clipNotesRepository,
$clipFavoritesRepository,
$clipFavoritesRemoteRepository,
$antennasRepository,
$promoNotesRepository,
$promoReadsRepository,
Expand Down Expand Up @@ -679,6 +687,7 @@ const $officialTagRepository: Provider = {
$clipsRepository,
$clipNotesRepository,
$clipFavoritesRepository,
$clipFavoritesRemoteRepository,
$antennasRepository,
$promoNotesRepository,
$promoReadsRepository,
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/models/_.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -149,6 +150,7 @@ export {
MiClip,
MiClipNote,
MiClipFavorite,
MiClipFavoriteRemote,
MiDriveFile,
MiDriveFolder,
MiEmoji,
Expand Down Expand Up @@ -227,6 +229,7 @@ export type ChannelFavoritesRepository = Repository<MiChannelFavorite> & MiRepos
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
export type ClipFavoritesRemoteRepository = Repository<MiClipFavoriteRemote> & MiRepository<MiClipFavoriteRemote>;
export type DriveFilesRepository = Repository<MiDriveFile> & MiRepository<MiDriveFile>;
export type DriveFoldersRepository = Repository<MiDriveFolder> & MiRepository<MiDriveFolder>;
export type EmojisRepository = Repository<MiEmoji> & MiRepository<MiEmoji>;
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -190,6 +191,7 @@ export const entities = [
MiClip,
MiClipNote,
MiClipFavorite,
MiClipFavoriteRemote,
MiAntenna,
MiPromoNote,
MiPromoRead,
Expand Down
39 changes: 37 additions & 2 deletions packages/backend/src/server/api/endpoints/clips/favorite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;

Expand All @@ -55,13 +61,42 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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);
Expand Down
33 changes: 26 additions & 7 deletions packages/backend/src/server/api/endpoints/clips/my-favorites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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;
Expand All @@ -39,18 +43,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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());
});
}
}
Loading

0 comments on commit 4838494

Please sign in to comment.