improve compatibility with multipart/form-data mastodon API requests

This commit is contained in:
Hazelnoot 2025-05-06 12:55:51 -04:00
parent 1a19301c90
commit cd4fbc851b
11 changed files with 202 additions and 241 deletions

View file

@ -3,12 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import querystring from 'querystring';
import multer from 'fastify-multer';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
import { getErrorData, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js';
@ -20,6 +16,7 @@ import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifi
import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js';
import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js';
import { ApiError } from '@/server/api/error.js';
import { ServerUtilityService } from '@/server/ServerUtilityService.js';
import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js';
import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js';
import type { Entity } from 'megalodon';
@ -28,9 +25,6 @@ import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@Injectable()
export class MastodonApiServerService {
constructor(
@Inject(DI.config)
private readonly config: Config,
private readonly mastoConverters: MastodonConverters,
private readonly logger: MastodonLogger,
private readonly clientService: MastodonClientService,
@ -42,97 +36,15 @@ export class MastodonApiServerService {
private readonly apiSearchMastodon: ApiSearchMastodon,
private readonly apiStatusMastodon: ApiStatusMastodon,
private readonly apiTimelineMastodon: ApiTimelineMastodon,
private readonly serverUtilityService: ServerUtilityService,
) {}
@bindThis
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
const upload = multer({
storage: multer.diskStorage({}),
limits: {
fileSize: this.config.maxFileSize || 262144000,
files: 1,
},
});
fastify.addHook('onRequest', (_, reply, done) => {
// Allow web-based clients to connect from other origins.
reply.header('Access-Control-Allow-Origin', '*');
// Mastodon uses all types of request methods.
reply.header('Access-Control-Allow-Methods', '*');
// Allow web-based clients to access Link header - required for mastodon pagination.
// https://stackoverflow.com/a/54928828
// https://docs.joinmastodon.org/api/guidelines/#pagination
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers
reply.header('Access-Control-Expose-Headers', 'Link');
// Cache to avoid extra pre-flight requests
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds
done();
});
fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => {
let body = '';
payload.on('data', (data) => {
body += data;
});
payload.on('end', () => {
try {
const parsed = querystring.parse(body);
done(null, parsed);
} catch (e) {
done(e as Error);
}
});
payload.on('error', done);
});
// Remove trailing "[]" from query params
fastify.addHook('preValidation', (request, _reply, done) => {
if (!request.query || typeof(request.query) !== 'object') {
return done();
}
// Same object aliased with a different type
const query = request.query as Record<string, string | string[] | undefined>;
for (const key of Object.keys(query)) {
if (!key.endsWith('[]')) {
continue;
}
if (query[key] == null) {
continue;
}
const newKey = key.substring(0, key.length - 2);
const newValue = query[key];
const oldValue = query[newKey];
// Move the value to the correct key
if (oldValue != null) {
if (Array.isArray(oldValue)) {
// Works for both array and single values
query[newKey] = oldValue.concat(newValue);
} else if (Array.isArray(newValue)) {
// Preserve order
query[newKey] = [oldValue, ...newValue];
} else {
// Preserve order
query[newKey] = [oldValue, newValue];
}
} else {
query[newKey] = newValue;
}
// Remove the invalid key
delete query[key];
}
return done();
});
this.serverUtilityService.addMultipartFormDataContentType(fastify);
this.serverUtilityService.addFormUrlEncodedContentType(fastify);
this.serverUtilityService.addCORS(fastify);
this.serverUtilityService.addFlattenedQueryType(fastify);
fastify.setErrorHandler((error, request, reply) => {
const data = getErrorData(error);
@ -143,14 +55,12 @@ export class MastodonApiServerService {
reply.code(status).send(data);
});
fastify.register(multer.contentParser);
// External endpoints
this.apiAccountMastodon.register(fastify, upload);
this.apiAppsMastodon.register(fastify, upload);
this.apiFilterMastodon.register(fastify, upload);
this.apiAccountMastodon.register(fastify);
this.apiAppsMastodon.register(fastify);
this.apiFilterMastodon.register(fastify);
this.apiInstanceMastodon.register(fastify);
this.apiNotificationsMastodon.register(fastify, upload);
this.apiNotificationsMastodon.register(fastify);
this.apiSearchMastodon.register(fastify);
this.apiStatusMastodon.register(fastify);
this.apiTimelineMastodon.register(fastify);
@ -178,11 +88,10 @@ export class MastodonApiServerService {
reply.send(data.data);
});
fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => {
const multipartData = await _request.file();
fastify.post('/v1/media', async (_request, reply) => {
const multipartData = _request.savedRequestFiles?.[0];
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' });
}
const client = this.clientService.getClient(_request);
@ -192,11 +101,10 @@ export class MastodonApiServerService {
reply.send(response);
});
fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => {
const multipartData = await _request.file();
fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', async (_request, reply) => {
const multipartData = _request.savedRequestFiles?.[0];
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' });
}
const client = this.clientService.getClient(_request);
@ -294,7 +202,7 @@ export class MastodonApiServerService {
reply.send(response);
});
fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -304,7 +212,7 @@ export class MastodonApiServerService {
reply.send(response);
});
fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -325,7 +233,7 @@ export class MastodonApiServerService {
focus?: string,
is_sensitive?: string,
},
}>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => {
}>('/v1/media/:id', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const options = {

View file

@ -11,7 +11,6 @@ import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js';
import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js';
import type multer from 'fastify-multer';
import type { FastifyInstance } from 'fastify';
interface ApiAccountMastodonRoute {
@ -34,7 +33,7 @@ export class ApiAccountMastodon {
private readonly driveService: DriveService,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
public register(fastify: FastifyInstance): void {
fastify.get<ApiAccountMastodonRoute>('/v1/accounts/verify_credentials', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.verifyAccountCredentials();
@ -70,60 +69,50 @@ export class ApiAccountMastodon {
value: string,
}[],
},
}>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => {
}>('/v1/accounts/update_credentials', async (_request, reply) => {
const accessTokens = _request.headers.authorization;
const client = this.clientService.getClient(_request);
// Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it.
if (_request.files.length > 0 && accessTokens) {
if (_request.savedRequestFiles?.length && accessTokens) {
const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const avatar = (_request.files as any).find((obj: any) => {
const avatar = _request.savedRequestFiles.find(obj => {
return obj.fieldname === 'avatar';
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const header = (_request.files as any).find((obj: any) => {
const header = _request.savedRequestFiles.find(obj => {
return obj.fieldname === 'header';
});
if (tokeninfo && avatar) {
const upload = await this.driveService.addFile({
user: { id: tokeninfo.userId, host: null },
path: avatar.path,
name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined,
path: avatar.filepath,
name: avatar.filename && avatar.filename !== 'file' ? avatar.filename : undefined,
sensitive: false,
});
if (upload.type.startsWith('image/')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).avatar = upload.id;
_request.body.avatar = upload.id;
}
} else if (tokeninfo && header) {
const upload = await this.driveService.addFile({
user: { id: tokeninfo.userId, host: null },
path: header.path,
name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined,
path: header.filepath,
name: header.filename && header.filename !== 'file' ? header.filename : undefined,
sensitive: false,
});
if (upload.type.startsWith('image/')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).header = upload.id;
_request.body.header = upload.id;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((_request.body as any).fields_attributes) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fields = (_request.body as any).fields_attributes.map((field: any) => {
if (_request.body.fields_attributes) {
for (const field of _request.body.fields_attributes) {
if (!(field.name.trim() === '' && field.value.trim() === '')) {
if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty');
if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty');
}
return {
...field,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0);
}
_request.body.fields_attributes = _request.body.fields_attributes.filter(field => field.name.trim().length > 0 && field.value.length > 0);
}
const options = {
@ -234,7 +223,7 @@ export class ApiAccountMastodon {
reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -245,7 +234,7 @@ export class ApiAccountMastodon {
reply.send(acct);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -256,7 +245,7 @@ export class ApiAccountMastodon {
reply.send(acct);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -266,7 +255,7 @@ export class ApiAccountMastodon {
reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -276,7 +265,7 @@ export class ApiAccountMastodon {
return reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -289,7 +278,7 @@ export class ApiAccountMastodon {
reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);

View file

@ -6,7 +6,6 @@
import { Injectable } from '@nestjs/common';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import type { FastifyInstance } from 'fastify';
import type multer from 'fastify-multer';
const readScope = [
'read:account',
@ -62,8 +61,8 @@ export class ApiAppsMastodon {
private readonly clientService: MastodonClientService,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
fastify.post<AuthMastodonRoute>('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => {
public register(fastify: FastifyInstance): void {
fastify.post<AuthMastodonRoute>('/v1/apps', async (_request, reply) => {
const body = _request.body ?? _request.query;
if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' });
if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' });

View file

@ -8,7 +8,6 @@ import { toBoolean } from '@/server/api/mastodon/argsUtils.js';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import { convertFilter } from '../MastodonConverters.js';
import type { FastifyInstance } from 'fastify';
import type multer from 'fastify-multer';
interface ApiFilterMastodonRoute {
Params: {
@ -29,7 +28,7 @@ export class ApiFilterMastodon {
private readonly clientService: MastodonClientService,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
public register(fastify: FastifyInstance): void {
fastify.get('/v1/filters', async (_request, reply) => {
const client = this.clientService.getClient(_request);
@ -49,7 +48,7 @@ export class ApiFilterMastodon {
reply.send(response);
});
fastify.post<ApiFilterMastodonRoute>('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiFilterMastodonRoute>('/v1/filters', async (_request, reply) => {
if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' });
if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' });
@ -68,7 +67,7 @@ export class ApiFilterMastodon {
reply.send(response);
});
fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' });
if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' });

View file

@ -10,7 +10,6 @@ import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'
import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
import { MastodonClientService } from '../MastodonClientService.js';
import type { FastifyInstance } from 'fastify';
import type multer from 'fastify-multer';
interface ApiNotifyMastodonRoute {
Params: {
@ -26,7 +25,7 @@ export class ApiNotificationsMastodon {
private readonly clientService: MastodonClientService,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
public register(fastify: FastifyInstance): void {
fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (request, reply) => {
const { client, me } = await this.clientService.getAuthClient(request);
const data = await client.getNotifications(parseTimelineArgs(request.query));
@ -66,7 +65,7 @@ export class ApiNotificationsMastodon {
reply.send(response);
});
fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -75,7 +74,7 @@ export class ApiNotificationsMastodon {
reply.send(data.data);
});
fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.dismissNotifications();