diff options
Diffstat (limited to 'packages/backend/src/server/api/mastodon/endpoints')
10 files changed, 1132 insertions, 994 deletions
diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 79cdddcb9e..6a1af62be7 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -3,14 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { MiLocalUser } from '@/models/User.js'; -import { MastoConverters, convertRelationship } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Inject, Injectable } from '@nestjs/common'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; +import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; +import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js'; +import type { FastifyInstance } from 'fastify'; -export interface ApiAccountMastodonRoute { +interface ApiAccountMastodonRoute { Params: { id?: string }, Querystring: TimelineArgs & { acct?: string }, Body: { notifications?: boolean } @@ -19,133 +22,270 @@ export interface ApiAccountMastodonRoute { @Injectable() export class ApiAccountMastodon { constructor( - private readonly request: FastifyRequest<ApiAccountMastodonRoute>, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly mastoConverters: MastoConverters, + @Inject(DI.userProfilesRepository) + private readonly userProfilesRepository: UserProfilesRepository, + + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, + + private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, + private readonly driveService: DriveService, ) {} - public async verifyCredentials() { - const data = await this.client.verifyAccountCredentials(); - const acct = await this.mastoConverters.convertAccount(data.data); - return Object.assign({}, acct, { - source: { - note: acct.note, - fields: acct.fields, - privacy: '', - sensitive: false, - language: '', + public register(fastify: FastifyInstance): void { + fastify.get<ApiAccountMastodonRoute>('/v1/accounts/verify_credentials', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.verifyAccountCredentials(); + const acct = await this.mastoConverters.convertAccount(data.data); + const response = Object.assign({}, acct, { + source: { + note: acct.note, + fields: acct.fields, + privacy: 'public', + sensitive: false, + language: '', + }, + }); + return reply.send(response); + }); + + 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', async (_request, reply) => { + const accessTokens = _request.headers.authorization; + const client = this.clientService.getClient(_request); + // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. + if (_request.savedRequestFiles?.length && accessTokens) { + const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); + const avatar = _request.savedRequestFiles.find(obj => { + return obj.fieldname === 'avatar'; + }); + const header = _request.savedRequestFiles.find(obj => { + return obj.fieldname === 'header'; + }); + + if (tokeninfo && avatar) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: avatar.filepath, + name: avatar.filename && avatar.filename !== 'file' ? avatar.filename : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + _request.body.avatar = upload.id; + } + } else if (tokeninfo && header) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: header.filepath, + name: header.filename && header.filename !== 'file' ? header.filename : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + _request.body.header = upload.id; + } + } + } + + if (_request.body.fields_attributes) { + for (const field of _request.body.fields_attributes) { + if (!(field.name.trim() === '' && field.value.trim() === '')) { + if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); + if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); + } + } + _request.body.fields_attributes = _request.body.fields_attributes.filter(field => field.name.trim().length > 0 && field.value.length > 0); + } + + const options = { + ..._request.body, + discoverable: toBoolean(_request.body.discoverable), + bot: toBoolean(_request.body.bot), + locked: toBoolean(_request.body.locked), + source: _request.body.source ? { + ..._request.body.source, + sensitive: toBoolean(_request.body.source.sensitive), + } : undefined, + }; + const data = await client.updateCredentials(options); + const response = await this.mastoConverters.convertAccount(data.data); + + return reply.send(response); }); - } - public async lookup() { - if (!this.request.query.acct) throw new Error('Missing required property "acct"'); - const data = await this.client.search(this.request.query.acct, { type: 'accounts' }); - return this.mastoConverters.convertAccount(data.data.accounts[0]); - } + fastify.get<{ Querystring: { acct?: string } }>('/v1/accounts/lookup', async (_request, reply) => { + if (!_request.query.acct) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "acct"' }); - public async getRelationships(reqIds: string[]) { - const data = await this.client.getRelationships(reqIds); - return data.data.map(relationship => convertRelationship(relationship)); - } + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.acct, { type: 'accounts' }); + const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); + data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; + const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); - public async getStatuses() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountStatuses(this.request.params.id, parseTimelineArgs(this.request.query)); - return await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, this.me))); - } + return reply.send(response); + }); - public async getFollowers() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowers( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } + fastify.get<ApiAccountMastodonRoute & { Querystring: { id?: string | string[] } }>('/v1/accounts/relationships', async (_request, reply) => { + if (!_request.query.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "id"' }); - public async getFollowing() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowing( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } + const client = this.clientService.getClient(_request); + const data = await client.getRelationships(_request.query.id); + const response = data.data.map(relationship => convertRelationship(relationship)); - public async addFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.followAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = true; - return acct; - } + return reply.send(response); + }); - public async rmFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unfollowAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = false; - return acct; - } + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public async addBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.blockAccount(this.request.params.id); - return convertRelationship(data.data); - } + const client = this.clientService.getClient(_request); + const data = await client.getAccount(_request.params.id); + const account = await this.mastoConverters.convertAccount(data.data); - public async rmBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unblockAccount(this.request.params.id); - return convertRelationship(data.data); - } + return reply.send(account); + }); - public async addMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.muteAccount( - this.request.params.id, - this.request.body.notifications ?? true, - ); - return convertRelationship(data.data); - } + fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public async rmMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unmuteAccount(this.request.params.id); - return convertRelationship(data.data); - } + const { client, me } = await this.clientService.getAuthClient(request); + const args = parseTimelineArgs(request.query); + const data = await client.getAccountStatuses(request.params.id, args); + const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); - public async getBookmarks() { - const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); - public async getFavourites() { - const data = await this.client.getFavourites(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public async getMutes() { - const data = await this.client.getMutes(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } + const client = this.clientService.getClient(_request); + const data = await client.getFeaturedTags(); + const response = data.data.map((tag) => convertFeaturedTag(tag)); - public async getBlocks() { - const data = await this.client.getBlocks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } + return reply.send(response); + }); - public async acceptFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.acceptFollowRequest(this.request.params.id); - return convertRelationship(data.data); - } + fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(request); + const data = await client.getAccountFollowers( + request.params.id, + parseTimelineArgs(request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); + + fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(request); + const data = await client.getAccountFollowing( + request.params.id, + parseTimelineArgs(request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountLists(_request.params.id); + const response = data.data.map((list) => convertList(list)); + + return reply.send(response); + }); + + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.followAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = true; // TODO this is wrong, follow may not have processed immediately + + return reply.send(acct); + }); + + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unfollowAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = false; + + return reply.send(acct); + }); + + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.blockAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); + }); + + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unblockAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); + }); + + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.muteAccount( + _request.params.id, + _request.body.notifications ?? true, + ); + const response = convertRelationship(data.data); + + return reply.send(response); + }); + + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public async rejectFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.rejectFollowRequest(this.request.params.id); - return convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.unmuteAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts new file mode 100644 index 0000000000..72b520c74a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; +import type { FastifyInstance } from 'fastify'; + +const readScope = [ + 'read:account', + 'read:drive', + 'read:blocks', + 'read:favorites', + 'read:following', + 'read:messaging', + 'read:mutes', + 'read:notifications', + 'read:reactions', + 'read:pages', + 'read:page-likes', + 'read:user-groups', + 'read:channels', + 'read:gallery', + 'read:gallery-likes', +]; + +const writeScope = [ + 'write:account', + 'write:drive', + 'write:blocks', + 'write:favorites', + 'write:following', + 'write:messaging', + 'write:mutes', + 'write:notes', + 'write:notifications', + 'write:reactions', + 'write:votes', + 'write:pages', + 'write:page-likes', + 'write:user-groups', + 'write:channels', + 'write:gallery', + 'write:gallery-likes', +]; + +export interface AuthPayload { + scopes?: string | string[], + redirect_uris?: string | string[], + client_name?: string | string[], + website?: string | string[], +} + +// Not entirely right, but it gets TypeScript to work so *shrug* +type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; + +@Injectable() +export class ApiAppsMastodon { + constructor( + private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, + ) {} + + public register(fastify: FastifyInstance): void { + fastify.post<AuthMastodonRoute>('/v1/apps', async (_request, reply) => { + const body = _request.body ?? _request.query; + if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' }); + if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' }); + if (Array.isArray(body.redirect_uris)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "redirect_uris": only one value is allowed' }); + if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' }); + if (Array.isArray(body.client_name)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "client_name": only one value is allowed' }); + if (Array.isArray(body.website)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "website": only one value is allowed' }); + + let scope = body.scopes; + if (typeof scope === 'string') { + scope = scope.split(/[ +]/g); + } + + const pushScope = new Set<string>(); + for (const s of scope) { + if (s.match(/^read/)) { + for (const r of readScope) { + pushScope.add(r); + } + } + if (s.match(/^write/)) { + for (const r of writeScope) { + pushScope.add(r); + } + } + } + + const client = this.clientService.getClient(_request); + const appData = await client.registerApp(body.client_name, { + scopes: Array.from(pushScope), + redirect_uri: body.redirect_uris, + website: body.website, + }); + + const response = { + id: Math.floor(Math.random() * 100).toString(), + name: appData.name, + website: body.website, + redirect_uri: body.redirect_uris, + client_id: Buffer.from(appData.url || '').toString('base64'), + client_secret: appData.clientSecret, + }; + + return reply.send(response); + }); + + fastify.get('/v1/apps/verify_credentials', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.verifyAppCredentials(); + const response = this.mastoConverters.convertApplication(data.data); + return reply.send(response); + }); + } +} + diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts deleted file mode 100644 index b58cc902da..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -const readScope = [ - 'read:account', - 'read:drive', - 'read:blocks', - 'read:favorites', - 'read:following', - 'read:messaging', - 'read:mutes', - 'read:notifications', - 'read:reactions', - 'read:pages', - 'read:page-likes', - 'read:user-groups', - 'read:channels', - 'read:gallery', - 'read:gallery-likes', -]; - -const writeScope = [ - 'write:account', - 'write:drive', - 'write:blocks', - 'write:favorites', - 'write:following', - 'write:messaging', - 'write:mutes', - 'write:notes', - 'write:notifications', - 'write:reactions', - 'write:votes', - 'write:pages', - 'write:page-likes', - 'write:user-groups', - 'write:channels', - 'write:gallery', - 'write:gallery-likes', -]; - -export interface AuthPayload { - scopes?: string | string[], - redirect_uris?: string, - client_name?: string, - website?: string, -} - -// Not entirely right, but it gets TypeScript to work so *shrug* -export type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; - -export async function ApiAuthMastodon(request: FastifyRequest<AuthMastodonRoute>, client: MegalodonInterface) { - const body = request.body ?? request.query; - if (!body.scopes) throw new Error('Missing required payload "scopes"'); - if (!body.redirect_uris) throw new Error('Missing required payload "redirect_uris"'); - if (!body.client_name) throw new Error('Missing required payload "client_name"'); - - let scope = body.scopes; - if (typeof scope === 'string') { - scope = scope.split(/[ +]/g); - } - - const pushScope = new Set<string>(); - for (const s of scope) { - if (s.match(/^read/)) { - for (const r of readScope) { - pushScope.add(r); - } - } - if (s.match(/^write/)) { - for (const r of writeScope) { - pushScope.add(r); - } - } - } - - const red = body.redirect_uris; - const appData = await client.registerApp(body.client_name, { - scopes: Array.from(pushScope), - redirect_uris: red, - website: body.website, - }); - - return { - id: Math.floor(Math.random() * 100).toString(), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url || '').toString('base64'), // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - client_secret: appData.clientSecret, - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 382f0a8f1f..f2bd0052d5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -3,12 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { toBoolean } from '@/server/api/mastodon/timelineArgs.js'; -import { convertFilter } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Injectable } from '@nestjs/common'; +import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { convertFilter } from '../MastodonConverters.js'; +import type { FastifyInstance } from 'fastify'; -export interface ApiFilterMastodonRoute { +interface ApiFilterMastodonRoute { Params: { id?: string, }, @@ -21,55 +22,78 @@ export interface ApiFilterMastodonRoute { } } +@Injectable() export class ApiFilterMastodon { constructor( - private readonly request: FastifyRequest<ApiFilterMastodonRoute>, - private readonly client: MegalodonInterface, + private readonly clientService: MastodonClientService, ) {} - public async getFilters() { - const data = await this.client.getFilters(); - return data.data.map((filter) => convertFilter(filter)); - } + public register(fastify: FastifyInstance): void { + fastify.get('/v1/filters', async (_request, reply) => { + const client = this.clientService.getClient(_request); - public async getFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getFilter(this.request.params.id); - return convertFilter(data.data); - } + const data = await client.getFilters(); + const response = data.data.map((filter) => convertFilter(filter)); - public async createFilter() { - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.createFilter(this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + return reply.send(response); + }); - public async updateFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.updateFilter(this.request.params.id, this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + fastify.get<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getFilter(_request.params.id); + const response = convertFilter(data.data); + + return reply.send(response); + }); + + fastify.post<ApiFilterMastodonRoute>('/v1/filters', async (_request, reply) => { + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.createFilter(_request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + return reply.send(response); + }); + + fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + return reply.send(response); + }); + + fastify.delete<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteFilter(_request.params.id); - public async rmFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.deleteFilter(this.request.params.id); - return data.data; + return reply.send(data.data); + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts new file mode 100644 index 0000000000..cfca5b1350 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import type { MiMeta } from '@/models/_.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { RoleService } from '@/core/RoleService.js'; +import type { FastifyInstance } from 'fastify'; +import type { MastodonEntity } from 'megalodon'; + +@Injectable() +export class ApiInstanceMastodon { + constructor( + @Inject(DI.meta) + private readonly meta: MiMeta, + + @Inject(DI.config) + private readonly config: Config, + + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, + private readonly roleService: RoleService, + ) {} + + public register(fastify: FastifyInstance): void { + fastify.get('/v1/instance', async (_request, reply) => { + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getInstance(); + const contact = this.meta.rootUser != null + ? await this.mastoConverters.convertAccount(this.meta.rootUser) + : null; + const roles = await this.roleService.getUserPolicies(me?.id ?? null); + + const instance = data.data; + const response: MastodonEntity.Instance = { + uri: this.config.host, + title: this.meta.name || 'Sharkey', + description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + email: instance.email || '', + version: `3.0.0 (compatible; Sharkey ${this.config.version}; like Akkoma)`, + urls: instance.urls, + stats: { + user_count: instance.stats.user_count, + status_count: instance.stats.status_count, + domain_count: instance.stats.domain_count, + }, + thumbnail: this.meta.backgroundImageUrl || '/static-assets/transparent.png', + languages: this.meta.langs, + registrations: !this.meta.disableRegistration || instance.registrations, + approval_required: this.meta.approvalRequiredForSignup, + invites_enabled: instance.registrations, + configuration: { + accounts: { + max_featured_tags: 20, + max_pinned_statuses: roles.pinLimit, + }, + statuses: { + max_characters: this.config.maxNoteLength, + max_media_attachments: 16, + characters_reserved_per_url: instance.uri.length, + }, + media_attachments: { + supported_mime_types: FILE_TYPE_BROWSERSAFE, + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 10, + max_characters_per_option: 150, + min_expiration: 50, + max_expiration: 2629746, + }, + reactions: { + max_reactions: 1, + }, + }, + contact_account: contact, + rules: instance.rules ?? [], + }; + + return reply.send(response); + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts deleted file mode 100644 index 48a56138cf..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity } from 'megalodon'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import type { Config } from '@/config.js'; -import type { MiMeta } from '@/models/Meta.js'; - -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -export async function getInstance( - response: Entity.Instance, - contact: Entity.Account, - config: Config, - meta: MiMeta, -) { - return { - uri: config.url, - title: meta.name || 'Sharkey', - short_description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - email: response.email || '', - version: `3.0.0 (compatible; Sharkey ${config.version})`, - urls: response.urls, - stats: { - user_count: response.stats.user_count, - status_count: response.stats.status_count, - domain_count: response.stats.domain_count, - }, - thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png', - languages: meta.langs, - registrations: !meta.disableRegistration || response.registrations, - approval_required: meta.approvalRequiredForSignup, - invites_enabled: response.registrations, - configuration: { - accounts: { - max_featured_tags: 20, - }, - statuses: { - max_characters: config.maxNoteLength, - max_media_attachments: 16, - characters_reserved_per_url: response.uri.length, - }, - media_attachments: { - supported_mime_types: FILE_TYPE_BROWSERSAFE, - image_size_limit: 10485760, - image_matrix_limit: 16777216, - video_size_limit: 41943040, - video_frame_rate_limit: 60, - video_matrix_limit: 2304000, - }, - polls: { - max_options: 10, - max_characters_per_option: 150, - min_expiration: 50, - max_expiration: 2629746, - }, - reactions: { - max_reactions: 1, - }, - }, - contact_account: contact, - rules: [], - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 14eee8565a..f6cc59e782 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -3,56 +3,82 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.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'; +import { Injectable } from '@nestjs/common'; +import { MastodonEntity } from 'megalodon'; +import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; +import { MastodonClientService } from '../MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; -export interface ApiNotifyMastodonRoute { +interface ApiNotifyMastodonRoute { Params: { id?: string, }, Querystring: TimelineArgs, } -export class ApiNotifyMastodon { +@Injectable() +export class ApiNotificationsMastodon { constructor( - private readonly request: FastifyRequest<ApiNotifyMastodonRoute>, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, ) {} - public async getNotifications() { - const data = await this.client.getNotifications(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map(async n => { - const converted = await this.mastoConverters.convertNotification(n, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; + public register(fastify: FastifyInstance): void { + fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const data = await client.getNotifications(parseTimelineArgs(request.query)); + const notifications = await Promise.all(data.data.map(n => this.mastoConverters.convertNotification(n, me))); + const response: MastodonEntity.Notification[] = []; + for (const notification of notifications) { + // Notifications for inaccessible notes will be null and should be ignored + if (!notification) continue; + + response.push(notification); + if (notification.type === 'reaction') { + response.push({ + ...notification, + 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 = await this.mastoConverters.convertNotification(data.data, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; - } - return converted; - } + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); - public async rmNotification() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.dismissNotification(this.request.params.id); - return data.data; - } + fastify.get<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotification(_request.params.id); + const response = await this.mastoConverters.convertNotification(data.data, me); + + // Notifications for inaccessible notes will be null and should be ignored + if (!response) { + return reply.code(404).send({ + error: 'NOT_FOUND', + }); + } + + return reply.send(response); + }); + + fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.dismissNotification(_request.params.id); + + return reply.send(data.data); + }); + + fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.dismissNotifications(); - public async rmNotifications() { - const data = await this.client.dismissNotifications(); - return data.data; + return reply.send(data.data); + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 4850b4652f..c43d6cfc9a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -3,92 +3,188 @@ * 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; -import Status = Entity.Status; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; +import { MastodonConverters } from '../MastodonConverters.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; +import { ApiError } from '../../error.js'; +import type { FastifyInstance } from 'fastify'; +import type { Entity } from 'megalodon'; -export interface ApiSearchMastodonRoute { +interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { - type?: 'accounts' | 'hashtags' | 'statuses'; + type?: string; q?: string; + resolve?: string; } } +@Injectable() export class ApiSearchMastodon { constructor( - private readonly request: FastifyRequest<ApiSearchMastodonRoute>, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly BASE_URL: string, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, ) {} - public async SearchV1() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const data = await this.client.search(this.request.query.q, { type: this.request.query.type, ...query }); - return data.data; - } + public register(fastify: FastifyInstance): void { + fastify.get<ApiSearchMastodonRoute>('/v1/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (!request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); - public async SearchV2() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const type = this.request.query.type; - const acct = !type || type === 'accounts' ? await this.client.search(this.request.query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await this.client.search(this.request.query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await this.client.search(this.request.query.q, { type: 'hashtags', ...query }) : null; - return { - accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, this.me)) ?? []), - hashtags: tags?.data.hashtags ?? [], - }; - } + const type = request.query.type; + if (type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } + + const { client, me } = await this.clientService.getAuthClient(request); + + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } + + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const { data } = await client.search(request.query.q, { type, ...query }); + const response = { + ...data, + accounts: await Promise.all(data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))), + statuses: await Promise.all(data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))), + }; + + if (type === 'hashtags') { + attachOffsetPagination(request, reply, response.hashtags); + } else { + attachMinMaxPagination(request, reply, response[type]); + } + + return reply.send(response); + }); + + fastify.get<ApiSearchMastodonRoute>('/v2/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + + const type = request.query.type; + if (type !== undefined && type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } + + const { client, me } = await this.clientService.getAuthClient(request); + + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } + + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const acct = !type || type === 'accounts' ? await client.search(request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await client.search(request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await client.search(request.query.q, { type: 'hashtags', ...query }) : null; + const response = { + accounts: await Promise.all(acct?.data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; + + // Pagination hack, based on "best guess" expected behavior. + // Mastodon doesn't document this part at all! + const longestResult = [response.statuses, response.hashtags] + .reduce((longest: unknown[], current: unknown[]) => current.length > longest.length ? current : longest, response.accounts); - public async getStatusTrends() { - const data = await fetch(`${this.BASE_URL}/api/notes/featured`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - i: this.request.headers.authorization?.replace('Bearer ', ''), - }), - }) - .then(res => res.json() as Promise<Status[]>) - .then(data => data.map(status => this.mastoConverters.convertStatus(status, this.me))); - return Promise.all(data); + // Ignore min/max pagination because how TF would that work with multiple result sets?? + // Offset pagination is the only possible option + attachOffsetPagination(request, reply, longestResult); + + return reply.send(response); + }); + + fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); + const res = await fetch(`${baseUrl}/api/notes/featured`, + { + method: 'POST', + headers: { + ...request.headers, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: '{}', + }); + + await verifyResponse(res); + + const data = await res.json() as Entity.Status[]; + const me = await this.clientService.getAuth(request); + const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); + + fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); + const res = await fetch(`${baseUrl}/api/users`, + { + method: 'POST', + headers: { + ...request.headers, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + limit: parseTimelineArgs(request.query).limit ?? 20, + origin: 'local', + sort: '+follower', + state: 'alive', + }), + }); + + await verifyResponse(res); + + const data = await res.json() as Entity.Account[]; + const response = await Promise.all(data.map(async entry => { + return { + source: 'global', + account: await this.mastoConverters.convertAccount(entry), + }; + })); + + attachOffsetPagination(request, reply, response); + return reply.send(response); + }); } +} + +async function verifyResponse(res: Response): Promise<void> { + if (res.ok) return; - public async getSuggestions() { - const data = await fetch(`${this.BASE_URL}/api/users`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - i: this.request.headers.authorization?.replace('Bearer ', ''), - limit: parseTimelineArgs(this.request.query).limit ?? 20, - origin: 'local', - sort: '+follower', - state: 'alive', - }), - }) - .then(res => res.json() as Promise<Account[]>) - .then(data => data.map((entry => ({ - source: 'global', - account: entry, - })))); - return Promise.all(data.map(async suggestion => { - suggestion.account = await this.mastoConverters.convertAccount(suggestion.account); - return suggestion; - })); + const text = await res.text(); + + if (res.headers.get('content-type') === 'application/json') { + try { + const json = JSON.parse(text); + + if (json && typeof(json) === 'object') { + json.httpStatusCode = res.status; + return json; + } + } catch { /* ignore */ } } + + // Response is not a JSON object; treat as string + throw new ApiError({ + code: 'INTERNAL_ERROR', + message: text || 'Internal error occurred. Please contact us if the error persists.', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + httpStatusCode: res.status, + }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 4c49a6a293..22b8a911ca 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -4,12 +4,11 @@ */ import querystring, { ParsedUrlQueryInput } from 'querystring'; +import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/timelineArgs.js'; -import { AuthenticateService } from '@/server/api/AuthenticateService.js'; -import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; -import { getAccessToken, getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { convertAttachment, convertPoll, MastodonConverters } from '../MastodonConverters.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -18,167 +17,112 @@ function normalizeQuery(data: Record<string, unknown>) { return querystring.parse(str); } +@Injectable() export class ApiStatusMastodon { constructor( - private readonly fastify: FastifyInstance, - private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, - private readonly authenticateService: AuthenticateService, - private readonly mastodon: MastodonApiServerService, + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, ) {} - public getStatus() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatus(_request.params.id); - 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); - reply.code(_request.is404 ? 404 : 401).send(data); + public register(fastify: FastifyInstance): void { + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + // Fixup - Discord ignores CWs and renders the entire post. + if (response.sensitive && _request.headers['user-agent']?.match(/\bDiscordbot\//)) { + response.content = '(preview disabled for sensitive content)'; + response.media_attachments = []; } + + return reply.send(response); }); - } - public getStatusSource() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatusSource(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/source`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getStatusSource(_request.params.id); + + return reply.send(data.data); }); - } - public getContext() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); - const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me))); - const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me))); - reply.send({ ancestors, descendants }); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/context`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); + const ancestors = await Promise.all(data.ancestors.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + const descendants = await Promise.all(data.descendants.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + const response = { ancestors, descendants }; + + return reply.send(response); }); - } - public getHistory() { - 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 [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); - this.logger.error(`GET /v1/statuses/${_request.params.id}/history`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const user = await this.clientService.getAuth(_request); + const edits = await this.mastoConverters.getEdits(_request.params.id, user); + + return reply.send(edits); }); - } - public getReblogged() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatusRebloggedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/reblogged_by`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getStatusRebloggedBy(_request.params.id); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + return reply.send(response); }); - } - public getFavourites() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatusFavouritedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/favourited_by`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getStatusFavouritedBy(_request.params.id); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + return reply.send(response); }); - } - public getMedia() { - this.fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getMedia(_request.params.id); - reply.send(convertAttachment(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/media/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getMedia(_request.params.id); + const response = convertAttachment(data.data); + + return reply.send(response); }); - } - public getPoll() { - this.fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getPoll(_request.params.id); - reply.send(convertPoll(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/polls/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getPoll(_request.params.id); + const response = convertPoll(data.data); + + return reply.send(response); }); - } - public votePoll() { - this.fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); - const data = await client.votePoll(_request.params.id, _request.body.choices); - reply.send(convertPoll(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.choices) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "choices"' }); + + const client = this.clientService.getClient(_request); + const data = await client.votePoll(_request.params.id, _request.body.choices); + const response = convertPoll(data.data); + + return reply.send(response); }); - } - public postStatus() { - this.fastify.post<{ + fastify.post<{ Body: { media_ids?: string[], poll?: { @@ -202,63 +146,58 @@ export class ApiStatusMastodon { } }>('/v1/statuses', async (_request, reply) => { 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); - } - const text = body.status ??= ' '; - const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); - const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); - const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); - if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { - const a = await client.createEmojiReaction( - body.in_reply_to_id, - removed, - ); - reply.send(a.data); - } - if (body.in_reply_to_id && removed === '/unreact') { - const id = body.in_reply_to_id; - const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter(e => e.me)[0].name; - const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); - } - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - - if (body.poll && !body.poll.options) { - return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); - } - if (body.poll && !body.poll.expires_in) { - return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); - } + if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) + ) { + body = normalizeQuery(body); + } + const text = body.status ??= ' '; + const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); + const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); + const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; + const { client, me } = await this.clientService.getAuthClient(_request); + if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { + const a = await client.createEmojiReaction( + body.in_reply_to_id, + removed, + ); + return reply.send(a.data); + } + if (body.in_reply_to_id && removed === '/unreact') { + const id = body.in_reply_to_id; + const post = await client.getStatus(id); + const react = post.data.emoji_reactions.filter((e: Entity.Emoji) => e.me)[0].name; + const data = await client.deleteEmojiReaction(id, react); + return reply.send(data.data); + } + if (!body.media_ids) body.media_ids = undefined; + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - const data = await client.postStatus(text, options); - 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); - reply.code(401).send(data); + if (body.poll && !body.poll.options) { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.options"' }); + } + if (body.poll && !body.poll.expires_in) { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.expires_in"' }); } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.postStatus(text, options); + const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); + + return reply.send(response); }); - } - public updateStatus() { - this.fastify.put<{ + fastify.put<{ Params: { id: string }, Body: { status?: string, @@ -273,201 +212,138 @@ export class ApiStatusMastodon { }, } }>('/v1/statuses/:id', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const body = _request.body; + const { client, me } = await this.clientService.getAuthClient(_request); + const body = _request.body; - if (!body.media_ids || !body.media_ids.length) { - body.media_ids = undefined; - } + if (!body.media_ids || !body.media_ids.length) { + body.media_ids = undefined; + } - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options, - expires_in: toInt(body.poll.expires_in), - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options, + expires_in: toInt(body.poll.expires_in), + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; - const data = await client.editStatus(_request.params.id, options); - 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); - reply.code(401).send(data); - } + const data = await client.editStatus(_request.params.id, options); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public addFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, '❤'); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public rmFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.deleteEmojiReaction(_request.params.id, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public reblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.reblogStatus(_request.params.id); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.reblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unreblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.unreblogStatus(_request.params.id); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unreblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public bookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.bookmarkStatus(_request.params.id); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.bookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unbookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.unbookmarkStatus(_request.params.id); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unbookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public pinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.pinStatus(_request.params.id); - 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); - reply.code(401).send(data); - } + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.pinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unpinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.unpinStatus(_request.params.id); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unpinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public reactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unreactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - 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); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public deleteStatus() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.deleteStatus(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/statuses/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteStatus(_request.params.id); + + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 1a732d62de..b2f7b18dc9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -3,232 +3,156 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { convertList, MastoConverters } from '../converters.js'; -import { getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean } from '../timelineArgs.js'; +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; +import { convertList, MastodonConverters } from '../MastodonConverters.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; +@Injectable() export class ApiTimelineMastodon { constructor( - private readonly fastify: FastifyInstance, - private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, - private readonly mastodon: MastodonApiServerService, + private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, ) {} - public getTL() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { - 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, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/timelines/public', data); - reply.code(401).send(data); - } + public register(fastify: FastifyInstance): void { + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = toBoolean(request.query.local) + ? await client.getLocalTimeline(query) + : await client.getPublicTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getHomeTl() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getHomeTimeline(parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/timelines/home', data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getHomeTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getTagTl() { - this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - try { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getTagTimeline(_request.params.hashtag, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => { + if (!request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); + + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getTagTimeline(request.params.hashtag, query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getListTL() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getListTimeline(_request.params.id, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getListTimeline(request.params.id, query); + const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getConversations() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getConversationTimeline(parseTimelineArgs(_request.query)); - const conversations = await Promise.all(data.data.map(async (conversation: Entity.Conversation) => await this.mastoConverters.convertConversation(conversation, me))); - reply.send(conversations); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/conversations', data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getConversationTimeline(query); + const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getList() { - this.fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.getList(_request.params.id); - reply.send(convertList(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getList(_request.params.id); + const response = convertList(data.data); + + return reply.send(response); }); - } - public getLists() { - this.fastify.get('/v1/lists', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.getLists(); - reply.send(data.data.map((list: Entity.List) => convertList(list))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/lists', data); - reply.code(401).send(data); - } + fastify.get('/v1/lists', async (request, reply) => { + const client = this.clientService.getClient(request); + const data = await client.getLists(); + const response = data.data.map((list: Entity.List) => convertList(list)); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getListAccounts() { - this.fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.getAccountsInList(_request.params.id, _request.query); - const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(accounts); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(request); + const data = await client.getAccountsInList(request.params.id, parseTimelineArgs(request.query)); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public addListAccount() { - this.fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); + + return reply.send(data.data); }); - } - public rmListAccount() { - this.fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); + + return reply.send(data.data); }); - } - public createList() { - this.fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { - try { - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.createList(_request.body.title); - reply.send(convertList(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/lists', data); - reply.code(401).send(data); - } + fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); + + const client = this.clientService.getClient(_request); + const data = await client.createList(_request.body.title); + const response = convertList(data.data); + + return reply.send(response); }); - } - public updateList() { - this.fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.updateList(_request.params.id, _request.body.title); - reply.send(convertList(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`PUT /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); + + const client = this.clientService.getClient(_request); + const data = await client.updateList(_request.params.id, _request.body.title); + const response = convertList(data.data); + + return reply.send(response); }); - } - public deleteList() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - await client.deleteList(_request.params.id); - reply.send({}); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + await client.deleteList(_request.params.id); + + return reply.send({}); }); } } |