UNPKG

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