From f1f24e39d2df3135493e2c2087230b428e2d02b7 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:32:46 +0900 Subject: Feat: Chat (#15686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * wip * wip * wip * Update types.ts * Create 1742203321812-chat.js * wip * wip * Update room.vue * Update home.vue * Update home.vue * Update ja-JP.yml * Update index.d.ts * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update CHANGELOG.md * wip * Update home.vue * clean up * Update misskey-js.api.md * wip * wip * wip * wip * wip * wip * wip * wip * wip * lint fixes * lint * Update UserEntityService.ts * search * wip * 🎨 * wip * Update home.ownedRooms.vue * wip * Update CHANGELOG.md * Update style.scss * wip * improve performance * improve performance * Update timeline.test.ts --- packages/backend/src/server/ServerModule.ts | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'packages/backend/src/server/ServerModule.ts') diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 3ab0b815f2..0223650329 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -44,6 +44,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; +import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; +import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @@ -84,6 +86,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j GlobalTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, + ChatUserChannelService, + ChatRoomChannelService, ReversiChannelService, ReversiGameChannelService, HomeTimelineChannelService, -- cgit v1.2.3-freya From f61d71ac8cda6455238faa976ef221525ab5ed34 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 20 Mar 2025 20:43:05 -0400 Subject: refactor mastodon API and preserve remote user agent for requests --- packages/backend/src/server/ServerModule.ts | 18 + .../api/mastodon/MastodonApiServerService.ts | 786 +++------------------ .../server/api/mastodon/MastodonClientService.ts | 69 ++ .../backend/src/server/api/mastodon/argsUtils.ts | 47 ++ .../backend/src/server/api/mastodon/converters.ts | 3 - .../backend/src/server/api/mastodon/endpoints.ts | 22 - .../src/server/api/mastodon/endpoints/account.ts | 474 ++++++++++--- .../src/server/api/mastodon/endpoints/apps.ts | 121 ++++ .../src/server/api/mastodon/endpoints/auth.ts | 97 --- .../src/server/api/mastodon/endpoints/filter.ts | 149 ++-- .../src/server/api/mastodon/endpoints/instance.ts | 110 +++ .../src/server/api/mastodon/endpoints/meta.ts | 66 -- .../server/api/mastodon/endpoints/notifications.ts | 109 ++- .../src/server/api/mastodon/endpoints/search.ts | 174 +++-- .../src/server/api/mastodon/endpoints/status.ts | 257 +++---- .../src/server/api/mastodon/endpoints/timeline.ts | 169 +++-- .../src/server/api/mastodon/timelineArgs.ts | 47 -- .../src/server/oauth/OAuth2ProviderService.ts | 104 +-- 18 files changed, 1347 insertions(+), 1475 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/MastodonClientService.ts create mode 100644 packages/backend/src/server/api/mastodon/argsUtils.ts delete mode 100644 packages/backend/src/server/api/mastodon/endpoints.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/apps.ts delete mode 100644 packages/backend/src/server/api/mastodon/endpoints/auth.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/instance.ts delete mode 100644 packages/backend/src/server/api/mastodon/endpoints/meta.ts delete mode 100644 packages/backend/src/server/api/mastodon/timelineArgs.ts (limited to 'packages/backend/src/server/ServerModule.ts') diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 2c067afe88..5af41ddd9f 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -7,6 +7,15 @@ import { Module } from '@nestjs/common'; import { EndpointsModule } from '@/server/api/EndpointsModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js'; +import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; +import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; +import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; +import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js'; +import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; import { HealthServerService } from './HealthServerService.js'; @@ -107,6 +116,15 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j MastoConverters, MastodonLogger, MastodonDataService, + MastodonClientService, + ApiAccountMastodon, + ApiAppsMastodon, + ApiFilterMastodon, + ApiInstanceMastodon, + ApiNotificationsMastodon, + ApiSearchMastodon, + ApiStatusMastodon, + ApiTimelineMastodon, ], exports: [ ServerService, diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 69799bdade..7a4611fd74 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -4,75 +4,44 @@ */ import querystring from 'querystring'; -import { megalodon, Entity, MegalodonInterface } from 'megalodon'; -import { IsNull } from 'typeorm'; import multer from 'fastify-multer'; import { Inject, Injectable } from '@nestjs/common'; -import type { AccessTokensRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; -import { DriveService } from '@/core/DriveService.js'; import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account.js'; -import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js'; -import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js'; -import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js'; -import { AuthenticateService } from '@/server/api/AuthenticateService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { AuthMastodonRoute } from './endpoints/auth.js'; -import { toBoolean } from './timelineArgs.js'; -import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; -import { getInstance } from './endpoints/meta.js'; -import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; -import type { FastifyInstance, FastifyPluginOptions, FastifyRequest } from 'fastify'; - -export function getAccessToken(authorization: string | undefined): string | null { - const accessTokenArr = authorization?.split(' ') ?? [null]; - return accessTokenArr[accessTokenArr.length - 1]; -} - -export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessToken = getAccessToken(authorization); - return megalodon('misskey', BASE_URL, accessToken); -} +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; +import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js'; +import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; +import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js'; +import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; +import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; +import { convertAnnouncement, convertAttachment, MastoConverters, convertRelationship } from './converters.js'; +import type { Entity } from 'megalodon'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() export class MastodonApiServerService { constructor( - @Inject(DI.meta) - private readonly serverSettings: MiMeta, - @Inject(DI.usersRepository) - private readonly usersRepository: UsersRepository, - @Inject(DI.userProfilesRepository) - private readonly userProfilesRepository: UserProfilesRepository, - @Inject(DI.accessTokensRepository) - private readonly accessTokensRepository: AccessTokensRepository, @Inject(DI.config) private readonly config: Config, - private readonly driveService: DriveService, + private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, - private readonly authenticateService: AuthenticateService, - ) { } - - @bindThis - public async getAuthClient(request: FastifyRequest): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { - const accessToken = getAccessToken(request.headers.authorization); - const [me] = await this.authenticateService.authenticate(accessToken); - - const baseUrl = `${request.protocol}://${request.host}`; - const client = megalodon('misskey', baseUrl, accessToken); - - return { client, me }; - } - - @bindThis - public async getAuthOnly(request: FastifyRequest): Promise { - const accessToken = getAccessToken(request.headers.authorization); - const [me] = await this.authenticateService.authenticate(accessToken); - return me; - } + private readonly clientService: MastodonClientService, + private readonly apiAccountMastodon: ApiAccountMastodon, + private readonly apiAppsMastodon: ApiAppsMastodon, + private readonly apiFilterMastodon: ApiFilterMastodon, + private readonly apiInstanceMastodon: ApiInstanceMastodon, + private readonly apiNotificationsMastodon: ApiNotificationsMastodon, + private readonly apiSearchMastodon: ApiSearchMastodon, + private readonly apiStatusMastodon: ApiStatusMastodon, + private readonly apiTimelineMastodon: ApiTimelineMastodon, + ) {} @bindThis public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { @@ -107,11 +76,19 @@ export class MastodonApiServerService { fastify.register(multer.contentParser); + // External endpoints + this.apiAccountMastodon.register(fastify, upload); + this.apiAppsMastodon.register(fastify, upload); + this.apiFilterMastodon.register(fastify, upload); + this.apiInstanceMastodon.register(fastify); + this.apiNotificationsMastodon.register(fastify, upload); + this.apiSearchMastodon.register(fastify); + this.apiStatusMastodon.register(fastify); + this.apiTimelineMastodon.register(fastify); + fastify.get('/v1/custom_emojis', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceCustomEmojis(); reply.send(data.data); } catch (e) { @@ -121,36 +98,9 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/instance', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getInstance(); - const admin = await this.usersRepository.findOne({ - where: { - host: IsNull(), - isRoot: true, - isDeleted: false, - isSuspended: false, - }, - order: { id: 'ASC' }, - }); - const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); - reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/instance', data); - reply.code(401).send(data); - } - }); - fastify.get('/v1/announcements', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceAnnouncements(); reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); } catch (e) { @@ -161,11 +111,9 @@ export class MastodonApiServerService { }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + const client = this.clientService.getClient(_request); const data = await client.dismissInstanceAnnouncement(_request.body['id']); reply.send(data.data); } catch (e) { @@ -176,15 +124,13 @@ export class MastodonApiServerService { }); fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } + const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData); reply.send(convertAttachment(data.data as Entity.Attachment)); } catch (e) { @@ -195,15 +141,13 @@ export class MastodonApiServerService { }); fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } + const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData, _request.body); reply.send(convertAttachment(data.data as Entity.Attachment)); } catch (e) { @@ -213,27 +157,9 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getFilters(); - reply.send(data.data.map((filter) => convertFilter(filter))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/filters', data); - reply.code(401).send(data); - } - }); - fastify.get('/v1/trends', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); reply.send(data.data); } catch (e) { @@ -244,11 +170,8 @@ export class MastodonApiServerService { }); fastify.get('/v1/trends/tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); reply.send(data.data); } catch (e) { @@ -263,26 +186,9 @@ export class MastodonApiServerService { reply.send([]); }); - fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await ApiAuthMastodon(_request, client); - reply.send(data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/apps', data); - reply.code(401).send(data); - } - }); - fastify.get('/v1/preferences', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in try { + const client = this.clientService.getClient(_request); const data = await client.getPreferences(); reply.send(data.data); } catch (e) { @@ -292,367 +198,71 @@ export class MastodonApiServerService { } }); - //#region Accounts - fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.verifyCredentials()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/verify_credentials', data); - reply.code(401).send(data); - } - }); - - fastify.patch<{ - Body: { - discoverable?: string, - bot?: string, - display_name?: string, - note?: string, - avatar?: string, - header?: string, - locked?: string, - source?: { - privacy?: string, - sensitive?: string, - language?: string, - }, - fields_attributes?: { - name: string, - value: string, - }[], - }, - }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - // Check if there is an Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. - if (_request.files.length > 0 && accessTokens) { - const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const avatar = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'avatar'; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const header = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'header'; - }); - - if (tokeninfo && avatar) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: avatar.path, - name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).avatar = upload.id; - } - } else if (tokeninfo && header) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: header.path, - name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).header = upload.id; - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((_request.body as any).fields_attributes) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fields = (_request.body as any).fields_attributes.map((field: any) => { - if (!(field.name.trim() === '' && field.value.trim() === '')) { - if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); - if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); - } - return { - ...field, - }; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); - } - - const options = { - ..._request.body, - discoverable: toBoolean(_request.body.discoverable), - bot: toBoolean(_request.body.bot), - locked: toBoolean(_request.body.locked), - source: _request.body.source ? { - ..._request.body.source, - sensitive: toBoolean(_request.body.source.sensitive), - } : undefined, - }; - const data = await client.updateCredentials(options); - reply.send(await this.mastoConverters.convertAccount(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('PATCH /v1/accounts/update_credentials', data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in - try { - if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); - const data = await client.search(_request.query.acct, { type: 'accounts' }); - const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); - data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; - reply.send(await this.mastoConverters.convertAccount(data.data.accounts[0])); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/lookup', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; - if (typeof ids === 'string') { - ids = [ids]; - } - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getRelationships(ids)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/relationships', data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getAccount(_request.params.id); - const account = await this.mastoConverters.convertAccount(data.data); - reply.send(account); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getStatuses()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getFeaturedTags(); - reply.send(data.data.map((tag) => convertFeaturedTag(tag))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFollowers()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/following', async (_request, reply) => { + fastify.get('/v1/followed_tags', async (_request, reply) => { try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFollowing()); + const client = this.clientService.getClient(_request); + const data = await client.getFollowedTags(); + reply.send(data.data); } catch (e) { const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data); + this.logger.error('GET /v1/followed_tags', data); reply.code(401).send(data); } }); - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => { try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getAccountLists(_request.params.id); - reply.send(data.data.map((list) => convertList(list))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); - reply.code(401).send(data); - } - }); + const { client, me } = await this.clientService.getAuthClient(_request); - fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); - reply.code(401).send(data); - } - }); + const data = await client.getBookmarks(parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmFollow()); + reply.send(response); } catch (e) { const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); + this.logger.error('GET /v1/bookmarks', data); reply.code(401).send(data); } }); - fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addBlock()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); - reply.code(401).send(data); - } - }); + const { client, me } = await this.clientService.getAuthClient(_request); - fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmBlock()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); - reply.code(401).send(data); - } - }); + const data = await client.getFavourites(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addMute()); + reply.send(response); } catch (e) { const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); + this.logger.error('GET /v1/favourites', data); reply.code(401).send(data); } }); - fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => { try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmMute()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); - reply.code(401).send(data); - } - }); + const client = this.clientService.getClient(_request); - fastify.get('/v1/followed_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFollowedTags(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/followed_tags', data); - reply.code(401).send(data); - } - }); + const data = await client.getMutes(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - fastify.get('/v1/bookmarks', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getBookmarks()); + reply.send(response); } catch (e) { const data = getErrorData(e); - this.logger.error('GET /v1/bookmarks', data); + this.logger.error('GET /v1/mutes', data); reply.code(401).send(data); } }); - fastify.get('/v1/favourites', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => { try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFavourites()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/favourites', data); - reply.code(401).send(data); - } - }); + const client = this.clientService.getClient(_request); - fastify.get('/v1/mutes', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getMutes()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/mutes', data); - reply.code(401).send(data); - } - }); + const data = await client.getBlocks(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - fastify.get('/v1/blocks', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getBlocks()); + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/blocks', data); @@ -661,10 +271,8 @@ export class MastodonApiServerService { }); fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const client = this.clientService.getClient(_request); const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; const data = await client.getFollowRequests(limit); reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account as Entity.Account)))); @@ -675,254 +283,39 @@ export class MastodonApiServerService { } }); - fastify.post('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.acceptFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rejectFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); - reply.code(401).send(data); - } - }); - //#endregion - //#region Search - fastify.get('/v1/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.SearchV1()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/search', data); - reply.code(401).send(data); - } - }); + const client = this.clientService.getClient(_request); + const data = await client.acceptFollowRequest(_request.params.id); + const response = convertRelationship(data.data); - fastify.get('/v2/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.SearchV2()); + reply.send(response); } catch (e) { const data = getErrorData(e); - this.logger.error('GET /v2/search', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.getStatusTrends()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends/statuses', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v2/suggestions', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.getSuggestions()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/suggestions', data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Notifications - fastify.get('/v1/notifications', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.getNotifications()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/notifications', data); + this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); reply.code(401).send(data); } }); - fastify.get('/v1/notification/:id', async (_request, reply) => { + fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.getNotification()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/notification/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.rmNotification()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data); - reply.code(401).send(data); - } - }); + const client = this.clientService.getClient(_request); + const data = await client.rejectFollowRequest(_request.params.id); + const response = convertRelationship(data.data); - fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.rmNotifications()); + reply.send(response); } catch (e) { const data = getErrorData(e); - this.logger.error('POST /v1/notifications/clear', data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Filters - fastify.get('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - _request.params.id - ? reply.send(await filter.getFilter()) - : reply.send(await filter.getFilters()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.createFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/filters', data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.updateFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.delete('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.rmFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); + this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); reply.code(401).send(data); } }); //#endregion - //#region Timelines - const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverters, this.logger, this); - - // GET Endpoints - TLEndpoint.getTL(); - TLEndpoint.getHomeTl(); - TLEndpoint.getListTL(); - TLEndpoint.getTagTl(); - TLEndpoint.getConversations(); - TLEndpoint.getList(); - TLEndpoint.getLists(); - TLEndpoint.getListAccounts(); - - // POST Endpoints - TLEndpoint.createList(); - TLEndpoint.addListAccount(); - - // PUT Endpoint - TLEndpoint.updateList(); - - // DELETE Endpoints - TLEndpoint.deleteList(); - TLEndpoint.rmListAccount(); - //#endregion - - //#region Status - const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverters, this.logger, this.authenticateService, this); - - // GET Endpoints - NoteEndpoint.getStatus(); - NoteEndpoint.getStatusSource(); - NoteEndpoint.getContext(); - NoteEndpoint.getHistory(); - NoteEndpoint.getReblogged(); - NoteEndpoint.getFavourites(); - NoteEndpoint.getMedia(); - NoteEndpoint.getPoll(); - - //POST Endpoints - NoteEndpoint.postStatus(); - NoteEndpoint.addFavourite(); - NoteEndpoint.rmFavourite(); - NoteEndpoint.reblogStatus(); - NoteEndpoint.unreblogStatus(); - NoteEndpoint.bookmarkStatus(); - NoteEndpoint.unbookmarkStatus(); - NoteEndpoint.pinStatus(); - NoteEndpoint.unpinStatus(); - NoteEndpoint.reactStatus(); - NoteEndpoint.unreactStatus(); - NoteEndpoint.votePoll(); - - // PUT Endpoint fastify.put<{ Params: { id?: string, @@ -934,28 +327,25 @@ export class MastodonApiServerService { is_sensitive?: string, }, }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const options = { ..._request.body, is_sensitive: toBoolean(_request.body.is_sensitive), }; + const client = this.clientService.getClient(_request); const data = await client.updateMedia(_request.params.id, options); - reply.send(convertAttachment(data.data)); + const response = convertAttachment(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`PUT /v1/media/${_request.params.id}`, data); reply.code(401).send(data); } }); - NoteEndpoint.updateStatus(); - // DELETE Endpoint - NoteEndpoint.deleteStatus(); - //#endregion done(); } } diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts new file mode 100644 index 0000000000..82f9b7bfa9 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { megalodon, MegalodonInterface } from 'megalodon'; +import { Injectable } from '@nestjs/common'; +import { MiLocalUser } from '@/models/User.js'; +import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import type { FastifyRequest } from 'fastify'; + +@Injectable() +export class MastodonClientService { + constructor( + private readonly authenticateService: AuthenticateService, + ) {} + + /** + * Gets the authenticated user and API client for a request. + */ + public async getAuthClient(request: FastifyRequest, accessToken?: string | null): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + + const me = await this.getAuth(request, accessToken); + const client = this.getClient(request, accessToken); + + return { client, me }; + } + + /** + * Gets the authenticated client user for a request. + */ + public async getAuth(request: FastifyRequest, accessToken?: string | null): Promise { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + const [me] = await this.authenticateService.authenticate(accessToken); + return me; + } + + /** + * Creates an authenticated API client for a request. + */ + public getClient(request: FastifyRequest, accessToken?: string | null): MegalodonInterface { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + + // TODO pass agent? + const baseUrl = this.getBaseUrl(request); + const userAgent = request.headers['user-agent']; + return megalodon('misskey', baseUrl, accessToken, userAgent); + } + + /** + * Gets the base URL (origin) of the incoming request + */ + public getBaseUrl(request: FastifyRequest): string { + return `${request.protocol}://${request.host}`; + } +} + +/** + * Extracts the first access token from an authorization header + * Returns null if none were found. + */ +function getAccessToken(authorization: string | undefined): string | null { + const accessTokenArr = authorization?.split(' ') ?? [null]; + return accessTokenArr[accessTokenArr.length - 1]; +} diff --git a/packages/backend/src/server/api/mastodon/argsUtils.ts b/packages/backend/src/server/api/mastodon/argsUtils.ts new file mode 100644 index 0000000000..167d493ab6 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/argsUtils.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// Keys taken from: +// - https://docs.joinmastodon.org/methods/accounts/#statuses +// - https://docs.joinmastodon.org/methods/timelines/#public +// - https://docs.joinmastodon.org/methods/timelines/#tag +export interface TimelineArgs { + max_id?: string; + min_id?: string; + since_id?: string; + limit?: string; + offset?: string; + local?: string; + pinned?: string; + exclude_reblogs?: string; + exclude_replies?: string; + only_media?: string; +} + +// Values taken from https://docs.joinmastodon.org/client/intro/#boolean +export function toBoolean(value: string | undefined): boolean | undefined { + if (!value) return undefined; + return !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value); +} + +export function toInt(value: string | undefined): number | undefined { + if (!value) return undefined; + return parseInt(value); +} + +export function parseTimelineArgs(q: TimelineArgs) { + return { + max_id: q.max_id, + min_id: q.min_id, + since_id: q.since_id, + limit: typeof(q.limit) === 'string' ? parseInt(q.limit, 10) : undefined, + offset: typeof(q.offset) === 'string' ? parseInt(q.offset, 10) : undefined, + local: typeof(q.local) === 'string' ? toBoolean(q.local) : undefined, + pinned: typeof(q.pinned) === 'string' ? toBoolean(q.pinned) : undefined, + exclude_reblogs: typeof(q.exclude_reblogs) === 'string' ? toBoolean(q.exclude_reblogs) : undefined, + exclude_replies: typeof(q.exclude_replies) === 'string' ? toBoolean(q.exclude_replies) : undefined, + only_media: typeof(q.only_media) === 'string' ? toBoolean(q.only_media) : undefined, + }; +} diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index b6ff5bc59a..d1bd92b618 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -68,7 +68,6 @@ export class MastoConverters { private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention { let acct = u.username; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let acctUrl = `https://${u.host || this.config.host}/@${u.username}`; let url: string | null = null; if (u.host) { @@ -161,7 +160,6 @@ export class MastoConverters { }); const fqn = `${user.username}@${user.host ?? this.config.hostname}`; let acct = user.username; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let acctUrl = `https://${user.host || this.config.host}/@${user.username}`; const acctUri = `https://${this.config.host}/users/${user.id}`; if (user.host) { @@ -265,7 +263,6 @@ export class MastoConverters { }); // This must mirror the usual isQuote / isPureRenote logic used elsewhere. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId); const renote: Promise | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null; diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts deleted file mode 100644 index 085314059b..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ApiAuthMastodon } from './endpoints/auth.js'; -import { ApiAccountMastodon } from './endpoints/account.js'; -import { ApiSearchMastodon } from './endpoints/search.js'; -import { ApiNotifyMastodon } from './endpoints/notifications.js'; -import { ApiFilterMastodon } from './endpoints/filter.js'; -import { ApiTimelineMastodon } from './endpoints/timeline.js'; -import { ApiStatusMastodon } from './endpoints/status.js'; - -export { - ApiAccountMastodon, - ApiAuthMastodon, - ApiSearchMastodon, - ApiNotifyMastodon, - ApiFilterMastodon, - ApiTimelineMastodon, - ApiStatusMastodon, -}; diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 79cdddcb9e..a5d7d89f7d 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -3,14 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { MiLocalUser } from '@/models/User.js'; -import { MastoConverters, convertRelationship } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -export interface ApiAccountMastodonRoute { +import { Inject, Injectable } from '@nestjs/common'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; +import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; +import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js'; +import type multer from 'fastify-multer'; +import type { FastifyInstance } from 'fastify'; + +interface ApiAccountMastodonRoute { Params: { id?: string }, Querystring: TimelineArgs & { acct?: string }, Body: { notifications?: boolean } @@ -19,133 +23,375 @@ export interface ApiAccountMastodonRoute { @Injectable() export class ApiAccountMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, + @Inject(DI.userProfilesRepository) + private readonly userProfilesRepository: UserProfilesRepository, + + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, + + private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, + private readonly logger: MastodonLogger, + private readonly driveService: DriveService, ) {} - public async verifyCredentials() { - const data = await this.client.verifyAccountCredentials(); - const acct = await this.mastoConverters.convertAccount(data.data); - return Object.assign({}, acct, { - source: { - note: acct.note, - fields: acct.fields, - privacy: '', - sensitive: false, - language: '', + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { + try { + const client = await this.clientService.getClient(_request); + const data = await client.verifyAccountCredentials(); + const acct = await this.mastoConverters.convertAccount(data.data); + const response = Object.assign({}, acct, { + source: { + // TODO move these into the convertAccount logic directly + note: acct.note, + fields: acct.fields, + privacy: '', + sensitive: false, + language: '', + }, + }); + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/verify_credentials', data); + reply.code(401).send(data); + } + }); + + fastify.patch<{ + Body: { + discoverable?: string, + bot?: string, + display_name?: string, + note?: string, + avatar?: string, + header?: string, + locked?: string, + source?: { + privacy?: string, + sensitive?: string, + language?: string, + }, + fields_attributes?: { + name: string, + value: string, + }[], }, + }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { + const accessTokens = _request.headers.authorization; + try { + const client = this.clientService.getClient(_request); + // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. + if (_request.files.length > 0 && accessTokens) { + const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const avatar = (_request.files as any).find((obj: any) => { + return obj.fieldname === 'avatar'; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const header = (_request.files as any).find((obj: any) => { + return obj.fieldname === 'header'; + }); + + if (tokeninfo && avatar) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: avatar.path, + name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).avatar = upload.id; + } + } else if (tokeninfo && header) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: header.path, + name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).header = upload.id; + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((_request.body as any).fields_attributes) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fields = (_request.body as any).fields_attributes.map((field: any) => { + if (!(field.name.trim() === '' && field.value.trim() === '')) { + if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); + if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); + } + return { + ...field, + }; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); + } + + const options = { + ..._request.body, + discoverable: toBoolean(_request.body.discoverable), + bot: toBoolean(_request.body.bot), + locked: toBoolean(_request.body.locked), + source: _request.body.source ? { + ..._request.body.source, + sensitive: toBoolean(_request.body.source.sensitive), + } : undefined, + }; + const data = await client.updateCredentials(options); + reply.send(await this.mastoConverters.convertAccount(data.data)); + } catch (e) { + const data = getErrorData(e); + this.logger.error('PATCH /v1/accounts/update_credentials', data); + reply.code(401).send(data); + } }); - } - public async lookup() { - if (!this.request.query.acct) throw new Error('Missing required property "acct"'); - const data = await this.client.search(this.request.query.acct, { type: 'accounts' }); - return this.mastoConverters.convertAccount(data.data.accounts[0]); - } + fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { + try { + if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); - public async getRelationships(reqIds: string[]) { - const data = await this.client.getRelationships(reqIds); - return data.data.map(relationship => convertRelationship(relationship)); - } + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.acct, { type: 'accounts' }); + const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); + data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; + const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); - public async getStatuses() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountStatuses(this.request.params.id, parseTimelineArgs(this.request.query)); - return await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, this.me))); - } + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/lookup', data); + reply.code(401).send(data); + } + }); - public async getFollowers() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowers( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } + fastify.get('/v1/accounts/relationships', async (_request, reply) => { + try { + let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; + if (typeof ids === 'string') { + ids = [ids]; + } - public async getFollowing() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowing( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } + const client = this.clientService.getClient(_request); + const data = await client.getRelationships(ids); + const response = data.data.map(relationship => convertRelationship(relationship)); - public async addFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.followAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = true; - return acct; - } + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/relationships', data); + reply.code(401).send(data); + } + }); - public async rmFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unfollowAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = false; - return acct; - } + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - public async addBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.blockAccount(this.request.params.id); - return convertRelationship(data.data); - } + const client = this.clientService.getClient(_request); + const data = await client.getAccount(_request.params.id); + const account = await this.mastoConverters.convertAccount(data.data); - public async rmBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unblockAccount(this.request.params.id); - return convertRelationship(data.data); - } + reply.send(account); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); - public async addMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.muteAccount( - this.request.params.id, - this.request.body.notifications ?? true, - ); - return convertRelationship(data.data); - } + fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - public async rmMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unmuteAccount(this.request.params.id); - return convertRelationship(data.data); - } + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getAccountStatuses(_request.params.id, parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); - public async getBookmarks() { - const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); + reply.code(401).send(data); + } + }); - public async getFavourites() { - const data = await this.client.getFavourites(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - public async getMutes() { - const data = await this.client.getMutes(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } + const client = this.clientService.getClient(_request); + const data = await client.getFeaturedTags(); + const response = data.data.map((tag) => convertFeaturedTag(tag)); - public async getBlocks() { - const data = await this.client.getBlocks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); + reply.code(401).send(data); + } + }); - public async acceptFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.acceptFollowRequest(this.request.params.id); - return convertRelationship(data.data); - } + fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountFollowers( + _request.params.id, + parseTimelineArgs(_request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); + reply.code(401).send(data); + } + }); + + fastify.get('/v1/accounts/:id/following', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountFollowing( + _request.params.id, + parseTimelineArgs(_request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data); + reply.code(401).send(data); + } + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountLists(_request.params.id); + const response = data.data.map((list) => convertList(list)); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.followAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = true; + + reply.send(acct); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unfollowAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = false; + + reply.send(acct); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.blockAccount(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unblockAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.muteAccount( + _request.params.id, + _request.body.notifications ?? true, + ); + const response = convertRelationship(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - public async rejectFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.rejectFollowRequest(this.request.params.id); - return convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.unmuteAccount(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); + reply.code(401).send(data); + } + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts new file mode 100644 index 0000000000..17b9ba889d --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; +import type multer from 'fastify-multer'; + +const readScope = [ + 'read:account', + 'read:drive', + 'read:blocks', + 'read:favorites', + 'read:following', + 'read:messaging', + 'read:mutes', + 'read:notifications', + 'read:reactions', + 'read:pages', + 'read:page-likes', + 'read:user-groups', + 'read:channels', + 'read:gallery', + 'read:gallery-likes', +]; + +const writeScope = [ + 'write:account', + 'write:drive', + 'write:blocks', + 'write:favorites', + 'write:following', + 'write:messaging', + 'write:mutes', + 'write:notes', + 'write:notifications', + 'write:reactions', + 'write:votes', + 'write:pages', + 'write:page-likes', + 'write:user-groups', + 'write:channels', + 'write:gallery', + 'write:gallery-likes', +]; + +export interface AuthPayload { + scopes?: string | string[], + redirect_uris?: string, + client_name?: string, + website?: string, +} + +// Not entirely right, but it gets TypeScript to work so *shrug* +type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; + +@Injectable() +export class ApiAppsMastodon { + constructor( + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, + ) {} + + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + const body = _request.body ?? _request.query; + if (!body.scopes) return reply.code(400).send({ error: 'Missing required payload "scopes"' }); + if (!body.redirect_uris) return reply.code(400).send({ error: 'Missing required payload "redirect_uris"' }); + if (!body.client_name) return reply.code(400).send({ error: 'Missing required payload "client_name"' }); + + let scope = body.scopes; + if (typeof scope === 'string') { + scope = scope.split(/[ +]/g); + } + + const pushScope = new Set(); + for (const s of scope) { + if (s.match(/^read/)) { + for (const r of readScope) { + pushScope.add(r); + } + } + if (s.match(/^write/)) { + for (const r of writeScope) { + pushScope.add(r); + } + } + } + + const red = body.redirect_uris; + + const client = this.clientService.getClient(_request); + const appData = await client.registerApp(body.client_name, { + scopes: Array.from(pushScope), + redirect_uris: red, + website: body.website, + }); + + const response = { + id: Math.floor(Math.random() * 100).toString(), + name: appData.name, + website: body.website, + redirect_uri: red, + client_id: Buffer.from(appData.url || '').toString('base64'), + client_secret: appData.clientSecret, + }; + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/apps', data); + reply.code(401).send(data); + } + }); + } +} + diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts deleted file mode 100644 index b58cc902da..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -const readScope = [ - 'read:account', - 'read:drive', - 'read:blocks', - 'read:favorites', - 'read:following', - 'read:messaging', - 'read:mutes', - 'read:notifications', - 'read:reactions', - 'read:pages', - 'read:page-likes', - 'read:user-groups', - 'read:channels', - 'read:gallery', - 'read:gallery-likes', -]; - -const writeScope = [ - 'write:account', - 'write:drive', - 'write:blocks', - 'write:favorites', - 'write:following', - 'write:messaging', - 'write:mutes', - 'write:notes', - 'write:notifications', - 'write:reactions', - 'write:votes', - 'write:pages', - 'write:page-likes', - 'write:user-groups', - 'write:channels', - 'write:gallery', - 'write:gallery-likes', -]; - -export interface AuthPayload { - scopes?: string | string[], - redirect_uris?: string, - client_name?: string, - website?: string, -} - -// Not entirely right, but it gets TypeScript to work so *shrug* -export type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; - -export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { - const body = request.body ?? request.query; - if (!body.scopes) throw new Error('Missing required payload "scopes"'); - if (!body.redirect_uris) throw new Error('Missing required payload "redirect_uris"'); - if (!body.client_name) throw new Error('Missing required payload "client_name"'); - - let scope = body.scopes; - if (typeof scope === 'string') { - scope = scope.split(/[ +]/g); - } - - const pushScope = new Set(); - for (const s of scope) { - if (s.match(/^read/)) { - for (const r of readScope) { - pushScope.add(r); - } - } - if (s.match(/^write/)) { - for (const r of writeScope) { - pushScope.add(r); - } - } - } - - const red = body.redirect_uris; - const appData = await client.registerApp(body.client_name, { - scopes: Array.from(pushScope), - redirect_uris: red, - website: body.website, - }); - - return { - id: Math.floor(Math.random() * 100).toString(), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url || '').toString('base64'), // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - client_secret: appData.clientSecret, - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 382f0a8f1f..10353ff7af 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -3,12 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { toBoolean } from '@/server/api/mastodon/timelineArgs.js'; +import { Injectable } from '@nestjs/common'; +import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { convertFilter } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import type multer from 'fastify-multer'; -export interface ApiFilterMastodonRoute { +interface ApiFilterMastodonRoute { Params: { id?: string, }, @@ -21,55 +24,109 @@ export interface ApiFilterMastodonRoute { } } +@Injectable() export class ApiFilterMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, ) {} - public async getFilters() { - const data = await this.client.getFilters(); - return data.data.map((filter) => convertFilter(filter)); - } + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.get('/v1/filters', async (_request, reply) => { + try { + const client = this.clientService.getClient(_request); - public async getFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getFilter(this.request.params.id); - return convertFilter(data.data); - } + const data = await client.getFilters(); + const response = data.data.map((filter) => convertFilter(filter)); - public async createFilter() { - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.createFilter(this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/filters', data); + reply.code(401).send(data); + } + }); - public async updateFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.updateFilter(this.request.params.id, this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + fastify.get('/v1/filters/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getFilter(_request.params.id); + const response = convertFilter(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.createFilter(_request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/filters', data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); + + fastify.delete('/v1/filters/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteFilter(_request.params.id); - public async rmFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.deleteFilter(this.request.params.id); - return data.data; + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts new file mode 100644 index 0000000000..ffffb5e537 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; +import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; + +// TODO rename to ApiInstanceMastodon + +@Injectable() +export class ApiInstanceMastodon { + constructor( + @Inject(DI.meta) + private readonly meta: MiMeta, + + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + + @Inject(DI.config) + private readonly config: Config, + + private readonly mastoConverters: MastoConverters, + private readonly logger: MastodonLogger, + private readonly clientService: MastodonClientService, + ) {} + + public register(fastify: FastifyInstance): void { + fastify.get('/v1/instance', async (_request, reply) => { + try { + const client = this.clientService.getClient(_request); + const data = await client.getInstance(); + const instance = data.data; + const admin = await this.usersRepository.findOne({ + where: { + host: IsNull(), + isRoot: true, + isDeleted: false, + isSuspended: false, + }, + order: { id: 'ASC' }, + }); + const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); + + const response = { + uri: this.config.url, + title: this.meta.name || 'Sharkey', + short_description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + email: instance.email || '', + version: `3.0.0 (compatible; Sharkey ${this.config.version})`, + urls: instance.urls, + stats: { + user_count: instance.stats.user_count, + status_count: instance.stats.status_count, + domain_count: instance.stats.domain_count, + }, + thumbnail: this.meta.backgroundImageUrl || '/static-assets/transparent.png', + languages: this.meta.langs, + registrations: !this.meta.disableRegistration || instance.registrations, + approval_required: this.meta.approvalRequiredForSignup, + invites_enabled: instance.registrations, + configuration: { + accounts: { + max_featured_tags: 20, + }, + statuses: { + max_characters: this.config.maxNoteLength, + max_media_attachments: 16, + characters_reserved_per_url: instance.uri.length, + }, + media_attachments: { + supported_mime_types: FILE_TYPE_BROWSERSAFE, + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_rate_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 10, + max_characters_per_option: 150, + min_expiration: 50, + max_expiration: 2629746, + }, + reactions: { + max_reactions: 1, + }, + }, + contact_account: contact, + rules: [], + }; + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/instance', data); + reply.code(401).send(data); + } + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts deleted file mode 100644 index 48a56138cf..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity } from 'megalodon'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import type { Config } from '@/config.js'; -import type { MiMeta } from '@/models/Meta.js'; - -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -export async function getInstance( - response: Entity.Instance, - contact: Entity.Account, - config: Config, - meta: MiMeta, -) { - return { - uri: config.url, - title: meta.name || 'Sharkey', - short_description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - email: response.email || '', - version: `3.0.0 (compatible; Sharkey ${config.version})`, - urls: response.urls, - stats: { - user_count: response.stats.user_count, - status_count: response.stats.status_count, - domain_count: response.stats.domain_count, - }, - thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png', - languages: meta.langs, - registrations: !meta.disableRegistration || response.registrations, - approval_required: meta.approvalRequiredForSignup, - invites_enabled: response.registrations, - configuration: { - accounts: { - max_featured_tags: 20, - }, - statuses: { - max_characters: config.maxNoteLength, - max_media_attachments: 16, - characters_reserved_per_url: response.uri.length, - }, - media_attachments: { - supported_mime_types: FILE_TYPE_BROWSERSAFE, - image_size_limit: 10485760, - image_matrix_limit: 16777216, - video_size_limit: 41943040, - video_frame_rate_limit: 60, - video_matrix_limit: 2304000, - }, - polls: { - max_options: 10, - max_characters_per_option: 150, - min_expiration: 50, - max_expiration: 2629746, - }, - reactions: { - max_reactions: 1, - }, - }, - contact_account: contact, - rules: [], - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 14eee8565a..0dba247e5f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -3,56 +3,95 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { MiLocalUser } from '@/models/User.js'; +import { Injectable } from '@nestjs/common'; +import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '../MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; +import type multer from 'fastify-multer'; -export interface ApiNotifyMastodonRoute { +interface ApiNotifyMastodonRoute { Params: { id?: string, }, Querystring: TimelineArgs, } -export class ApiNotifyMastodon { +@Injectable() +export class ApiNotificationsMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, private readonly mastoConverters: MastoConverters, + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, ) {} - public async getNotifications() { - const data = await this.client.getNotifications(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map(async n => { - const converted = await this.mastoConverters.convertNotification(n, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.get('/v1/notifications', async (_request, reply) => { + try { + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotifications(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map(async n => { + const converted = await this.mastoConverters.convertNotification(n, me); + if (converted.type === 'reaction') { + converted.type = 'favourite'; + } + return converted; + })); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/notifications', data); + reply.code(401).send(data); } - return converted; - })); - } + }); - public async getNotification() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getNotification(this.request.params.id); - const converted = await this.mastoConverters.convertNotification(data.data, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; - } - return converted; - } + fastify.get('/v1/notification/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - public async rmNotification() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.dismissNotification(this.request.params.id); - return data.data; - } + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotification(_request.params.id); + const converted = await this.mastoConverters.convertNotification(data.data, me); + if (converted.type === 'reaction') { + converted.type = 'favourite'; + } + + reply.send(converted); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/notification/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); - public async rmNotifications() { - const data = await this.client.dismissNotifications(); - return data.data; + fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.dismissNotification(_request.params.id); + + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + const client = this.clientService.getClient(_request); + const data = await client.dismissNotifications(); + + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/notifications/clear', data); + reply.code(401).send(data); + } + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 4850b4652f..3b1c984c3e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -3,92 +3,128 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { MiLocalUser } from '@/models/User.js'; +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastoConverters } from '../converters.js'; -import { parseTimelineArgs, TimelineArgs } from '../timelineArgs.js'; +import { parseTimelineArgs, TimelineArgs } from '../argsUtils.js'; import Account = Entity.Account; import Status = Entity.Status; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import type { FastifyInstance } from 'fastify'; -export interface ApiSearchMastodonRoute { +interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { type?: 'accounts' | 'hashtags' | 'statuses'; q?: string; } } +@Injectable() export class ApiSearchMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly BASE_URL: string, private readonly mastoConverters: MastoConverters, + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, ) {} - public async SearchV1() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const data = await this.client.search(this.request.query.q, { type: this.request.query.type, ...query }); - return data.data; - } + public register(fastify: FastifyInstance): void { + fastify.get('/v1/search', async (_request, reply) => { + try { + if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); - public async SearchV2() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const type = this.request.query.type; - const acct = !type || type === 'accounts' ? await this.client.search(this.request.query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await this.client.search(this.request.query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await this.client.search(this.request.query.q, { type: 'hashtags', ...query }) : null; - return { - accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, this.me)) ?? []), - hashtags: tags?.data.hashtags ?? [], - }; - } + const query = parseTimelineArgs(_request.query); + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); - public async getStatusTrends() { - const data = await fetch(`${this.BASE_URL}/api/notes/featured`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - i: this.request.headers.authorization?.replace('Bearer ', ''), - }), - }) - .then(res => res.json() as Promise) - .then(data => data.map(status => this.mastoConverters.convertStatus(status, this.me))); - return Promise.all(data); - } + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/search', data); + reply.code(401).send(data); + } + }); + + fastify.get('/v2/search', async (_request, reply) => { + try { + if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + + const query = parseTimelineArgs(_request.query); + const type = _request.query.type; + const { client, me } = await this.clientService.getAuthClient(_request); + const acct = !type || type === 'accounts' ? await client.search(_request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await client.search(_request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await client.search(_request.query.q, { type: 'hashtags', ...query }) : null; + const response = { + accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/search', data); + reply.code(401).send(data); + } + }); + + fastify.get('/v1/trends/statuses', async (_request, reply) => { + try { + const baseUrl = this.clientService.getBaseUrl(_request); + const res = await fetch(`${baseUrl}/api/notes/featured`, + { + method: 'POST', + headers: { + ..._request.headers as HeadersInit, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: '{}', + }); + const data = await res.json() as Status[]; + const me = await this.clientService.getAuth(_request); + const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends/statuses', data); + reply.code(401).send(data); + } + }); + + fastify.get('/v2/suggestions', async (_request, reply) => { + try { + const baseUrl = this.clientService.getBaseUrl(_request); + const res = await fetch(`${baseUrl}/api/users`, + { + method: 'POST', + headers: { + ..._request.headers as HeadersInit, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + limit: parseTimelineArgs(_request.query).limit ?? 20, + origin: 'local', + sort: '+follower', + state: 'alive', + }), + }); + const data = await res.json() as Account[]; + const response = await Promise.all(data.map(async entry => { + return { + source: 'global', + account: await this.mastoConverters.convertAccount(entry), + }; + })); - public async getSuggestions() { - const data = await fetch(`${this.BASE_URL}/api/users`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - i: this.request.headers.authorization?.replace('Bearer ', ''), - limit: parseTimelineArgs(this.request.query).limit ?? 20, - origin: 'local', - sort: '+follower', - state: 'alive', - }), - }) - .then(res => res.json() as Promise) - .then(data => data.map((entry => ({ - source: 'global', - account: entry, - })))); - return Promise.all(data.map(async suggestion => { - suggestion.account = await this.mastoConverters.convertAccount(suggestion.account); - return suggestion; - })); + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/suggestions', data); + reply.code(401).send(data); + } + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 4c49a6a293..ba61918b75 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -4,12 +4,12 @@ */ import querystring, { ParsedUrlQueryInput } from 'querystring'; +import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/timelineArgs.js'; -import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; -import { getAccessToken, getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -18,38 +18,38 @@ function normalizeQuery(data: Record) { return querystring.parse(str); } +@Injectable() export class ApiStatusMastodon { constructor( - private readonly fastify: FastifyInstance, private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, - private readonly authenticateService: AuthenticateService, - private readonly mastodon: MastodonApiServerService, + private readonly clientService: MastodonClientService, ) {} - public getStatus() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { + public register(fastify: FastifyInstance): void { + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}`, data); reply.code(_request.is404 ? 404 : 401).send(data); } }); - } - public getStatusSource() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getStatusSource(_request.params.id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); @@ -57,31 +57,32 @@ export class ApiStatusMastodon { reply.code(_request.is404 ? 404 : 401).send(data); } }); - } - public getContext() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me))); const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me))); - reply.send({ ancestors, descendants }); + const response = { ancestors, descendants }; + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/context`, data); reply.code(_request.is404 ? 404 : 401).send(data); } }); - } - public getHistory() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const [user] = await this.authenticateService.authenticate(getAccessToken(_request.headers.authorization)); + + const user = await this.clientService.getAuth(_request); const edits = await this.mastoConverters.getEdits(_request.params.id, user); + reply.send(edits); } catch (e) { const data = getErrorData(e); @@ -89,96 +90,89 @@ export class ApiStatusMastodon { reply.code(401).send(data); } }); - } - public getReblogged() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getStatusRebloggedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/reblogged_by`, data); reply.code(401).send(data); } }); - } - public getFavourites() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getStatusFavouritedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/favourited_by`, data); reply.code(401).send(data); } }); - } - public getMedia() { - this.fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getMedia(_request.params.id); - reply.send(convertAttachment(data.data)); + const response = convertAttachment(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/media/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public getPoll() { - this.fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getPoll(_request.params.id); - reply.send(convertPoll(data.data)); + const response = convertPoll(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/polls/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public votePoll() { - this.fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); + + const client = this.clientService.getClient(_request); const data = await client.votePoll(_request.params.id, _request.body.choices); - reply.send(convertPoll(data.data)); + const response = convertPoll(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data); reply.code(401).send(data); } }); - } - public postStatus() { - this.fastify.post<{ + fastify.post<{ Body: { media_ids?: string[], poll?: { @@ -203,7 +197,7 @@ export class ApiStatusMastodon { }>('/v1/statuses', async (_request, reply) => { let body = _request.body; try { - const { client, me } = await this.mastodon.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) ) { body = normalizeQuery(body); @@ -248,17 +242,17 @@ export class ApiStatusMastodon { }; const data = await client.postStatus(text, options); - reply.send(await this.mastoConverters.convertStatus(data.data as Entity.Status, me)); + const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('POST /v1/statuses', data); reply.code(401).send(data); } }); - } - public updateStatus() { - this.fastify.put<{ + fastify.put<{ Params: { id: string }, Body: { status?: string, @@ -274,7 +268,7 @@ export class ApiStatusMastodon { } }>('/v1/statuses/:id', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); const body = _request.body; if (!body.media_ids || !body.media_ids.length) { @@ -293,175 +287,184 @@ export class ApiStatusMastodon { }; const data = await client.editStatus(_request.params.id, options); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public addFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/favorite`, data); reply.code(401).send(data); } }); - } - public rmFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data); reply.code(401).send(data); } }); - } - public reblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.reblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data); reply.code(401).send(data); } }); - } - public unreblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unreblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data); reply.code(401).send(data); } }); - } - public bookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.bookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data); reply.code(401).send(data); } }); - } - public unbookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unbookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data); reply.code(401).send(data); } }); - } - - public pinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.pinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data); reply.code(401).send(data); } }); - } - public unpinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unpinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data); reply.code(401).send(data); } }); - } - public reactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data); reply.code(401).send(data); } }); - } - public unreactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data); reply.code(401).send(data); } }); - } - public deleteStatus() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.deleteStatus(_request.params.id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 1a732d62de..864fdc7691 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -3,87 +3,97 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { Injectable } from '@nestjs/common'; import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertList, MastoConverters } from '../converters.js'; -import { getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean } from '../timelineArgs.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; +@Injectable() export class ApiTimelineMastodon { constructor( - private readonly fastify: FastifyInstance, + private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, - private readonly mastodon: MastodonApiServerService, ) {} - public getTL() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { + public register(fastify: FastifyInstance): void { + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); + + const query = parseTimelineArgs(_request.query); const data = toBoolean(_request.query.local) - ? await client.getLocalTimeline(parseTimelineArgs(_request.query)) - : await client.getPublicTimeline(parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + ? await client.getLocalTimeline(query) + : await client.getPublicTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/timelines/public', data); reply.code(401).send(data); } }); - } - public getHomeTl() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getHomeTimeline(parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getHomeTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/timelines/home', data); reply.code(401).send(data); } }); - } - public getTagTl() { - this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { + fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { try { if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getTagTimeline(_request.params.hashtag, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getTagTimeline(_request.params.hashtag, query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data); reply.code(401).send(data); } }); - } - public getListTL() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getListTimeline(_request.params.id, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getListTimeline(_request.params.id, query); + const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public getConversations() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getConversationTimeline(parseTimelineArgs(_request.query)); - const conversations = await Promise.all(data.data.map(async (conversation: Entity.Conversation) => await this.mastoConverters.convertConversation(conversation, me))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getConversationTimeline(query); + const conversations = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); + reply.send(conversations); } catch (e) { const data = getErrorData(e); @@ -91,50 +101,45 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public getList() { - this.fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.getList(_request.params.id); - reply.send(convertList(data.data)); + const response = convertList(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/lists/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public getLists() { - this.fastify.get('/v1/lists', async (_request, reply) => { + fastify.get('/v1/lists', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + const client = this.clientService.getClient(_request); const data = await client.getLists(); - reply.send(data.data.map((list: Entity.List) => convertList(list))); + const response = data.data.map((list: Entity.List) => convertList(list)); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/lists', data); reply.code(401).send(data); } }); - } - public getListAccounts() { - this.fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.getAccountsInList(_request.params.id, _request.query); const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + reply.send(accounts); } catch (e) { const data = getErrorData(e); @@ -142,17 +147,15 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public addListAccount() { - this.fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); @@ -160,17 +163,15 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public rmListAccount() { - this.fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); @@ -178,51 +179,47 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public createList() { - this.fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { + fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { try { if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.createList(_request.body.title); - reply.send(convertList(data.data)); + const response = convertList(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('POST /v1/lists', data); reply.code(401).send(data); } }); - } - public updateList() { - this.fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { + fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.updateList(_request.params.id, _request.body.title); - reply.send(convertList(data.data)); + const response = convertList(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`PUT /v1/lists/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public deleteList() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); await client.deleteList(_request.params.id); + reply.send({}); } catch (e) { const data = getErrorData(e); diff --git a/packages/backend/src/server/api/mastodon/timelineArgs.ts b/packages/backend/src/server/api/mastodon/timelineArgs.ts deleted file mode 100644 index 167d493ab6..0000000000 --- a/packages/backend/src/server/api/mastodon/timelineArgs.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// Keys taken from: -// - https://docs.joinmastodon.org/methods/accounts/#statuses -// - https://docs.joinmastodon.org/methods/timelines/#public -// - https://docs.joinmastodon.org/methods/timelines/#tag -export interface TimelineArgs { - max_id?: string; - min_id?: string; - since_id?: string; - limit?: string; - offset?: string; - local?: string; - pinned?: string; - exclude_reblogs?: string; - exclude_replies?: string; - only_media?: string; -} - -// Values taken from https://docs.joinmastodon.org/client/intro/#boolean -export function toBoolean(value: string | undefined): boolean | undefined { - if (!value) return undefined; - return !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value); -} - -export function toInt(value: string | undefined): number | undefined { - if (!value) return undefined; - return parseInt(value); -} - -export function parseTimelineArgs(q: TimelineArgs) { - return { - max_id: q.max_id, - min_id: q.min_id, - since_id: q.since_id, - limit: typeof(q.limit) === 'string' ? parseInt(q.limit, 10) : undefined, - offset: typeof(q.offset) === 'string' ? parseInt(q.offset, 10) : undefined, - local: typeof(q.local) === 'string' ? toBoolean(q.local) : undefined, - pinned: typeof(q.pinned) === 'string' ? toBoolean(q.pinned) : undefined, - exclude_reblogs: typeof(q.exclude_reblogs) === 'string' ? toBoolean(q.exclude_reblogs) : undefined, - exclude_replies: typeof(q.exclude_replies) === 'string' ? toBoolean(q.exclude_replies) : undefined, - only_media: typeof(q.only_media) === 'string' ? toBoolean(q.only_media) : undefined, - }; -} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 6598aa9891..86d903f223 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -5,7 +5,6 @@ import querystring from 'querystring'; import { Inject, Injectable } from '@nestjs/common'; -import megalodon, { MegalodonInterface } from 'megalodon'; import { v4 as uuid } from 'uuid'; /* import { kinds } from '@/misc/api-permissions.js'; import type { Config } from '@/config.js'; @@ -14,6 +13,8 @@ import multer from 'fastify-multer'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { getErrorData } from '@/server/api/mastodon/MastodonLogger.js'; import type { FastifyInstance } from 'fastify'; const kinds = [ @@ -51,19 +52,13 @@ const kinds = [ 'write:gallery-likes', ]; -function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessTokenArr = authorization?.split(' ') ?? [null]; - const accessToken = accessTokenArr[accessTokenArr.length - 1]; - const generator = (megalodon as any).default; - const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; - return client; -} - @Injectable() export class OAuth2ProviderService { constructor( @Inject(DI.config) private config: Config, + + private readonly mastodonClientService: MastodonClientService, ) { } // https://datatracker.ietf.org/doc/html/rfc8414.html @@ -122,8 +117,8 @@ export class OAuth2ProviderService { try { const parsed = querystring.parse(body); done(null, parsed); - } catch (e: any) { - done(e); + } catch (e: unknown) { + done(e instanceof Error ? e : new Error(String(e))); } }); payload.on('error', done); @@ -131,74 +126,53 @@ export class OAuth2ProviderService { fastify.register(multer.contentParser); - fastify.get('/authorize', async (request, reply) => { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - }); + for (const url of ['/authorize', '/authorize/']) { + fastify.get<{ Querystring: Record }>(url, async (request, reply) => { + if (typeof(request.query.client_id) !== 'string') return reply.code(400).send({ error: 'Missing required query "client_id"' }); - fastify.get('/authorize/', async (request, reply) => { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - }); + const redirectUri = new URL(Buffer.from(request.query.client_id, 'base64').toString()); + redirectUri.searchParams.set('mastodon', 'true'); + if (request.query.state) redirectUri.searchParams.set('state', String(request.query.state)); + if (request.query.redirect_uri) redirectUri.searchParams.set('redirect_uri', String(request.query.redirect_uri)); + + reply.redirect(redirectUri.toString()); + }); + } - fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => { - const body: any = request.body || request.query; - if (body.grant_type === "client_credentials") { + fastify.post<{ Body?: Record, Querystring: Record }>('/token', { preHandler: upload.none() }, async (request, reply) => { + const body = request.body ?? request.query; + + if (body.grant_type === 'client_credentials') { const ret = { access_token: uuid(), - token_type: "Bearer", - scope: "read", + token_type: 'Bearer', + scope: 'read', created_at: Math.floor(new Date().getTime() / 1000), }; reply.send(ret); } - let client_id: any = body.client_id; - const BASE_URL = `${request.protocol}://${request.hostname}`; - const client = getClient(BASE_URL, ''); - let token = null; - if (body.code) { - //m = body.code.match(/^([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/); - //if (!m.length) { - // ctx.body = { error: "Invalid code" }; - // return; - //} - //token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` - //console.log(body.code, token); - token = body.code; - } - if (client_id instanceof Array) { - client_id = client_id.toString(); - } else if (!client_id) { - client_id = null; - } + try { - const atData = await client.fetchAccessToken( - client_id, - body.client_secret, - token ? token : "", - ); + if (!body.client_secret) return reply.code(400).send({ error: 'Missing required query "client_secret"' }); + + const clientId = body.client_id ? String(body.clientId) : null; + const secret = String(body.client_secret); + const code = body.code ? String(body.code) : ''; + + // TODO fetch the access token directly + const client = this.mastodonClientService.getClient(request); + const atData = await client.fetchAccessToken(clientId, secret, code); + const ret = { access_token: atData.accessToken, - token_type: "Bearer", - scope: body.scope || "read write follow push", + token_type: 'Bearer', + scope: body.scope || 'read write follow push', created_at: Math.floor(new Date().getTime() / 1000), }; reply.send(ret); - } catch (err: any) { - /* console.error(err); */ - reply.code(401).send(err.response.data); + } catch (e: unknown) { + const data = getErrorData(e); + reply.code(401).send(data); } }); } -- cgit v1.2.3-freya From a81a00e94dfdf85348ce8f2d843675c93ab9f2f2 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 11:03:20 -0400 Subject: rename MastodonConverters.ts to matching naming scheme --- packages/backend/src/server/ServerModule.ts | 4 +- .../api/mastodon/MastodonApiServerService.ts | 4 +- .../src/server/api/mastodon/MastodonConverters.ts | 435 +++++++++++++++++++++ .../backend/src/server/api/mastodon/converters.ts | 435 --------------------- .../src/server/api/mastodon/endpoints/account.ts | 4 +- .../src/server/api/mastodon/endpoints/filter.ts | 2 +- .../src/server/api/mastodon/endpoints/instance.ts | 4 +- .../server/api/mastodon/endpoints/notifications.ts | 4 +- .../src/server/api/mastodon/endpoints/search.ts | 4 +- .../src/server/api/mastodon/endpoints/status.ts | 4 +- .../src/server/api/mastodon/endpoints/timeline.ts | 4 +- 11 files changed, 452 insertions(+), 452 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/MastodonConverters.ts delete mode 100644 packages/backend/src/server/api/mastodon/converters.ts (limited to 'packages/backend/src/server/ServerModule.ts') diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 5af41ddd9f..d217c49fa2 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -35,7 +35,7 @@ import { SignupApiService } from './api/SignupApiService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; -import { MastoConverters } from './api/mastodon/converters.js'; +import { MastodonConverters } from './api/mastodon/MastodonConverters.js'; import { MastodonLogger } from './api/mastodon/MastodonLogger.js'; import { MastodonDataService } from './api/mastodon/MastodonDataService.js'; import { FeedService } from './web/FeedService.js'; @@ -113,7 +113,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j OpenApiServerService, MastodonApiServerService, OAuth2ProviderService, - MastoConverters, + MastodonConverters, MastodonLogger, MastodonDataService, MastodonClientService, diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index d7afc1254e..b289ad7135 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -21,7 +21,7 @@ import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; import { ApiError } from '@/server/api/error.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; -import { convertAnnouncement, convertAttachment, MastoConverters, convertRelationship } from './converters.js'; +import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @@ -31,7 +31,7 @@ export class MastodonApiServerService { @Inject(DI.config) private readonly config: Config, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly logger: MastodonLogger, private readonly clientService: MastodonClientService, private readonly apiAccountMastodon: ApiAccountMastodon, diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts new file mode 100644 index 0000000000..11ddcd23da --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -0,0 +1,435 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Entity } from 'megalodon'; +import mfm from '@transfem-org/sfm-js'; +import { DI } from '@/di-symbols.js'; +import { MfmService } from '@/core/MfmService.js'; +import type { Config } from '@/config.js'; +import { IMentionedRemoteUsers, MiNote } from '@/models/Note.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; +import type { NoteEditRepository, UserProfilesRepository } from '@/models/_.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js'; +import { GetterService } from '@/server/api/GetterService.js'; + +// Missing from Megalodon apparently +// https://docs.joinmastodon.org/entities/StatusEdit/ +export interface StatusEdit { + content: string; + spoiler_text: string; + sensitive: boolean; + created_at: string; + account: MastodonEntity.Account; + poll?: { + options: { + title: string; + }[] + }, + media_attachments: MastodonEntity.Attachment[], + emojis: MastodonEntity.Emoji[], +} + +export const escapeMFM = (text: string): string => text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/`/g, '`') + .replace(/\r?\n/g, '
'); + +@Injectable() +export class MastodonConverters { + constructor( + @Inject(DI.config) + private readonly config: Config, + + @Inject(DI.userProfilesRepository) + private readonly userProfilesRepository: UserProfilesRepository, + + @Inject(DI.noteEditRepository) + private readonly noteEditRepository: NoteEditRepository, + + private readonly mfmService: MfmService, + private readonly getterService: GetterService, + private readonly customEmojiService: CustomEmojiService, + private readonly idService: IdService, + private readonly driveFileEntityService: DriveFileEntityService, + private readonly mastodonDataService: MastodonDataService, + ) {} + + private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention { + let acct = u.username; + let acctUrl = `https://${u.host || this.config.host}/@${u.username}`; + let url: string | null = null; + if (u.host) { + const info = m.find(r => r.username === u.username && r.host === u.host); + acct = `${u.username}@${u.host}`; + acctUrl = `https://${u.host}/@${u.username}`; + if (info) url = info.url ?? info.uri; + } + return { + id: u.id, + username: u.username, + acct: acct, + url: url ?? acctUrl, + }; + } + + public fileType(s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' { + if (s === 'image/gif') { + return 'gifv'; + } + if (s.includes('image')) { + return 'image'; + } + if (s.includes('video')) { + return 'video'; + } + if (s.includes('audio')) { + return 'audio'; + } + return 'unknown'; + } + + public encodeFile(f: Packed<'DriveFile'>): MastodonEntity.Attachment { + const { width, height } = f.properties; + const size = (width && height) ? `${width}x${height}` : undefined; + const aspect = (width && height) ? (width / height) : undefined; + + return { + id: f.id, + type: this.fileType(f.type), + url: f.url, + remote_url: f.url, + preview_url: f.thumbnailUrl, + text_url: f.url, + meta: { + original: { + width, + height, + size, + aspect, + }, + width, + height, + size, + aspect, + }, + description: f.comment ?? null, + blurhash: f.blurhash ?? null, + }; + } + + public async getUser(id: string): Promise { + return this.getterService.getUser(id).then(p => { + return p; + }); + } + + private encodeField(f: Entity.Field): MastodonEntity.Field { + return { + name: f.name, + value: this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), + verified_at: null, + }; + } + + public async convertAccount(account: Entity.Account | MiUser): Promise { + const user = await this.getUser(account.id); + const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); + const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host); + const emoji: Entity.Emoji[] = []; + Object.entries(emojis).forEach(entry => { + const [key, value] = entry; + emoji.push({ + shortcode: key, + static_url: value, + url: value, + visible_in_picker: true, + category: undefined, + }); + }); + const fqn = `${user.username}@${user.host ?? this.config.hostname}`; + let acct = user.username; + let acctUrl = `https://${user.host || this.config.host}/@${user.username}`; + const acctUri = `https://${this.config.host}/users/${user.id}`; + if (user.host) { + acct = `${user.username}@${user.host}`; + acctUrl = `https://${user.host}/@${user.username}`; + } + return awaitAll({ + id: account.id, + username: user.username, + acct: acct, + fqn: fqn, + display_name: user.name ?? user.username, + locked: user.isLocked, + created_at: this.idService.parse(user.id).date.toISOString(), + followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0, + following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0, + statuses_count: user.notesCount, + note: profile?.description ?? '', + url: user.uri ?? acctUrl, + uri: user.uri ?? acctUri, + avatar: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', + avatar_static: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', + header: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png', + header_static: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png', + emojis: emoji, + moved: null, //FIXME + fields: profile?.fields.map(p => this.encodeField(p)) ?? [], + bot: user.isBot, + discoverable: user.isExplorable, + noindex: user.noindex, + group: null, + suspended: user.isSuspended, + limited: user.isSilenced, + }); + } + + public async getEdits(id: string, me: MiLocalUser | null): Promise { + const note = await this.mastodonDataService.getNote(id, me); + if (!note) { + return []; + } + const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); + const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); + const history: StatusEdit[] = []; + + // TODO this looks wrong, according to mastodon docs + let lastDate = this.idService.parse(note.id).date; + for (const edit of edits) { + const files = await this.driveFileEntityService.packManyByIds(edit.fileIds); + const item = { + account: noteUser, + content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', + created_at: lastDate.toISOString(), + emojis: [], //FIXME + sensitive: edit.cw != null && edit.cw.length > 0, + spoiler_text: edit.cw ?? '', + media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [], + }; + lastDate = edit.updatedAt; + history.push(item); + } + + return history; + } + + private async convertReblog(status: Entity.Status | null, me: MiLocalUser | null): Promise { + if (!status) return null; + return await this.convertStatus(status, me); + } + + public async convertStatus(status: Entity.Status, me: MiLocalUser | null): Promise { + const convertedAccount = this.convertAccount(status.account); + const note = await this.mastodonDataService.requireNote(status.id, me); + const noteUser = await this.getUser(status.account.id); + const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers); + + const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host); + const emoji: Entity.Emoji[] = []; + Object.entries(emojis).forEach(entry => { + const [key, value] = entry; + emoji.push({ + shortcode: key, + static_url: value, + url: value, + visible_in_picker: true, + category: undefined, + }); + }); + + const mentions = Promise.all(note.mentions.map(p => + this.getUser(p) + .then(u => this.encode(u, mentionedRemoteUsers)) + .catch(() => null))) + .then(p => p.filter(m => m)) as Promise; + + const tags = note.tags.map(tag => { + return { + name: tag, + url: `${this.config.url}/tags/${tag}`, + } as Entity.Tag; + }); + + // This must mirror the usual isQuote / isPureRenote logic used elsewhere. + const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId); + + const renote: Promise | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null; + + const quoteUri = Promise.resolve(renote).then(renote => { + if (!renote || !isQuote) return null; + return renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`; + }); + + const text = note.text; + const content = text !== null + ? quoteUri + .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri) ?? escapeMFM(text)) + : ''; + + const reblogged = await this.mastodonDataService.hasReblog(note.id, me); + + // noinspection ES6MissingAwait + return await awaitAll({ + id: note.id, + uri: note.uri ?? `https://${this.config.host}/notes/${note.id}`, + url: note.url ?? note.uri ?? `https://${this.config.host}/notes/${note.id}`, + account: convertedAccount, + in_reply_to_id: note.replyId, + in_reply_to_account_id: note.replyUserId, + reblog: !isQuote ? this.convertReblog(status.reblog, me) : null, + content: content, + content_type: 'text/x.misskeymarkdown', + text: note.text, + created_at: status.created_at, + edited_at: note.updatedAt?.toISOString() ?? null, + emojis: emoji, + replies_count: note.repliesCount, + reblogs_count: note.renoteCount, + favourites_count: status.favourites_count, + reblogged, + favourited: status.favourited, + muted: status.muted, + sensitive: status.sensitive || !!note.cw, + spoiler_text: note.cw ?? '', + visibility: status.visibility, + media_attachments: status.media_attachments.map(a => convertAttachment(a)), + mentions: mentions, + tags: tags, + card: null, //FIXME + poll: status.poll ?? null, + application: null, //FIXME + language: null, //FIXME + pinned: false, //FIXME + bookmarked: false, //FIXME + quote_id: isQuote ? status.reblog?.id : undefined, + quote: isQuote ? this.convertReblog(status.reblog, me) : null, + reactions: status.emoji_reactions, + }); + } + + public async convertConversation(conversation: Entity.Conversation, me: MiLocalUser | null): Promise { + return { + id: conversation.id, + accounts: await Promise.all(conversation.accounts.map(a => this.convertAccount(a))), + last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null, + unread: conversation.unread, + }; + } + + public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { + return { + account: await this.convertAccount(notification.account), + created_at: notification.created_at, + id: notification.id, + status: notification.status ? await this.convertStatus(notification.status, me) : undefined, + type: notification.type, + }; + } + + // public convertEmoji(emoji: string): MastodonEntity.Emoji { + // const reaction: MastodonEntity.Reaction = { + // name: emoji, + // count: 1, + // }; + // + // if (emoji.startsWith(':')) { + // const [, name] = emoji.match(/^:([^@:]+(?:@[^@:]+)?):$/) ?? []; + // if (name) { + // const url = `${this.config.url}/emoji/${name}.webp`; + // reaction.url = url; + // reaction.static_url = url; + // } + // } + // + // return reaction; + // } +} + +function simpleConvert(data: T): T { + // copy the object to bypass weird pass by reference bugs + return Object.assign({}, data); +} + +export function convertAnnouncement(announcement: Entity.Announcement): MastodonEntity.Announcement { + return { + ...announcement, + updated_at: announcement.updated_at ?? announcement.published_at, + }; +} + +export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment { + const { width, height } = attachment.meta?.original ?? attachment.meta ?? {}; + const size = (width && height) ? `${width}x${height}` : undefined; + const aspect = (width && height) ? (width / height) : undefined; + return { + ...attachment, + meta: attachment.meta ? { + ...attachment.meta, + original: { + ...attachment.meta.original, + width, + height, + size, + aspect, + frame_rate: String(attachment.meta.fps), + duration: attachment.meta.duration, + bitrate: attachment.meta.audio_bitrate ? parseInt(attachment.meta.audio_bitrate) : undefined, + }, + width, + height, + size, + aspect, + } : null, + }; +} +export function convertFilter(filter: Entity.Filter): MastodonEntity.Filter { + return simpleConvert(filter); +} +export function convertList(list: Entity.List): MastodonEntity.List { + return { + id: list.id, + title: list.title, + replies_policy: list.replies_policy ?? 'followed', + }; +} +export function convertFeaturedTag(tag: Entity.FeaturedTag): MastodonEntity.FeaturedTag { + return simpleConvert(tag); +} + +export function convertPoll(poll: Entity.Poll): MastodonEntity.Poll { + return simpleConvert(poll); +} + +// Megalodon sometimes returns broken / stubbed relationship data +export function convertRelationship(relationship: Partial & { id: string }): MastodonEntity.Relationship { + return { + id: relationship.id, + following: relationship.following ?? false, + showing_reblogs: relationship.showing_reblogs ?? true, + notifying: relationship.notifying ?? true, + languages: [], + followed_by: relationship.followed_by ?? false, + blocking: relationship.blocking ?? false, + blocked_by: relationship.blocked_by ?? false, + muting: relationship.muting ?? false, + muting_notifications: relationship.muting_notifications ?? false, + requested: relationship.requested ?? false, + requested_by: relationship.requested_by ?? false, + domain_blocking: relationship.domain_blocking ?? false, + endorsed: relationship.endorsed ?? false, + note: relationship.note ?? '', + }; +} + diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts deleted file mode 100644 index 1adbd95642..0000000000 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ /dev/null @@ -1,435 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Entity } from 'megalodon'; -import mfm from '@transfem-org/sfm-js'; -import { DI } from '@/di-symbols.js'; -import { MfmService } from '@/core/MfmService.js'; -import type { Config } from '@/config.js'; -import { IMentionedRemoteUsers, MiNote } from '@/models/Note.js'; -import type { MiLocalUser, MiUser } from '@/models/User.js'; -import type { NoteEditRepository, UserProfilesRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { IdService } from '@/core/IdService.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js'; -import { GetterService } from '@/server/api/GetterService.js'; - -// Missing from Megalodon apparently -// https://docs.joinmastodon.org/entities/StatusEdit/ -export interface StatusEdit { - content: string; - spoiler_text: string; - sensitive: boolean; - created_at: string; - account: MastodonEntity.Account; - poll?: { - options: { - title: string; - }[] - }, - media_attachments: MastodonEntity.Attachment[], - emojis: MastodonEntity.Emoji[], -} - -export const escapeMFM = (text: string): string => text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/`/g, '`') - .replace(/\r?\n/g, '
'); - -@Injectable() -export class MastoConverters { - constructor( - @Inject(DI.config) - private readonly config: Config, - - @Inject(DI.userProfilesRepository) - private readonly userProfilesRepository: UserProfilesRepository, - - @Inject(DI.noteEditRepository) - private readonly noteEditRepository: NoteEditRepository, - - private readonly mfmService: MfmService, - private readonly getterService: GetterService, - private readonly customEmojiService: CustomEmojiService, - private readonly idService: IdService, - private readonly driveFileEntityService: DriveFileEntityService, - private readonly mastodonDataService: MastodonDataService, - ) {} - - private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention { - let acct = u.username; - let acctUrl = `https://${u.host || this.config.host}/@${u.username}`; - let url: string | null = null; - if (u.host) { - const info = m.find(r => r.username === u.username && r.host === u.host); - acct = `${u.username}@${u.host}`; - acctUrl = `https://${u.host}/@${u.username}`; - if (info) url = info.url ?? info.uri; - } - return { - id: u.id, - username: u.username, - acct: acct, - url: url ?? acctUrl, - }; - } - - public fileType(s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' { - if (s === 'image/gif') { - return 'gifv'; - } - if (s.includes('image')) { - return 'image'; - } - if (s.includes('video')) { - return 'video'; - } - if (s.includes('audio')) { - return 'audio'; - } - return 'unknown'; - } - - public encodeFile(f: Packed<'DriveFile'>): MastodonEntity.Attachment { - const { width, height } = f.properties; - const size = (width && height) ? `${width}x${height}` : undefined; - const aspect = (width && height) ? (width / height) : undefined; - - return { - id: f.id, - type: this.fileType(f.type), - url: f.url, - remote_url: f.url, - preview_url: f.thumbnailUrl, - text_url: f.url, - meta: { - original: { - width, - height, - size, - aspect, - }, - width, - height, - size, - aspect, - }, - description: f.comment ?? null, - blurhash: f.blurhash ?? null, - }; - } - - public async getUser(id: string): Promise { - return this.getterService.getUser(id).then(p => { - return p; - }); - } - - private encodeField(f: Entity.Field): MastodonEntity.Field { - return { - name: f.name, - value: this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), - verified_at: null, - }; - } - - public async convertAccount(account: Entity.Account | MiUser): Promise { - const user = await this.getUser(account.id); - const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); - const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host); - const emoji: Entity.Emoji[] = []; - Object.entries(emojis).forEach(entry => { - const [key, value] = entry; - emoji.push({ - shortcode: key, - static_url: value, - url: value, - visible_in_picker: true, - category: undefined, - }); - }); - const fqn = `${user.username}@${user.host ?? this.config.hostname}`; - let acct = user.username; - let acctUrl = `https://${user.host || this.config.host}/@${user.username}`; - const acctUri = `https://${this.config.host}/users/${user.id}`; - if (user.host) { - acct = `${user.username}@${user.host}`; - acctUrl = `https://${user.host}/@${user.username}`; - } - return awaitAll({ - id: account.id, - username: user.username, - acct: acct, - fqn: fqn, - display_name: user.name ?? user.username, - locked: user.isLocked, - created_at: this.idService.parse(user.id).date.toISOString(), - followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0, - following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0, - statuses_count: user.notesCount, - note: profile?.description ?? '', - url: user.uri ?? acctUrl, - uri: user.uri ?? acctUri, - avatar: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', - avatar_static: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', - header: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png', - header_static: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png', - emojis: emoji, - moved: null, //FIXME - fields: profile?.fields.map(p => this.encodeField(p)) ?? [], - bot: user.isBot, - discoverable: user.isExplorable, - noindex: user.noindex, - group: null, - suspended: user.isSuspended, - limited: user.isSilenced, - }); - } - - public async getEdits(id: string, me: MiLocalUser | null): Promise { - const note = await this.mastodonDataService.getNote(id, me); - if (!note) { - return []; - } - const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); - const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); - const history: StatusEdit[] = []; - - // TODO this looks wrong, according to mastodon docs - let lastDate = this.idService.parse(note.id).date; - for (const edit of edits) { - const files = await this.driveFileEntityService.packManyByIds(edit.fileIds); - const item = { - account: noteUser, - content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', - created_at: lastDate.toISOString(), - emojis: [], //FIXME - sensitive: edit.cw != null && edit.cw.length > 0, - spoiler_text: edit.cw ?? '', - media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [], - }; - lastDate = edit.updatedAt; - history.push(item); - } - - return history; - } - - private async convertReblog(status: Entity.Status | null, me: MiLocalUser | null): Promise { - if (!status) return null; - return await this.convertStatus(status, me); - } - - public async convertStatus(status: Entity.Status, me: MiLocalUser | null): Promise { - const convertedAccount = this.convertAccount(status.account); - const note = await this.mastodonDataService.requireNote(status.id, me); - const noteUser = await this.getUser(status.account.id); - const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers); - - const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host); - const emoji: Entity.Emoji[] = []; - Object.entries(emojis).forEach(entry => { - const [key, value] = entry; - emoji.push({ - shortcode: key, - static_url: value, - url: value, - visible_in_picker: true, - category: undefined, - }); - }); - - const mentions = Promise.all(note.mentions.map(p => - this.getUser(p) - .then(u => this.encode(u, mentionedRemoteUsers)) - .catch(() => null))) - .then(p => p.filter(m => m)) as Promise; - - const tags = note.tags.map(tag => { - return { - name: tag, - url: `${this.config.url}/tags/${tag}`, - } as Entity.Tag; - }); - - // This must mirror the usual isQuote / isPureRenote logic used elsewhere. - const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId); - - const renote: Promise | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null; - - const quoteUri = Promise.resolve(renote).then(renote => { - if (!renote || !isQuote) return null; - return renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`; - }); - - const text = note.text; - const content = text !== null - ? quoteUri - .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri) ?? escapeMFM(text)) - : ''; - - const reblogged = await this.mastodonDataService.hasReblog(note.id, me); - - // noinspection ES6MissingAwait - return await awaitAll({ - id: note.id, - uri: note.uri ?? `https://${this.config.host}/notes/${note.id}`, - url: note.url ?? note.uri ?? `https://${this.config.host}/notes/${note.id}`, - account: convertedAccount, - in_reply_to_id: note.replyId, - in_reply_to_account_id: note.replyUserId, - reblog: !isQuote ? this.convertReblog(status.reblog, me) : null, - content: content, - content_type: 'text/x.misskeymarkdown', - text: note.text, - created_at: status.created_at, - edited_at: note.updatedAt?.toISOString() ?? null, - emojis: emoji, - replies_count: note.repliesCount, - reblogs_count: note.renoteCount, - favourites_count: status.favourites_count, - reblogged, - favourited: status.favourited, - muted: status.muted, - sensitive: status.sensitive || !!note.cw, - spoiler_text: note.cw ?? '', - visibility: status.visibility, - media_attachments: status.media_attachments.map(a => convertAttachment(a)), - mentions: mentions, - tags: tags, - card: null, //FIXME - poll: status.poll ?? null, - application: null, //FIXME - language: null, //FIXME - pinned: false, //FIXME - bookmarked: false, //FIXME - quote_id: isQuote ? status.reblog?.id : undefined, - quote: isQuote ? this.convertReblog(status.reblog, me) : null, - reactions: status.emoji_reactions, - }); - } - - public async convertConversation(conversation: Entity.Conversation, me: MiLocalUser | null): Promise { - return { - id: conversation.id, - accounts: await Promise.all(conversation.accounts.map(a => this.convertAccount(a))), - last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null, - unread: conversation.unread, - }; - } - - public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { - return { - account: await this.convertAccount(notification.account), - created_at: notification.created_at, - id: notification.id, - status: notification.status ? await this.convertStatus(notification.status, me) : undefined, - type: notification.type, - }; - } - - // public convertEmoji(emoji: string): MastodonEntity.Emoji { - // const reaction: MastodonEntity.Reaction = { - // name: emoji, - // count: 1, - // }; - // - // if (emoji.startsWith(':')) { - // const [, name] = emoji.match(/^:([^@:]+(?:@[^@:]+)?):$/) ?? []; - // if (name) { - // const url = `${this.config.url}/emoji/${name}.webp`; - // reaction.url = url; - // reaction.static_url = url; - // } - // } - // - // return reaction; - // } -} - -function simpleConvert(data: T): T { - // copy the object to bypass weird pass by reference bugs - return Object.assign({}, data); -} - -export function convertAnnouncement(announcement: Entity.Announcement): MastodonEntity.Announcement { - return { - ...announcement, - updated_at: announcement.updated_at ?? announcement.published_at, - }; -} - -export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment { - const { width, height } = attachment.meta?.original ?? attachment.meta ?? {}; - const size = (width && height) ? `${width}x${height}` : undefined; - const aspect = (width && height) ? (width / height) : undefined; - return { - ...attachment, - meta: attachment.meta ? { - ...attachment.meta, - original: { - ...attachment.meta.original, - width, - height, - size, - aspect, - frame_rate: String(attachment.meta.fps), - duration: attachment.meta.duration, - bitrate: attachment.meta.audio_bitrate ? parseInt(attachment.meta.audio_bitrate) : undefined, - }, - width, - height, - size, - aspect, - } : null, - }; -} -export function convertFilter(filter: Entity.Filter): MastodonEntity.Filter { - return simpleConvert(filter); -} -export function convertList(list: Entity.List): MastodonEntity.List { - return { - id: list.id, - title: list.title, - replies_policy: list.replies_policy ?? 'followed', - }; -} -export function convertFeaturedTag(tag: Entity.FeaturedTag): MastodonEntity.FeaturedTag { - return simpleConvert(tag); -} - -export function convertPoll(poll: Entity.Poll): MastodonEntity.Poll { - return simpleConvert(poll); -} - -// Megalodon sometimes returns broken / stubbed relationship data -export function convertRelationship(relationship: Partial & { id: string }): MastodonEntity.Relationship { - return { - id: relationship.id, - following: relationship.following ?? false, - showing_reblogs: relationship.showing_reblogs ?? true, - notifying: relationship.notifying ?? true, - languages: [], - followed_by: relationship.followed_by ?? false, - blocking: relationship.blocking ?? false, - blocked_by: relationship.blocked_by ?? false, - muting: relationship.muting ?? false, - muting_notifications: relationship.muting_notifications ?? false, - requested: relationship.requested ?? false, - requested_by: relationship.requested_by ?? false, - domain_blocking: relationship.domain_blocking ?? false, - endorsed: relationship.endorsed ?? false, - note: relationship.note ?? '', - }; -} - diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index f669b71efb..efb26ca53e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -10,7 +10,7 @@ import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; -import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js'; +import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js'; import type multer from 'fastify-multer'; import type { FastifyInstance } from 'fastify'; @@ -30,7 +30,7 @@ export class ApiAccountMastodon { private readonly accessTokensRepository: AccessTokensRepository, private readonly clientService: MastodonClientService, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly driveService: DriveService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index d02ddd1999..deac1e9aad 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; -import { convertFilter } from '../converters.js'; +import { convertFilter } from '../MastodonConverters.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index 1f08f0a3b0..d6ee92b466 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -9,7 +9,7 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type { MiMeta, UsersRepository } from '@/models/_.js'; -import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { RoleService } from '@/core/RoleService.js'; import type { FastifyInstance } from 'fastify'; @@ -27,7 +27,7 @@ export class ApiInstanceMastodon { @Inject(DI.config) private readonly config: Config, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, private readonly roleService: RoleService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 120b9ba7f9..c81b3ca236 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; -import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonClientService } from '../MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; @@ -21,7 +21,7 @@ interface ApiNotifyMastodonRoute { @Injectable() export class ApiNotificationsMastodon { constructor( - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 997a585077..7277a35220 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; -import { MastoConverters } from '../converters.js'; +import { MastodonConverters } from '../MastodonConverters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; import Account = Entity.Account; import Status = Entity.Status; @@ -23,7 +23,7 @@ interface ApiSearchMastodonRoute { @Injectable() export class ApiSearchMastodon { constructor( - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index e64df3d74c..ea796e4f0b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -8,7 +8,7 @@ import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; -import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; +import { convertAttachment, convertPoll, MastodonConverters } from '../MastodonConverters.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -20,7 +20,7 @@ function normalizeQuery(data: Record) { @Injectable() export class ApiStatusMastodon { constructor( - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index a333e77c3e..b6162d9eb2 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; -import { convertList, MastoConverters } from '../converters.js'; +import { convertList, MastodonConverters } from '../MastodonConverters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -15,7 +15,7 @@ import type { FastifyInstance } from 'fastify'; export class ApiTimelineMastodon { constructor( private readonly clientService: MastodonClientService, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, ) {} public register(fastify: FastifyInstance): void { -- cgit v1.2.3-freya From 6ac37b4d6cae064545b13fd7fdb414d0cffa178b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 1 Apr 2025 20:47:04 -0400 Subject: lint and type fixes --- packages/backend/src/GlobalModule.ts | 16 ++- packages/backend/src/core/ApLogService.ts | 18 ++++ packages/backend/src/core/NoteCreateService.ts | 29 ++---- .../backend/src/core/activitypub/misc/validator.ts | 2 + .../src/core/activitypub/models/ApPersonService.ts | 3 +- .../backend/src/core/entities/NoteEntityService.ts | 18 ++-- packages/backend/src/misc/gen-identicon.ts | 4 +- packages/backend/src/models/NoteSchedule.ts | 4 +- packages/backend/src/models/UserProfile.ts | 2 +- .../backend/src/models/json-schema/note-edit.ts | 59 ------------ .../processors/DeleteAccountProcessorService.ts | 5 + .../src/queue/processors/InboxProcessorService.ts | 3 - packages/backend/src/server/ServerModule.ts | 3 - packages/backend/src/server/ServerService.ts | 19 ++-- .../backend/src/server/api/RateLimiterService.ts | 107 --------------------- .../backend/src/server/api/SigninApiService.ts | 9 +- .../backend/src/server/api/SignupApiService.ts | 2 - .../src/server/api/StreamingApiServerService.ts | 6 +- .../server/api/endpoints/admin/emoji/import-zip.ts | 2 +- .../server/api/endpoints/admin/gen-vapid-keys.ts | 4 +- .../server/api/endpoints/admin/reset-password.ts | 1 - .../src/server/api/endpoints/admin/update-meta.ts | 8 +- .../backend/src/server/api/endpoints/ap/show.ts | 3 +- .../api/endpoints/federation/update-remote-user.ts | 3 +- packages/backend/src/server/api/endpoints/i.ts | 3 +- .../src/server/api/endpoints/i/2fa/key-done.ts | 1 - .../src/server/api/endpoints/i/2fa/register-key.ts | 1 - .../src/server/api/endpoints/i/2fa/register.ts | 1 - .../src/server/api/endpoints/i/2fa/remove-key.ts | 1 - .../src/server/api/endpoints/i/2fa/unregister.ts | 1 - .../src/server/api/endpoints/i/2fa/update-key.ts | 1 - .../src/server/api/endpoints/i/change-password.ts | 2 - .../src/server/api/endpoints/i/delete-account.ts | 1 - .../src/server/api/endpoints/i/regenerate-token.ts | 1 - .../src/server/api/endpoints/i/update-email.ts | 1 - .../server/api/endpoints/notes/global-timeline.ts | 6 +- .../server/api/endpoints/notes/search-by-tag.ts | 6 +- .../src/server/api/endpoints/notes/unrenote.ts | 1 + .../src/server/api/endpoints/reset-password.ts | 2 - .../src/server/api/endpoints/server-info.ts | 3 +- .../backend/src/server/api/endpoints/users/show.ts | 3 +- .../backend/src/server/api/stream/Connection.ts | 1 - .../src/server/oauth/OAuth2ProviderService.ts | 3 - .../backend/src/server/web/ClientServerService.ts | 2 +- packages/frontend-embed/src/components/EmNote.vue | 1 - .../src/components/EmNoteDetailed.vue | 1 - .../frontend-embed/src/components/EmNoteSimple.vue | 1 - .../frontend-embed/src/components/EmNoteSub.vue | 1 - packages/frontend/src/components/DynamicNote.vue | 4 +- .../src/components/DynamicNoteDetailed.vue | 4 +- .../frontend/src/components/DynamicNoteSimple.vue | 4 +- packages/frontend/src/components/MkCaptcha.vue | 1 + .../frontend/src/components/MkImgWithBlurhash.vue | 4 +- packages/frontend/src/components/MkNote.vue | 8 +- .../frontend/src/components/MkNoteDetailed.vue | 8 +- packages/frontend/src/components/MkNoteHeader.vue | 8 +- packages/frontend/src/components/MkNoteSimple.vue | 2 +- packages/frontend/src/components/MkNoteSub.vue | 8 +- .../frontend/src/components/MkNotification.vue | 29 +++--- packages/frontend/src/components/MkOmit.vue | 1 - .../frontend/src/components/MkSubNoteContent.vue | 1 - packages/frontend/src/components/SkNote.vue | 8 +- .../frontend/src/components/SkNoteDetailed.vue | 8 +- packages/frontend/src/components/SkNoteSub.vue | 4 +- .../frontend/src/components/page/page.text.vue | 7 +- packages/frontend/src/pages/about.federation.vue | 3 +- packages/frontend/src/pages/about.overview.vue | 17 ++-- packages/frontend/src/pages/admin-user.vue | 1 + packages/frontend/src/pages/admin/moderation.vue | 1 + packages/frontend/src/pages/admin/roles.vue | 1 + packages/frontend/src/pages/favorites.vue | 2 +- packages/frontend/src/pages/note.vue | 2 +- .../frontend/src/pages/settings/drive-cleaner.vue | 5 +- .../frontend/src/pages/settings/preferences.vue | 1 - packages/frontend/src/pages/settings/profile.vue | 1 - packages/frontend/src/pages/welcome.setup.vue | 1 + packages/frontend/src/types/post-form.ts | 1 + packages/frontend/src/ui/_common_/common.vue | 12 +-- packages/frontend/src/ui/_common_/navbar.vue | 2 +- packages/frontend/src/use/use-note-capture.ts | 2 +- packages/frontend/src/utility/deep-equal.ts | 2 +- packages/frontend/src/utility/favicon-dot.ts | 20 ++-- packages/frontend/src/utility/get-note-menu.ts | 2 +- packages/frontend/test/aiscript/ui.test.ts | 2 +- 84 files changed, 188 insertions(+), 374 deletions(-) delete mode 100644 packages/backend/src/models/json-schema/note-edit.ts delete mode 100644 packages/backend/src/server/api/RateLimiterService.ts (limited to 'packages/backend/src/server/ServerModule.ts') diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 92d1bf20fa..90dbdaf2a6 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -178,15 +178,13 @@ export class GlobalModule implements OnApplicationShutdown { // Wait for all potential DB queries await allSettled(); // And then disconnect from DB - await Promise.all([ - this.db.destroy(), - this.redisClient.disconnect(), - this.redisForPub.disconnect(), - this.redisForSub.disconnect(), - this.redisForTimelines.disconnect(), - this.redisForReactions.disconnect(), - this.redisForRateLimit.disconnect(), - ]); + await this.db.destroy(); + this.redisClient.disconnect(); + this.redisForPub.disconnect(); + this.redisForSub.disconnect(); + this.redisForTimelines.disconnect(); + this.redisForReactions.disconnect(); + this.redisForRateLimit.disconnect(); } async onApplicationShutdown(signal: string): Promise { diff --git a/packages/backend/src/core/ApLogService.ts b/packages/backend/src/core/ApLogService.ts index 096ec21de7..f21c6da313 100644 --- a/packages/backend/src/core/ApLogService.ts +++ b/packages/backend/src/core/ApLogService.ts @@ -139,6 +139,24 @@ export class ApLogService { } } + /** + * Deletes all logged inbox activities from a user or users + * @param userIds IDs of the users to delete + */ + public async deleteInboxLogs(userIds: string | string[]): Promise { + if (Array.isArray(userIds)) { + const logsDeleted = await this.apInboxLogsRepository.delete({ + authUserId: In(userIds), + }); + return logsDeleted.affected ?? 0; + } else { + const logsDeleted = await this.apInboxLogsRepository.delete({ + authUserId: userIds, + }); + return logsDeleted.affected ?? 0; + } + } + /** * Deletes all expired AP logs and garbage-collects the AP context cache. * Returns the total number of deleted rows. diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 72df948c8b..98d9571255 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -571,7 +571,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (this.meta.enableStatsForFederatedInstances) { if (this.userEntityService.isRemoteUser(user)) { this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { - if (note.renote && note.text || !note.renote) { + if (!this.isRenote(note) || this.isQuote(note)) { this.updateNotesCountQueue.enqueue(i.id, 1); } if (this.meta.enableChartsForFederatedInstances) { @@ -583,17 +583,12 @@ export class NoteCreateService implements OnApplicationShutdown { // ハッシュタグ更新 if (data.visibility === 'public' || data.visibility === 'home') { - if (user.isBot && this.meta.enableBotTrending) { - this.hashtagService.updateHashtags(user, tags); - } else if (!user.isBot) { + if (!user.isBot || this.meta.enableBotTrending) { this.hashtagService.updateHashtags(user, tags); } } - if (data.renote && data.text) { - // Increment notes count (user) - this.incNotesCountOfUser(user); - } else if (!data.renote) { + if (!this.isRenote(note) || this.isQuote(note)) { // Increment notes count (user) this.incNotesCountOfUser(user); } @@ -631,7 +626,7 @@ export class NoteCreateService implements OnApplicationShutdown { }); } - if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) { + if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) { this.incRenoteCount(data.renote); } @@ -706,13 +701,7 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - const [ - userIdsWhoMeMuting, - ] = data.renote.userId ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(data.renote.userId), - ]) : [new Set()]; - - const muted = isUserRelated(note, userIdsWhoMeMuting); + const muted = data.renote.userId && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(data.renote.userId)); if (!isThreadMuted && !muted) { nm.push(data.renote.userId, type); @@ -848,13 +837,7 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - const [ - userIdsWhoMeMuting, - ] = u.id ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(u.id), - ]) : [new Set()]; - - const muted = isUserRelated(note, userIdsWhoMeMuting); + const muted = u.id && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(u.id)); if (isThreadMuted || muted) { continue; diff --git a/packages/backend/src/core/activitypub/misc/validator.ts b/packages/backend/src/core/activitypub/misc/validator.ts index 4292b7e0f7..0ff83659c1 100644 --- a/packages/backend/src/core/activitypub/misc/validator.ts +++ b/packages/backend/src/core/activitypub/misc/validator.ts @@ -5,6 +5,8 @@ import type { Response } from 'node-fetch'; +// TODO throw identifiable or unrecoverable errors + export function validateContentTypeSetAsActivityPub(response: Response): void { const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index d7ee6c306b..c57c3f1704 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -322,6 +322,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { const host = this.utilityService.punyHost(uri); if (host === this.utilityService.toPuny(this.config.host)) { + // TODO convert to unrecoverable error throw new StatusError(`cannot resolve local user: ${uri}`, 400, 'cannot resolve local user'); } @@ -570,7 +571,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { .catch(err => { if (!(err instanceof StatusError) || err.isRetryable) { this.logger.error('error occurred while fetching following/followers collection', { stack: err }); - // Do not update the visibiility on transient errors. + // Do not update the visibility on transient errors. return undefined; } return 'private'; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 537677ed34..c3d00ffa9d 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -479,14 +479,6 @@ export class NoteEntityService implements OnModuleInit { mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, - poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, - ...(meId && Object.keys(reactions).length > 0 ? { - myReaction: this.populateMyReaction({ - id: note.id, - reactions: reactions, - reactionAndUserPairCache: reactionAndUserPairCache, - }, meId, options?._hint_), - } : {}), ...(opts.detail ? { clippedCount: note.clippedCount, @@ -505,6 +497,16 @@ export class NoteEntityService implements OnModuleInit { withReactionAndUserPairCache: opts.withReactionAndUserPairCache, _hint_: options?._hint_, }) : undefined, + + poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, + + ...(meId && Object.keys(reactions).length > 0 ? { + myReaction: this.populateMyReaction({ + id: note.id, + reactions: reactions, + reactionAndUserPairCache: reactionAndUserPairCache, + }, meId, options?._hint_), + } : {}), } : {}), }); diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index f3c08cc76e..ac7db82f2e 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -44,7 +44,7 @@ const sideN = Math.floor(n / 2); /** * Generate buffer of an identicon by seed */ -export async function genIdenticon(seed: string): Promise { +export function genIdenticon(seed: string): Buffer { const rand = gen.create(seed); const canvas = createCanvas(size, size); const ctx = canvas.getContext('2d'); @@ -100,5 +100,5 @@ export async function genIdenticon(seed: string): Promise { } } - return await canvas.toBuffer('image/png'); + return canvas.toBuffer('image/png'); } diff --git a/packages/backend/src/models/NoteSchedule.ts b/packages/backend/src/models/NoteSchedule.ts index dde0af6ad7..c9d031c281 100644 --- a/packages/backend/src/models/NoteSchedule.ts +++ b/packages/backend/src/models/NoteSchedule.ts @@ -17,7 +17,7 @@ type MinimumUser = { uri: MiUser['uri']; }; -export type MiScheduleNoteType={ +export type MiScheduleNoteType = { visibility: 'public' | 'home' | 'followers' | 'specified'; visibleUsers: MinimumUser[]; channel?: MiChannel['id']; @@ -37,7 +37,7 @@ export type MiScheduleNoteType={ apMentions?: MinimumUser[] | null; apHashtags?: string[] | null; apEmojis?: string[] | null; -} +}; @Entity('note_schedule') export class MiNoteSchedule { diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 449c2f370b..cda55451d0 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -4,7 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, noteVisibilities, defaultCWPriorities } from '@/types.js'; +import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, defaultCWPriorities } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; diff --git a/packages/backend/src/models/json-schema/note-edit.ts b/packages/backend/src/models/json-schema/note-edit.ts deleted file mode 100644 index ba936f866b..0000000000 --- a/packages/backend/src/models/json-schema/note-edit.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedNoteEdit = { - type: "object", - properties: { - id: { - type: "string", - optional: false, - nullable: false, - format: "id", - example: "xxxxxxxxxx", - }, - updatedAt: { - type: "string", - optional: false, - nullable: false, - format: "date-time", - }, - note: { - type: "object", - optional: false, - nullable: false, - ref: "Note", - }, - noteId: { - type: "string", - optional: false, - nullable: false, - format: "id", - }, - oldText: { - type: "string", - optional: true, - nullable: true, - }, - newText: { - type: "string", - optional: true, - nullable: true, - }, - cw: { - type: "string", - optional: true, - nullable: true, - }, - fileIds: { - type: "array", - optional: true, - nullable: true, - items: { - type: "string", - format: "id", - }, - }, - }, -} as const; diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 0c70829132..46cee096cf 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -184,6 +184,11 @@ export class DeleteAccountProcessorService { await this.apLogService.deleteObjectLogs(user.uri) .catch(err => this.logger.error(err, `Failed to delete AP logs for user '${user.uri}'`)); } + + await this.apLogService.deleteInboxLogs(user.id) + .catch(err => this.logger.error(err, `Failed to delete AP logs for user '${user.uri}'`)); + + this.logger.succ('All AP logs deleted'); } { // Send email notification diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 50630e4061..9564724c62 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -25,8 +25,6 @@ import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -//import { CollapsedQueue } from '@/misc/collapsed-queue.js'; -//import { MiNote } from '@/models/Note.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; import { SkApInboxLog } from '@/models/_.js'; @@ -68,7 +66,6 @@ export class InboxProcessorService implements OnApplicationShutdown { private readonly updateInstanceQueue: UpdateInstanceQueue, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); - //this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance); } @bindThis diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index c8f6e88fa9..6726d4aa67 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -28,7 +28,6 @@ import { ActivityPubServerService } from './ActivityPubServerService.js'; import { ApiLoggerService } from './api/ApiLoggerService.js'; import { ApiServerService } from './api/ApiServerService.js'; import { AuthenticateService } from './api/AuthenticateService.js'; -import { RateLimiterService } from './api/RateLimiterService.js'; import { SigninApiService } from './api/SigninApiService.js'; import { SigninService } from './api/SigninService.js'; import { SignupApiService } from './api/SignupApiService.js'; @@ -88,8 +87,6 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ApiServerService, AuthenticateService, SkRateLimiterService, - // No longer used, but kept for backwards compatibility - RateLimiterService, SigninApiService, SigninWithPasskeyApiService, SigninService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 9ae8f2efe4..c90c206e94 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -229,12 +229,12 @@ export class ServerService implements OnApplicationShutdown { } }); - fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => { - reply.header('Content-Type', 'image/png'); + fastify.get<{ Params: { x: string } }>('/identicon/:x', (request, reply) => { + reply.header('Content-Type', 'image/png'); reply.header('Cache-Control', 'public, max-age=86400'); if (this.meta.enableIdenticonGeneration) { - return await genIdenticon(request.params.x); + return genIdenticon(request.params.x); } else { return reply.redirect('/static-assets/avatar.png'); } @@ -293,13 +293,14 @@ export class ServerService implements OnApplicationShutdown { if (fs.existsSync(this.config.socket)) { fs.unlinkSync(this.config.socket); } - fastify.listen({ path: this.config.socket }, (err, address) => { - if (this.config.chmodSocket) { - fs.chmodSync(this.config.socket!, this.config.chmodSocket); - } - }); + + await fastify.listen({ path: this.config.socket }); + + if (this.config.chmodSocket) { + fs.chmodSync(this.config.socket!, this.config.chmodSocket); + } } else { - fastify.listen({ port: this.config.port, host: this.config.address }); + await fastify.listen({ port: this.config.port, host: this.config.address }); } await fastify.ready(); diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts deleted file mode 100644 index 879529090f..0000000000 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import Limiter from 'ratelimiter'; -import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; -import { LegacyRateLimit } from '@/misc/rate-limit-utils.js'; -import type { IEndpointMeta } from './endpoints.js'; - -/** @deprecated Use SkRateLimiterService instead */ -@Injectable() -export class RateLimiterService { - private logger: Logger; - private disabled = false; - - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('limiter'); - - if (process.env.NODE_ENV !== 'production') { - this.disabled = true; - } - } - - @bindThis - public limit(limitation: LegacyRateLimit & { key: NonNullable }, actor: string, factor = 1) { - return new Promise((ok, reject) => { - if (this.disabled) ok(); - - // Short-term limit - const minP = (): void => { - const minIntervalLimiter = new Limiter({ - id: `${actor}:${limitation.key}:min`, - duration: limitation.minInterval! * factor, - max: 1, - db: this.redisClient, - }); - - minIntervalLimiter.get((err, info) => { - if (err) { - return reject({ code: 'ERR', info }); - } - - this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); - - if (info.remaining === 0) { - return reject({ code: 'BRIEF_REQUEST_INTERVAL', info }); - } else { - if (hasLongTermLimit) { - return maxP(); - } else { - return ok(); - } - } - }); - }; - - // Long term limit - const maxP = (): void => { - const limiter = new Limiter({ - id: `${actor}:${limitation.key}`, - duration: limitation.duration! * factor, - max: limitation.max! / factor, - db: this.redisClient, - }); - - limiter.get((err, info) => { - if (err) { - return reject({ code: 'ERR', info }); - } - - this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); - - if (info.remaining === 0) { - return reject({ code: 'RATE_LIMIT_EXCEEDED', info }); - } else { - return ok(); - } - }); - }; - - const hasShortTermLimit = typeof limitation.minInterval === 'number'; - - const hasLongTermLimit = - typeof limitation.duration === 'number' && - typeof limitation.max === 'number'; - - if (hasShortTermLimit) { - minP(); - } else if (hasLongTermLimit) { - maxP(); - } else { - ok(); - } - }); - } -} diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 72712bce60..7f371ea309 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -35,7 +35,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; // Up to 10 attempts, then 1 per minute const signinRateLimit: Keyed = { key: 'signin', - max: 10, + type: 'bucket', + size: 10, dripRate: 1000 * 60, }; @@ -146,7 +147,7 @@ export class SigninApiService { if (isSystemAccount(user)) { return error(403, { - id: 's8dhsj9s-a93j-493j-ja9k-kas9sj20aml2', + id: 'ba4ba3bc-ef1e-4c74-ad88-1d2b7d69a100', }); } @@ -243,7 +244,7 @@ export class SigninApiService { if (profile.password!.startsWith('$2')) { const newHash = await argon2.hash(password); this.userProfilesRepository.update(user.id, { - password: newHash + password: newHash, }); } if (!this.meta.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true }); @@ -267,7 +268,7 @@ export class SigninApiService { if (profile.password!.startsWith('$2')) { const newHash = await argon2.hash(password); this.userProfilesRepository.update(user.id, { - password: newHash + password: newHash, }); } await this.userAuthService.twoFactorAuthenticate(profile, token); diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 42137d3298..cb71047a24 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; @@ -205,7 +204,6 @@ export class SignupApiService { const code = secureRndstr(16, { chars: L_CHARS }); // Generate hash of password - //const salt = await bcrypt.genSalt(8); const hash = await argon2.hash(password); const pendingUser = await this.userPendingsRepository.insertOne({ diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index da13007bba..eaeaecb1c2 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -124,9 +124,11 @@ export class StreamingApiServerService { const requestIp = proxyAddr(request, () => true ); const limitActor = user?.id ?? getIpHash(requestIp); if (await this.rateLimitThis(limitActor, { + // Up to 32 connections, then 1 every 10 seconds + type: 'bucket', key: 'wsconnect', - duration: ms('5min'), - max: 32, + size: 32, + dripRate: 10 * 1000, })) { socket.write('HTTP/1.1 429 Rate Limit Exceeded\r\n\r\n'); socket.destroy(); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index 7b544bee8d..921ecacaf3 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -33,7 +33,7 @@ export default class extends Endpoint { // eslint- private readonly driveFilesRepository: DriveFilesRepository, ) { super(meta, paramDef, async (ps, me) => { - const file = await driveFilesRepository.findOneByOrFail({ id: ps.fileId }); + const file = await this.driveFilesRepository.findOneByOrFail({ id: ps.fileId }); await this.moderationLogService.log(me, 'importCustomEmojis', { fileName: file.name, }); diff --git a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts index 5695866265..85e3cd0477 100644 --- a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts +++ b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts @@ -26,7 +26,9 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const keys = await generateVAPIDKeys(); - + + // TODO add moderation log + return { public: keys.publicKey, private: keys.privateKey }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index b99f420928..57b7170052 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index e6d5dffad8..15f59907af 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -404,14 +404,14 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } - if (ps.enableFC !== undefined) { - set.enableFC = ps.enableFC; - } - if (ps.enableTestcaptcha !== undefined) { set.enableTestcaptcha = ps.enableTestcaptcha; } + if (ps.enableFC !== undefined) { + set.enableFC = ps.enableFC; + } + if (ps.fcSiteKey !== undefined) { set.fcSiteKey = ps.fcSiteKey; } diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 8ba18a3b8d..d69850515c 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -30,7 +30,8 @@ export const meta = { // Up to 30 calls, then 1 per 1/2 second limit: { - max: 30, + type: 'bucket', + size: 30, dripRate: 500, }, diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index 5217f79065..67fa5ed343 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -16,7 +16,8 @@ export const meta = { // Up to 10 calls, then 4 / second. // This allows for reliable automation. limit: { - max: 10, + type: 'bucket', + size: 10, dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 48a2e3b40a..177bc601ac 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -34,7 +34,8 @@ export const meta = { // up to 20 calls, then 1 per second. // This handles bursty traffic when all tabs reload as a group limit: { - max: 20, + type: 'bucket', + size: 20, dripSize: 1, dripRate: 1000, }, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 370d9915a3..6d1972456d 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 893ea30391..77f71ce5fd 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index d27c14c69b..6fde3a90a7 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index b01e452056..d4098458d7 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 2fe4fdc4c0..fc5a51f81b 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts index 4a41c7b984..a9f631cfaf 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index 4069683740..ea84ef24d7 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; @@ -65,7 +64,6 @@ export default class extends Endpoint { // eslint- } // Generate hash of password - //const salt = await bcrypt.genSalt(8); const hash = await argon2.hash(ps.newPassword); await this.userProfilesRepository.update(me.id, { diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index 10fb923d4f..8a2b523449 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index c7599aada2..4fd6202604 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 0be8bfb695..dc07556760 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index efecf0b3c1..545ec0f6eb 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -76,11 +76,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.gtlDisabled); } - const [ - followings, - ] = me ? await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - ]) : [undefined]; + const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {}; //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 50711bc2bd..af9bc3b426 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -100,11 +100,7 @@ export default class extends Endpoint { // eslint- if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); - const [ - followings, - ] = me ? await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - ]) : [undefined]; + const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {}; try { if (ps.tag) { diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index 58932bd83a..f2a927f3c5 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -66,6 +66,7 @@ export default class extends Endpoint { // eslint- renoteId: note.id, }); + // TODO inline this into the above query for (const note of renotes) { if (ps.quote) { if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index d9240dec5e..ba0c60f4ec 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import type { UserProfilesRepository, PasswordResetRequestsRepository } from '@/models/_.js'; @@ -60,7 +59,6 @@ export default class extends Endpoint { // eslint- } // Generate hash of password - //const salt = await bcrypt.genSalt(8); const hash = await argon2.hash(ps.password); await this.userProfilesRepository.update(req.userId, { diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index 528de76707..33ef48226d 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -66,7 +66,8 @@ export const meta = { // 24 calls, then 7 per second-ish (1 for each type of server info graph) limit: { - max: 24, + type: 'bucket', + size: 24, dripSize: 7, dripRate: 900, }, diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 118362149d..7b1c8adfb8 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -59,7 +59,8 @@ export const meta = { // up to 50 calls @ 4 per second limit: { - max: 50, + type: 'bucket', + size: 50, dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 1ed2e5e9a5..e0535a2f14 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -23,7 +23,6 @@ import type Channel from './channel.js'; const MAX_CHANNELS_PER_CONNECTION = 32; const MAX_SUBSCRIPTIONS_PER_CONNECTION = 512; -const MAX_CACHED_NOTES_PER_CONNECTION = 64; /** * Main stream connection diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 87c09abaf4..b7e09633ed 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -6,9 +6,6 @@ import querystring from 'querystring'; import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; -/* import { kinds } from '@/misc/api-permissions.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; */ import multer from 'fastify-multer'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 165e4f3f73..99cc922281 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -427,7 +427,7 @@ export class ClientServerService { fastify.get('/robots.txt', async (request, reply) => { if (this.meta.robotsTxt) { reply.header('Content-Type', 'text/plain'); - return await reply.send(this.meta.robotsTxt); + return reply.send(this.meta.robotsTxt); } else { return await reply.sendFile('/robots.txt', staticAssets); } diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue index bf96c557ea..666cbde72d 100644 --- a/packages/frontend-embed/src/components/EmNote.vue +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -155,7 +155,6 @@ const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value const isLong = shouldCollapsed(appearNote.value, []); const collapsed = ref(appearNote.value.cw == null && isLong); const isDeleted = ref(false); - const mergedCW = computed(() => computeMergedCw(appearNote.value)); diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue index 0961b36e35..9f4be8c666 100644 --- a/packages/frontend-embed/src/components/EmNoteDetailed.vue +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -176,7 +176,6 @@ const isDeleted = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const isLong = shouldCollapsed(appearNote.value, []); const collapsed = ref(appearNote.value.cw == null && isLong); - const mergedCW = computed(() => computeMergedCw(appearNote.value)); diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue index 688758edb6..a1dee733c7 100644 --- a/packages/frontend-embed/src/components/EmNoteSimple.vue +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -36,7 +36,6 @@ const props = defineProps<{ }>(); const showContent = ref(false); - const mergedCW = computed(() => computeMergedCw(props.note)); diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue index 629f0bffcd..931e1e2d79 100644 --- a/packages/frontend-embed/src/components/EmNoteSub.vue +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -55,7 +55,6 @@ const props = withDefaults(defineProps<{ const showContent = ref(false); const replies = ref([]); - const mergedCW = computed(() => computeMergedCw(props.note)); if (props.detail) { diff --git a/packages/frontend/src/components/DynamicNote.vue b/packages/frontend/src/components/DynamicNote.vue index 6ce64d8352..e86fbf7374 100644 --- a/packages/frontend/src/components/DynamicNote.vue +++ b/packages/frontend/src/components/DynamicNote.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only