Skip to content

Commit

Permalink
Encode non-ASCII chars in Dropbox headers
Browse files Browse the repository at this point in the history
Path with non-ASCII char are passed in header, creating an error.
Now we replace all special char with the \uXXXX encoding when stringifying the headers.

Closes #134
  • Loading branch information
JbIPS authored and lexoyo committed Dec 10, 2017
1 parent f3b6ae0 commit 55a7691
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 32 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- [Dropbox] Batch correctly overwrite existing files (https://github.com/silexlabs/unifile/issues/131)
- [Dropbox] Batch now correctly rejects the promise if one action failed (https://github.com/silexlabs/unifile/issues/131)
- [Dropbox] Batch upload uses `Buffer` for file content and supports UTF-8 (https://github.com/silexlabs/unifile/issues/130)
- [Dropbox] All the methods support non-ASCII char in filename and content (https://github.com/silexlabs/unifile/issues/134)

## [2.0.0] - 2017-11-25
### Changed
Expand Down
28 changes: 22 additions & 6 deletions lib/unifile-dropbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const {UnifileError, BatchError} = require('./error');
const NAME = 'dropbox';
const DB_OAUTH_URL = 'https://www.dropbox.com/oauth2';

const charsToEncode = /[\u007f-\uffff]/g;

/**
* Make a call to the Dropbox API
* @param {Object} session - Dropbox session storage
Expand Down Expand Up @@ -62,12 +64,18 @@ function callAPI(session, path, data, subdomain = 'api', isJson = true, headers
errorMessage = (isJson ? body : JSON.parse(body)).error_summary;
}
// Dropbox only uses 409 for endpoints specific errors
let filename = null;
try {
filename = res.request.headers.hasOwnProperty('Dropbox-API-Arg') ?
JSON.parse(res.request.headers['Dropbox-API-Arg']).path
: JSON.parse(res.request.body).path;
} catch (e) {}
if(errorMessage.includes('/not_found/')) {
reject(new UnifileError(UnifileError.ENOENT, 'Not Found'));
reject(new UnifileError(UnifileError.ENOENT, `Not Found: ${filename}`));
} else if(errorMessage.startsWith('path/conflict/')) {
reject(new UnifileError(UnifileError.EINVAL, 'Creation failed due to conflict'));
reject(new UnifileError(UnifileError.EINVAL, `Creation failed due to conflict: ${filename}`));
} else if(errorMessage.startsWith('path/not_file/')) {
reject(new UnifileError(UnifileError.EINVAL, 'Path is a directory'));
reject(new UnifileError(UnifileError.EINVAL, `Path is a directory: ${filename}`));
} else if(res.statusCode === 401) {
reject(new UnifileError(UnifileError.EACCES, errorMessage));
} else {
Expand Down Expand Up @@ -143,6 +151,14 @@ function makePathAbsolute(path) {
return path === '' ? path : '/' + path.split('/').filter((token) => token != '').join('/');
}

function safeStringify(v) {
return JSON.stringify(v).replace(charsToEncode,
function(c) {
return '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4);
}
);
}

/**
* Service connector for {@link https://dropbox.com|Dropbox} plateform.
*
Expand Down Expand Up @@ -319,7 +335,7 @@ class DropboxConnector {
// TODO Handle file conflict and write mode
return callAPI(session, '/files/upload', data, 'content', false, {
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': JSON.stringify({
'Dropbox-API-Arg': safeStringify({
path: makePathAbsolute(path),
mode: this.writeMode
})
Expand Down Expand Up @@ -352,7 +368,7 @@ class DropboxConnector {

readFile(session, path) {
return callAPI(session, '/files/download', {}, 'content', false, {
'Dropbox-API-Arg': JSON.stringify({
'Dropbox-API-Arg': safeStringify({
path: makePathAbsolute(path)
})
});
Expand All @@ -366,7 +382,7 @@ class DropboxConnector {
headers: {
'Authorization': 'Bearer ' + session.token,
'User-Agent': 'Unifile',
'Dropbox-API-Arg': JSON.stringify({
'Dropbox-API-Arg': safeStringify({
path: makePathAbsolute(path)
})
}
Expand Down
146 changes: 121 additions & 25 deletions test/unifile-dropbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,8 @@ describe('DropboxConnector', function() {
return connector.setAccessToken(session, process.env.DROPBOX_TOKEN)
.then(() => connector.batch(session, [
{name: 'mkdir', path: 'unifile_stat'},
{name: 'writeFile', path: 'unifile_stat/file1.txt', content: 'lorem ipsum'}
{name: 'writeFile', path: 'unifile_stat/file1.txt', content: 'lorem ipsum'},
{name: 'writeFile', path: 'unifile_stat/fileéà.txt', content: 'lorem ipsum'}
]));
} else this.skip();
});
Expand Down Expand Up @@ -321,6 +322,16 @@ describe('DropboxConnector', function() {
});
});

it('gives stats on a file with special chars', function() {
return connector.stat(session, 'unifile_stat/fileéà.txt')
.then((stat) => {
expect(stat).to.be.an.instanceof(Object);
const keys = Object.keys(stat);
['isDir', 'mime', 'modified', 'name', 'size'].every((key) => keys.includes(key))
.should.be.true;
});
});

after('Remove folder', function() {
if(isEnvValid()) connector.rmdir(session, 'unifile_stat');
else this.skip();
Expand Down Expand Up @@ -365,6 +376,13 @@ describe('DropboxConnector', function() {
});
});

it('creates a new folder with specials char', function() {
return connector.mkdir(session, 'unifile_mkdir/testéü')
.then(() => {
return expect(connector.readdir(session, 'unifile_mkdir/testéü')).to.be.fulfilled;
});
});

after('Remove folder', function() {
if(isEnvValid()) connector.rmdir(session, 'unifile_mkdir');
else this.skip();
Expand Down Expand Up @@ -398,9 +416,6 @@ describe('DropboxConnector', function() {
})
.then((content) => {
return expect(content.toString()).to.equal(data);
})
.then(() => {
return connector.unlink(session, 'unifile_writeFile/testFile');
});
});

Expand All @@ -411,9 +426,17 @@ describe('DropboxConnector', function() {
})
.then((content) => {
return expect(content.toString()).to.equal(data);
})
});
});

it('writes into a file with special chars', function() {
const specialData = 'éçàïô';
return connector.writeFile(session, 'unifile_writeFile/fileéù.txt', specialData)
.then(() => {
return connector.unlink(session, 'unifile_writeFile/file1.txt');
return connector.readFile(session, 'unifile_writeFile/fileéù.txt');
})
.then((content) => {
return expect(content.toString()).to.equal(specialData);
});
});

Expand Down Expand Up @@ -466,11 +489,12 @@ describe('DropboxConnector', function() {
stream.end(data);
});

it('creates a writable stream', function(done) {
const stream = connector.createWriteStream(session, 'unifile_writeStream/file1.txt');
it('creates a writable stream with special chars', function(done) {
const data = 'éàôï';
const stream = connector.createWriteStream(session, 'unifile_writeStream/fileéà.txt');
// Wait for 'end' (not 'finish') to be sure it has been consumed
stream.on('close', () => {
return connector.readFile(session, 'unifile_writeStream/file1.txt')
return connector.readFile(session, 'unifile_writeStream/fileéà.txt')
.then((result) => {
expect(result.toString()).to.equal(data);
done();
Expand All @@ -491,14 +515,16 @@ describe('DropboxConnector', function() {
describe('readFile()', function() {
let connector;
const data = 'lorem ipsum';
const specialData = 'éçàùï';

before('Init tests folder', function() {
if(isEnvValid()) {
connector = new DropboxConnector(authConfig);
return connector.setAccessToken(session, process.env.DROPBOX_TOKEN)
.then(() => connector.batch(session, [
{name: 'mkdir', path: 'unifile_readFile'},
{name: 'writeFile', path: 'unifile_readFile/file1.txt', content: data}
{name: 'writeFile', path: 'unifile_readFile/file1.txt', content: data},
{name: 'writeFile', path: 'unifile_readFile/fileéù.txt', content: specialData}
]));
} else this.skip();
});
Expand All @@ -518,6 +544,13 @@ describe('DropboxConnector', function() {
});
});

it('returns the content of a file with special chars', function() {
return connector.readFile(session, 'unifile_readFile/fileéù.txt')
.then((content) => {
return expect(content.toString()).to.equal(specialData);
});
});

after('Remove folder', function() {
if(isEnvValid()) connector.rmdir(session, 'unifile_readFile');
else this.skip();
Expand All @@ -527,14 +560,16 @@ describe('DropboxConnector', function() {
describe('createReadStream()', function() {
let connector;
const data = 'lorem ipsum';
const specialData = 'éçàôï';

before('Init tests folder', function() {
if(isEnvValid()) {
connector = new DropboxConnector(authConfig);
return connector.setAccessToken(session, process.env.DROPBOX_TOKEN)
.then(() => connector.batch(session, [
{name: 'mkdir', path: 'unifile_readstream'},
{name: 'writeFile', path: 'unifile_readstream/file1.txt', content: data}
{name: 'writeFile', path: 'unifile_readstream/file1.txt', content: data},
{name: 'writeFile', path: 'unifile_readstream/fileéà.txt', content: specialData}
]));
} else this.skip();
});
Expand Down Expand Up @@ -598,6 +633,17 @@ describe('DropboxConnector', function() {
stream.on('data', (content) => chunks.push(content));
});

it('creates a readable stream with special chars', function(done) {
const chunks = [];
const stream = connector.createReadStream(session, 'unifile_readstream/fileéà.txt');
stream.on('end', () => {
expect(Buffer.concat(chunks).toString()).to.equal(specialData);
done();
});
stream.on('error', done);
stream.on('data', (content) => chunks.push(content));
});

after('Remove folder', function() {
if(isEnvValid()) connector.rmdir(session, 'unifile_readstream');
else this.skip();
Expand Down Expand Up @@ -638,7 +684,15 @@ describe('DropboxConnector', function() {
.then(() => {
connector.readdir(session, 'unifile_rename3').should.be.fulfilled;
})
.then(() => connector.rmdir(session, 'unifile_rename3'));
.then(() => connector.rename(session, 'unifile_rename3', 'unifile_rename2'));
});

it('renames a folder with special char', function() {
return connector.rename(session, 'unifile_rename2', 'unifile_renameéà')
.then(() => {
connector.readdir(session, 'unifile_renameéà').should.be.fulfilled;
})
.then(() => connector.rename(session, 'unifile_renameéà', 'unifile_rename2'));
});

it('renames a file', function() {
Expand All @@ -651,12 +705,35 @@ describe('DropboxConnector', function() {
})
.then((content) => {
return expect(content.toString()).to.equal(data);
})
.then((content) => {
return connector.rename(session, 'unifile_rename/fileB.txt', 'unifile_rename/file1.txt');
});
});

it('renames a file with special chars', function() {
return connector.rename(session, 'unifile_rename/file1.txt', 'unifile_rename/fileéà.txt')
.then(() => {
return connector.readFile(session, 'unifile_rename/file1.txt').should.be.rejectedWith('Not Found');
})
.then(() => {
return connector.readFile(session, 'unifile_rename/fileéà.txt');
})
.then((content) => {
return expect(content.toString()).to.equal(data);
})
.then((content) => {
return connector.rename(session, 'unifile_rename/fileéà.txt', 'unifile_rename/file1.txt');
});
});

after('Remove folder', function() {
if(isEnvValid()) connector.rmdir(session, 'unifile_rename');
else this.skip();
if(isEnvValid()) {
return Promise.all([
connector.rmdir(session, 'unifile_rename'),
connector.rmdir(session, 'unifile_rename2')
]);
} else this.skip();
});
});

Expand All @@ -668,7 +745,8 @@ describe('DropboxConnector', function() {
return connector.setAccessToken(session, process.env.DROPBOX_TOKEN)
.then(() => connector.batch(session, [
{name: 'mkdir', path: 'unifile_unlink'},
{name: 'writeFile', path: 'unifile_unlink/file1.txt', content: 'lorem ipsum'}
{name: 'writeFile', path: 'unifile_unlink/file1.txt', content: 'lorem ipsum'},
{name: 'writeFile', path: 'unifile_unlink/fileéà.txt', content: 'lorem ipsum'}
]));
} else this.skip();
});
Expand All @@ -688,6 +766,13 @@ describe('DropboxConnector', function() {
});
});

it('deletes a file with special chars', function() {
return connector.unlink(session, 'unifile_unlink/fileéà.txt')
.then((content) => {
return expect(connector.readFile(session, 'unifile_unlink/fileéà.txt')).to.be.rejectedWith('Not Found');
});
});

after('Remove folder', function() {
if(isEnvValid()) connector.rmdir(session, 'unifile_unlink');
else this.skip();
Expand All @@ -701,7 +786,8 @@ describe('DropboxConnector', function() {
connector = new DropboxConnector(authConfig);
return connector.setAccessToken(session, process.env.DROPBOX_TOKEN)
.then(() => connector.batch(session, [
{name: 'mkdir', path: 'unifile_rmdir'}
{name: 'mkdir', path: 'unifile_rmdir'},
{name: 'mkdir', path: 'unifile_rmdiréà'}
]));
} else this.skip();
});
Expand All @@ -717,13 +803,23 @@ describe('DropboxConnector', function() {
it('deletes a folder', function() {
return connector.rmdir(session, 'unifile_rmdir')
.then((content) => {
return expect(connector.readdir(session, 'unifile_rmdir2')).to.be.rejectedWith('Not Found');
return expect(connector.readdir(session, 'unifile_rmdir')).to.be.rejectedWith('Not Found');
});
});

it('deletes a folder with special chars', function() {
return connector.rmdir(session, 'unifile_rmdiréà')
.then((content) => {
return expect(connector.readdir(session, 'unifile_rmdiréà')).to.be.rejectedWith('Not Found');
});
});

after('Remove folder', function() {
if(isEnvValid()) connector.rmdir(session, 'unifile_rmdir')
.then(() => connector.rmdir(session, 'unifile_rmdir3'));
if(isEnvValid())
return Promise.all([
connector.rmdir(session, 'unifile_rmdir'),
connector.rmdir(session, 'unifile_rmdiréà')
]).catch(() => {});
else this.skip();
});
});
Expand All @@ -734,17 +830,17 @@ describe('DropboxConnector', function() {
const content = 'lorem ipsum';
const creation = [
{name: 'mkdir', path: 'tmp'},
{name: 'mkdir', path: 'tmp/test'},
{name: 'writeFile', path: 'tmp/test/a', content},
{name: 'mkdir', path: 'tmp/testé'},
{name: 'writeFile', path: 'tmp/test/', content},
{name: 'mkdir', path: 'tmp/test/dir'},
{name: 'rename', path: 'tmp/test/a', destination: 'tmp/test/b'},
{name: 'rename', path: 'tmp/test/', destination: 'tmp/test/'},
{name: 'mkdir', path: 'tmp2'},
{name: 'rename', path: 'tmp2', destination: 'tmp3'}
];
const destruction = [
{name: 'rmdir', path: 'tmp3'},
{name: 'unlink', path: 'tmp/test/b'},
{name: 'rmdir', path: 'tmp/test'},
{name: 'unlink', path: 'tmp/test/'},
{name: 'rmdir', path: 'tmp/testé'},
{name: 'rmdir', path: 'tmp'}
];

Expand Down Expand Up @@ -797,7 +893,7 @@ describe('DropboxConnector', function() {
return connector.batch(session, creation)
.then(() => {
return Promise.all([
connector.readFile(session, 'tmp/test/b'),
connector.readFile(session, 'tmp/test/'),
expect(connector.readdir(session, 'tmp3')).to.be.fulfilled
]);
})
Expand Down
2 changes: 1 addition & 1 deletion test/unifile-fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function createDefaultConnector() {
return new FsConnector({sandbox: [Os.homedir(), Os.tmpdir()]});
}

describe.only('FsConnector', function() {
describe('FsConnector', function() {
describe('constructor', function() {
it('create a new instance with empty config', function() {
const connector = new FsConnector();
Expand Down

0 comments on commit 55a7691

Please sign in to comment.