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 --- .../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 ++++---- 10 files changed, 1086 insertions(+), 640 deletions(-) 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 (limited to 'packages/backend/src/server/api/mastodon/endpoints') 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); -- cgit v1.2.3-freya From da25595ba306f1767883c9c3949dea446343def5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 20:38:28 -0400 Subject: de-duplicate mastodon API logging --- .../api/mastodon/MastodonApiServerService.ts | 278 ++++------- .../src/server/api/mastodon/MastodonLogger.ts | 124 ++++- .../src/server/api/mastodon/endpoints/account.ts | 470 +++++++----------- .../src/server/api/mastodon/endpoints/apps.ts | 76 ++- .../src/server/api/mastodon/endpoints/filter.ts | 136 ++---- .../src/server/api/mastodon/endpoints/instance.ts | 130 +++-- .../server/api/mastodon/endpoints/notifications.ts | 76 +-- .../src/server/api/mastodon/endpoints/search.ts | 140 +++--- .../src/server/api/mastodon/endpoints/status.ts | 543 ++++++++------------- .../src/server/api/mastodon/endpoints/timeline.ts | 261 ++++------ 10 files changed, 916 insertions(+), 1318 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 7a4611fd74..17f706e617 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -9,7 +9,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { getErrorData, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; 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'; @@ -74,6 +74,15 @@ export class MastodonApiServerService { payload.on('error', done); }); + fastify.setErrorHandler((error, request, reply) => { + const data = getErrorData(error); + const status = getErrorStatus(error); + + this.logger.error(request, data, status); + + reply.code(status).send(data); + }); + fastify.register(multer.contentParser); // External endpoints @@ -87,98 +96,56 @@ export class MastodonApiServerService { this.apiTimelineMastodon.register(fastify); fastify.get('/v1/custom_emojis', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceCustomEmojis(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/custom_emojis', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceCustomEmojis(); + reply.send(data.data); }); fastify.get('/v1/announcements', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceAnnouncements(); - reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/announcements', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceAnnouncements(); + reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - 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) { - const data = getErrorData(e); - this.logger.error(`POST /v1/announcements/${_request.body.id}/dismiss`, data); - reply.code(401).send(data); - } + 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); }); fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - 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) { - const data = getErrorData(e); - this.logger.error('POST /v1/media', data); - reply.code(401).send(data); + 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)); }); fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - 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) { - const data = getErrorData(e); - this.logger.error('POST /v2/media', data); - reply.code(401).send(data); + 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)); }); fastify.get('/v1/trends', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceTrends(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceTrends(); + reply.send(data.data); }); fastify.get('/v1/trends/tags', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceTrends(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends/tags', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceTrends(); + reply.send(data.data); }); fastify.get('/v1/trends/links', async (_request, reply) => { @@ -187,132 +154,81 @@ export class MastodonApiServerService { }); fastify.get('/v1/preferences', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getPreferences(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/preferences', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getPreferences(); + reply.send(data.data); }); fastify.get('/v1/followed_tags', async (_request, reply) => { - try { - 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/followed_tags', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getFollowedTags(); + reply.send(data.data); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getBookmarks(parseTimelineArgs(_request.query)); - const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + const data = await client.getBookmarks(parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/bookmarks', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getFavourites(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + const data = await client.getFavourites(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/favourites', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(_request); - const data = await client.getMutes(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + const data = await client.getMutes(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/mutes', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(_request); - const data = await client.getBlocks(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + const data = await client.getBlocks(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/blocks', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { - 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)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/follow_requests', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + + const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; + const data = await client.getFollowRequests(limit); + const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account as Entity.Account))); + + reply.send(response); }); 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 = this.clientService.getClient(_request); - const data = await client.acceptFollowRequest(_request.params.id); - const response = convertRelationship(data.data); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); - reply.code(401).send(data); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.acceptFollowRequest(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); }); 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 = this.clientService.getClient(_request); - const data = await client.rejectFollowRequest(_request.params.id); - const response = convertRelationship(data.data); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); - reply.code(401).send(data); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.rejectFollowRequest(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); }); //#endregion @@ -327,23 +243,17 @@ export class MastodonApiServerService { is_sensitive?: string, }, }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - 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); - 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); - } + 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); + const response = convertAttachment(data.data); + + reply.send(response); }); done(); diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index bb844773c4..c7bca22922 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -3,37 +3,137 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; -import Logger, { Data } from '@/logger.js'; +import { Inject, Injectable } from '@nestjs/common'; +import Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; +import { ApiError } from '@/server/api/error.js'; +import { EnvService } from '@/core/EnvService.js'; +import { FastifyRequest } from 'fastify'; @Injectable() export class MastodonLogger { public readonly logger: Logger; - constructor(loggerService: LoggerService) { + constructor( + @Inject(EnvService) + private readonly envService: EnvService, + + loggerService: LoggerService, + ) { this.logger = loggerService.getLogger('masto-api'); } - public error(endpoint: string, error: Data): void { - this.logger.error(`Error in mastodon API endpoint ${endpoint}:`, error); + public error(request: FastifyRequest, error: MastodonError, status: number): void { + if ((status < 400 && status > 499) || this.envService.env.NODE_ENV === 'development') { + this.logger.error(`Error in mastodon endpoint ${request.method} ${request.url}:`, error); + } } } -export function getErrorData(error: unknown): Data { - if (error == null) return {}; - if (typeof(error) === 'string') return error; - if (typeof(error) === 'object') { +// TODO move elsewhere +export interface MastodonError { + error: string; + error_description: string; +} + +export function getErrorData(error: unknown): MastodonError { + if (error && typeof(error) === 'object') { + // AxiosError, comes from the backend if ('response' in error) { if (typeof(error.response) === 'object' && error.response) { if ('data' in error.response) { if (typeof(error.response.data) === 'object' && error.response.data) { - return error.response.data as Record; + if ('error' in error.response.data) { + if (typeof(error.response.data.error) === 'object' && error.response.data.error) { + if ('code' in error.response.data.error) { + if (typeof(error.response.data.error.code) === 'string') { + return convertApiError(error.response.data.error as ApiError); + } + } + + return convertUnknownError(error.response.data.error); + } + } + + return convertUnknownError(error.response.data); + } + } + } + + // No data - this is a fallback to avoid leaking request/response details in the error + return convertUnknownError(); + } + + if (error instanceof ApiError) { + return convertApiError(error); + } + + if (error instanceof Error) { + return convertGenericError(error); + } + + return convertUnknownError(error); + } + + return { + error: 'UNKNOWN_ERROR', + error_description: String(error), + }; +} + +function convertApiError(apiError: ApiError): MastodonError { + const mastoError: MastodonError & Partial = { + error: apiError.code, + error_description: apiError.message, + ...apiError, + }; + + delete mastoError.code; + delete mastoError.message; + + return mastoError; +} + +function convertUnknownError(data: object = {}): MastodonError { + return Object.assign({}, data, { + error: 'INTERNAL_ERROR', + error_description: 'Internal error occurred. Please contact us if the error persists.', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + }); +} + +function convertGenericError(error: Error): MastodonError { + const mastoError: MastodonError & Partial = { + error: 'INTERNAL_ERROR', + error_description: String(error), + ...error, + }; + + delete mastoError.name; + delete mastoError.message; + delete mastoError.stack; + + return mastoError; +} + +export function getErrorStatus(error: unknown): number { + // AxiosError, comes from the backend + if (typeof(error) === 'object' && error) { + if ('response' in error) { + if (typeof (error.response) === 'object' && error.response) { + if ('status' in error.response) { + if (typeof(error.response.status) === 'number') { + return error.response.status; } } } } - return error as Record; } - return { error }; + + if (error instanceof ApiError && error.httpStatusCode) { + return error.httpStatusCode; + } + + return 500; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index a5d7d89f7d..6ae6ea7c6a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -5,7 +5,6 @@ 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'; @@ -31,32 +30,25 @@ export class ApiAccountMastodon { private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, private readonly driveService: DriveService, ) {} 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); - } + 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); }); fastify.patch<{ @@ -80,318 +72,230 @@ export class ApiAccountMastodon { }, }>('/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'; + 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, }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const header = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'header'; + 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 (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; - } + 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 - 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); + 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); + const response = await this.mastoConverters.convertAccount(data.data); + + reply.send(response); }); 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"' }); - - 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]); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/lookup', data); - reply.code(401).send(data); - } + if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); + + 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]); + + reply.send(response); }); fastify.get('/v1/accounts/relationships', async (_request, reply) => { - try { - let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; - if (typeof ids === 'string') { - ids = [ids]; - } + let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; + if (typeof ids === 'string') { + ids = [ids]; + } - const client = this.clientService.getClient(_request); - const data = await client.getRelationships(ids); - const response = data.data.map(relationship => convertRelationship(relationship)); + const client = this.clientService.getClient(_request); + const data = await client.getRelationships(ids); + const response = data.data.map(relationship => convertRelationship(relationship)); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/relationships', data); - reply.code(401).send(data); - } + reply.send(response); }); 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"' }); - - const client = this.clientService.getClient(_request); - 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); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccount(_request.params.id); + const account = await this.mastoConverters.convertAccount(data.data); + + reply.send(account); }); 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.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))); - - 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); - } + 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.getAccountStatuses(_request.params.id, parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); + + reply.send(response); }); 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"' }); - - const client = this.clientService.getClient(_request); - const data = await client.getFeaturedTags(); - const response = data.data.map((tag) => convertFeaturedTag(tag)); - - 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); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getFeaturedTags(); + const response = data.data.map((tag) => convertFeaturedTag(tag)); + + reply.send(response); }); 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); - } + 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); }); 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); - } + 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); }); 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); - } + 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); }); 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); - } + 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); }); 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); - } + 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); }); 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); - } + 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); }); 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); - } + 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); }); 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); - } + 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); }); 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"' }); - - 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); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unmuteAccount(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index 17b9ba889d..e1c5f27739 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -4,7 +4,6 @@ */ 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'; @@ -61,60 +60,53 @@ type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; 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"' }); + 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); - } + 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); - } + 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); - } + } + if (s.match(/^write/)) { + for (const r of writeScope) { + pushScope.add(r); } } + } - const red = body.redirect_uris; + 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 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, - }; + 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); - } + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 10353ff7af..7f986974fc 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -6,7 +6,6 @@ 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 { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; @@ -28,105 +27,74 @@ interface ApiFilterMastodonRoute { export class ApiFilterMastodon { constructor( private readonly clientService: MastodonClientService, - private readonly logger: MastodonLogger, ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.get('/v1/filters', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - - const data = await client.getFilters(); - const response = data.data.map((filter) => convertFilter(filter)); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/filters', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + + const data = await client.getFilters(); + const response = data.data.map((filter) => convertFilter(filter)); + + reply.send(response); }); 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); - } + 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); }); 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); - } + 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); }); 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); - } + 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); }); 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); - - 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); - } + 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); + + reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index ffffb5e537..bc7ef69100 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -10,12 +10,9 @@ 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( @@ -29,82 +26,75 @@ export class ApiInstanceMastodon { 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 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, + 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, }, - 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, - }, + reactions: { + max_reactions: 1, }, - contact_account: contact, - rules: [], - }; + }, + 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); - } + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 0dba247e5f..27e6cbcd0d 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -6,7 +6,6 @@ import { Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; -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'; @@ -23,75 +22,50 @@ export class ApiNotificationsMastodon { constructor( private readonly mastoConverters: MastoConverters, private readonly clientService: MastodonClientService, - private readonly logger: MastodonLogger, ) {} 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; - })); + 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); - } + reply.send(response); }); fastify.get('/v1/notification/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + 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.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); + 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); }); 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"' }); + 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); + 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); - } + reply.send(data.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(); + 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); - } + reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 3b1c984c3e..814e2cf776 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -5,7 +5,6 @@ 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 '../argsUtils.js'; import Account = Entity.Account; @@ -24,107 +23,82 @@ export class ApiSearchMastodon { constructor( private readonly mastoConverters: MastoConverters, private readonly clientService: MastodonClientService, - private readonly logger: MastodonLogger, ) {} 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"' }); + if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); - const query = parseTimelineArgs(_request.query); - const client = this.clientService.getClient(_request); - const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); + const query = parseTimelineArgs(_request.query); + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/search', data); - reply.code(401).send(data); - } + reply.send(data.data); }); fastify.get('/v2/search', async (_request, reply) => { - try { - if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + 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 ?? [], - }; + 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); - } + reply.send(response); }); 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))); + 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); - } + reply.send(response); }); 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), - }; - })); + 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), + }; + })); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/suggestions', data); - reply.code(401).send(data); - } + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index ba61918b75..8b9ccf44b6 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -6,7 +6,6 @@ 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/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; @@ -22,154 +21,99 @@ function normalizeQuery(data: Record) { export class ApiStatusMastodon { constructor( private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, private readonly clientService: MastodonClientService, ) {} public register(fastify: FastifyInstance): void { fastify.get<{ 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, me } = await this.clientService.getAuthClient(_request); - const data = await client.getStatus(_request.params.id); - 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); - } + 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); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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"' }); + 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); + const client = this.clientService.getClient(_request); + const data = await client.getStatusSource(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/source`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } + reply.send(data.data); }); 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.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))); - 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); - } + 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.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))); + const response = { ancestors, descendants }; + + reply.send(response); }); 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const user = await this.clientService.getAuth(_request); - const edits = await this.mastoConverters.getEdits(_request.params.id, user); + 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); - this.logger.error(`GET /v1/statuses/${_request.params.id}/history`, data); - reply.code(401).send(data); - } + reply.send(edits); }); 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); - 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); - } + 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); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); }); 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); - 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); - } + 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); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); }); 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); - 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); - } + 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); + const response = convertAttachment(data.data); + + reply.send(response); }); 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); - 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); - } + 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); + const response = convertPoll(data.data); + + reply.send(response); }); 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); - 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); - } + 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); + const response = convertPoll(data.data); + + reply.send(response); }); fastify.post<{ @@ -196,60 +140,55 @@ export class ApiStatusMastodon { } }>('/v1/statuses', async (_request, reply) => { let body = _request.body; - try { - const { client, me } = await this.clientService.getAuthClient(_request); - if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) - ) { - body = normalizeQuery(body); - } - const text = body.status ??= ' '; - const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); - const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); - const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); - if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { - const a = await client.createEmojiReaction( - body.in_reply_to_id, - removed, - ); - reply.send(a.data); - } - if (body.in_reply_to_id && removed === '/unreact') { - const id = body.in_reply_to_id; - const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter(e => e.me)[0].name; - const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); - } - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - - if (body.poll && !body.poll.options) { - return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); - } - if (body.poll && !body.poll.expires_in) { - return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); - } - - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; - - const data = await client.postStatus(text, options); - 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); + if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) + ) { + body = normalizeQuery(body); + } + const text = body.status ??= ' '; + const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); + const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); + const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); + + const { client, me } = await this.clientService.getAuthClient(_request); + if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { + const a = await client.createEmojiReaction( + body.in_reply_to_id, + removed, + ); + reply.send(a.data); } + if (body.in_reply_to_id && removed === '/unreact') { + const id = body.in_reply_to_id; + const post = await client.getStatus(id); + const react = post.data.emoji_reactions.filter(e => e.me)[0].name; + const data = await client.deleteEmojiReaction(id, react); + reply.send(data.data); + } + if (!body.media_ids) body.media_ids = undefined; + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; + + if (body.poll && !body.poll.options) { + return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); + } + if (body.poll && !body.poll.expires_in) { + return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); + } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.postStatus(text, options); + const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); + + reply.send(response); }); fastify.put<{ @@ -267,210 +206,138 @@ export class ApiStatusMastodon { }, } }>('/v1/statuses/:id', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); - const body = _request.body; - - if (!body.media_ids || !body.media_ids.length) { - body.media_ids = undefined; - } - - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options, - expires_in: toInt(body.poll.expires_in), - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; - - const data = await client.editStatus(_request.params.id, options); - 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); + const { client, me } = await this.clientService.getAuthClient(_request); + const body = _request.body; + + if (!body.media_ids || !body.media_ids.length) { + body.media_ids = undefined; } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options, + expires_in: toInt(body.poll.expires_in), + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.editStatus(_request.params.id, options); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, '❤'); - 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); - } + 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.createEmojiReaction(_request.params.id, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - try { - 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, '❤'); - 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); - } + 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, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.reblogStatus(_request.params.id); - 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); - } + 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.reblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.unreblogStatus(_request.params.id); - 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); - } + 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.unreblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.bookmarkStatus(_request.params.id); - 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); - } + 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.bookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.unbookmarkStatus(_request.params.id); - 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); - } + 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.unbookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.pinStatus(_request.params.id); - 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); - } + 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.pinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.unpinStatus(_request.params.id); - 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); - } + 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.unpinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - 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); - } + 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.clientService.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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.clientService.getAuthClient(_request); - const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - 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); - } + 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.clientService.getAuthClient(_request); + const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); 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"' }); + 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); + const client = this.clientService.getClient(_request); + const data = await client.deleteStatus(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/statuses/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 864fdc7691..7dee9a062c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -4,7 +4,6 @@ */ 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 { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; @@ -16,216 +15,136 @@ export class ApiTimelineMastodon { constructor( private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, ) {} public register(fastify: FastifyInstance): void { fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); - - const query = parseTimelineArgs(_request.query); - const data = toBoolean(_request.query.local) - ? 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); - } + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = toBoolean(_request.query.local) + ? 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); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - try { - 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); - } + 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); }); 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.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); - } + if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); + + 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); }); 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.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); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + 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); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - try { - 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); - this.logger.error('GET /v1/conversations', data); - reply.code(401).send(data); - } + 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); }); 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 client = this.clientService.getClient(_request); - const data = await client.getList(_request.params.id); - 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); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getList(_request.params.id); + const response = convertList(data.data); + + reply.send(response); }); fastify.get('/v1/lists', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getLists(); - 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); - } + const client = this.clientService.getClient(_request); + const data = await client.getLists(); + const response = data.data.map((list: Entity.List) => convertList(list)); + + reply.send(response); }); 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 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); - this.logger.error(`GET /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + 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); }); 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 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); - this.logger.error(`POST /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + 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 client = this.clientService.getClient(_request); + const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); + + reply.send(data.data); }); 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 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); - this.logger.error(`DELETE /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + 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 client = this.clientService.getClient(_request); + const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); + + reply.send(data.data); }); 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 client = this.clientService.getClient(_request); - const data = await client.createList(_request.body.title); - 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); - } + if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + + const client = this.clientService.getClient(_request); + const data = await client.createList(_request.body.title); + const response = convertList(data.data); + + reply.send(response); }); 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 client = this.clientService.getClient(_request); - const data = await client.updateList(_request.params.id, _request.body.title); - 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); - } + 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 client = this.clientService.getClient(_request); + const data = await client.updateList(_request.params.id, _request.body.title); + const response = convertList(data.data); + + reply.send(response); }); 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 client = this.clientService.getClient(_request); - await client.deleteList(_request.params.id); - - reply.send({}); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + await client.deleteList(_request.params.id); + + reply.send({}); }); } } -- cgit v1.2.3-freya From 4a1dd7165edab1984313b8198b163ba3cccafbb5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 21:48:25 -0400 Subject: normalize mastodon BAD_REQUEST errors --- .../api/mastodon/MastodonApiServerService.ts | 8 ++-- .../src/server/api/mastodon/endpoints/account.ts | 29 +++++++------ .../src/server/api/mastodon/endpoints/apps.ts | 6 +-- .../src/server/api/mastodon/endpoints/filter.ts | 14 +++--- .../server/api/mastodon/endpoints/notifications.ts | 4 +- .../src/server/api/mastodon/endpoints/search.ts | 5 ++- .../src/server/api/mastodon/endpoints/status.ts | 50 +++++++++++----------- .../src/server/api/mastodon/endpoints/timeline.ts | 24 +++++------ .../src/server/oauth/OAuth2ProviderService.ts | 4 +- 9 files changed, 73 insertions(+), 71 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index eca0883e65..2735856139 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -110,7 +110,7 @@ export class MastodonApiServerService { }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + if (!_request.body.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "id"' }); const client = this.clientService.getClient(_request); const data = await client.dismissInstanceAnnouncement(_request.body.id); @@ -222,7 +222,7 @@ export class MastodonApiServerService { }); fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.acceptFollowRequest(_request.params.id); @@ -232,7 +232,7 @@ export class MastodonApiServerService { }); fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.rejectFollowRequest(_request.params.id); @@ -253,7 +253,7 @@ export class MastodonApiServerService { is_sensitive?: string, }, }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const options = { ..._request.body, diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 6ae6ea7c6a..d25f43193a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -143,7 +143,7 @@ export class ApiAccountMastodon { }); fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { - if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); + if (!_request.query.acct) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "acct"' }); const client = this.clientService.getClient(_request); const data = await client.search(_request.query.acct, { type: 'accounts' }); @@ -168,7 +168,7 @@ export class ApiAccountMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccount(_request.params.id); @@ -178,17 +178,18 @@ export class ApiAccountMastodon { }); fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getAccountStatuses(_request.params.id, parseTimelineArgs(_request.query)); + const args = parseTimelineArgs(_request.query); + const data = await client.getAccountStatuses(_request.params.id, args); const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getFeaturedTags(); @@ -198,7 +199,7 @@ export class ApiAccountMastodon { }); fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountFollowers( @@ -211,7 +212,7 @@ export class ApiAccountMastodon { }); fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountFollowing( @@ -224,7 +225,7 @@ export class ApiAccountMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountLists(_request.params.id); @@ -234,7 +235,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.followAccount(_request.params.id); @@ -245,7 +246,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.unfollowAccount(_request.params.id); @@ -256,7 +257,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.blockAccount(_request.params.id); @@ -266,7 +267,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.unblockAccount(_request.params.id); @@ -276,7 +277,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.muteAccount( @@ -289,7 +290,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.unmuteAccount(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index e1c5f27739..dbef3b7d35 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -65,9 +65,9 @@ export class ApiAppsMastodon { public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { 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"' }); + if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' }); + if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' }); + if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' }); let scope = body.scopes; if (typeof scope === 'string') { diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 7f986974fc..d02ddd1999 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -40,7 +40,7 @@ export class ApiFilterMastodon { }); fastify.get('/v1/filters/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getFilter(_request.params.id); @@ -50,8 +50,8 @@ export class ApiFilterMastodon { }); fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - 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"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); const options = { phrase: _request.body.phrase, @@ -69,9 +69,9 @@ export class ApiFilterMastodon { }); fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); const options = { phrase: _request.body.phrase, @@ -89,7 +89,7 @@ export class ApiFilterMastodon { }); fastify.delete('/v1/filters/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.deleteFilter(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 27e6cbcd0d..5b03c21d6f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -40,7 +40,7 @@ export class ApiNotificationsMastodon { }); fastify.get('/v1/notification/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getNotification(_request.params.id); @@ -53,7 +53,7 @@ export class ApiNotificationsMastodon { }); fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.dismissNotification(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 814e2cf776..34d82096ba 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -27,7 +27,8 @@ export class ApiSearchMastodon { public register(fastify: FastifyInstance): void { fastify.get('/v1/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (!_request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); const query = parseTimelineArgs(_request.query); const client = this.clientService.getClient(_request); @@ -37,7 +38,7 @@ export class ApiSearchMastodon { }); fastify.get('/v2/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); const query = parseTimelineArgs(_request.query); const type = _request.query.type; diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 8b9ccf44b6..e64df3d74c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -26,7 +26,7 @@ export class ApiStatusMastodon { public register(fastify: FastifyInstance): void { fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getStatus(_request.params.id); @@ -36,7 +36,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getStatusSource(_request.params.id); @@ -45,7 +45,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); @@ -57,7 +57,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const user = await this.clientService.getAuth(_request); const edits = await this.mastoConverters.getEdits(_request.params.id, user); @@ -66,7 +66,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getStatusRebloggedBy(_request.params.id); @@ -76,7 +76,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getStatusFavouritedBy(_request.params.id); @@ -86,7 +86,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getMedia(_request.params.id); @@ -96,7 +96,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getPoll(_request.params.id); @@ -106,8 +106,8 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { - 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.choices) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "choices"' }); const client = this.clientService.getClient(_request); const data = await client.votePoll(_request.params.id, _request.body.choices); @@ -168,10 +168,10 @@ export class ApiStatusMastodon { if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; if (body.poll && !body.poll.options) { - return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.options"' }); } if (body.poll && !body.poll.expires_in) { - return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.expires_in"' }); } const options = { @@ -231,7 +231,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, '❤'); @@ -241,7 +241,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, '❤'); @@ -251,7 +251,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.reblogStatus(_request.params.id); @@ -261,7 +261,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unreblogStatus(_request.params.id); @@ -271,7 +271,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.bookmarkStatus(_request.params.id); @@ -281,7 +281,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unbookmarkStatus(_request.params.id); @@ -290,7 +290,7 @@ export class ApiStatusMastodon { reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.pinStatus(_request.params.id); @@ -300,7 +300,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unpinStatus(_request.params.id); @@ -310,8 +310,8 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, _request.params.name); @@ -321,8 +321,8 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); @@ -332,7 +332,7 @@ export class ApiStatusMastodon { }); fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.deleteStatus(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 7dee9a062c..975aa9d04b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -39,7 +39,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); + if (!_request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); const { client, me } = await this.clientService.getAuthClient(_request); const query = parseTimelineArgs(_request.query); @@ -50,7 +50,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const query = parseTimelineArgs(_request.query); @@ -70,7 +70,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getList(_request.params.id); @@ -88,7 +88,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountsInList(_request.params.id, _request.query); @@ -98,8 +98,8 @@ export class ApiTimelineMastodon { }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); const client = this.clientService.getClient(_request); const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); @@ -108,8 +108,8 @@ export class ApiTimelineMastodon { }); fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); const client = this.clientService.getClient(_request); const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); @@ -118,7 +118,7 @@ export class ApiTimelineMastodon { }); fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); const client = this.clientService.getClient(_request); const data = await client.createList(_request.body.title); @@ -128,8 +128,8 @@ export class ApiTimelineMastodon { }); fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { - 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"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); const client = this.clientService.getClient(_request); const data = await client.updateList(_request.params.id, _request.body.title); @@ -139,7 +139,7 @@ export class ApiTimelineMastodon { }); fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); await client.deleteList(_request.params.id); diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 86d903f223..87c09abaf4 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -128,7 +128,7 @@ export class OAuth2ProviderService { 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"' }); + if (typeof(request.query.client_id) !== 'string') return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_id"' }); const redirectUri = new URL(Buffer.from(request.query.client_id, 'base64').toString()); redirectUri.searchParams.set('mastodon', 'true'); @@ -153,7 +153,7 @@ export class OAuth2ProviderService { } try { - if (!body.client_secret) return reply.code(400).send({ error: 'Missing required query "client_secret"' }); + if (!body.client_secret) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_secret"' }); const clientId = body.client_id ? String(body.clientId) : null; const secret = String(body.client_secret); -- cgit v1.2.3-freya From 8b0555cab899dbccde46ba1f73d91d9fe547e2d7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 21:48:51 -0400 Subject: fix /api/v1/instance response --- .../backend/src/server/api/mastodon/endpoints/instance.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index bc7ef69100..37f64979b4 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -11,7 +11,9 @@ import { DI } from '@/di-symbols.js'; import type { MiMeta, UsersRepository } from '@/models/_.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { RoleService } from '@/core/RoleService.js'; import type { FastifyInstance } from 'fastify'; +import type { MastodonEntity } from 'megalodon'; @Injectable() export class ApiInstanceMastodon { @@ -27,11 +29,12 @@ export class ApiInstanceMastodon { private readonly mastoConverters: MastoConverters, private readonly clientService: MastodonClientService, + private readonly roleService: RoleService, ) {} public register(fastify: FastifyInstance): void { fastify.get('/v1/instance', async (_request, reply) => { - const client = this.clientService.getClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getInstance(); const instance = data.data; const admin = await this.usersRepository.findOne({ @@ -44,11 +47,11 @@ export class ApiInstanceMastodon { order: { id: 'ASC' }, }); const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); + const roles = await this.roleService.getUserPolicies(me?.id ?? null); - const response = { + const response: MastodonEntity.Instance = { 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})`, @@ -66,6 +69,7 @@ export class ApiInstanceMastodon { configuration: { accounts: { max_featured_tags: 20, + max_pinned_statuses: roles.pinLimit, }, statuses: { max_characters: this.config.maxNoteLength, @@ -77,7 +81,7 @@ export class ApiInstanceMastodon { image_size_limit: 10485760, image_matrix_limit: 16777216, video_size_limit: 41943040, - video_frame_rate_limit: 60, + video_frame_limit: 60, video_matrix_limit: 2304000, }, polls: { @@ -91,7 +95,7 @@ export class ApiInstanceMastodon { }, }, contact_account: contact, - rules: [], + rules: instance.rules ?? [], }; reply.send(response); -- cgit v1.2.3-freya From de26ffd60babc22a15b190c1a38af242140887b9 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 22:51:33 -0400 Subject: improve performance of /v1/accounts/relationships --- .../backend/src/server/api/mastodon/endpoints/account.ts | 9 +++------ packages/megalodon/src/megalodon.ts | 2 +- packages/megalodon/src/misskey.ts | 15 ++++++++++----- 3 files changed, 14 insertions(+), 12 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index d25f43193a..17ec9a97dd 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -154,14 +154,11 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; - if (typeof ids === 'string') { - ids = [ids]; - } + fastify.get('/v1/accounts/relationships', async (_request, reply) => { + if (!_request.query.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "id"' }); const client = this.clientService.getClient(_request); - const data = await client.getRelationships(ids); + const data = await client.getRelationships(_request.query.id); const response = data.data.map(relationship => convertRelationship(relationship)); reply.send(response); diff --git a/packages/megalodon/src/megalodon.ts b/packages/megalodon/src/megalodon.ts index 4328f41f1c..6032c351c9 100644 --- a/packages/megalodon/src/megalodon.ts +++ b/packages/megalodon/src/megalodon.ts @@ -342,7 +342,7 @@ export interface MegalodonInterface { * @param ids Array of account IDs. * @return Array of Relationship. */ - getRelationships(ids: Array): Promise>> + getRelationships(ids: string | Array): Promise>> /** * Search for matching accounts by username or display name. * diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index c9a33e3130..cf6adbb70d 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -606,11 +606,16 @@ export default class Misskey implements MegalodonInterface { * * @param ids Array of account ID, for example `['1sdfag', 'ds12aa']`. */ - public async getRelationships(ids: Array): Promise>> { - return Promise.all(ids.map(id => this.getRelationship(id))).then(results => ({ - ...results[0], - data: results.map(r => r.data) - })) + public async getRelationships(ids: string | Array): Promise>> { + return this.client + .post('/api/users/relation', { + userId: ids + }) + .then(res => { + return Object.assign(res, { + data: res.data.map(r => MisskeyAPI.Converter.relation(r)) + }) + }) } /** -- cgit v1.2.3-freya From f00a0fee4508a692d06a4216dd35049a6b9f6c85 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 22:52:42 -0400 Subject: minor fixes to /v1/accounts/verify_credentials --- packages/backend/src/server/api/mastodon/endpoints/account.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 17ec9a97dd..6f6999f2e1 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -35,15 +35,14 @@ export class ApiAccountMastodon { public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - const client = await this.clientService.getClient(_request); + const client = 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: '', + privacy: 'public', sensitive: false, language: '', }, -- cgit v1.2.3-freya From cac8377e4ea6e0af507684528d386ae4f1ffd94e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 23:24:55 -0400 Subject: fix empty response from /api/v1/notifications --- packages/backend/src/server/api/mastodon/endpoints/notifications.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 5b03c21d6f..3b2833bf86 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -28,7 +28,7 @@ export class ApiNotificationsMastodon { fastify.get('/v1/notifications', async (_request, reply) => { 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 response = await Promise.all(data.data.map(async n => { const converted = await this.mastoConverters.convertNotification(n, me); if (converted.type === 'reaction') { converted.type = 'favourite'; -- cgit v1.2.3-freya From fc1d0c958c0fa9eb4a495ef9138d4250f2ba81a0 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 14:19:32 -0400 Subject: support Mastodon v4 "link header" pagination --- .../server/api/mastodon/MastodonClientService.ts | 14 +- .../src/server/api/mastodon/endpoints/account.ts | 36 +++-- .../server/api/mastodon/endpoints/notifications.ts | 8 +- .../src/server/api/mastodon/endpoints/search.ts | 107 +++++++++---- .../src/server/api/mastodon/endpoints/timeline.ts | 68 +++++---- .../backend/src/server/api/mastodon/pagination.ts | 170 +++++++++++++++++++++ 6 files changed, 321 insertions(+), 82 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/pagination.ts (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts index 474aaefb35..d7b74bb751 100644 --- a/packages/backend/src/server/api/mastodon/MastodonClientService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts @@ -51,12 +51,14 @@ export class MastodonClientService { return new Misskey(baseUrl, accessToken, userAgent); } - /** - * Gets the base URL (origin) of the incoming request - */ - public getBaseUrl(request: FastifyRequest): string { - return `${request.protocol}://${request.host}`; - } + readonly getBaseUrl = getBaseUrl; +} + +/** + * Gets the base URL (origin) of the incoming request + */ +export function getBaseUrl(request: FastifyRequest): string { + return `${request.protocol}://${request.host}`; } /** diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 6f6999f2e1..f669b71efb 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -9,6 +9,7 @@ import { MastodonClientService } from '@/server/api/mastodon/MastodonClientServi 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 type multer from 'fastify-multer'; import type { FastifyInstance } from 'fastify'; @@ -173,14 +174,15 @@ export class ApiAccountMastodon { reply.send(account); }); - fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/statuses', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const args = parseTimelineArgs(_request.query); - const data = await client.getAccountStatuses(_request.params.id, args); + const { client, me } = await this.clientService.getAuthClient(request); + const args = parseTimelineArgs(request.query); + const data = await client.getAccountStatuses(request.params.id, args); const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); @@ -194,29 +196,31 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/followers', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(request); const data = await client.getAccountFollowers( - _request.params.id, - parseTimelineArgs(_request.query), + request.params.id, + parseTimelineArgs(request.query), ); const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/following', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(request); const data = await client.getAccountFollowing( - _request.params.id, - parseTimelineArgs(_request.query), + request.params.id, + parseTimelineArgs(request.query), ); const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); @@ -236,7 +240,7 @@ export class ApiAccountMastodon { const client = this.clientService.getClient(_request); const data = await client.followAccount(_request.params.id); const acct = convertRelationship(data.data); - acct.following = true; + acct.following = true; // TODO this is wrong, follow may not have processed immediately reply.send(acct); }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 3b2833bf86..6acb9edd6b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonClientService } from '../MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; @@ -25,9 +26,9 @@ export class ApiNotificationsMastodon { ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { - fastify.get('/v1/notifications', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getNotifications(parseTimelineArgs(_request.query)); + fastify.get('/v1/notifications', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const data = await client.getNotifications(parseTimelineArgs(request.query)); const response = await Promise.all(data.data.map(async n => { const converted = await this.mastoConverters.convertNotification(n, me); if (converted.type === 'reaction') { @@ -36,6 +37,7 @@ export class ApiNotificationsMastodon { return converted; })); + attachMinMaxPagination(request, reply, response); reply.send(response); }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 34d82096ba..997a585077 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -5,16 +5,18 @@ 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 { parseTimelineArgs, TimelineArgs } from '../argsUtils.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; import Account = Entity.Account; import Status = Entity.Status; import type { FastifyInstance } from 'fastify'; interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { - type?: 'accounts' | 'hashtags' | 'statuses'; + type?: string; q?: string; + resolve?: string; } } @@ -26,66 +28,116 @@ export class ApiSearchMastodon { ) {} public register(fastify: FastifyInstance): void { - fastify.get('/v1/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); - if (!_request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); + fastify.get('/v1/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (!request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); - const query = parseTimelineArgs(_request.query); - const client = this.clientService.getClient(_request); - const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); + const type = request.query.type; + if (type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } - reply.send(data.data); + const { client, me } = await this.clientService.getAuthClient(request); + + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } + + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const { data } = await client.search(request.query.q, { type, ...query }); + const response = { + ...data, + accounts: await Promise.all(data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account))), + statuses: await Promise.all(data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me))), + }; + + if (type === 'hashtags') { + attachOffsetPagination(request, reply, response.hashtags); + } else { + attachMinMaxPagination(request, reply, response[type]); + } + + reply.send(response); }); - fastify.get('/v2/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + fastify.get('/v2/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + + const type = request.query.type; + if (type !== undefined && type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } + + const { client, me } = await this.clientService.getAuthClient(request); + + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } - 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; + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + 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)) ?? []), + accounts: await Promise.all(acct?.data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me)) ?? []), hashtags: tags?.data.hashtags ?? [], }; + // Pagination hack, based on "best guess" expected behavior. + // Mastodon doesn't document this part at all! + const longestResult = [response.statuses, response.hashtags] + .reduce((longest: unknown[], current: unknown[]) => current.length > longest.length ? current : longest, response.accounts); + + // Ignore min/max pagination because how TF would that work with multiple result sets?? + // Offset pagination is the only possible option + attachOffsetPagination(request, reply, longestResult); + reply.send(response); }); - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const baseUrl = this.clientService.getBaseUrl(_request); + fastify.get('/v1/trends/statuses', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); const res = await fetch(`${baseUrl}/api/notes/featured`, { method: 'POST', headers: { - ..._request.headers as HeadersInit, + ...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 me = await this.clientService.getAuth(request); const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get('/v2/suggestions', async (_request, reply) => { - const baseUrl = this.clientService.getBaseUrl(_request); + fastify.get('/v2/suggestions', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); const res = await fetch(`${baseUrl}/api/users`, { method: 'POST', headers: { - ..._request.headers as HeadersInit, + ...request.headers as HeadersInit, 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ - limit: parseTimelineArgs(_request.query).limit ?? 20, + limit: parseTimelineArgs(request.query).limit ?? 20, origin: 'local', sort: '+follower', state: 'alive', @@ -99,6 +151,7 @@ export class ApiSearchMastodon { }; })); + attachOffsetPagination(request, reply, response); reply.send(response); }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 975aa9d04b..a333e77c3e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -5,6 +5,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 { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; @@ -18,55 +19,60 @@ export class ApiTimelineMastodon { ) {} public register(fastify: FastifyInstance): void { - fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = toBoolean(_request.query.local) + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = toBoolean(request.query.local) ? await client.getLocalTimeline(query) : await client.getPublicTimeline(query); const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => { + 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))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); + fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => { + if (!request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getTagTimeline(_request.params.hashtag, query); + 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))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getListTimeline(_request.params.id, query); + 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))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); + fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => { + 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))); + const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); - reply.send(conversations); + attachMinMaxPagination(request, reply, response); + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -79,22 +85,24 @@ export class ApiTimelineMastodon { reply.send(response); }); - fastify.get('/v1/lists', async (_request, reply) => { - const client = this.clientService.getClient(_request); + fastify.get('/v1/lists', async (request, reply) => { + const client = this.clientService.getClient(request); const data = await client.getLists(); const response = data.data.map((list: Entity.List) => convertList(list)); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - 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))); + const client = this.clientService.getClient(request); + const data = await client.getAccountsInList(request.params.id, parseTimelineArgs(request.query)); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(accounts); + attachMinMaxPagination(request, reply, response); + reply.send(response); }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { diff --git a/packages/backend/src/server/api/mastodon/pagination.ts b/packages/backend/src/server/api/mastodon/pagination.ts new file mode 100644 index 0000000000..2cf24cfb24 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/pagination.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FastifyReply, FastifyRequest } from 'fastify'; +import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js'; + +interface AnyEntity { + readonly id: string; +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by min_id / max_id parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachMinMaxPagination(request: FastifyRequest, reply: FastifyReply, results: AnyEntity[]): void { + // No results, nothing to do + if (!hasItems(results)) return; + + // "next" link - older results + const oldest = findOldest(results); + const nextUrl = createPaginationUrl(request, { max_id: oldest }); // Next page (older) has IDs less than the oldest of this page + const next = `<${nextUrl}>; rel="next"`; + + // "prev" link - newer results + const newest = findNewest(results); + const prevUrl = createPaginationUrl(request, { min_id: newest }); // Previous page (newer) has IDs greater than the newest of this page + const prev = `<${prevUrl}>; rel="prev"`; + + // https://docs.joinmastodon.org/api/guidelines/#pagination + const link = `${next}, ${prev}`; + reply.header('link', link); +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by limit / offset parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachOffsetPagination(request: FastifyRequest, reply: FastifyReply, results: unknown[]): void { + const links: string[] = []; + + // Find initial offset + const offset = findOffset(request); + const limit = findLimit(request); + + // "next" link - older results + if (hasItems(results)) { + const oldest = offset + results.length; + const nextUrl = createPaginationUrl(request, { offset: oldest }); // Next page (older) has entries less than the oldest of this page + links.push(`<${nextUrl}>; rel="next"`); + } + + // "prev" link - newer results + // We can only paginate backwards if a limit is specified + if (limit) { + // Make sure we don't cross below 0, as that will produce an API error + if (limit <= offset) { + const newest = offset - limit; + const prevUrl = createPaginationUrl(request, { offset: newest }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } else { + const prevUrl = createPaginationUrl(request, { offset: 0, limit: offset }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } + } + + // https://docs.joinmastodon.org/api/guidelines/#pagination + if (links.length > 0) { + const link = links.join(', '); + reply.header('link', link); + } +} + +function hasItems(items: T[]): items is [T, ...T[]] { + return items.length > 0; +} + +function findOffset(request: FastifyRequest): number { + if (typeof(request.query) !== 'object') return 0; + + const query = request.query as Record; + if (!query.offset) return 0; + + if (Array.isArray(query.offset)) { + const offsets = query.offset + .map(o => parseInt(o)) + .filter(o => !isNaN(o)); + const offset = Math.max(...offsets); + return isNaN(offset) ? 0 : offset; + } + + const offset = parseInt(query.offset); + return isNaN(offset) ? 0 : offset; +} + +function findLimit(request: FastifyRequest): number | null { + if (typeof(request.query) !== 'object') return null; + + const query = request.query as Record; + if (!query.limit) return null; + + if (Array.isArray(query.limit)) { + const limits = query.limit + .map(l => parseInt(l)) + .filter(l => !isNaN(l)); + const limit = Math.max(...limits); + return isNaN(limit) ? null : limit; + } + + const limit = parseInt(query.limit); + return isNaN(limit) ? null : limit; +} + +function findOldest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? first : last; +} + +function findNewest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? last : first; +} + +function isOlder(a: string, b: string): boolean { + if (a === b) return false; + + if (a.length !== b.length) { + return a.length < b.length; + } + + return a < b; +} + +function createPaginationUrl(request: FastifyRequest, data: { + min_id?: string; + max_id?: string; + offset?: number; + limit?: number; +}): string { + const baseUrl = getBaseUrl(request); + const requestUrl = new URL(request.url, baseUrl); + + // Remove any existing pagination + requestUrl.searchParams.delete('min_id'); + requestUrl.searchParams.delete('max_id'); + requestUrl.searchParams.delete('since_id'); + requestUrl.searchParams.delete('offset'); + + if (data.min_id) requestUrl.searchParams.set('min_id', data.min_id); + if (data.max_id) requestUrl.searchParams.set('max_id', data.max_id); + if (data.offset) requestUrl.searchParams.set('offset', String(data.offset)); + if (data.limit) requestUrl.searchParams.set('limit', String(data.limit)); + + return requestUrl.href; +} -- cgit v1.2.3-freya From 3c5468086047a29cc56c90ab6547484364c22326 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 18:18:54 -0400 Subject: support reactions in mastodon API --- .../backend/src/server/api/mastodon/converters.ts | 62 ++++++++++++---------- .../server/api/mastodon/endpoints/notifications.ts | 23 ++++---- packages/megalodon/src/entities/reaction.ts | 2 + .../megalodon/src/mastodon/entities/reaction.ts | 16 ++++++ packages/megalodon/src/mastodon/entities/status.ts | 3 ++ packages/megalodon/src/mastodon/entity.ts | 1 + packages/megalodon/src/misskey.ts | 1 + packages/megalodon/src/misskey/api_client.ts | 28 +++++----- 8 files changed, 82 insertions(+), 54 deletions(-) create mode 100644 packages/megalodon/src/mastodon/entities/reaction.ts (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 0e468f9377..1adbd95642 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -180,10 +180,10 @@ export class MastoConverters { note: profile?.description ?? '', url: user.uri ?? acctUrl, uri: user.uri ?? acctUri, - avatar: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', - avatar_static: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', - header: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', - header_static: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', + 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)) ?? [], @@ -196,7 +196,7 @@ export class MastoConverters { }); } - public async getEdits(id: string, me?: MiLocalUser | null): Promise { + public async getEdits(id: string, me: MiLocalUser | null): Promise { const note = await this.mastodonDataService.getNote(id, me); if (!note) { return []; @@ -213,7 +213,7 @@ export class MastoConverters { account: noteUser, content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', created_at: lastDate.toISOString(), - emojis: [], + 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)) : [], @@ -222,15 +222,15 @@ export class MastoConverters { history.push(item); } - return await Promise.all(history); + return history; } - private async convertReblog(status: Entity.Status | null, me?: MiLocalUser | null): Promise { + 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 { + 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); @@ -279,7 +279,6 @@ export class MastoConverters { : ''; const reblogged = await this.mastodonDataService.hasReblog(note.id, me); - const reactions = await Promise.all(status.emoji_reactions.map(r => this.convertReaction(r))); // noinspection ES6MissingAwait return await awaitAll({ @@ -289,11 +288,12 @@ export class MastoConverters { account: convertedAccount, in_reply_to_id: note.replyId, in_reply_to_account_id: note.replyUserId, - reblog: !isQuote ? await this.convertReblog(status.reblog, me) : null, + 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, @@ -301,7 +301,7 @@ export class MastoConverters { reblogged, favourited: status.favourited, muted: status.muted, - sensitive: status.sensitive, + sensitive: status.sensitive || !!note.cw, spoiler_text: note.cw ?? '', visibility: status.visibility, media_attachments: status.media_attachments.map(a => convertAttachment(a)), @@ -312,15 +312,14 @@ export class MastoConverters { application: null, //FIXME language: null, //FIXME pinned: false, //FIXME - reactions, - emoji_reactions: reactions, bookmarked: false, //FIXME - quote: isQuote ? await this.convertReblog(status.reblog, me) : null, - edited_at: note.updatedAt?.toISOString() ?? null, + 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 { + 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))), @@ -329,7 +328,7 @@ export class MastoConverters { }; } - public async convertNotification(notification: Entity.Notification, me?: MiLocalUser | null): Promise { + public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { return { account: await this.convertAccount(notification.account), created_at: notification.created_at, @@ -339,12 +338,23 @@ export class MastoConverters { }; } - public async convertReaction(reaction: Entity.Reaction): Promise { - if (reaction.accounts) { - reaction.accounts = await Promise.all(reaction.accounts.map(a => this.convertAccount(a))); - } - return reaction; - } + // 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 { @@ -423,7 +433,3 @@ export function convertRelationship(relationship: Partial & }; } -// noinspection JSUnusedGlobalSymbols -export function convertStatusSource(status: Entity.StatusSource): MastodonEntity.StatusSource { - return simpleConvert(status); -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 6acb9edd6b..120b9ba7f9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -29,13 +29,17 @@ export class ApiNotificationsMastodon { fastify.get('/v1/notifications', async (request, reply) => { const { client, me } = await this.clientService.getAuthClient(request); const data = await client.getNotifications(parseTimelineArgs(request.query)); - const response = await Promise.all(data.data.map(async n => { - const converted = await this.mastoConverters.convertNotification(n, me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; + const notifications = await Promise.all(data.data.map(n => this.mastoConverters.convertNotification(n, me))); + const response: MastodonEntity.Notification[] = []; + for (const notification of notifications) { + response.push(notification); + if (notification.type === 'reaction') { + response.push({ + ...notification, + type: 'favourite', + }); } - return converted; - })); + } attachMinMaxPagination(request, reply, response); reply.send(response); @@ -46,12 +50,9 @@ export class ApiNotificationsMastodon { 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'; - } + const response = await this.mastoConverters.convertNotification(data.data, me); - reply.send(converted); + reply.send(response); }); fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { diff --git a/packages/megalodon/src/entities/reaction.ts b/packages/megalodon/src/entities/reaction.ts index 8c626f9e84..3315eded50 100644 --- a/packages/megalodon/src/entities/reaction.ts +++ b/packages/megalodon/src/entities/reaction.ts @@ -6,5 +6,7 @@ namespace Entity { me: boolean name: string accounts?: Array + url?: string + static_url?: string } } diff --git a/packages/megalodon/src/mastodon/entities/reaction.ts b/packages/megalodon/src/mastodon/entities/reaction.ts new file mode 100644 index 0000000000..370eeb5cbe --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/reaction.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/// + +namespace MastodonEntity { + export type Reaction = { + name: string + count: number + me?: boolean + url?: string + static_url?: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/status.ts b/packages/megalodon/src/mastodon/entities/status.ts index 54b5d3bfe3..76472a8580 100644 --- a/packages/megalodon/src/mastodon/entities/status.ts +++ b/packages/megalodon/src/mastodon/entities/status.ts @@ -6,6 +6,7 @@ /// /// /// +/// namespace MastodonEntity { export type Status = { @@ -41,6 +42,8 @@ namespace MastodonEntity { // These parameters are unique parameters in fedibird.com for quote. quote_id?: string quote?: Status | null + // These parameters are unique to glitch-soc for emoji reactions. + reactions?: Reaction[] } export type StatusTag = { diff --git a/packages/megalodon/src/mastodon/entity.ts b/packages/megalodon/src/mastodon/entity.ts index dcafdfe749..10a3aa71c4 100644 --- a/packages/megalodon/src/mastodon/entity.ts +++ b/packages/megalodon/src/mastodon/entity.ts @@ -22,6 +22,7 @@ /// /// /// +/// /// /// /// diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index dce0fb21b7..eb1e5824b8 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -2555,6 +2555,7 @@ export default class Misskey implements MegalodonInterface { })) } + // TODO implement public async getEmojiReaction(_id: string, _emoji: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError('misskey does not support') diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index a9a592b28c..ce6c4aa6cc 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -286,6 +286,7 @@ namespace MisskeyAPI { plain_content: n.text ? n.text : null, created_at: n.createdAt, edited_at: n.updatedAt || null, + // TODO this is probably wrong emojis: mapEmojis(n.emojis).concat(mapReactionEmojis(n.reactionEmojis)), replies_count: n.repliesCount, reblogs_count: n.renoteCount, @@ -304,7 +305,7 @@ namespace MisskeyAPI { application: null, language: null, pinned: null, - emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.myReaction) : [], + emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.reactionEmojis, n.myReaction) : [], bookmarked: false, quote: n.renote && n.text ? note(n.renote, n.user.host ? n.user.host : host ? host : null) : null } @@ -334,23 +335,20 @@ namespace MisskeyAPI { ) : 0; }; - export const mapReactions = (r: { [key: string]: number }, myReaction?: string): Array => { + export const mapReactions = (r: { [key: string]: number }, e: Record, myReaction?: string): Array => { return Object.keys(r).map(key => { - if (myReaction && key === myReaction) { - return { - count: r[key], - me: true, - name: key - } - } - return { - count: r[key], - me: false, - name: key - } + const me = myReaction != null && key === myReaction; + return { + count: r[key], + me, + name: key, + url: e[key], + static_url: e[key], + } }) } + // TODO implement other properties const mapReactionEmojis = (r: { [key: string]: string }): Array => { return Object.keys(r).map(key => ({ shortcode: key, @@ -371,7 +369,7 @@ namespace MisskeyAPI { result.push({ count: 1, me: false, - name: e.type + name: e.type, }) } }) -- cgit v1.2.3-freya From 984be9e7aa9612fab000cf0f0d8daaf28f42e0ce Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 18:43:08 -0400 Subject: enable local timeline in Phanpy clients --- packages/backend/src/server/api/mastodon/endpoints/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index 37f64979b4..1f08f0a3b0 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -54,7 +54,7 @@ export class ApiInstanceMastodon { title: this.meta.name || 'Sharkey', 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})`, + version: `3.0.0 (compatible; Sharkey ${this.config.version}; like Akkoma)`, urls: instance.urls, stats: { user_count: instance.stats.user_count, -- 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/api/mastodon/endpoints') 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 1fa290c3ebf5aba4f2f81e54a847dc03c3b72b56 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 12:20:31 -0400 Subject: handle errors in mastodon search endpoints --- .../src/server/api/mastodon/endpoints/search.ts | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 7277a35220..796f4cd5f7 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -8,6 +8,7 @@ import { MastodonClientService } from '@/server/api/mastodon/MastodonClientServi import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonConverters } from '../MastodonConverters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; +import { ApiError } from '../../error.js'; import Account = Entity.Account; import Status = Entity.Status; import type { FastifyInstance } from 'fastify'; @@ -118,6 +119,9 @@ export class ApiSearchMastodon { }, body: '{}', }); + + await verifyResponse(res); + 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))); @@ -143,6 +147,9 @@ export class ApiSearchMastodon { state: 'alive', }), }); + + await verifyResponse(res); + const data = await res.json() as Account[]; const response = await Promise.all(data.map(async entry => { return { @@ -156,3 +163,29 @@ export class ApiSearchMastodon { }); } } + +async function verifyResponse(res: Response): Promise { + if (res.ok) return; + + const text = await res.text(); + + if (res.headers.get('content-type') === 'application/json') { + try { + const json = JSON.parse(text); + + if (json && typeof(json) === 'object') { + json.httpStatusCode = res.status; + return json; + } + } catch { /* ignore */ } + } + + // Response is not a JSON object; treat as string + throw new ApiError({ + code: 'INTERNAL_ERROR', + message: text || 'Internal error occurred. Please contact us if the error persists.', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + httpStatusCode: res.status, + }); +} -- cgit v1.2.3-freya From ebc3abea5463fbb70bacb54b6c3df6c0fcad2a9c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 13:27:19 -0400 Subject: hide sensitive content from Discord previews --- packages/backend/src/server/api/mastodon/endpoints/status.ts | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index ea796e4f0b..39c4f44755 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -32,6 +32,12 @@ export class ApiStatusMastodon { const data = await client.getStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); + // Fixup - Discord ignores CWs and renders the entire post. + if (response.sensitive && _request.headers['user-agent']?.match(/\bDiscordbot\//)) { + response.content = '(preview disabled for sensitive content)'; + response.media_attachments = []; + } + reply.send(response); }); -- cgit v1.2.3-freya From 848a07a170322ec18cfcd6b6dd4c1e372656737f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Mar 2025 20:30:04 -0400 Subject: Ignore notifications that reference missing notes --- .../backend/src/server/api/mastodon/MastodonConverters.ts | 13 +++++++++++-- packages/backend/src/server/api/mastodon/MastodonLogger.ts | 2 +- .../src/server/api/mastodon/endpoints/notifications.ts | 10 ++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index 0e8ce5a2a7..e5d732ed79 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -351,12 +351,21 @@ export class MastodonConverters { }; } - public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { + public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { + const status = notification.status + ? await this.convertStatus(notification.status, me).catch(() => null) + : null; + + // We sometimes get notifications for inaccessible notes, these should be ignored. + if (!status) { + return null; + } + 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, + status, type: convertNotificationType(notification.type as NotificationType), }; } diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index 57cf876dff..81d3e8f03d 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -35,7 +35,7 @@ export class MastodonLogger { // TODO move elsewhere export interface MastodonError { error: string; - error_description: string; + error_description?: string; } export function getErrorData(error: unknown): MastodonError { diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index c81b3ca236..c3108c8b3e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -32,6 +32,9 @@ export class ApiNotificationsMastodon { const notifications = await Promise.all(data.data.map(n => this.mastoConverters.convertNotification(n, me))); const response: MastodonEntity.Notification[] = []; for (const notification of notifications) { + // Notifications for inaccessible notes will be null and should be ignored + if (!notification) continue; + response.push(notification); if (notification.type === 'reaction') { response.push({ @@ -52,6 +55,13 @@ export class ApiNotificationsMastodon { const data = await client.getNotification(_request.params.id); const response = await this.mastoConverters.convertNotification(data.data, me); + // Notifications for inaccessible notes will be null and should be ignored + if (!response) { + return reply.code(404).send({ + error: 'NOT_FOUND', + }); + } + reply.send(response); }); -- cgit v1.2.3-freya From 383633873dea318cb9783fc2228ddf6e7892350e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 1 Apr 2025 10:12:59 -0400 Subject: fix backend type errors --- packages/backend/src/core/WebhookTestService.ts | 9 ++++++++- .../server/api/endpoints/notes/bubble-timeline.ts | 6 +++--- .../src/server/api/endpoints/notes/following.ts | 4 ++-- .../src/server/api/endpoints/notes/reactions.ts | 4 ++-- .../backend/src/server/api/endpoints/notes/show.ts | 2 +- .../src/server/api/mastodon/MastodonDataService.ts | 2 +- .../src/server/api/mastodon/endpoints/instance.ts | 21 +++++---------------- .../src/server/api/stream/channels/chat-room.ts | 6 +++++- .../src/server/api/stream/channels/chat-user.ts | 6 +++++- 9 files changed, 32 insertions(+), 28 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 3e89595155..469b396fb0 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -243,7 +243,7 @@ export class WebhookTestService { break; } case 'edited': { - send('edited', { note: toPackedNote(dummyNote1) }); + send('edited', { note: await this.toPackedNote(dummyNote1) }); break; } case 'follow': { @@ -426,6 +426,7 @@ export class WebhookTestService { @bindThis private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise> { return { + ...user, id: user.id, name: user.name, username: user.username, @@ -445,6 +446,9 @@ export class WebhookTestService { emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: 'active', badgeRoles: [], + isAdmin: false, + isModerator: false, + isSystem: false, ...override, }; } @@ -462,6 +466,9 @@ export class WebhookTestService { lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, bannerUrl: user.bannerUrl, bannerBlurhash: user.bannerBlurhash, + backgroundUrl: user.backgroundUrl, + backgroundBlurhash: user.backgroundBlurhash, + listenbrainz: null, isLocked: user.isLocked, isSilenced: false, isSuspended: user.isSuspended, diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts index d36d1dfc15..5c2058b596 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -7,8 +7,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; import { CacheService } from '@/core/CacheService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -93,8 +93,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 228793fbf6..4b69d39e48 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -144,8 +144,8 @@ export default class extends Endpoint { // eslint- } // Respect blocks and mutes - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); // Support pagination this.queryService diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index e683cc87bd..f2355518a2 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -80,8 +80,8 @@ export default class extends Endpoint { // eslint- query.andWhere('reaction.reaction = :type', { type }); } - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); const reactions = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index f0c9db38b4..44e7137f29 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -67,7 +67,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } const note = await query.getOne(); diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts index 671ecdcbed..db257756de 100644 --- a/packages/backend/src/server/api/mastodon/MastodonDataService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts @@ -56,7 +56,7 @@ export class MastodonDataService { // Restrict visibility this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } return await query.getOne(); diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index d6ee92b466..0e363446a4 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -3,12 +3,11 @@ * 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 type { MiMeta } from '@/models/_.js'; import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { RoleService } from '@/core/RoleService.js'; @@ -21,9 +20,6 @@ export class ApiInstanceMastodon { @Inject(DI.meta) private readonly meta: MiMeta, - @Inject(DI.usersRepository) - private readonly usersRepository: UsersRepository, - @Inject(DI.config) private readonly config: Config, @@ -36,19 +32,12 @@ export class ApiInstanceMastodon { fastify.get('/v1/instance', async (_request, reply) => { const { client, me } = await this.clientService.getAuthClient(_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 contact = this.meta.rootUser != null + ? await this.mastoConverters.convertAccount(this.meta.rootUser) + : null; const roles = await this.roleService.getUserPolicies(me?.id ?? null); + const instance = data.data; const response: MastodonEntity.Instance = { uri: this.config.url, title: this.meta.name || 'Sharkey', diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts index eda333dd30..648e407569 100644 --- a/packages/backend/src/server/api/stream/channels/chat-room.ts +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -8,6 +8,7 @@ import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; class ChatRoomChannel extends Channel { @@ -22,8 +23,9 @@ class ChatRoomChannel extends Channel { id: string, connection: Channel['connection'], + noteEntityService: NoteEntityService, ) { - super(id, connection); + super(id, connection, noteEntityService); } @bindThis @@ -64,6 +66,7 @@ export class ChatRoomChannelService implements MiChannelService { constructor( private chatService: ChatService, + private readonly noteEntityService: NoteEntityService, ) { } @@ -73,6 +76,7 @@ export class ChatRoomChannelService implements MiChannelService { this.chatService, id, connection, + this.noteEntityService, ); } } diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts index 5323484ed7..b37aef29d1 100644 --- a/packages/backend/src/server/api/stream/channels/chat-user.ts +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -8,6 +8,7 @@ import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; class ChatUserChannel extends Channel { @@ -22,8 +23,9 @@ class ChatUserChannel extends Channel { id: string, connection: Channel['connection'], + noteEntityService: NoteEntityService, ) { - super(id, connection); + super(id, connection, noteEntityService); } @bindThis @@ -64,6 +66,7 @@ export class ChatUserChannelService implements MiChannelService { constructor( private chatService: ChatService, + private readonly noteEntityService: NoteEntityService, ) { } @@ -73,6 +76,7 @@ export class ChatUserChannelService implements MiChannelService { this.chatService, id, connection, + this.noteEntityService, ); } } -- cgit v1.2.3-freya From f9c153514738507268c913ffc9e5e619b65c88e9 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 2 Apr 2025 10:35:11 -0400 Subject: fix backend lint errors --- packages/backend/src/config.ts | 2 +- packages/backend/src/core/DownloadService.ts | 2 +- packages/backend/src/core/activitypub/type.ts | 2 +- packages/backend/src/misc/is-renote.ts | 2 +- .../queue/processors/ImportNotesProcessorService.ts | 2 +- packages/backend/src/queue/types.ts | 2 +- .../src/server/api/endpoints/notes/schedule/list.ts | 2 +- .../server/api/mastodon/MastodonApiServerService.ts | 4 ++-- .../src/server/api/mastodon/endpoints/account.ts | 4 ++-- packages/backend/test/e2e/streaming.ts | 18 +++++++++--------- packages/backend/test/e2e/users.ts | 4 +++- .../backend/test/unit/SigninWithPasskeyApiService.ts | 2 +- packages/backend/test/utils.ts | 4 +++- 13 files changed, 27 insertions(+), 23 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 2542cce778..46342fd633 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -487,7 +487,7 @@ function applyEnvOverrides(config: Source) { } } - function _step2name(step: string|number): string { + function _step2name(step: string | number): string { return step.toString().replaceAll(/[^a-z0-9]+/gi, '').toUpperCase(); } diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index a1a6914130..26e60e00b3 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -34,7 +34,7 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number} = {} ): Promise<{ + public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{ filename: string; }> { this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index e7459a57d2..281733d484 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -112,7 +112,7 @@ export interface IActivity extends IObject { actor: IObject | string; // ActivityPub spec allows for arrays: https://www.w3.org/TR/activitystreams-vocabulary/#properties // Misskey can only handle one value, so we use a tuple for that case. - object: IObject | string | [IObject | string] ; + object: IObject | string | [IObject | string]; target?: IObject | string; /** LD-Signature */ signature?: { diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts index d6872de46a..fcaafaf95a 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -77,7 +77,7 @@ type PackedPureRenote = PackedRenote & { replyId: NonNullable['replyId']>; poll: NonNullable['poll']>; fileIds: NonNullable['fileIds']>; -} +}; export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote { return note.renoteId != null; diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts index ee9819b29f..5e660e8081 100644 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts @@ -83,7 +83,7 @@ export class ImportNotesProcessorService { } @bindThis - private downloadUrl(url: string, path:string): Promise<{filename: string}> { + private downloadUrl(url: string, path:string): Promise<{ filename: string }> { return this.downloadService.downloadUrl(url, path, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 7d4d609f46..1bd9f7a0ab 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -161,4 +161,4 @@ export type ThinUser = { export type ScheduleNotePostJobData = { scheduleNoteId: MiNote['id']; -} +}; diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts index 4dd3d7a81a..3665348b97 100644 --- a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -100,7 +100,7 @@ export default class extends Endpoint { // eslint- id: string; note: { text?: string; - cw?: string|null; + cw?: string | null; fileIds: string[]; visibility: typeof noteVisibilities[number]; visibleUsers: Packed<'UserLite'>[]; diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index b289ad7135..59ab3b71aa 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -192,7 +192,7 @@ export class MastodonApiServerService { reply.send(response); }); - fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { + fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); @@ -284,7 +284,7 @@ export class MastodonApiServerService { reply.send(response); }); - fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { + fastify.get<{ Querystring: { limit?: string } }>('/v1/follow_requests', async (_request, reply) => { const client = this.clientService.getClient(_request); const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index efb26ca53e..8bc3c14c15 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -142,7 +142,7 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { + fastify.get<{ Querystring: { acct?: string } }>('/v1/accounts/lookup', async (_request, reply) => { if (!_request.query.acct) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "acct"' }); const client = this.clientService.getClient(_request); @@ -154,7 +154,7 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.get('/v1/accounts/relationships', async (_request, reply) => { + fastify.get('/v1/accounts/relationships', async (_request, reply) => { if (!_request.query.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "id"' }); const client = this.clientService.getClient(_request); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 72f26a38e0..af905b4c91 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -148,7 +148,7 @@ describe('Streaming', () => { test('フォローしているユーザーの投稿が流れる', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'foo' }, kyoko), // kyoko posts msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -158,7 +158,7 @@ describe('Streaming', () => { test('フォローしているユーザーの visibility: followers な投稿が流れる', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), // kyoko posts msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -170,7 +170,7 @@ describe('Streaming', () => { const note = await post(kyoko, { text: 'foo', visibility: 'followers' }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.id }, kyoko), // kyoko posts msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo', ); @@ -182,7 +182,7 @@ describe('Streaming', () => { const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'reply to chitose\'s followers-only post', replyId: chitoseNote.id }, kyoko), // kyoko's reply to chitose's followers-only post msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -195,7 +195,7 @@ describe('Streaming', () => { const kyokoReply = await post(kyoko, { text: 'reply to followers-only post', replyId: chitoseNote.id }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { renoteId: kyokoReply.id }, kyoko), // kyoko's renote of kyoko's reply to chitose's followers-only post msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -205,7 +205,7 @@ describe('Streaming', () => { test('フォローしていないユーザーの投稿は流れない', async () => { const fired = await waitFire( - kyoko, 'homeTimeline', // kyoko:home + kyoko, 'homeTimeline', // kyoko:home () => api('notes/create', { text: 'foo' }, ayano), // ayano posts msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano ); @@ -215,7 +215,7 @@ describe('Streaming', () => { test('フォローしているユーザーのダイレクト投稿が流れる', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), // kyoko dm => ayano msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -225,7 +225,7 @@ describe('Streaming', () => { test('フォローしているユーザーでも自分が指定されていないダイレクト投稿は流れない', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id] }, kyoko), // kyoko dm => chitose msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -574,7 +574,7 @@ describe('Streaming', () => { test('withReplies = falseでフォローしてる人によるリプライが流れてくる', async () => { const fired = await waitFire( - ayano, 'globalTimeline', // ayano:Global + ayano, 'globalTimeline', // ayano:Global () => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko), // kyoko posts msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index ed1dadd181..4b3ec856f1 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -312,7 +312,9 @@ describe('ユーザー', () => { assert.strictEqual(response.name, null); assert.strictEqual(response.username, 'zoe'); assert.strictEqual(response.host, null); - response.avatarUrl && assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + if (response.avatarUrl) { + assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + } assert.strictEqual(response.avatarBlurhash, null); assert.deepStrictEqual(response.avatarDecorations, []); assert.strictEqual(response.isBot, false); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index efed905e02..2b7afb02f9 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -54,7 +54,7 @@ class DummyFastifyReply { } class DummyFastifyRequest { public ip: string; - public body: {credential: any, context: string}; + public body: { credential: any, context: string }; public headers: IncomingHttpHeaders = { 'accept': 'application/json' }; constructor(body?: any) { this.ip = '0.0.0.0'; diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 8306208477..7b69cb04f4 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -661,7 +661,9 @@ export async function captureWebhook(postAction: () => let timeoutHandle: NodeJS.Timeout | null = null; const result = await new Promise(async (resolve, reject) => { fastify.all('/', async (req, res) => { - timeoutHandle && clearTimeout(timeoutHandle); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } const body = JSON.stringify(req.body); res.status(200).send('ok'); -- cgit v1.2.3-freya From 6e4e4fdc3335958f6eb8f35afc3e3f114fe463b8 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 30 Apr 2025 11:13:46 -0400 Subject: fix type errors in mastodon API --- .../src/server/api/mastodon/MastodonConverters.ts | 8 ++++---- .../server/api/mastodon/endpoints/notifications.ts | 1 + .../src/server/api/mastodon/endpoints/search.ts | 19 +++++++++---------- .../src/server/api/mastodon/endpoints/status.ts | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index e5d732ed79..cf625d6e94 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Entity } from 'megalodon'; +import { Entity, MastodonEntity } from 'megalodon'; import mfm from '@transfem-org/sfm-js'; import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js'; import { NotificationType } from 'megalodon/lib/src/notification.js'; @@ -275,7 +275,7 @@ export class MastodonConverters { this.getUser(p) .then(u => this.encode(u, mentionedRemoteUsers)) .catch(() => null))) - .then(p => p.filter(m => m)) as Promise; + .then((p: Entity.Mention[]) => p.filter(m => m)); const tags = note.tags.map(tag => { return { @@ -327,7 +327,7 @@ export class MastodonConverters { sensitive: status.sensitive || !!cw, spoiler_text: cw, visibility: status.visibility, - media_attachments: status.media_attachments.map(a => convertAttachment(a)), + media_attachments: status.media_attachments.map((a: Entity.Account) => convertAttachment(a)), mentions: mentions, tags: tags, card: null, //FIXME @@ -345,7 +345,7 @@ export class MastodonConverters { 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))), + accounts: await Promise.all(conversation.accounts.map((a: Entity.Account) => this.convertAccount(a))), last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null, unread: conversation.unread, }; diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index c3108c8b3e..ee6c990fd1 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -4,6 +4,7 @@ */ import { Injectable } from '@nestjs/common'; +import { MastodonEntity } from 'megalodon'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 796f4cd5f7..78672639e5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -9,9 +9,8 @@ import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mas import { MastodonConverters } from '../MastodonConverters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; import { ApiError } from '../../error.js'; -import Account = Entity.Account; -import Status = Entity.Status; import type { FastifyInstance } from 'fastify'; +import type { Entity } from 'megalodon'; interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { @@ -53,8 +52,8 @@ export class ApiSearchMastodon { const { data } = await client.search(request.query.q, { type, ...query }); const response = { ...data, - accounts: await Promise.all(data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account))), - statuses: await Promise.all(data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me))), + accounts: await Promise.all(data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))), + statuses: await Promise.all(data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))), }; if (type === 'hashtags') { @@ -90,8 +89,8 @@ export class ApiSearchMastodon { 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((account: Account) => this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me)) ?? []), + accounts: await Promise.all(acct?.data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)) ?? []), hashtags: tags?.data.hashtags ?? [], }; @@ -113,7 +112,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers as HeadersInit, + ...request.headers, 'Accept': 'application/json', 'Content-Type': 'application/json', }, @@ -122,7 +121,7 @@ export class ApiSearchMastodon { await verifyResponse(res); - const data = await res.json() as Status[]; + const data = await res.json() as Entity.Status[]; const me = await this.clientService.getAuth(request); const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); @@ -136,7 +135,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers as HeadersInit, + ...request.headers, 'Accept': 'application/json', 'Content-Type': 'application/json', }, @@ -150,7 +149,7 @@ export class ApiSearchMastodon { await verifyResponse(res); - const data = await res.json() as Account[]; + const data = await res.json() as Entity.Account[]; const response = await Promise.all(data.map(async entry => { return { source: 'global', diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 39c4f44755..ec31e0cc46 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -55,8 +55,8 @@ export class ApiStatusMastodon { 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))); + const ancestors = await Promise.all(data.ancestors.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + const descendants = await Promise.all(data.descendants.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); const response = { ancestors, descendants }; reply.send(response); @@ -166,7 +166,7 @@ export class ApiStatusMastodon { if (body.in_reply_to_id && removed === '/unreact') { const id = body.in_reply_to_id; const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter(e => e.me)[0].name; + const react = post.data.emoji_reactions.filter((e: Entity.Emoji) => e.me)[0].name; const data = await client.deleteEmojiReaction(id, react); reply.send(data.data); } -- cgit v1.2.3-freya From e87afe58045ef766d8ccd06508cf8f05c4582e30 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 3 May 2025 22:21:38 -0400 Subject: fix more type errors because TS can't make up its mind --- packages/backend/src/server/api/mastodon/endpoints/search.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 78672639e5..33bfa87e5f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -112,7 +112,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers, + ...request.headers as HeadersInit, 'Accept': 'application/json', 'Content-Type': 'application/json', }, @@ -135,7 +135,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers, + ...request.headers as HeadersInit, 'Accept': 'application/json', 'Content-Type': 'application/json', }, -- cgit v1.2.3-freya From 581cc2b5137d335d010a4b05b39cd5fdefcfd160 Mon Sep 17 00:00:00 2001 From: Marie Date: Mon, 5 May 2025 13:00:31 +0000 Subject: remove http/https protocol --- packages/backend/src/server/api/mastodon/endpoints/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index d6ee92b466..866a3acd44 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -50,7 +50,7 @@ export class ApiInstanceMastodon { const roles = await this.roleService.getUserPolicies(me?.id ?? null); const response: MastodonEntity.Instance = { - uri: this.config.url, + uri: this.config.url.replace(/^https?:\/\//, ''), title: this.meta.name || 'Sharkey', description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', email: instance.email || '', -- cgit v1.2.3-freya From e2be44fb99f567a1169e4dd6929ea634e1e0cf03 Mon Sep 17 00:00:00 2001 From: Marie Date: Mon, 5 May 2025 13:03:39 +0000 Subject: change regex to include a zero-length match --- packages/backend/src/server/api/mastodon/endpoints/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index 866a3acd44..7ed8d52313 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -50,7 +50,7 @@ export class ApiInstanceMastodon { const roles = await this.roleService.getUserPolicies(me?.id ?? null); const response: MastodonEntity.Instance = { - uri: this.config.url.replace(/^https?:\/\//, ''), + uri: this.config.url.replace(/^(https?:|)\/\//, ''), title: this.meta.name || 'Sharkey', description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', email: instance.email || '', -- cgit v1.2.3-freya From cb3f5f598da7eaee9818e17cab98a9dd81ed90a3 Mon Sep 17 00:00:00 2001 From: Marie Date: Mon, 5 May 2025 17:33:27 +0000 Subject: Update instance.ts --- packages/backend/src/server/api/mastodon/endpoints/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index 7ed8d52313..6ecd36970d 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -50,7 +50,7 @@ export class ApiInstanceMastodon { const roles = await this.roleService.getUserPolicies(me?.id ?? null); const response: MastodonEntity.Instance = { - uri: this.config.url.replace(/^(https?:|)\/\//, ''), + uri: this.config.host, title: this.meta.name || 'Sharkey', description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', email: instance.email || '', -- cgit v1.2.3-freya From cd4fbc851b0fc766c93552971cb916e4ccd1ef55 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 6 May 2025 12:55:51 -0400 Subject: improve compatibility with multipart/form-data mastodon API requests --- packages/backend/package.json | 1 - packages/backend/src/server/ServerModule.ts | 2 + .../backend/src/server/ServerUtilityService.ts | 141 +++++++++++++++++++++ .../api/mastodon/MastodonApiServerService.ts | 132 +++---------------- .../src/server/api/mastodon/endpoints/account.ts | 53 +++----- .../src/server/api/mastodon/endpoints/apps.ts | 5 +- .../src/server/api/mastodon/endpoints/filter.ts | 7 +- .../server/api/mastodon/endpoints/notifications.ts | 7 +- .../src/server/oauth/OAuth2ProviderService.ts | 40 +----- packages/megalodon/src/misskey.ts | 6 +- pnpm-lock.yaml | 49 ------- 11 files changed, 202 insertions(+), 241 deletions(-) create mode 100644 packages/backend/src/server/ServerUtilityService.ts (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/package.json b/packages/backend/package.json index 9aa26033d0..4a9560e833 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -115,7 +115,6 @@ "deep-email-validator": "0.1.21", "fast-xml-parser": "4.4.1", "fastify": "5.3.2", - "fastify-multer": "^2.0.3", "fastify-raw-body": "5.0.0", "feed": "4.2.2", "file-type": "19.6.0", diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 6726d4aa67..8ff8da380a 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -16,6 +16,7 @@ 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 { ServerUtilityService } from '@/server/ServerUtilityService.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; import { HealthServerService } from './HealthServerService.js'; @@ -126,6 +127,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ApiSearchMastodon, ApiStatusMastodon, ApiTimelineMastodon, + ServerUtilityService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerUtilityService.ts b/packages/backend/src/server/ServerUtilityService.ts new file mode 100644 index 0000000000..f2900fad4f --- /dev/null +++ b/packages/backend/src/server/ServerUtilityService.ts @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import querystring from 'querystring'; +import multipart from '@fastify/multipart'; +import { Inject, Injectable } from '@nestjs/common'; +import { FastifyInstance } from 'fastify'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; + +@Injectable() +export class ServerUtilityService { + constructor( + @Inject(DI.config) + private readonly config: Config, + ) {} + + public addMultipartFormDataContentType(fastify: FastifyInstance): void { + fastify.register(multipart, { + limits: { + fileSize: this.config.maxFileSize, + files: 1, + }, + }); + + // Default behavior saves files to memory - we don't want that! + // Store to temporary file instead, and copy the body fields while we're at it. + fastify.addHook<{ Body?: Record }>('onRequest', async request => { + if (request.isMultipart()) { + const body = request.body ??= {}; + + // Save upload to temp directory. + // These are attached to request.savedRequestFiles + await request.saveRequestFiles(); + + // Copy fields to body + const formData = await request.formData(); + formData.forEach((v, k) => { + // This can be string or File, and we handle files above. + if (typeof(v) === 'string') { + // This is just progressive conversion from undefined -> string -> string[] + if (body[k]) { + if (Array.isArray(body[k])) { + body[k].push(v); + } else { + body[k] = [body[k], v]; + } + } else { + body[k] = v; + } + } + }); + } + }); + } + + public addFormUrlEncodedContentType(fastify: FastifyInstance) { + fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { + let body = ''; + payload.on('data', (data) => { + body += data; + }); + payload.on('end', () => { + try { + const parsed = querystring.parse(body); + done(null, parsed); + } catch (e) { + done(e as Error); + } + }); + payload.on('error', done); + }); + } + + public addCORS(fastify: FastifyInstance) { + fastify.addHook('onRequest', (_, reply, done) => { + // Allow web-based clients to connect from other origins. + reply.header('Access-Control-Allow-Origin', '*'); + + // Mastodon uses all types of request methods. + reply.header('Access-Control-Allow-Methods', '*'); + + // Allow web-based clients to access Link header - required for mastodon pagination. + // https://stackoverflow.com/a/54928828 + // https://docs.joinmastodon.org/api/guidelines/#pagination + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers + reply.header('Access-Control-Expose-Headers', 'Link'); + + // Cache to avoid extra pre-flight requests + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age + reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds + + done(); + }); + } + + public addFlattenedQueryType(fastify: FastifyInstance) { + // Remove trailing "[]" from query params + fastify.addHook<{ Querystring?: Record }>('preValidation', (request, _reply, done) => { + if (!request.query || typeof(request.query) !== 'object') { + return done(); + } + + for (const key of Object.keys(request.query)) { + if (!key.endsWith('[]')) { + continue; + } + if (request.query[key] == null) { + continue; + } + + const newKey = key.substring(0, key.length - 2); + const newValue = request.query[key]; + const oldValue = request.query[newKey]; + + // Move the value to the correct key + if (oldValue != null) { + if (Array.isArray(oldValue)) { + // Works for both array and single values + request.query[newKey] = oldValue.concat(newValue); + } else if (Array.isArray(newValue)) { + // Preserve order + request.query[newKey] = [oldValue, ...newValue]; + } else { + // Preserve order + request.query[newKey] = [oldValue, newValue]; + } + } else { + request.query[newKey] = newValue; + } + + // Remove the invalid key + delete request.query[key]; + } + + return done(); + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 59ab3b71aa..757610450a 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -3,12 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import querystring from 'querystring'; -import multer from 'fastify-multer'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; -import type { Config } from '@/config.js'; import { getErrorData, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; @@ -20,6 +16,7 @@ import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifi 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 { ServerUtilityService } from '@/server/ServerUtilityService.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js'; import type { Entity } from 'megalodon'; @@ -28,9 +25,6 @@ import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() export class MastodonApiServerService { constructor( - @Inject(DI.config) - private readonly config: Config, - private readonly mastoConverters: MastodonConverters, private readonly logger: MastodonLogger, private readonly clientService: MastodonClientService, @@ -42,97 +36,15 @@ export class MastodonApiServerService { private readonly apiSearchMastodon: ApiSearchMastodon, private readonly apiStatusMastodon: ApiStatusMastodon, private readonly apiTimelineMastodon: ApiTimelineMastodon, + private readonly serverUtilityService: ServerUtilityService, ) {} @bindThis public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); - - fastify.addHook('onRequest', (_, reply, done) => { - // Allow web-based clients to connect from other origins. - reply.header('Access-Control-Allow-Origin', '*'); - - // Mastodon uses all types of request methods. - reply.header('Access-Control-Allow-Methods', '*'); - - // Allow web-based clients to access Link header - required for mastodon pagination. - // https://stackoverflow.com/a/54928828 - // https://docs.joinmastodon.org/api/guidelines/#pagination - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers - reply.header('Access-Control-Expose-Headers', 'Link'); - - // Cache to avoid extra pre-flight requests - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age - reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds - - done(); - }); - - fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e) { - done(e as Error); - } - }); - payload.on('error', done); - }); - - // Remove trailing "[]" from query params - fastify.addHook('preValidation', (request, _reply, done) => { - if (!request.query || typeof(request.query) !== 'object') { - return done(); - } - - // Same object aliased with a different type - const query = request.query as Record; - - for (const key of Object.keys(query)) { - if (!key.endsWith('[]')) { - continue; - } - if (query[key] == null) { - continue; - } - - const newKey = key.substring(0, key.length - 2); - const newValue = query[key]; - const oldValue = query[newKey]; - - // Move the value to the correct key - if (oldValue != null) { - if (Array.isArray(oldValue)) { - // Works for both array and single values - query[newKey] = oldValue.concat(newValue); - } else if (Array.isArray(newValue)) { - // Preserve order - query[newKey] = [oldValue, ...newValue]; - } else { - // Preserve order - query[newKey] = [oldValue, newValue]; - } - } else { - query[newKey] = newValue; - } - - // Remove the invalid key - delete query[key]; - } - - return done(); - }); + this.serverUtilityService.addMultipartFormDataContentType(fastify); + this.serverUtilityService.addFormUrlEncodedContentType(fastify); + this.serverUtilityService.addCORS(fastify); + this.serverUtilityService.addFlattenedQueryType(fastify); fastify.setErrorHandler((error, request, reply) => { const data = getErrorData(error); @@ -143,14 +55,12 @@ export class MastodonApiServerService { reply.code(status).send(data); }); - fastify.register(multer.contentParser); - // External endpoints - this.apiAccountMastodon.register(fastify, upload); - this.apiAppsMastodon.register(fastify, upload); - this.apiFilterMastodon.register(fastify, upload); + this.apiAccountMastodon.register(fastify); + this.apiAppsMastodon.register(fastify); + this.apiFilterMastodon.register(fastify); this.apiInstanceMastodon.register(fastify); - this.apiNotificationsMastodon.register(fastify, upload); + this.apiNotificationsMastodon.register(fastify); this.apiSearchMastodon.register(fastify); this.apiStatusMastodon.register(fastify); this.apiTimelineMastodon.register(fastify); @@ -178,11 +88,10 @@ export class MastodonApiServerService { reply.send(data.data); }); - fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const multipartData = await _request.file(); + fastify.post('/v1/media', async (_request, reply) => { + const multipartData = _request.savedRequestFiles?.[0]; if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' }); } const client = this.clientService.getClient(_request); @@ -192,11 +101,10 @@ export class MastodonApiServerService { reply.send(response); }); - fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const multipartData = await _request.file(); + fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', async (_request, reply) => { + const multipartData = _request.savedRequestFiles?.[0]; if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' }); } const client = this.clientService.getClient(_request); @@ -294,7 +202,7 @@ export class MastodonApiServerService { reply.send(response); }); - fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -304,7 +212,7 @@ export class MastodonApiServerService { reply.send(response); }); - fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -325,7 +233,7 @@ export class MastodonApiServerService { focus?: string, is_sensitive?: string, }, - }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { + }>('/v1/media/:id', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const options = { diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 8bc3c14c15..b4ce56408e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -11,7 +11,6 @@ import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js'; -import type multer from 'fastify-multer'; import type { FastifyInstance } from 'fastify'; interface ApiAccountMastodonRoute { @@ -34,7 +33,7 @@ export class ApiAccountMastodon { private readonly driveService: DriveService, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { + public register(fastify: FastifyInstance): void { fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.verifyAccountCredentials(); @@ -70,60 +69,50 @@ export class ApiAccountMastodon { value: string, }[], }, - }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { + }>('/v1/accounts/update_credentials', async (_request, reply) => { const accessTokens = _request.headers.authorization; 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) { + if (_request.savedRequestFiles?.length && 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) => { + const avatar = _request.savedRequestFiles.find(obj => { return obj.fieldname === 'avatar'; }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const header = (_request.files as any).find((obj: any) => { + const header = _request.savedRequestFiles.find(obj => { 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, + path: avatar.filepath, + name: avatar.filename && avatar.filename !== 'file' ? avatar.filename : undefined, sensitive: false, }); if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).avatar = upload.id; + _request.body.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, + path: header.filepath, + name: header.filename && header.filename !== 'file' ? header.filename : undefined, sensitive: false, }); if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).header = upload.id; + _request.body.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 (_request.body.fields_attributes) { + for (const field of _request.body.fields_attributes) { 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); + } + _request.body.fields_attributes = _request.body.fields_attributes.filter(field => field.name.trim().length > 0 && field.value.length > 0); } const options = { @@ -234,7 +223,7 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/follow', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -245,7 +234,7 @@ export class ApiAccountMastodon { reply.send(acct); }); - fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/unfollow', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -256,7 +245,7 @@ export class ApiAccountMastodon { reply.send(acct); }); - fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/block', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -266,7 +255,7 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/unblock', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -276,7 +265,7 @@ export class ApiAccountMastodon { return reply.send(response); }); - fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/mute', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -289,7 +278,7 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/unmute', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index dbef3b7d35..ec08600e53 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -6,7 +6,6 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; -import type multer from 'fastify-multer'; const readScope = [ 'read:account', @@ -62,8 +61,8 @@ export class ApiAppsMastodon { private readonly clientService: MastodonClientService, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { - fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { + public register(fastify: FastifyInstance): void { + fastify.post('/v1/apps', async (_request, reply) => { const body = _request.body ?? _request.query; if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' }); if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index deac1e9aad..242f068b99 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -8,7 +8,6 @@ import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertFilter } from '../MastodonConverters.js'; import type { FastifyInstance } from 'fastify'; -import type multer from 'fastify-multer'; interface ApiFilterMastodonRoute { Params: { @@ -29,7 +28,7 @@ export class ApiFilterMastodon { private readonly clientService: MastodonClientService, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { + public register(fastify: FastifyInstance): void { fastify.get('/v1/filters', async (_request, reply) => { const client = this.clientService.getClient(_request); @@ -49,7 +48,7 @@ export class ApiFilterMastodon { reply.send(response); }); - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/filters', async (_request, reply) => { if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); @@ -68,7 +67,7 @@ export class ApiFilterMastodon { reply.send(response); }); - fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/filters/:id', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index ee6c990fd1..75512c2efc 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -10,7 +10,6 @@ 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'; -import type multer from 'fastify-multer'; interface ApiNotifyMastodonRoute { Params: { @@ -26,7 +25,7 @@ export class ApiNotificationsMastodon { private readonly clientService: MastodonClientService, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { + public register(fastify: FastifyInstance): void { fastify.get('/v1/notifications', async (request, reply) => { const { client, me } = await this.clientService.getAuthClient(request); const data = await client.getNotifications(parseTimelineArgs(request.query)); @@ -66,7 +65,7 @@ export class ApiNotificationsMastodon { reply.send(response); }); - fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/notification/:id/dismiss', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -75,7 +74,7 @@ export class ApiNotificationsMastodon { reply.send(data.data); }); - fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/notifications/clear', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.dismissNotifications(); diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index a65acb7c9b..e1f39dd9b6 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -3,15 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import querystring from 'querystring'; import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; -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 { ServerUtilityService } from '@/server/ServerUtilityService.js'; import type { FastifyInstance } from 'fastify'; const kinds = [ @@ -56,6 +55,7 @@ export class OAuth2ProviderService { private config: Config, private readonly mastodonClientService: MastodonClientService, + private readonly serverUtilityService: ServerUtilityService, ) { } // https://datatracker.ietf.org/doc/html/rfc8414.html @@ -92,36 +92,10 @@ export class OAuth2ProviderService { }); }); */ - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); - - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Origin', '*'); - done(); - }); - - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e: unknown) { - done(e instanceof Error ? e : new Error(String(e))); - } - }); - payload.on('error', done); - }); - - fastify.register(multer.contentParser); + this.serverUtilityService.addMultipartFormDataContentType(fastify); + this.serverUtilityService.addFormUrlEncodedContentType(fastify); + this.serverUtilityService.addCORS(fastify); + this.serverUtilityService.addFlattenedQueryType(fastify); for (const url of ['/authorize', '/authorize/']) { fastify.get<{ Querystring: Record }>(url, async (request, reply) => { @@ -136,7 +110,7 @@ export class OAuth2ProviderService { }); } - fastify.post<{ Body?: Record, Querystring: Record }>('/token', { preHandler: upload.none() }, async (request, reply) => { + fastify.post<{ Body?: Record, Querystring: Record }>('/token', async (request, reply) => { const body = request.body ?? request.query; if (body.grant_type === 'client_credentials') { diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index 670b31e838..a7d604de26 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -1502,13 +1502,13 @@ export default class Misskey implements MegalodonInterface { /** * POST /api/drive/files/create */ - public async uploadMedia(file: any, _options?: { description?: string; focus?: string }): Promise> { + public async uploadMedia(file: { filepath: fs.PathLike, mimetype: string, filename: string }, _options?: { description?: string; focus?: string }): Promise> { const formData = new FormData() - formData.append('file', fs.createReadStream(file.path), { + formData.append('file', fs.createReadStream(file.filepath), { contentType: file.mimetype, }); - if (file.originalname != null && file.originalname !== "file") formData.append("name", file.originalname); + if (file.filename && file.filename !== "file") formData.append("name", file.filename); if (_options?.description != null) formData.append("comment", _options.description); let headers: { [key: string]: string } = {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 691069f563..4eb78db1b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,9 +233,6 @@ importers: fastify: specifier: 5.3.2 version: 5.3.2 - fastify-multer: - specifier: ^2.0.3 - version: 2.0.3 fastify-raw-body: specifier: 5.0.0 version: 5.0.0 @@ -2274,10 +2271,6 @@ packages: '@fastify/ajv-compiler@4.0.1': resolution: {integrity: sha512-DxrBdgsjNLP0YM6W5Hd6/Fmj43S8zMKiFJYgi+Ri3htTGAowPVG/tG1wpnWLMjufEnehRivUCKZ1pLDIoZdTuw==} - '@fastify/busboy@1.2.1': - resolution: {integrity: sha512-7PQA7EH43S0CxcOa9OeAnaeA0oQ+e/DHNPZwSQM9CQHW76jle5+OvLdibRp/Aafs9KXbLhxyjOTkRjWUbQEd3Q==} - engines: {node: '>=14'} - '@fastify/busboy@2.1.0': resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} engines: {node: '>=14'} @@ -5455,10 +5448,6 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} - config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -6313,13 +6302,6 @@ packages: resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} hasBin: true - fastify-multer@2.0.3: - resolution: {integrity: sha512-QnFqrRgxmUwWHTgX9uyQSu0C/hmVCfcxopqjApZ4uaZD5W9MJ+nHUlW4+9q7Yd3BRxDIuHvgiM5mjrh6XG8cAA==} - engines: {node: '>=10.17.0'} - - fastify-plugin@2.3.4: - resolution: {integrity: sha512-I+Oaj6p9oiRozbam30sh39BiuiqBda7yK2nmSPVwDCfIBlKnT8YB3MY+pRQc2Fcd07bf6KPGklHJaQ2Qu81TYQ==} - fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} @@ -9960,9 +9942,6 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} - text-decoding@1.0.0: - resolution: {integrity: sha512-/0TJD42KDnVwKmDK6jj3xP7E2MG7SHAOG4tyTgyUCRPdHwvkquYNLEQltmdMa3owq3TkddCVcTsoctJI8VQNKA==} - textarea-caret@3.1.0: resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==} @@ -12060,10 +12039,6 @@ snapshots: ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.0.1 - '@fastify/busboy@1.2.1': - dependencies: - text-decoding: 1.0.0 - '@fastify/busboy@2.1.0': {} '@fastify/busboy@3.0.0': {} @@ -15990,13 +15965,6 @@ snapshots: readable-stream: 2.3.7 typedarray: 0.0.6 - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.0 - typedarray: 0.0.6 - config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -17122,21 +17090,6 @@ snapshots: dependencies: strnum: 1.0.5 - fastify-multer@2.0.3: - dependencies: - '@fastify/busboy': 1.2.1 - append-field: 1.0.0 - concat-stream: 2.0.0 - fastify-plugin: 2.3.4 - mkdirp: 1.0.4 - on-finished: 2.4.1 - type-is: 1.6.18 - xtend: 4.0.2 - - fastify-plugin@2.3.4: - dependencies: - semver: 7.6.0 - fastify-plugin@4.5.1: {} fastify-plugin@5.0.1: {} @@ -21438,8 +21391,6 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 - text-decoding@1.0.0: {} - textarea-caret@3.1.0: {} thenify-all@1.6.0: -- cgit v1.2.3-freya From 7cd181df71ebac46c1c6a0ffb00ad81f82b62f3a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 6 May 2025 13:08:40 -0400 Subject: improve type checks in POST /api/v1/apps endpoint --- .../backend/src/server/api/mastodon/endpoints/apps.ts | 15 ++++++++------- packages/megalodon/src/misskey.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 13 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index ec08600e53..aae6103146 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -47,9 +47,9 @@ const writeScope = [ export interface AuthPayload { scopes?: string | string[], - redirect_uris?: string, - client_name?: string, - website?: string, + redirect_uris?: string | string[], + client_name?: string | string[], + website?: string | string[], } // Not entirely right, but it gets TypeScript to work so *shrug* @@ -66,7 +66,10 @@ export class ApiAppsMastodon { const body = _request.body ?? _request.query; if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' }); if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' }); + if (Array.isArray(body.redirect_uris)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "redirect_uris": only one value is allowed' }); if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' }); + if (Array.isArray(body.client_name)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "client_name": only one value is allowed' }); + if (Array.isArray(body.website)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "website": only one value is allowed' }); let scope = body.scopes; if (typeof scope === 'string') { @@ -87,12 +90,10 @@ export class ApiAppsMastodon { } } - 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, + redirect_uri: body.redirect_uris, website: body.website, }); @@ -100,7 +101,7 @@ export class ApiAppsMastodon { id: Math.floor(Math.random() * 100).toString(), name: appData.name, website: body.website, - redirect_uri: red, + redirect_uri: body.redirect_uris, client_id: Buffer.from(appData.url || '').toString('base64'), client_secret: appData.clientSecret, }; diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index a7d604de26..cfca2c291c 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -39,9 +39,9 @@ export default class Misskey implements MegalodonInterface { public async registerApp( client_name: string, - options: Partial<{ scopes: Array; redirect_uris: string; website: string }> = { + options: Partial<{ scopes: Array; redirect_uri: string; website: string }> = { scopes: MisskeyAPI.DEFAULT_SCOPE, - redirect_uris: this.baseUrl + redirect_uri: this.baseUrl } ): Promise { return this.createApp(client_name, options).then(async appData => { @@ -62,12 +62,12 @@ export default class Misskey implements MegalodonInterface { */ public async createApp( client_name: string, - options: Partial<{ scopes: Array; redirect_uris: string; website: string }> = { + options: Partial<{ scopes: Array; redirect_uri: string; website: string }> = { scopes: MisskeyAPI.DEFAULT_SCOPE, - redirect_uris: this.baseUrl + redirect_uri: this.baseUrl } ): Promise { - const redirect_uris = options.redirect_uris || this.baseUrl + const redirect_uri = options.redirect_uri || this.baseUrl const scopes = options.scopes || MisskeyAPI.DEFAULT_SCOPE const params: { @@ -79,7 +79,7 @@ export default class Misskey implements MegalodonInterface { name: client_name, description: '', permission: scopes, - callbackUrl: redirect_uris + callbackUrl: redirect_uri } /** -- cgit v1.2.3-freya From 2c5fb36e7f07fde634bcc91f6489c0c5b4885fcd Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 6 May 2025 21:15:56 -0400 Subject: add missing "return reply" calls to async fastify routes Required, according to docs: https://fastify.dev/docs/latest/Reference/Routes/#async-await --- .../api/mastodon/MastodonApiServerService.ts | 38 ++++++++--------- .../src/server/api/mastodon/endpoints/account.ts | 30 +++++++------- .../src/server/api/mastodon/endpoints/apps.ts | 2 +- .../src/server/api/mastodon/endpoints/filter.ts | 10 ++--- .../src/server/api/mastodon/endpoints/instance.ts | 2 +- .../server/api/mastodon/endpoints/notifications.ts | 8 ++-- .../src/server/api/mastodon/endpoints/search.ts | 8 ++-- .../src/server/api/mastodon/endpoints/status.ts | 48 +++++++++++----------- .../src/server/api/mastodon/endpoints/timeline.ts | 26 ++++++------ .../src/server/oauth/OAuth2ProviderService.ts | 8 ++-- 10 files changed, 90 insertions(+), 90 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 478c8f5cf2..74fd9d7d59 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -56,7 +56,7 @@ export class MastodonApiServerService { this.logger.exception(request, exception); } - reply.code(status).send(data); + return reply.code(status).send(data); }); // Log error responses (including converted JSON exceptions) @@ -84,7 +84,7 @@ export class MastodonApiServerService { fastify.get('/v1/custom_emojis', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getInstanceCustomEmojis(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/announcements', async (_request, reply) => { @@ -92,7 +92,7 @@ export class MastodonApiServerService { const data = await client.getInstanceAnnouncements(); const response = data.data.map((announcement) => convertAnnouncement(announcement)); - reply.send(response); + return reply.send(response); }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { @@ -101,7 +101,7 @@ export class MastodonApiServerService { const client = this.clientService.getClient(_request); const data = await client.dismissInstanceAnnouncement(_request.body.id); - reply.send(data.data); + return reply.send(data.data); }); fastify.post('/v1/media', async (_request, reply) => { @@ -114,7 +114,7 @@ export class MastodonApiServerService { const data = await client.uploadMedia(multipartData); const response = convertAttachment(data.data as Entity.Attachment); - reply.send(response); + return reply.send(response); }); fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', async (_request, reply) => { @@ -127,36 +127,36 @@ export class MastodonApiServerService { const data = await client.uploadMedia(multipartData, _request.body); const response = convertAttachment(data.data as Entity.Attachment); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/trends', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/trends/tags', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/trends/links', async (_request, reply) => { // As we do not have any system for news/links this will just return empty - reply.send([]); + return reply.send([]); }); fastify.get('/v1/preferences', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getPreferences(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/followed_tags', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getFollowedTags(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => { @@ -165,7 +165,7 @@ export class MastodonApiServerService { const data = await client.getBookmarks(parseTimelineArgs(_request.query)); const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { @@ -187,7 +187,7 @@ export class MastodonApiServerService { const data = await client.getFavourites(args); const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => { @@ -196,7 +196,7 @@ export class MastodonApiServerService { const data = await client.getMutes(parseTimelineArgs(_request.query)); const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => { @@ -205,7 +205,7 @@ export class MastodonApiServerService { const data = await client.getBlocks(parseTimelineArgs(_request.query)); const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: { limit?: string } }>('/v1/follow_requests', async (_request, reply) => { @@ -215,7 +215,7 @@ export class MastodonApiServerService { const data = await client.getFollowRequests(limit); const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account as Entity.Account))); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => { @@ -225,7 +225,7 @@ export class MastodonApiServerService { const data = await client.acceptFollowRequest(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => { @@ -235,7 +235,7 @@ export class MastodonApiServerService { const data = await client.rejectFollowRequest(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); //#endregion @@ -260,7 +260,7 @@ export class MastodonApiServerService { const data = await client.updateMedia(_request.params.id, options); const response = convertAttachment(data.data); - reply.send(response); + return reply.send(response); }); done(); diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index b4ce56408e..6a1af62be7 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -47,7 +47,7 @@ export class ApiAccountMastodon { language: '', }, }); - reply.send(response); + return reply.send(response); }); fastify.patch<{ @@ -128,7 +128,7 @@ export class ApiAccountMastodon { const data = await client.updateCredentials(options); const response = await this.mastoConverters.convertAccount(data.data); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: { acct?: string } }>('/v1/accounts/lookup', async (_request, reply) => { @@ -140,7 +140,7 @@ export class ApiAccountMastodon { data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/accounts/relationships', async (_request, reply) => { @@ -150,7 +150,7 @@ export class ApiAccountMastodon { const data = await client.getRelationships(_request.query.id); const response = data.data.map(relationship => convertRelationship(relationship)); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { @@ -160,7 +160,7 @@ export class ApiAccountMastodon { const data = await client.getAccount(_request.params.id); const account = await this.mastoConverters.convertAccount(data.data); - reply.send(account); + return reply.send(account); }); fastify.get('/v1/accounts/:id/statuses', async (request, reply) => { @@ -172,7 +172,7 @@ export class ApiAccountMastodon { const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { @@ -182,7 +182,7 @@ export class ApiAccountMastodon { const data = await client.getFeaturedTags(); const response = data.data.map((tag) => convertFeaturedTag(tag)); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/accounts/:id/followers', async (request, reply) => { @@ -196,7 +196,7 @@ export class ApiAccountMastodon { const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/accounts/:id/following', async (request, reply) => { @@ -210,7 +210,7 @@ export class ApiAccountMastodon { const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { @@ -220,7 +220,7 @@ export class ApiAccountMastodon { const data = await client.getAccountLists(_request.params.id); const response = data.data.map((list) => convertList(list)); - reply.send(response); + return reply.send(response); }); fastify.post('/v1/accounts/:id/follow', async (_request, reply) => { @@ -231,7 +231,7 @@ export class ApiAccountMastodon { const acct = convertRelationship(data.data); acct.following = true; // TODO this is wrong, follow may not have processed immediately - reply.send(acct); + return reply.send(acct); }); fastify.post('/v1/accounts/:id/unfollow', async (_request, reply) => { @@ -242,7 +242,7 @@ export class ApiAccountMastodon { const acct = convertRelationship(data.data); acct.following = false; - reply.send(acct); + return reply.send(acct); }); fastify.post('/v1/accounts/:id/block', async (_request, reply) => { @@ -252,7 +252,7 @@ export class ApiAccountMastodon { const data = await client.blockAccount(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); fastify.post('/v1/accounts/:id/unblock', async (_request, reply) => { @@ -275,7 +275,7 @@ export class ApiAccountMastodon { ); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); fastify.post('/v1/accounts/:id/unmute', async (_request, reply) => { @@ -285,7 +285,7 @@ export class ApiAccountMastodon { const data = await client.unmuteAccount(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index aae6103146..5fce838f47 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -106,7 +106,7 @@ export class ApiAppsMastodon { client_secret: appData.clientSecret, }; - reply.send(response); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 242f068b99..f2bd0052d5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -35,7 +35,7 @@ export class ApiFilterMastodon { const data = await client.getFilters(); const response = data.data.map((filter) => convertFilter(filter)); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/filters/:id', async (_request, reply) => { @@ -45,7 +45,7 @@ export class ApiFilterMastodon { const data = await client.getFilter(_request.params.id); const response = convertFilter(data.data); - reply.send(response); + return reply.send(response); }); fastify.post('/v1/filters', async (_request, reply) => { @@ -64,7 +64,7 @@ export class ApiFilterMastodon { const data = await client.createFilter(_request.body.phrase, _request.body.context, options); const response = convertFilter(data.data); - reply.send(response); + return reply.send(response); }); fastify.post('/v1/filters/:id', async (_request, reply) => { @@ -84,7 +84,7 @@ export class ApiFilterMastodon { const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); const response = convertFilter(data.data); - reply.send(response); + return reply.send(response); }); fastify.delete('/v1/filters/:id', async (_request, reply) => { @@ -93,7 +93,7 @@ export class ApiFilterMastodon { const client = this.clientService.getClient(_request); const data = await client.deleteFilter(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index a168339ac6..cfca5b1350 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -87,7 +87,7 @@ export class ApiInstanceMastodon { rules: instance.rules ?? [], }; - reply.send(response); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 75512c2efc..f6cc59e782 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -45,7 +45,7 @@ export class ApiNotificationsMastodon { } attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/notification/:id', async (_request, reply) => { @@ -62,7 +62,7 @@ export class ApiNotificationsMastodon { }); } - reply.send(response); + return reply.send(response); }); fastify.post('/v1/notification/:id/dismiss', async (_request, reply) => { @@ -71,14 +71,14 @@ export class ApiNotificationsMastodon { const client = this.clientService.getClient(_request); const data = await client.dismissNotification(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); fastify.post('/v1/notifications/clear', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.dismissNotifications(); - reply.send(data.data); + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 33bfa87e5f..f58f21966c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -62,7 +62,7 @@ export class ApiSearchMastodon { attachMinMaxPagination(request, reply, response[type]); } - reply.send(response); + return reply.send(response); }); fastify.get('/v2/search', async (request, reply) => { @@ -103,7 +103,7 @@ export class ApiSearchMastodon { // Offset pagination is the only possible option attachOffsetPagination(request, reply, longestResult); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/trends/statuses', async (request, reply) => { @@ -126,7 +126,7 @@ export class ApiSearchMastodon { const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get('/v2/suggestions', async (request, reply) => { @@ -158,7 +158,7 @@ export class ApiSearchMastodon { })); attachOffsetPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index ec31e0cc46..22b8a911ca 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -38,7 +38,7 @@ export class ApiStatusMastodon { response.media_attachments = []; } - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { @@ -47,7 +47,7 @@ export class ApiStatusMastodon { const client = this.clientService.getClient(_request); const data = await client.getStatusSource(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { @@ -59,7 +59,7 @@ export class ApiStatusMastodon { const descendants = await Promise.all(data.descendants.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); const response = { ancestors, descendants }; - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { @@ -68,7 +68,7 @@ export class ApiStatusMastodon { const user = await this.clientService.getAuth(_request); const edits = await this.mastoConverters.getEdits(_request.params.id, user); - reply.send(edits); + return reply.send(edits); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { @@ -78,7 +78,7 @@ export class ApiStatusMastodon { const data = await client.getStatusRebloggedBy(_request.params.id); const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { @@ -88,7 +88,7 @@ export class ApiStatusMastodon { const data = await client.getStatusFavouritedBy(_request.params.id); const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { @@ -98,7 +98,7 @@ export class ApiStatusMastodon { const data = await client.getMedia(_request.params.id); const response = convertAttachment(data.data); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { @@ -108,7 +108,7 @@ export class ApiStatusMastodon { const data = await client.getPoll(_request.params.id); const response = convertPoll(data.data); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { @@ -119,7 +119,7 @@ export class ApiStatusMastodon { const data = await client.votePoll(_request.params.id, _request.body.choices); const response = convertPoll(data.data); - reply.send(response); + return reply.send(response); }); fastify.post<{ @@ -161,14 +161,14 @@ export class ApiStatusMastodon { body.in_reply_to_id, removed, ); - reply.send(a.data); + return reply.send(a.data); } if (body.in_reply_to_id && removed === '/unreact') { const id = body.in_reply_to_id; const post = await client.getStatus(id); const react = post.data.emoji_reactions.filter((e: Entity.Emoji) => e.me)[0].name; const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); + return reply.send(data.data); } if (!body.media_ids) body.media_ids = undefined; if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; @@ -194,7 +194,7 @@ export class ApiStatusMastodon { const data = await client.postStatus(text, options); const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); - reply.send(response); + return reply.send(response); }); fastify.put<{ @@ -233,7 +233,7 @@ export class ApiStatusMastodon { const data = await client.editStatus(_request.params.id, options); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { @@ -243,7 +243,7 @@ export class ApiStatusMastodon { const data = await client.createEmojiReaction(_request.params.id, '❤'); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { @@ -253,7 +253,7 @@ export class ApiStatusMastodon { const data = await client.deleteEmojiReaction(_request.params.id, '❤'); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { @@ -263,7 +263,7 @@ export class ApiStatusMastodon { const data = await client.reblogStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { @@ -273,7 +273,7 @@ export class ApiStatusMastodon { const data = await client.unreblogStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { @@ -283,7 +283,7 @@ export class ApiStatusMastodon { const data = await client.bookmarkStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { @@ -293,7 +293,7 @@ export class ApiStatusMastodon { const data = await client.unbookmarkStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); @@ -302,7 +302,7 @@ export class ApiStatusMastodon { const data = await client.pinStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { @@ -312,7 +312,7 @@ export class ApiStatusMastodon { const data = await client.unpinStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { @@ -323,7 +323,7 @@ export class ApiStatusMastodon { const data = await client.createEmojiReaction(_request.params.id, _request.params.name); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { @@ -334,7 +334,7 @@ export class ApiStatusMastodon { const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { @@ -343,7 +343,7 @@ export class ApiStatusMastodon { const client = this.clientService.getClient(_request); const data = await client.deleteStatus(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index b6162d9eb2..b2f7b18dc9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -28,7 +28,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => { @@ -38,7 +38,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => { @@ -50,7 +50,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => { @@ -62,7 +62,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => { @@ -72,7 +72,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -82,7 +82,7 @@ export class ApiTimelineMastodon { const data = await client.getList(_request.params.id); const response = convertList(data.data); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/lists', async (request, reply) => { @@ -91,7 +91,7 @@ export class ApiTimelineMastodon { const response = data.data.map((list: Entity.List) => convertList(list)); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => { @@ -102,7 +102,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { @@ -112,7 +112,7 @@ export class ApiTimelineMastodon { const client = this.clientService.getClient(_request); const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); + return reply.send(data.data); }); fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { @@ -122,7 +122,7 @@ export class ApiTimelineMastodon { const client = this.clientService.getClient(_request); const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); + return reply.send(data.data); }); fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { @@ -132,7 +132,7 @@ export class ApiTimelineMastodon { const data = await client.createList(_request.body.title); const response = convertList(data.data); - reply.send(response); + return reply.send(response); }); fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -143,7 +143,7 @@ export class ApiTimelineMastodon { const data = await client.updateList(_request.params.id, _request.body.title); const response = convertList(data.data); - reply.send(response); + return reply.send(response); }); fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -152,7 +152,7 @@ export class ApiTimelineMastodon { const client = this.clientService.getClient(_request); await client.deleteList(_request.params.id); - reply.send({}); + return reply.send({}); }); } } diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index e1f39dd9b6..18f585ea28 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -106,7 +106,7 @@ export class OAuth2ProviderService { 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()); + return reply.redirect(redirectUri.toString()); }); } @@ -120,7 +120,7 @@ export class OAuth2ProviderService { scope: 'read', created_at: Math.floor(new Date().getTime() / 1000), }; - reply.send(ret); + return reply.send(ret); } try { @@ -140,10 +140,10 @@ export class OAuth2ProviderService { scope: body.scope || 'read write follow push', created_at: Math.floor(new Date().getTime() / 1000), }; - reply.send(ret); + return reply.send(ret); } catch (e: unknown) { const data = getErrorData(e); - reply.code(401).send(data); + return reply.code(401).send(data); } }); } -- cgit v1.2.3-freya From b2ea03383cd53ac213c4dee6dbd086ab6f54daa7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 6 May 2025 23:19:23 -0400 Subject: implement '/v1/apps/verify_credentials' --- .../backend/src/server/api/AuthenticateService.ts | 2 + packages/backend/src/server/api/endpoint-list.ts | 1 + .../src/server/api/endpoints/app/current.ts | 73 ++++++++++++++++++++++ .../src/server/api/mastodon/MastodonConverters.ts | 12 +++- .../src/server/api/mastodon/endpoints/apps.ts | 9 +++ packages/megalodon/src/index.ts | 2 + .../megalodon/src/mastodon/entities/application.ts | 3 + packages/megalodon/src/misskey.ts | 9 +-- packages/megalodon/src/misskey/entities/app.ts | 2 +- packages/misskey-js/etc/misskey-js.api.md | 4 ++ packages/misskey-js/src/autogen/apiClientJSDoc.ts | 11 ++++ packages/misskey-js/src/autogen/endpoint.ts | 2 + packages/misskey-js/src/autogen/entities.ts | 1 + packages/misskey-js/src/autogen/types.ts | 61 ++++++++++++++++++ 14 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/app/current.ts (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 601618553e..397626c49d 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -84,6 +84,8 @@ export class AuthenticateService implements OnApplicationShutdown { return [user, { id: accessToken.id, permission: app.permission, + appId: app.id, + app, } as MiAccessToken]; } else { return [user, accessToken]; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 1c5a781fd9..a78c3e9ae6 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -128,6 +128,7 @@ export * as 'antennas/update' from './endpoints/antennas/update.js'; export * as 'ap/get' from './endpoints/ap/get.js'; export * as 'ap/show' from './endpoints/ap/show.js'; export * as 'app/create' from './endpoints/app/create.js'; +export * as 'app/current' from './endpoints/app/current.js'; export * as 'app/show' from './endpoints/app/show.js'; export * as 'auth/accept' from './endpoints/auth/accept.js'; export * as 'auth/session/generate' from './endpoints/auth/session/generate.js'; diff --git a/packages/backend/src/server/api/endpoints/app/current.ts b/packages/backend/src/server/api/endpoints/app/current.ts new file mode 100644 index 0000000000..39b5ef347c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/current.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AppsRepository } from '@/models/_.js'; +import { AppEntityService } from '@/core/entities/AppEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['app'], + + errors: { + credentialRequired: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }, + noAppLogin: { + message: 'Not logged in with an app.', + code: 'NO_APP_LOGIN', + id: '339a4ad2-48c3-47fc-bd9d-2408f05120f8', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'App', + }, + + // 10 calls per 5 seconds + limit: { + duration: 1000 * 5, + max: 10, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + private appEntityService: AppEntityService, + ) { + super(meta, paramDef, async (_, user, token) => { + if (!user) { + throw new ApiError(meta.errors.credentialRequired); + } + if (!token || !token.appId) { + throw new ApiError(meta.errors.noAppLogin); + } + + const app = token.app ?? await this.appsRepository.findOneByOrFail({ id: token.appId }); + + return await this.appEntityService.pack(app, user, { + detail: true, + includeSecret: false, + }); + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index cf625d6e94..375ea1ef08 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Entity, MastodonEntity } from 'megalodon'; +import { Entity, MastodonEntity, MisskeyEntity } from 'megalodon'; import mfm from '@transfem-org/sfm-js'; import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js'; import { NotificationType } from 'megalodon/lib/src/notification.js'; @@ -369,6 +369,15 @@ export class MastodonConverters { type: convertNotificationType(notification.type as NotificationType), }; } + + public convertApplication(app: MisskeyEntity.App): MastodonEntity.Application { + return { + name: app.name, + scopes: app.permission, + redirect_uri: app.callbackUrl, + redirect_uris: [app.callbackUrl], + }; + } } function simpleConvert(data: T): T { @@ -459,4 +468,3 @@ export function convertRelationship(relationship: Partial & note: relationship.note ?? '', }; } - diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index 5fce838f47..72b520c74a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import type { FastifyInstance } from 'fastify'; const readScope = [ @@ -59,6 +60,7 @@ type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; export class ApiAppsMastodon { constructor( private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, ) {} public register(fastify: FastifyInstance): void { @@ -108,6 +110,13 @@ export class ApiAppsMastodon { return reply.send(response); }); + + fastify.get('/v1/apps/verify_credentials', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.verifyAppCredentials(); + const response = this.mastoConverters.convertApplication(data.data); + return reply.send(response); + }); } } diff --git a/packages/megalodon/src/index.ts b/packages/megalodon/src/index.ts index 50663c3ce5..bacd0574d4 100644 --- a/packages/megalodon/src/index.ts +++ b/packages/megalodon/src/index.ts @@ -9,6 +9,7 @@ import * as NotificationType from './notification' import FilterContext from './filter_context' import Converter from './converter' import MastodonEntity from './mastodon/entity'; +import MisskeyEntity from './misskey/entity'; export { Response, @@ -23,4 +24,5 @@ export { Entity, Converter, MastodonEntity, + MisskeyEntity, } diff --git a/packages/megalodon/src/mastodon/entities/application.ts b/packages/megalodon/src/mastodon/entities/application.ts index a3f07997ee..f402152bf6 100644 --- a/packages/megalodon/src/mastodon/entities/application.ts +++ b/packages/megalodon/src/mastodon/entities/application.ts @@ -3,5 +3,8 @@ namespace MastodonEntity { name: string website?: string | null vapid_key?: string | null + scopes: string[] + redirect_uris: string[] + redirect_uri?: string } } diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index 669eb0f106..bc38e27ce5 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -102,7 +102,7 @@ export default class Misskey implements MegalodonInterface { website: null, redirect_uri: res.data.callbackUrl, client_id: '', - client_secret: res.data.secret + client_secret: res.data.secret! } return OAuth.AppData.from(appData) }) @@ -122,11 +122,8 @@ export default class Misskey implements MegalodonInterface { // ====================================== // apps // ====================================== - public async verifyAppCredentials(): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('misskey does not support') - reject(err) - }) + public async verifyAppCredentials(): Promise> { + return await this.client.post('/api/app/current'); } // ====================================== diff --git a/packages/megalodon/src/misskey/entities/app.ts b/packages/megalodon/src/misskey/entities/app.ts index 40a704b944..49c431596f 100644 --- a/packages/megalodon/src/misskey/entities/app.ts +++ b/packages/megalodon/src/misskey/entities/app.ts @@ -4,6 +4,6 @@ namespace MisskeyEntity { name: string callbackUrl: string permission: Array - secret: string + secret?: string } } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 18cb070af5..44700add31 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -552,6 +552,9 @@ type AppCreateRequest = operations['app___create']['requestBody']['content']['ap // @public (undocumented) type AppCreateResponse = operations['app___create']['responses']['200']['content']['application/json']; +// @public (undocumented) +type AppCurrentResponse = operations['app___current']['responses']['200']['content']['application/json']; + // @public (undocumented) type AppShowRequest = operations['app___show']['requestBody']['content']['application/json']; @@ -1643,6 +1646,7 @@ declare namespace entities { ApShowResponse, AppCreateRequest, AppCreateResponse, + AppCurrentResponse, AppShowRequest, AppShowResponse, AuthAcceptRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 75b3c5769e..0dfe042811 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1313,6 +1313,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 9293a5e950..b424927316 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -159,6 +159,7 @@ import type { ApShowResponse, AppCreateRequest, AppCreateResponse, + AppCurrentResponse, AppShowRequest, AppShowResponse, AuthAcceptRequest, @@ -778,6 +779,7 @@ export type Endpoints = { 'ap/get': { req: ApGetRequest; res: ApGetResponse }; 'ap/show': { req: ApShowRequest; res: ApShowResponse }; 'app/create': { req: AppCreateRequest; res: AppCreateResponse }; + 'app/current': { req: EmptyRequest; res: AppCurrentResponse }; 'app/show': { req: AppShowRequest; res: AppShowResponse }; 'auth/accept': { req: AuthAcceptRequest; res: EmptyResponse }; 'auth/session/generate': { req: AuthSessionGenerateRequest; res: AuthSessionGenerateResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index f71407a6ae..39359e3cfa 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -162,6 +162,7 @@ export type ApShowRequest = operations['ap___show']['requestBody']['content']['a export type ApShowResponse = operations['ap___show']['responses']['200']['content']['application/json']; export type AppCreateRequest = operations['app___create']['requestBody']['content']['application/json']; export type AppCreateResponse = operations['app___create']['responses']['200']['content']['application/json']; +export type AppCurrentResponse = operations['app___current']['responses']['200']['content']['application/json']; export type AppShowRequest = operations['app___show']['requestBody']['content']['application/json']; export type AppShowResponse = operations['app___show']['responses']['200']['content']['application/json']; export type AuthAcceptRequest = operations['auth___accept']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 57e98f2f88..077ea35729 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1086,6 +1086,15 @@ export type paths = { */ post: operations['app___create']; }; + '/app/current': { + /** + * app/current + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['app___current']; + }; '/app/show': { /** * app/show @@ -13071,6 +13080,58 @@ export type operations = { }; }; }; + /** + * app/current + * @description No description provided. + * + * **Credential required**: *No* + */ + app___current: { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['App']; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Too many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * app/show * @description No description provided. -- cgit v1.2.3-freya From 261a7e3ab3063ec0955a9ea4242dce81eaad4544 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 3 May 2025 22:25:33 -0400 Subject: fix type errors --- packages/backend/src/server/api/mastodon/endpoints/search.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/endpoints') diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 33bfa87e5f..78672639e5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -112,7 +112,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers as HeadersInit, + ...request.headers, 'Accept': 'application/json', 'Content-Type': 'application/json', }, @@ -135,7 +135,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers as HeadersInit, + ...request.headers, 'Accept': 'application/json', 'Content-Type': 'application/json', }, -- cgit v1.2.3-freya