From 16f483d273114d0ef86d7ac2c24d3d9386f9f486 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 31 Jan 2025 02:46:38 -0500 Subject: cleanup Mastodon API (resolves #804 and #865, partially resolves #492) * Fix TS errors and warnings * Fix ESLint errors and warnings * Fix property typos in various places * Fix property data conversion * Add missing entity properties * Normalize logging and reduce spam * Check for missing request parameters * Allow mastodon API to work with local debugging * Safer error handling * Fix quote-post detection --- .../api/mastodon/MastodonApiServerService.ts | 740 +++++++++++---------- 1 file changed, 406 insertions(+), 334 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/MastodonApiServerService.ts') diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index b40e4cdaa4..18a974c234 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -3,50 +3,50 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import megalodon, { Entity, MegalodonInterface } from 'megalodon'; import querystring from 'querystring'; +import { megalodon, Entity, MegalodonInterface } from 'megalodon'; import { IsNull } from 'typeorm'; import multer from 'fastify-multer'; -import type { AccessTokensRepository, NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { AccessTokensRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; +import { DriveService } from '@/core/DriveService.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js'; +import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js'; +import { AuthMastodonRoute } from './endpoints/auth.js'; +import { toBoolean } from './timelineArgs.js'; import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; import { getInstance } from './endpoints/meta.js'; import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DriveService } from '@/core/DriveService.js'; export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { const accessTokenArr = authorization?.split(' ') ?? [null]; const accessToken = accessTokenArr[accessTokenArr.length - 1]; - const generator = (megalodon as any).default; - const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; - return client; + return megalodon('misskey', BASE_URL, accessToken); } @Injectable() export class MastodonApiServerService { constructor( @Inject(DI.meta) - private serverSettings: MiMeta, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, + private readonly serverSettings: MiMeta, + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, + private readonly userProfilesRepository: UserProfilesRepository, @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - @Inject(DI.config) - private config: Config, - private userEntityService: UserEntityService, - private driveService: DriveService, - private mastoConverter: MastoConverters, + private readonly accessTokensRepository: AccessTokensRepository, + @Inject(DI.config) + private readonly config: Config, + private readonly driveService: DriveService, + private readonly mastoConverter: MastoConverters, + private readonly logger: MastodonLogger, ) { } @bindThis @@ -59,12 +59,12 @@ export class MastodonApiServerService { }, }); - fastify.addHook('onRequest', (request, reply, done) => { + fastify.addHook('onRequest', (_, reply, done) => { reply.header('Access-Control-Allow-Origin', '*'); done(); }); - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { + fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { let body = ''; payload.on('data', (data) => { body += data; @@ -73,8 +73,8 @@ export class MastodonApiServerService { try { const parsed = querystring.parse(body); done(null, parsed); - } catch (e: any) { - done(e); + } catch (e) { + done(e as Error); } }); payload.on('error', done); @@ -83,20 +83,21 @@ export class MastodonApiServerService { fastify.register(multer.contentParser); fastify.get('/v1/custom_emojis', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const data = await client.getInstanceCustomEmojis(); reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/custom_emojis', data); + reply.code(401).send(data); } }); fastify.get('/v1/instance', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in @@ -113,116 +114,122 @@ export class MastodonApiServerService { }); const contact = admin == null ? null : await this.mastoConverter.convertAccount((await client.getAccount(admin.id)).data); reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/instance', data); + reply.code(401).send(data); } }); fastify.get('/v1/announcements', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const data = await client.getInstanceAnnouncements(); reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/announcements', data); + reply.code(401).send(data); } }); - fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.dismissInstanceAnnouncement( - _request.body['id'], - ); + if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + const data = await client.dismissInstanceAnnouncement(_request.body['id']); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/announcements/${_request.body.id}/dismiss`, data); + reply.code(401).send(data); } - }, - ); + }); fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const multipartData = await _request.file; + const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } const data = await client.uploadMedia(multipartData); reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/media', data); + reply.code(401).send(data); } }); - fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const multipartData = await _request.file; + const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } - const data = await client.uploadMedia(multipartData, _request.body!); + const data = await client.uploadMedia(multipartData, _request.body); reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v2/media', data); + reply.code(401).send(data); } }); fastify.get('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getFilters(); reply.send(data.data.map((filter) => convertFilter(filter))); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/filters', data); + reply.code(401).send(data); } }); fastify.get('/v1/trends', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getInstanceTrends(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends', data); + reply.code(401).send(data); } }); fastify.get('/v1/trends/tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getInstanceTrends(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends/tags', data); + reply.code(401).send(data); } }); @@ -231,50 +238,72 @@ export class MastodonApiServerService { reply.send([]); }); - fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await ApiAuthMastodon(_request, client); reply.send(data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/apps', data); + reply.code(401).send(data); } }); fastify.get('/v1/preferences', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getPreferences(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/preferences', data); + reply.code(401).send(data); } }); //#region Accounts - fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.verifyCredentials()); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/verify_credentials', data); + reply.code(401).send(data); } }); - fastify.patch('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.patch<{ + Body: { + discoverable?: string, + bot?: string, + display_name?: string, + note?: string, + avatar?: string, + header?: string, + locked?: string, + source?: { + privacy?: string, + sensitive?: string, + language?: string, + }, + fields_attributes?: { + name: string, + value: string, + }[], + }, + }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in @@ -332,512 +361,539 @@ export class MastodonApiServerService { (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); } - const data = await client.updateCredentials(_request.body!); + 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.mastoConverter.convertAccount(data.data)); - } catch (e: any) { - //console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('PATCH /v1/accounts/update_credentials', data); + reply.code(401).send(data); } }); - fastify.get('/v1/accounts/lookup', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in + const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in try { - const data = await client.search((_request.query as any).acct, { type: 'accounts' }); + if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); + const data = await client.search(_request.query.acct, { type: 'accounts' }); const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); - data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) || []; + data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; reply.send(await this.mastoConverter.convertAccount(data.data.accounts[0])); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/lookup', data); + reply.code(401).send(data); } }); - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/accounts/relationships', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - let users; + const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in try { - let ids = _request.query ? (_request.query as any)['id[]'] ?? (_request.query as any)['id'] : null; + let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; if (typeof ids === 'string') { ids = [ids]; } - users = ids; - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getRelationships(users)); - } catch (e: any) { - /* console.error(e); */ - const data = e.response.data; - data.users = users; - console.error(data); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + reply.send(await account.getRelationships(ids)); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/relationships', data); reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const sharkId = _request.params.id; - const data = await client.getAccount(sharkId); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const data = await client.getAccount(_request.params.id); const account = await this.mastoConverter.convertAccount(data.data); reply.send(account); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.getStatuses()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getFeaturedTags(); reply.send(data.data.map((tag) => convertFeaturedTag(tag))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.getFollowers()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/accounts/:id/following', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.getFollowing()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } 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) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getAccountLists(_request.params.id); reply.send(data.data.map((list) => convertList(list))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.addFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.rmFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.addBlock()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.rmBlock()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.addMute()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.rmMute()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); + reply.code(401).send(data); } }); fastify.get('/v1/followed_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const data = await client.getFollowedTags(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/followed_tags', data); + reply.code(401).send(data); } }); - fastify.get('/v1/bookmarks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/bookmarks', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.getBookmarks()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/bookmarks', data); + reply.code(401).send(data); } }); - fastify.get('/v1/favourites', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/favourites', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.getFavourites()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/favourites', data); + reply.code(401).send(data); } }); - fastify.get('/v1/mutes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/mutes', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.getMutes()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/mutes', data); + reply.code(401).send(data); } }); - fastify.get('/v1/blocks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/blocks', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.getBlocks()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/blocks', data); + reply.code(401).send(data); } }); - fastify.get('/v1/follow_requests', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit ); + 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.mastoConverter.convertAccount(account as Entity.Account)))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/follow_requests', data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.acceptFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const account = new ApiAccountMastodon(_request, client, this.mastoConverter); reply.send(await account.rejectFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); + reply.code(401).send(data); } }); //#endregion //#region Search - fastify.get('/v1/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/search', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.SearchV1()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.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) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v2/search', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.SearchV2()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/search', data); + reply.code(401).send(data); } }); - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/trends/statuses', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.getStatusTrends()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends/statuses', data); + reply.code(401).send(data); } }); - fastify.get('/v2/suggestions', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v2/suggestions', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.getSuggestions()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/suggestions', data); + reply.code(401).send(data); } }); //#endregion //#region Notifications - fastify.get('/v1/notifications', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/notifications', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const notify = new ApiNotifyMastodon(_request, client); reply.send(await notify.getNotifications()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/notifications', data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/notification/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const notify = new ApiNotifyMastodon(_request, client); reply.send(await notify.getNotification()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/notification/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const notify = new ApiNotifyMastodon(_request, client); reply.send(await notify.rmNotification()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.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) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const notify = new ApiNotifyMastodon(_request, client); reply.send(await notify.rmNotifications()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/notifications/clear', data); + reply.code(401).send(data); } }); //#endregion //#region Filters - fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get('/v1/filters/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const filter = new ApiFilterMastodon(_request, client); - !_request.params.id ? reply.send(await filter.getFilters()) : reply.send(await filter.getFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + _request.params.id + ? reply.send(await filter.getFilter()) + : reply.send(await filter.getFilters()); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const filter = new ApiFilterMastodon(_request, client); reply.send(await filter.createFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/filters', data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const filter = new ApiFilterMastodon(_request, client); reply.send(await filter.updateFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.delete('/v1/filters/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const filter = new ApiFilterMastodon(_request, client); reply.send(await filter.rmFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); } }); //#endregion //#region Timelines - const TLEndpoint = new ApiTimelineMastodon(fastify, this.config, this.mastoConverter); + const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverter, this.logger); // GET Endpoints TLEndpoint.getTL(); @@ -862,7 +918,7 @@ export class MastodonApiServerService { //#endregion //#region Status - const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter); + const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter, this.logger); // GET Endpoints NoteEndpoint.getStatus(); @@ -889,16 +945,32 @@ export class MastodonApiServerService { NoteEndpoint.votePoll(); // PUT Endpoint - fastify.put<{ Params: { id: string } }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.put<{ + Params: { + id?: string, + }, + Body: { + file?: unknown, + description?: string, + focus?: string, + is_sensitive?: string, + }, + }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.updateMedia(_request.params.id, _request.body!); + 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 data = await client.updateMedia(_request.params.id, options); reply.send(convertAttachment(data.data)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`PUT /v1/media/${_request.params.id}`, data); + reply.code(401).send(data); } }); NoteEndpoint.updateStatus(); -- cgit v1.2.3-freya From 3550ce27d50f160917b3e68829fd649e3e3f0571 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 31 Jan 2025 11:38:20 -0500 Subject: hide restricted edit history from mastodon api (resolves #811) --- packages/backend/src/server/ServerModule.ts | 2 ++ .../api/mastodon/MastodonApiServerService.ts | 12 +++++-- .../src/server/api/mastodon/MastodonDataService.ts | 40 ++++++++++++++++++++++ .../backend/src/server/api/mastodon/converters.ts | 14 +++++--- .../src/server/api/mastodon/endpoints/status.ts | 7 ++-- 5 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/MastodonDataService.ts (limited to 'packages/backend/src/server/api/mastodon/MastodonApiServerService.ts') diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 9a0610b1b7..2c067afe88 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -28,6 +28,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { MastoConverters } from './api/mastodon/converters.js'; import { MastodonLogger } from './api/mastodon/MastodonLogger.js'; +import { MastodonDataService } from './api/mastodon/MastodonDataService.js'; import { FeedService } from './web/FeedService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; @@ -105,6 +106,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j OAuth2ProviderService, MastoConverters, MastodonLogger, + MastodonDataService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 18a974c234..40180394d3 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -18,6 +18,7 @@ import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js'; import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js'; import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js'; +import { AuthenticateService } from '@/server/api/AuthenticateService.js'; import { AuthMastodonRoute } from './endpoints/auth.js'; import { toBoolean } from './timelineArgs.js'; import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; @@ -25,9 +26,13 @@ import { getInstance } from './endpoints/meta.js'; import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { +export function getAccessToken(authorization: string | undefined): string | null { const accessTokenArr = authorization?.split(' ') ?? [null]; - const accessToken = accessTokenArr[accessTokenArr.length - 1]; + return accessTokenArr[accessTokenArr.length - 1]; +} + +export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { + const accessToken = getAccessToken(authorization); return megalodon('misskey', BASE_URL, accessToken); } @@ -47,6 +52,7 @@ export class MastodonApiServerService { private readonly driveService: DriveService, private readonly mastoConverter: MastoConverters, private readonly logger: MastodonLogger, + private readonly authenticateService: AuthenticateService, ) { } @bindThis @@ -918,7 +924,7 @@ export class MastodonApiServerService { //#endregion //#region Status - const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter, this.logger); + const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter, this.logger, this.authenticateService); // GET Endpoints NoteEndpoint.getStatus(); diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts new file mode 100644 index 0000000000..782f592ee5 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { QueryService } from '@/core/QueryService.js'; +import type { MiNote, NotesRepository } from '@/models/_.js'; +import type { MiLocalUser } from '@/models/User.js'; + +/** + * Utility service for accessing data with Mastodon semantics + */ +@Injectable() +export class MastodonDataService { + constructor( + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, + + @Inject(QueryService) + private readonly queryService: QueryService, + ) {} + + public async getNote(noteId: string, me?: MiLocalUser | null): Promise { + // Root query: note + required dependencies + const query = this.notesRepository + .createQueryBuilder('note') + .where('note.id = :noteId', { noteId }) + .innerJoinAndSelect('note.user', 'user'); + + // Restrict visibility + this.queryService.generateVisibilityQuery(query, me); + if (me) { + this.queryService.generateBlockedUserQuery(query, me); + } + + return await query.getOne(); + } +} diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 4aea91b4f2..d7309b5a87 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -10,14 +10,15 @@ 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 { MiUser } from '@/models/User.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 { GetterService } from '../GetterService.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/ @@ -62,6 +63,7 @@ export class MastoConverters { private readonly customEmojiService: CustomEmojiService, private readonly idService: IdService, private readonly driveFileEntityService: DriveFileEntityService, + private readonly mastodonDataService: MastodonDataService, ) {} private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention { @@ -184,9 +186,11 @@ export class MastoConverters { }); } - public async getEdits(id: string) { - const note = await this.getterService.getNote(id); - + public async getEdits(id: string, me?: MiLocalUser | null) { + 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: Promise[] = []; diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 1767439c2f..99259d2542 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -7,8 +7,9 @@ import querystring, { ParsedUrlQueryInput } from 'querystring'; 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 { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; -import { getClient } from '../MastodonApiServerService.js'; +import { getAccessToken, getClient } from '../MastodonApiServerService.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -22,6 +23,7 @@ export class ApiStatusMastodon { private readonly fastify: FastifyInstance, private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, + private readonly authenticateService: AuthenticateService, ) {} public getStatus() { @@ -81,7 +83,8 @@ export class ApiStatusMastodon { this.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 edits = await this.mastoConverters.getEdits(_request.params.id); + const [user] = await this.authenticateService.authenticate(getAccessToken(_request.headers.authorization)); + const edits = await this.mastoConverters.getEdits(_request.params.id, user); reply.send(edits); } catch (e) { const data = getErrorData(e); -- cgit v1.2.3-freya From 1e43162ba777d471c5406f614b72425e0800bf2f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 31 Jan 2025 12:54:18 -0500 Subject: improve mastodon note conversion and use access checks in more places (resolves #509) --- .../api/mastodon/MastodonApiServerService.ts | 184 +++++++++------------ .../src/server/api/mastodon/MastodonDataService.ts | 44 +++++ .../backend/src/server/api/mastodon/converters.ts | 68 ++++---- .../src/server/api/mastodon/endpoints/account.ts | 8 +- .../server/api/mastodon/endpoints/notifications.ts | 13 +- .../src/server/api/mastodon/endpoints/search.ts | 16 +- .../src/server/api/mastodon/endpoints/status.ts | 89 ++++------ .../src/server/api/mastodon/endpoints/timeline.ts | 36 ++-- 8 files changed, 222 insertions(+), 236 deletions(-) (limited to 'packages/backend/src/server/api/mastodon/MastodonApiServerService.ts') diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 40180394d3..69799bdade 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -19,12 +19,13 @@ import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.j import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js'; import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import { MiLocalUser } from '@/models/User.js'; import { AuthMastodonRoute } from './endpoints/auth.js'; import { toBoolean } from './timelineArgs.js'; import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; import { getInstance } from './endpoints/meta.js'; import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import type { FastifyInstance, FastifyPluginOptions, FastifyRequest } from 'fastify'; export function getAccessToken(authorization: string | undefined): string | null { const accessTokenArr = authorization?.split(' ') ?? [null]; @@ -50,11 +51,29 @@ export class MastodonApiServerService { @Inject(DI.config) private readonly config: Config, private readonly driveService: DriveService, - private readonly mastoConverter: MastoConverters, + private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, private readonly authenticateService: AuthenticateService, ) { } + @bindThis + public async getAuthClient(request: FastifyRequest): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { + const accessToken = getAccessToken(request.headers.authorization); + const [me] = await this.authenticateService.authenticate(accessToken); + + const baseUrl = `${request.protocol}://${request.host}`; + const client = megalodon('misskey', baseUrl, accessToken); + + return { client, me }; + } + + @bindThis + public async getAuthOnly(request: FastifyRequest): Promise { + const accessToken = getAccessToken(request.headers.authorization); + const [me] = await this.authenticateService.authenticate(accessToken); + return me; + } + @bindThis public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { const upload = multer({ @@ -118,7 +137,7 @@ export class MastodonApiServerService { }, order: { id: 'ASC' }, }); - const contact = admin == null ? null : await this.mastoConverter.convertAccount((await client.getAccount(admin.id)).data); + const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings)); } catch (e) { const data = getErrorData(e); @@ -275,12 +294,9 @@ export class MastodonApiServerService { //#region Accounts fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in try { - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.verifyCredentials()); } catch (e) { const data = getErrorData(e); @@ -378,7 +394,7 @@ export class MastodonApiServerService { } : undefined, }; const data = await client.updateCredentials(options); - reply.send(await this.mastoConverter.convertAccount(data.data)); + reply.send(await this.mastoConverters.convertAccount(data.data)); } catch (e) { const data = getErrorData(e); this.logger.error('PATCH /v1/accounts/update_credentials', data); @@ -395,7 +411,7 @@ export class MastodonApiServerService { const data = await client.search(_request.query.acct, { type: 'accounts' }); const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; - reply.send(await this.mastoConverter.convertAccount(data.data.accounts[0])); + reply.send(await this.mastoConverters.convertAccount(data.data.accounts[0])); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/accounts/lookup', data); @@ -404,15 +420,13 @@ export class MastodonApiServerService { }); fastify.get('/v1/accounts/relationships', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in try { + const { client, me } = await this.getAuthClient(_request); let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; if (typeof ids === 'string') { ids = [ids]; } - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getRelationships(ids)); } catch (e) { const data = getErrorData(e); @@ -428,7 +442,7 @@ export class MastodonApiServerService { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getAccount(_request.params.id); - const account = await this.mastoConverter.convertAccount(data.data); + const account = await this.mastoConverters.convertAccount(data.data); reply.send(account); } catch (e) { const data = getErrorData(e); @@ -438,12 +452,10 @@ export class MastodonApiServerService { }); fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getStatuses()); } catch (e) { const data = getErrorData(e); @@ -468,12 +480,10 @@ export class MastodonApiServerService { }); fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getFollowers()); } catch (e) { const data = getErrorData(e); @@ -483,12 +493,10 @@ export class MastodonApiServerService { }); fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getFollowing()); } catch (e) { const data = getErrorData(e); @@ -513,12 +521,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.addFollow()); } catch (e) { const data = getErrorData(e); @@ -528,12 +534,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rmFollow()); } catch (e) { const data = getErrorData(e); @@ -543,12 +547,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.addBlock()); } catch (e) { const data = getErrorData(e); @@ -558,12 +560,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rmBlock()); } catch (e) { const data = getErrorData(e); @@ -573,12 +573,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.addMute()); } catch (e) { const data = getErrorData(e); @@ -588,12 +586,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rmMute()); } catch (e) { const data = getErrorData(e); @@ -617,11 +613,9 @@ export class MastodonApiServerService { }); fastify.get('/v1/bookmarks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getBookmarks()); } catch (e) { const data = getErrorData(e); @@ -631,11 +625,9 @@ export class MastodonApiServerService { }); fastify.get('/v1/favourites', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getFavourites()); } catch (e) { const data = getErrorData(e); @@ -645,11 +637,9 @@ export class MastodonApiServerService { }); fastify.get('/v1/mutes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getMutes()); } catch (e) { const data = getErrorData(e); @@ -659,11 +649,9 @@ export class MastodonApiServerService { }); fastify.get('/v1/blocks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getBlocks()); } catch (e) { const data = getErrorData(e); @@ -679,7 +667,7 @@ export class MastodonApiServerService { try { 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.mastoConverter.convertAccount(account as Entity.Account)))); + 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); @@ -688,12 +676,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.acceptFollow()); } catch (e) { const data = getErrorData(e); @@ -703,12 +689,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const account = new ApiAccountMastodon(_request, client, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rejectFollow()); } catch (e) { const data = getErrorData(e); @@ -721,10 +705,9 @@ export class MastodonApiServerService { //#region Search fastify.get('/v1/search', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.SearchV1()); } catch (e) { const data = getErrorData(e); @@ -735,10 +718,9 @@ export class MastodonApiServerService { fastify.get('/v2/search', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.SearchV2()); } catch (e) { const data = getErrorData(e); @@ -749,10 +731,9 @@ export class MastodonApiServerService { fastify.get('/v1/trends/statuses', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.getStatusTrends()); } catch (e) { const data = getErrorData(e); @@ -763,10 +744,9 @@ export class MastodonApiServerService { fastify.get('/v2/suggestions', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.getSuggestions()); } catch (e) { const data = getErrorData(e); @@ -778,11 +758,9 @@ export class MastodonApiServerService { //#region Notifications fastify.get('/v1/notifications', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const notify = new ApiNotifyMastodon(_request, client); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.getNotifications()); } catch (e) { const data = getErrorData(e); @@ -792,12 +770,10 @@ export class MastodonApiServerService { }); fastify.get('/v1/notification/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const notify = new ApiNotifyMastodon(_request, client); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.getNotification()); } catch (e) { const data = getErrorData(e); @@ -807,12 +783,10 @@ export class MastodonApiServerService { }); fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const notify = new ApiNotifyMastodon(_request, client); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.rmNotification()); } catch (e) { const data = getErrorData(e); @@ -822,11 +796,9 @@ export class MastodonApiServerService { }); fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const notify = new ApiNotifyMastodon(_request, client); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.rmNotifications()); } catch (e) { const data = getErrorData(e); @@ -899,7 +871,7 @@ export class MastodonApiServerService { //#endregion //#region Timelines - const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverter, this.logger); + const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverters, this.logger, this); // GET Endpoints TLEndpoint.getTL(); @@ -924,7 +896,7 @@ export class MastodonApiServerService { //#endregion //#region Status - const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter, this.logger, this.authenticateService); + const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverters, this.logger, this.authenticateService, this); // GET Endpoints NoteEndpoint.getStatus(); diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts index 782f592ee5..671ecdcbed 100644 --- a/packages/backend/src/server/api/mastodon/MastodonDataService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts @@ -4,10 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import { QueryService } from '@/core/QueryService.js'; import type { MiNote, NotesRepository } from '@/models/_.js'; import type { MiLocalUser } from '@/models/User.js'; +import { ApiError } from '../error.js'; /** * Utility service for accessing data with Mastodon semantics @@ -22,6 +24,28 @@ export class MastodonDataService { private readonly queryService: QueryService, ) {} + /** + * Fetches a note in the context of the current user, and throws an exception if not found. + */ + public async requireNote(noteId: string, me?: MiLocalUser | null): Promise { + const note = await this.getNote(noteId, me); + + if (!note) { + throw new ApiError({ + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', + kind: 'client', + httpStatusCode: 404, + }); + } + + return note; + } + + /** + * Fetches a note in the context of the current user. + */ public async getNote(noteId: string, me?: MiLocalUser | null): Promise { // Root query: note + required dependencies const query = this.notesRepository @@ -37,4 +61,24 @@ export class MastodonDataService { return await query.getOne(); } + + /** + * Checks where the current user has made a reblog / boost / pure renote of a given target note. + */ + public async hasReblog(noteId: string, me: MiLocalUser | null | undefined): Promise { + if (!me) return false; + + return await this.notesRepository.existsBy({ + // Reblog of the target note by me + userId: me.id, + renoteId: noteId, + + // That is pure (not a quote) + text: IsNull(), + cw: IsNull(), + replyId: IsNull(), + hasPoll: false, + fileIds: '{}', + }); + } } diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index d7309b5a87..773dfb6923 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -215,15 +215,16 @@ export class MastoConverters { return await Promise.all(history); } - private async convertReblog(status: Entity.Status | null): Promise { + private async convertReblog(status: Entity.Status | null, me?: MiLocalUser | null): Promise { if (!status) return null; - return await this.convertStatus(status); + return await this.convertStatus(status, me); } - public async convertStatus(status: Entity.Status): Promise { + public async convertStatus(status: Entity.Status, me?: MiLocalUser | null): Promise { const convertedAccount = this.convertAccount(status.account); - const note = await this.getterService.getNote(status.id); + 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[] = []; @@ -240,7 +241,7 @@ export class MastoConverters { const mentions = Promise.all(note.mentions.map(p => this.getUser(p) - .then(u => this.encode(u, JSON.parse(note.mentionedRemoteUsers))) + .then(u => this.encode(u, mentionedRemoteUsers)) .catch(() => null))) .then(p => p.filter(m => m)) as Promise; @@ -255,7 +256,7 @@ export class MastoConverters { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId); - const renote: Promise | null = note.renoteId ? this.getterService.getNote(note.renoteId) : null; + 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; @@ -265,10 +266,12 @@ export class MastoConverters { const text = note.text; const content = text !== null ? quoteUri - .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), false, quoteUri)) + .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri)) .then(p => p ?? escapeMFM(text)) : ''; + const reblogged = await this.mastodonDataService.hasReblog(note.id, me); + // noinspection ES6MissingAwait return await awaitAll({ id: note.id, @@ -277,7 +280,7 @@ 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) : null, + reblog: !isQuote ? await this.convertReblog(status.reblog, me) : null, content: content, content_type: 'text/x.misskeymarkdown', text: note.text, @@ -286,7 +289,7 @@ export class MastoConverters { replies_count: note.repliesCount, reblogs_count: note.renoteCount, favourites_count: status.favourites_count, - reblogged: false, + reblogged, favourited: status.favourited, muted: status.muted, sensitive: status.sensitive, @@ -303,10 +306,29 @@ export class MastoConverters { reactions: status.emoji_reactions, emoji_reactions: status.emoji_reactions, bookmarked: false, //FIXME - quote: isQuote ? await this.convertReblog(status.reblog) : null, + quote: isQuote ? await this.convertReblog(status.reblog, me) : null, edited_at: note.updatedAt?.toISOString() ?? null, }); } + + 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, + }; + } } function simpleConvert(data: T): T { @@ -333,12 +355,6 @@ export function convertFeaturedTag(tag: Entity.FeaturedTag) { return simpleConvert(tag); } -export function convertNotification(notification: Entity.Notification) { - notification.account = convertAccount(notification.account); - if (notification.status) notification.status = convertStatus(notification.status); - return notification; -} - export function convertPoll(poll: Entity.Poll) { return simpleConvert(poll); } @@ -372,27 +388,7 @@ export function convertRelationship(relationship: Partial & }; } -export function convertStatus(status: Entity.Status) { - status.account = convertAccount(status.account); - status.media_attachments = status.media_attachments.map((attachment) => - convertAttachment(attachment), - ); - if (status.poll) status.poll = convertPoll(status.poll); - if (status.reblog) status.reblog = convertStatus(status.reblog); - - return status; -} - // noinspection JSUnusedGlobalSymbols export function convertStatusSource(status: Entity.StatusSource) { return simpleConvert(status); } - -export function convertConversation(conversation: Entity.Conversation) { - conversation.accounts = conversation.accounts.map(convertAccount); - if (conversation.last_status) { - conversation.last_status = convertStatus(conversation.last_status); - } - - return conversation; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 1481a48924..79cdddcb9e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -5,6 +5,7 @@ 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'; @@ -20,6 +21,7 @@ export class ApiAccountMastodon { constructor( private readonly request: FastifyRequest, private readonly client: MegalodonInterface, + private readonly me: MiLocalUser | null, private readonly mastoConverters: MastoConverters, ) {} @@ -51,7 +53,7 @@ export class ApiAccountMastodon { 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))); + return await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, this.me))); } public async getFollowers() { @@ -117,12 +119,12 @@ export class ApiAccountMastodon { public async getBookmarks() { const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status))); + return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); } public async getFavourites() { const data = await this.client.getFavourites(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status))); + return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); } public async getMutes() { diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index c228e17ddc..14eee8565a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -4,7 +4,8 @@ */ import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { convertNotification } from '../converters.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MastoConverters } from '@/server/api/mastodon/converters.js'; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; @@ -19,23 +20,25 @@ export class ApiNotifyMastodon { constructor( private readonly request: FastifyRequest, private readonly client: MegalodonInterface, + private readonly me: MiLocalUser | null, + private readonly mastoConverters: MastoConverters, ) {} public async getNotifications() { const data = await this.client.getNotifications(parseTimelineArgs(this.request.query)); - return data.data.map(n => { - const converted = convertNotification(n); + return Promise.all(data.data.map(async n => { + const converted = await this.mastoConverters.convertNotification(n, this.me); if (converted.type === 'reaction') { converted.type = 'favourite'; } 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 = convertNotification(data.data); + const converted = await this.mastoConverters.convertNotification(data.data, this.me); if (converted.type === 'reaction') { converted.type = 'favourite'; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index ac5b836c1e..4850b4652f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MiLocalUser } from '@/models/User.js'; import { MastoConverters } from '../converters.js'; import { parseTimelineArgs, TimelineArgs } from '../timelineArgs.js'; import Account = Entity.Account; @@ -21,8 +22,9 @@ export class ApiSearchMastodon { constructor( private readonly request: FastifyRequest, private readonly client: MegalodonInterface, + private readonly me: MiLocalUser | null, private readonly BASE_URL: string, - private readonly mastoConverter: MastoConverters, + private readonly mastoConverters: MastoConverters, ) {} public async SearchV1() { @@ -40,8 +42,8 @@ export class ApiSearchMastodon { 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.mastoConverter.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverter.convertStatus(status)) ?? []), + 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 ?? [], }; } @@ -54,10 +56,12 @@ export class ApiSearchMastodon { 'Accept': 'application/json', 'Content-Type': 'application/json', }, - body: '{}', + body: JSON.stringify({ + i: this.request.headers.authorization?.replace('Bearer ', ''), + }), }) .then(res => res.json() as Promise) - .then(data => data.map(status => this.mastoConverter.convertStatus(status))); + .then(data => data.map(status => this.mastoConverters.convertStatus(status, this.me))); return Promise.all(data); } @@ -83,7 +87,7 @@ export class ApiSearchMastodon { account: entry, })))); return Promise.all(data.map(async suggestion => { - suggestion.account = await this.mastoConverter.convertAccount(suggestion.account); + suggestion.account = await this.mastoConverters.convertAccount(suggestion.account); return suggestion; })); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 99259d2542..4c49a6a293 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -9,7 +9,7 @@ import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogg import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/timelineArgs.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js'; import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; -import { getAccessToken, getClient } from '../MastodonApiServerService.js'; +import { getAccessToken, getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -24,17 +24,16 @@ export class ApiStatusMastodon { private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, private readonly authenticateService: AuthenticateService, + private readonly mastodon: MastodonApiServerService, ) {} public getStatus() { this.fastify.get<{ 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); try { + const { client, me } = await this.mastodon.getAuthClient(_request); if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}`, data); @@ -62,14 +61,12 @@ export class ApiStatusMastodon { public getContext() { this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.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))); - const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status))); + 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 }); } catch (e) { const data = getErrorData(e); @@ -204,11 +201,9 @@ export class ApiStatusMastodon { 'media_ids[]'?: string[], } }>('/v1/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); let body = _request.body; try { + const { client, me } = await this.mastodon.getAuthClient(_request); if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) ) { body = normalizeQuery(body); @@ -253,7 +248,7 @@ export class ApiStatusMastodon { }; const data = await client.postStatus(text, options); - reply.send(await this.mastoConverters.convertStatus(data.data as Entity.Status)); + reply.send(await this.mastoConverters.convertStatus(data.data as Entity.Status, me)); } catch (e) { const data = getErrorData(e); this.logger.error('POST /v1/statuses', data); @@ -278,10 +273,8 @@ export class ApiStatusMastodon { }, } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const { client, me } = await this.mastodon.getAuthClient(_request); const body = _request.body; if (!body.media_ids || !body.media_ids.length) { @@ -300,7 +293,7 @@ export class ApiStatusMastodon { }; const data = await client.editStatus(_request.params.id, options); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}`, data); @@ -311,13 +304,11 @@ export class ApiStatusMastodon { public addFavourite() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/favorite`, data); @@ -328,13 +319,11 @@ export class ApiStatusMastodon { public rmFavourite() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const { client, me } = await this.mastodon.getAuthClient(_request); if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data); @@ -345,13 +334,11 @@ export class ApiStatusMastodon { public reblogStatus() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.reblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data); @@ -362,13 +349,11 @@ export class ApiStatusMastodon { public unreblogStatus() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.unreblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data); @@ -379,13 +364,11 @@ export class ApiStatusMastodon { public bookmarkStatus() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.bookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data); @@ -396,13 +379,11 @@ export class ApiStatusMastodon { public unbookmarkStatus() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.unbookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data); @@ -413,13 +394,11 @@ export class ApiStatusMastodon { public pinStatus() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.pinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data); @@ -430,13 +409,11 @@ export class ApiStatusMastodon { public unpinStatus() { this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.unpinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data); @@ -447,14 +424,12 @@ export class ApiStatusMastodon { public reactStatus() { this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data); @@ -465,14 +440,12 @@ export class ApiStatusMastodon { public unreactStatus() { this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data)); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data); diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index a6a778721a..1a732d62de 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -4,8 +4,8 @@ */ import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { convertConversation, convertList, MastoConverters } from '../converters.js'; -import { getClient } from '../MastodonApiServerService.js'; +import { convertList, MastoConverters } from '../converters.js'; +import { getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from '../timelineArgs.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -15,18 +15,17 @@ export class ApiTimelineMastodon { private readonly fastify: FastifyInstance, 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) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const { client, me } = await this.mastodon.getAuthClient(_request); 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)))); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/timelines/public', data); @@ -37,12 +36,10 @@ export class ApiTimelineMastodon { public getHomeTl() { this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); 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)))); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/timelines/home', data); @@ -53,13 +50,11 @@ export class ApiTimelineMastodon { public getTagTl() { this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.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)))); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data); @@ -70,13 +65,11 @@ export class ApiTimelineMastodon { public getListTL() { this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { 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)))); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data); @@ -87,12 +80,11 @@ export class ApiTimelineMastodon { public getConversations() { this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.getConversationTimeline(parseTimelineArgs(_request.query)); - reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation))); + const conversations = await Promise.all(data.data.map(async (conversation: Entity.Conversation) => await this.mastoConverters.convertConversation(conversation, me))); + reply.send(conversations); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/conversations', data); -- cgit v1.2.3-freya