Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"graphql": "16.13.2",
"graphql-list-fields": "2.0.4",
"graphql-relay": "0.10.2",
"graphql-upload": "15.0.2",
"graphql-upload": "17.0.0",
"intersect": "1.0.1",
"jsonwebtoken": "9.0.3",
"jwks-rsa": "3.2.0",
Expand Down
97 changes: 97 additions & 0 deletions spec/FileNameNormalization.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use strict';

const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter')
.GridFSBucketAdapter;
const request = require('../lib/request');

const databaseURI = 'mongodb://localhost:27017/parse';

describe_only_db('mongo')('Unicode filename normalization', () => {
beforeEach(async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
const db = await gfsAdapter._connect();
await db.dropDatabase();
await gfsAdapter.handleShutdown();
});

it('normalizes each path segment for direct GridFS adapter operations', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
const decomposedFilename = 'cafe\u0301.txt';
const normalizedFilename = 'caf\u00e9.txt';
const storedFilename = `docs/${normalizedFilename}`;

await gfsAdapter.createFile(`docs/${decomposedFilename}`, 'normalized content', 'text/plain', {
metadata: {},
});

const bucket = await gfsAdapter._getBucket();
let documents = await bucket.find({ filename: storedFilename }).toArray();
expect(documents.length).toBe(1);

const metadata = await gfsAdapter.getMetadata(`docs/${decomposedFilename}`);
expect(metadata).toEqual({ metadata: {} });

const data = await gfsAdapter.getFileData(`docs/${decomposedFilename}`);
expect(data.toString('utf8')).toBe('normalized content');

await gfsAdapter.deleteFile(`docs/${decomposedFilename}`);
documents = await bucket.find({ filename: storedFilename }).toArray();
expect(documents.length).toBe(0);
});

it('normalizes filenames across upload, metadata, download, and delete routes', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
await reconfigureServer({
filesAdapter: gfsAdapter,
preserveFileName: true,
});

const decomposedFilename = 'cafe\u0301.txt';
const normalizedFilename = 'caf\u00e9.txt';
const requestedFilename = encodeURIComponent(decomposedFilename);

const createResponse = await request({
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
url: `http://localhost:8378/1/files/${requestedFilename}`,
body: 'normalized content',
});
expect(createResponse.data.name).toBe(normalizedFilename);
expect(createResponse.data.url).toBe(
`http://localhost:8378/1/files/test/${encodeURIComponent(normalizedFilename)}`
);

const bucket = await gfsAdapter._getBucket();
let documents = await bucket.find({ filename: normalizedFilename }).toArray();
expect(documents.length).toBe(1);

const metadataResponse = await request({
method: 'GET',
url: `http://localhost:8378/1/files/test/metadata/${requestedFilename}`,
});
expect(metadataResponse.data).toEqual({ metadata: {} });

const downloadResponse = await request({
method: 'GET',
url: `http://localhost:8378/1/files/test/${requestedFilename}`,
});
expect(downloadResponse.text).toBe('normalized content');

const deleteResponse = await request({
method: 'DELETE',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
url: `http://localhost:8378/1/files/${requestedFilename}`,
});
expect(deleteResponse.status).toBe(200);

documents = await bucket.find({ filename: normalizedFilename }).toArray();
expect(documents.length).toBe(0);
});
});
7 changes: 7 additions & 0 deletions spec/FilesController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,11 @@ describe('FilesController', () => {
expect(gridFSAdapter.validateFilename(fileName)).not.toBe(null);
done();
});

it('should allow accented characters in file names', done => {
const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse');
const fileName = 'café.txt';
expect(gridFSAdapter.validateFilename(fileName)).toBe(null);
done();
});
});
22 changes: 22 additions & 0 deletions spec/ParseFile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,28 @@ describe('Parse.File testing', () => {
});
});

it('allows accented filename characters', done => {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/files/caf%C3%A9.txt',
body: 'accented filename',
}).then(response => {
const b = response.data;
expect(b.name).toMatch(/_café.txt$/);
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*caf%C3%A9.txt$/);
request({ url: b.url }).then(response => {
expect(response.text).toEqual('accented filename');
done();
});
}, fail);
});

it('validates filename length', done => {
const headers = {
'Content-Type': 'text/plain',
Expand Down
65 changes: 65 additions & 0 deletions spec/ParseGraphQLServer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7465,6 +7465,71 @@ describe('ParseGraphQLServer', () => {
expect(res.status).toEqual(200);
expect(await res.text()).toEqual('My File Content');
});

it('should preserve accented characters in uploaded filenames', async () => {
const clientMutationId = uuidv4();

parseServer = await global.reconfigureServer({
publicServerURL: 'http://localhost:13377/parse',
});
await createGQLFromParseServer(parseServer);
const body = new FormData();
body.append(
'operations',
JSON.stringify({
query: `
mutation CreateFile($input: CreateFileInput!) {
createFile(input: $input) {
clientMutationId
fileInfo {
name
url
}
}
}
`,
variables: {
input: {
clientMutationId,
upload: null,
},
},
})
);
body.append('map', JSON.stringify({ 1: ['variables.input.upload'] }));
body.append('1', 'My File Content', {
filename: 'café.txt',
contentType: 'text/plain',
});

let res = await fetch('http://localhost:13377/graphql', {
method: 'POST',
headers,
body,
});

expect(res.status).toEqual(200);

const result = JSON.parse(await res.text());

expect(result.errors).toBeUndefined();
expect(result.data?.createFile).not.toBeNull();
if (result.errors || !result.data?.createFile) {
return;
}
expect(result.data.createFile.clientMutationId).toEqual(clientMutationId);
expect(result.data.createFile.fileInfo.name).toEqual(
jasmine.stringMatching(/_café.txt$/)
);
expect(result.data.createFile.fileInfo.url).toEqual(
jasmine.stringMatching(/_caf%C3%A9.txt$/)
);

res = await fetch(result.data.createFile.fileInfo.url);

expect(res.status).toEqual(200);
expect(await res.text()).toEqual('My File Content');
});
});
});

Expand Down
13 changes: 12 additions & 1 deletion src/Adapters/Files/FilesAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,29 @@ export class FilesAdapter {
// getMetadata(filename: string): Promise<any> {}
}

export function normalizeFilename(filename: any): any {
if (typeof filename !== 'string') {
return filename;
}
return filename
.split('/')
.map(segment => segment.normalize('NFC'))
.join('/');
}

Comment thread
coratgerl marked this conversation as resolved.
/**
* Simple filename validation
*
* @param filename
* @returns {null|Parse.Error}
*/
export function validateFilename(filename): ?Parse.Error {
filename = normalizeFilename(filename);
if (filename.length > 128) {
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.');
}

const regx = /^[_a-zA-Z0-9][a-zA-Z0-9@. ~_-]*$/;
const regx = /^[_\p{L}\p{N}][\p{L}\p{M}\p{N}@. ~_-]*$/u;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coratgerl marked this conversation as resolved.
Outdated
if (!filename.match(regx)) {
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename contains invalid characters.');
}
Expand Down
8 changes: 7 additions & 1 deletion src/Adapters/Files/GridFSBucketAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

// @flow-disable-next
import { MongoClient, GridFSBucket, Db } from 'mongodb';
import { FilesAdapter, validateFilename } from './FilesAdapter';
import { FilesAdapter, normalizeFilename, validateFilename } from './FilesAdapter';
import defaults, { ParseServerDatabaseOptions } from '../../defaults';
const crypto = require('crypto');

Expand Down Expand Up @@ -78,6 +78,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
// For a given config object, filename, and data, store a file
// Returns a promise
async createFile(filename: string, data, contentType, options = {}) {
filename = normalizeFilename(filename);
const bucket = await this._getBucket();
const stream = await bucket.openUploadStream(filename, {
metadata: options.metadata,
Expand Down Expand Up @@ -135,6 +136,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
}

async deleteFile(filename: string) {
filename = normalizeFilename(filename);
const bucket = await this._getBucket();
const documents = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
if (documents.length === 0) {
Expand All @@ -148,6 +150,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
}

async getFileData(filename: string) {
filename = normalizeFilename(filename);
const bucket = await this._getBucket();
const stream = bucket.openDownloadStreamByName(filename);
stream.read();
Expand Down Expand Up @@ -221,11 +224,13 @@ export class GridFSBucketAdapter extends FilesAdapter {
}

getFileLocation(config, filename) {
filename = normalizeFilename(filename);
const encodedFilename = filename.split('/').map(encodeURIComponent).join('/');
return config.mount + '/files/' + config.applicationId + '/' + encodedFilename;
}

async getMetadata(filename) {
filename = normalizeFilename(filename);
const bucket = await this._getBucket();
const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
if (files.length === 0) {
Expand All @@ -236,6 +241,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
}

async handleFileStream(filename: string, req, res, contentType) {
filename = normalizeFilename(filename);
const bucket = await this._getBucket();
const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
if (files.length === 0) {
Expand Down
Loading
Loading