UNPKG

34.9 kBJavaScriptView Raw
1'use strict';
2
3const Url = require('url');
4const {Writable, Transform, PassThrough} = require('stream');
5
6const request = require('request');
7const Promise = require('bluebird');
8const Mime = require('mime');
9
10const Tools = require('unifile-common-tools');
11
12const NAME = 'github';
13const SERVICE_HOST = 'github.com';
14const APP_PERMISSION = 'scope=repo,delete_repo,user';
15
16const {UnifileError, BatchError} = require('./error.js');
17
18/*
19 * Remove first '/', split the path and remove empty tokens
20 * @param {String} path - Path to split
21 * @return {Array<String>} an array with path levels as elements
22 * @private
23 */
24function getPathTokens(path) {
25 const cleanPath = path.startsWith('/') ? path.substr(1) : path;
26 return cleanPath.split('/').filter((s) => s !== '');
27}
28
29/**
30 * Handle GitHub pagination
31 * @param {Object} reqOptions - Options to pass to the request. Url will be overidden
32 * @param {string} link - Link header
33 * @param {Object[]} memo - Aggregator of result
34 * @return {Promise} a Promise of aggregated result
35 * @private
36 */
37function paginate(reqOptions, link, memo) {
38 const links = link.split(/,\s*/);
39 let matches;
40 links.some(function(link) {
41 matches = link.trim().match(/<(.+)>;\s*rel="next"/);
42 return matches !== null;
43 });
44 // End of pagination
45 if(!matches) {
46 return Promise.resolve(memo);
47 }
48 return new Promise(function(resolve, reject) {
49 reqOptions.url = matches[1];
50 request(reqOptions, function(err, res, body) {
51 paginate(reqOptions, res.headers.link, memo.concat(JSON.parse(body))).then(resolve);
52 });
53 });
54}
55
56
57/**
58 * Move a folder or a file in a branch by transforming the given tree
59 * @param {string} src - Source path relative to branch root
60 * @param {string} dest - Destination path relative to branch root
61 * @param {Object} treeRes[].tree - Commit tree
62 * @param {string} [branch=master] - Branch containing file/folder
63 * @private
64 */
65function move(src, dest, treeRes) {
66 return treeRes.tree.map(function(file) {
67 const regex = new RegExp('^' + src + '$|^' + src + '(/)');
68 // Overrides file path
69 return Object.assign({}, file, {path: file.path.replace(regex, dest + '$1')});
70 });
71}
72
73/**
74 * Remove a file/folder by transforming the given tree
75 * @param {string} path - Path to the file/folder to delete
76 * @param {Object} treeRes - Result of a GET request to the tree API
77 * @param {Object} treeRes[].tree - Commit tree
78 * @param {string} [branch=master] - Branch containing file/folder
79 * @private
80 */
81function removeFile(path, treeRes, done) {
82 const regex = new RegExp('^' + path + '$|^' + path + '(/)');
83 const filteredTree = treeRes.tree.filter(function(file) {
84 return !regex.test(file.path);
85 });
86 if(filteredTree.length === treeRes.tree.length)
87 done(new UnifileError(UnifileError.ENOENT, 'Not Found'));
88 else done(null, filteredTree);
89}
90
91const createBranch = Symbol('createBranch');
92const commit = Symbol('commit');
93const transformTree = Symbol('transformTree');
94const createBlob = Symbol('createBlob');
95const assignSessionAccount = Symbol('assignSessionAccount');
96const commitBlob = Symbol('commitBlob');
97const callAPI = Symbol('callAPI');
98
99/**
100 * Service connector for {@link https://github.com|GitHub} plateform.
101 *
102 * This will need a registered GitHub application with valid redirection for your server.
103 * You can register a new application {@link https://github.com/settings/applications/new|here} and
104 * learn more about GitHub OAuth Web application flow
105 * {@link https://developer.github.com/v3/oauth/#web-application-flow|here}
106 */
107class GitHubConnector {
108 /**
109 * @constructor
110 * @param {Object} config - Configuration object
111 * @param {string} config.clientId - GitHub application client ID
112 * @param {string} config.clientSecret - GitHub application client secret
113 * @param {string} [config.redirectUri] - GitHub application redirect URI.
114 * You still need to register it in your GitHub App
115 * @param {string} [config.name=github] - Name of the connector
116 * @param {string} [config.serviceHost=github.com] - Hostname of the service
117 * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
118 */
119 constructor(config) {
120 if(!config || !config.clientId || !config.clientSecret)
121 throw new Error('Invalid configuration. Please refer to the documentation to get the required fields.');
122 this.clientId = config.clientId;
123 this.clientSecret = config.clientSecret;
124 this.serviceHost = config.serviceHost || SERVICE_HOST;
125 this.oauthCallbackUrl = `https://${this.serviceHost}/login/oauth`;
126 this.redirectUri = config.redirectUri || null;
127
128 this.infos = Tools.mergeInfos(config.infos, {
129 name: NAME,
130 displayName: 'GitHub',
131 icon: '../assets/github.png',
132 description: 'Edit files from your GitHub repository.'
133 });
134
135 this.name = this.infos.name;
136 }
137
138 getInfos(session) {
139 return Object.assign({
140 isLoggedIn: !!(session && ('token' in session) || ('basic' in session)),
141 isOAuth: true,
142 username: (session && session.account) ? session.account.display_name : undefined
143 }, this.infos);
144 }
145
146 login(session, loginInfos) {
147 // Authenticated URL
148 if(loginInfos.constructor === String) {
149 const url = Url.parse(loginInfos);
150 if(!url.auth)
151 return Promise.reject(new UnifileError(
152 UnifileError.EACCES,
153 'Invalid URL. You must provide authentication: http://user:pwd@host'));
154 this.serviceHost = url.host || this.serviceHost;
155 return this.setAccessToken(session, `Basic ${new Buffer(url.auth).toString('base64')}`);
156
157 // Basic auth
158 } else if('user' in loginInfos && 'password' in loginInfos) {
159 const auth = new Buffer(loginInfos.user + ':' + loginInfos.password).toString('base64');
160 return this.setAccessToken(session, `Basic ${auth}`);
161
162 // OAuth
163 } else if('state' in loginInfos && 'code' in loginInfos) {
164 if(loginInfos.state !== session.state)
165 return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid request (cross-site request)'));
166
167 return new Promise((resolve, reject) => {
168 request({
169 url: this.oauthCallbackUrl + '/access_token',
170 method: 'POST',
171 body: {
172 client_id: this.clientId,
173 client_secret: this.clientSecret,
174 code: loginInfos.code,
175 state: session.state
176 },
177 json: true
178 }, function(err, response, body) {
179 if(err) reject(new UnifileError(UnifileError.EINVAL, 'Error while calling GitHub API. ' + err));
180 else if(response.statusCode >= 400 || 'error' in body)
181 reject(new UnifileError(UnifileError.EACCES, 'Unable to get access token. Please check your credentials.'));
182 else resolve(body.access_token);
183 });
184 })
185 .then((token) => {
186 return this.setAccessToken(session, `token ${token}`);
187 });
188 } else {
189 return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid credentials'));
190 }
191 }
192
193 setAccessToken(session, token) {
194 // Check if token is a valid OAuth or Basic token
195 if(!token.startsWith('token ') && !token.startsWith('Basic '))
196 return Promise.reject(new UnifileError(
197 UnifileError.EACCES,
198 'Invalid token. It must start with either "token" or "Basic".'));
199 // Create a copy to only set the true token when we know it's the good one
200 const sessionCopy = Object.assign({}, session);
201 sessionCopy.token = token;
202 return this[callAPI](sessionCopy, '/user', null, 'GET')
203 .then(this[assignSessionAccount].bind(undefined, session))
204 .then(() => {
205 session.token = sessionCopy.token;
206 return session.token;
207 })
208 .catch((e) => {
209 // Override default error message ('Requires authentication')
210 if(e.code === UnifileError.EACCES) throw new UnifileError(401, 'Bad credentials');
211 });
212 }
213
214 clearAccessToken(session) {
215 Tools.clearSession(session);
216 return Promise.resolve(session);
217 }
218
219 getAuthorizeURL(session) {
220 // Generate a random string for the state
221 session.state = (+new Date() * Math.random()).toString(36).replace('.', '');
222 return Promise.resolve(this.oauthCallbackUrl
223 + '/authorize?' + APP_PERMISSION
224 + '&client_id=' + this.clientId
225 + '&state=' + session.state
226 + (this.redirectUri ? '&redirect_uri=' + this.redirectUri : ''));
227 }
228
229 //Filesystem commands
230
231 readdir(session, path) {
232 const splitPath = getPathTokens(path);
233 let resultPromise;
234 let apiPath;
235 switch (splitPath.length) {
236 case 0: // List repos
237 resultPromise = this[callAPI](session, '/user/repos', {affiliation: 'owner'}, 'GET')
238 .then(function(res) {
239 return res.map(function(item) {
240 return {
241 size: item.size,
242 modified: item.updated_at,
243 name: item.name,
244 isDir: true,
245 mime: 'application/git-repo'
246 };
247 });
248 });
249 break;
250 case 1: // List all branches
251 apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/branches';
252 resultPromise = this[callAPI](session, apiPath, null, 'GET')
253 .map((item) => {
254 return this[callAPI](session, Url.parse(item.commit.url).path, null, 'GET')
255 .then(function(result) {
256 return result.commit.author.date;
257 })
258 .then(function(date) {
259 return {
260 size: 'N/A',
261 modified: date,
262 name: item.name,
263 isDir: true,
264 mime: 'application/git-branch'
265 };
266 });
267 });
268 break;
269 default: // List files of one branch
270 apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
271 const filePath = splitPath.slice(2).join('/');
272 const reqData = {
273 ref: splitPath[1]
274 };
275 resultPromise = this[callAPI](session, apiPath + '/contents/' + filePath, reqData, 'GET')
276 .map((item) => {
277 return this[callAPI](session, apiPath + '/commits', {path: item.path, sha: splitPath[1]}, 'GET')
278 .then(function(commits) {
279 const isDir = item.type === 'dir';
280 return {
281 size: item.size,
282 modified: commits[0].commit.author.date,
283 name: item.name,
284 isDir: isDir,
285 mime: isDir ? 'application/directory' : Mime.getType(item.name)
286 };
287 });
288 });
289 }
290
291 return resultPromise;
292 }
293
294 stat(session, path) {
295 const splitPath = getPathTokens(path);
296 let resultPromise;
297 let apiPath;
298 switch (splitPath.length) {
299 case 0: resultPromise = Promise.reject(new UnifileError(UnifileError.EINVAL, 'You must provide a path to stat'));
300 break;
301 case 1: // Get repo stat
302 apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
303 resultPromise = this[callAPI](session, apiPath, null, 'GET')
304 .then(function(repo) {
305 return {
306 size: repo.size,
307 modified: repo.updated_at,
308 name: repo.name,
309 isDir: true,
310 mime: 'application/git-repo'
311 };
312 });
313 break;
314 case 2: // Get branch stat
315 apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/branches/' + splitPath[1];
316 resultPromise = this[callAPI](session, apiPath, null, 'GET')
317 .then(function(branch) {
318 return {
319 size: 'N/A',
320 modified: branch.commit.commit.author.date,
321 name: branch.name,
322 isDir: true,
323 mime: 'application/git-branch'
324 };
325 });
326 break;
327 default: // Get a content stat
328 apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
329 const filePath = splitPath.slice(2).join('/');
330 const reqData = {
331 ref: splitPath[1]
332 };
333 resultPromise = this[callAPI](session, apiPath + '/contents/' + filePath, reqData, 'GET')
334 .then((stat) => {
335 if(Array.isArray(stat)) {
336 return {
337 size: 'N/A',
338 modified: 'N/A',
339 name: filePath.split('/').pop(),
340 isDir: true,
341 mime: 'application/directory'
342 };
343 } else {
344 return this[callAPI](session, apiPath + '/commits', {path: stat.path, sha: splitPath[1]}, 'GET')
345 .then(function(commit) {
346 return {
347 size: stat.size,
348 modified: commit[0].commit.author.date,
349 name: stat.name,
350 isDir: false,
351 mime: Mime.getType(stat.name)
352 };
353 });
354 }
355 });
356 }
357
358 return resultPromise;
359 }
360
361 mkdir(session, path) {
362 const splitPath = getPathTokens(path);
363 let reqData = null;
364 let apiPath;
365 switch (splitPath.length) {
366 case 0: // Error
367 return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot create dir with an empty name.'));
368 case 1: // Create a repo
369 apiPath = '/user/repos';
370 reqData = {
371 name: splitPath[0],
372 auto_init: true
373 };
374 return this[callAPI](session, apiPath, reqData, 'POST')
375 // Renames default README to a more discreet .gitkeep
376 .then(() => this.rename(session, path + '/master/README.md', path + '/master/.gitkeep'));
377 case 2: // Create a branch
378 return this[createBranch](session, splitPath[0], splitPath[1]);
379 default: // Create a folder (with a .gitkeep file in it because git doesn't track empty folder)
380 const filePath = splitPath.slice(2).join('/');
381
382 apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/contents/' + filePath;
383 reqData = {
384 message: 'Create ' + filePath,
385 content: new Buffer('').toString('base64'),
386 branch: splitPath[1]
387 };
388 return this[callAPI](session, apiPath + '/.gitkeep', reqData, 'PUT')
389 .catch((err) => {
390 if(err.message.startsWith('Invalid request')) throw new Error('Reference already exists');
391 else throw err;
392 });
393 }
394 }
395
396 writeFile(session, path, data) {
397 const splitPath = getPathTokens(path);
398 if(splitPath.length < 3) {
399 return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'This folder can only contain folders.'));
400 }
401 return this[createBlob](session, splitPath[0], data)
402 .then((blob) => this[commitBlob](session, splitPath, blob));
403 }
404
405 createWriteStream(session, path) {
406 const splitPath = getPathTokens(path);
407 if(splitPath.length < 3) {
408 const stream = new PassThrough();
409 process.nextTick(() => {
410 stream.emit('error', new UnifileError(UnifileError.ENOTSUP, 'This folder can only contain folders.'));
411 });
412 return stream;
413 }
414
415 const apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/git/blobs';
416 // This will encapsulate the raw content into an acceptable Blob request
417 const transformer = new Transform({
418 transform(chunk, encoding, callback) {
419 if(this.first) {
420 this.push('{"encoding": "base64", "content": "');
421 this.first = false;
422 }
423 callback(null, chunk.toString('base64'));
424 },
425 flush(callback) {
426 this.push('"}');
427 callback(null);
428 }
429 });
430 transformer.first = true;
431
432 // Make the request and pipe the transformer as input
433 const stream = this[callAPI](session, apiPath, {}, 'POST', true);
434 transformer.pipe(stream);
435
436 // Catch Blob request response
437 const chunks = [];
438 const aggregator = new Writable({
439 write(chunk, encoding, callback) {
440 chunks.push(chunk);
441 callback(null);
442 }
443 });
444 stream.pipe(aggregator);
445
446 // Now commit the blob with the full response
447 aggregator.on('finish', () => {
448 this[commitBlob](session, splitPath, JSON.parse(Buffer.concat(chunks).toString()))
449 .then(() => {
450 transformer.emit('close');
451 });
452 });
453
454 return transformer;
455 }
456
457 readFile(session, path, isStream = false) {
458 const splitPath = getPathTokens(path);
459 if(!isStream && splitPath.length < 3) {
460 return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'This folder only contain folders.'));
461 }
462 const apiPath = '/repos/' + session.account.login
463 + '/' + splitPath[0] + '/contents/'
464 + splitPath.slice(2).join('/');
465
466 var promise = this[callAPI](session, apiPath, {ref: splitPath[1]}, 'GET', isStream);
467 if(isStream) return promise;
468 else {
469 return promise.then(function(res) {
470 if(res.type === 'file') {
471 return Buffer.from(res.content, res.encoding);
472 } else {
473 return Promise.reject(new UnifileError(UnifileError.EISDIR, 'Path is a directory.'));
474 }
475 });
476 }
477 }
478
479 createReadStream(session, path) {
480 function extract(data, idx, token) {
481 return data.substr(idx + token.length).split('"')[0];
482 }
483
484 const transformer = new Transform({
485 transform(chunk, encoding, callback) {
486 const data = chunk.toString();
487 if(this.isContent) {
488 // return all the content until a " shows up
489 callback(null, data.split('"')[0]);
490 } else {
491 // TODO better start detection
492 let idx;
493 if((idx = data.indexOf(this.contentToken)) > -1) {
494 this.isContent = true;
495 // Content detected, returns it until "
496 callback(null, Buffer.from(extract(data, idx, this.contentToken), 'base64').toString());
497 } else if((idx = data.indexOf(this.errorToken)) > -1) {
498 // Request errored
499 this.emit('error', new Error(extract(data, idx, this.errorToken)));
500 } else {
501 // Drop content
502 callback(null);
503 }
504 }
505 }
506 });
507 transformer.isContent = false;
508 transformer.contentToken = 'content":"';
509 transformer.errorToken = 'message":"';
510 return this.readFile(session, path, true)
511 .pipe(transformer);
512 }
513
514 rename(session, src, dest) {
515 const splitPath = getPathTokens(src);
516 if(!dest) return Promise.reject(new Error('Cannot rename path with an empty destination'));
517 const splitPathDest = getPathTokens(dest);
518 let apiPath;
519 switch (splitPath.length) {
520 case 0: // Error
521 return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot rename path with an empty name.'));
522 case 1: // Rename repo
523 apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
524 const reqData = {name: dest};
525 return this[callAPI](session, apiPath, reqData, 'PATCH');
526 case 2: // Rename branch (actually copy src to dest then remove src)
527 apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/git/refs/heads/';
528 return this[createBranch](session, splitPath[0], splitPathDest[1], splitPath[1])
529 .then(() => {
530 return this[callAPI](session, apiPath + splitPath[1], null, 'DELETE');
531 });
532 default: // Rename a file/folder
533 const fileSrc = splitPath.slice(2).join('/');
534 const fileDest = splitPathDest.slice(2).join('/');
535 return this[transformTree](session, splitPath[0], (tree, done) => done(null, move(fileSrc, fileDest, tree)),
536 'Move ' + fileSrc + ' to ' + fileDest, splitPath[1]);
537 }
538 }
539
540 unlink(session, path) {
541 if(!path) return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot remove path with an empty name.'));
542 const splitPath = getPathTokens(path);
543 if(splitPath.length < 3)
544 return Promise.reject(new UnifileError(UnifileError.EISDIR, 'Path is a folder. Use rmdir()'));
545
546 const filePath = splitPath.slice(2).join('/');
547 return this[transformTree](session, splitPath[0], removeFile.bind(undefined, filePath),
548 'Remove ' + filePath, splitPath[1]);
549 }
550
551 rmdir(session, path) {
552 const splitPath = getPathTokens(path);
553 const repoPath = '/repos/' + session.account.login + '/' + splitPath[0];
554 switch (splitPath.length) {
555 case 0: // Error
556 return Promise.reject(new UnifileError(UnifileError.INVAL, 'Cannot remove path with an empty name.'));
557 case 1: // Remove repo
558 return this[callAPI](session, repoPath, null, 'DELETE');
559 case 2: // Remove branch
560 return this[callAPI](session, repoPath + '/branches', null, 'GET')
561 .then((branches) => {
562 if(branches.length > 1)
563 return this[callAPI](session, repoPath + '/git/refs/heads/' + splitPath[1], null, 'DELETE');
564 else {
565 throw new UnifileError(UnifileError.INVAL, 'You cannot leave this folder empty.');
566 }
567 });
568 default: // Remove file/folder
569 const path = splitPath.slice(2).join('/');
570 return this[transformTree](session, splitPath[0], removeFile.bind(undefined, path),
571 'Remove ' + path, splitPath[1]);
572 }
573 }
574
575 batch(session, actions, message) {
576 let actionsChain = Promise.resolve();
577 // Filter invalid batch actions
578 const actionQueue = actions.slice()
579 .filter((action) => ['rmdir', 'unlink', 'mkdir', 'writefile', 'rename'].indexOf(action.name.toLowerCase()) > -1);
580 while(actionQueue.length > 0) {
581 const action = actionQueue.shift();
582 const splitPath = getPathTokens(action.path);
583 switch (splitPath.length) {
584 case 0: return Promise.reject(new BatchError(
585 UnifileError.EINVAL,
586 'Cannot execute batch action without a path'));
587 case 1:
588 case 2:
589 const actionName = action.name.toLowerCase();
590 switch (actionName) {
591 case 'rmdir':
592 case 'mkdir':
593 actionsChain = actionsChain.then(() => this[actionName](session, action.path))
594 .catch((err) => err.name !== 'BatchError',
595 (err) => {throw new Error(`Could not complete action ${actionName}: ${err.message}`);});
596 break;
597 case 'writefile':
598 return Promise.reject(new UnifileError(
599 UnifileError.ENOTSUP,
600 `Could not complete action ${actionName}: Cannot create file here.`));
601 case 'rename':
602 if(!action.destination)
603 return Promise.reject(new BatchError(
604 UnifileError.EINVAL,
605 'Rename actions should have a destination'));
606 actionsChain = actionsChain.then(() => this.rename(session, action.path, action.destination))
607 .catch((err) => err.name !== 'BatchError',
608 (err) => {
609 throw new BatchError(
610 UnifileError.EINVAL,
611 `Could not complete action ${actionName}: ${err.message}`);
612 });
613 break;
614 default:
615 console.warn(`Unsupported batch action on repo/branch: ${actionName}`);
616 }
617 break;
618 default:
619 const fileActions = [action];
620 // Get all the file action on this branch to group in a commit
621 let sameBranch = true;
622 while(actionQueue.length > 0 && sameBranch) {
623 const nextSplitPath = getPathTokens(actionQueue[0].path);
624 const [lastRepo, lastBranch] = getPathTokens(action.path);
625 sameBranch = nextSplitPath.length > 2 && lastRepo === nextSplitPath[0] && lastBranch === nextSplitPath[1];
626 if(sameBranch) fileActions.push(actionQueue.shift());
627 }
628
629 actionsChain = actionsChain.then(() => this[transformTree](session, splitPath[0], (treeRes, done) => {
630 const newTrees = {};
631 const blobsWaiting = [];
632
633 for(const currentAction of fileActions) {
634 const path = getPathTokens(currentAction.path).slice(2).join('/');
635 switch (currentAction.name.toLowerCase()) {
636 case 'unlink':
637 case 'rmdir':
638 treeRes.tree = removeFile(path, treeRes, done);
639 break;
640 case 'rename':
641 if(!currentAction.destination)
642 return new Promise.reject(new UnifileError(
643 UnifileError.EINVAL,
644 'Rename actions should have a destination'));
645 const src = path;
646 const dest = getPathTokens(currentAction.destination).slice(2).join('/');
647 treeRes.tree = move(src, dest, treeRes);
648 const re = new RegExp(`(^|/)${src}($|/)`);
649 blobsWaiting.forEach((blob) => blob.path = blob.path.replace(re, `$1${dest}$2`));
650 break;
651 case 'mkdir':
652 newTrees[path] = {
653 tree: [],
654 blobs: []
655 };
656 break;
657 case 'writefile':
658 if(!currentAction.content)
659 return new Promise.reject(new UnifileError(
660 UnifileError.EINVAL,
661 'WriteFile actions should have a content'));
662 if(path.includes('/')) {
663 // We'll need a subtree
664 // Check existing ones with the longest path matching this file parent
665 let closestParent = Object.keys(newTrees).filter((p) => path.includes(p)).sort().pop();
666 if(!closestParent) {
667 // If none, create one
668 closestParent = path.split('/').slice(0, -1).join('/');
669 newTrees[closestParent] = {
670 tree: [],
671 blobs: []
672 };
673 }
674 newTrees[closestParent].blobs.push(this[createBlob](session, splitPath[0], currentAction.content));
675 newTrees[closestParent].tree.push({
676 path: path.replace(closestParent + '/', ''),
677 mode: '100644',
678 type: 'blob'
679 });
680 } else {
681 treeRes.tree = treeRes.tree.filter((node) => node.path !== path);
682 const newNode = {
683 path: path,
684 mode: '100644',
685 type: 'blob'
686 };
687 if(Buffer.isBuffer(currentAction.content)) {
688 blobsWaiting.push({
689 path,
690 blob: this[createBlob](session, splitPath[0], currentAction.content)
691 });
692 } else newNode.content = currentAction.content;
693 treeRes.tree.push(newNode);
694 }
695 break;
696 default:
697 console.warn(`Unsupported batch action: ${currentAction.name}`);
698 }
699 }
700
701 const treesToPlant = [];
702 Object.keys(newTrees).forEach((path) => {
703 if(newTrees[path].tree.length > 0)
704 treesToPlant.push(path);
705 else treeRes.tree.push({
706 path: path + '/.gitkeep',
707 mode: '100644',
708 type: 'blob',
709 content: ''
710 });
711 });
712 return Promise.all(blobsWaiting.map((b) => b.blob))
713 .then((blobShas) => {
714 blobShas.forEach((sha, idx) => {
715 treeRes.tree
716 .find((n) => n.path === blobsWaiting[idx].path).sha = sha.sha;
717 });
718 return Promise.all(treesToPlant.map((treePath) => {
719 const tree = {tree: newTrees[treePath].tree};
720 const apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
721 return Promise.all(newTrees[treePath].blobs)
722 .then((shas) => {
723 tree.tree.forEach((t, index) => t.sha = shas[index].sha);
724 return this[callAPI](session, apiPath + '/git/trees', tree, 'POST');
725 })
726 .then((t) => {
727 treeRes.tree = treeRes.tree.filter((node) => node.path !== treePath);
728 treeRes.tree.push({
729 path: treePath,
730 type: 'tree',
731 mode: '040000',
732 sha: t.sha
733 });
734 });
735 }))
736 .then(() => {
737 done(null, treeRes.tree);
738 })
739 .catch((e) => {
740 done(new Error(`Could not create a new tree ${e}`));
741 });
742 })
743 .catch((e) => {
744 done(new Error(`Could not create a new blob ${e}`));
745 });
746 }, message || 'Batch update', splitPath[1]))
747 .catch((err) => err.name !== 'BatchError', (err) => {
748 throw new BatchError(UnifileError.EIO, `Error while batch: ${err.message}`);
749 });
750 }
751 }
752 return actionsChain;
753 }
754
755 // Internals
756
757 /**
758 * Create a branch with the given parameters
759 * @param {GHSession} session - GH session
760 * @param {string} repo - Repository name where to create the branch
761 * @param {string} branchName - Name for the newly created branch
762 * @param {string} [fromBranch] - Branch to start the new branch from. Default to the default_branch of the repo
763 * @return {Promise} a Promise of the API call result
764 * @private
765 */
766 [createBranch](session, repo, branchName, fromBranch) {
767 const apiPath = '/repos/' + session.account.login + '/' + repo + '/git/refs';
768 return this[callAPI](session, apiPath + '/heads', null, 'GET')
769 .then((res) => {
770 const reqData = {
771 ref: 'refs/heads/' + branchName
772 };
773 if(!fromBranch) reqData.sha = res[0].object.sha;
774 else {
775 const origin = res.filter(function(branch) {
776 return branch.ref === 'refs/heads/' + fromBranch;
777 })[0];
778 if(!origin) throw new Error('Unknown branch origin ' + fromBranch);
779 reqData.sha = origin.object.sha;
780 }
781 return this[callAPI](session, apiPath, reqData, 'POST');
782 });
783 }
784
785 /**
786 * Create and push a commit
787 * @param {GHSession} session - GH session
788 * @param {string} repo - Name of the repository to commit
789 * @param {Object[]} tree - Array of objects to commit
790 * @param {string} tree[].path - Full path to the file to modify
791 * @param {string} tree[].mode - Object mode (100644 for files)
792 * @param {string} tree[].type - Object type (blob/commit/tree)
793 * @param {string} [tree[].content] - Content to put into file. If set, sha will be ignored
794 * @param {string} [tree[].sha] - Sha of the object to put in the tree. Will be ignored if content is set
795 * @param {string} message - Message of the commit
796 * @param {string} [branch=master] - Branch containing the tree
797 * @return {Promise} a Promise of the server response
798 *
799 * @see {@link https://developer.github.com/v3/git/trees/#create-a-tree|Create a tree}
800 * @private
801 * */
802 [commit](session, repo, tree, message, branch) {
803 const apiPath = '/repos/' + session.account.login + '/' + repo + '/git';
804 let lastCommitSha;
805
806 // Get branch head
807 return this[callAPI](session, apiPath + '/refs/heads/' + branch, null, 'GET')
808 .then((res) => {
809 lastCommitSha = res.object.sha;
810 // Get last commit info
811 return this[callAPI](session, apiPath + '/commits/' + lastCommitSha, null, 'GET');
812 })
813 .then((res) => {
814 const data = {
815 base_tree: res.tree.sha,
816 tree: tree
817 };
818 // Create a new tree
819 return this[callAPI](session, apiPath + '/trees', data, 'POST');
820 })
821 .then((res) => {
822 const data = {
823 parents: [lastCommitSha],
824 tree: res.sha,
825 message: message
826 };
827 // Create a new commit with the new tree
828 return this[callAPI](session, apiPath + '/commits', data, 'POST');
829 })
830 .then((res) => {
831 const data = {
832 sha: res.sha
833 };
834 // Update head
835 return this[callAPI](session, apiPath + '/refs/heads/' + branch, data, 'PATCH');
836 });
837 }
838
839
840 /**
841 * Transform the git tree and commit the transformed tree
842 * @param {GHSession} session - GH session
843 * @param {string} repo - Name of the repository to commit
844 * @param {Function} transformer - Function to apply on tree. Get the tree as first param and wait for an array in the callback.
845 * @param {string} message - Commit message for the new tree
846 * @param {string} [branch=master] - Branch containing the tree
847 * @return {Promise} a Promise of the server response
848 *
849 * @see {@link https://developer.github.com/v3/git/trees/#create-a-tree|Create a tree}
850 * @private
851 */
852 [transformTree](session, repo, transformer, message, branch = 'master') {
853 let lastCommitSha;
854 const apiPath = '/repos/' + session.account.login + '/' + repo;
855 return this[callAPI](session, apiPath + '/git/refs/heads/' + branch, null, 'GET')
856 .then((head) => {
857 lastCommitSha = head.object.sha;
858 return this[callAPI](session, apiPath + '/git/trees/' + head.object.sha, {recursive: 1}, 'GET');
859 })
860 .then((res) => {
861 return new Promise((resolve, reject) => {
862 transformer(res, (err, result) => {
863 if(err && err instanceof Error) reject(err);
864 else resolve(result);
865 });
866 });
867 })
868 .then((tree) => {
869 if(Array.isArray(tree) && tree.length > 0) {
870 return this[callAPI](session, apiPath + '/git/trees', {tree: tree}, 'POST');
871 } else if(Array.isArray(tree)) {
872 return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'You can not leave this folder empty.'));
873 } else {
874 return Promise.reject(new UnifileError(
875 UnifileError.EIO,
876 'Invalid tree transformation. Transformer must return an array.'));
877 }
878 })
879 .then((newTree) => {
880 const data = {
881 parents: [lastCommitSha],
882 tree: newTree.sha,
883 message: message
884 };
885 return this[callAPI](session, apiPath + '/git/commits', data, 'POST');
886 })
887 .then((res) => {
888 const data = {
889 sha: res.sha
890 };
891 return this[callAPI](session, apiPath + '/git/refs/heads/' + branch, data, 'PATCH');
892 });
893 }
894
895 /**
896 * Create a blob in the designated repository
897 * @param {Object} session - GitHub session storage
898 * @param {string} repoName - Name of the repository where to create the blob
899 * @param {string|Buffer} content - Content of the blob
900 * @return {Promise} a promise of result for the blob creation
901 *
902 * @see {@link https://developer.github.com/v3/git/blobs/#create-a-blob|Create a blob}
903 * @private
904 */
905 [createBlob](session, repoName, content) {
906 const buffer = Buffer.isBuffer(content) ? content : new Buffer(content);
907 const apiPath = '/repos/' + session.account.login + '/' + repoName + '/git/blobs';
908 return this[callAPI](session, apiPath, {
909 content: buffer.toString('base64'),
910 encoding: 'base64'
911 }, 'POST');
912 }
913
914 /**
915 * Fetch the account information on the service and map them to the session
916 * @param {Object} session - GH session
917 * @param {Object} account - GH account
918 * @return {Promise<null>} an empty promise
919 * @private
920 */
921 [assignSessionAccount](session, account) {
922 session.account = {
923 display_name: account.name,
924 login: account.login,
925 num_repos: account.public_repos
926 };
927 }
928
929 /**
930 * Commit a blob to the given repo, branch and path
931 * @param {Object} session - GH session
932 * @param {string[]} splitPath - Path tokens containing repo/branch/path
933 * @param {Object} blob - Blob return by the blob creation route
934 * @private
935 */
936 [commitBlob](session, splitPath, blob) {
937 const path = splitPath.slice(2).join('/');
938 return this[commit](session, splitPath[0], [{
939 path: path,
940 sha: blob.sha,
941 mode: '100644',
942 type: 'blob'
943 }], 'Create ' + path, splitPath[1]);
944 }
945
946 /**
947 * Make a call to the GitHub API
948 * @param {Object} session - GitHub session storage
949 * @param {string} path - End point path
950 * @param {Object} data - Data to pass. Convert to querystring if method is GET or to the request body
951 * @param {string} method - HTTP verb to use
952 * @param {boolean} isStream - Access the API as a stream or not
953 * @param {boolean} retry - Allow the request to retry on error
954 * @return {Promise|Stream} a Promise of the result send by server or a stream to the endpoint
955 * @private
956 */
957 [callAPI](session, path, data, method, isStream = false, retry = true) {
958 const reqOptions = {
959 url: `https://api.${this.serviceHost}${path}`,
960 method: method,
961 headers: {
962 'Accept': 'application/vnd.github.v3+json',
963 'Authorization': session.token,
964 'User-Agent': 'Unifile',
965 'X-OAuth-Scopes': 'delete_repo, repo, user'
966 }
967 };
968
969 if(method === 'GET') reqOptions.qs = data;
970 else if(!isStream) reqOptions.body = JSON.stringify(data);
971
972 if(isStream) return request(reqOptions);
973 else {
974 return new Promise((resolve, reject) => {
975 request(reqOptions, (err, res, body) => {
976 if(err) {
977 return reject(err);
978 }
979 if(res.statusCode >= 400) {
980 const {code, message} = (() => {
981 const defaultMessage = JSON.parse(body).message;
982 switch (res.statusCode) {
983 case 401:
984 return {code: UnifileError.EACCES, message: 'Bad credentials'};
985 case 403:
986 return {code: UnifileError.EACCES, message: defaultMessage};
987 case 404:
988 return {code: UnifileError.ENOENT, message: 'Not Found'};
989 default:
990 return {code: UnifileError.EIO, message: defaultMessage};
991 }
992 })();
993 return reject(new UnifileError(code, message));
994 }
995 try {
996 const result = res.statusCode !== 204 ? JSON.parse(body) : null;
997 if(res.headers.hasOwnProperty('link')) {
998 paginate(reqOptions, res.headers.link, result).then(resolve);
999 } else resolve(result);
1000 } catch (e) {
1001 reject(e);
1002 }
1003 });
1004 });
1005 }
1006 }
1007}
1008
1009module.exports = GitHubConnector;