mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
feat: Add support for invoking Cloud Function with multipart/form-data protocol (#10395)
This commit is contained in:
Generated
+1
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@apollo/server": "5.5.0",
|
||||
"@as-integrations/express5": "1.1.2",
|
||||
"@fastify/busboy": "3.2.0",
|
||||
"@graphql-tools/merge": "9.1.7",
|
||||
"@graphql-tools/schema": "10.0.31",
|
||||
"@graphql-tools/utils": "11.0.0",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dependencies": {
|
||||
"@apollo/server": "5.5.0",
|
||||
"@as-integrations/express5": "1.1.2",
|
||||
"@fastify/busboy": "3.2.0",
|
||||
"@graphql-tools/merge": "9.1.7",
|
||||
"@graphql-tools/schema": "10.0.31",
|
||||
"@graphql-tools/utils": "11.0.0",
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
'use strict';
|
||||
const http = require('http');
|
||||
|
||||
function postMultipart(url, headers, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const req = http.request(
|
||||
{
|
||||
method: 'POST',
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname,
|
||||
headers,
|
||||
},
|
||||
res => {
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
try {
|
||||
resolve({ status: res.statusCode, data: JSON.parse(raw) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode, data: raw });
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function buildMultipartBody(boundary, parts) {
|
||||
const segments = [];
|
||||
for (const part of parts) {
|
||||
segments.push(`--${boundary}\r\n`);
|
||||
if (part.filename) {
|
||||
segments.push(
|
||||
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n`
|
||||
);
|
||||
segments.push(`Content-Type: ${part.contentType || 'application/octet-stream'}\r\n\r\n`);
|
||||
segments.push(part.data);
|
||||
} else {
|
||||
segments.push(`Content-Disposition: form-data; name="${part.name}"\r\n\r\n`);
|
||||
segments.push(part.value);
|
||||
}
|
||||
segments.push('\r\n');
|
||||
}
|
||||
segments.push(`--${boundary}--\r\n`);
|
||||
return Buffer.concat(segments.map(s => (typeof s === 'string' ? Buffer.from(s) : s)));
|
||||
}
|
||||
|
||||
describe('Cloud Code Multipart', () => {
|
||||
it('should not reject multipart requests at the JSON parser level', async () => {
|
||||
Parse.Cloud.define('multipartTest', req => {
|
||||
return { received: true };
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundary123';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'key', value: 'value' },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartTest`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should parse text fields from multipart request', async () => {
|
||||
Parse.Cloud.define('multipartText', req => {
|
||||
return { userId: req.params.userId, count: req.params.count };
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundary456';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'userId', value: 'abc123' },
|
||||
{ name: 'count', value: '5' },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartText`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.userId).toBe('abc123');
|
||||
expect(result.data.result.count).toBe('5');
|
||||
});
|
||||
|
||||
it('should parse file fields from multipart request', async () => {
|
||||
Parse.Cloud.define('multipartFile', req => {
|
||||
const file = req.params.avatar;
|
||||
return {
|
||||
filename: file.filename,
|
||||
contentType: file.contentType,
|
||||
size: file.data.length,
|
||||
content: file.data.toString('utf8'),
|
||||
};
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundary789';
|
||||
const fileContent = Buffer.from('hello world');
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'avatar', filename: 'photo.txt', contentType: 'text/plain', data: fileContent },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartFile`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.filename).toBe('photo.txt');
|
||||
expect(result.data.result.contentType).toBe('text/plain');
|
||||
expect(result.data.result.size).toBe(11);
|
||||
expect(result.data.result.content).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should parse mixed text and file fields from multipart request', async () => {
|
||||
Parse.Cloud.define('multipartMixed', req => {
|
||||
return {
|
||||
userId: req.params.userId,
|
||||
hasAvatar: !!req.params.avatar,
|
||||
avatarFilename: req.params.avatar.filename,
|
||||
};
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryMixed';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'userId', value: 'user42' },
|
||||
{ name: 'avatar', filename: 'img.jpg', contentType: 'image/jpeg', data: Buffer.from([0xff, 0xd8, 0xff]) },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartMixed`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.userId).toBe('user42');
|
||||
expect(result.data.result.hasAvatar).toBe(true);
|
||||
expect(result.data.result.avatarFilename).toBe('img.jpg');
|
||||
});
|
||||
|
||||
it('should parse multiple file fields from multipart request', async () => {
|
||||
Parse.Cloud.define('multipartMultiFile', req => {
|
||||
return {
|
||||
file1Name: req.params.doc1.filename,
|
||||
file2Name: req.params.doc2.filename,
|
||||
file1Size: req.params.doc1.data.length,
|
||||
file2Size: req.params.doc2.data.length,
|
||||
};
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryMulti';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'doc1', filename: 'a.txt', contentType: 'text/plain', data: Buffer.from('aaa') },
|
||||
{ name: 'doc2', filename: 'b.txt', contentType: 'text/plain', data: Buffer.from('bbbbb') },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartMultiFile`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.file1Name).toBe('a.txt');
|
||||
expect(result.data.result.file2Name).toBe('b.txt');
|
||||
expect(result.data.result.file1Size).toBe(3);
|
||||
expect(result.data.result.file2Size).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle empty file field from multipart request', async () => {
|
||||
Parse.Cloud.define('multipartEmptyFile', req => {
|
||||
return {
|
||||
filename: req.params.empty.filename,
|
||||
size: req.params.empty.data.length,
|
||||
};
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryEmpty';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'empty', filename: 'empty.bin', contentType: 'application/octet-stream', data: Buffer.alloc(0) },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartEmptyFile`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.filename).toBe('empty.bin');
|
||||
expect(result.data.result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should still handle JSON requests as before', async () => {
|
||||
Parse.Cloud.define('jsonTest', req => {
|
||||
return { name: req.params.name, count: req.params.count };
|
||||
});
|
||||
|
||||
const result = await Parse.Cloud.run('jsonTest', { name: 'hello', count: 42 });
|
||||
|
||||
expect(result.name).toBe('hello');
|
||||
expect(result.count).toBe(42);
|
||||
});
|
||||
|
||||
it('should reject multipart request exceeding maxUploadSize', async () => {
|
||||
await reconfigureServer({ maxUploadSize: '1kb' });
|
||||
|
||||
Parse.Cloud.define('multipartLarge', req => {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryLarge';
|
||||
const largeData = Buffer.alloc(2 * 1024, 'x');
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'bigfile', filename: 'large.bin', contentType: 'application/octet-stream', data: largeData },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartLarge`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE);
|
||||
});
|
||||
|
||||
it('should reject multipart request exceeding maxUploadSize via file stream', async () => {
|
||||
await reconfigureServer({ maxUploadSize: '1kb' });
|
||||
|
||||
Parse.Cloud.define('multipartLargeFile', req => {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryLargeFile';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: 'small', value: 'ok' },
|
||||
{ name: 'bigfile', filename: 'large.bin', contentType: 'application/octet-stream', data: Buffer.alloc(2 * 1024, 'x') },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartLargeFile`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE);
|
||||
});
|
||||
|
||||
it('should reject malformed multipart body', async () => {
|
||||
Parse.Cloud.define('multipartMalformed', req => {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartMalformed`,
|
||||
{
|
||||
'Content-Type': 'multipart/form-data; boundary=----TestBoundaryBad',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
Buffer.from('this is not valid multipart data')
|
||||
);
|
||||
|
||||
expect(result.data.code).toBe(Parse.Error.INVALID_JSON);
|
||||
});
|
||||
|
||||
it('should not allow prototype pollution via __proto__ field name', async () => {
|
||||
Parse.Cloud.define('multipartProto', req => {
|
||||
const obj = {};
|
||||
return {
|
||||
polluted: obj.polluted !== undefined,
|
||||
paramsClean: Object.getPrototypeOf(req.params) === Object.prototype,
|
||||
};
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryProto';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: '__proto__', value: '{"polluted":"yes"}' },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartProto`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.polluted).toBe(false);
|
||||
expect(result.data.result.paramsClean).toBe(true);
|
||||
});
|
||||
|
||||
it('should not grant master key access via multipart fields', async () => {
|
||||
const obj = new Parse.Object('SecretClass');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
Parse.Cloud.define('multipartAuthCheck', req => {
|
||||
return { isMaster: req.master };
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryAuth';
|
||||
const body = buildMultipartBody(boundary, [
|
||||
{ name: '_MasterKey', value: 'test' },
|
||||
]);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartAuthCheck`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.isMaster).toBe(false);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -329,7 +329,7 @@ class ParseServer {
|
||||
new PagesRouter(pages).expressRouter()
|
||||
);
|
||||
|
||||
api.use(express.json({ type: '*/*', limit: maxUploadSize }));
|
||||
api.use(express.json({ type: req => !req.is('multipart/form-data'), limit: maxUploadSize }));
|
||||
api.use(middlewares.allowMethodOverride);
|
||||
api.use(middlewares.handleParseHeaders);
|
||||
api.use(middlewares.enforceRouteAllowList);
|
||||
|
||||
@@ -9,6 +9,25 @@ import { jobStatusHandler } from '../StatusHandler';
|
||||
import _ from 'lodash';
|
||||
import { logger } from '../logger';
|
||||
import { createSanitizedError } from '../Error';
|
||||
import Busboy from '@fastify/busboy';
|
||||
import Utils from '../Utils';
|
||||
|
||||
function redactBuffers(obj) {
|
||||
if (Buffer.isBuffer(obj)) {
|
||||
return `[Buffer: ${obj.length} bytes]`;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(redactBuffers);
|
||||
}
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
result[key] = redactBuffers(obj[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function parseObject(obj, config) {
|
||||
if (Array.isArray(obj)) {
|
||||
@@ -29,6 +48,8 @@ function parseObject(obj, config) {
|
||||
className: obj.className,
|
||||
objectId: obj.objectId,
|
||||
});
|
||||
} else if (Buffer.isBuffer(obj)) {
|
||||
return obj;
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
return parseParams(obj, config);
|
||||
} else {
|
||||
@@ -46,6 +67,7 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
'POST',
|
||||
'/functions/:functionName',
|
||||
promiseEnsureIdempotency,
|
||||
FunctionsRouter.multipartMiddleware,
|
||||
FunctionsRouter.handleCloudFunction
|
||||
);
|
||||
this.route(
|
||||
@@ -162,6 +184,106 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
};
|
||||
return responseObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses multipart/form-data requests for Cloud Function invocation.
|
||||
* For non-multipart requests, this is a no-op.
|
||||
*
|
||||
* Text fields are set as strings in `req.body`. File fields are set as
|
||||
* objects with the shape `{ filename: string, contentType: string, data: Buffer }`.
|
||||
* All fields are merged flat into `req.body`; the caller is responsible for
|
||||
* avoiding name collisions between text and file fields.
|
||||
*
|
||||
* The total request size is limited by the server's `maxUploadSize` option.
|
||||
*/
|
||||
static multipartMiddleware(req) {
|
||||
if (!req.is || !req.is('multipart/form-data')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const maxBytes = Utils.parseSizeToBytes(req.config.maxUploadSize);
|
||||
return new Promise((resolve, reject) => {
|
||||
const fields = Object.create(null);
|
||||
let totalBytes = 0;
|
||||
let settled = false;
|
||||
let busboy;
|
||||
try {
|
||||
busboy = Busboy({ headers: req.headers, limits: { fieldSize: maxBytes } });
|
||||
} catch (err) {
|
||||
return reject(
|
||||
new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`)
|
||||
);
|
||||
}
|
||||
const safeReject = (err) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
busboy.destroy();
|
||||
reject(err);
|
||||
};
|
||||
busboy.on('field', (name, value, fieldnameTruncated, valueTruncated) => {
|
||||
if (valueTruncated) {
|
||||
return safeReject(
|
||||
new Parse.Error(
|
||||
Parse.Error.OBJECT_TOO_LARGE,
|
||||
'Multipart request exceeds maximum upload size.'
|
||||
)
|
||||
);
|
||||
}
|
||||
totalBytes += Buffer.byteLength(value);
|
||||
if (totalBytes > maxBytes) {
|
||||
return safeReject(
|
||||
new Parse.Error(
|
||||
Parse.Error.OBJECT_TOO_LARGE,
|
||||
'Multipart request exceeds maximum upload size.'
|
||||
)
|
||||
);
|
||||
}
|
||||
fields[name] = value;
|
||||
});
|
||||
busboy.on('file', (name, stream, filename, transferEncoding, mimeType) => {
|
||||
const chunks = [];
|
||||
stream.on('data', chunk => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
stream.destroy();
|
||||
return safeReject(
|
||||
new Parse.Error(
|
||||
Parse.Error.OBJECT_TOO_LARGE,
|
||||
'Multipart request exceeds maximum upload size.'
|
||||
)
|
||||
);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
fields[name] = {
|
||||
filename,
|
||||
contentType: mimeType || 'application/octet-stream',
|
||||
data: Buffer.concat(chunks),
|
||||
};
|
||||
});
|
||||
});
|
||||
busboy.on('finish', () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
req.body = fields;
|
||||
resolve();
|
||||
});
|
||||
busboy.on('error', err => {
|
||||
safeReject(
|
||||
new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`)
|
||||
);
|
||||
});
|
||||
req.pipe(busboy);
|
||||
});
|
||||
}
|
||||
|
||||
static handleCloudFunction(req) {
|
||||
const functionName = req.params.functionName;
|
||||
const applicationId = req.config.applicationId;
|
||||
@@ -192,7 +314,7 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
result => {
|
||||
try {
|
||||
if (req.config.logLevels.cloudFunctionSuccess !== 'silent') {
|
||||
const cleanInput = logger.truncateLogMessage(JSON.stringify(params));
|
||||
const cleanInput = logger.truncateLogMessage(JSON.stringify(redactBuffers(params)));
|
||||
const cleanResult = logger.truncateLogMessage(JSON.stringify(result.response.result));
|
||||
logger[req.config.logLevels.cloudFunctionSuccess](
|
||||
`Ran cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Result: ${cleanResult}`,
|
||||
@@ -211,7 +333,7 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
error => {
|
||||
try {
|
||||
if (req.config.logLevels.cloudFunctionError !== 'silent') {
|
||||
const cleanInput = logger.truncateLogMessage(JSON.stringify(params));
|
||||
const cleanInput = logger.truncateLogMessage(JSON.stringify(redactBuffers(params)));
|
||||
logger[req.config.logLevels.cloudFunctionError](
|
||||
`Failed running cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Error: ` +
|
||||
JSON.stringify(error),
|
||||
|
||||
@@ -821,7 +821,9 @@ module.exports = ParseCloud;
|
||||
* @property {Boolean} master If true, means the master key or the read-only master key was used.
|
||||
* @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access.
|
||||
* @property {Parse.User} user If set, the user that made the request.
|
||||
* @property {Object} params The params passed to the cloud function.
|
||||
* @property {Object} params The params passed to the cloud function. For JSON requests, values
|
||||
* retain their JSON types. For multipart/form-data requests, text fields are strings and file
|
||||
* fields are objects with `{ filename: string, contentType: string, data: Buffer }`.
|
||||
* @property {String} ip The IP address of the client making the request.
|
||||
* @property {Object} headers The original HTTP headers for the request.
|
||||
* @property {Object} log The current logger inside Parse Server.
|
||||
|
||||
Reference in New Issue
Block a user