summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-02-11 10:52:52 -0500
committerHazelnoot <acomputerdog@gmail.com>2025-02-11 10:52:52 -0500
commitfeb80ee992c8cf2d719b089023b1352052aaad2d (patch)
treed4559dc61e763b8b431abeaa8e0a3c30663dadaf /packages/backend/src/server/api
parentMerge branch 'develop' into merge/2024-02-03 (diff)
parentmerge: Cleanup and bulk fixes to Mastodon API (resolves #495, #509, #707, #7... (diff)
downloadsharkey-feb80ee992c8cf2d719b089023b1352052aaad2d.tar.gz
sharkey-feb80ee992c8cf2d719b089023b1352052aaad2d.tar.bz2
sharkey-feb80ee992c8cf2d719b089023b1352052aaad2d.zip
Merge branch 'develop' into merge/2024-02-03
Diffstat (limited to 'packages/backend/src/server/api')
-rw-r--r--packages/backend/src/server/api/endpoints/ap/show.ts25
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search-by-tag.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/relation.ts16
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonApiServerService.ts850
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonDataService.ts84
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonLogger.ts39
-rw-r--r--packages/backend/src/server/api/mastodon/converters.ts241
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/account.ts294
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/auth.ts76
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/filter.ts93
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/meta.ts8
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/notifications.ts85
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/search.ts138
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/status.ts528
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/timeline.ts307
-rw-r--r--packages/backend/src/server/api/mastodon/timelineArgs.ts47
16 files changed, 1530 insertions, 1303 deletions
diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts
index 19ca3ceb8e..fc19e18e59 100644
--- a/packages/backend/src/server/api/endpoints/ap/show.ts
+++ b/packages/backend/src/server/api/endpoints/ap/show.ts
@@ -4,11 +4,10 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiNote } from '@/models/Note.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
-import { isActor, isPost, getApId } from '@/core/activitypub/type.js';
+import { isActor, isPost, getApId, getNullableApId, ObjectWithId } from '@/core/activitypub/type.js';
import type { SchemaType } from '@/misc/json-schema.js';
import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
@@ -18,6 +17,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
+import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
+import { InstanceActorService } from '@/core/InstanceActorService.js';
import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
@@ -27,9 +28,10 @@ export const meta = {
requireCredential: true,
kind: 'read:account',
+ // Up to 30 calls, then 1 per 1/2 second
limit: {
- duration: ms('1minute'),
max: 30,
+ dripRate: 500,
},
errors: {
@@ -120,6 +122,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apDbResolverService: ApDbResolverService,
private apPersonService: ApPersonService,
private apNoteService: ApNoteService,
+ private readonly apRequestService: ApRequestService,
+ private readonly instanceActorService: InstanceActorService,
) {
super(meta, paramDef, async (ps, me) => {
const object = await this.fetchAny(ps.uri, me);
@@ -146,6 +150,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
]));
if (local != null) return local;
+ // No local object found with that uri.
+ // Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that.
+ // Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup.
+ uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign
+ if (!this.utilityService.isFederationAllowedUri(uri)) return null;
+
const host = this.utilityService.extractDbHost(uri);
// local object, not found in db? fail
@@ -228,4 +238,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return null;
}
+
+ /**
+ * Resolves an arbitrary URI to its canonical, post-redirect form.
+ */
+ private async resolveCanonicalUri(uri: string): Promise<string> {
+ const user = await this.instanceActorService.getInstanceActor();
+ const res = await this.apRequestService.signedGet(uri, user, true) as ObjectWithId;
+ return getNullableApId(res) ?? uri;
+ }
}
diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
index 227ac0ebbf..6bba7bf37e 100644
--- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
+++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
@@ -87,7 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
- .andWhere('note.visibility = \'public\'')
+ .andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()`
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts
index e659c46713..c7016d8d32 100644
--- a/packages/backend/src/server/api/endpoints/users/relation.ts
+++ b/packages/backend/src/server/api/endpoints/users/relation.ts
@@ -58,6 +58,14 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
+ isInstanceMuted: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
+ memo: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
},
},
{
@@ -103,6 +111,14 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
+ isInstanceMuted: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
+ memo: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
},
},
},
diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
index b40e4cdaa4..69799bdade 100644
--- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
+++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
@@ -3,53 +3,78 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Inject, Injectable } from '@nestjs/common';
-import megalodon, { Entity, MegalodonInterface } from 'megalodon';
import querystring from 'querystring';
+import { megalodon, Entity, MegalodonInterface } from 'megalodon';
import { IsNull } from 'typeorm';
import multer from 'fastify-multer';
-import type { AccessTokensRepository, NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js';
+import { Inject, Injectable } from '@nestjs/common';
+import type { AccessTokensRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
+import { DriveService } from '@/core/DriveService.js';
+import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
+import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account.js';
+import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js';
+import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js';
+import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js';
+import { AuthenticateService } from '@/server/api/AuthenticateService.js';
+import { MiLocalUser } from '@/models/User.js';
+import { AuthMastodonRoute } from './endpoints/auth.js';
+import { toBoolean } from './timelineArgs.js';
import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js';
import { getInstance } from './endpoints/meta.js';
import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js';
-import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { DriveService } from '@/core/DriveService.js';
+import type { FastifyInstance, FastifyPluginOptions, FastifyRequest } from 'fastify';
-export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
+export function getAccessToken(authorization: string | undefined): string | null {
const accessTokenArr = authorization?.split(' ') ?? [null];
- const accessToken = accessTokenArr[accessTokenArr.length - 1];
- const generator = (megalodon as any).default;
- const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface;
- return client;
+ return accessTokenArr[accessTokenArr.length - 1];
+}
+
+export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
+ const accessToken = getAccessToken(authorization);
+ return megalodon('misskey', BASE_URL, accessToken);
}
@Injectable()
export class MastodonApiServerService {
constructor(
@Inject(DI.meta)
- private serverSettings: MiMeta,
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
- @Inject(DI.notesRepository)
- private notesRepository: NotesRepository,
+ private readonly serverSettings: MiMeta,
+ @Inject(DI.usersRepository)
+ private readonly usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
- private userProfilesRepository: UserProfilesRepository,
- @Inject(DI.noteEditRepository)
- private noteEditRepository: NoteEditRepository,
+ private readonly userProfilesRepository: UserProfilesRepository,
@Inject(DI.accessTokensRepository)
- private accessTokensRepository: AccessTokensRepository,
- @Inject(DI.config)
- private config: Config,
- private userEntityService: UserEntityService,
- private driveService: DriveService,
- private mastoConverter: MastoConverters,
+ private readonly accessTokensRepository: AccessTokensRepository,
+ @Inject(DI.config)
+ private readonly config: Config,
+ private readonly driveService: DriveService,
+ private readonly mastoConverters: MastoConverters,
+ private readonly logger: MastodonLogger,
+ private readonly authenticateService: AuthenticateService,
) { }
@bindThis
+ public async getAuthClient(request: FastifyRequest): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> {
+ const accessToken = getAccessToken(request.headers.authorization);
+ const [me] = await this.authenticateService.authenticate(accessToken);
+
+ const baseUrl = `${request.protocol}://${request.host}`;
+ const client = megalodon('misskey', baseUrl, accessToken);
+
+ return { client, me };
+ }
+
+ @bindThis
+ public async getAuthOnly(request: FastifyRequest): Promise<MiLocalUser | null> {
+ const accessToken = getAccessToken(request.headers.authorization);
+ const [me] = await this.authenticateService.authenticate(accessToken);
+ return me;
+ }
+
+ @bindThis
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
const upload = multer({
storage: multer.diskStorage({}),
@@ -59,12 +84,12 @@ export class MastodonApiServerService {
},
});
- fastify.addHook('onRequest', (request, reply, done) => {
+ fastify.addHook('onRequest', (_, reply, done) => {
reply.header('Access-Control-Allow-Origin', '*');
done();
});
- fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => {
+ fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => {
let body = '';
payload.on('data', (data) => {
body += data;
@@ -73,8 +98,8 @@ export class MastodonApiServerService {
try {
const parsed = querystring.parse(body);
done(null, parsed);
- } catch (e: any) {
- done(e);
+ } catch (e) {
+ done(e as Error);
}
});
payload.on('error', done);
@@ -83,20 +108,21 @@ export class MastodonApiServerService {
fastify.register(multer.contentParser);
fastify.get('/v1/custom_emojis', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
reply.send(data.data);
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/custom_emojis', data);
+ reply.code(401).send(data);
}
});
fastify.get('/v1/instance', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
@@ -111,118 +137,124 @@ export class MastodonApiServerService {
},
order: { id: 'ASC' },
});
- const contact = admin == null ? null : await this.mastoConverter.convertAccount((await client.getAccount(admin.id)).data);
+ const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data);
reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings));
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/instance', data);
+ reply.code(401).send(data);
}
});
fastify.get('/v1/announcements', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceAnnouncements();
reply.send(data.data.map((announcement) => convertAnnouncement(announcement)));
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/announcements', data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
- const data = await client.dismissInstanceAnnouncement(
- _request.body['id'],
- );
+ if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' });
+ const data = await client.dismissInstanceAnnouncement(_request.body['id']);
reply.send(data.data);
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/announcements/${_request.body.id}/dismiss`, data);
+ reply.code(401).send(data);
}
- },
- );
+ });
fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
- const multipartData = await _request.file;
+ const multipartData = await _request.file();
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
}
const data = await client.uploadMedia(multipartData);
reply.send(convertAttachment(data.data as Entity.Attachment));
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('POST /v1/media', data);
+ reply.code(401).send(data);
}
});
- fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
- const multipartData = await _request.file;
+ const multipartData = await _request.file();
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
}
- const data = await client.uploadMedia(multipartData, _request.body!);
+ const data = await client.uploadMedia(multipartData, _request.body);
reply.send(convertAttachment(data.data as Entity.Attachment));
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('POST /v2/media', data);
+ reply.code(401).send(data);
}
});
fastify.get('/v1/filters', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getFilters();
reply.send(data.data.map((filter) => convertFilter(filter)));
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/filters', data);
+ reply.code(401).send(data);
}
});
fastify.get('/v1/trends', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstanceTrends();
reply.send(data.data);
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/trends', data);
+ reply.code(401).send(data);
}
});
fastify.get('/v1/trends/tags', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstanceTrends();
reply.send(data.data);
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/trends/tags', data);
+ reply.code(401).send(data);
}
});
@@ -231,50 +263,69 @@ export class MastodonApiServerService {
reply.send([]);
});
- fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.post<AuthMastodonRoute>('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await ApiAuthMastodon(_request, client);
reply.send(data);
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/apps', data);
+ reply.code(401).send(data);
}
});
fastify.get('/v1/preferences', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getPreferences();
reply.send(data.data);
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/preferences', data);
+ reply.code(401).send(data);
}
});
//#region Accounts
- fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
+ fastify.get<ApiAccountMastodonRoute>('/v1/accounts/verify_credentials', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.verifyCredentials());
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/accounts/verify_credentials', data);
+ reply.code(401).send(data);
}
});
- fastify.patch('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.patch<{
+ Body: {
+ discoverable?: string,
+ bot?: string,
+ display_name?: string,
+ note?: string,
+ avatar?: string,
+ header?: string,
+ locked?: string,
+ source?: {
+ privacy?: string,
+ sensitive?: string,
+ language?: string,
+ },
+ fields_attributes?: {
+ name: string,
+ value: string,
+ }[],
+ },
+ }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
@@ -332,512 +383,495 @@ export class MastodonApiServerService {
(_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0);
}
- const data = await client.updateCredentials(_request.body!);
- reply.send(await this.mastoConverter.convertAccount(data.data));
- } catch (e: any) {
- //console.error(e);
- reply.code(401).send(e.response.data);
+ const options = {
+ ..._request.body,
+ discoverable: toBoolean(_request.body.discoverable),
+ bot: toBoolean(_request.body.bot),
+ locked: toBoolean(_request.body.locked),
+ source: _request.body.source ? {
+ ..._request.body.source,
+ sensitive: toBoolean(_request.body.source.sensitive),
+ } : undefined,
+ };
+ const data = await client.updateCredentials(options);
+ reply.send(await this.mastoConverters.convertAccount(data.data));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('PATCH /v1/accounts/update_credentials', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/accounts/lookup', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in
try {
- const data = await client.search((_request.query as any).acct, { type: 'accounts' });
+ if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' });
+ const data = await client.search(_request.query.acct, { type: 'accounts' });
const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id });
- data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) || [];
- reply.send(await this.mastoConverter.convertAccount(data.data.accounts[0]));
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? [];
+ reply.send(await this.mastoConverters.convertAccount(data.data.accounts[0]));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/accounts/lookup', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/accounts/relationships', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- let users;
+ fastify.get<ApiAccountMastodonRoute & { Querystring: { id?: string | string[], 'id[]'?: string | string[] }}>('/v1/accounts/relationships', async (_request, reply) => {
try {
- let ids = _request.query ? (_request.query as any)['id[]'] ?? (_request.query as any)['id'] : null;
+ const { client, me } = await this.getAuthClient(_request);
+ let ids = _request.query['id[]'] ?? _request.query['id'] ?? [];
if (typeof ids === 'string') {
ids = [ids];
}
- users = ids;
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
- reply.send(await account.getRelationships(users));
- } catch (e: any) {
- /* console.error(e); */
- const data = e.response.data;
- data.users = users;
- console.error(data);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
+ reply.send(await account.getRelationships(ids));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/accounts/relationships', data);
reply.code(401).send(data);
}
});
- fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
- const sharkId = _request.params.id;
- const data = await client.getAccount(sharkId);
- const account = await this.mastoConverter.convertAccount(data.data);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const data = await client.getAccount(_request.params.id);
+ const account = await this.mastoConverters.convertAccount(data.data);
reply.send(account);
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/accounts/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
- fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.getStatuses());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data);
+ reply.code(401).send(data);
}
});
- fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
const data = await client.getFeaturedTags();
reply.send(data.data.map((tag) => convertFeaturedTag(tag)));
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data);
+ reply.code(401).send(data);
}
});
- fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.getFollowers());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data);
+ reply.code(401).send(data);
}
});
- fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.getFollowing());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data);
+ reply.code(401).send(data);
}
});
- fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
const data = await client.getAccountLists(_request.params.id);
reply.send(data.data.map((list) => convertList(list)));
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.addFollow());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.rmFollow());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.addBlock());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.rmBlock());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.addMute());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.rmMute());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data);
+ reply.code(401).send(data);
}
});
fastify.get('/v1/followed_tags', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFollowedTags();
reply.send(data.data);
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/followed_tags', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/bookmarks', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiAccountMastodonRoute>('/v1/bookmarks', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.getBookmarks());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/bookmarks', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/favourites', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiAccountMastodonRoute>('/v1/favourites', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.getFavourites());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/favourites', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/mutes', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiAccountMastodonRoute>('/v1/mutes', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.getMutes());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/mutes', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/blocks', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiAccountMastodonRoute>('/v1/blocks', async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.getBlocks());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/blocks', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/follow_requests', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
- const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit );
- reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverter.convertAccount(account as Entity.Account))));
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ const limit = _request.query.limit ? parseInt(_request.query.limit) : 20;
+ const data = await client.getFollowRequests(limit);
+ reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account as Entity.Account))));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/follow_requests', data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.acceptFollow());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
reply.send(await account.rejectFollow());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data);
+ reply.code(401).send(data);
}
});
//#endregion
//#region Search
- fastify.get('/v1/search', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiSearchMastodonRoute>('/v1/search', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
try {
- const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
reply.send(await search.SearchV1());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/search', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v2/search', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiSearchMastodonRoute>('/v2/search', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
try {
- const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
reply.send(await search.SearchV2());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v2/search', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v1/trends/statuses', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
try {
- const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
reply.send(await search.getStatusTrends());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/trends/statuses', data);
+ reply.code(401).send(data);
}
});
- fastify.get('/v2/suggestions', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
try {
- const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
+ const { client, me } = await this.getAuthClient(_request);
+ const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
reply.send(await search.getSuggestions());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v2/suggestions', data);
+ reply.code(401).send(data);
}
});
//#endregion
//#region Notifications
- fastify.get('/v1/notifications', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (_request, reply) => {
try {
- const notify = new ApiNotifyMastodon(_request, client);
+ const { client, me } = await this.getAuthClient(_request);
+ const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
reply.send(await notify.getNotifications());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/notifications', data);
+ reply.code(401).send(data);
}
});
- fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.get<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id', async (_request, reply) => {
try {
- const notify = new ApiNotifyMastodon(_request, client);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
reply.send(await notify.getNotification());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/notification/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const notify = new ApiNotifyMastodon(_request, client);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const { client, me } = await this.getAuthClient(_request);
+ const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
reply.send(await notify.rmNotification());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data);
+ reply.code(401).send(data);
}
});
- fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => {
try {
- const notify = new ApiNotifyMastodon(_request, client);
+ const { client, me } = await this.getAuthClient(_request);
+ const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
reply.send(await notify.rmNotifications());
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('POST /v1/notifications/clear', data);
+ reply.code(401).send(data);
}
});
//#endregion
//#region Filters
- fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.get<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const filter = new ApiFilterMastodon(_request, client);
- !_request.params.id ? reply.send(await filter.getFilters()) : reply.send(await filter.getFilter());
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ _request.params.id
+ ? reply.send(await filter.getFilter())
+ : reply.send(await filter.getFilters());
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/filters/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
- fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.post<ApiFilterMastodonRoute>('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const filter = new ApiFilterMastodon(_request, client);
reply.send(await filter.createFilter());
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('POST /v1/filters', data);
+ reply.code(401).send(data);
}
});
- fastify.post<{ Params: { id: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
const filter = new ApiFilterMastodon(_request, client);
reply.send(await filter.updateFilter());
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/filters/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
- fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.delete<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
const filter = new ApiFilterMastodon(_request, client);
reply.send(await filter.rmFilter());
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
//#endregion
//#region Timelines
- const TLEndpoint = new ApiTimelineMastodon(fastify, this.config, this.mastoConverter);
+ const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverters, this.logger, this);
// GET Endpoints
TLEndpoint.getTL();
@@ -862,7 +896,7 @@ export class MastodonApiServerService {
//#endregion
//#region Status
- const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter);
+ const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverters, this.logger, this.authenticateService, this);
// GET Endpoints
NoteEndpoint.getStatus();
@@ -889,16 +923,32 @@ export class MastodonApiServerService {
NoteEndpoint.votePoll();
// PUT Endpoint
- fastify.put<{ Params: { id: string } }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ fastify.put<{
+ Params: {
+ id?: string,
+ },
+ Body: {
+ file?: unknown,
+ description?: string,
+ focus?: string,
+ is_sensitive?: string,
+ },
+ }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
- const data = await client.updateMedia(_request.params.id, _request.body!);
+ if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
+ const options = {
+ ..._request.body,
+ is_sensitive: toBoolean(_request.body.is_sensitive),
+ };
+ const data = await client.updateMedia(_request.params.id, options);
reply.send(convertAttachment(data.data));
- } catch (e: any) {
- /* console.error(e); */
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`PUT /v1/media/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
NoteEndpoint.updateStatus();
diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts
new file mode 100644
index 0000000000..671ecdcbed
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts
@@ -0,0 +1,84 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { IsNull } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import { QueryService } from '@/core/QueryService.js';
+import type { MiNote, NotesRepository } from '@/models/_.js';
+import type { MiLocalUser } from '@/models/User.js';
+import { ApiError } from '../error.js';
+
+/**
+ * Utility service for accessing data with Mastodon semantics
+ */
+@Injectable()
+export class MastodonDataService {
+ constructor(
+ @Inject(DI.notesRepository)
+ private readonly notesRepository: NotesRepository,
+
+ @Inject(QueryService)
+ private readonly queryService: QueryService,
+ ) {}
+
+ /**
+ * Fetches a note in the context of the current user, and throws an exception if not found.
+ */
+ public async requireNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote> {
+ const note = await this.getNote(noteId, me);
+
+ if (!note) {
+ throw new ApiError({
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d',
+ kind: 'client',
+ httpStatusCode: 404,
+ });
+ }
+
+ return note;
+ }
+
+ /**
+ * Fetches a note in the context of the current user.
+ */
+ public async getNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote | null> {
+ // Root query: note + required dependencies
+ const query = this.notesRepository
+ .createQueryBuilder('note')
+ .where('note.id = :noteId', { noteId })
+ .innerJoinAndSelect('note.user', 'user');
+
+ // Restrict visibility
+ this.queryService.generateVisibilityQuery(query, me);
+ if (me) {
+ this.queryService.generateBlockedUserQuery(query, me);
+ }
+
+ return await query.getOne();
+ }
+
+ /**
+ * Checks where the current user has made a reblog / boost / pure renote of a given target note.
+ */
+ public async hasReblog(noteId: string, me: MiLocalUser | null | undefined): Promise<boolean> {
+ if (!me) return false;
+
+ return await this.notesRepository.existsBy({
+ // Reblog of the target note by me
+ userId: me.id,
+ renoteId: noteId,
+
+ // That is pure (not a quote)
+ text: IsNull(),
+ cw: IsNull(),
+ replyId: IsNull(),
+ hasPoll: false,
+ fileIds: '{}',
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts
new file mode 100644
index 0000000000..bb844773c4
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import Logger, { Data } from '@/logger.js';
+import { LoggerService } from '@/core/LoggerService.js';
+
+@Injectable()
+export class MastodonLogger {
+ public readonly logger: Logger;
+
+ constructor(loggerService: LoggerService) {
+ this.logger = loggerService.getLogger('masto-api');
+ }
+
+ public error(endpoint: string, error: Data): void {
+ this.logger.error(`Error in mastodon API endpoint ${endpoint}:`, error);
+ }
+}
+
+export function getErrorData(error: unknown): Data {
+ if (error == null) return {};
+ if (typeof(error) === 'string') return error;
+ if (typeof(error) === 'object') {
+ if ('response' in error) {
+ if (typeof(error.response) === 'object' && error.response) {
+ if ('data' in error.response) {
+ if (typeof(error.response.data) === 'object' && error.response.data) {
+ return error.response.data as Record<string, unknown>;
+ }
+ }
+ }
+ }
+ return error as Record<string, unknown>;
+ }
+ return { error };
+}
diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts
index 405701e826..b6ff5bc59a 100644
--- a/packages/backend/src/server/api/mastodon/converters.ts
+++ b/packages/backend/src/server/api/mastodon/converters.ts
@@ -9,18 +9,32 @@ import mfm from '@transfem-org/sfm-js';
import { DI } from '@/di-symbols.js';
import { MfmService } from '@/core/MfmService.js';
import type { Config } from '@/config.js';
-import type { IMentionedRemoteUsers } from '@/models/Note.js';
-import type { MiUser } from '@/models/User.js';
-import type { NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { IMentionedRemoteUsers, MiNote } from '@/models/Note.js';
+import type { MiLocalUser, MiUser } from '@/models/User.js';
+import type { NoteEditRepository, UserProfilesRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { IdService } from '@/core/IdService.js';
-import { GetterService } from '../GetterService.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js';
+import { GetterService } from '@/server/api/GetterService.js';
-export enum IdConvertType {
- MastodonId,
- SharkeyId,
+// Missing from Megalodon apparently
+// https://docs.joinmastodon.org/entities/StatusEdit/
+export interface StatusEdit {
+ content: string;
+ spoiler_text: string;
+ sensitive: boolean;
+ created_at: string;
+ account: MastodonEntity.Account;
+ poll?: {
+ options: {
+ title: string;
+ }[]
+ },
+ media_attachments: MastodonEntity.Attachment[],
+ emojis: MastodonEntity.Emoji[],
}
export const escapeMFM = (text: string): string => text
@@ -36,27 +50,25 @@ export const escapeMFM = (text: string): string => text
export class MastoConverters {
constructor(
@Inject(DI.config)
- private config: Config,
-
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
+ private readonly config: Config,
@Inject(DI.userProfilesRepository)
- private userProfilesRepository: UserProfilesRepository,
+ private readonly userProfilesRepository: UserProfilesRepository,
@Inject(DI.noteEditRepository)
- private noteEditRepository: NoteEditRepository,
+ private readonly noteEditRepository: NoteEditRepository,
- private mfmService: MfmService,
- private getterService: GetterService,
- private customEmojiService: CustomEmojiService,
- private idService: IdService,
- private driveFileEntityService: DriveFileEntityService,
- ) {
- }
+ private readonly mfmService: MfmService,
+ private readonly getterService: GetterService,
+ private readonly customEmojiService: CustomEmojiService,
+ private readonly idService: IdService,
+ private readonly driveFileEntityService: DriveFileEntityService,
+ private readonly mastodonDataService: MastodonDataService,
+ ) {}
- private encode(u: MiUser, m: IMentionedRemoteUsers): Entity.Mention {
+ private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention {
let acct = u.username;
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let acctUrl = `https://${u.host || this.config.host}/@${u.username}`;
let url: string | null = null;
if (u.host) {
@@ -89,7 +101,11 @@ export class MastoConverters {
return 'unknown';
}
- public encodeFile(f: any): Entity.Attachment {
+ public encodeFile(f: Packed<'DriveFile'>): MastodonEntity.Attachment {
+ const { width, height } = f.properties;
+ const size = (width && height) ? `${width}x${height}` : undefined;
+ const aspect = (width && height) ? (width / height) : undefined;
+
return {
id: f.id,
type: this.fileType(f.type),
@@ -98,11 +114,19 @@ export class MastoConverters {
preview_url: f.thumbnailUrl,
text_url: f.url,
meta: {
- width: f.properties.width,
- height: f.properties.height,
+ original: {
+ width,
+ height,
+ size,
+ aspect,
+ },
+ width,
+ height,
+ size,
+ aspect,
},
- description: f.comment ? f.comment : null,
- blurhash: f.blurhash ? f.blurhash : null,
+ description: f.comment ?? null,
+ blurhash: f.blurhash ?? null,
};
}
@@ -112,7 +136,7 @@ export class MastoConverters {
});
}
- private async encodeField(f: Entity.Field): Promise<Entity.Field> {
+ private async encodeField(f: Entity.Field): Promise<MastodonEntity.Field> {
return {
name: f.name,
value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value),
@@ -120,7 +144,7 @@ export class MastoConverters {
};
}
- public async convertAccount(account: Entity.Account | MiUser) {
+ public async convertAccount(account: Entity.Account | MiUser): Promise<MastodonEntity.Account> {
const user = await this.getUser(account.id);
const profile = await this.userProfilesRepository.findOneBy({ userId: user.id });
const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host);
@@ -137,6 +161,7 @@ export class MastoConverters {
});
const fqn = `${user.username}@${user.host ?? this.config.hostname}`;
let acct = user.username;
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let acctUrl = `https://${user.host || this.config.host}/@${user.username}`;
const acctUri = `https://${this.config.host}/users/${user.id}`;
if (user.host) {
@@ -166,19 +191,23 @@ export class MastoConverters {
fields: Promise.all(profile?.fields.map(async p => this.encodeField(p)) ?? []),
bot: user.isBot,
discoverable: user.isExplorable,
+ noindex: user.noindex,
+ group: null,
+ suspended: user.isSuspended,
+ limited: user.isSilenced,
});
}
- public async getEdits(id: string) {
- const note = await this.getterService.getNote(id);
+ public async getEdits(id: string, me?: MiLocalUser | null) {
+ const note = await this.mastodonDataService.getNote(id, me);
if (!note) {
- return {};
+ return [];
}
-
const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p));
const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } });
- const history: Promise<any>[] = [];
+ const history: Promise<StatusEdit>[] = [];
+ // TODO this looks wrong, according to mastodon docs
let lastDate = this.idService.parse(note.id).date;
for (const edit of edits) {
const files = this.driveFileEntityService.packManyByIds(edit.fileIds);
@@ -187,9 +216,8 @@ export class MastoConverters {
content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''),
created_at: lastDate.toISOString(),
emojis: [],
- sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false),
+ sensitive: edit.cw != null && edit.cw.length > 0,
spoiler_text: edit.cw ?? '',
- poll: null,
media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []),
};
lastDate = edit.updatedAt;
@@ -199,15 +227,16 @@ export class MastoConverters {
return await Promise.all(history);
}
- private async convertReblog(status: Entity.Status | null): Promise<any> {
+ private async convertReblog(status: Entity.Status | null, me?: MiLocalUser | null): Promise<MastodonEntity.Status | null> {
if (!status) return null;
- return await this.convertStatus(status);
+ return await this.convertStatus(status, me);
}
- public async convertStatus(status: Entity.Status) {
+ public async convertStatus(status: Entity.Status, me?: MiLocalUser | null): Promise<MastodonEntity.Status> {
const convertedAccount = this.convertAccount(status.account);
- const note = await this.getterService.getNote(status.id);
+ const note = await this.mastodonDataService.requireNote(status.id, me);
const noteUser = await this.getUser(status.account.id);
+ const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers);
const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host);
const emoji: Entity.Emoji[] = [];
@@ -224,7 +253,7 @@ export class MastoConverters {
const mentions = Promise.all(note.mentions.map(p =>
this.getUser(p)
- .then(u => this.encode(u, JSON.parse(note.mentionedRemoteUsers)))
+ .then(u => this.encode(u, mentionedRemoteUsers))
.catch(() => null)))
.then(p => p.filter(m => m)) as Promise<Entity.Mention[]>;
@@ -235,20 +264,26 @@ export class MastoConverters {
} as Entity.Tag;
});
- const isQuote = note.renoteId && note.text ? true : false;
+ // This must mirror the usual isQuote / isPureRenote logic used elsewhere.
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId);
- const renote = note.renoteId ? this.getterService.getNote(note.renoteId) : null;
+ const renote: Promise<MiNote> | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null;
const quoteUri = Promise.resolve(renote).then(renote => {
if (!renote || !isQuote) return null;
return renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`;
});
- const content = note.text !== null
- ? quoteUri.then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri))
- .then(p => p ?? escapeMFM(note.text!))
+ const text = note.text;
+ const content = text !== null
+ ? quoteUri
+ .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri))
+ .then(p => p ?? escapeMFM(text))
: '';
+ const reblogged = await this.mastodonDataService.hasReblog(note.id, me);
+
// noinspection ES6MissingAwait
return await awaitAll({
id: note.id,
@@ -257,7 +292,7 @@ export class MastoConverters {
account: convertedAccount,
in_reply_to_id: note.replyId,
in_reply_to_account_id: note.replyUserId,
- reblog: !isQuote ? await this.convertReblog(status.reblog) : null,
+ reblog: !isQuote ? await this.convertReblog(status.reblog, me) : null,
content: content,
content_type: 'text/x.misskeymarkdown',
text: note.text,
@@ -266,34 +301,51 @@ export class MastoConverters {
replies_count: note.repliesCount,
reblogs_count: note.renoteCount,
favourites_count: status.favourites_count,
- reblogged: false,
+ reblogged,
favourited: status.favourited,
muted: status.muted,
sensitive: status.sensitive,
- spoiler_text: note.cw ? note.cw : '',
+ spoiler_text: note.cw ?? '',
visibility: status.visibility,
- media_attachments: status.media_attachments,
+ media_attachments: status.media_attachments.map(a => convertAttachment(a)),
mentions: mentions,
tags: tags,
card: null, //FIXME
poll: status.poll ?? null,
application: null, //FIXME
language: null, //FIXME
- pinned: false,
+ pinned: false, //FIXME
reactions: status.emoji_reactions,
emoji_reactions: status.emoji_reactions,
- bookmarked: false,
- quote: isQuote ? await this.convertReblog(status.reblog) : null,
- // optional chaining cannot be used, as it evaluates to undefined, not null
- edited_at: note.updatedAt ? note.updatedAt.toISOString() : null,
+ bookmarked: false, //FIXME
+ quote: isQuote ? await this.convertReblog(status.reblog, me) : null,
+ edited_at: note.updatedAt?.toISOString() ?? null,
});
}
+
+ public async convertConversation(conversation: Entity.Conversation, me?: MiLocalUser | null): Promise<MastodonEntity.Conversation> {
+ return {
+ id: conversation.id,
+ accounts: await Promise.all(conversation.accounts.map(a => this.convertAccount(a))),
+ last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null,
+ unread: conversation.unread,
+ };
+ }
+
+ public async convertNotification(notification: Entity.Notification, me?: MiLocalUser | null): Promise<MastodonEntity.Notification> {
+ return {
+ account: await this.convertAccount(notification.account),
+ created_at: notification.created_at,
+ id: notification.id,
+ status: notification.status ? await this.convertStatus(notification.status, me) : undefined,
+ type: notification.type,
+ };
+ }
}
-function simpleConvert(data: any) {
+function simpleConvert<T>(data: T): T {
// copy the object to bypass weird pass by reference bugs
- const result = Object.assign({}, data);
- return result;
+ return Object.assign({}, data);
}
export function convertAccount(account: Entity.Account) {
@@ -302,8 +354,30 @@ export function convertAccount(account: Entity.Account) {
export function convertAnnouncement(announcement: Entity.Announcement) {
return simpleConvert(announcement);
}
-export function convertAttachment(attachment: Entity.Attachment) {
- return simpleConvert(attachment);
+export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment {
+ const { width, height } = attachment.meta?.original ?? attachment.meta ?? {};
+ const size = (width && height) ? `${width}x${height}` : undefined;
+ const aspect = (width && height) ? (width / height) : undefined;
+ return {
+ ...attachment,
+ meta: attachment.meta ? {
+ ...attachment.meta,
+ original: {
+ ...attachment.meta.original,
+ width,
+ height,
+ size,
+ aspect,
+ frame_rate: String(attachment.meta.fps),
+ duration: attachment.meta.duration,
+ bitrate: attachment.meta.audio_bitrate ? parseInt(attachment.meta.audio_bitrate) : undefined,
+ },
+ width,
+ height,
+ size,
+ aspect,
+ } : null,
+ };
}
export function convertFilter(filter: Entity.Filter) {
return simpleConvert(filter);
@@ -315,45 +389,40 @@ export function convertFeaturedTag(tag: Entity.FeaturedTag) {
return simpleConvert(tag);
}
-export function convertNotification(notification: Entity.Notification) {
- notification.account = convertAccount(notification.account);
- if (notification.status) notification.status = convertStatus(notification.status);
- return notification;
-}
-
export function convertPoll(poll: Entity.Poll) {
return simpleConvert(poll);
}
+
+// noinspection JSUnusedGlobalSymbols
export function convertReaction(reaction: Entity.Reaction) {
if (reaction.accounts) {
reaction.accounts = reaction.accounts.map(convertAccount);
}
return reaction;
}
-export function convertRelationship(relationship: Entity.Relationship) {
- return simpleConvert(relationship);
-}
-export function convertStatus(status: Entity.Status) {
- status.account = convertAccount(status.account);
- status.media_attachments = status.media_attachments.map((attachment) =>
- convertAttachment(attachment),
- );
- if (status.poll) status.poll = convertPoll(status.poll);
- if (status.reblog) status.reblog = convertStatus(status.reblog);
-
- return status;
+// Megalodon sometimes returns broken / stubbed relationship data
+export function convertRelationship(relationship: Partial<Entity.Relationship> & { id: string }): MastodonEntity.Relationship {
+ return {
+ id: relationship.id,
+ following: relationship.following ?? false,
+ showing_reblogs: relationship.showing_reblogs ?? true,
+ notifying: relationship.notifying ?? true,
+ languages: [],
+ followed_by: relationship.followed_by ?? false,
+ blocking: relationship.blocking ?? false,
+ blocked_by: relationship.blocked_by ?? false,
+ muting: relationship.muting ?? false,
+ muting_notifications: relationship.muting_notifications ?? false,
+ requested: relationship.requested ?? false,
+ requested_by: relationship.requested_by ?? false,
+ domain_blocking: relationship.domain_blocking ?? false,
+ endorsed: relationship.endorsed ?? false,
+ note: relationship.note ?? '',
+ };
}
+// noinspection JSUnusedGlobalSymbols
export function convertStatusSource(status: Entity.StatusSource) {
return simpleConvert(status);
}
-
-export function convertConversation(conversation: Entity.Conversation) {
- conversation.accounts = conversation.accounts.map(convertAccount);
- if (conversation.last_status) {
- conversation.last_status = convertStatus(conversation.last_status);
- }
-
- return conversation;
-}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts
index 6fcfb0019c..79cdddcb9e 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/account.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts
@@ -3,273 +3,149 @@
* 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 { argsToBools, limitToInt } from './timeline.js';
import type { MegalodonInterface } from 'megalodon';
import type { FastifyRequest } from 'fastify';
-import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import type { Config } from '@/config.js';
-import { Injectable } from '@nestjs/common';
-const relationshipModel = {
- id: '',
- following: false,
- followed_by: false,
- delivery_following: false,
- blocking: false,
- blocked_by: false,
- muting: false,
- muting_notifications: false,
- requested: false,
- domain_blocking: false,
- showing_reblogs: false,
- endorsed: false,
- notifying: false,
- note: '',
-};
+export interface ApiAccountMastodonRoute {
+ Params: { id?: string },
+ Querystring: TimelineArgs & { acct?: string },
+ Body: { notifications?: boolean }
+}
@Injectable()
export class ApiAccountMastodon {
- private request: FastifyRequest;
- private client: MegalodonInterface;
- private BASE_URL: string;
-
- constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoconverter: MastoConverters) {
- this.request = request;
- this.client = client;
- this.BASE_URL = BASE_URL;
- }
+ constructor(
+ private readonly request: FastifyRequest<ApiAccountMastodonRoute>,
+ private readonly client: MegalodonInterface,
+ private readonly me: MiLocalUser | null,
+ private readonly mastoConverters: MastoConverters,
+ ) {}
public async verifyCredentials() {
- try {
- const data = await this.client.verifyAccountCredentials();
- const acct = await this.mastoconverter.convertAccount(data.data);
- const newAcct = Object.assign({}, acct, {
- source: {
- note: acct.note,
- fields: acct.fields,
- privacy: '',
- sensitive: false,
- language: '',
- },
- });
- return newAcct;
- } catch (e: any) {
- /* console.error(e);
- console.error(e.response.data); */
- return e.response;
- }
+ 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 async lookup() {
- try {
- const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' });
- return this.mastoconverter.convertAccount(data.data.accounts[0]);
- } catch (e: any) {
- /* console.error(e)
- console.error(e.response.data); */
- return e.response;
- }
+ 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]);
}
- public async getRelationships(users: [string]) {
- try {
- relationshipModel.id = users.toString() || '1';
-
- if (!(users.length > 0)) {
- return [relationshipModel];
- }
-
- const reqIds = [];
- for (let i = 0; i < users.length; i++) {
- reqIds.push(users[i]);
- }
-
- const data = await this.client.getRelationships(reqIds);
- return data.data.map((relationship) => convertRelationship(relationship));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ public async getRelationships(reqIds: string[]) {
+ const data = await this.client.getRelationships(reqIds);
+ return data.data.map(relationship => convertRelationship(relationship));
}
public async getStatuses() {
- try {
- const data = await this.client.getAccountStatuses((this.request.params as any).id, argsToBools(limitToInt(this.request.query as any)));
- return await Promise.all(data.data.map(async (status) => await this.mastoconverter.convertStatus(status)));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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)));
}
public async getFollowers() {
- try {
- const data = await this.client.getAccountFollowers(
- (this.request.params as any).id,
- limitToInt(this.request.query as any),
- );
- return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account)));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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)));
}
public async getFollowing() {
- try {
- const data = await this.client.getAccountFollowing(
- (this.request.params as any).id,
- limitToInt(this.request.query as any),
- );
- return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account)));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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)));
}
public async addFollow() {
- try {
- const data = await this.client.followAccount( (this.request.params as any).id );
- const acct = convertRelationship(data.data);
- acct.following = true;
- return acct;
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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;
}
public async rmFollow() {
- try {
- const data = await this.client.unfollowAccount( (this.request.params as any).id );
- const acct = convertRelationship(data.data);
- acct.following = false;
- return acct;
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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;
}
public async addBlock() {
- try {
- const data = await this.client.blockAccount( (this.request.params as any).id );
- return convertRelationship(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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);
}
public async rmBlock() {
- try {
- const data = await this.client.unblockAccount( (this.request.params as any).id );
- return convertRelationship(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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);
}
public async addMute() {
- try {
- const data = await this.client.muteAccount(
- (this.request.params as any).id,
- this.request.body as any,
- );
- return convertRelationship(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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);
}
public async rmMute() {
- try {
- const data = await this.client.unmuteAccount( (this.request.params as any).id );
- return convertRelationship(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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);
}
public async getBookmarks() {
- try {
- const data = await this.client.getBookmarks( limitToInt(this.request.query as any) );
- return data.data.map((status) => this.mastoconverter.convertStatus(status));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query));
+ return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me)));
}
public async getFavourites() {
- try {
- const data = await this.client.getFavourites( limitToInt(this.request.query as any) );
- return data.data.map((status) => this.mastoconverter.convertStatus(status));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ const data = await this.client.getFavourites(parseTimelineArgs(this.request.query));
+ return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me)));
}
public async getMutes() {
- try {
- const data = await this.client.getMutes( limitToInt(this.request.query as any) );
- return data.data.map((account) => this.mastoconverter.convertAccount(account));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ const data = await this.client.getMutes(parseTimelineArgs(this.request.query));
+ return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
}
public async getBlocks() {
- try {
- const data = await this.client.getBlocks( limitToInt(this.request.query as any) );
- return data.data.map((account) => this.mastoconverter.convertAccount(account));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ const data = await this.client.getBlocks(parseTimelineArgs(this.request.query));
+ return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
}
public async acceptFollow() {
- try {
- const data = await this.client.acceptFollowRequest( (this.request.params as any).id );
- return convertRelationship(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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);
}
public async rejectFollow() {
- try {
- const data = await this.client.rejectFollowRequest( (this.request.params as any).id );
- return convertRelationship(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- return e.response.data;
- }
+ 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);
}
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts
index a447bdb1b7..b58cc902da 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts
@@ -44,36 +44,54 @@ const writeScope = [
'write:gallery-likes',
];
-export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) {
- const body: any = request.body || request.query;
- try {
- let scope = body.scopes;
- if (typeof scope === 'string') scope = scope.split(' ') || scope.split('+');
- 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 scopeArr = Array.from(pushScope);
+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 };
- const red = body.redirect_uris;
- const appData = await client.registerApp(body.client_name, {
- scopes: scopeArr,
- redirect_uris: red,
- website: body.website,
- });
- const returns = {
- id: Math.floor(Math.random() * 100).toString(),
- name: appData.name,
- website: body.website,
- redirect_uri: red,
- client_id: Buffer.from(appData.url || '').toString('base64'),
- client_secret: appData.clientSecret,
- };
+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"');
- return returns;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
+ 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 ce6809d230..382f0a8f1f 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts
@@ -3,68 +3,73 @@
* 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';
-export class ApiFilterMastodon {
- private request: FastifyRequest;
- private client: MegalodonInterface;
-
- constructor(request: FastifyRequest, client: MegalodonInterface) {
- this.request = request;
- this.client = client;
+export interface ApiFilterMastodonRoute {
+ Params: {
+ id?: string,
+ },
+ Body: {
+ phrase?: string,
+ context?: string[],
+ irreversible?: string,
+ whole_word?: string,
+ expires_in?: string,
}
+}
+
+export class ApiFilterMastodon {
+ constructor(
+ private readonly request: FastifyRequest<ApiFilterMastodonRoute>,
+ private readonly client: MegalodonInterface,
+ ) {}
public async getFilters() {
- try {
- const data = await this.client.getFilters();
- return data.data.map((filter) => convertFilter(filter));
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ const data = await this.client.getFilters();
+ return data.data.map((filter) => convertFilter(filter));
}
public async getFilter() {
- try {
- const data = await this.client.getFilter( (this.request.params as any).id );
- return convertFilter(data.data);
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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);
}
public async createFilter() {
- try {
- const body: any = this.request.body;
- const data = await this.client.createFilter(body.pharse, body.context, body);
- return convertFilter(data.data);
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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);
}
public async updateFilter() {
- try {
- const body: any = this.request.body;
- const data = await this.client.updateFilter((this.request.params as any).id, body.pharse, body.context);
- return convertFilter(data.data);
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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);
}
public async rmFilter() {
- try {
- const data = await this.client.deleteFilter( (this.request.params as any).id );
- return data.data;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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;
}
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts
index c9833b85d7..48a56138cf 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts
@@ -8,6 +8,7 @@ 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,
@@ -17,11 +18,8 @@ export async function getInstance(
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.',
+ 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,
diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
index 0eefb5894c..14eee8565a 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
@@ -3,73 +3,56 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { convertNotification } from '../converters.js';
-import type { MegalodonInterface, Entity } from 'megalodon';
+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';
-function toLimitToInt(q: any) {
- if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10);
- return q;
+export interface ApiNotifyMastodonRoute {
+ Params: {
+ id?: string,
+ },
+ Querystring: TimelineArgs,
}
export class ApiNotifyMastodon {
- private request: FastifyRequest;
- private client: MegalodonInterface;
-
- constructor(request: FastifyRequest, client: MegalodonInterface) {
- this.request = request;
- this.client = client;
- }
+ constructor(
+ private readonly request: FastifyRequest<ApiNotifyMastodonRoute>,
+ private readonly client: MegalodonInterface,
+ private readonly me: MiLocalUser | null,
+ private readonly mastoConverters: MastoConverters,
+ ) {}
public async getNotifications() {
- try {
- const data = await this.client.getNotifications( toLimitToInt(this.request.query) );
- const notifs = data.data;
- const processed = notifs.map((n: Entity.Notification) => {
- const convertedn = convertNotification(n);
- if (convertedn.type !== 'follow' && convertedn.type !== 'follow_request') {
- if (convertedn.type === 'reaction') convertedn.type = 'favourite';
- return convertedn;
- } else {
- return convertedn;
- }
- });
- return processed;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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';
+ }
+ return converted;
+ }));
}
public async getNotification() {
- try {
- const data = await this.client.getNotification( (this.request.params as any).id );
- const notif = convertNotification(data.data);
- if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite';
- return notif;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
+ 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;
}
public async rmNotification() {
- try {
- const data = await this.client.dismissNotification( (this.request.params as any).id );
- return data.data;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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;
}
public async rmNotifications() {
- try {
- const data = await this.client.dismissNotifications();
- return data.data;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ const data = await this.client.dismissNotifications();
+ return 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 946e796e2a..4850b4652f 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/search.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts
@@ -3,88 +3,92 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { MiLocalUser } from '@/models/User.js';
import { MastoConverters } from '../converters.js';
-import { limitToInt } from './timeline.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';
-export class ApiSearchMastodon {
- private request: FastifyRequest;
- private client: MegalodonInterface;
- private BASE_URL: string;
-
- constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoConverter: MastoConverters) {
- this.request = request;
- this.client = client;
- this.BASE_URL = BASE_URL;
+export interface ApiSearchMastodonRoute {
+ Querystring: TimelineArgs & {
+ type?: 'accounts' | 'hashtags' | 'statuses';
+ q?: string;
}
+}
+
+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,
+ ) {}
public async SearchV1() {
- try {
- const query: any = limitToInt(this.request.query as any);
- const type = query.type || '';
- const data = await this.client.search(query.q, { type: type, ...query });
- return data.data;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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 async SearchV2() {
- try {
- const query: any = limitToInt(this.request.query as any);
- const type = query.type;
- const acct = !type || type === 'accounts' ? await this.client.search(query.q, { type: 'accounts', ...query }) : null;
- const stat = !type || type === 'statuses' ? await this.client.search(query.q, { type: 'statuses', ...query }) : null;
- const tags = !type || type === 'hashtags' ? await this.client.search(query.q, { type: 'hashtags', ...query }) : null;
- const data = {
- accounts: await Promise.all(acct?.data.accounts.map(async (account: any) => await this.mastoConverter.convertAccount(account)) ?? []),
- statuses: await Promise.all(stat?.data.statuses.map(async (status: any) => await this.mastoConverter.convertStatus(status)) ?? []),
- hashtags: tags?.data.hashtags ?? [],
- };
- return data;
- } catch (e: any) {
- console.error(e);
- return e.response.data;
- }
+ 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 ?? [],
+ };
}
public async getStatusTrends() {
- try {
- const data = await fetch(`${this.BASE_URL}/api/notes/featured`,
- {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({}),
- })
- .then(res => res.json())
- .then(data => data.map((status: any) => this.mastoConverter.convertStatus(status)));
- return data;
- } catch (e: any) {
- console.error(e);
- return [];
- }
+ 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);
}
public async getSuggestions() {
- try {
- 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: parseInt((this.request.query as any).limit) || 20, origin: 'local', sort: '+follower', state: 'alive' }),
- }).then((res) => res.json()).then(data => data.map(((entry: any) => { return { source: 'global', account: entry }; })));
- return Promise.all(data.map(async (suggestion: any) => { suggestion.account = await this.mastoConverter.convertAccount(suggestion.account); return suggestion; }));
- } catch (e: any) {
- console.error(e);
- return [];
- }
+ 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;
+ }));
}
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts
index ddc99639fa..4c49a6a293 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/status.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts
@@ -3,181 +3,212 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import querystring from 'querystring';
+import querystring, { ParsedUrlQueryInput } from 'querystring';
import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js';
-import { convertAttachment, convertPoll, convertStatusSource, MastoConverters } from '../converters.js';
-import { getClient } from '../MastodonApiServerService.js';
-import { limitToInt } from './timeline.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 type { Entity } from 'megalodon';
import type { FastifyInstance } from 'fastify';
-import type { Config } from '@/config.js';
-import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
-function normalizeQuery(data: any) {
- const str = querystring.stringify(data);
+function normalizeQuery(data: Record<string, unknown>) {
+ const str = querystring.stringify(data as ParsedUrlQueryInput);
return querystring.parse(str);
}
export class ApiStatusMastodon {
- private fastify: FastifyInstance;
- private mastoconverter: MastoConverters;
+ constructor(
+ private readonly fastify: FastifyInstance,
+ private readonly mastoConverters: MastoConverters,
+ private readonly logger: MastodonLogger,
+ private readonly authenticateService: AuthenticateService,
+ private readonly mastodon: MastodonApiServerService,
+ ) {}
- constructor(fastify: FastifyInstance, mastoconverter: MastoConverters) {
- this.fastify = fastify;
- this.mastoconverter = mastoconverter;
- }
-
- public async getStatus() {
- this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(_request.is404 ? 404 : 401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/statuses/${_request.params.id}`, data);
+ reply.code(_request.is404 ? 404 : 401).send(data);
}
});
}
- public async getStatusSource() {
- this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/source', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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: any) {
- console.error(e);
- reply.code(_request.is404 ? 404 : 401).send(e.response.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);
}
});
}
- public async getContext() {
- this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const query: any = _request.query;
+ public getContext() {
+ this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => {
try {
- const data = await client.getStatusContext(_request.params.id, limitToInt(query));
- data.data.ancestors = await Promise.all(data.data.ancestors.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)));
- data.data.descendants = await Promise.all(data.data.descendants.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)));
- reply.send(data.data);
- } catch (e: any) {
- console.error(e);
- reply.code(_request.is404 ? 404 : 401).send(e.response.data);
+ 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);
}
});
}
- public async getHistory() {
- this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/history', async (_request, reply) => {
+ public getHistory() {
+ this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => {
try {
- const edits = await this.mastoconverter.getEdits(_request.params.id);
+ 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: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/statuses/${_request.params.id}/history`, data);
+ reply.code(401).send(data);
}
});
}
- public async getReblogged() {
- this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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.mastoconverter.convertAccount(account))));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async getFavourites() {
- this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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.mastoconverter.convertAccount(account))));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async getMedia() {
- this.fastify.get<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/media/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
}
- public async getPoll() {
- this.fastify.get<{ Params: { id: string } }>('/v1/polls/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/polls/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
}
- public async votePoll() {
- this.fastify.post<{ Params: { id: string } }>('/v1/polls/:id/votes', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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);
- const body: any = _request.body;
try {
- const data = await client.votePoll(_request.params.id, body.choices);
+ 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: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data);
+ reply.code(401).send(data);
}
});
}
- public async postStatus() {
- this.fastify.post('/v1/statuses', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- let body: any = _request.body;
+ public postStatus() {
+ this.fastify.post<{
+ Body: {
+ media_ids?: string[],
+ poll?: {
+ options?: string[],
+ expires_in?: string,
+ multiple?: string,
+ hide_totals?: string,
+ },
+ in_reply_to_id?: string,
+ sensitive?: string,
+ spoiler_text?: string,
+ visibility?: 'public' | 'unlisted' | 'private' | 'direct',
+ scheduled_at?: string,
+ language?: string,
+ quote_id?: string,
+ status?: string,
+
+ // Broken clients
+ 'poll[options][]'?: string[],
+ 'media_ids[]'?: string[],
+ }
+ }>('/v1/statuses', async (_request, reply) => {
+ let body = _request.body;
try {
- if (
- (!body.poll && body['poll[options][]']) ||
- (!body.media_ids && body['media_ids[]'])
+ 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 ? body.status : ' ';
+ 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);
@@ -189,226 +220,253 @@ export class ApiStatusMastodon {
reply.send(a.data);
}
if (body.in_reply_to_id && removed === '/unreact') {
- try {
- const id = body.in_reply_to_id;
- const post = await client.getStatus(id);
- const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name;
- const data = await client.deleteEmojiReaction(id, react);
- reply.send(data.data);
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
- }
+ 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;
- const { sensitive } = body;
- body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive;
-
- if (body.poll) {
- if (
- body.poll.expires_in != null &&
- typeof body.poll.expires_in === 'string'
- ) body.poll.expires_in = parseInt(body.poll.expires_in);
- if (
- body.poll.multiple != null &&
- typeof body.poll.multiple === 'string'
- ) body.poll.multiple = body.poll.multiple === 'true';
- if (
- body.poll.hide_totals != null &&
- typeof body.poll.hide_totals === 'string'
- ) body.poll.hide_totals = body.poll.hide_totals === 'true';
+ if (body.poll && !body.poll.options) {
+ return reply.code(400).send({ error: 'Missing required payload "poll.options"' });
+ }
+ if (body.poll && !body.poll.expires_in) {
+ return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' });
}
- const data = await client.postStatus(text, body);
- reply.send(await this.mastoconverter.convertStatus(data.data as Entity.Status));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ 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);
+ 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);
}
});
}
- public async updateStatus() {
- this.fastify.put<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const body: any = _request.body;
+ public updateStatus() {
+ this.fastify.put<{
+ Params: { id: string },
+ Body: {
+ status?: string,
+ spoiler_text?: string,
+ sensitive?: string,
+ media_ids?: string[],
+ poll?: {
+ options?: string[],
+ expires_in?: string,
+ multiple?: string,
+ hide_totals?: string,
+ },
+ }
+ }>('/v1/statuses/:id', async (_request, reply) => {
try {
- if (!body.media_ids) body.media_ids = undefined;
- if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
- const data = await client.editStatus(_request.params.id, body);
- reply.send(await this.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(_request.is404 ? 404 : 401).send(e.response.data);
+ const { client, me } = await this.mastodon.getAuthClient(_request);
+ const body = _request.body;
+
+ if (!body.media_ids || !body.media_ids.length) {
+ body.media_ids = undefined;
+ }
+
+ const options = {
+ ...body,
+ sensitive: toBoolean(body.sensitive),
+ poll: body.poll ? {
+ options: body.poll.options,
+ expires_in: toInt(body.poll.expires_in),
+ multiple: toBoolean(body.poll.multiple),
+ hide_totals: toBoolean(body.poll.hide_totals),
+ } : undefined,
+ };
+
+ const data = await client.editStatus(_request.params.id, options);
+ 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);
}
});
}
- public async addFavourite() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ public addFavourite() {
+ this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => {
try {
- const data = (await client.createEmojiReaction(_request.params.id, '❤')) as any;
- reply.send(await this.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async rmFavourite() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data);
+ reply.code(401).send(data);
}
});
}
- public async reblogStatus() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data);
+ reply.code(401).send(data);
}
});
}
- public async unreblogStatus() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data);
+ reply.code(401).send(data);
}
});
}
- public async bookmarkStatus() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data);
+ reply.code(401).send(data);
}
});
}
- public async unbookmarkStatus() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data);
+ reply.code(401).send(data);
}
});
}
- public async pinStatus() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/pin', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data);
+ reply.code(401).send(data);
}
});
}
- public async unpinStatus() {
- this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data);
+ reply.code(401).send(data);
}
});
}
- public async reactStatus() {
- this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data);
+ reply.code(401).send(data);
}
});
}
- public async unreactStatus() {
- this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ 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.mastoconverter.convertStatus(data.data));
- } catch (e: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ reply.send(await this.mastoConverters.convertStatus(data.data, me));
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data);
+ reply.code(401).send(data);
}
});
}
- public async deleteStatus() {
- this.fastify.delete<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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: any) {
- console.error(e);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`DELETE /v1/statuses/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
index 3eb4898713..1a732d62de 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
@@ -3,270 +3,231 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { ParsedUrlQuery } from 'querystring';
-import { convertConversation, convertList, MastoConverters } from '../converters.js';
-import { getClient } from '../MastodonApiServerService.js';
+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 type { Entity } from 'megalodon';
import type { FastifyInstance } from 'fastify';
-import type { Config } from '@/config.js';
-import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
-
-export function limitToInt(q: ParsedUrlQuery) {
- const object: any = q;
- if (q.limit) if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10);
- if (q.offset) if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10);
- return object;
-}
-
-export function argsToBools(q: ParsedUrlQuery) {
- // Values taken from https://docs.joinmastodon.org/client/intro/#boolean
- const toBoolean = (value: string) =>
- !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value);
-
- // Keys taken from:
- // - https://docs.joinmastodon.org/methods/accounts/#statuses
- // - https://docs.joinmastodon.org/methods/timelines/#public
- // - https://docs.joinmastodon.org/methods/timelines/#tag
- const object: any = q;
- if (q.only_media) if (typeof q.only_media === 'string') object.only_media = toBoolean(q.only_media);
- if (q.exclude_replies) if (typeof q.exclude_replies === 'string') object.exclude_replies = toBoolean(q.exclude_replies);
- if (q.exclude_reblogs) if (typeof q.exclude_reblogs === 'string') object.exclude_reblogs = toBoolean(q.exclude_reblogs);
- if (q.pinned) if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned);
- if (q.local) if (typeof q.local === 'string') object.local = toBoolean(q.local);
- return q;
-}
export class ApiTimelineMastodon {
- private fastify: FastifyInstance;
-
- constructor(fastify: FastifyInstance, config: Config, private mastoconverter: MastoConverters) {
- this.fastify = fastify;
- }
+ constructor(
+ private readonly fastify: FastifyInstance,
+ private readonly mastoConverters: MastoConverters,
+ private readonly logger: MastodonLogger,
+ private readonly mastodon: MastodonApiServerService,
+ ) {}
- public async getTL() {
- this.fastify.get('/v1/timelines/public', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ public getTL() {
+ this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => {
try {
- const query: any = _request.query;
- const data = query.local === 'true'
- ? await client.getLocalTimeline(argsToBools(limitToInt(query)))
- : await client.getPublicTimeline(argsToBools(limitToInt(query)));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ 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 async getHomeTl() {
- this.fastify.get('/v1/timelines/home', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ public getHomeTl() {
+ this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => {
try {
- const query: any = _request.query;
- const data = await client.getHomeTimeline(limitToInt(query));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async getTagTl() {
- this.fastify.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ public getTagTl() {
+ this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => {
try {
- const query: any = _request.query;
- const params: any = _request.params;
- const data = await client.getTagTimeline(params.hashtag, limitToInt(query));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async getListTL() {
- this.fastify.get<{ Params: { id: string } }>('/v1/timelines/list/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ public getListTL() {
+ this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => {
try {
- const query: any = _request.query;
- const params: any = _request.params;
- const data = await client.getListTimeline(params.id, limitToInt(query));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async getConversations() {
- this.fastify.get('/v1/conversations', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
+ public getConversations() {
+ this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => {
try {
- const query: any = _request.query;
- const data = await client.getConversationTimeline(limitToInt(query));
- reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation)));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async getList() {
- this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
+ public getList() {
+ this.fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
try {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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 params: any = _request.params;
- const data = await client.getList(params.id);
+ const data = await client.getList(_request.params.id);
reply.send(convertList(data.data));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`GET /v1/lists/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
}
- public async getLists() {
+ public getLists() {
this.fastify.get('/v1/lists', async (_request, reply) => {
try {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const BASE_URL = `${_request.protocol}://${_request.host}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const data = await client.getLists();
reply.send(data.data.map((list: Entity.List) => convertList(list)));
- } catch (e: any) {
- console.error(e);
- return e.response.data;
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('GET /v1/lists', data);
+ reply.code(401).send(data);
}
});
}
- public async getListAccounts() {
- this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ 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 {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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 params: any = _request.params;
- const query: any = _request.query;
- const data = await client.getAccountsInList(params.id, query);
- reply.send(data.data.map((account: Entity.Account) => this.mastoconverter.convertAccount(account)));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ 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);
}
});
}
- public async addListAccount() {
- this.fastify.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ public addListAccount() {
+ this.fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
try {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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 params: any = _request.params;
- const query: any = _request.query;
- const data = await client.addAccountsToList(params.id, query.accounts_id);
+ const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id);
reply.send(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`POST /v1/lists/${_request.params.id}/accounts`, data);
+ reply.code(401).send(data);
}
});
}
- public async rmListAccount() {
- this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ public rmListAccount() {
+ this.fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
try {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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 params: any = _request.params;
- const query: any = _request.query;
- const data = await client.deleteAccountsFromList(params.id, query.accounts_id);
+ const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id);
reply.send(data.data);
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`DELETE /v1/lists/${_request.params.id}/accounts`, data);
+ reply.code(401).send(data);
}
});
}
- public async createList() {
- this.fastify.post('/v1/lists', async (_request, reply) => {
+ public createList() {
+ this.fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => {
try {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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 body: any = _request.body;
- const data = await client.createList(body.title);
+ const data = await client.createList(_request.body.title);
reply.send(convertList(data.data));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error('POST /v1/lists', data);
+ reply.code(401).send(data);
}
});
}
- public async updateList() {
- this.fastify.put<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
+ public updateList() {
+ this.fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => {
try {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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 body: any = _request.body;
- const params: any = _request.params;
- const data = await client.updateList(params.id, body.title);
+ const data = await client.updateList(_request.params.id, _request.body.title);
reply.send(convertList(data.data));
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`PUT /v1/lists/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
}
- public async deleteList() {
- this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
+ public deleteList() {
+ this.fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
try {
- const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ 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 params: any = _request.params;
- const data = await client.deleteList(params.id);
+ await client.deleteList(_request.params.id);
reply.send({});
- } catch (e: any) {
- console.error(e);
- console.error(e.response.data);
- reply.code(401).send(e.response.data);
+ } catch (e) {
+ const data = getErrorData(e);
+ this.logger.error(`DELETE /v1/lists/${_request.params.id}`, data);
+ reply.code(401).send(data);
}
});
}
diff --git a/packages/backend/src/server/api/mastodon/timelineArgs.ts b/packages/backend/src/server/api/mastodon/timelineArgs.ts
new file mode 100644
index 0000000000..3fba8ec57a
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/timelineArgs.ts
@@ -0,0 +1,47 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// Keys taken from:
+// - https://docs.joinmastodon.org/methods/accounts/#statuses
+// - https://docs.joinmastodon.org/methods/timelines/#public
+// - https://docs.joinmastodon.org/methods/timelines/#tag
+export interface TimelineArgs {
+ max_id?: string;
+ min_id?: string;
+ since_id?: string;
+ limit?: string;
+ offset?: string;
+ local?: string;
+ pinned?: string;
+ exclude_reblogs?: string;
+ exclude_replies?: string;
+ only_media?: string;
+}
+
+// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
+export function toBoolean(value: string | undefined): boolean | undefined {
+ if (value === undefined) return undefined;
+ return !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value);
+}
+
+export function toInt(value: string | undefined): number | undefined {
+ if (value === undefined) return undefined;
+ return parseInt(value);
+}
+
+export function parseTimelineArgs(q: TimelineArgs) {
+ return {
+ max_id: q.max_id,
+ min_id: q.min_id,
+ since_id: q.since_id,
+ limit: typeof(q.limit) === 'string' ? parseInt(q.limit, 10) : undefined,
+ offset: typeof(q.offset) === 'string' ? parseInt(q.offset, 10) : undefined,
+ local: typeof(q.local) === 'string' ? toBoolean(q.local) : undefined,
+ pinned: typeof(q.pinned) === 'string' ? toBoolean(q.pinned) : undefined,
+ exclude_reblogs: typeof(q.exclude_reblogs) === 'string' ? toBoolean(q.exclude_reblogs) : undefined,
+ exclude_replies: typeof(q.exclude_replies) === 'string' ? toBoolean(q.exclude_replies) : undefined,
+ only_media: typeof(q.only_media) === 'string' ? toBoolean(q.only_media) : undefined,
+ };
+}